diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 8cf573be..3eae28c3 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -163,6 +163,8 @@ "showcase/tavern-stool", "showcase/iron-cauldron", "showcase/wooden-ladder", - "showcase/hay-bale" + "showcase/hay-bale", + "showcase/crate-stack", + "showcase/stone-archway" ] } diff --git a/CLAUDE.md b/CLAUDE.md index be91f2ae..1a3ac430 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ rules/.mdc - Anti-pattern rules, 9 total templates// - Starter projects, 3 total snippets/.py - Standalone code patterns, 27 total examples// - Runnable smoke-gated examples, 59 total (+ gallery.json) -showcase// - Budget-conformance props, 26 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 28 pieces (sibling of examples/; see showcase/README.md) scripts/build_gallery.py - Regenerates docs/gallery/ from examples/gallery.json + showcase/gallery.json scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders diff --git a/README.md b/README.md index df575e95..5d350cb1 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  26 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  28 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 26 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. +This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 28 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. The content is consumed by AI coding agents reading these files directly from a checkout — **there is no MCP server in this repository, and none is required**. Cursor applies `rules/*.mdc` automatically wherever their scope globs match and takes skills by name in chat; Claude Code reads `skills/` and `rules/` from the project workspace, or from this repo kept as a referenced checkout. Any agent that can read files in a workspace can use it the same way. There is no build step for the content — edit the Markdown and Python files directly. @@ -106,7 +106,7 @@ per-script exit-code model, are in Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](showcase/README.md).

-26 showcase pieces — click to expand the preview grid +28 showcase pieces — click to expand the preview grid

Shipping crate: a wooden slat crate with iron corner brackets on a dark studio floor, warm wedge on the back wall @@ -135,6 +135,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Iron cauldron: a bulged iron pot hanging from a timber tripod on a dark studio floor Wooden ladder: a raked timber ladder with six rungs and iron shoes on a dark studio floor Hay bale: a bound straw bale with two sisal twine belts on a dark studio floor + Crate stack: three slatted shipping crates stacked and yawed, each with iron corner straps and runners, on a dark studio floor + Stone archway: a semicircular masonry arch with coursed piers, projecting imposts, nine voussoirs and a proud keystone, on a dark studio floor

