Examples and Showcase
GitHub

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.

category Village

tags mesh export showcase

blender --background --python showcase/crate-stack/crate_stack.py --

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 contentUsed for
skills/mesh-editing-and-bmeshbox, bevel and UV construction in one bmesh
skills/procedural-materials-and-shadersattribute-driven timber (per-plank tone, grain along each board) and rusted iron
skills/bake-high-to-lowCycles tangent-space normal bake, high onto low
skills/engine-export-presetsUnity glTF (export_yup=True)
skills/depsgraph-and-evaluated-dataevaluated triangle counts for the LOD ratios
snippets/decimate_to_budget.pyLOD1 / LOD2 COLLAPSE chain
snippets/convex_hull_collider.pyconvex collider
snippets/lod_chain.pyLOD naming and ratio pattern
examples/mesh-hygiene-audithygiene 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.

BudgetBandMeasured
Base triangles5200–70006060
LOD1 ratio0.32–0.620.5000
LOD2 ratio0.10–0.350.2198
Material slotsexactly 2, distinct2
Iron faces≥ 9001344
Timber faces≥ 18002418
UV boundsinside 0..1(0.0006, 0.0007)–(0.9994, 0.9993)
UV AABB overlap≤ 1e-50.000000
Outer AABB0.658 × 0.524 × 0.704 m ± 0.0200.6583 × 0.5235 × 0.7040
Crate body footprint0.588 × 0.428 m ± 0.015, in the crate's own frame0.5882 × 0.4282 (all three)
Collider triangles≤ 260230
Normal bake{'FINISHED'} with image data{'FINISHED'}, has_data=True
glTF exportfile written, non-empty~490 kB
Hygieneall zeroloose 0/0, non-manifold 0, zero-area 0, doubles 0, n-gons 0, coplanar disjoint pairs 0
Grounded AABB\zmin\≤ 1e-40.00000
Ground runners3 named supports, each zmin ≤ 1e-33 at 0.00000
Crate-to-crate seatboth seats, surface gap ≤ 0.0015 m0.00000 (overlapping)
Yaw bandeach \yaw\in 0.040–0.192 rad0.0681, 0.0943, 0.1093
Per-instance variationyaw spread ≥ 0.020 rad, plank spread ≥ 0.0008 m0.0262 rad, 0.00205 m
Nail seat48 nails; bite 0.3–1.0 mm into the strap, head ≥ 1.0 mm proud48; bite 0.60 mm, proud 1.80 mm
Edge treatmentmanifold edges within 5° of a right angle: 0timber 0, iron 0

Real-world size: each crate is 0.58 × 0.42 m at the timber and 0.588 × 0.428 m over the corner iron and its nail heads, 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.5882 × 0.4282 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.

Why the corner iron is one shell#

Each strap used to be two overlapping boxes, one per leg of the L. Both boxes had a vertical edge on the strap's outer corner, and once the iron was chamfered each box laid a chamfer strip on that same line: a coplanar cross-shell pair at all twelve corners, which the z-fight budget caught. The strap is now a single L-section extrusion — each cap two convex quads meeting on the inner-corner diagonal, so no n-gon — with one outer corner. The iron bevel passes material=METAL_IDX; left to its default, bevel gave the chamfer faces slot 0 and the plates rendered and classified as timber.

Why nails are told from plates by the strap's size#

Nail and plate shells are both iron; the nail audit separates them by world-AABB extent. The threshold is IRON_WRAP * 0.5, derived from the strap. A fixed 10 mm threshold dropped 8 of 48 nails as soon as the heads grew to 10 mm, because a yawed head's world AABB is wider than the head.

Timber surface#

paint_planks writes two face attributes: PlankTone, a seeded tone per shell, and GrainDir, the shell's own long axis recovered from its vertices. The wood shader samples noise in object space with the component along GrainDir compressed, so the grain streaks run along each board whatever the crate's yaw, and scales the colour by the plank's tone. Neither attribute is a budget: a tone cannot be seen by an assertion, which is why the inspection sheet looked at 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. LOD2 used to differ — DECIMATE COLLAPSE produced 878 triangles on 5.2 and 934 on 4.5 and 5.1 on the earlier mesh — and on the current mesh lands on 1332 on all three. The LOD gate stays a ratio band (0.10–0.35) rather than an exact count, because that agreement is a property of this mesh, not of the modifier.

