shipping-crate
A procedural shipping crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
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.
category Village
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.
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 | attribute-driven timber (per-plank tone, grain along each board) and rusted iron |
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 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.
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 | 5200–7000 | 6060 | ||
| LOD1 ratio | 0.32–0.62 | 0.5000 | ||
| LOD2 ratio | 0.10–0.35 | 0.2198 | ||
| Material slots | exactly 2, distinct | 2 | ||
| Iron faces | ≥ 900 | 1344 | ||
| Timber faces | ≥ 1800 | 2418 | ||
| UV bounds | inside 0..1 | (0.0006, 0.0007)–(0.9994, 0.9993) | ||
| UV AABB overlap | ≤ 1e-5 | 0.000000 | ||
| Outer AABB | 0.658 × 0.524 × 0.704 m ± 0.020 | 0.6583 × 0.5235 × 0.7040 | ||
| Crate body footprint | 0.588 × 0.428 m ± 0.015, in the crate's own frame | 0.5882 × 0.4282 (all three) | ||
| Collider triangles | ≤ 260 | 230 | ||
| Normal bake | {'FINISHED'} with image data | {'FINISHED'}, has_data=True | ||
| glTF export | file written, non-empty | ~490 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 | ||
| Nail seat | 48 nails; bite 0.3–1.0 mm into the strap, head ≥ 1.0 mm proud | 48; bite 0.60 mm, proud 1.80 mm | ||
| Edge treatment | manifold edges within 5° of a right angle: 0 | timber 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.
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.
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.
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.
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.
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.
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.
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.
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.
| 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 |
--float-nails | lifts every nail head 1.5 mm along its plate normal; bite goes to −0.90 mm, nail seat budget | 18 |
--sharp-iron | skips the iron chamfer pass; 216 right-angle iron edges, edge-treatment budget | 21 |
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), or a nail head off its strap seat band (--float-nails) |
| 19 | Crate body footprint off declared size |
| 20 | Per-instance variation collapsed (--same-seed) |
| 21 | A 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.
# 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.
| Value | 4.5.11 | 5.1.2 | 5.2.1 |
|---|---|---|---|
| Base triangles | 6060 | 6060 | 6060 |
| LOD1 tris / ratio | 3030 / 0.5000 | 3030 / 0.5000 | 3030 / 0.5000 |
| LOD2 tris / ratio | 1332 / 0.2198 | 1332 / 0.2198 | 1332 / 0.2198 |
| Outer AABB | 0.6583 × 0.5235 × 0.7040 | same | same |
| Collider tris | 230 | 230 | 230 |
| Yaws (rad) | 0.0943, −0.1093, 0.0681 | same | same |
| Seat gap | 0.00000 | 0.00000 | 0.00000 |
| Nails / bite / proud | 48 / 0.60 mm / 1.80 mm | same | same |
| Right-angle edges | 0 | 0 | 0 |
| Check wall-clock | ~1.59 s | ~1.41 s | ~1.65 s |
"""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)