[`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9. diff --git a/ROADMAP.md b/ROADMAP.md index 35923ca8..2576949e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,12 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Cast-iron cooking cauldron on a tripod as a game-prop showcase piece~~ **SHIPPED** as `showcase/iron-cauldron/` — bulged open pot, bail, timber tripod, hook; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Wooden ladder (rungs, stiles, iron shoes) as a game-prop showcase piece~~ **SHIPPED** as `showcase/wooden-ladder/` — raked stiles, six rungs, iron shoes and straps; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Bound hay bale as a game-prop showcase piece~~ **SHIPPED** as `showcase/hay-bale/` — bevelled loaf, end nap, two sisal twine belts; `--skip-decimate` exits 9 on the LOD1 ratio band +- ~~Stack of shipping crates as a game-prop showcase piece~~ **SHIPPED** as `showcase/crate-stack/` — one generator called three times with a per-instance seed driving yaw and plank widths, crates seated on the lid below; `--same-seed` exits 20 on the per-instance variation budget, `--float-stack` exits 18 on the seat +- ~~Keystone archway with voussoirs as a game-prop showcase piece~~ **SHIPPED** as `showcase/stone-archway/` — coursed piers, projecting imposts, nine voussoirs, proud keystone; the intrados circle fit is recomputed from vertex positions and `--off-circle` exits 19 on it while leaving every other budget passing +- Rope bridge (planks, rope rails, sag between anchors) as a game-prop showcase piece +- Grain sacks (cloth sacks, some slumped, tied necks) as a game-prop showcase piece +- Brazier (iron bowl on legs, coals, ash) as a game-prop showcase piece +- Apothecary shelf (shelf unit, bottles and jars of varied form) as a game-prop showcase piece - Wooden yoke (oxbow, iron rings) as a game-prop showcase piece - Butter churn (staved tub, lid, dasher) as a game-prop showcase piece - ~~Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract)~~ **SHIPPED** as `showcase/fence-kit/` — tiling post-and-rail section (AABB X is the tile width; does not re-witness the snap contract); `--skip-decimate` exits 9 on the LOD1 ratio band diff --git a/docs/gallery/assets/crate-stack-hero.webp b/docs/gallery/assets/crate-stack-hero.webp new file mode 100644 index 00000000..c2cbd00b Binary files /dev/null and b/docs/gallery/assets/crate-stack-hero.webp differ diff --git a/docs/gallery/assets/stone-archway-hero.webp b/docs/gallery/assets/stone-archway-hero.webp new file mode 100644 index 00000000..04f851b8 Binary files /dev/null and b/docs/gallery/assets/stone-archway-hero.webp differ diff --git a/docs/gallery/contact-sheets/crate-stack-contact-sheet.webp b/docs/gallery/contact-sheets/crate-stack-contact-sheet.webp new file mode 100644 index 00000000..6da93537 Binary files /dev/null and b/docs/gallery/contact-sheets/crate-stack-contact-sheet.webp differ diff --git a/docs/gallery/contact-sheets/stone-archway-contact-sheet.webp b/docs/gallery/contact-sheets/stone-archway-contact-sheet.webp new file mode 100644 index 00000000..472e139a Binary files /dev/null and b/docs/gallery/contact-sheets/stone-archway-contact-sheet.webp differ diff --git a/docs/gallery/crate-stack/index.html b/docs/gallery/crate-stack/index.html new file mode 100644 index 00000000..fbe4d3ce --- /dev/null +++ b/docs/gallery/crate-stack/index.html @@ -0,0 +1,1756 @@ + + + + + + crate-stack — Showcase — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

crate-stack

+

A stack of three procedural shipping crates built by one generator called three times with a per-instance seed, carried through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+
+
+ +

Rendered headless by the showcase piece itself — click to zoom.

+
witnesses Recomputed: 4380 tris, two materials with 216 iron and 2436 timber faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.658×0.524×0.704 m, zmin 0, hygiene 0 including zero coplanar disjoint pairs, three ground runners at z=0, both crate-to-crate seats overlapping, each crate's own footprint 0.585×0.425 m measured in its own frame, yaw spread 0.026 rad and plank spread 2.05 mm across instances, LOD ratios in band, convex collider 202 tris, non-empty glTF. --float-stack exits 18 on the seat; --same-seed exits 20 on per-instance variation.
+
+
blender --background --python showcase/crate-stack/crate_stack.py --
+ +
+
+

!Three shipping crates stacked, each yawed and offset differently

+

A stack of three shipping crates. A showcase piece, not an example — it witnesses no API contract. It asserts that generated geometry meets declared asset budgets, recomputed from the finished mesh.

+

What it composes

+

One build_crate generator, invoked three times with a per-instance seed, then the shipped pipeline:

+

| Shipped content | Used for | | --- | --- | | skills/mesh-editing-and-bmesh | box, bevel and UV construction in one bmesh | | skills/procedural-materials-and-shaders | two Principled materials with noise-driven wear | | skills/bake-high-to-low | Cycles tangent-space normal bake, high onto low | | skills/engine-export-presets | Unity glTF (export_yup=True) | | skills/depsgraph-and-evaluated-data | evaluated triangle counts for the LOD ratios | | snippets/decimate_to_budget.py | LOD1 / LOD2 COLLAPSE chain | | snippets/convex_hull_collider.py | convex collider | | snippets/lod_chain.py | LOD naming and ratio pattern | | examples/mesh-hygiene-audit | hygiene combinatorics (copied, not imported) |

+

Reuse with variation

+

The point of the piece. Each crate comes out of the same generator; what differs is the seed. That seed drives the yaw, the plank widths, and the board heights, so the three read as three of the same design rather than one model pasted three times.

+

True instancing — three objects sharing one mesh datablock — and per-instance geometry variation are mutually exclusive. This piece takes the variation, and the shipped asset is the flattened single mesh an engine would receive. The generator is reused; the geometry is not.

+

Two independent random streams keep that honest. The design stream (yaw, plank widths) is what --same-seed collapses. The placement stream (lateral offsets) stays per-instance either way, so a falsified stack has the same footprint as a good one and fails on the variation budget rather than on the bounding box.

+

Budgets

+

Declared in the script as named constants, recomputed from the generated mesh. Measured values below are from Blender 5.2.1; see the table at the end for the 4.5.11 / 5.1.2 figures.

+

| Budget | Band | Measured | | --- | --- | --- | | Base triangles | 3200–7200 | 4380 | | LOD1 ratio | 0.32–0.62 | 0.5000 | | LOD2 ratio | 0.10–0.35 | 0.2005 (5.2) / 0.2199 (4.5, 5.1) | | Material slots | exactly 2, distinct | 2 | | Iron faces | ≥ 120 | 216 | | Timber faces | ≥ 600 | 2436 | | UV bounds | inside 0..1 | (0.0008, 0.0008)–(0.9992, 0.9992) | | UV AABB overlap | ≤ 1e-5 | 0.000000 | | Outer AABB | 0.658 × 0.524 × 0.704 m ± 0.020 | 0.6582 × 0.5235 × 0.7040 | | Crate body footprint | 0.585 × 0.425 m ± 0.015, in the crate's own frame | 0.5846 × 0.4246 (all three) | | Collider triangles | ≤ 260 | 202 | | Normal bake | {'FINISHED'} with image data | {'FINISHED'}, has_data=True | | glTF export | file written, non-empty | ~340 kB | | Hygiene | all zero | loose 0/0, non-manifold 0, zero-area 0, doubles 0, n-gons 0, coplanar disjoint pairs 0 | | Grounded AABB | \|zmin\| ≤ 1e-4 | 0.00000 | | Ground runners | 3 named supports, each zmin ≤ 1e-3 | 3 at 0.00000 | | Crate-to-crate seat | both seats, surface gap ≤ 0.0015 m | 0.00000 (overlapping) | | Yaw band | each \|yaw\| in 0.040–0.192 rad | 0.0681, 0.0943, 0.1093 | | Per-instance variation | yaw spread ≥ 0.020 rad, plank spread ≥ 0.0008 m | 0.0262 rad, 0.00205 m |

+

Real-world size: each crate is 0.58 × 0.42 m at the timber and 0.585 × 0.425 m over the corner iron, 0.244 m tall including runners and lid. Three stacked come to 0.70 m — knee height, wider than tall.

+

Why the footprint is measured in the crate's own frame

+

The stack AABB is the union of three yawed boxes, so a crate could drift to any size underneath it and the outer budget would not notice. Each crate's footprint is therefore measured after un-rotating by the yaw that crate was *measured* to have, not the yaw it was built with. All three land on 0.5846 × 0.4246 m exactly, which is what makes the un-rotation trustworthy.

+

Why parts are classified by principal axes

+

Every part is an axis-aligned box in its crate's frame and then rotated about Z. A world-AABB shape filter measures the rotated bounding box, not the part: a 0.554 m board yawed 0.09 rad reports 0.061 m of depth instead of its 0.013 m thickness, and every filter keyed to thickness silently matches nothing. xy_principal recovers each box's own axes, and hands back the yaw as a by-product — which is where the variation budget's yaw numbers come from.

+

Why stack levels come from the runners

+

Binning parts against the declared stack pitch put a lid — which sits 8 mm below the next crate's base — on the wrong level the moment a falsifier shifted a crate, and the seat budget then compared a crate against itself. The bands are clustered from the runner heights the mesh actually has, so they follow the geometry including when a falsifier moves it.

+

Determinism

+

Fixed seed 41; no unseeded randomness. Identical geometry across runs on one binary and across 4.5.11, 5.1.2 and 5.2.1 — every measured value above is byte-identical on the three except LOD2, where DECIMATE COLLAPSE produces 878 triangles on 5.2 and 934 on 4.5 and 5.1. That is why the LOD gate is a ratio band (0.10–0.35, measured 0.2005 and 0.2199) and not an exact count.

+

Falsifiers

+

Each breaks one pipeline stage so a named budget fails. All six were run on 4.5.11, 5.1.2 and 5.2.1 and produced the same exit code on all three.

+

| Flag | Breaks | Exit | | --- | --- | --- | | --skip-decimate | drops the DECIMATE modifiers, LOD1 ratio goes to 1.0000 | 9 | | --stray-vert | adds one loose vertex *inside* the silhouette, so hygiene catches it rather than the bounding box | 15 | | --lift-z | lifts the whole mesh 50 mm off the floor | 16 | | --short-skids | floats one of the three ground runners 12 mm; the other two still ground the AABB, so only the named-support budget sees it | 16 | | --float-stack | lifts the top crate 9 mm clear of the lid below, opening a 34 mm seat gap | 18 | | --same-seed | gives all three crates the same design seed; yaw spread and plank spread both go to 0 | 20 |

+

Exit codes

+

File-local and sequential. 9 is a valid check code; there is no rule against it. 1 is the FATAL wrapper — a crash, never a named check.

+

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build, or has no UV layer | | 4 | Base triangle count outside band | | 5 | Material slots, or a material's face floor | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | Outer AABB off declared size | | 9 | LOD1 or LOD2 ratio outside band (--skip-decimate) | | 10 | Framing gate (examples/gallery_framing.py, render path only) | | 11 | Collider triangles above ceiling | | 12 | Normal bake failed or produced no image data | | 13 | glTF export missing or empty | | 14 | --output produced no file | | 15 | Mesh hygiene (--stray-vert) | | 16 | Grounded zmin, or a named ground runner floating (--lift-z, --short-skids) | | 18 | Crate-to-crate seat gap (--float-stack) | | 19 | Crate body footprint off declared size | | 20 | Per-instance variation collapsed (--same-seed) |

+

17 is unused here: this piece has no diagonal member. 15–19 are reserved across showcase pieces for the hygiene family, so the numbering skips rather than reuses.

+

Run it

+
# Budget check, no render. ~1.0 s on 4.5/5.1, ~1.2 s on 5.2.
+blender --background --python crate_stack.py --
+
+# Falsifier: the three crates become copies. Must exit 20.
+blender --background --python crate_stack.py -- --same-seed
+
+# Falsifier: the top crate floats off the lid below. Must exit 18.
+blender --background --python crate_stack.py -- --float-stack
+
+# Render the gallery still (EEVEE; --engine cycles on a GPU-less host).
+blender --background --python crate_stack.py -- --output stack.webp
+

Smoke runs the check-only path. It does not pass --output or any falsifier.

+

Cross-version measurements

+

| Value | 4.5.11 | 5.1.2 | 5.2.1 | | --- | --- | --- | --- | | Base triangles | 4380 | 4380 | 4380 | | LOD1 tris / ratio | 2190 / 0.5000 | 2190 / 0.5000 | 2190 / 0.5000 | | LOD2 tris / ratio | 934 / 0.2199 | 934 / 0.2199 | 878 / 0.2005 | | Outer AABB | 0.6582 × 0.5235 × 0.7040 | same | same | | Collider tris | 202 | 202 | 202 | | Yaws (rad) | 0.0943, −0.1093, 0.0681 | same | same | | Seat gap | 0.00000 | 0.00000 | 0.00000 | | Check wall-clock | ~0.99 s | ~1.01 s | ~1.20 s |

+
+
+

Source

+
+ showcase/crate-stack/crate_stack.py + View on GitHub → +
+
"""Game-ready stack of three shipping crates — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural crate *stack*: one crate
+generator invoked three times with a per-instance seed, each instance
+yawed and seated on the lid of the one below, then run through the
+shipped pipeline (bmesh construction, UVs, two materials, high-to-low
+normal bake, LOD chain, convex collider, Unity glTF export).
+
+What this piece is about is **reuse with variation**. The three crates
+come out of a single ``build_crate`` call each, differing only by the
+seed handed to it. That seed drives the yaw, the lean, and the plank
+widths, so the crates read as three of the same design rather than one
+model copied three times. True instancing (three objects sharing one
+mesh datablock) and per-instance geometry variation are mutually
+exclusive; this piece takes the variation and says so, and the shipped
+asset is the flattened single mesh a game engine would receive.
+
+Budgets are declared below and recomputed from the generated result.
+They are not API-contract witnesses. Each falsifier violates one named
+budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh
+hygiene, ``--lift-z`` grounded zmin, ``--short-skids`` the named ground
+supports, ``--float-stack`` the crate-to-crate seat, ``--same-seed`` the
+per-instance variation budget.
+
+Fixed seed 41. DECIMATE COLLAPSE triangle counts are not byte-identical
+across Blender versions — the LOD gate is a ratio band, not an exact
+count.
+
+    blender --background --python crate_stack.py --
+    blender --background --python crate_stack.py -- --same-seed
+    blender --background --python crate_stack.py -- --output stack.png
+"""
+import argparse
+import math
+import os
+import random
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Matrix, Vector
+from mathutils.bvhtree import BVHTree
+
+_REPO = os.path.abspath(
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing  # noqa: E402
+
+# One crate: 0.58 x 0.42 at the posts, 0.244 tall including skids and lid.
+# Three stacked come to knee height, wider than they are tall.
+CRATE_X = 0.58
+CRATE_Y = 0.42
+POST = 0.034
+SKID_H = 0.024
+SKID_W = 0.052
+SLAT_T = 0.013
+RAIL_H = 0.032
+TENON = 0.008
+IRON_T = 0.0035
+IRON_WRAP = 0.052
+# Straps bite into the timber and stop short of the rail top. Sitting them
+# flush made the plate corner and the post corner the same point, which is
+# a welded double, not a fixing.
+IRON_BITE = 0.0012
+IRON_DROP = 0.005
+# The lid bites down onto the rails for the same reason: a lid resting
+# exactly on the rail top shares that plane and those corner vertices.
+LID_BITE = 0.004
+BODY_H = 0.205
+N_SIDE = 3
+N_END = 2
+N_FLOOR = 3
+N_LID = 3
+SLAT_JITTER = 0.20
+
+N_CRATES = 3
+STACK_SEED = 41
+# Each crate bites this far into the lid of the one below, so the seat is
+# an overlap rather than two coincident faces (which would z-fight).
+STACK_BITE = 0.005
+YAW_MIN = 0.060
+YAW_MAX = 0.170
+# Crates also step sideways. Three boxes stacked dead-centre read as a
+# filing cabinet however much they are yawed.
+OFFSET_MAX = 0.030
+YAW_SPREAD_MIN = 0.020
+WIDTH_SPREAD_MIN = 0.0008
+
+CRATE_H = SKID_H + BODY_H + SLAT_T - LID_BITE
+STACK_H = N_CRATES * CRATE_H - (N_CRATES - 1) * STACK_BITE
+
+BBOX_TOL = 0.020
+OUTER_SIZE = (0.658, 0.524, 0.704)
+# The crate body over its corner iron, measured in the crate's own frame.
+BODY_X = CRATE_X + 2.0 * (IRON_T - IRON_BITE)
+BODY_Y = CRATE_Y + 2.0 * (IRON_T - IRON_BITE)
+CRATE_TOL = 0.015
+
+BASE_TRIS_MIN = 3200
+BASE_TRIS_MAX = 7200
+LOD1_RATIO_MIN = 0.32
+LOD1_RATIO_MAX = 0.62
+LOD2_RATIO_MIN = 0.10
+LOD2_RATIO_MAX = 0.35
+LOD1_TARGET = 0.50
+LOD2_TARGET = 0.22
+MATERIAL_COUNT = 2
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 260
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 120
+WOOD_FACES_MIN = 600
+ZMIN_EPS = 1e-4
+DOUBLES_EPS = 1e-5
+AREA_EPS = 1e-10
+ZFIGHT_EPS = 1e-4
+ZFIGHT_COS = 0.999
+LIFT_Z = 0.05
+SKID_Z_MAX = 1e-3
+GROUND_SKIDS_MIN = 3
+STACK_SEAT_MAX = 0.0015
+FLOAT_LIFT = 0.009
+SHORT_SKID_LIFT = 0.012
+
+WOOD_IDX = 0
+METAL_IDX = 1
+
+
+def eevee_engine_id():
+    """EEVEE id: 'BLENDER_EEVEE' on 5.0+, 'BLENDER_EEVEE_NEXT' on 4.2-4.5."""
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"FAIL[{code}]: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    deps = bpy.context.evaluated_depsgraph_get()
+    ev = obj.evaluated_get(deps)
+    mesh = ev.to_mesh()
+    try:
+        mesh.calc_loop_triangles()
+        return len(mesh.loop_triangles)
+    finally:
+        ev.to_mesh_clear()
+
+
+def add_box(bm, loc, scale, mat_idx, xform=None):
+    """Axis-aligned box in the crate frame, optionally placed by *xform*."""
+    geo = bmesh.ops.create_cube(bm, size=1.0)
+    verts = geo["verts"]
+    for v in verts:
+        p = Vector(
+            (
+                v.co.x * scale[0] + loc[0],
+                v.co.y * scale[1] + loc[1],
+                v.co.z * scale[2] + loc[2],
+            )
+        )
+        v.co = xform @ p if xform is not None else p
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def pack_uvs(bm, margin=0.08):
+    uv = bm.loops.layers.uv.new("UVMap")
+    faces = list(bm.faces)
+    n = len(faces)
+    cols = max(1, math.ceil(math.sqrt(n)))
+    rows = max(1, math.ceil(n / cols))
+    cell_w = 1.0 / cols
+    cell_h = 1.0 / rows
+    pad_u = margin * cell_w * 0.5
+    pad_v = margin * cell_h * 0.5
+    usable_w = cell_w - 2.0 * pad_u
+    usable_h = cell_h - 2.0 * pad_v
+    for i, face in enumerate(faces):
+        col = i % cols
+        row = i // cols
+        nrm = face.normal
+        ax, ay, az = abs(nrm.x), abs(nrm.y), abs(nrm.z)
+        coords = []
+        for loop in face.loops:
+            co = loop.vert.co
+            if az >= ax and az >= ay:
+                coords.append((co.x, co.y))
+            elif ax >= ay:
+                coords.append((co.y, co.z))
+            else:
+                coords.append((co.x, co.z))
+        xs = [c[0] for c in coords]
+        ys = [c[1] for c in coords]
+        minx, maxx = min(xs), max(xs)
+        miny, maxy = min(ys), max(ys)
+        dx = max(maxx - minx, 1e-8)
+        dy = max(maxy - miny, 1e-8)
+        origin_u = col * cell_w + pad_u
+        origin_v = row * cell_h + pad_v
+        for loop, (x, y) in zip(face.loops, coords):
+            loop[uv].uv = (
+                origin_u + (x - minx) / dx * usable_w,
+                origin_v + (y - miny) / dy * usable_h,
+            )
+
+
+def _span_layout(count, span, rng, gap=0.009):
+    """Uneven plank widths that still fill *span* with named gaps."""
+    raw = [1.0 + rng.uniform(-SLAT_JITTER, SLAT_JITTER) for _ in range(count)]
+    s = sum(raw)
+    usable = span - gap * (count + 1)
+    widths = [usable * r / s for r in raw]
+    pos = -span / 2.0 + gap
+    centres = []
+    for w in widths:
+        centres.append(pos + w / 2.0)
+        pos += w + gap
+    return centres, widths
+
+
+def build_crate(bm, base_z, yaw, rng, offset=(0.0, 0.0), short_skids=False):
+    """One crate, placed with its skid underside at *base_z* and yawed.
+
+    Called once per stack level. Everything that differs between levels
+    comes out of *rng* and *yaw*; the geometry recipe itself is shared.
+    """
+    xform = Matrix.Translation((offset[0], offset[1], 0.0)) @ Matrix.Rotation(
+        yaw, 4, "Z"
+    )
+    hx = CRATE_X / 2.0 - POST / 2.0
+    hy = CRATE_Y / 2.0 - POST / 2.0
+    skid_z0 = base_z + (SHORT_SKID_LIFT if short_skids else 0.0)
+    deck_z = base_z + SKID_H
+    top_z = deck_z + BODY_H
+    wood = []
+
+    # Two runners under the posts. These are the named ground supports on
+    # the bottom crate and the seat feet on the ones above.
+    # Three runners, not two: two leave a slot you can see daylight
+    # through between stacked crates, and a centre bearer is what a crate
+    # this wide would actually carry.
+    for si, sy in enumerate((-hy, 0.0, hy)):
+        z0 = skid_z0 if (short_skids and si == 0) else base_z
+        wood.extend(
+            add_box(
+                bm,
+                (0.0, sy, z0 + SKID_H / 2.0),
+                (CRATE_X, SKID_W, SKID_H),
+                WOOD_IDX,
+                xform,
+            )
+        )
+    # Corner posts, tenoned down into the skid line so no shared plane.
+    post_h = BODY_H + TENON
+    for sxn in (-1.0, 1.0):
+        for syn in (-1.0, 1.0):
+            wood.extend(
+                add_box(
+                    bm,
+                    (sxn * hx, syn * hy, deck_z - TENON + post_h / 2.0),
+                    (POST, POST, post_h),
+                    WOOD_IDX,
+                    xform,
+                )
+            )
+    # Top rails and bottom sills, both tenoned into the posts.
+    for zc, h in ((top_z - RAIL_H / 2.0, RAIL_H),
+                  (deck_z - TENON + (RAIL_H + TENON) / 2.0, RAIL_H + TENON)):
+        for syn in (-1.0, 1.0):
+            wood.extend(
+                add_box(
+                    bm,
+                    (0.0, syn * hy, zc),
+                    (CRATE_X - POST + 2.0 * TENON, POST, h),
+                    WOOD_IDX,
+                    xform,
+                )
+            )
+        for sxn in (-1.0, 1.0):
+            wood.extend(
+                add_box(
+                    bm,
+                    (sxn * hx, 0.0, zc),
+                    (POST, CRATE_Y - POST + 2.0 * TENON, h),
+                    WOOD_IDX,
+                    xform,
+                )
+            )
+
+    floor_c, floor_w = _span_layout(N_FLOOR, CRATE_X - POST, rng)
+    for c, w in zip(floor_c, floor_w):
+        wood.extend(
+            add_box(
+                bm,
+                (c, 0.0, deck_z + SLAT_T / 2.0),
+                (w, CRATE_Y - POST + TENON, SLAT_T),
+                WOOD_IDX,
+                xform,
+            )
+        )
+    lid_c, lid_w = _span_layout(N_LID, CRATE_X, rng)
+    for c, w in zip(lid_c, lid_w):
+        wood.extend(
+            add_box(
+                bm,
+                (c, 0.0, top_z + SLAT_T / 2.0 - LID_BITE),
+                (w, CRATE_Y, SLAT_T),
+                WOOD_IDX,
+                xform,
+            )
+        )
+
+    # Side and end boards. Their heights carry the per-instance jitter and
+    # are what the variation budget recomputes.
+    side_c, side_w = _span_layout(N_SIDE, BODY_H - RAIL_H * 1.6, rng)
+    band_z = deck_z + RAIL_H * 0.8 + (BODY_H - RAIL_H * 1.6) / 2.0
+    for syn in (-1.0, 1.0):
+        y = syn * (CRATE_Y / 2.0 - SLAT_T / 2.0 - 0.0012)
+        for c, w in zip(side_c, side_w):
+            wood.extend(
+                add_box(
+                    bm,
+                    (0.0, y, band_z + c),
+                    (CRATE_X - POST + TENON, SLAT_T, w),
+                    WOOD_IDX,
+                    xform,
+                )
+            )
+    end_c, end_w = _span_layout(N_END, BODY_H - RAIL_H * 1.6, rng)
+    for sxn in (-1.0, 1.0):
+        x = sxn * (CRATE_X / 2.0 - SLAT_T / 2.0 - 0.0012)
+        for c, w in zip(end_c, end_w):
+            wood.extend(
+                add_box(
+                    bm,
+                    (x, 0.0, band_z + c),
+                    (SLAT_T, CRATE_Y - POST + TENON, w),
+                    WOOD_IDX,
+                    xform,
+                )
+            )
+
+    # L-straps: two plates meeting at the vertical corner edge, proud of
+    # the post. Two plates, never three overlapping cubes.
+    strap_z0 = deck_z + 0.010
+    strap_h = top_z - IRON_DROP - strap_z0
+    zc = strap_z0 + strap_h / 2.0
+    # Outer face of each plate, after biting into the post.
+    px = CRATE_X / 2.0 + IRON_T - IRON_BITE
+    py = CRATE_Y / 2.0 + IRON_T - IRON_BITE
+    for sxn in (-1.0, 1.0):
+        for syn in (-1.0, 1.0):
+            add_box(
+                bm,
+                (sxn * (px - IRON_T / 2.0), syn * (py - IRON_WRAP / 2.0), zc),
+                (IRON_T, IRON_WRAP, strap_h),
+                METAL_IDX,
+                xform,
+            )
+            add_box(
+                bm,
+                (sxn * (px - IRON_WRAP / 2.0), syn * (py - IRON_T / 2.0), zc),
+                (IRON_WRAP, IRON_T, strap_h - 2.0 * IRON_T),
+                METAL_IDX,
+                xform,
+            )
+    return wood, side_w
+
+
+def build_stack_mesh(
+    name,
+    same_seed=False,
+    short_skids=False,
+    float_stack=False,
+):
+    bm = bmesh.new()
+    wood_verts = []
+    try:
+        base_z = 0.0
+        for i in range(N_CRATES):
+            # Two streams. The design stream is what --same-seed collapses:
+            # yaw and plank widths, the things that make each crate its own
+            # instance. The placement stream stays per-instance either way,
+            # so the falsified stack keeps the same footprint and fails on
+            # the variation budget rather than on the bounding box.
+            seed = STACK_SEED if same_seed else STACK_SEED + i * 7
+            rng = random.Random(seed)
+            place = random.Random(STACK_SEED * 31 + i)
+            sign = 1.0 if i % 2 == 0 else -1.0
+            yaw = sign * (YAW_MIN + rng.random() * (YAW_MAX - YAW_MIN - 0.02))
+            offset = (
+                0.0 if i == 0 else place.uniform(-OFFSET_MAX, OFFSET_MAX),
+                0.0 if i == 0 else place.uniform(-OFFSET_MAX, OFFSET_MAX),
+            )
+            lift = FLOAT_LIFT if (float_stack and i == N_CRATES - 1) else 0.0
+            verts, _widths = build_crate(
+                bm,
+                base_z + lift,
+                yaw,
+                rng,
+                offset=offset,
+                short_skids=short_skids and i == 0,
+            )
+            wood_verts.extend(verts)
+            base_z += CRATE_H - STACK_BITE
+
+        if wood_verts:
+            edges = list({e for v in wood_verts for e in v.link_edges if v.is_valid})
+            if edges:
+                bmesh.ops.bevel(
+                    bm,
+                    geom=edges,
+                    offset=0.0018,
+                    segments=1,
+                    profile=0.5,
+                    affect="EDGES",
+                    clamp_overlap=True,
+                )
+
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = True
+        for edge in bm.edges:
+            edge.smooth = True
+            if edge.is_manifold and len(edge.link_faces) == 2:
+                if edge.calc_face_angle() > math.radians(35.0):
+                    edge.smooth = False
+        me = bpy.data.meshes.new(name)
+        bm.to_mesh(me)
+        me.update()
+    finally:
+        bm.free()
+    obj = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(obj)
+    return obj
+
+
+def principled(name, color, metallic, roughness, noise_scale=0.0, wear=None):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = color
+    bsdf.inputs["Metallic"].default_value = metallic
+    bsdf.inputs["Roughness"].default_value = roughness
+    if noise_scale > 0.0 and wear is not None:
+        tex = nt.nodes.new("ShaderNodeTexNoise")
+        tex.inputs["Scale"].default_value = noise_scale
+        tex.inputs["Detail"].default_value = 8.0
+        tex.inputs["Roughness"].default_value = 0.55
+        mix = nt.nodes.new("ShaderNodeMix")
+        mix.data_type = "RGBA"
+        mix.inputs["A"].default_value = color
+        mix.inputs["B"].default_value = wear
+        fac = mix.inputs.get("Factor") or mix.inputs.get("Fac")
+        nt.links.new(tex.outputs["Fac"], fac)
+        nt.links.new(mix.outputs["Result"], bsdf.inputs["Base Color"])
+        rmix = nt.nodes.new("ShaderNodeMix")
+        rmix.data_type = "FLOAT"
+        rmix.inputs["A"].default_value = roughness
+        rmix.inputs["B"].default_value = min(1.0, roughness + 0.18)
+        rfac = rmix.inputs.get("Factor") or rmix.inputs.get("Fac")
+        nt.links.new(tex.outputs["Fac"], rfac)
+        nt.links.new(rmix.outputs["Result"], bsdf.inputs["Roughness"])
+    return mat
+
+
+def assign_slots(obj, wood, metal):
+    mats = obj.data.materials
+    for i, mat in enumerate((wood, metal)):
+        if i < len(mats):
+            mats[i] = mat
+        else:
+            mats.append(mat)
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs = [c.x for c in corners]
+    ys = [c.y for c in corners]
+    zs = [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    """UV bounds plus AABB overlap area, bucketed so this stays linear."""
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0.0, 0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    # Bucket on a grid at least as coarse as the largest island, so any
+    # overlapping pair lands in a shared cell. O(n) instead of O(n^2).
+    span = max(
+        1e-6,
+        max((a[2] - a[0]) for a in aabbs),
+        max((a[3] - a[1]) for a in aabbs),
+    )
+    buckets = {}
+    for i, a in enumerate(aabbs):
+        c0 = int(math.floor(a[0] / span))
+        c1 = int(math.floor(a[2] / span))
+        r0 = int(math.floor(a[1] / span))
+        r1 = int(math.floor(a[3] / span))
+        for c in range(c0, c1 + 1):
+            for r in range(r0, r1 + 1):
+                buckets.setdefault((c, r), []).append(i)
+    overlap = 0.0
+    seen = set()
+    for members in buckets.values():
+        for ii in range(len(members)):
+            for jj in range(ii + 1, len(members)):
+                i, j = members[ii], members[jj]
+                key = (i, j) if i < j else (j, i)
+                if key in seen:
+                    continue
+                seen.add(key)
+                a, b = aabbs[i], aabbs[j]
+                x0 = max(a[0], b[0])
+                y0 = max(a[1], b[1])
+                x1 = min(a[2], b[2])
+                y1 = min(a[3], b[3])
+                overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def face_area(me, poly):
+    idxs = poly.vertices
+    if len(idxs) < 3:
+        return 0.0
+    v0 = me.vertices[idxs[0]].co
+    area = 0.0
+    for i in range(1, len(idxs) - 1):
+        vs = (me.vertices[idxs[i]].co, me.vertices[idxs[i + 1]].co)
+        area += (vs[0] - v0).cross(vs[1] - v0).length * 0.5
+    return area
+
+
+def hygiene_audit(me):
+    nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons)
+    ngons = sum(1 for p in me.polygons if len(p.vertices) > 4)
+    zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(me)
+        bm.verts.ensure_lookup_table()
+        bm.edges.ensure_lookup_table()
+        loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0)
+        loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0)
+        nonman = sum(1 for e in bm.edges if not e.is_manifold)
+        ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS)
+        doubles = len(ret.get("targetmap") or {})
+    finally:
+        bm.free()
+    return {
+        "nv": nv, "ne": ne, "nf": nf, "ngons": ngons,
+        "loose_v": loose_v, "loose_e": loose_e, "nonman": nonman,
+        "zero_area": zero_area, "doubles": doubles,
+    }
+
+
+def zfight_pairs(me):
+    """Coplanar, near-coincident face pairs that share no vertex.
+
+    Bucketed on a grid of ZFIGHT_EPS so a 2500-face stack does not cost
+    three million Python-level pair tests in smoke.
+    """
+    data = [
+        (p.center.copy(), p.normal.copy(), frozenset(p.vertices))
+        for p in me.polygons
+    ]
+    cell = ZFIGHT_EPS
+    buckets = {}
+    for i, (c, _n, _v) in enumerate(data):
+        key = (
+            int(math.floor(c.x / cell)),
+            int(math.floor(c.y / cell)),
+            int(math.floor(c.z / cell)),
+        )
+        buckets.setdefault(key, []).append(i)
+    eps2 = ZFIGHT_EPS * ZFIGHT_EPS
+    count = 0
+    checked = set()
+    for key, members in buckets.items():
+        kx, ky, kz = key
+        near = []
+        for dx in (-1, 0, 1):
+            for dy in (-1, 0, 1):
+                for dz in (-1, 0, 1):
+                    near.extend(buckets.get((kx + dx, ky + dy, kz + dz), ()))
+        for i in members:
+            ci, ni, vi = data[i]
+            for j in near:
+                if j == i:
+                    continue
+                pair = (i, j) if i < j else (j, i)
+                if pair in checked:
+                    continue
+                checked.add(pair)
+                cj, nj, vj = data[j]
+                if (cj - ci).length_squared > eps2:
+                    continue
+                if abs(ni.dot(nj)) <= ZFIGHT_COS:
+                    continue
+                if vi & vj:
+                    continue
+                count += 1
+    return count
+
+
+def shells(me):
+    neighbors = [[] for _ in range(len(me.vertices))]
+    for edge in me.edges:
+        a, b = edge.vertices
+        neighbors[a].append(b)
+        neighbors[b].append(a)
+    seen = [False] * len(me.vertices)
+    groups = []
+    for start in range(len(me.vertices)):
+        if seen[start]:
+            continue
+        seen[start] = True
+        stack = [start]
+        group = []
+        while stack:
+            current = stack.pop()
+            group.append(current)
+            for nxt in neighbors[current]:
+                if not seen[nxt]:
+                    seen[nxt] = True
+                    stack.append(nxt)
+        groups.append(group)
+    return groups
+
+
+def shell_aabb(me, group):
+    pts = [me.vertices[i].co for i in group]
+    return (
+        min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts),
+        max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts),
+    )
+
+
+def mat_of(me, group, face_of_vert):
+    member = set(group)
+    for i in group:
+        for fi in face_of_vert[i]:
+            poly = me.polygons[fi]
+            if all(v in member for v in poly.vertices):
+                return poly.material_index
+    return None
+
+
+def vert_faces(me):
+    table = [[] for _ in range(len(me.vertices))]
+    for fi, poly in enumerate(me.polygons):
+        for vi in poly.vertices:
+            table[vi].append(fi)
+    return table
+
+
+def xy_principal(pts):
+    """Long/short XY extent and orientation of a shell, free of world yaw.
+
+    Every part is an axis-aligned box in its crate's frame, then rotated
+    about Z. A world-AABB test therefore measures the rotated bounding
+    box, not the part: a 0.554 m board yawed 0.09 rad reports 0.061 m of
+    depth instead of its 0.013 m thickness, and every shape filter keyed
+    to thickness silently matches nothing. Recovering the box's own axes
+    by principal components makes the classification yaw-invariant, and
+    hands back the yaw as a by-product.
+    """
+    n = len(pts)
+    cx = sum(p.x for p in pts) / n
+    cy = sum(p.y for p in pts) / n
+    sxx = syy = sxy = 0.0
+    for p in pts:
+        dx, dy = p.x - cx, p.y - cy
+        sxx += dx * dx
+        syy += dy * dy
+        sxy += dx * dy
+    theta = 0.5 * math.atan2(2.0 * sxy, sxx - syy)
+    c, s = math.cos(theta), math.sin(theta)
+    us = [(p.x - cx) * c + (p.y - cy) * s for p in pts]
+    vs = [-(p.x - cx) * s + (p.y - cy) * c for p in pts]
+    e1 = max(us) - min(us)
+    e2 = max(vs) - min(vs)
+    if e1 < e2:
+        e1, e2 = e2, e1
+        theta += math.pi / 2.0
+    while theta > math.pi / 2.0:
+        theta -= math.pi
+    while theta <= -math.pi / 2.0:
+        theta += math.pi
+    return e1, e2, theta
+
+
+def _levels_from_runners(cands):
+    """Stack bases, clustered from the runners the mesh actually has.
+
+    Binning against the declared pitch put a lid — which sits 8 mm below
+    the next crate's base — on the wrong level the moment a falsifier
+    shifted a crate, and the seat budget then compared a crate against
+    itself. Clustering the measured runner heights instead makes the
+    bands follow the geometry, including when a falsifier moves it.
+    """
+    bases = []
+    for z in sorted(cands):
+        if not bases or z - bases[-1] > CRATE_H * 0.5:
+            bases.append(z)
+        else:
+            bases[-1] = min(bases[-1], z)
+    return bases
+
+
+def classify(me):
+    """Bin every wood shell to a stack level and name the parts.
+
+    Two passes: find the runners, cluster their heights into stack bases,
+    then assign every shell to the highest base at or below it. Only the
+    identification uses the layout; every number a budget later asserts
+    on — heights, gaps, yaws, footprints — is measured from vertices.
+    """
+    vf = vert_faces(me)
+    wood = []
+    for g in shells(me):
+        if mat_of(me, g, vf) != WOOD_IDX:
+            continue
+        pts = [me.vertices[i].co for i in g]
+        zmin = min(p.z for p in pts)
+        zmax = max(p.z for p in pts)
+        e1, e2, theta = xy_principal(pts)
+        wood.append(
+            {
+                "g": g, "zmin": zmin, "dz": zmax - zmin,
+                "e1": e1, "e2": e2, "yaw": theta,
+            }
+        )
+
+    # e2 is a band, not a ceiling: a side board is the same length and can
+    # be thinner than a runner is tall, so an open-ended short-axis test
+    # promotes boards to runners and floats the ground budget.
+    runner_like = [
+        s for s in wood
+        if s["dz"] < SKID_H * 1.25
+        and s["e1"] > CRATE_X * 0.85
+        and SKID_W * 0.6 < s["e2"] < SKID_W * 1.8
+    ]
+    # Top rails share the runners' footprint and are only 2 mm taller, so
+    # a shape filter alone puts four runners on every level. A runner is
+    # the lowest thing in its crate; a rail is 27 cm above it.
+    bases = _levels_from_runners([s["zmin"] for s in runner_like])
+    if len(bases) != N_CRATES:
+        bases = [i * (CRATE_H - STACK_BITE) for i in range(N_CRATES)]
+
+    def level_of(z):
+        lvl = 0
+        for i, b in enumerate(bases):
+            if z >= b - 1e-4:
+                lvl = i
+        return lvl
+
+    out = {i: {"skids": [], "lids": [], "boards": []} for i in range(N_CRATES)}
+    for s in wood:
+        lvl = level_of(s["zmin"])
+        rel = s["zmin"] - bases[lvl]
+        if s in runner_like and rel < SKID_H * 3.0:
+            out[lvl]["skids"].append(s)
+        elif (
+            s["dz"] < SLAT_T * 2.2
+            and s["e1"] > CRATE_Y * 0.9
+            and rel > CRATE_H * 0.6
+        ):
+            out[lvl]["lids"].append(s)
+        elif (
+            SLAT_T * 1.5 < s["dz"] < BODY_H * 0.7
+            and s["e1"] > CRATE_X * 0.85
+            and s["e2"] < SLAT_T * 2.2
+        ):
+            out[lvl]["boards"].append(dict(s, h=s["dz"]))
+    return out, bases
+
+
+def _bvh_gap(me, hosts, guests):
+    """Worst surface gap from any guest shell to the host surface.
+
+    Surface-to-surface, not vertex-to-vertex: a runner crossing a lid
+    plank has no vertex near the plank's own vertices, and a vertex
+    metric would report a large gap for parts that are in fact seated.
+    Overlapping shells return 0.
+    """
+    if not hosts or not guests:
+        return 99.0
+    bm_h = bmesh.new()
+    try:
+        bm_h.from_mesh(me)
+        keep = set()
+        for g in hosts:
+            keep.update(g)
+        drop = [f for f in bm_h.faces if not all(v.index in keep for v in f.verts)]
+        if drop:
+            bmesh.ops.delete(bm_h, geom=drop, context="FACES")
+        if not bm_h.faces:
+            return 99.0
+        tree = BVHTree.FromBMesh(bm_h)
+        worst = 0.0
+        for g in guests:
+            bm_g = bmesh.new()
+            try:
+                bm_g.from_mesh(me)
+                member = set(g)
+                drop_g = [
+                    f for f in bm_g.faces
+                    if not all(v.index in member for v in f.verts)
+                ]
+                if drop_g:
+                    bmesh.ops.delete(bm_g, geom=drop_g, context="FACES")
+                if not bm_g.faces:
+                    worst = max(worst, 99.0)
+                    continue
+                tree_g = BVHTree.FromBMesh(bm_g)
+                if tree.overlap(tree_g):
+                    continue
+                best = 99.0
+                for i in g:
+                    hit = tree.find_nearest(me.vertices[i].co)
+                    if hit[0] is None:
+                        continue
+                    best = min(best, hit[3])
+                worst = max(worst, best)
+            finally:
+                bm_g.free()
+        return worst
+    finally:
+        bm_h.free()
+
+
+def stack_audit(me):
+    """Ground supports, crate-to-crate seats, and per-instance variation."""
+    by_level, bases = classify(me)
+
+    ground = by_level[0]["skids"]
+    ground_n = len(ground)
+    # max, not min: one planted runner would hide a floating one.
+    ground_z = max((s["zmin"] for s in ground), default=99.0)
+
+    seat = 0.0
+    seats_checked = 0
+    for i in range(1, N_CRATES):
+        hosts = [s["g"] for s in by_level[i - 1]["lids"]]
+        guests = [s["g"] for s in by_level[i]["skids"]]
+        if hosts and guests:
+            seats_checked += 1
+            seat = max(seat, _bvh_gap(me, hosts, guests))
+        else:
+            seat = 99.0
+
+    widths = {}
+    yaws = {}
+    for i in range(N_CRATES):
+        widths[i] = sorted(round(b["h"], 6) for b in by_level[i]["boards"])
+        sk = by_level[i]["skids"]
+        if sk:
+            yaws[i] = sum(s["yaw"] for s in sk) / len(sk)
+
+    width_spread = 0.0
+    pairs = 0
+    for i in range(N_CRATES):
+        for j in range(i + 1, N_CRATES):
+            wi, wj = widths.get(i) or [], widths.get(j) or []
+            if wi and len(wi) == len(wj):
+                d = max(abs(a - b) for a, b in zip(wi, wj))
+                width_spread = d if pairs == 0 else min(width_spread, d)
+                pairs += 1
+
+    yaw_vals = [yaws[i] for i in range(N_CRATES) if i in yaws]
+    yaw_spread = 0.0
+    if len(yaw_vals) == N_CRATES:
+        yaw_spread = min(
+            abs(yaw_vals[i] - yaw_vals[j])
+            for i in range(N_CRATES)
+            for j in range(i + 1, N_CRATES)
+        )
+    return {
+        "ground_n": ground_n,
+        "ground_z": ground_z,
+        "seat": seat,
+        "seats": seats_checked,
+        "lids": sum(len(by_level[i]["lids"]) for i in range(N_CRATES)),
+        "boards": sum(len(by_level[i]["boards"]) for i in range(N_CRATES)),
+        "board_counts": [len(by_level[i]["boards"]) for i in range(N_CRATES)],
+        "skid_counts": [len(by_level[i]["skids"]) for i in range(N_CRATES)],
+        "width_spread": width_spread,
+        "width_pairs": pairs,
+        "yaw_spread": yaw_spread,
+        "yaw_min": min((abs(y) for y in yaw_vals), default=0.0),
+        "yaw_max": max((abs(y) for y in yaw_vals), default=9.0),
+        "yaws": [round(y, 4) for y in yaw_vals],
+        "bases": [round(b, 4) for b in bases],
+    }
+
+
+def crate_size_audit(me, yaws, bases):
+    """Each crate's own footprint, measured in that crate's frame.
+
+    The stack AABB cannot cover this: it is the union of three yawed
+    boxes, so a crate could drift to any size underneath it. Un-rotating
+    by the yaw this crate was measured to have is what makes the number
+    the crate's own width and depth rather than its rotated bounding box.
+    """
+    out = []
+    for i, base in enumerate(bases):
+        # Body band only. A band that reaches the crate base also picks
+        # up the runners of the crate above, which carry a different yaw
+        # and inflate the footprint by two centimetres.
+        lo = base + SKID_H + 0.012
+        hi = base + SKID_H + BODY_H - 0.012
+        pts = [v.co for v in me.vertices if lo <= v.co.z <= hi]
+        if not pts or i >= len(yaws):
+            out.append((0.0, 0.0))
+            continue
+        c, s = math.cos(-yaws[i]), math.sin(-yaws[i])
+        us = [p.x * c - p.y * s for p in pts]
+        vs = [p.x * s + p.y * c for p in pts]
+        out.append((max(us) - min(us), max(vs) - min(vs)))
+    return out
+
+
+def add_stray_vert(me):
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(me)
+        bm.verts.new((0.0, 0.0, STACK_H * 0.5))
+        bm.to_mesh(me)
+        me.update()
+    finally:
+        bm.free()
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+    mesh = obj.data.copy()
+    lod = bpy.data.objects.new(name, mesh)
+    lod.matrix_world = obj.matrix_world.copy()
+    bpy.context.scene.collection.objects.link(lod)
+    if not skip_decimate and 0.0 < ratio < 1.0:
+        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+        mod.decimate_type = "COLLAPSE"
+        mod.ratio = ratio
+    return lod
+
+
+def convex_hull_collider(obj, name):
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(obj.data)
+        result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+        interior = result.get("geom_interior") or []
+        unused = result.get("geom_unused") or []
+        if interior:
+            bmesh.ops.delete(bm, geom=interior, context="VERTS")
+        if unused:
+            bmesh.ops.delete(bm, geom=unused, context="VERTS")
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    collider = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(collider)
+    collider.matrix_world = obj.matrix_world.copy()
+    return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+    if not obj.data.uv_layers:
+        return None, None
+    img = bpy.data.images.new("StackNrm", size, size, alpha=True, float_buffer=False)
+    img.colorspace_settings.name = "Non-Color"
+    nodes = target_mat.node_tree.nodes
+    tex = nodes.new("ShaderNodeTexImage")
+    tex.image = img
+    nodes.active = tex
+    tex.select = True
+    obj.active_material_index = WOOD_IDX
+    return img, tex
+
+
+def bake_normal(high, low):
+    scene = bpy.context.scene
+    scene.render.engine = "CYCLES"
+    scene.cycles.device = "CPU"
+    scene.cycles.samples = 1
+    scene.cycles.use_denoising = False
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    high.select_set(True)
+    low.select_set(True)
+    bpy.context.view_layer.objects.active = low
+    return bpy.ops.object.bake(
+        type="NORMAL",
+        use_selected_to_active=True,
+        cage_extrusion=CAGE_EXTRUSION,
+        use_cage=False,
+        normal_space="TANGENT",
+        margin=4,
+        margin_type="ADJACENT_FACES",
+        use_clear=True,
+        target="IMAGE_TEXTURES",
+    )
+
+
+def export_unity(path, objects):
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    for ob in objects:
+        ob.select_set(True)
+    bpy.context.view_layer.objects.active = objects[0]
+    bpy.ops.export_scene.gltf(
+        filepath=path,
+        use_selection=True,
+        export_yup=True,
+        export_apply=True,
+        export_draco_mesh_compression_enable=False,
+        export_animations=False,
+    )
+
+
+def check(
+    skip_decimate,
+    lift_z=False,
+    stray_vert=False,
+    short_skids=False,
+    float_stack=False,
+    same_seed=False,
+):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    flags = dict(
+        same_seed=same_seed,
+        short_skids=short_skids,
+        float_stack=float_stack,
+    )
+    nothing = (None,) * 5
+    low = build_stack_mesh("StackLow", **flags)
+    high = build_stack_mesh("StackHigh", **flags)
+    wood = principled(
+        "StackWood", (0.40, 0.18, 0.065, 1.0), 0.0, 0.55,
+        noise_scale=9.0, wear=(0.20, 0.085, 0.030, 1.0),
+    )
+    metal = principled(
+        "StackMetal", (0.20, 0.19, 0.180, 1.0), 0.88, 0.33,
+        noise_scale=6.0, wear=(0.10, 0.095, 0.088, 1.0),
+    )
+    assign_slots(low, wood, metal)
+    assign_slots(high, wood, metal)
+    if stray_vert:
+        add_stray_vert(low.data)
+    if lift_z:
+        for v in low.data.vertices:
+            v.co.z += LIFT_Z
+        low.data.update()
+        bpy.context.view_layer.update()
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return (fail("stack mesh did not build", 3),) + nothing
+
+    base_tris = triangle_count(low.data)
+    mats = [s for s in low.data.materials if s is not None]
+    nmat = len(mats)
+    distinct_mats = len({id(s) for s in mats})
+    idx_counts = {}
+    for poly in low.data.polygons:
+        idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1
+    u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data)
+    bb = world_bbox(low)
+    size_x, size_y, size_z = bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2]
+
+    img, tex = setup_bake_image(low, wood)
+    if img is None:
+        return (fail("stack has no UV layer", 3),) + nothing
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "StackLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "StackLOD2", LOD2_TARGET, skip_decimate)
+    bpy.context.view_layer.update()
+    lod1_tris = evaluated_triangle_count(lod1)
+    lod2_tris = evaluated_triangle_count(lod2)
+    r1 = lod1_tris / base_tris if base_tris else 0.0
+    r2 = lod2_tris / base_tris if base_tris else 0.0
+
+    collider_src = build_stack_mesh("StackColSrc", **flags)
+    collider = convex_hull_collider(collider_src, "StackCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(), f"bdt_crate_stack_{os.getpid()}.glb"
+    )
+    if os.path.exists(export_path):
+        os.remove(export_path)
+    export_unity(export_path, [low, collider])
+    export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+    hyg = hygiene_audit(low.data)
+    zf = zfight_pairs(low.data)
+    st = stack_audit(low.data)
+    crates = crate_size_audit(low.data, st['yaws'], st['bases'])
+
+    print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
+    print(f"measured mat_index_counts={idx_counts}")
+    print(
+        f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+        f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
+    )
+    print(
+        f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+        f"overlap={overlap:.6f} nfaces={nfaces}"
+    )
+    print(
+        f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+        f"outer={OUTER_SIZE} zmin={bb[2]:.5f}"
+    )
+    print(
+        f"measured collider_tris={col_tris} bake={bake_result} "
+        f"bake_has_data={img.has_data} export_bytes={export_size}"
+    )
+    print(
+        f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} "
+        f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} "
+        f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}"
+    )
+    print(
+        f"measured stack ground_n={st['ground_n']} ground_z={st['ground_z']:.5f} "
+        f"seats={st['seats']} seat_gap={st['seat']:.5f} lids={st['lids']} "
+        f"boards={st['boards']} per_level_boards={st['board_counts']} "
+        f"per_level_skids={st['skid_counts']}"
+    )
+    print(
+        f"measured variation yaws={st['yaws']} yaw_spread={st['yaw_spread']:.4f} "
+        f"yaw_min={st['yaw_min']:.4f} yaw_max={st['yaw_max']:.4f} "
+        f"width_spread={st['width_spread']:.5f} pairs={st['width_pairs']}"
+    )
+    print(
+        "measured crate_footprints="
+        f"{[(round(c[0], 4), round(c[1], 4)) for c in crates]}"
+    )
+
+    if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+        return (fail(
+            f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", 4
+        ),) + nothing
+    if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+        return (fail(
+            f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}", 5
+        ),) + nothing
+    if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN:
+        return (fail(
+            f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}", 5
+        ),) + nothing
+    if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN:
+        return (fail(
+            f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}", 5
+        ),) + nothing
+    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+        return (fail(
+            f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", 6
+        ),) + nothing
+    if overlap > UV_OVERLAP_MAX:
+        return (fail(f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7),) + nothing
+    if (
+        abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+        or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+        or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+    ):
+        return (fail(
+            f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) off outer {OUTER_SIZE}", 8
+        ),) + nothing
+    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+        return (fail(
+            f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+            "(--skip-decimate is the designed fail)", 9
+        ),) + nothing
+    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+        return (fail(
+            f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", 9
+        ),) + nothing
+    if col_tris > COLLIDER_TRIS_MAX:
+        return (fail(f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", 11),) + nothing
+    if bake_result != {"FINISHED"} or not img.has_data:
+        return (fail(
+            f"bake failed result={bake_result} has_data={img.has_data}", 12
+        ),) + nothing
+    if export_size <= 0:
+        return (fail("export file missing or empty", 13),) + nothing
+    if (
+        hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"]
+        or hyg["zero_area"] or hyg["doubles"] or hyg["ngons"] or zf
+    ):
+        return (fail(
+            f"hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} "
+            f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} "
+            f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf} "
+            "(--stray-vert is the designed fail)", 15
+        ),) + nothing
+    if abs(bb[2]) > ZMIN_EPS:
+        return (fail(
+            f"zmin {bb[2]:.6f} not within {ZMIN_EPS} of 0 "
+            "(--lift-z is the designed fail)", 16
+        ),) + nothing
+    if st["ground_n"] < GROUND_SKIDS_MIN or st["ground_z"] > SKID_Z_MAX:
+        return (fail(
+            f"ground runners {st['ground_n']} ground_z={st['ground_z']:.5f} "
+            "(--short-skids is the designed fail)", 16
+        ),) + nothing
+    if st["seats"] != N_CRATES - 1 or st["seat"] > STACK_SEAT_MAX:
+        return (fail(
+            f"stack seat gap {st['seat']:.5f} > {STACK_SEAT_MAX} over "
+            f"{st['seats']} seats (--float-stack is the designed fail)", 18
+        ),) + nothing
+    for i, (cx, cy) in enumerate(crates):
+        if abs(cx - BODY_X) > CRATE_TOL or abs(cy - BODY_Y) > CRATE_TOL:
+            return (fail(
+                f"crate {i} body footprint ({cx:.4f},{cy:.4f}) off "
+                f"({BODY_X:.4f},{BODY_Y:.4f})", 19
+            ),) + nothing
+    if st["yaw_min"] < YAW_MIN * 0.8 or st["yaw_max"] > YAW_MAX * 1.2:
+        return (fail(
+            f"crate yaws {st['yaws']} outside [{YAW_MIN}, {YAW_MAX}]", 20
+        ),) + nothing
+    if st["yaw_spread"] < YAW_SPREAD_MIN or st["width_spread"] < WIDTH_SPREAD_MIN:
+        return (fail(
+            f"per-instance variation too small: yaw_spread={st['yaw_spread']:.4f} "
+            f"(min {YAW_SPREAD_MIN}) width_spread={st['width_spread']:.5f} "
+            f"(min {WIDTH_SPREAD_MIN}) — the three crates are copies, not "
+            "instances (--same-seed is the designed fail)", 20
+        ),) + nothing
+    return 0, low, high, wood, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nrm.inputs["Strength"].default_value = 1.0
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
+
+
+def render_still(low, wood, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(wood, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    low.rotation_euler.z = math.radians(-14.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=16.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Floor")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 9.0, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02, 0.021, 0.025, 1.0,
+    )
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", (-3.2, -4.0, 5.2), 545.0, 5.0, (1.0, 0.96, 0.90), (44, 0, -38))
+    light("Fill", (4.2, -2.8, 1.5), 145.0, 9.0, (0.75, 0.85, 1.00), (72, 0, 54))
+    light("Rim", (-1.5, 3.4, 2.6), 320.0, 3.0, (0.60, 0.78, 1.00), (-64, 0, 200))
+    # Wedge sits between the subject and the wall so the pool lands on the
+    # backdrop, not across the crates.
+    light("Wedge", (1.1, 4.3, 1.95), 560.0, 6.0, (1.0, 0.72, 0.40), (-94, 0, 194))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 52.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (1.62, -2.42, 1.06)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, STACK_H * 0.46)
+    scene.collection.objects.link(aim)
+    con = cam.constraints.new("TRACK_TO")
+    con.target = aim
+    con.track_axis = "TRACK_NEGATIVE_Z"
+    con.up_axis = "UP_Y"
+    scene.camera = cam
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 32
+        scene.cycles.device = "CPU"
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = (
+        "WEBP" if path.lower().endswith(".webp") else "PNG"
+    )
+    if path.lower().endswith(".webp"):
+        scene.render.image_settings.quality = 90
+    scene.render.filepath = path
+    scene.view_settings.view_transform = "Standard"
+
+    fcode = gallery_framing.check_framing(
+        scene, cam, hero=[low], elements=[low], stage=[floor, wall],
+    )
+    if fcode:
+        return fcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        return fail("render produced no file", 14)
+    return 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None)
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+    p.add_argument("--skip-decimate", action="store_true")
+    p.add_argument("--lift-z", action="store_true")
+    p.add_argument("--stray-vert", action="store_true")
+    p.add_argument("--short-skids", action="store_true")
+    p.add_argument("--float-stack", action="store_true")
+    p.add_argument("--same-seed", action="store_true")
+    args = p.parse_args(argv)
+
+    code, low, _high, wood, tex, _col = check(
+        args.skip_decimate,
+        lift_z=args.lift_z,
+        stray_vert=args.stray_vert,
+        short_skids=args.short_skids,
+        float_stack=args.float_stack,
+        same_seed=args.same_seed,
+    )
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(low, wood, tex, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    print("crate-stack OK")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as e:
+        traceback.print_exc()
+        print(f"FATAL: {e}", file=sys.stderr)
+        sys.exit(1)
+
+
+
+ +
+
+ generated from examples/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 54aa9a9c..36cdae78 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -272,7 +272,7 @@

Examples and Showcase

autocomplete="off" spellcheck="false" aria-label="Search examples and showcase pieces" /> - 51 examples, 26 showcase pieces + 51 examples, 28 showcase pieces
@@ -1171,6 +1171,28 @@

hay-bale

View showcase piece
+
+ + crate-stack — A stack of three procedural shipping crates built by one generator called three times with a per-instance seed, carried through UVs, bake, LOD, collider, and… + +
+

crate-stack

+

A stack of three procedural shipping crates built by one generator called three times with a per-instance seed, carried through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 4380 tris, two materials with 216 iron and 2436 timber faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.658×0.524×0.704 m, zmin 0, hygiene 0 including zero coplanar disjoint pairs, three ground runners at z=0, both crate-to-crate seats overlapping, each crate's own footprint 0.585×0.425 m measured in its own frame, yaw spread 0.026 rad and plank spread 2.05 mm across instances, LOD ratios in band, convex collider 202 tris, non-empty glTF. --float-stack exits 18 on the seat; --same-seed exits 20 on per-instance variation.

+ View showcase piece +
+
+
+ + stone-archway — A procedural masonry archway — coursed piers, projecting imposts, nine voussoirs and a proud keystone — carried through UVs, bake, LOD, collider, and Unity… + +
+

stone-archway

+

A procedural masonry archway — coursed piers, projecting imposts, nine voussoirs and a proud keystone — carried through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 924 tris, two materials with 182 dressed and 364 ashlar faces re-stamped after the chamfer, UVs in 0..1 with zero AABB overlap, outer AABB 1.644×0.510×2.057 m, clear opening 1.203 m, zmin 0, hygiene 0 including zero coplanar disjoint pairs, both piers grounded, the springing joint an overlap, all eight mortar joints at 9.70 mm inside the 6–17 mm band, and every intrados vertex within 0.97 mm of the declared 0.60 m radius over 178°. --off-circle exits 19 on the circle fit; --wide-mortar exits 18 on the joint band.

+ View showcase piece +
+
@@ -1200,7 +1222,7 @@

hay-bale

var tagsToggle = document.getElementById('tagsToggle'); var toTop = document.getElementById('toTop'); var total = cards.length; - var COUNT_LABEL = '51 examples, 26 showcase pieces'; + var COUNT_LABEL = '51 examples, 28 showcase pieces'; var LS_KEY = 'bdt-gallery-density'; var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; diff --git a/docs/gallery/stone-archway/index.html b/docs/gallery/stone-archway/index.html new file mode 100644 index 00000000..69a10167 --- /dev/null +++ b/docs/gallery/stone-archway/index.html @@ -0,0 +1,1568 @@ + + + + + + stone-archway — Showcase — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

stone-archway

+

A procedural masonry archway — coursed piers, projecting imposts, nine voussoirs and a proud keystone — carried through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+
+
+ +

Rendered headless by the showcase piece itself — click to zoom.

+
witnesses Recomputed: 924 tris, two materials with 182 dressed and 364 ashlar faces re-stamped after the chamfer, UVs in 0..1 with zero AABB overlap, outer AABB 1.644×0.510×2.057 m, clear opening 1.203 m, zmin 0, hygiene 0 including zero coplanar disjoint pairs, both piers grounded, the springing joint an overlap, all eight mortar joints at 9.70 mm inside the 6–17 mm band, and every intrados vertex within 0.97 mm of the declared 0.60 m radius over 178°. --off-circle exits 19 on the circle fit; --wide-mortar exits 18 on the joint band.
+
+
blender --background --python showcase/stone-archway/stone_archway.py --
+ +
+
+

!A semicircular masonry archway: coursed piers, projecting imposts, nine voussoirs and a proud keystone

+

A freestanding masonry arch — two coursed piers, projecting imposts, nine voussoirs turning a semicircle, and a keystone standing proud at the crown. A showcase piece, not an example — it witnesses no API contract. It asserts that generated geometry meets declared asset budgets, recomputed from the finished mesh.

+

What it composes

+

| Shipped content | Used for | | --- | --- | | skills/mesh-editing-and-bmesh | wedge and box construction, chamfer, UVs in one bmesh | | skills/procedural-materials-and-shaders | two Principled stone materials with noise-driven weathering | | skills/bake-high-to-low | Cycles tangent-space normal bake, high onto low | | skills/engine-export-presets | Unity glTF (export_yup=True) | | skills/depsgraph-and-evaluated-data | evaluated triangle counts for the LOD ratios | | snippets/decimate_to_budget.py | LOD1 / LOD2 COLLAPSE chain | | snippets/convex_hull_collider.py | convex collider | | snippets/lod_chain.py | LOD naming and ratio pattern | | examples/mesh-hygiene-audit | hygiene combinatorics (copied, not imported) |

+

The budget that matters

+

An arch can fail invisibly. Nine wedge blocks laid in a row are still nine wedge blocks; the thing that makes them an arch is that their intrados vertices sit on a circle. So the piece recomputes that circle from vertex positions — not from the angles the generator used — and asserts every intrados vertex lands within 4 mm of the declared 0.60 m radius, over an arc spanning at least 168°.

+

--off-circle is the falsifier built for exactly this. It keeps the angles, the joints, the materials, the triangle count and the bounding box identical, and only wanders the intrados radius by ±18 mm. Every other budget in the piece still passes. Only the circle fit sees it.

+

Measuring it took two corrections

+

The first attempt selected intrados vertices by radius. That swept in the chamfer vertices sitting one bevel-width out on each radial face and reported a 5.1 mm error on a true arch. The fit now runs over the vertices of faces that actually face the springing centre — normal inward in XZ *and* with a small Y component, which is what excludes the chamfer strips running along the intrados edges.

+

Budgets

+

Declared in the script as named constants, recomputed from the generated mesh. Measured values are from Blender 5.2.1; the cross-version table is at the end.

+

| Budget | Band | Measured | | --- | --- | --- | | Base triangles | 700–2600 | 924 | | LOD1 ratio | 0.32–0.62 | 0.5000 | | LOD2 ratio | 0.10–0.35 | 0.2121 (5.2) / 0.2186 (4.5, 5.1) | | Material slots | exactly 2, distinct | 2 | | Dressed faces (keystone, imposts, plinth and head courses) | ≥ 100 | 182 | | Ashlar faces | ≥ 280 | 364 | | UV bounds | inside 0..1 | (0.0017, 0.0017)–(0.9983, 0.9983) | | UV AABB overlap | ≤ 1e-5 | 0.000000 | | Outer AABB | 1.644 × 0.510 × 2.057 m ± 0.020 | 1.6440 × 0.5100 × 2.0568 | | Clear opening | 1.200 m ± 0.015, measured between the pier faces | 1.2033 | | Collider triangles | ≤ 260 | 168 | | Normal bake | {'FINISHED'} with image data | {'FINISHED'}, has_data=True | | glTF export | file written, non-empty | ~87 kB | | Hygiene | all zero | loose 0/0, non-manifold 0, zero-area 0, doubles 0, n-gons 0, coplanar disjoint pairs 0 | | Grounded AABB | \|zmin\| ≤ 1e-4 | 0.00000 | | Pier supports | 2 piers, each base course zmin ≤ 1e-3 | 2 at 0.00000 | | Keystone proud of the wall face | ≥ 0.020 m | 0.03500 | | Springing joint | overlap, surface gap ≤ 1e-4 | 0.00000 | | Mortar joints | every adjacent pair in 0.006–0.017 m | all eight at 0.00970 | | Intrados circle fit | every vertex within 0.004 m of R = 0.60 | 0.00097 | | Intrados arc span | ≥ 168° | 178.0° |

+

Real-world size: a 1.20 m clear opening under a semicircular head, 1.64 m across the piers and 2.06 m to the top of the keystone — a garden gate arch.

+

Determinism

+

Fixed seed 23; no unseeded randomness. Every measured value above is byte-identical on 4.5.11, 5.1.2 and 5.2.1 except LOD2, where DECIMATE COLLAPSE produces 196 triangles on 5.2 and 202 on 4.5 and 5.1. That is why the LOD gate is a ratio band (0.10–0.35, measured 0.2121 and 0.2186) and not an exact count.

+

Falsifiers

+

Each breaks one pipeline stage so a named budget fails. All seven were run on 4.5.11, 5.1.2 and 5.2.1 and produced the same exit code on all three.

+

| Flag | Breaks | Exit | | --- | --- | --- | | --skip-decimate | drops the DECIMATE modifiers, LOD1 ratio goes to 1.0000 | 9 | | --stray-vert | adds one loose vertex inside the opening, so hygiene catches it rather than the bounding box | 15 | | --lift-z | lifts the whole mesh 50 mm off the floor | 16 | | --float-pier | floats one pier 12 mm; the other still grounds the AABB, so only the named-support budget sees it | 16 | | --sink-keystone | sinks the keystone to a quarter of its projection (8.75 mm) — the imposts still set the Y envelope, so the bounding box is unchanged | 17 | | --wide-mortar | triples the joint angle, opening every joint to 29.1 mm | 18 | | --off-circle | wanders the intrados radius ±18 mm while keeping angles, joints and envelope | 19 |

+

Three of these needed the model changed, not the budget:

+
  • --sink-keystone originally removed the projection outright, which shrank the Y bounding box by 70 mm and tripped the AABB gate first. The imposts were added so the envelope no longer depends on the keystone — and an impost course is correct masonry the arch was missing anyway.
  • --off-circle began as --flat-arch, laying the voussoirs as a lintel. That is 0.62 m shorter and fails on the bounding box, proving nothing about the circle fit. Wandering the radius inside the same envelope is the honest version.
  • --float-pier tripped the z-fight budget rather than the support budget, because the impost was pinned to an absolute height while the courses under it rose into it. The impost now rides on its own pier.
+

Findings the budgets forced

+
  • The chamfer pass silently repainted the mesh. bmesh.ops.bevel gives every face it creates material_index 0, so chamfering nineteen blocks left 30 faces on the dressed slot and moved 464 to ashlar. The slot count and the distinct-material check both still passed. Only the per-material face floor caught it, which is exactly the class showcase/README.md warns about. Materials are now re-stamped after the bevel, per block, by nearest recorded centroid.
  • The impost landed exactly on the top course. Same footprint, same plane, two coincident face centres — a z-fight. It now sits on its own mortar bed, which is why the pier lays N_COURSE beds rather than N_COURSE - 1.
  • find_nearest is unsigned. A voussoir seated *inside* the pier head reported a 5.4 mm gap where there was none, because the distance to the host's skin is positive from inside too. Joint gaps now test BVH overlap first and return zero when two blocks interpenetrate.
  • Joint gaps have to be measured both ways. The keystone's radial faces are wider in Y than its neighbours', so every keystone vertex on the joint lies outside the neighbour and read 33 mm instead of the 9.7 mm joint. The metric is the closest approach of the two surfaces, min of both directions.
+

Exit codes

+

File-local and sequential. 9 is a valid check code; there is no rule against it. 1 is the FATAL wrapper — a crash, never a named check.

+

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build, or has no UV layer | | 4 | Base triangle count outside band | | 5 | Material slots, or a material's face floor | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | Outer AABB off declared size | | 9 | LOD1 or LOD2 ratio outside band (--skip-decimate) | | 10 | Framing gate (examples/gallery_framing.py, render path only) | | 11 | Collider triangles above ceiling | | 12 | Normal bake failed or produced no image data | | 13 | glTF export missing or empty | | 14 | --output produced no file | | 15 | Mesh hygiene (--stray-vert) | | 16 | Grounded zmin, or a pier base floating (--lift-z, --float-pier) | | 17 | Keystone projection or springing joint (--sink-keystone) | | 18 | Mortar joint outside band (--wide-mortar) | | 19 | Intrados circle fit, or clear opening (--off-circle) |

+

Run it

+
# Budget check, no render. ~0.82 s on 4.5, ~0.85 s on 5.1, ~0.98 s on 5.2.
+blender --background --python stone_archway.py --
+
+# Falsifier: the intrados stops being a circle. Must exit 19.
+blender --background --python stone_archway.py -- --off-circle
+
+# Falsifier: every mortar joint opens to 29 mm. Must exit 18.
+blender --background --python stone_archway.py -- --wide-mortar
+
+# Render the gallery still (EEVEE; --engine cycles on a GPU-less host).
+blender --background --python stone_archway.py -- --output arch.webp
+

Smoke runs the check-only path. It does not pass --output or any falsifier.

+

Cross-version measurements

+

| Value | 4.5.11 | 5.1.2 | 5.2.1 | | --- | --- | --- | --- | | Base triangles | 924 | 924 | 924 | | LOD1 tris / ratio | 462 / 0.5000 | 462 / 0.5000 | 462 / 0.5000 | | LOD2 tris / ratio | 202 / 0.2186 | 202 / 0.2186 | 196 / 0.2121 | | Face counts (ashlar / dressed) | 364 / 182 | 364 / 182 | 364 / 182 | | Outer AABB | 1.6440 × 0.5100 × 2.0568 | same | same | | Collider tris | 168 | 168 | 168 | | Intrados deviation | 0.00097 | 0.00097 | 0.00097 | | Mortar joints | all 0.00970 | all 0.00970 | all 0.00970 | | Check wall-clock | ~0.82 s | ~0.85 s | ~0.98 s |

+
+
+

Source

+
+ showcase/stone-archway/stone_archway.py + View on GitHub → +
+
"""Game-ready stone archway — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural masonry arch: two coursed
+piers, nine voussoirs turning a semicircle, and a proud keystone, carried
+through UVs, two materials, a high-to-low normal bake, an LOD chain, a
+convex collider, and a Unity glTF export.
+
+The budget that matters here is the one an arch can fail invisibly: the
+voussoir intrados vertices must lie on a circle of the declared radius,
+recomputed from vertex positions rather than from the angles the
+generator used. A row of wedges that never turned is still a row of
+wedges; only the circle fit knows the difference.
+
+Budgets are declared below and recomputed from the generated result.
+They are not API-contract witnesses. Each falsifier violates one named
+budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh
+hygiene, ``--lift-z`` grounded zmin, ``--float-pier`` the named pier
+supports, ``--sink-keystone`` the keystone joint, ``--wide-mortar`` the
+mortar-joint band, ``--off-circle`` the intrados circle fit.
+
+Fixed seed 23 for course and block weathering. DECIMATE COLLAPSE
+triangle counts are not byte-identical across Blender versions — the LOD
+gate is a ratio band, not an exact count.
+
+    blender --background --python stone_archway.py --
+    blender --background --python stone_archway.py -- --off-circle
+    blender --background --python stone_archway.py -- --output arch.png
+"""
+import argparse
+import math
+import os
+import random
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Vector
+from mathutils.bvhtree import BVHTree
+
+_REPO = os.path.abspath(
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing  # noqa: E402
+
+# A 1.2 m clear opening under a semicircular head: a gate arch, about
+# 1.6 m across the piers and 2.0 m to the crown.
+R_IN = 0.60
+T_V = 0.20
+R_OUT = R_IN + T_V
+WALL_Y = 0.40
+H_SPRING = 1.20
+N_VOUSSOIR = 9
+KEY_INDEX = N_VOUSSOIR // 2
+# The keystone stands proud of the wall face and rises past the extrados.
+KEY_PROUD = 0.035
+KEY_RISE = 0.080
+# The keystone reads by standing proud and rising past the extrados, not
+# by being angularly wider: widening it eats its neighbours' mortar joints.
+KEY_WIDEN = 0.0
+# Voussoirs bite down into the pier head so the springing joint is an
+# overlap, not two faces sharing the z = H_SPRING plane.
+SPRING_BITE = 0.018
+# Imposts: the projecting string-course at the springing line. They set
+# the Y envelope, which is what lets the keystone falsifier change the
+# keystone without changing the bounding box.
+IMPOST_PROUD = 0.055
+IMPOST_H = 0.075
+IMPOST_OUT = 0.022
+# Radial mortar joints, as an angle so the joint stays radial.
+MORTAR_ANG = 0.016
+N_COURSE = 5
+COURSE_MORTAR = 0.007
+WEATHER = 0.18
+ARCH_SEED = 23
+# Worn arrises. Also what lifts the block count out of programmer-art
+# territory: nineteen unbevelled boxes are 228 triangles.
+CHAMFER = 0.006
+
+STACK_H = H_SPRING + R_OUT + KEY_RISE
+
+BBOX_TOL = 0.020
+OUTER_SIZE = (1.644, 0.510, 2.057)
+SPAN_TOL = 0.015
+OPENING_W = 2.0 * R_IN
+
+BASE_TRIS_MIN = 700
+BASE_TRIS_MAX = 2600
+LOD1_RATIO_MIN = 0.32
+LOD1_RATIO_MAX = 0.62
+LOD2_RATIO_MIN = 0.10
+LOD2_RATIO_MAX = 0.35
+LOD1_TARGET = 0.50
+LOD2_TARGET = 0.22
+MATERIAL_COUNT = 2
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 260
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+DRESSED_FACES_MIN = 100
+ASHLAR_FACES_MIN = 280
+ZMIN_EPS = 1e-4
+DOUBLES_EPS = 1e-5
+AREA_EPS = 1e-10
+ZFIGHT_EPS = 1e-4
+ZFIGHT_COS = 0.999
+LIFT_Z = 0.05
+PIER_Z_MAX = 1e-3
+PIERS_MIN = 2
+# Intrados circle fit: every inner-arc vertex within this of R_IN.
+ARC_TOL = 0.004
+ARC_SPAN_MIN = math.radians(168.0)
+# Mortar joint band, measured surface-to-surface per adjacent pair.
+MORTAR_MIN = 0.006
+MORTAR_MAX = 0.017
+KEY_PROUD_MIN = 0.020
+SPRING_GAP_MAX = 1e-4
+FLOAT_PIER_LIFT = 0.012
+
+ASHLAR_IDX = 0
+DRESSED_IDX = 1
+
+
+def eevee_engine_id():
+    """EEVEE id: 'BLENDER_EEVEE' on 5.0+, 'BLENDER_EEVEE_NEXT' on 4.2-4.5."""
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"FAIL[{code}]: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    deps = bpy.context.evaluated_depsgraph_get()
+    ev = obj.evaluated_get(deps)
+    mesh = ev.to_mesh()
+    try:
+        mesh.calc_loop_triangles()
+        return len(mesh.loop_triangles)
+    finally:
+        ev.to_mesh_clear()
+
+
+def add_box(bm, loc, scale, mat_idx):
+    geo = bmesh.ops.create_cube(bm, size=1.0)
+    verts = geo["verts"]
+    for v in verts:
+        v.co = Vector(
+            (
+                v.co.x * scale[0] + loc[0],
+                v.co.y * scale[1] + loc[1],
+                v.co.z * scale[2] + loc[2],
+            )
+        )
+    for f in {f for v in verts for f in v.link_faces}:
+        f.material_index = mat_idx
+    return verts, (Vector(loc), mat_idx)
+
+
+def add_wedge(bm, cz, a0, a1, r0, r1, hy, mat_idx):
+    """One voussoir: a radial wedge in XZ, extruded across the wall in Y.
+
+    Eight vertices, six quads, closed. The radial end faces are what the
+    mortar-joint budget measures, so they stay flat and parallel to the
+    neighbour's rather than being bevelled away.
+    """
+    prof = ((a0, r0), (a1, r0), (a1, r1), (a0, r1))
+    rows = []
+    for y in (-hy, hy):
+        rows.append(
+            [
+                bm.verts.new((r * math.cos(a), y, cz + r * math.sin(a)))
+                for a, r in prof
+            ]
+        )
+    faces = [bm.faces.new(rows[0]), bm.faces.new(tuple(reversed(rows[1])))]
+    for k in range(4):
+        kn = (k + 1) % 4
+        faces.append(
+            bm.faces.new((rows[0][k], rows[0][kn], rows[1][kn], rows[1][k]))
+        )
+    for f in faces:
+        f.material_index = mat_idx
+    vs = [v for row in rows for v in row]
+    c = sum((v.co for v in vs), Vector((0.0, 0.0, 0.0))) / len(vs)
+    return vs, (c, mat_idx)
+
+
+def pack_uvs(bm, margin=0.08):
+    uv = bm.loops.layers.uv.new("UVMap")
+    faces = list(bm.faces)
+    n = len(faces)
+    cols = max(1, math.ceil(math.sqrt(n)))
+    rows = max(1, math.ceil(n / cols))
+    cell_w = 1.0 / cols
+    cell_h = 1.0 / rows
+    pad_u = margin * cell_w * 0.5
+    pad_v = margin * cell_h * 0.5
+    usable_w = cell_w - 2.0 * pad_u
+    usable_h = cell_h - 2.0 * pad_v
+    for i, face in enumerate(faces):
+        col = i % cols
+        row = i // cols
+        nrm = face.normal
+        ax, ay, az = abs(nrm.x), abs(nrm.y), abs(nrm.z)
+        coords = []
+        for loop in face.loops:
+            co = loop.vert.co
+            if az >= ax and az >= ay:
+                coords.append((co.x, co.y))
+            elif ax >= ay:
+                coords.append((co.y, co.z))
+            else:
+                coords.append((co.x, co.z))
+        xs = [c[0] for c in coords]
+        ys = [c[1] for c in coords]
+        minx, maxx = min(xs), max(xs)
+        miny, maxy = min(ys), max(ys)
+        dx = max(maxx - minx, 1e-8)
+        dy = max(maxy - miny, 1e-8)
+        ou = col * cell_w + pad_u
+        ov = row * cell_h + pad_v
+        for loop, (x, y) in zip(face.loops, coords):
+            loop[uv].uv = (
+                ou + (x - minx) / dx * usable_w,
+                ov + (y - miny) / dy * usable_h,
+            )
+
+
+def assign_materials_by_block(bm, blocks):
+    """Re-stamp material indices after the chamfer pass.
+
+    ``bmesh.ops.bevel`` gives every face it creates ``material_index`` 0,
+    so chamfering nineteen blocks repainted 464 of 494 faces with the
+    first slot and left 30 on the second. The slot count and the distinct
+    material check both still passed; only a per-material face floor
+    catches it. Each block is its own shell, so re-deriving the material
+    from the nearest recorded block centroid restores the assignment
+    without depending on face or vertex ordering.
+    """
+    seen = set()
+    for v in bm.verts:
+        if v in seen:
+            continue
+        stack = [v]
+        seen.add(v)
+        comp = []
+        while stack:
+            cur = stack.pop()
+            comp.append(cur)
+            for e in cur.link_edges:
+                other = e.other_vert(cur)
+                if other not in seen:
+                    seen.add(other)
+                    stack.append(other)
+        centre = sum((x.co for x in comp), Vector((0.0, 0.0, 0.0))) / len(comp)
+        _c, mat = min(blocks, key=lambda b: (b[0] - centre).length_squared)
+        for f in {f for x in comp for f in x.link_faces}:
+            f.material_index = mat
+
+
+def build_pier(bm, sign, rng, float_pier=False):
+    """One coursed pier, base course on the floor, head at H_SPRING."""
+    raw = [1.0 + rng.uniform(-WEATHER, WEATHER) for _ in range(N_COURSE)]
+    total = sum(raw)
+    # N_COURSE beds, not N_COURSE-1: the impost needs one under it too,
+    # or its underside lands exactly on the top course and the two share
+    # a face centre, which is a z-fight.
+    usable = H_SPRING - IMPOST_H - COURSE_MORTAR * N_COURSE
+    heights = [usable * r / total for r in raw]
+    verts = []
+    blocks = []
+    z = FLOAT_PIER_LIFT if (float_pier and sign < 0) else 0.0
+    for i, h in enumerate(heights):
+        # Courses weather back a little as they rise; the jitter also keeps
+        # two neighbouring courses from presenting identical faces.
+        inset = rng.uniform(0.0, 0.010)
+        depth = WALL_Y - rng.uniform(0.0, 0.014)
+        mat = DRESSED_IDX if i in (0, N_COURSE - 1) else ASHLAR_IDX
+        vs, block = add_box(
+            bm,
+            (sign * (R_IN + T_V / 2.0), 0.0, z + h / 2.0),
+            (T_V - inset, depth, h),
+            mat,
+        )
+        verts.extend(vs)
+        blocks.append(block)
+        z += h + COURSE_MORTAR
+    # The impost rides on the courses rather than sitting at an absolute
+    # height: pinned to H_SPRING it stays put while --float-pier lifts the
+    # courses into it, and the two then share a face plane.
+    vs, block = add_box(
+        bm,
+        (sign * (R_IN + T_V / 2.0), 0.0, z + IMPOST_H / 2.0),
+        (T_V + 2.0 * IMPOST_OUT, WALL_Y + 2.0 * IMPOST_PROUD, IMPOST_H),
+        DRESSED_IDX,
+    )
+    verts.extend(vs)
+    blocks.append(block)
+    return verts, blocks
+
+
+def build_arch(bm, rng, off_circle=False, sink_keystone=False, wide_mortar=False):
+    """Nine voussoirs turning 0..pi, keystone at the crown."""
+    gap = MORTAR_ANG * (3.0 if wide_mortar else 1.0)
+    step = math.pi / N_VOUSSOIR
+    cz = H_SPRING - SPRING_BITE
+    verts = []
+    blocks = []
+    for k in range(N_VOUSSOIR):
+        is_key = k == KEY_INDEX
+        widen = KEY_WIDEN if is_key else 0.0
+        a0 = k * step + gap / 2.0 - widen
+        a1 = (k + 1) * step - gap / 2.0 + widen
+        r1 = R_OUT + (KEY_RISE if is_key else 0.0)
+        hy = WALL_Y / 2.0
+        if is_key:
+            # Sunk, not removed: the imposts still set the Y envelope, so
+            # the bounding box is unchanged and the keystone budget is the
+            # only thing that can see this.
+            hy += KEY_PROUD * (0.25 if sink_keystone else 1.0)
+        mat = DRESSED_IDX if is_key else ASHLAR_IDX
+        r0 = R_IN
+        if off_circle:
+            # Same angles, same joints, same envelope — only the intrados
+            # radius wanders. This is the failure the circle fit exists
+            # for: nothing else in the piece can see it.
+            r0 = R_IN + (0.018 if k % 2 else -0.018)
+        # Weathering rides on the extrados only; the intrados is the face
+        # the circle-fit budget measures and is left true.
+        r1 += rng.uniform(0.0, 0.012)
+        vs, block = add_wedge(bm, cz, a0, a1, r0, r1, hy, mat)
+        verts.extend(vs)
+        blocks.append(block)
+    return verts, blocks
+
+
+def build_arch_mesh(
+    name,
+    off_circle=False,
+    sink_keystone=False,
+    wide_mortar=False,
+    float_pier=False,
+):
+    rng = random.Random(ARCH_SEED)
+    bm = bmesh.new()
+    try:
+        blocks = []
+        for sign in (-1.0, 1.0):
+            _v, bl = build_pier(bm, sign, rng, float_pier=float_pier)
+            blocks.extend(bl)
+        _v, bl = build_arch(
+            bm,
+            rng,
+            off_circle=off_circle,
+            sink_keystone=sink_keystone,
+            wide_mortar=wide_mortar,
+        )
+        blocks.extend(bl)
+        edges = list(bm.edges)
+        if edges:
+            bmesh.ops.bevel(
+                bm,
+                geom=edges,
+                offset=CHAMFER,
+                segments=1,
+                profile=0.5,
+                affect="EDGES",
+                clamp_overlap=True,
+            )
+        assign_materials_by_block(bm, blocks)
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = False
+        me = bpy.data.meshes.new(name)
+        bm.to_mesh(me)
+        me.update()
+    finally:
+        bm.free()
+    obj = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(obj)
+    return obj
+
+
+def principled(name, color, metallic, roughness, noise_scale=0.0, wear=None):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = color
+    bsdf.inputs["Metallic"].default_value = metallic
+    bsdf.inputs["Roughness"].default_value = roughness
+    if noise_scale > 0.0 and wear is not None:
+        tex = nt.nodes.new("ShaderNodeTexNoise")
+        tex.inputs["Scale"].default_value = noise_scale
+        tex.inputs["Detail"].default_value = 9.0
+        tex.inputs["Roughness"].default_value = 0.6
+        mix = nt.nodes.new("ShaderNodeMix")
+        mix.data_type = "RGBA"
+        mix.inputs["A"].default_value = color
+        mix.inputs["B"].default_value = wear
+        fac = mix.inputs.get("Factor") or mix.inputs.get("Fac")
+        nt.links.new(tex.outputs["Fac"], fac)
+        nt.links.new(mix.outputs["Result"], bsdf.inputs["Base Color"])
+        rmix = nt.nodes.new("ShaderNodeMix")
+        rmix.data_type = "FLOAT"
+        rmix.inputs["A"].default_value = roughness
+        rmix.inputs["B"].default_value = min(1.0, roughness + 0.16)
+        rfac = rmix.inputs.get("Factor") or rmix.inputs.get("Fac")
+        nt.links.new(tex.outputs["Fac"], rfac)
+        nt.links.new(rmix.outputs["Result"], bsdf.inputs["Roughness"])
+    return mat
+
+
+def assign_slots(obj, ashlar, dressed):
+    mats = obj.data.materials
+    for i, mat in enumerate((ashlar, dressed)):
+        if i < len(mats):
+            mats[i] = mat
+        else:
+            mats.append(mat)
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs = [c.x for c in corners]
+    ys = [c.y for c in corners]
+    zs = [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0.0, 0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    span = max(
+        1e-6,
+        max((a[2] - a[0]) for a in aabbs),
+        max((a[3] - a[1]) for a in aabbs),
+    )
+    buckets = {}
+    for i, a in enumerate(aabbs):
+        for c in range(int(a[0] // span), int(a[2] // span) + 1):
+            for r in range(int(a[1] // span), int(a[3] // span) + 1):
+                buckets.setdefault((c, r), []).append(i)
+    overlap = 0.0
+    seen = set()
+    for members in buckets.values():
+        for ii in range(len(members)):
+            for jj in range(ii + 1, len(members)):
+                i, j = members[ii], members[jj]
+                key = (i, j) if i < j else (j, i)
+                if key in seen:
+                    continue
+                seen.add(key)
+                a, b = aabbs[i], aabbs[j]
+                overlap += max(0.0, min(a[2], b[2]) - max(a[0], b[0])) * max(
+                    0.0, min(a[3], b[3]) - max(a[1], b[1])
+                )
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def face_area(me, poly):
+    idxs = poly.vertices
+    if len(idxs) < 3:
+        return 0.0
+    v0 = me.vertices[idxs[0]].co
+    area = 0.0
+    for i in range(1, len(idxs) - 1):
+        a = me.vertices[idxs[i]].co
+        b = me.vertices[idxs[i + 1]].co
+        area += (a - v0).cross(b - v0).length * 0.5
+    return area
+
+
+def hygiene_audit(me):
+    nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons)
+    ngons = sum(1 for p in me.polygons if len(p.vertices) > 4)
+    zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(me)
+        bm.verts.ensure_lookup_table()
+        bm.edges.ensure_lookup_table()
+        loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0)
+        loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0)
+        nonman = sum(1 for e in bm.edges if not e.is_manifold)
+        ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS)
+        doubles = len(ret.get("targetmap") or {})
+    finally:
+        bm.free()
+    return {
+        "nv": nv, "ne": ne, "nf": nf, "ngons": ngons, "loose_v": loose_v,
+        "loose_e": loose_e, "nonman": nonman, "zero_area": zero_area,
+        "doubles": doubles,
+    }
+
+
+def zfight_pairs(me):
+    """Coplanar, near-coincident face pairs that share no vertex."""
+    data = [
+        (p.center.copy(), p.normal.copy(), frozenset(p.vertices))
+        for p in me.polygons
+    ]
+    cell = ZFIGHT_EPS
+    buckets = {}
+    for i, (c, _n, _v) in enumerate(data):
+        key = (
+            int(math.floor(c.x / cell)),
+            int(math.floor(c.y / cell)),
+            int(math.floor(c.z / cell)),
+        )
+        buckets.setdefault(key, []).append(i)
+    eps2 = ZFIGHT_EPS * ZFIGHT_EPS
+    count = 0
+    checked = set()
+    for (kx, ky, kz), members in buckets.items():
+        near = []
+        for dx in (-1, 0, 1):
+            for dy in (-1, 0, 1):
+                for dz in (-1, 0, 1):
+                    near.extend(buckets.get((kx + dx, ky + dy, kz + dz), ()))
+        for i in members:
+            ci, ni, vi = data[i]
+            for j in near:
+                if j == i:
+                    continue
+                pair = (i, j) if i < j else (j, i)
+                if pair in checked:
+                    continue
+                checked.add(pair)
+                cj, nj, vj = data[j]
+                if (cj - ci).length_squared > eps2:
+                    continue
+                if abs(ni.dot(nj)) <= ZFIGHT_COS:
+                    continue
+                if vi & vj:
+                    continue
+                count += 1
+    return count
+
+
+def shells(me):
+    neighbors = [[] for _ in range(len(me.vertices))]
+    for edge in me.edges:
+        a, b = edge.vertices
+        neighbors[a].append(b)
+        neighbors[b].append(a)
+    seen = [False] * len(me.vertices)
+    groups = []
+    for start in range(len(me.vertices)):
+        if seen[start]:
+            continue
+        seen[start] = True
+        stack = [start]
+        group = []
+        while stack:
+            cur = stack.pop()
+            group.append(cur)
+            for nxt in neighbors[cur]:
+                if not seen[nxt]:
+                    seen[nxt] = True
+                    stack.append(nxt)
+        groups.append(group)
+    return groups
+
+
+def shell_tree(me, group):
+    """A BVH for one shell, built once so pair gaps stay cheap."""
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(me)
+        member = set(group)
+        drop = [f for f in bm.faces if not all(v.index in member for v in f.verts)]
+        if drop:
+            bmesh.ops.delete(bm, geom=drop, context="FACES")
+        if not bm.faces:
+            return None
+        return BVHTree.FromBMesh(bm)
+    finally:
+        bm.free()
+
+
+def pair_gap(me, tree_a, group_b, tree_b=None):
+    """Surface gap from shell B to shell A; 0 when the two interpenetrate.
+
+    find_nearest returns an unsigned distance, so a block seated *inside*
+    its host reports the distance to the host's skin rather than zero. The
+    springing joint is an overlap by design, so without the overlap test
+    first this reads a 5 mm gap where there is none.
+    """
+    if tree_a is None:
+        return 99.0
+    if tree_b is not None and tree_a.overlap(tree_b):
+        return 0.0
+    best = 99.0
+    for i in group_b:
+        hit = tree_a.find_nearest(me.vertices[i].co)
+        if hit[0] is None:
+            continue
+        best = min(best, hit[3])
+    return best
+
+
+def classify(me):
+    """Name the shells: pier courses by side, voussoirs by crown angle."""
+    out = {"left": [], "right": [], "voussoirs": []}
+    for g in shells(me):
+        pts = [me.vertices[i].co for i in g]
+        zmin = min(p.z for p in pts)
+        zmax = max(p.z for p in pts)
+        xc = sum(p.x for p in pts) / len(pts)
+        rec = {"g": g, "zmin": zmin, "zmax": zmax, "xc": xc, "pts": pts}
+        if zmax <= H_SPRING + 1e-6:
+            (out["left"] if xc < 0 else out["right"]).append(rec)
+        else:
+            out["voussoirs"].append(rec)
+    out["left"].sort(key=lambda r: r["zmin"])
+    out["right"].sort(key=lambda r: r["zmin"])
+    # Around the arch, not left to right: atan2 about the springing centre
+    # orders the voussoirs even when a falsifier has flattened them.
+    out["voussoirs"].sort(
+        key=lambda r: math.atan2(
+            max(1e-9, sum(p.z for p in r["pts"]) / len(r["pts"]) - (H_SPRING - SPRING_BITE)),
+            sum(p.x for p in r["pts"]) / len(r["pts"]),
+        ),
+        reverse=True,
+    )
+    return out
+
+
+def arch_audit(me):
+    """Circle fit, mortar band, keystone proudness, pier supports."""
+    parts = classify(me)
+    cz = H_SPRING - SPRING_BITE
+
+    # Intrados circle fit, measured on the vertices of faces that actually
+    # face the springing centre. Selecting by radius alone would sweep in
+    # the chamfer vertices sitting one bevel-width out on each radial face,
+    # and report a 6 mm error on a true arch.
+    member = {}
+    for rec in parts["voussoirs"]:
+        for i in rec["g"]:
+            member[i] = True
+    radii = []
+    angles = []
+    for poly in me.polygons:
+        if not all(i in member for i in poly.vertices):
+            continue
+        c = poly.center
+        rc = math.hypot(c.x, c.z - cz)
+        if rc > R_IN + T_V * 0.5:
+            continue
+        inward = Vector((-c.x, 0.0, -(c.z - cz)))
+        if inward.length < 1e-9:
+            continue
+        inward.normalize()
+        n = poly.normal
+        if abs(n.y) > 0.2:
+            continue
+        if Vector((n.x, 0.0, n.z)).normalized().dot(inward) < 0.9:
+            continue
+        for i in poly.vertices:
+            p = me.vertices[i].co
+            radii.append(math.hypot(p.x, p.z - cz))
+            angles.append(math.atan2(p.z - cz, p.x))
+    arc_dev = max((abs(r - R_IN) for r in radii), default=99.0)
+    arc_span = (max(angles) - min(angles)) if angles else 0.0
+
+    # Mortar joints, surface to surface, per adjacent pair around the arch.
+    vs = parts["voussoirs"]
+    trees = [shell_tree(me, rec["g"]) for rec in vs]
+    gaps = []
+    for i in range(len(vs) - 1):
+        gaps.append(
+            min(
+                pair_gap(me, trees[i], vs[i + 1]["g"], trees[i + 1]),
+                pair_gap(me, trees[i + 1], vs[i]["g"], trees[i]),
+            )
+        )
+    gap_min = min(gaps) if gaps else 99.0
+    gap_max = max(gaps) if gaps else 99.0
+
+    # Keystone: the voussoir that stands proud of the wall face.
+    wall_half = WALL_Y / 2.0
+    prouds = [
+        max(abs(p.y) for p in rec["pts"]) - wall_half for rec in vs
+    ]
+    key_proud = max(prouds) if prouds else 0.0
+
+    # Springing joints must overlap the pier head, not rest on it.
+    spring_gap = 0.0
+    for side in ("left", "right"):
+        if not parts[side] or not vs:
+            spring_gap = 99.0
+            continue
+        head = parts[side][-1]
+        tree = shell_tree(me, head["g"])
+        idx = min(
+            range(len(vs)),
+            key=lambda k: abs(
+                sum(p.x for p in vs[k]["pts"]) / len(vs[k]["pts"]) - head["xc"]
+            ),
+        )
+        spring_gap = max(
+            spring_gap, pair_gap(me, tree, vs[idx]["g"], trees[idx])
+        )
+
+    piers = 0
+    pier_z = 99.0
+    for side in ("left", "right"):
+        if parts[side]:
+            piers += 1
+            pier_z = min(pier_z, 99.0)
+    pier_worst = max(
+        (parts[s][0]["zmin"] for s in ("left", "right") if parts[s]), default=99.0
+    )
+
+    return {
+        "n_vous": len(vs),
+        "n_left": len(parts["left"]),
+        "n_right": len(parts["right"]),
+        "arc_dev": arc_dev,
+        "arc_span": arc_span,
+        "gap_min": gap_min,
+        "gap_max": gap_max,
+        "key_proud": key_proud,
+        "spring_gap": spring_gap,
+        "piers": piers,
+        "pier_worst": pier_worst,
+        "gaps": [round(g, 5) for g in gaps],
+    }
+
+
+def opening_audit(me):
+    """Clear opening width at mid-pier height, measured from vertices."""
+    # Band stops clear of the impost, which projects inboard of the pier
+    # face and is not part of the clear opening.
+    zc = H_SPRING * 0.5
+    half = H_SPRING * 0.40
+    xs_left = [
+        v.co.x for v in me.vertices
+        if v.co.x < 0 and abs(v.co.z - zc) < half
+    ]
+    xs_right = [
+        v.co.x for v in me.vertices
+        if v.co.x > 0 and abs(v.co.z - zc) < half
+    ]
+    if not xs_left or not xs_right:
+        return 0.0
+    return min(xs_right) - max(xs_left)
+
+
+def add_stray_vert(me):
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(me)
+        bm.verts.new((0.0, 0.0, H_SPRING * 0.5))
+        bm.to_mesh(me)
+        me.update()
+    finally:
+        bm.free()
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+    mesh = obj.data.copy()
+    lod = bpy.data.objects.new(name, mesh)
+    lod.matrix_world = obj.matrix_world.copy()
+    bpy.context.scene.collection.objects.link(lod)
+    if not skip_decimate and 0.0 < ratio < 1.0:
+        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+        mod.decimate_type = "COLLAPSE"
+        mod.ratio = ratio
+    return lod
+
+
+def convex_hull_collider(obj, name):
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(obj.data)
+        result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+        for key in ("geom_interior", "geom_unused"):
+            geom = result.get(key) or []
+            if geom:
+                bmesh.ops.delete(bm, geom=geom, context="VERTS")
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    collider = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(collider)
+    collider.matrix_world = obj.matrix_world.copy()
+    return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+    if not obj.data.uv_layers:
+        return None, None
+    img = bpy.data.images.new("ArchNrm", size, size, alpha=True, float_buffer=False)
+    img.colorspace_settings.name = "Non-Color"
+    nodes = target_mat.node_tree.nodes
+    tex = nodes.new("ShaderNodeTexImage")
+    tex.image = img
+    nodes.active = tex
+    tex.select = True
+    obj.active_material_index = ASHLAR_IDX
+    return img, tex
+
+
+def bake_normal(high, low):
+    scene = bpy.context.scene
+    scene.render.engine = "CYCLES"
+    scene.cycles.device = "CPU"
+    scene.cycles.samples = 1
+    scene.cycles.use_denoising = False
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    high.select_set(True)
+    low.select_set(True)
+    bpy.context.view_layer.objects.active = low
+    return bpy.ops.object.bake(
+        type="NORMAL",
+        use_selected_to_active=True,
+        cage_extrusion=CAGE_EXTRUSION,
+        use_cage=False,
+        normal_space="TANGENT",
+        margin=4,
+        margin_type="ADJACENT_FACES",
+        use_clear=True,
+        target="IMAGE_TEXTURES",
+    )
+
+
+def export_unity(path, objects):
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    for ob in objects:
+        ob.select_set(True)
+    bpy.context.view_layer.objects.active = objects[0]
+    bpy.ops.export_scene.gltf(
+        filepath=path,
+        use_selection=True,
+        export_yup=True,
+        export_apply=True,
+        export_draco_mesh_compression_enable=False,
+        export_animations=False,
+    )
+
+
+def check(
+    skip_decimate,
+    lift_z=False,
+    stray_vert=False,
+    float_pier=False,
+    sink_keystone=False,
+    wide_mortar=False,
+    off_circle=False,
+):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    flags = dict(
+        off_circle=off_circle,
+        sink_keystone=sink_keystone,
+        wide_mortar=wide_mortar,
+        float_pier=float_pier,
+    )
+    nothing = (None,) * 5
+    low = build_arch_mesh("ArchLow", **flags)
+    high = build_arch_mesh("ArchHigh", **flags)
+    ashlar = principled(
+        "ArchAshlar", (0.300, 0.219, 0.126, 1.0), 0.0, 0.80,
+        noise_scale=14.0, wear=(0.168, 0.113, 0.055, 1.0),
+    )
+    dressed = principled(
+        "ArchDressed", (0.452, 0.348, 0.205, 1.0), 0.0, 0.62,
+        noise_scale=8.0, wear=(0.262, 0.196, 0.108, 1.0),
+    )
+    assign_slots(low, ashlar, dressed)
+    assign_slots(high, ashlar, dressed)
+    if stray_vert:
+        add_stray_vert(low.data)
+    if lift_z:
+        for v in low.data.vertices:
+            v.co.z += LIFT_Z
+        low.data.update()
+        bpy.context.view_layer.update()
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return (fail("arch mesh did not build", 3),) + nothing
+
+    base_tris = triangle_count(low.data)
+    mats = [s for s in low.data.materials if s is not None]
+    nmat = len(mats)
+    distinct = len({id(s) for s in mats})
+    idx_counts = {}
+    for poly in low.data.polygons:
+        idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1
+    u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data)
+    bb = world_bbox(low)
+    size_x, size_y, size_z = bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2]
+
+    img, tex = setup_bake_image(low, ashlar)
+    if img is None:
+        return (fail("arch has no UV layer", 3),) + nothing
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "ArchLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "ArchLOD2", LOD2_TARGET, skip_decimate)
+    bpy.context.view_layer.update()
+    lod1_tris = evaluated_triangle_count(lod1)
+    lod2_tris = evaluated_triangle_count(lod2)
+    r1 = lod1_tris / base_tris if base_tris else 0.0
+    r2 = lod2_tris / base_tris if base_tris else 0.0
+
+    col_src = build_arch_mesh("ArchColSrc", **flags)
+    collider = convex_hull_collider(col_src, "ArchCollider")
+    bpy.data.objects.remove(col_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(), f"bdt_stone_archway_{os.getpid()}.glb"
+    )
+    if os.path.exists(export_path):
+        os.remove(export_path)
+    export_unity(export_path, [low, collider])
+    export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+    hyg = hygiene_audit(low.data)
+    zf = zfight_pairs(low.data)
+    ar = arch_audit(low.data)
+    opening = opening_audit(low.data)
+
+    print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
+    print(f"measured mat_index_counts={idx_counts}")
+    print(
+        f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+        f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
+    )
+    print(
+        f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+        f"overlap={overlap:.6f} nfaces={nfaces}"
+    )
+    print(
+        f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+        f"outer={OUTER_SIZE} zmin={bb[2]:.5f} opening={opening:.4f}"
+    )
+    print(
+        f"measured collider_tris={col_tris} bake={bake_result} "
+        f"bake_has_data={img.has_data} export_bytes={export_size}"
+    )
+    print(
+        f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} "
+        f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} "
+        f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}"
+    )
+    print(
+        f"measured arch vous={ar['n_vous']} courses={ar['n_left']}/{ar['n_right']} "
+        f"arc_dev={ar['arc_dev']:.5f} arc_span={math.degrees(ar['arc_span']):.1f}deg "
+        f"gap=({ar['gap_min']:.5f},{ar['gap_max']:.5f}) "
+        f"key_proud={ar['key_proud']:.5f} spring_gap={ar['spring_gap']:.5f} "
+        f"pier_worst={ar['pier_worst']:.5f} gaps={ar['gaps']}"
+    )
+
+    if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+        return (fail(
+            f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", 4
+        ),) + nothing
+    if nmat != MATERIAL_COUNT or distinct != MATERIAL_COUNT:
+        return (fail(
+            f"material slots {nmat} distinct {distinct} != {MATERIAL_COUNT}", 5
+        ),) + nothing
+    if idx_counts.get(DRESSED_IDX, 0) < DRESSED_FACES_MIN:
+        return (fail(
+            f"dressed faces {idx_counts.get(DRESSED_IDX, 0)} < {DRESSED_FACES_MIN}", 5
+        ),) + nothing
+    if idx_counts.get(ASHLAR_IDX, 0) < ASHLAR_FACES_MIN:
+        return (fail(
+            f"ashlar faces {idx_counts.get(ASHLAR_IDX, 0)} < {ASHLAR_FACES_MIN}", 5
+        ),) + nothing
+    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+        return (fail(
+            f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", 6
+        ),) + nothing
+    if overlap > UV_OVERLAP_MAX:
+        return (fail(f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7),) + nothing
+    if (
+        abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+        or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+        or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+    ):
+        return (fail(
+            f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) off outer {OUTER_SIZE}", 8
+        ),) + nothing
+    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+        return (fail(
+            f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+            "(--skip-decimate is the designed fail)", 9
+        ),) + nothing
+    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+        return (fail(
+            f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", 9
+        ),) + nothing
+    if col_tris > COLLIDER_TRIS_MAX:
+        return (fail(f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", 11),) + nothing
+    if bake_result != {"FINISHED"} or not img.has_data:
+        return (fail(
+            f"bake failed result={bake_result} has_data={img.has_data}", 12
+        ),) + nothing
+    if export_size <= 0:
+        return (fail("export file missing or empty", 13),) + nothing
+    if (
+        hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"]
+        or hyg["doubles"] or hyg["ngons"] or zf
+    ):
+        return (fail(
+            f"hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} "
+            f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} "
+            f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf} "
+            "(--stray-vert is the designed fail)", 15
+        ),) + nothing
+    if abs(bb[2]) > ZMIN_EPS:
+        return (fail(
+            f"zmin {bb[2]:.6f} not within {ZMIN_EPS} of 0 "
+            "(--lift-z is the designed fail)", 16
+        ),) + nothing
+    if ar["piers"] < PIERS_MIN or ar["pier_worst"] > PIER_Z_MAX:
+        return (fail(
+            f"pier supports {ar['piers']} worst base z={ar['pier_worst']:.5f} "
+            "(--float-pier is the designed fail)", 16
+        ),) + nothing
+    if ar["key_proud"] < KEY_PROUD_MIN or ar["spring_gap"] > SPRING_GAP_MAX:
+        return (fail(
+            f"keystone proud {ar['key_proud']:.5f} < {KEY_PROUD_MIN} or "
+            f"springing gap {ar['spring_gap']:.5f} > {SPRING_GAP_MAX} "
+            "(--sink-keystone is the designed fail)", 17
+        ),) + nothing
+    if not (MORTAR_MIN <= ar["gap_min"] and ar["gap_max"] <= MORTAR_MAX):
+        return (fail(
+            f"mortar joints ({ar['gap_min']:.5f}, {ar['gap_max']:.5f}) outside "
+            f"[{MORTAR_MIN}, {MORTAR_MAX}] (--wide-mortar is the designed fail)", 18
+        ),) + nothing
+    if ar["arc_dev"] > ARC_TOL or ar["arc_span"] < ARC_SPAN_MIN:
+        return (fail(
+            f"intrados off circle by {ar['arc_dev']:.5f} > {ARC_TOL} or spans "
+            f"{math.degrees(ar['arc_span']):.1f} deg < "
+            f"{math.degrees(ARC_SPAN_MIN):.1f} "
+            "(--off-circle is the designed fail)", 19
+        ),) + nothing
+    if abs(opening - OPENING_W) > SPAN_TOL:
+        return (fail(
+            f"clear opening {opening:.4f} off {OPENING_W}", 19
+        ),) + nothing
+    return 0, low, high, ashlar, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nrm.inputs["Strength"].default_value = 1.0
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
+
+
+def render_still(low, ashlar, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(ashlar, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    low.rotation_euler.z = math.radians(-17.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=20.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Floor")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 9.0, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02, 0.021, 0.025, 1.0,
+    )
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", (-4.0, -4.6, 6.0), 640.0, 5.0, (1.0, 0.96, 0.90), (42, 0, -40))
+    light("Fill", (4.6, -3.2, 2.0), 108.0, 9.0, (0.75, 0.85, 1.00), (70, 0, 54))
+    light("Rim", (-2.2, 3.8, 3.4), 420.0, 3.5, (0.60, 0.78, 1.00), (-60, 0, 202))
+    light("Wedge", (1.35, 4.4, 2.35), 640.0, 6.0, (1.0, 0.70, 0.36), (-94, 0, 194))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (3.46, -5.18, 2.05)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, STACK_H * 0.48)
+    scene.collection.objects.link(aim)
+    con = cam.constraints.new("TRACK_TO")
+    con.target = aim
+    con.track_axis = "TRACK_NEGATIVE_Z"
+    con.up_axis = "UP_Y"
+    scene.camera = cam
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 32
+        scene.cycles.device = "CPU"
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = (
+        "WEBP" if path.lower().endswith(".webp") else "PNG"
+    )
+    if path.lower().endswith(".webp"):
+        scene.render.image_settings.quality = 90
+    scene.render.filepath = path
+    scene.view_settings.view_transform = "Standard"
+
+    fcode = gallery_framing.check_framing(
+        scene, cam, hero=[low], elements=[low], stage=[floor, wall],
+    )
+    if fcode:
+        return fcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        return fail("render produced no file", 14)
+    return 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None)
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+    p.add_argument("--skip-decimate", action="store_true")
+    p.add_argument("--lift-z", action="store_true")
+    p.add_argument("--stray-vert", action="store_true")
+    p.add_argument("--float-pier", action="store_true")
+    p.add_argument("--sink-keystone", action="store_true")
+    p.add_argument("--wide-mortar", action="store_true")
+    p.add_argument("--off-circle", action="store_true")
+    args = p.parse_args(argv)
+
+    code, low, _high, ashlar, tex, _col = check(
+        args.skip_decimate,
+        lift_z=args.lift_z,
+        stray_vert=args.stray_vert,
+        float_pier=args.float_pier,
+        sink_keystone=args.sink_keystone,
+        wide_mortar=args.wide_mortar,
+        off_circle=args.off_circle,
+    )
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(low, ashlar, tex, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    print("stone-archway OK")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as e:
+        traceback.print_exc()
+        print(f"FATAL: {e}", file=sys.stderr)
+        sys.exit(1)
+
+
+
+ +
+
+ generated from examples/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/showcase/crate-stack/README.md b/showcase/crate-stack/README.md new file mode 100644 index 00000000..166d9069 --- /dev/null +++ b/showcase/crate-stack/README.md @@ -0,0 +1,185 @@ +# crate-stack + +![Three shipping crates stacked, each yawed and offset differently](preview.webp) + +A stack of three shipping crates. **A showcase piece, not an example** — it +witnesses no API contract. It asserts that generated geometry meets declared +asset budgets, recomputed from the finished mesh. + +## What it composes + +One `build_crate` generator, invoked three times with a per-instance seed, +then the shipped pipeline: + +| Shipped content | Used for | +| --- | --- | +| `skills/mesh-editing-and-bmesh` | box, bevel and UV construction in one `bmesh` | +| `skills/procedural-materials-and-shaders` | two Principled materials with noise-driven wear | +| `skills/bake-high-to-low` | Cycles tangent-space normal bake, high onto low | +| `skills/engine-export-presets` | Unity glTF (`export_yup=True`) | +| `skills/depsgraph-and-evaluated-data` | evaluated triangle counts for the LOD ratios | +| `snippets/decimate_to_budget.py` | LOD1 / LOD2 COLLAPSE chain | +| `snippets/convex_hull_collider.py` | convex collider | +| `snippets/lod_chain.py` | LOD naming and ratio pattern | +| `examples/mesh-hygiene-audit` | hygiene combinatorics (copied, not imported) | + +## Reuse with variation + +The point of the piece. Each crate comes out of the same generator; what +differs is the seed. That seed drives the yaw, the plank widths, and the +board heights, so the three read as three of the same design rather than one +model pasted three times. + +True instancing — three objects sharing one mesh datablock — and +per-instance geometry variation are mutually exclusive. This piece takes the +variation, and the shipped asset is the flattened single mesh an engine would +receive. The generator is reused; the geometry is not. + +Two independent random streams keep that honest. The **design** stream +(yaw, plank widths) is what `--same-seed` collapses. The **placement** stream +(lateral offsets) stays per-instance either way, so a falsified stack has the +same footprint as a good one and fails on the variation budget rather than on +the bounding box. + +## Budgets + +Declared in the script as named constants, recomputed from the generated +mesh. Measured values below are from Blender 5.2.1; see the table at the end +for the 4.5.11 / 5.1.2 figures. + +| Budget | Band | Measured | +| --- | --- | --- | +| Base triangles | 3200–7200 | 4380 | +| LOD1 ratio | 0.32–0.62 | 0.5000 | +| LOD2 ratio | 0.10–0.35 | 0.2005 (5.2) / 0.2199 (4.5, 5.1) | +| Material slots | exactly 2, distinct | 2 | +| Iron faces | ≥ 120 | 216 | +| Timber faces | ≥ 600 | 2436 | +| UV bounds | inside 0..1 | (0.0008, 0.0008)–(0.9992, 0.9992) | +| UV AABB overlap | ≤ 1e-5 | 0.000000 | +| Outer AABB | 0.658 × 0.524 × 0.704 m ± 0.020 | 0.6582 × 0.5235 × 0.7040 | +| Crate body footprint | 0.585 × 0.425 m ± 0.015, in the crate's own frame | 0.5846 × 0.4246 (all three) | +| Collider triangles | ≤ 260 | 202 | +| Normal bake | `{'FINISHED'}` with image data | `{'FINISHED'}`, `has_data=True` | +| glTF export | file written, non-empty | ~340 kB | +| Hygiene | all zero | loose 0/0, non-manifold 0, zero-area 0, doubles 0, n-gons 0, coplanar disjoint pairs 0 | +| Grounded AABB | \|zmin\| ≤ 1e-4 | 0.00000 | +| Ground runners | 3 named supports, each zmin ≤ 1e-3 | 3 at 0.00000 | +| Crate-to-crate seat | both seats, surface gap ≤ 0.0015 m | 0.00000 (overlapping) | +| Yaw band | each \|yaw\| in 0.040–0.192 rad | 0.0681, 0.0943, 0.1093 | +| Per-instance variation | yaw spread ≥ 0.020 rad, plank spread ≥ 0.0008 m | 0.0262 rad, 0.00205 m | + +Real-world size: each crate is 0.58 × 0.42 m at the timber and 0.585 × 0.425 m +over the corner iron, 0.244 m tall including runners and lid. Three stacked +come to 0.70 m — knee height, wider than tall. + +### Why the footprint is measured in the crate's own frame + +The stack AABB is the union of three yawed boxes, so a crate could drift to +any size underneath it and the outer budget would not notice. Each crate's +footprint is therefore measured after un-rotating by the yaw that crate was +*measured* to have, not the yaw it was built with. All three land on +0.5846 × 0.4246 m exactly, which is what makes the un-rotation trustworthy. + +### Why parts are classified by principal axes + +Every part is an axis-aligned box in its crate's frame and then rotated about +Z. A world-AABB shape filter measures the rotated bounding box, not the part: +a 0.554 m board yawed 0.09 rad reports 0.061 m of depth instead of its +0.013 m thickness, and every filter keyed to thickness silently matches +nothing. `xy_principal` recovers each box's own axes, and hands back the yaw +as a by-product — which is where the variation budget's yaw numbers come from. + +### Why stack levels come from the runners + +Binning parts against the declared stack pitch put a lid — which sits 8 mm +below the next crate's base — on the wrong level the moment a falsifier +shifted a crate, and the seat budget then compared a crate against itself. +The bands are clustered from the runner heights the mesh actually has, so +they follow the geometry including when a falsifier moves it. + +## Determinism + +Fixed seed 41; no unseeded randomness. Identical geometry across runs on one +binary and across 4.5.11, 5.1.2 and 5.2.1 — every measured value above is +byte-identical on the three **except** LOD2, where `DECIMATE COLLAPSE` +produces 878 triangles on 5.2 and 934 on 4.5 and 5.1. That is why the LOD +gate is a ratio band (0.10–0.35, measured 0.2005 and 0.2199) and not an exact +count. + +## Falsifiers + +Each breaks one pipeline stage so a **named** budget fails. All six were run +on 4.5.11, 5.1.2 and 5.2.1 and produced the same exit code on all three. + +| Flag | Breaks | Exit | +| --- | --- | --- | +| `--skip-decimate` | drops the DECIMATE modifiers, LOD1 ratio goes to 1.0000 | 9 | +| `--stray-vert` | adds one loose vertex *inside* the silhouette, so hygiene catches it rather than the bounding box | 15 | +| `--lift-z` | lifts the whole mesh 50 mm off the floor | 16 | +| `--short-skids` | floats **one** of the three ground runners 12 mm; the other two still ground the AABB, so only the named-support budget sees it | 16 | +| `--float-stack` | lifts the top crate 9 mm clear of the lid below, opening a 34 mm seat gap | 18 | +| `--same-seed` | gives all three crates the same design seed; yaw spread and plank spread both go to 0 | 20 | + +## Exit codes + +File-local and sequential. `9` is a valid check code; there is no rule +against it. `1` is the FATAL wrapper — a crash, never a named check. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Mesh did not build, or has no UV layer | +| 4 | Base triangle count outside band | +| 5 | Material slots, or a material's face floor | +| 6 | UVs outside 0..1 | +| 7 | UV AABB overlap above tolerance | +| 8 | Outer AABB off declared size | +| 9 | LOD1 or LOD2 ratio outside band (`--skip-decimate`) | +| 10 | Framing gate (`examples/gallery_framing.py`, render path only) | +| 11 | Collider triangles above ceiling | +| 12 | Normal bake failed or produced no image data | +| 13 | glTF export missing or empty | +| 14 | `--output` produced no file | +| 15 | Mesh hygiene (`--stray-vert`) | +| 16 | Grounded zmin, or a named ground runner floating (`--lift-z`, `--short-skids`) | +| 18 | Crate-to-crate seat gap (`--float-stack`) | +| 19 | Crate body footprint off declared size | +| 20 | Per-instance variation collapsed (`--same-seed`) | + +`17` is unused here: this piece has no diagonal member. `15`–`19` are +reserved across showcase pieces for the hygiene family, so the numbering +skips rather than reuses. + +## Run it + +```bash +# Budget check, no render. ~1.0 s on 4.5/5.1, ~1.2 s on 5.2. +blender --background --python crate_stack.py -- + +# Falsifier: the three crates become copies. Must exit 20. +blender --background --python crate_stack.py -- --same-seed + +# Falsifier: the top crate floats off the lid below. Must exit 18. +blender --background --python crate_stack.py -- --float-stack + +# Render the gallery still (EEVEE; --engine cycles on a GPU-less host). +blender --background --python crate_stack.py -- --output stack.webp +``` + +Smoke runs the check-only path. It does not pass `--output` or any falsifier. + +## Cross-version measurements + +| Value | 4.5.11 | 5.1.2 | 5.2.1 | +| --- | --- | --- | --- | +| Base triangles | 4380 | 4380 | 4380 | +| LOD1 tris / ratio | 2190 / 0.5000 | 2190 / 0.5000 | 2190 / 0.5000 | +| LOD2 tris / ratio | 934 / 0.2199 | 934 / 0.2199 | 878 / 0.2005 | +| Outer AABB | 0.6582 × 0.5235 × 0.7040 | same | same | +| Collider tris | 202 | 202 | 202 | +| Yaws (rad) | 0.0943, −0.1093, 0.0681 | same | same | +| Seat gap | 0.00000 | 0.00000 | 0.00000 | +| Check wall-clock | ~0.99 s | ~1.01 s | ~1.20 s | diff --git a/showcase/crate-stack/crate_stack.py b/showcase/crate-stack/crate_stack.py new file mode 100644 index 00000000..1e1c848f --- /dev/null +++ b/showcase/crate-stack/crate_stack.py @@ -0,0 +1,1405 @@ +"""Game-ready stack of three shipping crates — a showcase piece, not an example. + +Asserts budget conformance of a procedural crate *stack*: one crate +generator invoked three times with a per-instance seed, each instance +yawed and seated on the lid of the one below, then run through the +shipped pipeline (bmesh construction, UVs, two materials, high-to-low +normal bake, LOD chain, convex collider, Unity glTF export). + +What this piece is about is **reuse with variation**. The three crates +come out of a single ``build_crate`` call each, differing only by the +seed handed to it. That seed drives the yaw, the lean, and the plank +widths, so the crates read as three of the same design rather than one +model copied three times. True instancing (three objects sharing one +mesh datablock) and per-instance geometry variation are mutually +exclusive; this piece takes the variation and says so, and the shipped +asset is the flattened single mesh a game engine would receive. + +Budgets are declared below and recomputed from the generated result. +They are not API-contract witnesses. Each falsifier violates one named +budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh +hygiene, ``--lift-z`` grounded zmin, ``--short-skids`` the named ground +supports, ``--float-stack`` the crate-to-crate seat, ``--same-seed`` the +per-instance variation budget. + +Fixed seed 41. DECIMATE COLLAPSE triangle counts are not byte-identical +across Blender versions — the LOD gate is a ratio band, not an exact +count. + + blender --background --python crate_stack.py -- + blender --background --python crate_stack.py -- --same-seed + blender --background --python crate_stack.py -- --output stack.png +""" +import argparse +import math +import os +import random +import sys +import tempfile +import traceback + +import bmesh +import bpy +from mathutils import Matrix, Vector +from mathutils.bvhtree import BVHTree + +_REPO = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) +) +sys.path.insert(0, os.path.join(_REPO, "examples")) +sys.dont_write_bytecode = True +import gallery_framing # noqa: E402 + +# One crate: 0.58 x 0.42 at the posts, 0.244 tall including skids and lid. +# Three stacked come to knee height, wider than they are tall. +CRATE_X = 0.58 +CRATE_Y = 0.42 +POST = 0.034 +SKID_H = 0.024 +SKID_W = 0.052 +SLAT_T = 0.013 +RAIL_H = 0.032 +TENON = 0.008 +IRON_T = 0.0035 +IRON_WRAP = 0.052 +# Straps bite into the timber and stop short of the rail top. Sitting them +# flush made the plate corner and the post corner the same point, which is +# a welded double, not a fixing. +IRON_BITE = 0.0012 +IRON_DROP = 0.005 +# The lid bites down onto the rails for the same reason: a lid resting +# exactly on the rail top shares that plane and those corner vertices. +LID_BITE = 0.004 +BODY_H = 0.205 +N_SIDE = 3 +N_END = 2 +N_FLOOR = 3 +N_LID = 3 +SLAT_JITTER = 0.20 + +N_CRATES = 3 +STACK_SEED = 41 +# Each crate bites this far into the lid of the one below, so the seat is +# an overlap rather than two coincident faces (which would z-fight). +STACK_BITE = 0.005 +YAW_MIN = 0.060 +YAW_MAX = 0.170 +# Crates also step sideways. Three boxes stacked dead-centre read as a +# filing cabinet however much they are yawed. +OFFSET_MAX = 0.030 +YAW_SPREAD_MIN = 0.020 +WIDTH_SPREAD_MIN = 0.0008 + +CRATE_H = SKID_H + BODY_H + SLAT_T - LID_BITE +STACK_H = N_CRATES * CRATE_H - (N_CRATES - 1) * STACK_BITE + +BBOX_TOL = 0.020 +OUTER_SIZE = (0.658, 0.524, 0.704) +# The crate body over its corner iron, measured in the crate's own frame. +BODY_X = CRATE_X + 2.0 * (IRON_T - IRON_BITE) +BODY_Y = CRATE_Y + 2.0 * (IRON_T - IRON_BITE) +CRATE_TOL = 0.015 + +BASE_TRIS_MIN = 3200 +BASE_TRIS_MAX = 7200 +LOD1_RATIO_MIN = 0.32 +LOD1_RATIO_MAX = 0.62 +LOD2_RATIO_MIN = 0.10 +LOD2_RATIO_MAX = 0.35 +LOD1_TARGET = 0.50 +LOD2_TARGET = 0.22 +MATERIAL_COUNT = 2 +UV_EPS = 1e-4 +UV_OVERLAP_MAX = 1e-5 +COLLIDER_TRIS_MAX = 260 +BAKE_RES = 256 +CAGE_EXTRUSION = 0.08 +METAL_FACES_MIN = 120 +WOOD_FACES_MIN = 600 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +ZFIGHT_EPS = 1e-4 +ZFIGHT_COS = 0.999 +LIFT_Z = 0.05 +SKID_Z_MAX = 1e-3 +GROUND_SKIDS_MIN = 3 +STACK_SEAT_MAX = 0.0015 +FLOAT_LIFT = 0.009 +SHORT_SKID_LIFT = 0.012 + +WOOD_IDX = 0 +METAL_IDX = 1 + + +def eevee_engine_id(): + """EEVEE id: 'BLENDER_EEVEE' on 5.0+, 'BLENDER_EEVEE_NEXT' on 4.2-4.5.""" + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"FAIL[{code}]: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + deps = bpy.context.evaluated_depsgraph_get() + ev = obj.evaluated_get(deps) + mesh = ev.to_mesh() + try: + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + finally: + ev.to_mesh_clear() + + +def add_box(bm, loc, scale, mat_idx, xform=None): + """Axis-aligned box in the crate frame, optionally placed by *xform*.""" + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + for v in verts: + p = Vector( + ( + v.co.x * scale[0] + loc[0], + v.co.y * scale[1] + loc[1], + v.co.z * scale[2] + loc[2], + ) + ) + v.co = xform @ p if xform is not None else p + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def pack_uvs(bm, margin=0.08): + uv = bm.loops.layers.uv.new("UVMap") + faces = list(bm.faces) + n = len(faces) + cols = max(1, math.ceil(math.sqrt(n))) + rows = max(1, math.ceil(n / cols)) + cell_w = 1.0 / cols + cell_h = 1.0 / rows + pad_u = margin * cell_w * 0.5 + pad_v = margin * cell_h * 0.5 + usable_w = cell_w - 2.0 * pad_u + usable_h = cell_h - 2.0 * pad_v + for i, face in enumerate(faces): + col = i % cols + row = i // cols + nrm = face.normal + ax, ay, az = abs(nrm.x), abs(nrm.y), abs(nrm.z) + coords = [] + for loop in face.loops: + co = loop.vert.co + if az >= ax and az >= ay: + coords.append((co.x, co.y)) + elif ax >= ay: + coords.append((co.y, co.z)) + else: + coords.append((co.x, co.z)) + xs = [c[0] for c in coords] + ys = [c[1] for c in coords] + minx, maxx = min(xs), max(xs) + miny, maxy = min(ys), max(ys) + dx = max(maxx - minx, 1e-8) + dy = max(maxy - miny, 1e-8) + origin_u = col * cell_w + pad_u + origin_v = row * cell_h + pad_v + for loop, (x, y) in zip(face.loops, coords): + loop[uv].uv = ( + origin_u + (x - minx) / dx * usable_w, + origin_v + (y - miny) / dy * usable_h, + ) + + +def _span_layout(count, span, rng, gap=0.009): + """Uneven plank widths that still fill *span* with named gaps.""" + raw = [1.0 + rng.uniform(-SLAT_JITTER, SLAT_JITTER) for _ in range(count)] + s = sum(raw) + usable = span - gap * (count + 1) + widths = [usable * r / s for r in raw] + pos = -span / 2.0 + gap + centres = [] + for w in widths: + centres.append(pos + w / 2.0) + pos += w + gap + return centres, widths + + +def build_crate(bm, base_z, yaw, rng, offset=(0.0, 0.0), short_skids=False): + """One crate, placed with its skid underside at *base_z* and yawed. + + Called once per stack level. Everything that differs between levels + comes out of *rng* and *yaw*; the geometry recipe itself is shared. + """ + xform = Matrix.Translation((offset[0], offset[1], 0.0)) @ Matrix.Rotation( + yaw, 4, "Z" + ) + hx = CRATE_X / 2.0 - POST / 2.0 + hy = CRATE_Y / 2.0 - POST / 2.0 + skid_z0 = base_z + (SHORT_SKID_LIFT if short_skids else 0.0) + deck_z = base_z + SKID_H + top_z = deck_z + BODY_H + wood = [] + + # Two runners under the posts. These are the named ground supports on + # the bottom crate and the seat feet on the ones above. + # Three runners, not two: two leave a slot you can see daylight + # through between stacked crates, and a centre bearer is what a crate + # this wide would actually carry. + for si, sy in enumerate((-hy, 0.0, hy)): + z0 = skid_z0 if (short_skids and si == 0) else base_z + wood.extend( + add_box( + bm, + (0.0, sy, z0 + SKID_H / 2.0), + (CRATE_X, SKID_W, SKID_H), + WOOD_IDX, + xform, + ) + ) + # Corner posts, tenoned down into the skid line so no shared plane. + post_h = BODY_H + TENON + for sxn in (-1.0, 1.0): + for syn in (-1.0, 1.0): + wood.extend( + add_box( + bm, + (sxn * hx, syn * hy, deck_z - TENON + post_h / 2.0), + (POST, POST, post_h), + WOOD_IDX, + xform, + ) + ) + # Top rails and bottom sills, both tenoned into the posts. + for zc, h in ((top_z - RAIL_H / 2.0, RAIL_H), + (deck_z - TENON + (RAIL_H + TENON) / 2.0, RAIL_H + TENON)): + for syn in (-1.0, 1.0): + wood.extend( + add_box( + bm, + (0.0, syn * hy, zc), + (CRATE_X - POST + 2.0 * TENON, POST, h), + WOOD_IDX, + xform, + ) + ) + for sxn in (-1.0, 1.0): + wood.extend( + add_box( + bm, + (sxn * hx, 0.0, zc), + (POST, CRATE_Y - POST + 2.0 * TENON, h), + WOOD_IDX, + xform, + ) + ) + + floor_c, floor_w = _span_layout(N_FLOOR, CRATE_X - POST, rng) + for c, w in zip(floor_c, floor_w): + wood.extend( + add_box( + bm, + (c, 0.0, deck_z + SLAT_T / 2.0), + (w, CRATE_Y - POST + TENON, SLAT_T), + WOOD_IDX, + xform, + ) + ) + lid_c, lid_w = _span_layout(N_LID, CRATE_X, rng) + for c, w in zip(lid_c, lid_w): + wood.extend( + add_box( + bm, + (c, 0.0, top_z + SLAT_T / 2.0 - LID_BITE), + (w, CRATE_Y, SLAT_T), + WOOD_IDX, + xform, + ) + ) + + # Side and end boards. Their heights carry the per-instance jitter and + # are what the variation budget recomputes. + side_c, side_w = _span_layout(N_SIDE, BODY_H - RAIL_H * 1.6, rng) + band_z = deck_z + RAIL_H * 0.8 + (BODY_H - RAIL_H * 1.6) / 2.0 + for syn in (-1.0, 1.0): + y = syn * (CRATE_Y / 2.0 - SLAT_T / 2.0 - 0.0012) + for c, w in zip(side_c, side_w): + wood.extend( + add_box( + bm, + (0.0, y, band_z + c), + (CRATE_X - POST + TENON, SLAT_T, w), + WOOD_IDX, + xform, + ) + ) + end_c, end_w = _span_layout(N_END, BODY_H - RAIL_H * 1.6, rng) + for sxn in (-1.0, 1.0): + x = sxn * (CRATE_X / 2.0 - SLAT_T / 2.0 - 0.0012) + for c, w in zip(end_c, end_w): + wood.extend( + add_box( + bm, + (x, 0.0, band_z + c), + (SLAT_T, CRATE_Y - POST + TENON, w), + WOOD_IDX, + xform, + ) + ) + + # L-straps: two plates meeting at the vertical corner edge, proud of + # the post. Two plates, never three overlapping cubes. + strap_z0 = deck_z + 0.010 + strap_h = top_z - IRON_DROP - strap_z0 + zc = strap_z0 + strap_h / 2.0 + # Outer face of each plate, after biting into the post. + px = CRATE_X / 2.0 + IRON_T - IRON_BITE + py = CRATE_Y / 2.0 + IRON_T - IRON_BITE + for sxn in (-1.0, 1.0): + for syn in (-1.0, 1.0): + add_box( + bm, + (sxn * (px - IRON_T / 2.0), syn * (py - IRON_WRAP / 2.0), zc), + (IRON_T, IRON_WRAP, strap_h), + METAL_IDX, + xform, + ) + add_box( + bm, + (sxn * (px - IRON_WRAP / 2.0), syn * (py - IRON_T / 2.0), zc), + (IRON_WRAP, IRON_T, strap_h - 2.0 * IRON_T), + METAL_IDX, + xform, + ) + return wood, side_w + + +def build_stack_mesh( + name, + same_seed=False, + short_skids=False, + float_stack=False, +): + bm = bmesh.new() + wood_verts = [] + try: + base_z = 0.0 + for i in range(N_CRATES): + # Two streams. The design stream is what --same-seed collapses: + # yaw and plank widths, the things that make each crate its own + # instance. The placement stream stays per-instance either way, + # so the falsified stack keeps the same footprint and fails on + # the variation budget rather than on the bounding box. + seed = STACK_SEED if same_seed else STACK_SEED + i * 7 + rng = random.Random(seed) + place = random.Random(STACK_SEED * 31 + i) + sign = 1.0 if i % 2 == 0 else -1.0 + yaw = sign * (YAW_MIN + rng.random() * (YAW_MAX - YAW_MIN - 0.02)) + offset = ( + 0.0 if i == 0 else place.uniform(-OFFSET_MAX, OFFSET_MAX), + 0.0 if i == 0 else place.uniform(-OFFSET_MAX, OFFSET_MAX), + ) + lift = FLOAT_LIFT if (float_stack and i == N_CRATES - 1) else 0.0 + verts, _widths = build_crate( + bm, + base_z + lift, + yaw, + rng, + offset=offset, + short_skids=short_skids and i == 0, + ) + wood_verts.extend(verts) + base_z += CRATE_H - STACK_BITE + + if wood_verts: + edges = list({e for v in wood_verts for e in v.link_edges if v.is_valid}) + if edges: + bmesh.ops.bevel( + bm, + geom=edges, + offset=0.0018, + segments=1, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + + pack_uvs(bm) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for face in bm.faces: + face.smooth = True + for edge in bm.edges: + edge.smooth = True + if edge.is_manifold and len(edge.link_faces) == 2: + if edge.calc_face_angle() > math.radians(35.0): + edge.smooth = False + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + me.update() + finally: + bm.free() + obj = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(obj) + return obj + + +def principled(name, color, metallic, roughness, noise_scale=0.0, wear=None): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = color + bsdf.inputs["Metallic"].default_value = metallic + bsdf.inputs["Roughness"].default_value = roughness + if noise_scale > 0.0 and wear is not None: + tex = nt.nodes.new("ShaderNodeTexNoise") + tex.inputs["Scale"].default_value = noise_scale + tex.inputs["Detail"].default_value = 8.0 + tex.inputs["Roughness"].default_value = 0.55 + mix = nt.nodes.new("ShaderNodeMix") + mix.data_type = "RGBA" + mix.inputs["A"].default_value = color + mix.inputs["B"].default_value = wear + fac = mix.inputs.get("Factor") or mix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], fac) + nt.links.new(mix.outputs["Result"], bsdf.inputs["Base Color"]) + rmix = nt.nodes.new("ShaderNodeMix") + rmix.data_type = "FLOAT" + rmix.inputs["A"].default_value = roughness + rmix.inputs["B"].default_value = min(1.0, roughness + 0.18) + rfac = rmix.inputs.get("Factor") or rmix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], rfac) + nt.links.new(rmix.outputs["Result"], bsdf.inputs["Roughness"]) + return mat + + +def assign_slots(obj, wood, metal): + mats = obj.data.materials + for i, mat in enumerate((wood, metal)): + if i < len(mats): + mats[i] = mat + else: + mats.append(mat) + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs = [c.x for c in corners] + ys = [c.y for c in corners] + zs = [c.z for c in corners] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def uv_stats(mesh): + """UV bounds plus AABB overlap area, bucketed so this stays linear.""" + uv = mesh.uv_layers.active + if uv is None: + return 0.0, 0.0, 1.0, 1.0, 0.0, 0 + data = uv.data + us = [loop.uv[0] for loop in data] + vs = [loop.uv[1] for loop in data] + aabbs = [] + for poly in mesh.polygons: + pu = [data[i].uv[0] for i in poly.loop_indices] + pv = [data[i].uv[1] for i in poly.loop_indices] + aabbs.append((min(pu), min(pv), max(pu), max(pv))) + # Bucket on a grid at least as coarse as the largest island, so any + # overlapping pair lands in a shared cell. O(n) instead of O(n^2). + span = max( + 1e-6, + max((a[2] - a[0]) for a in aabbs), + max((a[3] - a[1]) for a in aabbs), + ) + buckets = {} + for i, a in enumerate(aabbs): + c0 = int(math.floor(a[0] / span)) + c1 = int(math.floor(a[2] / span)) + r0 = int(math.floor(a[1] / span)) + r1 = int(math.floor(a[3] / span)) + for c in range(c0, c1 + 1): + for r in range(r0, r1 + 1): + buckets.setdefault((c, r), []).append(i) + overlap = 0.0 + seen = set() + for members in buckets.values(): + for ii in range(len(members)): + for jj in range(ii + 1, len(members)): + i, j = members[ii], members[jj] + key = (i, j) if i < j else (j, i) + if key in seen: + continue + seen.add(key) + a, b = aabbs[i], aabbs[j] + x0 = max(a[0], b[0]) + y0 = max(a[1], b[1]) + x1 = min(a[2], b[2]) + y1 = min(a[3], b[3]) + overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0) + return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) + + +def face_area(me, poly): + idxs = poly.vertices + if len(idxs) < 3: + return 0.0 + v0 = me.vertices[idxs[0]].co + area = 0.0 + for i in range(1, len(idxs) - 1): + vs = (me.vertices[idxs[i]].co, me.vertices[idxs[i + 1]].co) + area += (vs[0] - v0).cross(vs[1] - v0).length * 0.5 + return area + + +def hygiene_audit(me): + nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons) + ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) + zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS) + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0) + loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0) + nonman = sum(1 for e in bm.edges if not e.is_manifold) + ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS) + doubles = len(ret.get("targetmap") or {}) + finally: + bm.free() + return { + "nv": nv, "ne": ne, "nf": nf, "ngons": ngons, + "loose_v": loose_v, "loose_e": loose_e, "nonman": nonman, + "zero_area": zero_area, "doubles": doubles, + } + + +def zfight_pairs(me): + """Coplanar, near-coincident face pairs that share no vertex. + + Bucketed on a grid of ZFIGHT_EPS so a 2500-face stack does not cost + three million Python-level pair tests in smoke. + """ + data = [ + (p.center.copy(), p.normal.copy(), frozenset(p.vertices)) + for p in me.polygons + ] + cell = ZFIGHT_EPS + buckets = {} + for i, (c, _n, _v) in enumerate(data): + key = ( + int(math.floor(c.x / cell)), + int(math.floor(c.y / cell)), + int(math.floor(c.z / cell)), + ) + buckets.setdefault(key, []).append(i) + eps2 = ZFIGHT_EPS * ZFIGHT_EPS + count = 0 + checked = set() + for key, members in buckets.items(): + kx, ky, kz = key + near = [] + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + for dz in (-1, 0, 1): + near.extend(buckets.get((kx + dx, ky + dy, kz + dz), ())) + for i in members: + ci, ni, vi = data[i] + for j in near: + if j == i: + continue + pair = (i, j) if i < j else (j, i) + if pair in checked: + continue + checked.add(pair) + cj, nj, vj = data[j] + if (cj - ci).length_squared > eps2: + continue + if abs(ni.dot(nj)) <= ZFIGHT_COS: + continue + if vi & vj: + continue + count += 1 + return count + + +def shells(me): + neighbors = [[] for _ in range(len(me.vertices))] + for edge in me.edges: + a, b = edge.vertices + neighbors[a].append(b) + neighbors[b].append(a) + seen = [False] * len(me.vertices) + groups = [] + for start in range(len(me.vertices)): + if seen[start]: + continue + seen[start] = True + stack = [start] + group = [] + while stack: + current = stack.pop() + group.append(current) + for nxt in neighbors[current]: + if not seen[nxt]: + seen[nxt] = True + stack.append(nxt) + groups.append(group) + return groups + + +def shell_aabb(me, group): + pts = [me.vertices[i].co for i in group] + return ( + min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts), + max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts), + ) + + +def mat_of(me, group, face_of_vert): + member = set(group) + for i in group: + for fi in face_of_vert[i]: + poly = me.polygons[fi] + if all(v in member for v in poly.vertices): + return poly.material_index + return None + + +def vert_faces(me): + table = [[] for _ in range(len(me.vertices))] + for fi, poly in enumerate(me.polygons): + for vi in poly.vertices: + table[vi].append(fi) + return table + + +def xy_principal(pts): + """Long/short XY extent and orientation of a shell, free of world yaw. + + Every part is an axis-aligned box in its crate's frame, then rotated + about Z. A world-AABB test therefore measures the rotated bounding + box, not the part: a 0.554 m board yawed 0.09 rad reports 0.061 m of + depth instead of its 0.013 m thickness, and every shape filter keyed + to thickness silently matches nothing. Recovering the box's own axes + by principal components makes the classification yaw-invariant, and + hands back the yaw as a by-product. + """ + n = len(pts) + cx = sum(p.x for p in pts) / n + cy = sum(p.y for p in pts) / n + sxx = syy = sxy = 0.0 + for p in pts: + dx, dy = p.x - cx, p.y - cy + sxx += dx * dx + syy += dy * dy + sxy += dx * dy + theta = 0.5 * math.atan2(2.0 * sxy, sxx - syy) + c, s = math.cos(theta), math.sin(theta) + us = [(p.x - cx) * c + (p.y - cy) * s for p in pts] + vs = [-(p.x - cx) * s + (p.y - cy) * c for p in pts] + e1 = max(us) - min(us) + e2 = max(vs) - min(vs) + if e1 < e2: + e1, e2 = e2, e1 + theta += math.pi / 2.0 + while theta > math.pi / 2.0: + theta -= math.pi + while theta <= -math.pi / 2.0: + theta += math.pi + return e1, e2, theta + + +def _levels_from_runners(cands): + """Stack bases, clustered from the runners the mesh actually has. + + Binning against the declared pitch put a lid — which sits 8 mm below + the next crate's base — on the wrong level the moment a falsifier + shifted a crate, and the seat budget then compared a crate against + itself. Clustering the measured runner heights instead makes the + bands follow the geometry, including when a falsifier moves it. + """ + bases = [] + for z in sorted(cands): + if not bases or z - bases[-1] > CRATE_H * 0.5: + bases.append(z) + else: + bases[-1] = min(bases[-1], z) + return bases + + +def classify(me): + """Bin every wood shell to a stack level and name the parts. + + Two passes: find the runners, cluster their heights into stack bases, + then assign every shell to the highest base at or below it. Only the + identification uses the layout; every number a budget later asserts + on — heights, gaps, yaws, footprints — is measured from vertices. + """ + vf = vert_faces(me) + wood = [] + for g in shells(me): + if mat_of(me, g, vf) != WOOD_IDX: + continue + pts = [me.vertices[i].co for i in g] + zmin = min(p.z for p in pts) + zmax = max(p.z for p in pts) + e1, e2, theta = xy_principal(pts) + wood.append( + { + "g": g, "zmin": zmin, "dz": zmax - zmin, + "e1": e1, "e2": e2, "yaw": theta, + } + ) + + # e2 is a band, not a ceiling: a side board is the same length and can + # be thinner than a runner is tall, so an open-ended short-axis test + # promotes boards to runners and floats the ground budget. + runner_like = [ + s for s in wood + if s["dz"] < SKID_H * 1.25 + and s["e1"] > CRATE_X * 0.85 + and SKID_W * 0.6 < s["e2"] < SKID_W * 1.8 + ] + # Top rails share the runners' footprint and are only 2 mm taller, so + # a shape filter alone puts four runners on every level. A runner is + # the lowest thing in its crate; a rail is 27 cm above it. + bases = _levels_from_runners([s["zmin"] for s in runner_like]) + if len(bases) != N_CRATES: + bases = [i * (CRATE_H - STACK_BITE) for i in range(N_CRATES)] + + def level_of(z): + lvl = 0 + for i, b in enumerate(bases): + if z >= b - 1e-4: + lvl = i + return lvl + + out = {i: {"skids": [], "lids": [], "boards": []} for i in range(N_CRATES)} + for s in wood: + lvl = level_of(s["zmin"]) + rel = s["zmin"] - bases[lvl] + if s in runner_like and rel < SKID_H * 3.0: + out[lvl]["skids"].append(s) + elif ( + s["dz"] < SLAT_T * 2.2 + and s["e1"] > CRATE_Y * 0.9 + and rel > CRATE_H * 0.6 + ): + out[lvl]["lids"].append(s) + elif ( + SLAT_T * 1.5 < s["dz"] < BODY_H * 0.7 + and s["e1"] > CRATE_X * 0.85 + and s["e2"] < SLAT_T * 2.2 + ): + out[lvl]["boards"].append(dict(s, h=s["dz"])) + return out, bases + + +def _bvh_gap(me, hosts, guests): + """Worst surface gap from any guest shell to the host surface. + + Surface-to-surface, not vertex-to-vertex: a runner crossing a lid + plank has no vertex near the plank's own vertices, and a vertex + metric would report a large gap for parts that are in fact seated. + Overlapping shells return 0. + """ + if not hosts or not guests: + return 99.0 + bm_h = bmesh.new() + try: + bm_h.from_mesh(me) + keep = set() + for g in hosts: + keep.update(g) + drop = [f for f in bm_h.faces if not all(v.index in keep for v in f.verts)] + if drop: + bmesh.ops.delete(bm_h, geom=drop, context="FACES") + if not bm_h.faces: + return 99.0 + tree = BVHTree.FromBMesh(bm_h) + worst = 0.0 + for g in guests: + bm_g = bmesh.new() + try: + bm_g.from_mesh(me) + member = set(g) + drop_g = [ + f for f in bm_g.faces + if not all(v.index in member for v in f.verts) + ] + if drop_g: + bmesh.ops.delete(bm_g, geom=drop_g, context="FACES") + if not bm_g.faces: + worst = max(worst, 99.0) + continue + tree_g = BVHTree.FromBMesh(bm_g) + if tree.overlap(tree_g): + continue + best = 99.0 + for i in g: + hit = tree.find_nearest(me.vertices[i].co) + if hit[0] is None: + continue + best = min(best, hit[3]) + worst = max(worst, best) + finally: + bm_g.free() + return worst + finally: + bm_h.free() + + +def stack_audit(me): + """Ground supports, crate-to-crate seats, and per-instance variation.""" + by_level, bases = classify(me) + + ground = by_level[0]["skids"] + ground_n = len(ground) + # max, not min: one planted runner would hide a floating one. + ground_z = max((s["zmin"] for s in ground), default=99.0) + + seat = 0.0 + seats_checked = 0 + for i in range(1, N_CRATES): + hosts = [s["g"] for s in by_level[i - 1]["lids"]] + guests = [s["g"] for s in by_level[i]["skids"]] + if hosts and guests: + seats_checked += 1 + seat = max(seat, _bvh_gap(me, hosts, guests)) + else: + seat = 99.0 + + widths = {} + yaws = {} + for i in range(N_CRATES): + widths[i] = sorted(round(b["h"], 6) for b in by_level[i]["boards"]) + sk = by_level[i]["skids"] + if sk: + yaws[i] = sum(s["yaw"] for s in sk) / len(sk) + + width_spread = 0.0 + pairs = 0 + for i in range(N_CRATES): + for j in range(i + 1, N_CRATES): + wi, wj = widths.get(i) or [], widths.get(j) or [] + if wi and len(wi) == len(wj): + d = max(abs(a - b) for a, b in zip(wi, wj)) + width_spread = d if pairs == 0 else min(width_spread, d) + pairs += 1 + + yaw_vals = [yaws[i] for i in range(N_CRATES) if i in yaws] + yaw_spread = 0.0 + if len(yaw_vals) == N_CRATES: + yaw_spread = min( + abs(yaw_vals[i] - yaw_vals[j]) + for i in range(N_CRATES) + for j in range(i + 1, N_CRATES) + ) + return { + "ground_n": ground_n, + "ground_z": ground_z, + "seat": seat, + "seats": seats_checked, + "lids": sum(len(by_level[i]["lids"]) for i in range(N_CRATES)), + "boards": sum(len(by_level[i]["boards"]) for i in range(N_CRATES)), + "board_counts": [len(by_level[i]["boards"]) for i in range(N_CRATES)], + "skid_counts": [len(by_level[i]["skids"]) for i in range(N_CRATES)], + "width_spread": width_spread, + "width_pairs": pairs, + "yaw_spread": yaw_spread, + "yaw_min": min((abs(y) for y in yaw_vals), default=0.0), + "yaw_max": max((abs(y) for y in yaw_vals), default=9.0), + "yaws": [round(y, 4) for y in yaw_vals], + "bases": [round(b, 4) for b in bases], + } + + +def crate_size_audit(me, yaws, bases): + """Each crate's own footprint, measured in that crate's frame. + + The stack AABB cannot cover this: it is the union of three yawed + boxes, so a crate could drift to any size underneath it. Un-rotating + by the yaw this crate was measured to have is what makes the number + the crate's own width and depth rather than its rotated bounding box. + """ + out = [] + for i, base in enumerate(bases): + # Body band only. A band that reaches the crate base also picks + # up the runners of the crate above, which carry a different yaw + # and inflate the footprint by two centimetres. + lo = base + SKID_H + 0.012 + hi = base + SKID_H + BODY_H - 0.012 + pts = [v.co for v in me.vertices if lo <= v.co.z <= hi] + if not pts or i >= len(yaws): + out.append((0.0, 0.0)) + continue + c, s = math.cos(-yaws[i]), math.sin(-yaws[i]) + us = [p.x * c - p.y * s for p in pts] + vs = [p.x * s + p.y * c for p in pts] + out.append((max(us) - min(us), max(vs) - min(vs))) + return out + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, STACK_H * 0.5)) + bm.to_mesh(me) + me.update() + finally: + bm.free() + + +def make_lod(obj, name, ratio, skip_decimate): + mesh = obj.data.copy() + lod = bpy.data.objects.new(name, mesh) + lod.matrix_world = obj.matrix_world.copy() + bpy.context.scene.collection.objects.link(lod) + if not skip_decimate and 0.0 < ratio < 1.0: + mod = lod.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return lod + + +def convex_hull_collider(obj, name): + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bm.from_mesh(obj.data) + result = bmesh.ops.convex_hull(bm, input=list(bm.verts)) + interior = result.get("geom_interior") or [] + unused = result.get("geom_unused") or [] + if interior: + bmesh.ops.delete(bm, geom=interior, context="VERTS") + if unused: + bmesh.ops.delete(bm, geom=unused, context="VERTS") + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + collider = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(collider) + collider.matrix_world = obj.matrix_world.copy() + return collider + + +def setup_bake_image(obj, target_mat, size=BAKE_RES): + if not obj.data.uv_layers: + return None, None + img = bpy.data.images.new("StackNrm", size, size, alpha=True, float_buffer=False) + img.colorspace_settings.name = "Non-Color" + nodes = target_mat.node_tree.nodes + tex = nodes.new("ShaderNodeTexImage") + tex.image = img + nodes.active = tex + tex.select = True + obj.active_material_index = WOOD_IDX + return img, tex + + +def bake_normal(high, low): + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = 1 + scene.cycles.use_denoising = False + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + high.select_set(True) + low.select_set(True) + bpy.context.view_layer.objects.active = low + return bpy.ops.object.bake( + type="NORMAL", + use_selected_to_active=True, + cage_extrusion=CAGE_EXTRUSION, + use_cage=False, + normal_space="TANGENT", + margin=4, + margin_type="ADJACENT_FACES", + use_clear=True, + target="IMAGE_TEXTURES", + ) + + +def export_unity(path, objects): + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + for ob in objects: + ob.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + bpy.ops.export_scene.gltf( + filepath=path, + use_selection=True, + export_yup=True, + export_apply=True, + export_draco_mesh_compression_enable=False, + export_animations=False, + ) + + +def check( + skip_decimate, + lift_z=False, + stray_vert=False, + short_skids=False, + float_stack=False, + same_seed=False, +): + bpy.ops.wm.read_factory_settings(use_empty=True) + flags = dict( + same_seed=same_seed, + short_skids=short_skids, + float_stack=float_stack, + ) + nothing = (None,) * 5 + low = build_stack_mesh("StackLow", **flags) + high = build_stack_mesh("StackHigh", **flags) + wood = principled( + "StackWood", (0.40, 0.18, 0.065, 1.0), 0.0, 0.55, + noise_scale=9.0, wear=(0.20, 0.085, 0.030, 1.0), + ) + metal = principled( + "StackMetal", (0.20, 0.19, 0.180, 1.0), 0.88, 0.33, + noise_scale=6.0, wear=(0.10, 0.095, 0.088, 1.0), + ) + assign_slots(low, wood, metal) + assign_slots(high, wood, metal) + if stray_vert: + add_stray_vert(low.data) + if lift_z: + for v in low.data.vertices: + v.co.z += LIFT_Z + low.data.update() + bpy.context.view_layer.update() + + if low.data is None or len(low.data.polygons) < 6: + return (fail("stack mesh did not build", 3),) + nothing + + base_tris = triangle_count(low.data) + mats = [s for s in low.data.materials if s is not None] + nmat = len(mats) + distinct_mats = len({id(s) for s in mats}) + idx_counts = {} + for poly in low.data.polygons: + idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1 + u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data) + bb = world_bbox(low) + size_x, size_y, size_z = bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2] + + img, tex = setup_bake_image(low, wood) + if img is None: + return (fail("stack has no UV layer", 3),) + nothing + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "StackLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "StackLOD2", LOD2_TARGET, skip_decimate) + bpy.context.view_layer.update() + lod1_tris = evaluated_triangle_count(lod1) + lod2_tris = evaluated_triangle_count(lod2) + r1 = lod1_tris / base_tris if base_tris else 0.0 + r2 = lod2_tris / base_tris if base_tris else 0.0 + + collider_src = build_stack_mesh("StackColSrc", **flags) + collider = convex_hull_collider(collider_src, "StackCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), f"bdt_crate_stack_{os.getpid()}.glb" + ) + if os.path.exists(export_path): + os.remove(export_path) + export_unity(export_path, [low, collider]) + export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + st = stack_audit(low.data) + crates = crate_size_audit(low.data, st['yaws'], st['bases']) + + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") + print(f"measured mat_index_counts={idx_counts}") + print( + f"measured base_tris={base_tris} lod1_tris={lod1_tris} " + f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" + ) + print( + f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) " + f"overlap={overlap:.6f} nfaces={nfaces}" + ) + print( + f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"outer={OUTER_SIZE} zmin={bb[2]:.5f}" + ) + print( + f"measured collider_tris={col_tris} bake={bake_result} " + f"bake_has_data={img.has_data} export_bytes={export_size}" + ) + print( + f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}" + ) + print( + f"measured stack ground_n={st['ground_n']} ground_z={st['ground_z']:.5f} " + f"seats={st['seats']} seat_gap={st['seat']:.5f} lids={st['lids']} " + f"boards={st['boards']} per_level_boards={st['board_counts']} " + f"per_level_skids={st['skid_counts']}" + ) + print( + f"measured variation yaws={st['yaws']} yaw_spread={st['yaw_spread']:.4f} " + f"yaw_min={st['yaw_min']:.4f} yaw_max={st['yaw_max']:.4f} " + f"width_spread={st['width_spread']:.5f} pairs={st['width_pairs']}" + ) + print( + "measured crate_footprints=" + f"{[(round(c[0], 4), round(c[1], 4)) for c in crates]}" + ) + + if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): + return (fail( + f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", 4 + ),) + nothing + if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT: + return (fail( + f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}", 5 + ),) + nothing + if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN: + return (fail( + f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}", 5 + ),) + nothing + if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN: + return (fail( + f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}", 5 + ),) + nothing + if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: + return (fail( + f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", 6 + ),) + nothing + if overlap > UV_OVERLAP_MAX: + return (fail(f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7),) + nothing + if ( + abs(size_x - OUTER_SIZE[0]) > BBOX_TOL + or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL + or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL + ): + return (fail( + f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) off outer {OUTER_SIZE}", 8 + ),) + nothing + if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): + return (fail( + f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " + "(--skip-decimate is the designed fail)", 9 + ),) + nothing + if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): + return (fail( + f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", 9 + ),) + nothing + if col_tris > COLLIDER_TRIS_MAX: + return (fail(f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", 11),) + nothing + if bake_result != {"FINISHED"} or not img.has_data: + return (fail( + f"bake failed result={bake_result} has_data={img.has_data}", 12 + ),) + nothing + if export_size <= 0: + return (fail("export file missing or empty", 13),) + nothing + if ( + hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] + or hyg["zero_area"] or hyg["doubles"] or hyg["ngons"] or zf + ): + return (fail( + f"hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf} " + "(--stray-vert is the designed fail)", 15 + ),) + nothing + if abs(bb[2]) > ZMIN_EPS: + return (fail( + f"zmin {bb[2]:.6f} not within {ZMIN_EPS} of 0 " + "(--lift-z is the designed fail)", 16 + ),) + nothing + if st["ground_n"] < GROUND_SKIDS_MIN or st["ground_z"] > SKID_Z_MAX: + return (fail( + f"ground runners {st['ground_n']} ground_z={st['ground_z']:.5f} " + "(--short-skids is the designed fail)", 16 + ),) + nothing + if st["seats"] != N_CRATES - 1 or st["seat"] > STACK_SEAT_MAX: + return (fail( + f"stack seat gap {st['seat']:.5f} > {STACK_SEAT_MAX} over " + f"{st['seats']} seats (--float-stack is the designed fail)", 18 + ),) + nothing + for i, (cx, cy) in enumerate(crates): + if abs(cx - BODY_X) > CRATE_TOL or abs(cy - BODY_Y) > CRATE_TOL: + return (fail( + f"crate {i} body footprint ({cx:.4f},{cy:.4f}) off " + f"({BODY_X:.4f},{BODY_Y:.4f})", 19 + ),) + nothing + if st["yaw_min"] < YAW_MIN * 0.8 or st["yaw_max"] > YAW_MAX * 1.2: + return (fail( + f"crate yaws {st['yaws']} outside [{YAW_MIN}, {YAW_MAX}]", 20 + ),) + nothing + if st["yaw_spread"] < YAW_SPREAD_MIN or st["width_spread"] < WIDTH_SPREAD_MIN: + return (fail( + f"per-instance variation too small: yaw_spread={st['yaw_spread']:.4f} " + f"(min {YAW_SPREAD_MIN}) width_spread={st['width_spread']:.5f} " + f"(min {WIDTH_SPREAD_MIN}) — the three crates are copies, not " + "instances (--same-seed is the designed fail)", 20 + ),) + nothing + return 0, low, high, wood, tex, collider + + +def wire_normal(mat, tex): + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + nrm = nt.nodes.new("ShaderNodeNormalMap") + nrm.inputs["Strength"].default_value = 1.0 + nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) + nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) + + +def render_still(low, wood, tex, path, engine): + scene = bpy.context.scene + wire_normal(wood, tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + + low.rotation_euler.z = math.radians(-14.0) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=16.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Floor") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 9.0, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.02, 0.021, 0.025, 1.0, + ) + scene.world = world + + def light(name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy = energy + ld.size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", (-3.2, -4.0, 5.2), 545.0, 5.0, (1.0, 0.96, 0.90), (44, 0, -38)) + light("Fill", (4.2, -2.8, 1.5), 145.0, 9.0, (0.75, 0.85, 1.00), (72, 0, 54)) + light("Rim", (-1.5, 3.4, 2.6), 320.0, 3.0, (0.60, 0.78, 1.00), (-64, 0, 200)) + # Wedge sits between the subject and the wall so the pool lands on the + # backdrop, not across the crates. + light("Wedge", (1.1, 4.3, 1.95), 560.0, 6.0, (1.0, 0.72, 0.40), (-94, 0, 194)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 52.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (1.62, -2.42, 1.06) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, STACK_H * 0.46) + scene.collection.objects.link(aim) + con = cam.constraints.new("TRACK_TO") + con.target = aim + con.track_axis = "TRACK_NEGATIVE_Z" + con.up_axis = "UP_Y" + scene.camera = cam + + scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + scene.cycles.samples = 32 + scene.cycles.device = "CPU" + else: + try: + scene.eevee.taa_render_samples = 64 + except AttributeError: + pass + scene.render.resolution_x = 1280 + scene.render.resolution_y = 720 + scene.render.image_settings.file_format = ( + "WEBP" if path.lower().endswith(".webp") else "PNG" + ) + if path.lower().endswith(".webp"): + scene.render.image_settings.quality = 90 + scene.render.filepath = path + scene.view_settings.view_transform = "Standard" + + fcode = gallery_framing.check_framing( + scene, cam, hero=[low], elements=[low], stage=[floor, wall], + ) + if fcode: + return fcode + bpy.ops.render.render(write_still=True) + if not (os.path.exists(path) and os.path.getsize(path) > 0): + return fail("render produced no file", 14) + return 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None) + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) + p.add_argument("--skip-decimate", action="store_true") + p.add_argument("--lift-z", action="store_true") + p.add_argument("--stray-vert", action="store_true") + p.add_argument("--short-skids", action="store_true") + p.add_argument("--float-stack", action="store_true") + p.add_argument("--same-seed", action="store_true") + args = p.parse_args(argv) + + code, low, _high, wood, tex, _col = check( + args.skip_decimate, + lift_z=args.lift_z, + stray_vert=args.stray_vert, + short_skids=args.short_skids, + float_stack=args.float_stack, + same_seed=args.same_seed, + ) + if code: + return code + if args.output: + rcode = render_still(low, wood, tex, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + print("crate-stack OK") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + traceback.print_exc() + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(1) diff --git a/showcase/crate-stack/preview.webp b/showcase/crate-stack/preview.webp new file mode 100644 index 00000000..c2cbd00b Binary files /dev/null and b/showcase/crate-stack/preview.webp differ diff --git a/showcase/gallery.json b/showcase/gallery.json index 6d6750d4..8434cb0b 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -316,6 +316,30 @@ "mesh", "export" ] + }, + { + "name": "crate-stack", + "dir": "showcase/crate-stack", + "teaches": "A stack of three procedural shipping crates built by one generator called three times with a per-instance seed, carried through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 4380 tris, two materials with 216 iron and 2436 timber faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.658×0.524×0.704 m, zmin 0, hygiene 0 including zero coplanar disjoint pairs, three ground runners at z=0, both crate-to-crate seats overlapping, each crate's own footprint 0.585×0.425 m measured in its own frame, yaw spread 0.026 rad and plank spread 2.05 mm across instances, LOD ratios in band, convex collider 202 tris, non-empty glTF. --float-stack exits 18 on the seat; --same-seed exits 20 on per-instance variation.", + "hero": "docs/gallery/assets/crate-stack-hero.webp", + "preview": "showcase/crate-stack/preview.webp", + "tags": [ + "mesh", + "export" + ] + }, + { + "name": "stone-archway", + "dir": "showcase/stone-archway", + "teaches": "A procedural masonry archway — coursed piers, projecting imposts, nine voussoirs and a proud keystone — carried through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 924 tris, two materials with 182 dressed and 364 ashlar faces re-stamped after the chamfer, UVs in 0..1 with zero AABB overlap, outer AABB 1.644×0.510×2.057 m, clear opening 1.203 m, zmin 0, hygiene 0 including zero coplanar disjoint pairs, both piers grounded, the springing joint an overlap, all eight mortar joints at 9.70 mm inside the 6–17 mm band, and every intrados vertex within 0.97 mm of the declared 0.60 m radius over 178°. --off-circle exits 19 on the circle fit; --wide-mortar exits 18 on the joint band.", + "hero": "docs/gallery/assets/stone-archway-hero.webp", + "preview": "showcase/stone-archway/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/showcase/stone-archway/README.md b/showcase/stone-archway/README.md new file mode 100644 index 00000000..0639ded2 --- /dev/null +++ b/showcase/stone-archway/README.md @@ -0,0 +1,198 @@ +# stone-archway + +![A semicircular masonry archway: coursed piers, projecting imposts, nine voussoirs and a proud keystone](preview.webp) + +A freestanding masonry arch — two coursed piers, projecting imposts, nine +voussoirs turning a semicircle, and a keystone standing proud at the crown. +**A showcase piece, not an example** — it witnesses no API contract. It +asserts that generated geometry meets declared asset budgets, recomputed +from the finished mesh. + +## What it composes + +| Shipped content | Used for | +| --- | --- | +| `skills/mesh-editing-and-bmesh` | wedge and box construction, chamfer, UVs in one `bmesh` | +| `skills/procedural-materials-and-shaders` | two Principled stone materials with noise-driven weathering | +| `skills/bake-high-to-low` | Cycles tangent-space normal bake, high onto low | +| `skills/engine-export-presets` | Unity glTF (`export_yup=True`) | +| `skills/depsgraph-and-evaluated-data` | evaluated triangle counts for the LOD ratios | +| `snippets/decimate_to_budget.py` | LOD1 / LOD2 COLLAPSE chain | +| `snippets/convex_hull_collider.py` | convex collider | +| `snippets/lod_chain.py` | LOD naming and ratio pattern | +| `examples/mesh-hygiene-audit` | hygiene combinatorics (copied, not imported) | + +## The budget that matters + +An arch can fail invisibly. Nine wedge blocks laid in a row are still nine +wedge blocks; the thing that makes them an arch is that their **intrados +vertices sit on a circle**. So the piece recomputes that circle from vertex +positions — not from the angles the generator used — and asserts every +intrados vertex lands within 4 mm of the declared 0.60 m radius, over an arc +spanning at least 168°. + +`--off-circle` is the falsifier built for exactly this. It keeps the angles, +the joints, the materials, the triangle count and the bounding box identical, +and only wanders the intrados radius by ±18 mm. Every other budget in the +piece still passes. Only the circle fit sees it. + +### Measuring it took two corrections + +The first attempt selected intrados vertices by radius. That swept in the +chamfer vertices sitting one bevel-width out on each radial face and reported +a 5.1 mm error on a true arch. The fit now runs over the vertices of faces +that actually face the springing centre — normal inward in XZ *and* with a +small Y component, which is what excludes the chamfer strips running along +the intrados edges. + +## Budgets + +Declared in the script as named constants, recomputed from the generated +mesh. Measured values are from Blender 5.2.1; the cross-version table is at +the end. + +| Budget | Band | Measured | +| --- | --- | --- | +| Base triangles | 700–2600 | 924 | +| LOD1 ratio | 0.32–0.62 | 0.5000 | +| LOD2 ratio | 0.10–0.35 | 0.2121 (5.2) / 0.2186 (4.5, 5.1) | +| Material slots | exactly 2, distinct | 2 | +| Dressed faces (keystone, imposts, plinth and head courses) | ≥ 100 | 182 | +| Ashlar faces | ≥ 280 | 364 | +| UV bounds | inside 0..1 | (0.0017, 0.0017)–(0.9983, 0.9983) | +| UV AABB overlap | ≤ 1e-5 | 0.000000 | +| Outer AABB | 1.644 × 0.510 × 2.057 m ± 0.020 | 1.6440 × 0.5100 × 2.0568 | +| Clear opening | 1.200 m ± 0.015, measured between the pier faces | 1.2033 | +| Collider triangles | ≤ 260 | 168 | +| Normal bake | `{'FINISHED'}` with image data | `{'FINISHED'}`, `has_data=True` | +| glTF export | file written, non-empty | ~87 kB | +| Hygiene | all zero | loose 0/0, non-manifold 0, zero-area 0, doubles 0, n-gons 0, coplanar disjoint pairs 0 | +| Grounded AABB | \|zmin\| ≤ 1e-4 | 0.00000 | +| Pier supports | 2 piers, each base course zmin ≤ 1e-3 | 2 at 0.00000 | +| Keystone proud of the wall face | ≥ 0.020 m | 0.03500 | +| Springing joint | overlap, surface gap ≤ 1e-4 | 0.00000 | +| Mortar joints | every adjacent pair in 0.006–0.017 m | all eight at 0.00970 | +| Intrados circle fit | every vertex within 0.004 m of R = 0.60 | 0.00097 | +| Intrados arc span | ≥ 168° | 178.0° | + +Real-world size: a 1.20 m clear opening under a semicircular head, 1.64 m +across the piers and 2.06 m to the top of the keystone — a garden gate arch. + +## Determinism + +Fixed seed 23; no unseeded randomness. Every measured value above is +byte-identical on 4.5.11, 5.1.2 and 5.2.1 **except** LOD2, where +`DECIMATE COLLAPSE` produces 196 triangles on 5.2 and 202 on 4.5 and 5.1. +That is why the LOD gate is a ratio band (0.10–0.35, measured 0.2121 and +0.2186) and not an exact count. + +## Falsifiers + +Each breaks one pipeline stage so a **named** budget fails. All seven were +run on 4.5.11, 5.1.2 and 5.2.1 and produced the same exit code on all three. + +| Flag | Breaks | Exit | +| --- | --- | --- | +| `--skip-decimate` | drops the DECIMATE modifiers, LOD1 ratio goes to 1.0000 | 9 | +| `--stray-vert` | adds one loose vertex inside the opening, so hygiene catches it rather than the bounding box | 15 | +| `--lift-z` | lifts the whole mesh 50 mm off the floor | 16 | +| `--float-pier` | floats one pier 12 mm; the other still grounds the AABB, so only the named-support budget sees it | 16 | +| `--sink-keystone` | sinks the keystone to a quarter of its projection (8.75 mm) — the imposts still set the Y envelope, so the bounding box is unchanged | 17 | +| `--wide-mortar` | triples the joint angle, opening every joint to 29.1 mm | 18 | +| `--off-circle` | wanders the intrados radius ±18 mm while keeping angles, joints and envelope | 19 | + +Three of these needed the model changed, not the budget: + +- **`--sink-keystone`** originally removed the projection outright, which + shrank the Y bounding box by 70 mm and tripped the AABB gate first. The + imposts were added so the envelope no longer depends on the keystone — + and an impost course is correct masonry the arch was missing anyway. +- **`--off-circle`** began as `--flat-arch`, laying the voussoirs as a + lintel. That is 0.62 m shorter and fails on the bounding box, proving + nothing about the circle fit. Wandering the radius inside the same + envelope is the honest version. +- **`--float-pier`** tripped the z-fight budget rather than the support + budget, because the impost was pinned to an absolute height while the + courses under it rose into it. The impost now rides on its own pier. + +## Findings the budgets forced + +- **The chamfer pass silently repainted the mesh.** `bmesh.ops.bevel` gives + every face it creates `material_index` 0, so chamfering nineteen blocks + left 30 faces on the dressed slot and moved 464 to ashlar. The slot count + and the distinct-material check both still passed. Only the per-material + face floor caught it, which is exactly the class `showcase/README.md` + warns about. Materials are now re-stamped after the bevel, per block, by + nearest recorded centroid. +- **The impost landed exactly on the top course.** Same footprint, same + plane, two coincident face centres — a z-fight. It now sits on its own + mortar bed, which is why the pier lays `N_COURSE` beds rather than + `N_COURSE - 1`. +- **`find_nearest` is unsigned.** A voussoir seated *inside* the pier head + reported a 5.4 mm gap where there was none, because the distance to the + host's skin is positive from inside too. Joint gaps now test BVH overlap + first and return zero when two blocks interpenetrate. +- **Joint gaps have to be measured both ways.** The keystone's radial faces + are wider in Y than its neighbours', so every keystone vertex on the joint + lies outside the neighbour and read 33 mm instead of the 9.7 mm joint. The + metric is the closest approach of the two surfaces, min of both directions. + +## Exit codes + +File-local and sequential. `9` is a valid check code; there is no rule +against it. `1` is the FATAL wrapper — a crash, never a named check. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Mesh did not build, or has no UV layer | +| 4 | Base triangle count outside band | +| 5 | Material slots, or a material's face floor | +| 6 | UVs outside 0..1 | +| 7 | UV AABB overlap above tolerance | +| 8 | Outer AABB off declared size | +| 9 | LOD1 or LOD2 ratio outside band (`--skip-decimate`) | +| 10 | Framing gate (`examples/gallery_framing.py`, render path only) | +| 11 | Collider triangles above ceiling | +| 12 | Normal bake failed or produced no image data | +| 13 | glTF export missing or empty | +| 14 | `--output` produced no file | +| 15 | Mesh hygiene (`--stray-vert`) | +| 16 | Grounded zmin, or a pier base floating (`--lift-z`, `--float-pier`) | +| 17 | Keystone projection or springing joint (`--sink-keystone`) | +| 18 | Mortar joint outside band (`--wide-mortar`) | +| 19 | Intrados circle fit, or clear opening (`--off-circle`) | + +## Run it + +```bash +# Budget check, no render. ~0.82 s on 4.5, ~0.85 s on 5.1, ~0.98 s on 5.2. +blender --background --python stone_archway.py -- + +# Falsifier: the intrados stops being a circle. Must exit 19. +blender --background --python stone_archway.py -- --off-circle + +# Falsifier: every mortar joint opens to 29 mm. Must exit 18. +blender --background --python stone_archway.py -- --wide-mortar + +# Render the gallery still (EEVEE; --engine cycles on a GPU-less host). +blender --background --python stone_archway.py -- --output arch.webp +``` + +Smoke runs the check-only path. It does not pass `--output` or any falsifier. + +## Cross-version measurements + +| Value | 4.5.11 | 5.1.2 | 5.2.1 | +| --- | --- | --- | --- | +| Base triangles | 924 | 924 | 924 | +| LOD1 tris / ratio | 462 / 0.5000 | 462 / 0.5000 | 462 / 0.5000 | +| LOD2 tris / ratio | 202 / 0.2186 | 202 / 0.2186 | 196 / 0.2121 | +| Face counts (ashlar / dressed) | 364 / 182 | 364 / 182 | 364 / 182 | +| Outer AABB | 1.6440 × 0.5100 × 2.0568 | same | same | +| Collider tris | 168 | 168 | 168 | +| Intrados deviation | 0.00097 | 0.00097 | 0.00097 | +| Mortar joints | all 0.00970 | all 0.00970 | all 0.00970 | +| Check wall-clock | ~0.82 s | ~0.85 s | ~0.98 s | diff --git a/showcase/stone-archway/preview.webp b/showcase/stone-archway/preview.webp new file mode 100644 index 00000000..04f851b8 Binary files /dev/null and b/showcase/stone-archway/preview.webp differ diff --git a/showcase/stone-archway/stone_archway.py b/showcase/stone-archway/stone_archway.py new file mode 100644 index 00000000..663ca1f8 --- /dev/null +++ b/showcase/stone-archway/stone_archway.py @@ -0,0 +1,1220 @@ +"""Game-ready stone archway — a showcase piece, not an example. + +Asserts budget conformance of a procedural masonry arch: two coursed +piers, nine voussoirs turning a semicircle, and a proud keystone, carried +through UVs, two materials, a high-to-low normal bake, an LOD chain, a +convex collider, and a Unity glTF export. + +The budget that matters here is the one an arch can fail invisibly: the +voussoir intrados vertices must lie on a circle of the declared radius, +recomputed from vertex positions rather than from the angles the +generator used. A row of wedges that never turned is still a row of +wedges; only the circle fit knows the difference. + +Budgets are declared below and recomputed from the generated result. +They are not API-contract witnesses. Each falsifier violates one named +budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh +hygiene, ``--lift-z`` grounded zmin, ``--float-pier`` the named pier +supports, ``--sink-keystone`` the keystone joint, ``--wide-mortar`` the +mortar-joint band, ``--off-circle`` the intrados circle fit. + +Fixed seed 23 for course and block weathering. DECIMATE COLLAPSE +triangle counts are not byte-identical across Blender versions — the LOD +gate is a ratio band, not an exact count. + + blender --background --python stone_archway.py -- + blender --background --python stone_archway.py -- --off-circle + blender --background --python stone_archway.py -- --output arch.png +""" +import argparse +import math +import os +import random +import sys +import tempfile +import traceback + +import bmesh +import bpy +from mathutils import Vector +from mathutils.bvhtree import BVHTree + +_REPO = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) +) +sys.path.insert(0, os.path.join(_REPO, "examples")) +sys.dont_write_bytecode = True +import gallery_framing # noqa: E402 + +# A 1.2 m clear opening under a semicircular head: a gate arch, about +# 1.6 m across the piers and 2.0 m to the crown. +R_IN = 0.60 +T_V = 0.20 +R_OUT = R_IN + T_V +WALL_Y = 0.40 +H_SPRING = 1.20 +N_VOUSSOIR = 9 +KEY_INDEX = N_VOUSSOIR // 2 +# The keystone stands proud of the wall face and rises past the extrados. +KEY_PROUD = 0.035 +KEY_RISE = 0.080 +# The keystone reads by standing proud and rising past the extrados, not +# by being angularly wider: widening it eats its neighbours' mortar joints. +KEY_WIDEN = 0.0 +# Voussoirs bite down into the pier head so the springing joint is an +# overlap, not two faces sharing the z = H_SPRING plane. +SPRING_BITE = 0.018 +# Imposts: the projecting string-course at the springing line. They set +# the Y envelope, which is what lets the keystone falsifier change the +# keystone without changing the bounding box. +IMPOST_PROUD = 0.055 +IMPOST_H = 0.075 +IMPOST_OUT = 0.022 +# Radial mortar joints, as an angle so the joint stays radial. +MORTAR_ANG = 0.016 +N_COURSE = 5 +COURSE_MORTAR = 0.007 +WEATHER = 0.18 +ARCH_SEED = 23 +# Worn arrises. Also what lifts the block count out of programmer-art +# territory: nineteen unbevelled boxes are 228 triangles. +CHAMFER = 0.006 + +STACK_H = H_SPRING + R_OUT + KEY_RISE + +BBOX_TOL = 0.020 +OUTER_SIZE = (1.644, 0.510, 2.057) +SPAN_TOL = 0.015 +OPENING_W = 2.0 * R_IN + +BASE_TRIS_MIN = 700 +BASE_TRIS_MAX = 2600 +LOD1_RATIO_MIN = 0.32 +LOD1_RATIO_MAX = 0.62 +LOD2_RATIO_MIN = 0.10 +LOD2_RATIO_MAX = 0.35 +LOD1_TARGET = 0.50 +LOD2_TARGET = 0.22 +MATERIAL_COUNT = 2 +UV_EPS = 1e-4 +UV_OVERLAP_MAX = 1e-5 +COLLIDER_TRIS_MAX = 260 +BAKE_RES = 256 +CAGE_EXTRUSION = 0.08 +DRESSED_FACES_MIN = 100 +ASHLAR_FACES_MIN = 280 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +ZFIGHT_EPS = 1e-4 +ZFIGHT_COS = 0.999 +LIFT_Z = 0.05 +PIER_Z_MAX = 1e-3 +PIERS_MIN = 2 +# Intrados circle fit: every inner-arc vertex within this of R_IN. +ARC_TOL = 0.004 +ARC_SPAN_MIN = math.radians(168.0) +# Mortar joint band, measured surface-to-surface per adjacent pair. +MORTAR_MIN = 0.006 +MORTAR_MAX = 0.017 +KEY_PROUD_MIN = 0.020 +SPRING_GAP_MAX = 1e-4 +FLOAT_PIER_LIFT = 0.012 + +ASHLAR_IDX = 0 +DRESSED_IDX = 1 + + +def eevee_engine_id(): + """EEVEE id: 'BLENDER_EEVEE' on 5.0+, 'BLENDER_EEVEE_NEXT' on 4.2-4.5.""" + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"FAIL[{code}]: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + deps = bpy.context.evaluated_depsgraph_get() + ev = obj.evaluated_get(deps) + mesh = ev.to_mesh() + try: + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + finally: + ev.to_mesh_clear() + + +def add_box(bm, loc, scale, mat_idx): + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + for v in verts: + v.co = Vector( + ( + v.co.x * scale[0] + loc[0], + v.co.y * scale[1] + loc[1], + v.co.z * scale[2] + loc[2], + ) + ) + for f in {f for v in verts for f in v.link_faces}: + f.material_index = mat_idx + return verts, (Vector(loc), mat_idx) + + +def add_wedge(bm, cz, a0, a1, r0, r1, hy, mat_idx): + """One voussoir: a radial wedge in XZ, extruded across the wall in Y. + + Eight vertices, six quads, closed. The radial end faces are what the + mortar-joint budget measures, so they stay flat and parallel to the + neighbour's rather than being bevelled away. + """ + prof = ((a0, r0), (a1, r0), (a1, r1), (a0, r1)) + rows = [] + for y in (-hy, hy): + rows.append( + [ + bm.verts.new((r * math.cos(a), y, cz + r * math.sin(a))) + for a, r in prof + ] + ) + faces = [bm.faces.new(rows[0]), bm.faces.new(tuple(reversed(rows[1])))] + for k in range(4): + kn = (k + 1) % 4 + faces.append( + bm.faces.new((rows[0][k], rows[0][kn], rows[1][kn], rows[1][k])) + ) + for f in faces: + f.material_index = mat_idx + vs = [v for row in rows for v in row] + c = sum((v.co for v in vs), Vector((0.0, 0.0, 0.0))) / len(vs) + return vs, (c, mat_idx) + + +def pack_uvs(bm, margin=0.08): + uv = bm.loops.layers.uv.new("UVMap") + faces = list(bm.faces) + n = len(faces) + cols = max(1, math.ceil(math.sqrt(n))) + rows = max(1, math.ceil(n / cols)) + cell_w = 1.0 / cols + cell_h = 1.0 / rows + pad_u = margin * cell_w * 0.5 + pad_v = margin * cell_h * 0.5 + usable_w = cell_w - 2.0 * pad_u + usable_h = cell_h - 2.0 * pad_v + for i, face in enumerate(faces): + col = i % cols + row = i // cols + nrm = face.normal + ax, ay, az = abs(nrm.x), abs(nrm.y), abs(nrm.z) + coords = [] + for loop in face.loops: + co = loop.vert.co + if az >= ax and az >= ay: + coords.append((co.x, co.y)) + elif ax >= ay: + coords.append((co.y, co.z)) + else: + coords.append((co.x, co.z)) + xs = [c[0] for c in coords] + ys = [c[1] for c in coords] + minx, maxx = min(xs), max(xs) + miny, maxy = min(ys), max(ys) + dx = max(maxx - minx, 1e-8) + dy = max(maxy - miny, 1e-8) + ou = col * cell_w + pad_u + ov = row * cell_h + pad_v + for loop, (x, y) in zip(face.loops, coords): + loop[uv].uv = ( + ou + (x - minx) / dx * usable_w, + ov + (y - miny) / dy * usable_h, + ) + + +def assign_materials_by_block(bm, blocks): + """Re-stamp material indices after the chamfer pass. + + ``bmesh.ops.bevel`` gives every face it creates ``material_index`` 0, + so chamfering nineteen blocks repainted 464 of 494 faces with the + first slot and left 30 on the second. The slot count and the distinct + material check both still passed; only a per-material face floor + catches it. Each block is its own shell, so re-deriving the material + from the nearest recorded block centroid restores the assignment + without depending on face or vertex ordering. + """ + seen = set() + for v in bm.verts: + if v in seen: + continue + stack = [v] + seen.add(v) + comp = [] + while stack: + cur = stack.pop() + comp.append(cur) + for e in cur.link_edges: + other = e.other_vert(cur) + if other not in seen: + seen.add(other) + stack.append(other) + centre = sum((x.co for x in comp), Vector((0.0, 0.0, 0.0))) / len(comp) + _c, mat = min(blocks, key=lambda b: (b[0] - centre).length_squared) + for f in {f for x in comp for f in x.link_faces}: + f.material_index = mat + + +def build_pier(bm, sign, rng, float_pier=False): + """One coursed pier, base course on the floor, head at H_SPRING.""" + raw = [1.0 + rng.uniform(-WEATHER, WEATHER) for _ in range(N_COURSE)] + total = sum(raw) + # N_COURSE beds, not N_COURSE-1: the impost needs one under it too, + # or its underside lands exactly on the top course and the two share + # a face centre, which is a z-fight. + usable = H_SPRING - IMPOST_H - COURSE_MORTAR * N_COURSE + heights = [usable * r / total for r in raw] + verts = [] + blocks = [] + z = FLOAT_PIER_LIFT if (float_pier and sign < 0) else 0.0 + for i, h in enumerate(heights): + # Courses weather back a little as they rise; the jitter also keeps + # two neighbouring courses from presenting identical faces. + inset = rng.uniform(0.0, 0.010) + depth = WALL_Y - rng.uniform(0.0, 0.014) + mat = DRESSED_IDX if i in (0, N_COURSE - 1) else ASHLAR_IDX + vs, block = add_box( + bm, + (sign * (R_IN + T_V / 2.0), 0.0, z + h / 2.0), + (T_V - inset, depth, h), + mat, + ) + verts.extend(vs) + blocks.append(block) + z += h + COURSE_MORTAR + # The impost rides on the courses rather than sitting at an absolute + # height: pinned to H_SPRING it stays put while --float-pier lifts the + # courses into it, and the two then share a face plane. + vs, block = add_box( + bm, + (sign * (R_IN + T_V / 2.0), 0.0, z + IMPOST_H / 2.0), + (T_V + 2.0 * IMPOST_OUT, WALL_Y + 2.0 * IMPOST_PROUD, IMPOST_H), + DRESSED_IDX, + ) + verts.extend(vs) + blocks.append(block) + return verts, blocks + + +def build_arch(bm, rng, off_circle=False, sink_keystone=False, wide_mortar=False): + """Nine voussoirs turning 0..pi, keystone at the crown.""" + gap = MORTAR_ANG * (3.0 if wide_mortar else 1.0) + step = math.pi / N_VOUSSOIR + cz = H_SPRING - SPRING_BITE + verts = [] + blocks = [] + for k in range(N_VOUSSOIR): + is_key = k == KEY_INDEX + widen = KEY_WIDEN if is_key else 0.0 + a0 = k * step + gap / 2.0 - widen + a1 = (k + 1) * step - gap / 2.0 + widen + r1 = R_OUT + (KEY_RISE if is_key else 0.0) + hy = WALL_Y / 2.0 + if is_key: + # Sunk, not removed: the imposts still set the Y envelope, so + # the bounding box is unchanged and the keystone budget is the + # only thing that can see this. + hy += KEY_PROUD * (0.25 if sink_keystone else 1.0) + mat = DRESSED_IDX if is_key else ASHLAR_IDX + r0 = R_IN + if off_circle: + # Same angles, same joints, same envelope — only the intrados + # radius wanders. This is the failure the circle fit exists + # for: nothing else in the piece can see it. + r0 = R_IN + (0.018 if k % 2 else -0.018) + # Weathering rides on the extrados only; the intrados is the face + # the circle-fit budget measures and is left true. + r1 += rng.uniform(0.0, 0.012) + vs, block = add_wedge(bm, cz, a0, a1, r0, r1, hy, mat) + verts.extend(vs) + blocks.append(block) + return verts, blocks + + +def build_arch_mesh( + name, + off_circle=False, + sink_keystone=False, + wide_mortar=False, + float_pier=False, +): + rng = random.Random(ARCH_SEED) + bm = bmesh.new() + try: + blocks = [] + for sign in (-1.0, 1.0): + _v, bl = build_pier(bm, sign, rng, float_pier=float_pier) + blocks.extend(bl) + _v, bl = build_arch( + bm, + rng, + off_circle=off_circle, + sink_keystone=sink_keystone, + wide_mortar=wide_mortar, + ) + blocks.extend(bl) + edges = list(bm.edges) + if edges: + bmesh.ops.bevel( + bm, + geom=edges, + offset=CHAMFER, + segments=1, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + assign_materials_by_block(bm, blocks) + pack_uvs(bm) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for face in bm.faces: + face.smooth = False + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + me.update() + finally: + bm.free() + obj = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(obj) + return obj + + +def principled(name, color, metallic, roughness, noise_scale=0.0, wear=None): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = color + bsdf.inputs["Metallic"].default_value = metallic + bsdf.inputs["Roughness"].default_value = roughness + if noise_scale > 0.0 and wear is not None: + tex = nt.nodes.new("ShaderNodeTexNoise") + tex.inputs["Scale"].default_value = noise_scale + tex.inputs["Detail"].default_value = 9.0 + tex.inputs["Roughness"].default_value = 0.6 + mix = nt.nodes.new("ShaderNodeMix") + mix.data_type = "RGBA" + mix.inputs["A"].default_value = color + mix.inputs["B"].default_value = wear + fac = mix.inputs.get("Factor") or mix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], fac) + nt.links.new(mix.outputs["Result"], bsdf.inputs["Base Color"]) + rmix = nt.nodes.new("ShaderNodeMix") + rmix.data_type = "FLOAT" + rmix.inputs["A"].default_value = roughness + rmix.inputs["B"].default_value = min(1.0, roughness + 0.16) + rfac = rmix.inputs.get("Factor") or rmix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], rfac) + nt.links.new(rmix.outputs["Result"], bsdf.inputs["Roughness"]) + return mat + + +def assign_slots(obj, ashlar, dressed): + mats = obj.data.materials + for i, mat in enumerate((ashlar, dressed)): + if i < len(mats): + mats[i] = mat + else: + mats.append(mat) + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs = [c.x for c in corners] + ys = [c.y for c in corners] + zs = [c.z for c in corners] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def uv_stats(mesh): + uv = mesh.uv_layers.active + if uv is None: + return 0.0, 0.0, 1.0, 1.0, 0.0, 0 + data = uv.data + us = [loop.uv[0] for loop in data] + vs = [loop.uv[1] for loop in data] + aabbs = [] + for poly in mesh.polygons: + pu = [data[i].uv[0] for i in poly.loop_indices] + pv = [data[i].uv[1] for i in poly.loop_indices] + aabbs.append((min(pu), min(pv), max(pu), max(pv))) + span = max( + 1e-6, + max((a[2] - a[0]) for a in aabbs), + max((a[3] - a[1]) for a in aabbs), + ) + buckets = {} + for i, a in enumerate(aabbs): + for c in range(int(a[0] // span), int(a[2] // span) + 1): + for r in range(int(a[1] // span), int(a[3] // span) + 1): + buckets.setdefault((c, r), []).append(i) + overlap = 0.0 + seen = set() + for members in buckets.values(): + for ii in range(len(members)): + for jj in range(ii + 1, len(members)): + i, j = members[ii], members[jj] + key = (i, j) if i < j else (j, i) + if key in seen: + continue + seen.add(key) + a, b = aabbs[i], aabbs[j] + overlap += max(0.0, min(a[2], b[2]) - max(a[0], b[0])) * max( + 0.0, min(a[3], b[3]) - max(a[1], b[1]) + ) + return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) + + +def face_area(me, poly): + idxs = poly.vertices + if len(idxs) < 3: + return 0.0 + v0 = me.vertices[idxs[0]].co + area = 0.0 + for i in range(1, len(idxs) - 1): + a = me.vertices[idxs[i]].co + b = me.vertices[idxs[i + 1]].co + area += (a - v0).cross(b - v0).length * 0.5 + return area + + +def hygiene_audit(me): + nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons) + ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) + zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS) + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0) + loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0) + nonman = sum(1 for e in bm.edges if not e.is_manifold) + ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS) + doubles = len(ret.get("targetmap") or {}) + finally: + bm.free() + return { + "nv": nv, "ne": ne, "nf": nf, "ngons": ngons, "loose_v": loose_v, + "loose_e": loose_e, "nonman": nonman, "zero_area": zero_area, + "doubles": doubles, + } + + +def zfight_pairs(me): + """Coplanar, near-coincident face pairs that share no vertex.""" + data = [ + (p.center.copy(), p.normal.copy(), frozenset(p.vertices)) + for p in me.polygons + ] + cell = ZFIGHT_EPS + buckets = {} + for i, (c, _n, _v) in enumerate(data): + key = ( + int(math.floor(c.x / cell)), + int(math.floor(c.y / cell)), + int(math.floor(c.z / cell)), + ) + buckets.setdefault(key, []).append(i) + eps2 = ZFIGHT_EPS * ZFIGHT_EPS + count = 0 + checked = set() + for (kx, ky, kz), members in buckets.items(): + near = [] + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + for dz in (-1, 0, 1): + near.extend(buckets.get((kx + dx, ky + dy, kz + dz), ())) + for i in members: + ci, ni, vi = data[i] + for j in near: + if j == i: + continue + pair = (i, j) if i < j else (j, i) + if pair in checked: + continue + checked.add(pair) + cj, nj, vj = data[j] + if (cj - ci).length_squared > eps2: + continue + if abs(ni.dot(nj)) <= ZFIGHT_COS: + continue + if vi & vj: + continue + count += 1 + return count + + +def shells(me): + neighbors = [[] for _ in range(len(me.vertices))] + for edge in me.edges: + a, b = edge.vertices + neighbors[a].append(b) + neighbors[b].append(a) + seen = [False] * len(me.vertices) + groups = [] + for start in range(len(me.vertices)): + if seen[start]: + continue + seen[start] = True + stack = [start] + group = [] + while stack: + cur = stack.pop() + group.append(cur) + for nxt in neighbors[cur]: + if not seen[nxt]: + seen[nxt] = True + stack.append(nxt) + groups.append(group) + return groups + + +def shell_tree(me, group): + """A BVH for one shell, built once so pair gaps stay cheap.""" + bm = bmesh.new() + try: + bm.from_mesh(me) + member = set(group) + drop = [f for f in bm.faces if not all(v.index in member for v in f.verts)] + if drop: + bmesh.ops.delete(bm, geom=drop, context="FACES") + if not bm.faces: + return None + return BVHTree.FromBMesh(bm) + finally: + bm.free() + + +def pair_gap(me, tree_a, group_b, tree_b=None): + """Surface gap from shell B to shell A; 0 when the two interpenetrate. + + find_nearest returns an unsigned distance, so a block seated *inside* + its host reports the distance to the host's skin rather than zero. The + springing joint is an overlap by design, so without the overlap test + first this reads a 5 mm gap where there is none. + """ + if tree_a is None: + return 99.0 + if tree_b is not None and tree_a.overlap(tree_b): + return 0.0 + best = 99.0 + for i in group_b: + hit = tree_a.find_nearest(me.vertices[i].co) + if hit[0] is None: + continue + best = min(best, hit[3]) + return best + + +def classify(me): + """Name the shells: pier courses by side, voussoirs by crown angle.""" + out = {"left": [], "right": [], "voussoirs": []} + for g in shells(me): + pts = [me.vertices[i].co for i in g] + zmin = min(p.z for p in pts) + zmax = max(p.z for p in pts) + xc = sum(p.x for p in pts) / len(pts) + rec = {"g": g, "zmin": zmin, "zmax": zmax, "xc": xc, "pts": pts} + if zmax <= H_SPRING + 1e-6: + (out["left"] if xc < 0 else out["right"]).append(rec) + else: + out["voussoirs"].append(rec) + out["left"].sort(key=lambda r: r["zmin"]) + out["right"].sort(key=lambda r: r["zmin"]) + # Around the arch, not left to right: atan2 about the springing centre + # orders the voussoirs even when a falsifier has flattened them. + out["voussoirs"].sort( + key=lambda r: math.atan2( + max(1e-9, sum(p.z for p in r["pts"]) / len(r["pts"]) - (H_SPRING - SPRING_BITE)), + sum(p.x for p in r["pts"]) / len(r["pts"]), + ), + reverse=True, + ) + return out + + +def arch_audit(me): + """Circle fit, mortar band, keystone proudness, pier supports.""" + parts = classify(me) + cz = H_SPRING - SPRING_BITE + + # Intrados circle fit, measured on the vertices of faces that actually + # face the springing centre. Selecting by radius alone would sweep in + # the chamfer vertices sitting one bevel-width out on each radial face, + # and report a 6 mm error on a true arch. + member = {} + for rec in parts["voussoirs"]: + for i in rec["g"]: + member[i] = True + radii = [] + angles = [] + for poly in me.polygons: + if not all(i in member for i in poly.vertices): + continue + c = poly.center + rc = math.hypot(c.x, c.z - cz) + if rc > R_IN + T_V * 0.5: + continue + inward = Vector((-c.x, 0.0, -(c.z - cz))) + if inward.length < 1e-9: + continue + inward.normalize() + n = poly.normal + if abs(n.y) > 0.2: + continue + if Vector((n.x, 0.0, n.z)).normalized().dot(inward) < 0.9: + continue + for i in poly.vertices: + p = me.vertices[i].co + radii.append(math.hypot(p.x, p.z - cz)) + angles.append(math.atan2(p.z - cz, p.x)) + arc_dev = max((abs(r - R_IN) for r in radii), default=99.0) + arc_span = (max(angles) - min(angles)) if angles else 0.0 + + # Mortar joints, surface to surface, per adjacent pair around the arch. + vs = parts["voussoirs"] + trees = [shell_tree(me, rec["g"]) for rec in vs] + gaps = [] + for i in range(len(vs) - 1): + gaps.append( + min( + pair_gap(me, trees[i], vs[i + 1]["g"], trees[i + 1]), + pair_gap(me, trees[i + 1], vs[i]["g"], trees[i]), + ) + ) + gap_min = min(gaps) if gaps else 99.0 + gap_max = max(gaps) if gaps else 99.0 + + # Keystone: the voussoir that stands proud of the wall face. + wall_half = WALL_Y / 2.0 + prouds = [ + max(abs(p.y) for p in rec["pts"]) - wall_half for rec in vs + ] + key_proud = max(prouds) if prouds else 0.0 + + # Springing joints must overlap the pier head, not rest on it. + spring_gap = 0.0 + for side in ("left", "right"): + if not parts[side] or not vs: + spring_gap = 99.0 + continue + head = parts[side][-1] + tree = shell_tree(me, head["g"]) + idx = min( + range(len(vs)), + key=lambda k: abs( + sum(p.x for p in vs[k]["pts"]) / len(vs[k]["pts"]) - head["xc"] + ), + ) + spring_gap = max( + spring_gap, pair_gap(me, tree, vs[idx]["g"], trees[idx]) + ) + + piers = 0 + pier_z = 99.0 + for side in ("left", "right"): + if parts[side]: + piers += 1 + pier_z = min(pier_z, 99.0) + pier_worst = max( + (parts[s][0]["zmin"] for s in ("left", "right") if parts[s]), default=99.0 + ) + + return { + "n_vous": len(vs), + "n_left": len(parts["left"]), + "n_right": len(parts["right"]), + "arc_dev": arc_dev, + "arc_span": arc_span, + "gap_min": gap_min, + "gap_max": gap_max, + "key_proud": key_proud, + "spring_gap": spring_gap, + "piers": piers, + "pier_worst": pier_worst, + "gaps": [round(g, 5) for g in gaps], + } + + +def opening_audit(me): + """Clear opening width at mid-pier height, measured from vertices.""" + # Band stops clear of the impost, which projects inboard of the pier + # face and is not part of the clear opening. + zc = H_SPRING * 0.5 + half = H_SPRING * 0.40 + xs_left = [ + v.co.x for v in me.vertices + if v.co.x < 0 and abs(v.co.z - zc) < half + ] + xs_right = [ + v.co.x for v in me.vertices + if v.co.x > 0 and abs(v.co.z - zc) < half + ] + if not xs_left or not xs_right: + return 0.0 + return min(xs_right) - max(xs_left) + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, H_SPRING * 0.5)) + bm.to_mesh(me) + me.update() + finally: + bm.free() + + +def make_lod(obj, name, ratio, skip_decimate): + mesh = obj.data.copy() + lod = bpy.data.objects.new(name, mesh) + lod.matrix_world = obj.matrix_world.copy() + bpy.context.scene.collection.objects.link(lod) + if not skip_decimate and 0.0 < ratio < 1.0: + mod = lod.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return lod + + +def convex_hull_collider(obj, name): + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bm.from_mesh(obj.data) + result = bmesh.ops.convex_hull(bm, input=list(bm.verts)) + for key in ("geom_interior", "geom_unused"): + geom = result.get(key) or [] + if geom: + bmesh.ops.delete(bm, geom=geom, context="VERTS") + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + collider = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(collider) + collider.matrix_world = obj.matrix_world.copy() + return collider + + +def setup_bake_image(obj, target_mat, size=BAKE_RES): + if not obj.data.uv_layers: + return None, None + img = bpy.data.images.new("ArchNrm", size, size, alpha=True, float_buffer=False) + img.colorspace_settings.name = "Non-Color" + nodes = target_mat.node_tree.nodes + tex = nodes.new("ShaderNodeTexImage") + tex.image = img + nodes.active = tex + tex.select = True + obj.active_material_index = ASHLAR_IDX + return img, tex + + +def bake_normal(high, low): + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = 1 + scene.cycles.use_denoising = False + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + high.select_set(True) + low.select_set(True) + bpy.context.view_layer.objects.active = low + return bpy.ops.object.bake( + type="NORMAL", + use_selected_to_active=True, + cage_extrusion=CAGE_EXTRUSION, + use_cage=False, + normal_space="TANGENT", + margin=4, + margin_type="ADJACENT_FACES", + use_clear=True, + target="IMAGE_TEXTURES", + ) + + +def export_unity(path, objects): + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + for ob in objects: + ob.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + bpy.ops.export_scene.gltf( + filepath=path, + use_selection=True, + export_yup=True, + export_apply=True, + export_draco_mesh_compression_enable=False, + export_animations=False, + ) + + +def check( + skip_decimate, + lift_z=False, + stray_vert=False, + float_pier=False, + sink_keystone=False, + wide_mortar=False, + off_circle=False, +): + bpy.ops.wm.read_factory_settings(use_empty=True) + flags = dict( + off_circle=off_circle, + sink_keystone=sink_keystone, + wide_mortar=wide_mortar, + float_pier=float_pier, + ) + nothing = (None,) * 5 + low = build_arch_mesh("ArchLow", **flags) + high = build_arch_mesh("ArchHigh", **flags) + ashlar = principled( + "ArchAshlar", (0.300, 0.219, 0.126, 1.0), 0.0, 0.80, + noise_scale=14.0, wear=(0.168, 0.113, 0.055, 1.0), + ) + dressed = principled( + "ArchDressed", (0.452, 0.348, 0.205, 1.0), 0.0, 0.62, + noise_scale=8.0, wear=(0.262, 0.196, 0.108, 1.0), + ) + assign_slots(low, ashlar, dressed) + assign_slots(high, ashlar, dressed) + if stray_vert: + add_stray_vert(low.data) + if lift_z: + for v in low.data.vertices: + v.co.z += LIFT_Z + low.data.update() + bpy.context.view_layer.update() + + if low.data is None or len(low.data.polygons) < 6: + return (fail("arch mesh did not build", 3),) + nothing + + base_tris = triangle_count(low.data) + mats = [s for s in low.data.materials if s is not None] + nmat = len(mats) + distinct = len({id(s) for s in mats}) + idx_counts = {} + for poly in low.data.polygons: + idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1 + u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data) + bb = world_bbox(low) + size_x, size_y, size_z = bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2] + + img, tex = setup_bake_image(low, ashlar) + if img is None: + return (fail("arch has no UV layer", 3),) + nothing + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "ArchLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "ArchLOD2", LOD2_TARGET, skip_decimate) + bpy.context.view_layer.update() + lod1_tris = evaluated_triangle_count(lod1) + lod2_tris = evaluated_triangle_count(lod2) + r1 = lod1_tris / base_tris if base_tris else 0.0 + r2 = lod2_tris / base_tris if base_tris else 0.0 + + col_src = build_arch_mesh("ArchColSrc", **flags) + collider = convex_hull_collider(col_src, "ArchCollider") + bpy.data.objects.remove(col_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), f"bdt_stone_archway_{os.getpid()}.glb" + ) + if os.path.exists(export_path): + os.remove(export_path) + export_unity(export_path, [low, collider]) + export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + ar = arch_audit(low.data) + opening = opening_audit(low.data) + + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") + print(f"measured mat_index_counts={idx_counts}") + print( + f"measured base_tris={base_tris} lod1_tris={lod1_tris} " + f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" + ) + print( + f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) " + f"overlap={overlap:.6f} nfaces={nfaces}" + ) + print( + f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"outer={OUTER_SIZE} zmin={bb[2]:.5f} opening={opening:.4f}" + ) + print( + f"measured collider_tris={col_tris} bake={bake_result} " + f"bake_has_data={img.has_data} export_bytes={export_size}" + ) + print( + f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}" + ) + print( + f"measured arch vous={ar['n_vous']} courses={ar['n_left']}/{ar['n_right']} " + f"arc_dev={ar['arc_dev']:.5f} arc_span={math.degrees(ar['arc_span']):.1f}deg " + f"gap=({ar['gap_min']:.5f},{ar['gap_max']:.5f}) " + f"key_proud={ar['key_proud']:.5f} spring_gap={ar['spring_gap']:.5f} " + f"pier_worst={ar['pier_worst']:.5f} gaps={ar['gaps']}" + ) + + if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): + return (fail( + f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", 4 + ),) + nothing + if nmat != MATERIAL_COUNT or distinct != MATERIAL_COUNT: + return (fail( + f"material slots {nmat} distinct {distinct} != {MATERIAL_COUNT}", 5 + ),) + nothing + if idx_counts.get(DRESSED_IDX, 0) < DRESSED_FACES_MIN: + return (fail( + f"dressed faces {idx_counts.get(DRESSED_IDX, 0)} < {DRESSED_FACES_MIN}", 5 + ),) + nothing + if idx_counts.get(ASHLAR_IDX, 0) < ASHLAR_FACES_MIN: + return (fail( + f"ashlar faces {idx_counts.get(ASHLAR_IDX, 0)} < {ASHLAR_FACES_MIN}", 5 + ),) + nothing + if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: + return (fail( + f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", 6 + ),) + nothing + if overlap > UV_OVERLAP_MAX: + return (fail(f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7),) + nothing + if ( + abs(size_x - OUTER_SIZE[0]) > BBOX_TOL + or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL + or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL + ): + return (fail( + f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) off outer {OUTER_SIZE}", 8 + ),) + nothing + if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): + return (fail( + f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " + "(--skip-decimate is the designed fail)", 9 + ),) + nothing + if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): + return (fail( + f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", 9 + ),) + nothing + if col_tris > COLLIDER_TRIS_MAX: + return (fail(f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", 11),) + nothing + if bake_result != {"FINISHED"} or not img.has_data: + return (fail( + f"bake failed result={bake_result} has_data={img.has_data}", 12 + ),) + nothing + if export_size <= 0: + return (fail("export file missing or empty", 13),) + nothing + if ( + hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"] + or hyg["doubles"] or hyg["ngons"] or zf + ): + return (fail( + f"hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf} " + "(--stray-vert is the designed fail)", 15 + ),) + nothing + if abs(bb[2]) > ZMIN_EPS: + return (fail( + f"zmin {bb[2]:.6f} not within {ZMIN_EPS} of 0 " + "(--lift-z is the designed fail)", 16 + ),) + nothing + if ar["piers"] < PIERS_MIN or ar["pier_worst"] > PIER_Z_MAX: + return (fail( + f"pier supports {ar['piers']} worst base z={ar['pier_worst']:.5f} " + "(--float-pier is the designed fail)", 16 + ),) + nothing + if ar["key_proud"] < KEY_PROUD_MIN or ar["spring_gap"] > SPRING_GAP_MAX: + return (fail( + f"keystone proud {ar['key_proud']:.5f} < {KEY_PROUD_MIN} or " + f"springing gap {ar['spring_gap']:.5f} > {SPRING_GAP_MAX} " + "(--sink-keystone is the designed fail)", 17 + ),) + nothing + if not (MORTAR_MIN <= ar["gap_min"] and ar["gap_max"] <= MORTAR_MAX): + return (fail( + f"mortar joints ({ar['gap_min']:.5f}, {ar['gap_max']:.5f}) outside " + f"[{MORTAR_MIN}, {MORTAR_MAX}] (--wide-mortar is the designed fail)", 18 + ),) + nothing + if ar["arc_dev"] > ARC_TOL or ar["arc_span"] < ARC_SPAN_MIN: + return (fail( + f"intrados off circle by {ar['arc_dev']:.5f} > {ARC_TOL} or spans " + f"{math.degrees(ar['arc_span']):.1f} deg < " + f"{math.degrees(ARC_SPAN_MIN):.1f} " + "(--off-circle is the designed fail)", 19 + ),) + nothing + if abs(opening - OPENING_W) > SPAN_TOL: + return (fail( + f"clear opening {opening:.4f} off {OPENING_W}", 19 + ),) + nothing + return 0, low, high, ashlar, tex, collider + + +def wire_normal(mat, tex): + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + nrm = nt.nodes.new("ShaderNodeNormalMap") + nrm.inputs["Strength"].default_value = 1.0 + nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) + nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) + + +def render_still(low, ashlar, tex, path, engine): + scene = bpy.context.scene + wire_normal(ashlar, tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + + low.rotation_euler.z = math.radians(-17.0) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=20.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Floor") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 9.0, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.02, 0.021, 0.025, 1.0, + ) + scene.world = world + + def light(name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy = energy + ld.size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", (-4.0, -4.6, 6.0), 640.0, 5.0, (1.0, 0.96, 0.90), (42, 0, -40)) + light("Fill", (4.6, -3.2, 2.0), 108.0, 9.0, (0.75, 0.85, 1.00), (70, 0, 54)) + light("Rim", (-2.2, 3.8, 3.4), 420.0, 3.5, (0.60, 0.78, 1.00), (-60, 0, 202)) + light("Wedge", (1.35, 4.4, 2.35), 640.0, 6.0, (1.0, 0.70, 0.36), (-94, 0, 194)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (3.46, -5.18, 2.05) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, STACK_H * 0.48) + scene.collection.objects.link(aim) + con = cam.constraints.new("TRACK_TO") + con.target = aim + con.track_axis = "TRACK_NEGATIVE_Z" + con.up_axis = "UP_Y" + scene.camera = cam + + scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + scene.cycles.samples = 32 + scene.cycles.device = "CPU" + else: + try: + scene.eevee.taa_render_samples = 64 + except AttributeError: + pass + scene.render.resolution_x = 1280 + scene.render.resolution_y = 720 + scene.render.image_settings.file_format = ( + "WEBP" if path.lower().endswith(".webp") else "PNG" + ) + if path.lower().endswith(".webp"): + scene.render.image_settings.quality = 90 + scene.render.filepath = path + scene.view_settings.view_transform = "Standard" + + fcode = gallery_framing.check_framing( + scene, cam, hero=[low], elements=[low], stage=[floor, wall], + ) + if fcode: + return fcode + bpy.ops.render.render(write_still=True) + if not (os.path.exists(path) and os.path.getsize(path) > 0): + return fail("render produced no file", 14) + return 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None) + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) + p.add_argument("--skip-decimate", action="store_true") + p.add_argument("--lift-z", action="store_true") + p.add_argument("--stray-vert", action="store_true") + p.add_argument("--float-pier", action="store_true") + p.add_argument("--sink-keystone", action="store_true") + p.add_argument("--wide-mortar", action="store_true") + p.add_argument("--off-circle", action="store_true") + args = p.parse_args(argv) + + code, low, _high, ashlar, tex, _col = check( + args.skip_decimate, + lift_z=args.lift_z, + stray_vert=args.stray_vert, + float_pier=args.float_pier, + sink_keystone=args.sink_keystone, + wide_mortar=args.wide_mortar, + off_circle=args.off_circle, + ) + if code: + return code + if args.output: + rcode = render_still(low, ashlar, tex, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + print("stone-archway OK") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + traceback.print_exc() + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(1) diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index d9c58f34..678fa48d 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -99,5 +99,7 @@ {"name": "tavern-stool", "script": "showcase/tavern-stool/tavern_stool.py"}, {"name": "iron-cauldron", "script": "showcase/iron-cauldron/iron_cauldron.py"}, {"name": "wooden-ladder", "script": "showcase/wooden-ladder/wooden_ladder.py"}, - {"name": "hay-bale", "script": "showcase/hay-bale/hay_bale.py"} + {"name": "hay-bale", "script": "showcase/hay-bale/hay_bale.py"}, + {"name": "crate-stack", "script": "showcase/crate-stack/crate_stack.py"}, + {"name": "stone-archway", "script": "showcase/stone-archway/stone_archway.py"} ]