Falsifiers#

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

FlagBreaksExit
--skip-decimatedrops the DECIMATE modifiers, LOD1 ratio goes to 1.00009
--stray-vertadds one loose vertex inside the silhouette, so hygiene catches it rather than the bounding box15
--lift-zlifts the whole mesh 50 mm off the floor16
--short-skidsfloats one of the three ground runners 12 mm; the other two still ground the AABB, so only the named-support budget sees it16
--float-stacklifts the top crate 9 mm clear of the lid below, opening a 34 mm seat gap18
--same-seedgives all three crates the same design seed; yaw spread and plank spread both go to 020
--float-nailslifts every nail head 1.5 mm along its plate normal; bite goes to −0.90 mm, nail seat budget18
--sharp-ironskips the iron chamfer pass; 216 right-angle iron edges, edge-treatment budget21

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.

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

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.4-1.7 s on 4.5 / 5.1 / 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#

Value4.5.115.1.25.2.1
Base triangles606060606060
LOD1 tris / ratio3030 / 0.50003030 / 0.50003030 / 0.5000
LOD2 tris / ratio1332 / 0.21981332 / 0.21981332 / 0.2198
Outer AABB0.6583 × 0.5235 × 0.7040samesame
Collider tris230230230
Yaws (rad)0.0943, −0.1093, 0.0681samesame
Seat gap0.000000.000000.00000
Nails / bite / proud48 / 0.60 mm / 1.80 mmsamesame
Right-angle edges000
Check wall-clock~1.59 s~1.41 s~1.65 s

Source

showcase/crate-stack/crate_stack.py 1751 lines · 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, ``--float-nails`` the nail seat,
``--sharp-iron`` the edge-treatment 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
# Iron is chamfered like the timber, only finer: a 3.5 mm plate with the
# timber's 1.8 mm bevel would have no flat left.
IRON_BEVEL = 0.0008
# Clout nails: two per plate, driven through the strap into the post. The
# shank end sits a named bite below the plate face so each head is seated,
# not parked on the surface; the head is a frustum so no visible edge of it
# is a right angle.
NAIL_R = 0.0050
NAIL_R_TOP = 0.0036
NAIL_H = 0.0024
NAIL_BITE = 0.0006
NAIL_SEGS = 6
NAIL_ZS = (0.18, 0.82)
NAIL_BITE_MIN = 0.0003
NAIL_BITE_MAX = 0.0010
NAIL_PROUD_MIN = 0.0010
# Nail versus plate is told apart by world-AABB extent. Derived from the
# strap, not the nail: a yawed 10 mm head has an AABB wider than 10 mm, and
# a fixed 10 mm threshold dropped 8 of 48 nails the moment the heads grew.
NAIL_EXTENT_MAX = IRON_WRAP * 0.5
FLOAT_NAIL_LIFT = 0.0015
# Edge treatment: no manifold edge on the finished mesh is a right angle.
# Every box edge is chamfered, so a 90-degree edge means a bevel pass was
# skipped.
RIGHT_ANGLE_TOL = math.radians(5.0)
PLANK_TONE_JITTER = 0.30
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

# Four corners, two plates per corner, one nail per plate per station.
N_NAILS = N_CRATES * 4 * 2 * len(NAIL_ZS)

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 and nail heads, in the crate's own frame.
# The strap stands IRON_T - IRON_BITE proud of the post, and each nail
# head a further NAIL_H - NAIL_BITE proud of the strap.
BODY_PROUD = IRON_T - IRON_BITE + NAIL_H - NAIL_BITE
BODY_X = CRATE_X + 2.0 * BODY_PROUD
BODY_Y = CRATE_Y + 2.0 * BODY_PROUD
CRATE_TOL = 0.015

