From 68d79c77b8614f2dd96b79f967fa9e58179c5567 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:12:44 -0400 Subject: [PATCH 1/4] feat: add crate-stack showcase piece MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three shipping crates stacked, built by one generator called three times with a per-instance seed. The subject is reuse with variation: the seed drives yaw and plank widths so the crates read as three of the same design rather than one model pasted three times, and the README says plainly that true instancing and per-instance geometry variation are mutually exclusive. Budgets recomputed from the finished mesh, measured before they were written: 4380 tris, 2 materials with 216 iron and 2436 timber faces, UVs in 0..1 with zero overlap, outer AABB 0.658x0.524x0.704 m, each crate's own footprint 0.585x0.425 m in its own frame, three ground runners at z=0, both crate-to-crate seats overlapping, LOD ratios in band, 202-tri collider, non-empty glTF, and hygiene zero across loose/non-manifold/zero-area/ doubles/n-gons/coplanar-disjoint. Six falsifiers, each breaking one named budget, all proven on 4.5.11, 5.1.2 and 5.2.1 with identical exit codes: --skip-decimate 9, --stray-vert 15, --lift-z 16, --short-skids 16, --float-stack 18, --same-seed 20. Three findings the budgets forced, all documented in the README: - Shape filters had to move to principal axes. Every part is axis-aligned in its crate's frame then rotated about Z, so a world-AABB filter measures the rotated box: a 0.554 m board yawed 0.09 rad reports 0.061 m of depth instead of 0.013 m, and every thickness-keyed filter matched nothing. - Stack levels had to be clustered from the runners the mesh actually has. Binning against the declared pitch put a lid on the wrong level as soon as a falsifier shifted a crate, and the seat budget then compared a crate against itself. - find_doubles caught two real joints, not false positives: the corner straps sat flush with the post corner, and the lid rested exactly on the rail top. Both now bite into their host. Falsifiers were also retuned so each fails on its own budget rather than on an earlier check: the stray vertex sits inside the silhouette, --short-skids floats one of three runners so the AABB still grounds, and --same-seed collapses only the design stream while placement stays per-instance so the footprint is unchanged. LOD2 is the one value that differs across versions (878 tris on 5.2, 934 on 4.5 and 5.1) — DECIMATE COLLAPSE, which is why the gate is a ratio band. Adds ~1.0 s to smoke on 4.5/5.1 and ~1.2 s on 5.2. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> --- .cursor-plugin/plugin.json | 3 +- CLAUDE.md | 2 +- README.md | 7 +- docs/gallery/assets/crate-stack-hero.webp | Bin 0 -> 25462 bytes docs/gallery/crate-stack/index.html | 1756 +++++++++++++++++++++ docs/gallery/index.html | 15 +- showcase/crate-stack/README.md | 185 +++ showcase/crate-stack/crate_stack.py | 1405 +++++++++++++++++ showcase/crate-stack/preview.webp | Bin 0 -> 25462 bytes showcase/gallery.json | 12 + tests/smoke/catalog.json | 3 +- 11 files changed, 3380 insertions(+), 8 deletions(-) create mode 100644 docs/gallery/assets/crate-stack-hero.webp create mode 100644 docs/gallery/crate-stack/index.html create mode 100644 showcase/crate-stack/README.md create mode 100644 showcase/crate-stack/crate_stack.py create mode 100644 showcase/crate-stack/preview.webp diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 8cf573be..9aff3236 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -163,6 +163,7 @@ "showcase/tavern-stool", "showcase/iron-cauldron", "showcase/wooden-ladder", - "showcase/hay-bale" + "showcase/hay-bale", + "showcase/crate-stack" ] } diff --git a/CLAUDE.md b/CLAUDE.md index be91f2ae..1e943987 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, 27 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..202b984a 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  •  27 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 27 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 +27 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,7 @@ 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