BASE_TRIS_MIN = 5200
BASE_TRIS_MAX = 7000
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 = 900
WOOD_FACES_MIN = 1800
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, float_nails=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
    plates = []
    for sxn in (-1.0, 1.0):
        for syn in (-1.0, 1.0):
            plates.extend(add_strap(bm, sxn, syn, px, py, strap_z0, strap_h, xform))
    # Nails are placed from the plate's own outer face and normal, so they
    # follow the strap if its thickness, bite or wrap ever changes.
    nail_stations = []
    for sxn in (-1.0, 1.0):
        for syn in (-1.0, 1.0):
            for fz in NAIL_ZS:
                z = strap_z0 + strap_h * fz
                nail_stations.append(
                    (Vector((sxn * px, syn * (py - IRON_WRAP * 0.5), z)),
                     Vector((sxn, 0.0, 0.0)))
                )
                nail_stations.append(
                    (Vector((sxn * (px - IRON_WRAP * 0.5), syn * py, z)),
                     Vector((0.0, syn, 0.0)))
                )
    for face_pt, nrm in nail_stations:
        add_nail(bm, face_pt, nrm, xform, lift=FLOAT_NAIL_LIFT if float_nails else 0.0)
    return wood, side_w, plates


def add_strap(bm, sxn, syn, px, py, z0, h, xform):
    """One L-section corner strap, a single closed shell.

    Two overlapping boxes shared their outer corner edge, so once the iron
    was chamfered both boxes put a strip on the same line: a coplanar pair
    at every corner. An extruded L has one outer corner. Each cap is two
    convex quads meeting on the inner-corner diagonal, so there is no n-gon.
    """
    outline = [
        (px, py - IRON_WRAP),
        (px, py),
        (px - IRON_WRAP, py),
        (px - IRON_WRAP, py - IRON_T),
        (px - IRON_T, py - IRON_T),
        (px - IRON_T, py - IRON_WRAP),
    ]
    rings = []
    for z in (z0, z0 + h):
        rings.append(
            [bm.verts.new(xform @ Vector((sxn * x, syn * y, z))) for x, y in outline]
        )
    lo, hi = rings
    faces = []
    # Bottom cap wound opposite the top so the shell is consistently
    # oriented; recalc_face_normals later points it outward.
    for ring, flip in ((lo, True), (hi, False)):
        for quad in ((0, 1, 4, 5), (1, 2, 3, 4)):
            vs = [ring[k] for k in quad]
            faces.append(bm.faces.new(vs[::-1] if flip else vs))
    n = len(outline)
    for i in range(n):
        j = (i + 1) % n
        faces.append(bm.faces.new((lo[i], lo[j], hi[j], hi[i])))
    for f in faces:
        f.material_index = METAL_IDX
    return lo + hi


def add_nail(bm, face_pt, nrm, xform, lift=0.0):
    """A frustum nail head seated NAIL_BITE into the plate at *face_pt*."""
    geo = bmesh.ops.create_cone(
        bm,
        cap_ends=True,
        cap_tris=True,
        segments=NAIL_SEGS,
        radius1=NAIL_R,
        radius2=NAIL_R_TOP,
        depth=NAIL_H,
    )
    orient = nrm.to_track_quat("Z", "Y").to_matrix().to_4x4()
    seat = Matrix.Translation(face_pt + nrm * lift) @ orient @ Matrix.Translation(
        (0.0, 0.0, NAIL_H / 2.0 - NAIL_BITE)
    )
    verts = geo["verts"]
    for v in verts:
        v.co = xform @ (seat @ v.co)
    for f in {f for v in verts for f in v.link_faces}:
        f.material_index = METAL_IDX


def build_stack_mesh(
    name,
    same_seed=False,
    short_skids=False,
    float_stack=False,
    float_nails=False,
    sharp_iron=False,
):
    bm = bmesh.new()
    wood_verts = []
    plate_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, plates = build_crate(
                bm,
                base_z + lift,
                yaw,
                rng,
                offset=offset,
                short_skids=short_skids and i == 0,
                float_nails=float_nails,
            )
            wood_verts.extend(verts)
            plate_verts.extend(plates)
            base_z += CRATE_H - STACK_BITE

        # Iron first, at its own finer offset: the plates are only 3.5 mm
        # thick. The nails are frustums and need no bevel.
        # The flat diagonal inside each L cap is not an edge anyone sees,
        # and chamfering it would crease a flat face.
        if plate_verts and not sharp_iron:
            edges = [
                e for e in {e for v in plate_verts for e in v.link_edges}
                if len(e.link_faces) == 2 and e.calc_face_angle(0.0) > 1e-3
            ]
            # material= pins the chamfer faces to iron; left to default,
            # bevel handed them slot 0 and the plates rendered as timber.
            bmesh.ops.bevel(
                bm,
                geom=edges,
                offset=IRON_BEVEL,
                segments=1,
                profile=0.5,
                affect="EDGES",
                clamp_overlap=True,
                material=METAL_IDX,
            )

        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()
    paint_planks(me)
    obj = bpy.data.objects.new(name, me)
    bpy.context.collection.objects.link(obj)
    return obj


def paint_planks(me):
    """Per-plank tone and grain direction, as face attributes.

    Every plank came out of one material, so every plank was the same
    board. Each shell gets a seeded tone and the direction it runs in —
    its long axis, recovered from its own vertices — which the wood shader
    uses to stretch its grain along the board rather than along a world
    axis that is 6 degrees off on a yawed crate.
    """
    tone = [0.5] * len(me.polygons)
    grain = [(1.0, 0.0, 0.0)] * len(me.polygons)
    vf = vert_faces(me)
    rng = random.Random(STACK_SEED * 13)
    for g in shells(me):
        pts = [me.vertices[i].co for i in g]
        e1, _e2, theta = xy_principal(pts)
        dz = max(p.z for p in pts) - min(p.z for p in pts)
        d = (0.0, 0.0, 1.0) if dz > e1 else (math.cos(theta), math.sin(theta), 0.0)
        t = 0.5 + rng.uniform(-PLANK_TONE_JITTER, PLANK_TONE_JITTER)
        for fi in {fi for i in g for fi in vf[i]}:
            tone[fi] = t
            grain[fi] = d
    a = me.attributes.new("PlankTone", "FLOAT", "FACE")
    a.data.foreach_set("value", tone)
    b = me.attributes.new("GrainDir", "FLOAT_VECTOR", "FACE")
    b.data.foreach_set("vector", [c for v in grain for c in v])


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 _sock(sockets, identifier):
    """A Mix-node socket by identifier; its A/B/Result names repeat per type."""
    return next(s for s in sockets if s.identifier == identifier)