[`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/docs/gallery/assets/crate-stack-hero.webp b/docs/gallery/assets/crate-stack-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..c4682541eeff593693bd4693b5730657d8048c30 GIT binary patch literal 25462 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/crate-stack/index.html b/docs/gallery/crate-stack/index.html new file mode 100644 index 00000000..74b87e50 --- /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.2, 2.7, 2.3), 380.0, 7.0, (1.0, 0.76, 0.50), (-92, 0, 196))
+
+    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..eb06464f 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, 27 showcase pieces
@@ -1171,6 +1171,17 @@

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 +
+
@@ -1200,7 +1211,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, 27 showcase pieces'; var LS_KEY = 'bdt-gallery-density'; var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; 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..525e8eee --- /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.2, 2.7, 2.3), 380.0, 7.0, (1.0, 0.76, 0.50), (-92, 0, 196)) + + 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 0000000000000000000000000000000000000000..c4682541eeff593693bd4693b5730657d8048c30 GIT binary patch literal 25462 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 literal 0 HcmV?d00001 diff --git a/showcase/gallery.json b/showcase/gallery.json index 6d6750d4..09784981 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -316,6 +316,18 @@ "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" + ] } ] } diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index d9c58f34..d928e354 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -99,5 +99,6 @@ {"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"} ] From 03e5f8a826496f25b4dd44d8598238fb97445d8a Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:31:19 -0400 Subject: [PATCH 2/4] fix: warm the crate-stack wedge pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building the contact sheet against the pinned calibration set (armature-bend, damped-track-aim, bmesh-gear) showed the wedge reading as a cool grey band rather than the warm pool the house style calls for. Move it closer to the backdrop, raise it to 560 W, and shift its colour to (1.0, 0.72, 0.40). Lighting only — no geometry, no budget, no assertion, no exit code changes. Mean luminance 0.2185, inside the calibration band [0.1722, 0.3048]. Hero and preview re-rendered; contact sheet committed under docs/gallery/contact-sheets/. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> --- docs/gallery/assets/crate-stack-hero.webp | Bin 25462 -> 25364 bytes .../crate-stack-contact-sheet.webp | Bin 0 -> 54690 bytes showcase/crate-stack/crate_stack.py | 2 +- showcase/crate-stack/preview.webp | Bin 25462 -> 25364 bytes 4 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 docs/gallery/contact-sheets/crate-stack-contact-sheet.webp diff --git a/docs/gallery/assets/crate-stack-hero.webp b/docs/gallery/assets/crate-stack-hero.webp index c4682541eeff593693bd4693b5730657d8048c30..c2cbd00badc75b1bf5575975b2efa20024c91f97 100644 GIT binary patch literal 25364 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 DX{-Y} literal 25462 zcmV(uKLk0n^3(oSA{l+V%iw>@{~`OU z@A1n%TgjU-A1ccq;5*=dyZyNLKl!u&Kle}H|Nfs(zJ7n?e&+kz{&W9N)SvTT_dmLx z`M>tPYJcSQ4gW>y1-~WBZgJ zntsdwoBZ#aUoEe=?-$O0;{MHh5&J#&f7$PKS<3&VrhiU6aE~eB|54xP-|c_@1TMra ziG?`Vf8K3l>qMv8vGt-;?O6KD_PMOTYnst~_d+qSF4;A2QtWC^W&2$2t9F#0(J5p8 z)Lq0J|CVK3{hkhF^)hHNvWMQ|o=3kaUL~d3M%|d=(P~#-@7qeBfqe*c@oJFU_6-%G+_1KoB#WMjv`_WW+58=JDji$wUP`|YW=Ka z5;tUoeqBQN0<7vGqprKl4u8TWQ@p8750J*_nh6DZMBB5e+!$u4HSEQ4G~n`}CfYt< zuBsRC0?6D9QAf9vAzgS18PO*$IhcbyrGTFGWWRku6y3pMr~jJ)E?r!7SrQWwLl}0} zwi^~Q(E*xa%k=_c5noYSz39~0O^mk5BAO=2ej>6)L9jVq0U;=28) z540U}xF7jdGqe=5fG~TI-m^Lew)*$w&N{4ubc4l7Ea~* z%n^gpun8w%T3UV*2GcC4isCA~>Cuh=SV?_6=ukI?8p?$3xE2s552I|gozHVv*}{!9 zl4&An&8`X`AsLlSTtnXUUvDu4k|WkXSjo&No^PEh zsHwd}GRRQ?x(D9F66S|0m@8|**8u51hF=ehY;id*55eNV8ANVI3JyD$^4{Jj!RlcZbPHP# zY%w*vuuegWC8h@vi3tZQc0l)qDXcs;W!-w zvoczpob%j@_I-D4G#Qr(N*ZcpPs$abr{h|5jgs7CSpe?qy#OBReQ&;j+PyiS0gYeXOSp^w)H%xD6fzu%t1_v({&cf}`Ac#!J{Eer)&>WyH|{`y+g3aCh(=Kv)M z43d-ui2^qNPP$WkN{^Q{|*Iw0YKOWLmCfu_XsgNgS;T2oXi*OY7XKO!w>m@%|J3Y8-+ zXpUHw(7u*Lr~+2ED49Gu>#POqAE51no$qn3ozOpX$dl$sBXYc49ag{4mlNTLAWW-` zYiut1eld3L+DW14qy^P~q2tsvr*P61wWCicw1$0YJZJx*jbO`S0K6`Uq`xKF;Sks3 zlvyZtQBLq7EPL@}-c)Sn7s1xH=L`1|)IzV+0IZK;Kv@GOP*>ZsmbxHkK!k3It~GC_ z$^0EqDo=Lgo!;SWAO<*T;0PrNBO;4#VpL=-furA4Py6DmmHkWB!KsCgh!YB8$U}cn z_%JuPD|8Z?SQNWWn-)UEqF*SZ1s#A+Y#?LFCw`lA+h50}toXJZI8O+Mt^Ho#2?pNQ zvziPEK8mTz^fOsn1{R3q8pPL8BeG604taA`kL+(VA8vqd+f=maurjnFWYwq zEy#oOXz3A(TD&knZhdSt@sDu_Werx<#%;u6TlV2wmUqhIaE>gao{A9{i#HoTr0#yH zEG&+IB(632$$)t2z5nh0RMY}Ku~4|`R2*-PHyvDozt4($4%C(rd?S<4NdYUuddVUs z^lM|T&G8N?fQToPLiTXE@&s??^eYt_#!HRQSF6&0aY$nEz#xAjeD@*K*tod_oZ(dj z?dw_lNR}0nppSZ~tf{*s2h#Dl8{)RnWQhTzUS^#JP8)X~z9)SICg^Vha$bBR#0@PG z=x;_)$_O;LfiSnTyQ!+@f$E4U+6rAvZ5d=wdkSf0_o}lu@>ZcRtwiUdW%eJm#S7 z-JkANR2-0foYXK0I;LF9psP`kEf47I8-&?7JEjayBX@&OKug$RnS zJ^Gai7(V`3Xk;2xk32=N_Bu}E8&-}Bxq-}QUZakYszq5PNxs!o0uf+1 zYc$0bU&O)k%2J+f*{DftM=I{T#%Q9>6IQy1q7zaRAKSo(o)2xFCW@IOK}?T~K`$Rj zv^J7YzBZP}pUm7;7%jA)G&wz%N2-eLQQVmoGm>f$la#sW6{l2(P(Ge>ONzVls888g z;NxuuT81zb$Ee|$G3_Kk0Mbhdnp3yQ`bX@N6bf@<l` zm&wGS>e({hEHB3GIZZuNB{^Rq)(zBaa-u}GuWFggyjYa6V6-v$fckae*o3hj2FDwm zjb+l8y^u~khBX%Xg^_Em22H99+$@VL<3S(;jh-9}Zn)9K-n(iN_NuvdFA6Ma0_c%fXk> zWYN#j4|eX)_}82r?j_s}qXF3h*?f;;VXx&1IgOEUR)#kBr87WKIhg^1^WXq6ff5ELS*9(vNxH_C;_AzP9byY0 z-Av-9lmo!!5`kWy0rv&ZN5AXYRDIOT&r`PW;$~MDyf(EwQ=BR!%_*o|No9XedFlqo zN!(K(n$EEC50GwcC-l9loPZcK${}E2fo~RwL?4CI+KgQK`W^KDcXRo!@S^ax*M9RC zrk zQRt8+O8K`eIM8;S%XKVHxZBSb&?q_8YaN z5qI(sxBtk9ZC~jGafUA;NK^G*%tZ}pY6u1ih^0DGiw;$!ro>I7np19iN8yJVk^nT5 zTlOMt?^J!XCre5MdwtAk4yzBk_IK6rX+xZL5t#&`SrG|DG`yZcBX#9}XS5Zb#+1iR zg@s$?leHpla$E1fYAb6(*Zd)_D|uoOSpOAG-dAf#UPLV^RW1LIX8`?u+V*3VDJvLY zwESd$AQ_%_vH(rpi(dqR-BGAvN4DlQ(RPtcd+`{kl=~Q}9a6h-k`{EJajU2ZQVn!oZop0DYy^BRvEIn0wI^m9n#r@;15uz4uI|6tczS@oy z7PD856UPxvwWerE6=RtmYiUGZi`qyGq^oS-xq%KxDh7cPKlcyAKmYpiW`Y@4sp<3) zO%ft+!l}!V8ocFoTy^^Zpe9g#<}7!YQy!gI^V)Hj7oQuA^e&UGC8c1R{3NY!3RP#|THxzSCvo7%ImAwYs}Bs=fJXoMSq~{$1{7#O)@O;4z6e{HeC<4cl6rIv zXXf=#aIJLogcUQlKw5AefoP*^AW2SpvpON8r$I2mV%T^j6RAF_nWYm+%ER&G|EP61 zFt+>>wb8lhG#Ka!ByEY4qpmCv^Hy z^Z42rq`LMDJdCZ&qZ&^dA+B@m3)2kpk;j@l05JX!3Azsux&Qs^Q3`Y-?a0@S9TwPJ zVRc!3NBlJy8xCXrn9`7%GLtzfY2_oNG1Pd0sR5@_Szpq}S=~wJl+m`t9b4R5+Frr0 zhz{OG8wd2XZdPu%akMSV@Zzz@p04w}H{ZZ-jxBa7BN5gL1z?DH2;*ct&W7qockOvT zeKv2LZYKYHx)N{ye?;ytHy;FoS8B4iUK%bO4RaRyc%+>R8wi6iUejoB`Z-;fNNr^dln0ET2|;<3R4EUm50T@VvIjFDs2Y6&wxT z_i&O{zM8(t&hom|=F?Xn?~a^%Whvl<<@6CU*Y$f~Ifs_XWgSBvIFjlkzOj;>;Yv_b z8Z)RsSIx>X|A*8;j0bSGx08L|^EK-)o#YJw*z7Ds2hm?0S7tJ13DfQ&70#O)_zLZv z%Oc~k$nz0@tC268NavliRxe|xi4v*2?t6EAvD_iaM;*IbHq!GKK}>ZznXd*FGj#|| zpQL`Bf{wjjx6%NuEWBAZ&<`|-iObKJV(?eF*jQjyO!Q?~PT9AZzwHih=OoC3%>U0W zCHhkt4m>Q6k_PRU_2 z0Y+Y%7A{d69yegd{Fd*ysn~Ex{Q{RaZ65H=z>R+t8Ok7WP%5@UA=alasFXW6hQh}L z5katUJXapRvKfU@pv5MfR3girNLuxaslamPA?9Cxpf0oNi%yIHgu;TYB;+a# zYQt_|w?^)jI+NqJZUq-!-ob^KOB-(S>I&UZI$SY;z4?Pq^8I*NvN~fw6JohrkNlkhu^kmMI-o(< zSH+~%TTG}{TdNN1LL-=xg(Fj2M)=mR%f`ugPt6Z8$jIA*2si7bT`E56uF?v%MK-Dw z48ogV8U|bYebx~L)Zi^qTW^aF!)AxNPN7z^*Qd50+<$GhQgf%(FYnMK;>8WQ{Jhuh zFfu~H9?^cGt<*kfI{p>)rhHePl5CX>3%k3dJ|gL!rh5Xocu$dmHA=5UzKHMl$UO9% zc|%EMz+yQ8f{Ld1SMvON$^Xn2r9RJq>iVedlOvGaUNs^UBfHYQu2XFNeNR~i6NRSJ zCuE;gIZY6TCz+1~75QRdqt}th+t8eAo%wMhe08ovj#>yqj;^D(2U2+0)l0^i6zSh8Q(<_ZZO4!y z9|CxY;U%WrCFMvgYQsF#y4H~uOiUij@BdwitQzs|y;PPFnqxEDDk7q(~jL z8u8IeV^3CR(*@3V_GiJpc(uEf$ z-!g&U+_r^30^0KKgj?^kbDg4zs6i0*l`na=obI_a;%*4#-4eFqmYoqDNT|(=wjzht zC{8}bu)`YY@b=rqXcWBf&~;A53}4GCJ@S$~eY=FpuFoZrjat(2I?>|re|*hKHylDx zzq#o)7uTqC;9u7Po(<k=1@?iprFr{j1Q9Zkn)+|6*5ClGJ#%BrF}Hi5 zEibe&z~sV9Gi$4RQ({ZE)Qk<6{B1~y;#L}0_z(nhcs#<=P1-SvI6Lc{eLFha>TduL z!YOd%Q`h!$zrk!ELqyyK#vU~HoJ-BiKdE$J1}c972bKAze5Jixy@EagactlXe)QV) zXQPfvWn78PGp07xUw3-lqXdu_%a@U$FeCSK3Gwg4hI3Ic*Hv+f&k1T~)>PH1Am~|y zwsY^7rNz#!2heYJ7I#^0%u^kiuER-pCBsqFMt$C48M<6Bb&gN&`n~@x)*&iKhZCeC zc_vnDp0jOd_@YIxPRRG$jkckdz1yNs=z@rUiu;kP30WgpW~X{DwE!LMEoE#-R>5>G zGmKTUi%4H`mvVMF1H@L&J!_c#cfrvr^9kVm{*keJQi8A3bzmZ1G~x$DcAZZONa`GG z&|~p%@%X;cK|X0>c&Zmn-i9m;p9}Qy9_~`?O?n)OLm^E^BB3Wl93n?VhWYOe6;s5R zA3izYLQeT9TVill5t};r?nR50Gd;rE7aNB-fUcoR90%rPJ)cY4X#=u9RlKpC)|E}< z=ypuzK-7}yJ6A!FiM=r+ofu9evvKz#w^ra^=qZ!f3tcJy-Vxw!!Ng6~Q$Cxd%? zzZ1!75hRtz+9G2q?ZofDDS}yXd_8>hJS}xXV;J`!@v&cbu^Kei*D~anryC*$JYAHW z-B8zXyuhjPhPC2%A0c}8npN;l;cg;kqSHN4VrINH!`x2sQB9>TsC6w~uuMoqZ~@ZD zjyvr`;;)qxI-B+hH{+JF&CYDoiB7hP#4|t5?s!h)pvHRr&FMFO_797{9fy3d@{*pE z7ir-727!E5DHRoF+#)u#>1StN((aAo#+$(DKzcKK2=ee#4qr95%az`&J{c+34?PUx zAjI5rOYuH9Gu{8OoS3V)G<7LfjF1wohKFoAMP)$qs*Wyo^e>fWj@h?G~!Fi#XROpYb z$U?h2a>Tx}*pu2{^s;_;QS%I^rZ3KX!xm&UT&Ufr)e~sl((O%m`%T!YUnBc~{ann; z9b^v8o-=M`thMZtHSft(bY3@CaCUj6l#$L)**{49+%TfpMqhvEOpAkMEIw zwTd2!PUqhBbm&ru41I8RS{NJPfw%D{>Kol(SO7NMG9ZaHutiISsjMdAV-t(rP!%GH zRNcd{-F%RrLb$=amCBdGRJO&$;@%|2EJ_lZhpu807UcS0=0l;L_J};5!a=nlR{PU0 z6DNO1BV6-R^!5k_}oyz3{ViRjovYx-nzM8HU&Z<|^z& zFLVNOnVA^Z5^-^Z%?0i;<0T*irLxUw3{v+v(ULNtiz>pbw9Pyj?c<&5No` zHS1(eq0if9=TF4;yT?PsA&4w3@>=Ni9oH1x?_G6s2~h)BI$3s!FT4RI>=udaM_Eef z#F3o>zryuT;ljbh1}${M1kOpIfqkGELwY}nfqAt$Q+djV>));<(S$xNmd(&@C^4makHn{(KCLxG`DkD3I8m4ZPn7AYEO1?ko%7 z#g^-o@MD$q_rJ37z_}7tf)aA;60jkRXAVK;xq!Xme7aOY;B?Yb~_o91RmoKEhR`8wKX11LP99JK~nHu^H{%iIUEBnn== z=_v1+z*iL4H^6W%N*U=`zT;95qMZZpzvCvEB^OKYq(SXoM9Qp2GnkyO){P*$H|~XY zDF1a8%{0hqRM;K=1)cD#%OfHKeq2`4gA?4=@IcyLhz3|K!kg#sIQ1ScG=bmEU)+hn zi$U*4n=aVte+IECaC&!(b__kwj{B`*ic%;st?E?UF7DA1)Zg9AZh?F0M7v^EgYLi~ zm;3d;VhG!9^H3ybkg|Ur_0n&7s6sPe27>?*dtkq&gnYNI-4Q9|DP#=0VtoNw`a#47;9aEkE=);5EK*WohqzrKx@}|;AApJ8` zae;G9qs8Mib2JDhAvFg~Z%QbHYiznNV^FP$o;QsvYUb!!E04nFRViNhkK$8VF_Lue z5(}Z^=~xgy=`uKiI$4LaV+fUh4=K`@wkBV5V73)HUx( zswr-aARfP$sYqq;72^=k!}4Ew-FuFm#SxR6O~3`ZtkwY^Q`9L%I@PQ!oU?OA{kx_B z-9wQx8Du=**;iSdU&pz`7i~aQG+f@oBAVbeEy67P?*=9B*(l75V7C}s1x_PZJZ147 zGz7)I5Q@>j5UC9^yBz$r!CV?92d{#}Zvj6MSNs#VG4lfQh~BHF3GKz$da*jVy_@3x z`1b`px3*<)n5SFcWu4nbVeZRxMbYeqcEV@K1C(_nNVgC_eGv}vxcqX0gG5!AUc(M?onGf{ zP>!FSU9Bu|mFWvcO&s=)hF#`}6rS=yG~!i%_e>iO>PQ{N{QZlI1J+-L()?oP$$&!r zafiaS-csMgh(nAq?}AS;#`H((2zKn+wh~xLCmH+>rzs$BcMxvpcy>u&sCH~r(CR7k z(hWfX7vua=ITYz&CA8SbfKJv5(Qb#7ydQ+YTEW{&o&R(M-;?0dlFjZiXF_p{d2!Mu zl*TZ`@Vm9fQxCD@5!TcRxQSe_8l>x_D#QgE!o1EL=AOevzgY= z%Uw@H4^_Nu4oIz&DrQ!Kd`^O<-iI%v`Z*D-?f645U42lDOg9YI?w8u0$-NAGA0r>3UcaH;eE1p^+KrsUPH z47MJ%7MM7{X}t`DOE&1SYKY|pnOG22!OelpxeW!d;gt@UaN}A8ppoESOcX{#HdZHu zg6C{FW!mw%&gD3aWK@*_#&ingUyB*ZPl!G0DHA60d^40E_iN?pYP;^b8$#0#Xz8QO zb$OFKd0<0cZ_*lefd)T$UOaszlFLy{G`8^P|5=cNaf9@#9e%i43{xk-~-y8mV%3DLJfPU-4JeO$g0SM+!H@BykGKs0eSQdD|5v_(&;2c{V z+__Ky#`?;%NzYv3f5L_YyF7tJ(FF<4u>Nnhd<%9a5BNA6BW>HsK+Z6v)3eJ|Z#Rd| z#rY|-;1g68YYx+MF6uOpkA99G|AA1jIbAEMBWLrgSrsJ?E#<~{l-*jhpR9u1T^aIp zWIZndl`0zcSHxcwC9yY@T84VSVR-}4bSEj9TFFYg>nK{j5_`GB@+8|Q9MBnIb&_eYg3VFpVJ!s5|(AtmQ>w2eM` zk0Qz0<9Xo7kBQ~J8a3)eI5l%&SSO7W_@2`zHEX>i5`M5m#G?wx1v&9I%(koNgH9kN zQ(+cW-A%4Jd5K>n-?Wi-uq{dc&PiUK4SGTN60nlk8U@-F)RePNaRtS=`6T-$67s?M zxoZ<5zSF_Y#0Cr5Yfsojnp!|N1HLALg}UHtKQ@PClel5a#s0xGClPfM{)%uvZn>sgPdJY&hY4~{2ypJiqis~;biWlCz-8I?&b4b8KYn7PSG zb6s-vpI{_jVq)IA<~@78l-Paff6|1EjBlZ@wB8rqNJ^N`(~bvY3Qhhj&nOy~#IES5 zo#Ay(RY6K(w<48PC&Cl_8L_b<5uw>kL>0Dmw4QRIB<#ZQNxS8#X;Xe_qH5n$Kf&SL zW=c}KuiF=5tq@iPVygvKac#x&Pwz~jj~=NC8k1nTHoYn$jcs(@Fjz67Vf#3JE*jh3 zHm{cFR+l9r@BYW&yJzPzUZ{c&&5jNr5RM}rDRn$qAzKkl2S;lElI^Z;(>dMFSeN97 zLESqkW5<+zfQnR0ZtZvXxf8ecDzc&GZxN>TH;~OPTbY4V*eJMvC*9C`H*>2{du2K& zmJI;9KaMF!?b3DUkD#w9tF8k=6e0ui%AMWRREZ|ux?5G{m()&7I7aa8P03FlYx*|WG#@ZR~`Zg^6`W%uuNHtJW zfrGu&;88kc=a3SWumx;)@vlK3i4S0ibigYSFD2_f_v%`GWCt^1i9QohAg~1C4N@Vs zWmxJPYKA#DppBU~28+s`2d%5A!CVxrSC3MYnywvVZ6h6R()6s+#L0}TOlsi9$}~vh z82GXtO0T(!xEAkY@BA$3ZU(4{*86DJBy-7Es;3obV)|%HPtIotLon-42rtm(iAgXp zN|@S;f&o83>6(okSOKDLR)+%l;|Jv?R)bUGhQlIJkd=ZN!)4-bzz4FS=XuJ0TeMiU zqo36o4NgLq5yb-o?0D+tmz~rq^hkkqf}-H(*k^tdRqzK+g7E={oETPY*kB>FnvS=( zAuhX@)j^SL5h~WZwRh3HZ@$=^&4-S0_E{Jx0|tr$;acWKz`d8_fiUL%7?e2;=M&?X z#sT7#E^L=HbmO!R#M|*0M>XUS1Wl>YO&ZSAFMws5+{Yi0V`GdMs%)E>!dXXh}k0G!;o&4QvTQM^~Y1(la zd<06#Lcb`qNS2~eZaO3ov6lHXG!KQ6w|+Axs3zc7W8^i^laE#^f%1@QV{{3SMM$ooM5g+~>bm)9!D*P9ahr70mDrf0EJxrl<@d~~5l?KLd|A@s zVfpeXno#)iG-yy#%svq0ZF-J`P&!US8?dof%WU7p=x>ElGYPPvZpboG3-P9S0&(|{ z>oVZ<(QXP|JD3ml2^{g9W4Wz%t9lbr(I1q=;9mj?GAZ1E)rDAwd8x+QOwyR~4PN|817DWjvG7&-ME zwSf?(HOuGjNVK5_hfi-oG-gVNc)=DTJlQocq(2%3t) zQk7~+B&5%DacQCVJ6eL)dgxGmgbBrD0v~WcmTARo|Jv3}l`3E~q5Z$slTzThB4~Ir z@$z^M{A)&CuJSi90h7JWgDG1xL9NU6mb|es${iCn8vZC@&ter9B+-#NNm*vGquiF} zVX~Bc6{`xRf&{X)&$THoc+*s$aFrgvdJYX4;D6m69Jn2LpMMSuO}qPoAdVgvzgxNI zsNjgKIsD>*wdEK+e*tEKyr^jO7+XoY1~)R{*vzU|fqgCa?(-Dk^4gA31wFGV4Y@{E zw{_s)@^MRW!d9#d7Ux~}Bj)t?ich4@Oh8EC6hQ9LCCq#(EW$1?p2v+GUuVLez&6Kl#Tn)(|Z)m*!Gsk$s^7a_3< zkS-eAT^Ggjw0#`&DcrCj4_&KO?iKrsXsKM;*);(umy*s}sH#aPeI1hf$$E$v*a`or zq{cZg{+mynI?1$YoWS3g?)15b!Lh>H1?)Wrnr7$};pvU8)~vJC0{LI7S*i;&psl+L z7$Gy2{LR=z)3Gl4k2kmFZe$s@@NSO+IOm%z!@*XDw}n ziab9W7Q>~i&k4M>fPa4ZXkV%I0ra1K!HJ2ZqU7WxLb*B~rH^Xi$J<&le7$v*O-w_x z3z`T1O@kk;{2E}3sR5Afvr#1R&Befxk1G#}ZQxF3U36InGlGfJPa)|H7K_9CvF0JD z`0Pgg4Mb-!5}TERB;YGWp`LR7vLW?2Eic#_a5~aXXKvCDE5~%D<%JXyI_Z=&BAPAK z>Y>eSS~?4DHt6LoPO6SeV8h*rmTB&!tS~vL9qQWLf5e9vlvkO%y~Y-e<{2S5GUwxR z#afhdWS3F69cp&|Wl)BXXJwHKAo*b66EEkew)P`M@F5Z(9N6Xp^*SDq=?Eb++^GD3 zEW(bJB)fa?*sOeMCluLjaPl$B4t61B$|qgWT>G=c8*Ic zKt}Lp#3}}1filxMC!Ve%UR4^zRHw61@xcG31q`kG<3yQ!;?di}*Q|BnySGR5I=Xg3C%0w^d3DV> zErO`ji&UOU`AZ}>DAg&ZRMp2((yB9P$ZN`G9|Is|D8o{OSzCAb&o~ZeoZB?BJ+XmqK;-J+fqLG^`NaE;wWi0mm(p_L9`c&>+@ZoUxPaey(X%Oqwj2m0X zn}R~fq7hKzcVop1cZMA9un1PDQXq_bYMeb_Q1lS>!GnI~_%uX@ig#n8Bianf9XpRIl(Y>mzHv76!DS5&@F6~2TY=Q*Eck2iH?ekFk+!q8aa#!q{4xJAeI zFd&RRvYMq*9OM1-K~>Zc>987cdg2Rv!SPOdlz;0?pe~dY zG!FrtE>E>)!%R@*pkZ?b(q>un94wVWkN1xc^wcx#X^eq%yo6@%pWK5vjspx7Nvrbn zCVVeXi*hB>jC(w6&KeGYpyOfF(NB96_P@(m24L`BE$iLal(kPypFeUh(MO_z9Fm(F z(?~Q!|EgrnZmjaN=34Z$F&y94ZLW0fl^nE*!VG*lZp!E*u%Jd++e`HsRBn*46^lWS z(Y^va0U@$U(J@urQT$2&>-AfZ$7g58sPi_X0RKHuah6dUf%VB zECo{MEwcMx^GZ$}b_>J>9z}<8w4wy>NinS4QhgE{zBbvI;Pb6YH9 zCeT%(to#|7=*8O4E>S(H*psro9YwjjlQug6CWtsL6quIVmG%blnJV){+5L1&VZ_ep z$^hFxZW)3qnYdW^Hn8FnkeSq>k!=n9^lV+rfHoXkX->Uq9C!GaUok(4O|2_+QTCOy zV@1$-fdHnG5+6Rs?a5^YnLWyGoy2+_&l_+hQN7aMSPI zB$jCVG`fYM;MdqaIKrhp(xS$80KBZ6;0vkS=P}$bpZmHk2nZ*qv%c!CBbXo&dnWZ( zK1Wlc%{X`BcVIhG;7_rQEyK!CJc_g+>aj%&wIMNJuyUzrk>s#3PM~Del#gVE&}3Bm8O2Q^V|-*w8B{C4!{La;S~Z zJ>~Dq0kD^A*<5|Ue1>MmqWzOt)K{U?j_@Ubi4whN< zXK%b7S!BPA;dQAu?ae^31HY#ZjPA~9Q4wyfkK>h_ONNE;Cz3YP0iK=Nu$hGA`Gi4e?z(yNl?myf9u;!rd2AYGasc*t0F zsgx+4<6IaxyYftjCBok%M{$;BBHje+VTM=Q=X4e+Ve+GO48T9pBYb;nRfjdR8$Oa> zsrVy~V}!kxh-I&G`AaDSo=&#MP+zA3Qkb;?+J}_KEv=uFPioD*oR-Oqu5^kBH#NMQ zQ*#){vKvwhFT$|>#5WO9@KCD64=DHGd{vuh)kZB9-x+)FV9NZ7HjTqj)J6ZOX=TdK zeuLHMO3oWg)C*Ue@$TXZSBqQB^5|GF^$7y{M`;~JRIVl-xA@2PXg7FUzVY8(u|GNA z;$$}OoN{u1`o2cdjD28`05CPy+jf8VMX?8Af|mTJLcxI_CnSs>!;@j^6b=bp4YDnl zOD4UFP7BoU6z^o_HvDk@9v}D)&hV{2Si${zJ^;o%WSjvI)4MA`0LP>~AqW;-Ea$PN zNEaHrrNs8g1c=1}q+K%WsI0sN@C%rB6Tfm{bt*-bK-BpySokFm1gqcjgA@nty)|pQf)fkqb~bW-~+eAQ48vSJpGwqX^?a{f4SX1G? z8mO=ep0yH{X>|S=rns2i@e}`qsx=%~uP}-(;tYux8)Ma991xF5GifyqoI0o|iLe(o zdOR5#zJ*?3du3{qs`}#P0!N@2>ywpw52C07J+3mkYGUx~^~dx!!utwjd0OGYsei~! zM!Joo>`^8juO;q9o#oLiIT2;3YsL=Hc|^ z0TxAAOmD=cYqb10!U@UY01!oyjS{vK&(O-PEK1ovZwPgOt6_5v;=~S}w^ooK$ChiH zwi}DxB!B-y^!Q(a9NcTh8&*gl&!h%wi_YrPr0))uA&GHV<;1wG5(iWQpb>$DFY*}{ z313cbMoiS4RGdm{)XAvh{l`fq4ycnOMFZE=N{e0B9N@AY01NElbicZq)v1D8h(!t{ zZ1AxHIKM5Fj^JmsIER@=uw;kV#hF>RvXnJ)EnYQ)BHr&L( zJkC73f^y3!{ZF64P-L!{*_b~Trf=1;esSgv+v(*(6BSeS+tovnE4Lybblld@{Q7}+ z`YyzM!y|~kVEvlXD$`OW5fYk1CbUVIv{QHlG;9*2*c_3(A)qaC}swN&~ z;>p~I`jcQx`{f4(=O8cE%)vI;2Rqa>OJVP}RtBoxEC>dWYbjn_u*#!)<)I>cSW?)o z2fi6@;qrD239TG2^(+Narhw(J?K}Eq@Kso@-a%kCsUS;p>9YFA5b&CU-Us4ikw;mx z2TKf5i;Q7*3slO0_(|Maz>fdJUi4OSuhRf|8Iuv){Vl8QC8amky23{ zgwFDPw^Q(8#0)DFb$abzD*VLEB(&HS$Cf@EM1L}|RR##R2xqHq?`)E(L zp0~4XSOyLqh-9Hd6@{7k9a12^57$8;vVyP{;ANLl6eext}*BFLW*O z;Ye>{^_H&phwWZ&KBhM3yWWNChy|3Z`~~@OG%C&eaQCy>icnTY@QYiA)BJJ zHD3Kj;RmHkURQ?E!5&j!6bpM+dGomS;Vp$hRIBVm?$NYm7jaU4rGl5=uNu5G1h(cP zu03g7wE>RjxRd|j+LBl>^$YTNrYhX|MjoM+t(r%fi6h_C>&Mz7uo9v>6^1{Z?~vX$ z7h=Y2;EQm@|5&BE{C3{eUyD!hB>%5!8b#v_-)+;&Q{!qg7xo2hlY!)S5qzdL%do-4 zYHZIe1se@#ImUNWe37j}n%W)WzD;fv&MSE!Kh!q$)}urgd@dK9f7<2{X9a-3Qt{SF z;OM+~0ko~o#3X_I+$o|(hRGi#&)kZwR7T>+TzO-6MHHG!7F}(DG_mDo^qH*Xae~kt z7BjrW3x7g@;RLSRBIT--!8Ed~WogT9%1j2GYFe z(GbbF)J6URFc+3o?(yMdl0EsMN4Y za~uaoe@io#&usMFSR%PEfHQuQ%~*fY+O^J|93NCBBANXJ$~{0j4-9YCcO#QedBnGr zgU*m9dzQT0ym0QqS3RTT0!7jna5v*E==2lc&JP9njBmR6muL0ft`(il=3Lb;Tc6QYeU#Z&+I58sr8B+ZFAMlX2Tp(Bf`MrMT8(0sH|IImQ0qQlNV(PLU{ViWDHksuvd>L5F0b>`(h zygQmvrvBAVtHie8ybjH)fJoCX046+k>ka!Lkk6&1^xXQZYZXmd`}!9EFBMy_!ADiA zAZ@414%qfCm|m6zYo$H*f5eHYml4w{%H$pDce^0x^cc!%LMl#6xuByR(ESw5u2A{j zCW^!IUC%qI@(E8Lqg4TIl~%i%hzmB&+Fj@WxN0QXylW>bTcrc7B{f`8;V*lmrO?Z? zuF7n^qef?jK>sQGlOb>B-ZN5W=-TLZokJotQU7h8x1#zOraiynV!(gup{#frt~#1? zjicqWixbj!U(B8+_I&A@q5aptyW4XwR5u5r^*Ojth^VE3X;lpfegxXFAtejxq&P56Ptu2&6?$xtmC z{Q(CutB0jP!}T7|@z$&+9NtHiybU!7Ss7h+%;tkDF|CJg;X@sYP=Nt%dh_L%JOj9S zCm{?xZFm(8-X|V|M3&SQ@Q)J~K+W6=*+t zb}KKtdnn$aeknEOxwU7LlC_mO)~}FmDo1V%t}SSHMP=XZ;rqa2UA=lefX;j$I{~%8FR03F^guec-lVqT zdT>3^?fu=4O_Jw$SR*a?jAPKQ0$HE{v=3dV^5vv%HP*^5jypy!7OfW z3(MV{FAIUO@~DIua2%dnZ{l`BnCMG!j(GQinA4~pv#9)>+idGeNuhwbj|J0@E6Ig=*4p`HzDF zD*XY)&Bu&*EE7l=^sIgn-S1rs`Dzl>dqy)vVz0 z!Wu&mGhDr?h!=L|s6A!%Gj&Py14$&=@=cNV7Ya}Qn764_>Femb&Qo{wk(wcNPj0|h zKzg=tV$71jo?L3Lre3^{fXs(KGy+jcEWI-u)O=nN301~?hr6xG2cOs*TD2hna~=28 z0U&wyp)5U|Fd?8qbFF^x)$t72oge(ZbSZE-(}^1jRdziNJ6=H&ql5*E_WK>>rygqq zuMNxMb*&mCC6V*@Fk@MD#UfyPw66oaM@wnAu1UueON+lIEDl06;gaN<0XNW z_kVmThhCWaG~XTtfR7GE&z;Hm!?w2%wP)B4$CJ-lD^<83As2fl->FpZ#3PAK!uaBn ziFkDsPk8v-6EKdH$l$aD8EV4<91hB)zNAYUvF#d6!zi%Q!3HJEl5If)k79mzEgHgD z)z9nkF>MFfu!2Lh*wvo*3K!*sfsOv#$*{)uTa)UhgRtMkVT|$`pPt_qS)cdI5U*Ac#1fB zOY&Qo{m8&-plm@Fvuz^{5hE4=i2NQ@fWymHr}TrG{ak>?gWDM~+qz2{p=Y)4Lxd30 zl4J0KQ&s+$tgN*}l49^LeVz1@pGlFd-jILLvcF-@2lutz>SBm8+^v9m*I`gD$`SH) z%zG8Nr&YF3@s0_?$ayHz70!g4Z^}l1aR7*Lu*-iW8%E}0QQ`Q*I9oDozZY+egi1S= zSOHm7zy-r+_@r3B1HT8ttBh;wL!qOP|I=2xKw1F~bDA!}L`8OUKw zO1@+5%CD6FZe>!gX+M|SvAO+CtlZeuRG{`Z1y#Dm<1=w_r5!%pCtSf9UFi_8WL=lR z@?&0WyqVK9gS>8$%PMMx46uc!r%yU`a7if<7D9SrPF-WsQt?iPl0sRkyON%CbJz;S z87(9|+oV`3X_V}JMWde!g%3qVv3fpOM9?Ate@v!d#aBAw8`y*$fQm@8ZF97(@(44w z^1m9^=c2txBKhZ)R}Gx3zEYZwJIDZ9;}!4wXglTgvs@t`N62~gCavE~)svh+4(Tf7Urd;`rk#?BeG+7sm5ms;Jz;-%}Gx zx2gl3T-czL$Ds}NRqe)PiN%5|5QF$|B9gw2Cvp@+A+#h+j@TMZxq~W(*3lN@8+aR- ze1iPCdf5{)+GazmNhGN&j2*2oF7e1ClzFv|C8RO9U}d`*enaoVMm#c^=(X%Rb8&TQ z;+;WM$fBp=V+oShO=7XzZD_3G+9j8?z6#{6KACHi>}Y~j7Sx2$)#MRq;X-0&k(BrO z7$T?%R3dCa(3Jsg8H^)DooyZQ+xhgQQ?n7V$FsH2(2lvXAdesxHA&ds>;#Y6wY?Hy@7Qhwd{;=*IDr(n+lbP!({ z@HVV2@!rgH5|44m+QaHj4Qafy+2tD}Ci6mY4ll3~1%)sDfY^BZ0YS zS1OwYscFD=VN|&N&xvFPeKNg+0M;?L?x4k|iHRro_3v5UdhM}*zy8V*5^YQwx%lut zh)bqebr-;KZ(c-nw@gz@K~-u7q%gGP4$^C5dKpL2X91O+*WLrOI`_di@5}Mo7>%~6 z&mPd;CEeiXdG0unm*Jf0MWP4m&u;%<=db*sA`?jc335NJtEOKn9ymPtBZhqa&rL*} zKu{u!oyoJ*j3FRA>z~SrA3H>42>qYwmNjvexl@vxD$r2#&zMOsExJToXJ_kyR-yog zek*}8dE+unPHeH4Ch~oNP3HW8cL=e(gpO@W${^eX$M=zOlH19BdDdemZXG%XAz@iL zmJck%sr=OR8?=>_y)Q)~6zNi;+C`1WB_k}YdqZ3hE_|lb4U)*yNELoB@r>I+27u_4 zTdkLqc~DOt^TFrTox26+OjHvmd_E@G9MB62&IX0Y^3BwOC?jnHO#WidYf==6X+P}K zrbI0dJUFTApQ&FIZptzH0sAp^(#lHpUg-grGBG#{)P|OiPbJ;654%AgCiGw#9CESmEXJ3JA24CuHp*Itw2D2fvQz#vD%Es)K%8#Q>55cx#uzEp1e(4E?JKqNU#l#pIT_2FxcGByL zkzFWcpAT2H1t<=p`~OKr0_Z!L7s;jGn=XJG;=rEmSL_{}XIiS|EASpBxW+sT(Iv2RYb$pvpXh8NZf>FDZs$AxUn` zq9(nlHdpx*`^4UGl2?cA`R3((BbnzvTJ>W!%>sDCyc#)y^RsGHX^;cQcG)h^0L>h} z2Yyp|?oP5f84^u1dv@yrTDy;M6GRkAs(($5N+?(C_l!pHngF|SCsqH}D+mRw+uBb< zbCRscG5#yOa~yI=hX0|$u5EafJ~;eNs1a*y4y&JMlYoXH~TMFP>okepTxqpy_IQa4<=-aH2s{Mt&u3n#VG=4ik13OyA~2yd7Cf$A3cS z{X)vz%l1*J;NB3?u9QU)zKgP&y`r=+&mV8ns{bF?MVAD0E61Mxktb;pj~BjvLb7LeuXe@Z4QGk&0O;DR7yZE=y9tdT zS#L4T*6>O*`on5EoY1Nm;IZLe??3i3sl}X#>LW$_ z)wg)A(ByIy%2Ds%{sl}l&B7`4#X&Z&wlEFDgF6NNG*-iLj(L-IDT4cmIi+7jy>oRD z*x&JCQ!))f@r>)H_bhQwQg10LdBLJhc{l@und5%lnE|MGlvUzFM=)jFDf=(dBebnp z^=$L^1`>bfc`w(Py-u$RJqY)m-DrF_cK`s8&800PS8xj>-k-;d8+L;2ZjC+j&j6Q2 zMvI$QX?#5HaJ_|v0)ILcq`dFQ)O1^nRS zVE1d5_4!+M1fHE><(T~n%_#u86Yz@TaIyQYNZc5B4oy2i@4*ME!fSceALZcw>S6sW zd_w~o!bJ7hA6Y@=2dx%8oTus&jBaKgFSRt#a2L$8jULg`DlrCYy#kMrmKm5v2*Z3< zJ^JFn%m<&HS*=tmRsn3Pg)x|w5)0Cj>w?Cj1KJh@PJ>bGLuku+@(?+}6~x58Rv#Oc z$r1?i05pRj{@~WA&jPzbA#cF>UMc1h4fTdg)72r7gwC;StHeI0q9|e|y;>%^oBvXZ z45B_^m}~L%QTr&yQR5}5EM%(+Fu{oBINAp6WV(GojX8Ch2&|+D-eKC6{22^~=eJ#i zpbVW<5juz6qG$$LV6$sY@&&BgSzi>j{J(|z@gTZ$I$fM+=j?42)6cKv7TgR3dbZ?c zj*OM0J89nh%PRjkv-aVcC>amlFz`_1&(|S3kNc|4sxuh(?+Hbf{kBha!La~05$wL7 zZ6pmh_m=MpR*< zo8d(tj5RRhJYnFq8X?>D&fW#)0f!fmxt4CD=xf}hs|T=}AI_p;yxhk6O6>LVuxb;C zOk8GA^O_)i)}u?Fg7PZag;+bkT^qhBI&YDk=I#g%HO#{RkY=j6jQ9v z3pg@Ag)O1!A>7rGgNdcM32>u{3Ofv5bqOo}1=px02jL`B^{7gpCGgMmd%D8SG}|A* zG?W3NC^>Ixi7~b)k^qAdxt;I@iD;QH|C5@C{nNrgv6sfms-1Yv`(fxhIc>EA`6mL4 zom3IUf)aK=@RxcX`ydjC8owXMy}pvQ61#mY*_24*-jK49g2Y=Jw!SVwK196WY*6pg z6zdk8ib0{wxaadJnG5;fA%suKVRf<;JSfrfc zi#^aL4_zO?ba`ZXCot7$JOK)^5XtSpS}U;_A|(TXxjg@CmR^jh`zf)TzR>tRguPyjZq$Z8@-(U4*qEt8 z5=JyaY}*n!8Ml5X2!(!Z_Yp^YHKR|`ftz(R`&5(N=nyKr=+Bwfo&&;`8*+TN$FeQG?kc-$C`mYF8d4;I{o4ch9Myia4`ON-o=SHdXH(`NLrg;^n^D?AtYh z{VAjr0s0#6aP0?*I!d^aCSrVtf%^tlMrp^Ff)37ZCvdI;xnqLJ>axX-hlC<4fJ$PV zsJ@~)xRd|i51i1lE#L{Q<=drcH+yI6} z^5}Wx_wN=@#4XO?hO;()zOd01D|=&te0>Gb#gPZ}GuM-stY zmKfHriS5R|30bGRNnA-hNe*b2qoW#vDfvZfZ>_B++_jML3j~1^a8VH_~mOW%_9MftfUfaStz0PSF zvQfFL32R;aI*8Wge9oB|wniEkWR=Qhwzqf>tso+XbaDlU$rc+m>AcUwn204HEBWw~QCwbCk#LHVSx zWJ%`j!S{ExYad7xpC7NlADA+$LMzk_H8I0+QQMmB2yoH5Hoe@78FWPw9`p_lG*dD_ z?R{tDA_;BNJMK#b++^n9K|b(MXzgPHbHyWU!NA1JJn_W-{7bo~c}t+a>86fnaLR52 z?uWHL>XMSGstimD<`R#1tjWws$wBfMZ$4sK)TVa2NH4g;W9j5k$Ey7*5*kg-d}H9o z*vrwH0G(cZnd-Q~_uY0NjOuHdkTL^|>7Ocsb2PwqvwmKw>`~zn-Zfd_xz4fbtcg5M zTg~q&<8%HK%?caX6e)J56IBRhlkb0iX?lM)p>83RDF}O~Wy^`wnUd#j*K5-}P8gR! zn9&-Wna3(*jRmr02S`}fW|qb2B&m{u9*34rdz+4tVT{$Z7bp|0q7OMnt*sb>n^Z;^ zxhOD8g-aJ_tT%nN_E|71`FU>boGh`T{fU$5|&p^a&q8K3}7_SWYs1c#>l}cP0*iz>I%Vv494C;&EnW5FWhbuK)ru7>a39q@^KTJxO3-G zLcViSl-y6NR@J1pOFizql{?|h7<2eW#6)1gU+Ec|bC2R)<_%DL+a63TfUF36K z$1Xgarz02|bv@R4SHQiOLCDA1U~|0Uk{Z=2`k^LWcyu{WTULLmhXO~o8k!-+OQ-TC z{JgV+C1I`UUvmTb$u^)4PE_!J23tE#ps>>2i9}BaK(@3da0p=EHQL1lPo*wfqG%go+r?TdORh;W+rO2^Fe zp}WkMP+ZF!VUH8u;6N;N;NvUpnH#A*xdHa4ck*s2J__c3Q08-_&-2=r z;yPrud>_xB{c|pn9Kj3~XHru{BnC*&RMvy2eVxVC1oxBd8g|cM-ZqZ?W*tcXQ~Q#d z%NeDvuQx-qyLVnPfws3s;kjhk%n2|02ofr`{NQmN=^;Cdf^Hbtr3Hp~s3lDXce(5tW$F zivv;mv8s{}GzRAW@Tap%pi-_v^-5l@@v8a?=X5smhqMGj6zeQ|{6v%n#;snNc$mH7 zLoB5Vmr#twkc5FFJAbp$;=iju-#DVO0RaEIplep2twTk$yuEUaC9XmIWZ zVh5R1I2Bf8K@gt?K<FCgO`DWNC;}m9{YxxtL6VZt+KuG|`fsNmuqrN+2cTX)UUQS+nu`oC=;-!u zvJ)`Ki$aQ-r2Ro~^AKj1sTTcCWQ2rMwYN|df+@b2osraPQd z(Vy@upmYwV&lr6P_k`_1!fr-*a#l;t42bPW&PN5_kid&<75`j)W_^~5R)XQ2riL1G zKAHY8fG;KUaoL`+694bZ1E^ zsAeo7hB5lbnc);)D^@2mWrEk@)L{R8)W7*(ZW14D5_9=Z;fE<6`0FMxQxgo;UlnOs zLL$f6+r!YKebJx+29MZF{g5fpT%geU;V{Mx0mGDts#s2StSbcBW;C2hskpj^04u)l z!SBxTdQzR=em`MCK)Nb43;6VrK+OLbjzz}opUxdHBSh;gR15?|C<2z(QS981VW@|) z!UQ8;YvGA|GM)_WG%R9~d1#iB=JjU0?YmjLYCxZH;7~+hS8Q(Z0yr)No?pNw&*%?; z2MfEVq`sp>3OXn&=H?c5P^xr*om-(Ok9l#H2goj}_1XmYj-$>?RqWcya5R&6;9DI& zDbN8MVGHZP26}+g@-eUKzV;xjWvdYRL`c^rfu$)EISG#u%@>+pmRYncfv`cjdht$l zGTxzt0CT;nQ6vp4nI)DH?_>@T2n%l-I+Gid@=AtxRIsVDzC|gsUItgW9T38NVIjCr zNlyq=DVb%eNZbHCeisYXcn|keZW`KiJd978M!-+_dIq*mSCfL>r2xOS->?EWgr(Y) xmt|DObewKLcU-~w;E_lJJy}#CR>Ddqzz!6m5k{9I0F7Bce4aSyK2JN+007NrqYwZ9 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 0000000000000000000000000000000000000000..6da93537305cb3e60ad0573043bcf121c0c380e4 GIT binary patch literal 54690 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 LoQs%Yn5F;#%cY{O literal 0 HcmV?d00001 diff --git a/showcase/crate-stack/crate_stack.py b/showcase/crate-stack/crate_stack.py index 525e8eee..1e1c848f 100644 --- a/showcase/crate-stack/crate_stack.py +++ b/showcase/crate-stack/crate_stack.py @@ -1318,7 +1318,7 @@ def light(name, loc, energy, size, col, rot): 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.2, 2.7, 2.3), 380.0, 7.0, (1.0, 0.76, 0.50), (-92, 0, 196)) + 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 diff --git a/showcase/crate-stack/preview.webp b/showcase/crate-stack/preview.webp index c4682541eeff593693bd4693b5730657d8048c30..c2cbd00badc75b1bf5575975b2efa20024c91f97 100644 GIT binary patch literal 25364 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 DX{-Y} literal 25462 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 From ce8a01755a8a4b76d2f9a246efde7ad3e35427f1 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:31:42 -0400 Subject: [PATCH 3/4] feat: add stone-archway showcase piece MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A freestanding masonry arch: two coursed piers, projecting imposts, nine voussoirs turning a semicircle, and a keystone standing proud at the crown. The headline budget is the one an arch can fail invisibly. Nine wedge blocks laid in a row are still nine wedge blocks; what makes them an arch is that their intrados vertices sit on a circle. The piece recomputes that circle from vertex positions rather than 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 of at least 168 degrees. Measured: 0.97 mm over 178.0 degrees. Other budgets, all recomputed: 924 tris, two materials with 182 dressed and 364 ashlar faces, UVs in 0..1 with zero overlap, outer AABB 1.644x0.510x2.057 m, clear opening 1.203 m, both piers grounded, the springing joint an overlap, all eight mortar joints at 9.70 mm inside a 6-17 mm band, 168-tri collider, non-empty glTF, and hygiene zero across loose/non-manifold/zero-area/doubles/n-gons/coplanar-disjoint. Seven falsifiers, each breaking one named budget, all proven on 4.5.11, 5.1.2 and 5.2.1 with identical exit codes: --skip-decimate 9, --stray-vert 15, --lift-z 16, --float-pier 16, --sink-keystone 17, --wide-mortar 18, --off-circle 19. Four findings the budgets forced, all documented in the README: - bmesh.ops.bevel gives every face it creates material_index 0. 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. Materials are now re-stamped per block after the bevel. - The impost landed exactly on the top course — same footprint, same plane, coincident face centres. It now sits on its own mortar bed. - BVHTree.find_nearest is unsigned, so a voussoir seated inside the pier head reported a 5.4 mm gap where there was none. Joint gaps now test overlap first. - Joint gaps need measuring 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 9.7 mm. Three falsifiers needed the model changed rather than the budget loosened. --sink-keystone originally shrank the Y bounding box and tripped the AABB gate, so imposts were added to own the envelope — correct masonry the arch was missing. --off-circle began as --flat-arch, which is 0.62 m shorter and also fails on the bounding box, proving nothing about the circle fit. --float-pier tripped the z-fight budget because the impost was pinned to an absolute height while its courses rose into it. LOD2 is the one value that differs across versions (196 tris on 5.2, 202 on 4.5 and 5.1) — DECIMATE COLLAPSE, which is why the gate is a ratio band. Adds ~0.82 s to smoke on 4.5, ~0.85 s on 5.1, ~0.98 s on 5.2. All 28 showcase pieces together run in 20.9 s on 5.2.1. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> --- .cursor-plugin/plugin.json | 3 +- CLAUDE.md | 2 +- README.md | 7 +- docs/gallery/assets/stone-archway-hero.webp | Bin 0 -> 17336 bytes .../stone-archway-contact-sheet.webp | Bin 0 -> 51322 bytes docs/gallery/crate-stack/index.html | 2 +- docs/gallery/index.html | 15 +- docs/gallery/stone-archway/index.html | 1568 +++++++++++++++++ showcase/gallery.json | 12 + showcase/stone-archway/README.md | 198 +++ showcase/stone-archway/preview.webp | Bin 0 -> 17336 bytes showcase/stone-archway/stone_archway.py | 1220 +++++++++++++ tests/smoke/catalog.json | 3 +- 13 files changed, 3021 insertions(+), 9 deletions(-) create mode 100644 docs/gallery/assets/stone-archway-hero.webp create mode 100644 docs/gallery/contact-sheets/stone-archway-contact-sheet.webp create mode 100644 docs/gallery/stone-archway/index.html create mode 100644 showcase/stone-archway/README.md create mode 100644 showcase/stone-archway/preview.webp create mode 100644 showcase/stone-archway/stone_archway.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 9aff3236..3eae28c3 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -164,6 +164,7 @@ "showcase/iron-cauldron", "showcase/wooden-ladder", "showcase/hay-bale", - "showcase/crate-stack" + "showcase/crate-stack", + "showcase/stone-archway" ] } diff --git a/CLAUDE.md b/CLAUDE.md index 1e943987..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, 27 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 202b984a..5d350cb1 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  27 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 27 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).

-27 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 @@ -136,6 +136,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show 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/docs/gallery/assets/stone-archway-hero.webp b/docs/gallery/assets/stone-archway-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..04f851b84fcd95a4a2a95b55609215589fa88275 GIT binary patch literal 17336 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 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.2, 2.7, 2.3), 380.0, 7.0, (1.0, 0.76, 0.50), (-92, 0, 196)) + 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 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index eb06464f..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, 27 showcase pieces + 51 examples, 28 showcase pieces
@@ -1182,6 +1182,17 @@

crate-stack

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 +
+
@@ -1211,7 +1222,7 @@

crate-stack

var tagsToggle = document.getElementById('tagsToggle'); var toTop = document.getElementById('toTop'); var total = cards.length; - var COUNT_LABEL = '51 examples, 27 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/gallery.json b/showcase/gallery.json index 09784981..8434cb0b 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -328,6 +328,18 @@ "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 0000000000000000000000000000000000000000..04f851b84fcd95a4a2a95b55609215589fa88275 GIT binary patch literal 17336 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 (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 d928e354..678fa48d 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -100,5 +100,6 @@ {"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": "crate-stack", "script": "showcase/crate-stack/crate_stack.py"} + {"name": "crate-stack", "script": "showcase/crate-stack/crate_stack.py"}, + {"name": "stone-archway", "script": "showcase/stone-archway/stone_archway.py"} ] From 06193129f9cf0ce7a318be6b23a5939b855bfdec Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:32:07 -0400 Subject: [PATCH 4/4] docs: sync the showcase candidate pool Mark crate-stack and stone-archway shipped, and restock the pool with four subjects identified while building them: rope bridge, grain sacks, brazier, apothecary shelf. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> --- ROADMAP.md | 6 ++++++ 1 file changed, 6 insertions(+) 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