def wood_material(name):
    """Timber whose grain runs along each board and whose tone varies by plank.

    Reads the ``PlankTone`` and ``GrainDir`` face attributes that
    ``paint_planks`` writes. The grain is noise sampled in object space with
    the component along the board compressed, so its streaks are long in
    the direction the plank runs whatever the crate's yaw.
    """
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    nt = mat.node_tree
    bsdf = nt.nodes["Principled BSDF"]
    bsdf.inputs["Metallic"].default_value = 0.0
    coord = nt.nodes.new("ShaderNodeTexCoord")
    gdir = nt.nodes.new("ShaderNodeAttribute")
    gdir.attribute_name = "GrainDir"
    tone = nt.nodes.new("ShaderNodeAttribute")
    tone.attribute_name = "PlankTone"
    dot = nt.nodes.new("ShaderNodeVectorMath")
    dot.operation = "DOT_PRODUCT"
    nt.links.new(coord.outputs["Object"], dot.inputs[0])
    nt.links.new(gdir.outputs["Vector"], dot.inputs[1])
    squash = nt.nodes.new("ShaderNodeMath")
    squash.operation = "MULTIPLY"
    squash.inputs[1].default_value = 0.94
    nt.links.new(dot.outputs["Value"], squash.inputs[0])
    along = nt.nodes.new("ShaderNodeVectorMath")
    along.operation = "SCALE"
    nt.links.new(gdir.outputs["Vector"], along.inputs[0])
    nt.links.new(squash.outputs["Value"], along.inputs["Scale"])
    grain_co = nt.nodes.new("ShaderNodeVectorMath")
    grain_co.operation = "SUBTRACT"
    nt.links.new(coord.outputs["Object"], grain_co.inputs[0])
    nt.links.new(along.outputs["Vector"], grain_co.inputs[1])
    # Plank tone also offsets the grain sample, so neighbouring boards do
    # not show one continuous figure across the gap between them.
    shift = nt.nodes.new("ShaderNodeVectorMath")
    shift.operation = "ADD"
    nt.links.new(grain_co.outputs["Vector"], shift.inputs[0])
    nt.links.new(tone.outputs["Fac"], shift.inputs[1])
    noise = nt.nodes.new("ShaderNodeTexNoise")
    noise.inputs["Scale"].default_value = 34.0
    noise.inputs["Detail"].default_value = 6.0
    noise.inputs["Roughness"].default_value = 0.62
    nt.links.new(shift.outputs["Vector"], noise.inputs["Vector"])
    ramp = nt.nodes.new("ShaderNodeValToRGB")
    ramp.color_ramp.elements[0].position = 0.30
    ramp.color_ramp.elements[0].color = (0.105, 0.045, 0.016, 1.0)
    ramp.color_ramp.elements[1].position = 0.72
    ramp.color_ramp.elements[1].color = (0.34, 0.16, 0.060, 1.0)
    nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
    gain = nt.nodes.new("ShaderNodeMath")
    gain.operation = "MULTIPLY_ADD"
    gain.inputs[1].default_value = 1.2
    gain.inputs[2].default_value = 0.40
    nt.links.new(tone.outputs["Fac"], gain.inputs[0])
    mix = nt.nodes.new("ShaderNodeMix")
    mix.data_type = "RGBA"
    mix.blend_type = "MULTIPLY"
    _sock(mix.inputs, "Factor_Float").default_value = 1.0
    nt.links.new(ramp.outputs["Color"], _sock(mix.inputs, "A_Color"))
    nt.links.new(gain.outputs["Value"], _sock(mix.inputs, "B_Color"))
    nt.links.new(_sock(mix.outputs, "Result_Color"), bsdf.inputs["Base Color"])
    rough = nt.nodes.new("ShaderNodeMapRange")
    rough.inputs["To Min"].default_value = 0.72
    rough.inputs["To Max"].default_value = 0.52
    nt.links.new(noise.outputs["Fac"], rough.inputs["Value"])
    nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"])
    return mat


def stack_materials():
    """(wood, iron) — shared by the check path, the render and inspection."""
    wood = wood_material("StackWood")
    metal = principled(
        "StackMetal", (0.17, 0.165, 0.155, 1.0), 0.80, 0.46,
        noise_scale=18.0, wear=(0.20, 0.085, 0.032, 1.0),
    )
    return wood, metal


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 right_angle_edges(me):
    """Manifold edges whose two faces meet at 90 degrees, iron and timber.

    Every box in the piece is chamfered, and a one-segment chamfer turns a
    90-degree edge into two 45-degree ones. An edge still at 90 is one a
    bevel pass skipped: the razor edge that renders as a hard black line.
    """
    counts = {WOOD_IDX: 0, METAL_IDX: 0}
    bm = bmesh.new()
    try:
        bm.from_mesh(me)
        for e in bm.edges:
            if len(e.link_faces) != 2:
                continue
            if abs(e.calc_face_angle(0.0) - math.pi / 2.0) <= RIGHT_ANGLE_TOL:
                idx = e.link_faces[0].material_index
                counts[idx] = counts.get(idx, 0) + 1
    finally:
        bm.free()
    return counts


def nail_audit(me):
    """Seat of every nail head in its strap, measured against the plate.

    Nails are the small iron shells; plates are the rest. Each nail vertex
    gets a signed distance to the plate surface (negative inside). The
    deepest is the bite, which must sit in a band — not parked on the plate
    and not sunk through it — and the highest is how proud the head
    stands, which must be enough to catch light.
    """
    vf = vert_faces(me)
    nails, plates = [], []
    for g in shells(me):
        if mat_of(me, g, vf) != METAL_IDX:
            continue
        lo_hi = shell_aabb(me, g)
        ext = max(lo_hi[3] - lo_hi[0], lo_hi[4] - lo_hi[1], lo_hi[5] - lo_hi[2])
        (nails if ext < NAIL_EXTENT_MAX else plates).append(g)
    if not nails or not plates:
        return {"n": len(nails), "bite_min": 0.0, "bite_max": 0.0, "proud_min": 0.0}
    bm = bmesh.new()
    try:
        bm.from_mesh(me)
        keep = set()
        for g in plates:
            keep.update(g)
        drop = [f for f in bm.faces if not all(v.index in keep for v in f.verts)]
        bmesh.ops.delete(bm, geom=drop, context="FACES")
        tree = BVHTree.FromBMesh(bm)
    finally:
        bm.free()
    bites, prouds = [], []
    for g in nails:
        signed = []
        for i in g:
            co = me.vertices[i].co
            loc, nrm, _idx, _d = tree.find_nearest(co)
            if loc is None:
                signed.append(99.0)
                continue
            signed.append((co - loc).dot(nrm))
        bites.append(-min(signed))
        prouds.append(max(signed))
    return {
        "n": len(nails),
        "bite_min": min(bites),
        "bite_max": max(bites),
        "proud_min": min(prouds),
    }


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,
    float_nails=False,
    sharp_iron=False,
):
    bpy.ops.wm.read_factory_settings(use_empty=True)
    flags = dict(
        same_seed=same_seed,
        short_skids=short_skids,
        float_stack=float_stack,
        float_nails=float_nails,
        sharp_iron=sharp_iron,
    )
    nothing = (None,) * 5
    low = build_stack_mesh("StackLow", **flags)
    high = build_stack_mesh("StackHigh", **flags)
    wood, metal = stack_materials()
    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
    # Blender points TMPDIR at its own temp preference, which on a portable
    # build is the working directory, so the export must not outlive this.
    if os.path.isfile(export_path):
        os.remove(export_path)

    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'])
    nails = nail_audit(low.data)
    right = right_angle_edges(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}"
    )
    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]}"
    )
    print(
        f"measured nails n={nails['n']} bite=[{nails['bite_min']:.5f}, "
        f"{nails['bite_max']:.5f}] proud_min={nails['proud_min']:.5f} "
        f"right_angle_edges wood={right.get(WOOD_IDX, 0)} "
        f"iron={right.get(METAL_IDX, 0)}"
    )

    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
    if (
        nails["n"] != N_NAILS
        or nails["bite_min"] < NAIL_BITE_MIN
        or nails["bite_max"] > NAIL_BITE_MAX
        or nails["proud_min"] < NAIL_PROUD_MIN
    ):
        return (fail(
            f"nail seat: {nails['n']} of {N_NAILS} nails, bite "
            f"[{nails['bite_min']:.5f}, {nails['bite_max']:.5f}] outside "
            f"[{NAIL_BITE_MIN}, {NAIL_BITE_MAX}] or proud "
            f"{nails['proud_min']:.5f} < {NAIL_PROUD_MIN} "
            "(--float-nails 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
    if sum(right.values()):
        return (fail(
            f"right-angle edges wood={right.get(WOOD_IDX, 0)} "
            f"iron={right.get(METAL_IDX, 0)}: a chamfer pass was skipped "
            "(--sharp-iron is the designed fail)", 21
        ),) + nothing
    return 0, low, high, wood, tex, collider


def wire_normal(mat, tex):
    nt = mat.node_tree
    bsdf = nt.nodes["Principled BSDF"]
    nrm = nt.nodes.new("ShaderNodeNormalMap")
    nrm.inputs["Strength"].default_value = 1.0
    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])


def render_still(low, wood, tex, path, engine):
    scene = bpy.context.scene
    wire_normal(wood, tex)
    for ob in list(scene.objects):
        if ob.type == "MESH" and ob != low:
            ob.hide_render = True
            ob.hide_viewport = True

    low.rotation_euler.z = math.radians(-14.0)

    floor_me = bpy.data.meshes.new("Floor")
    bm = bmesh.new()
    try:
        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=16.0)
        bm.to_mesh(floor_me)
    finally:
        bm.free()
    fmat = bpy.data.materials.new("Floor")
    fmat.use_nodes = True
    fb = fmat.node_tree.nodes["Principled BSDF"]
    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
    fb.inputs["Roughness"].default_value = 0.7
    floor_me.materials.append(fmat)
    floor = bpy.data.objects.new("Floor", floor_me)
    scene.collection.objects.link(floor)
    wall = bpy.data.objects.new("Wall", floor_me.copy())
    wall.location = (0.0, 9.0, 0.0)
    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
    scene.collection.objects.link(wall)

    world = bpy.data.worlds.new("World")
    world.use_nodes = True
    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
        0.02, 0.021, 0.025, 1.0,
    )
    scene.world = world

    def light(name, loc, energy, size, col, rot):
        ld = bpy.data.lights.new(name, "AREA")
        ld.energy = energy
        ld.size = size
        ld.color = col
        ob = bpy.data.objects.new(name, ld)
        ob.location = loc
        ob.rotation_euler = tuple(math.radians(a) for a in rot)
        scene.collection.objects.link(ob)

    light("Key", (-3.2, -4.0, 5.2), 545.0, 5.0, (1.0, 0.96, 0.90), (44, 0, -38))
    light("Fill", (4.2, -2.8, 1.5), 145.0, 9.0, (0.75, 0.85, 1.00), (72, 0, 54))
    light("Rim", (-1.5, 3.4, 2.6), 320.0, 3.0, (0.60, 0.78, 1.00), (-64, 0, 200))
    # Wedge sits between the subject and the wall so the pool lands on the
    # backdrop, not across the crates.
    light("Wedge", (1.1, 4.3, 1.95), 560.0, 6.0, (1.0, 0.72, 0.40), (-94, 0, 194))

    cam_data = bpy.data.cameras.new("Cam")
    cam_data.lens = 52.0
    cam = bpy.data.objects.new("Cam", cam_data)
    cam.location = (1.62, -2.42, 1.06)
    scene.collection.objects.link(cam)
    aim = bpy.data.objects.new("Aim", None)
    aim.location = (0.0, 0.0, STACK_H * 0.46)
    scene.collection.objects.link(aim)
    con = cam.constraints.new("TRACK_TO")
    con.target = aim
    con.track_axis = "TRACK_NEGATIVE_Z"
    con.up_axis = "UP_Y"
    scene.camera = cam

    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
    if engine == "cycles":
        scene.cycles.samples = 32
        scene.cycles.device = "CPU"
    else:
        try:
            scene.eevee.taa_render_samples = 64
        except AttributeError:
            pass
    scene.render.resolution_x = 1280
    scene.render.resolution_y = 720
    scene.render.image_settings.file_format = (
        "WEBP" if path.lower().endswith(".webp") else "PNG"
    )
    if path.lower().endswith(".webp"):
        scene.render.image_settings.quality = 90
    scene.render.filepath = path
    scene.view_settings.view_transform = "Standard"

    fcode = gallery_framing.check_framing(
        scene, cam, hero=[low], elements=[low], stage=[floor, wall],
    )
    if fcode:
        return fcode
    bpy.ops.render.render(write_still=True)
    if not (os.path.exists(path) and os.path.getsize(path) > 0):
        return fail("render produced no file", 14)
    return 0


def main():
    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
    p = argparse.ArgumentParser()
    p.add_argument("--output", default=None)
    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
    p.add_argument("--skip-decimate", action="store_true")
    p.add_argument("--lift-z", action="store_true")
    p.add_argument("--stray-vert", action="store_true")
    p.add_argument("--short-skids", action="store_true")
    p.add_argument("--float-stack", action="store_true")
    p.add_argument("--same-seed", action="store_true")
    p.add_argument("--float-nails", action="store_true")
    p.add_argument("--sharp-iron", 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,
        float_nails=args.float_nails,
        sharp_iron=args.sharp_iron,
    )
    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)
Three grained wooden shipping crates stacked, each turned slightly differently, with nailed iron corner straps.