diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index cec3e08..eb81285 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -82,6 +82,7 @@ "examples/light-link-studio", "examples/lod-decimate-chain", "examples/mesh-hygiene-audit", + "examples/modular-kit-snap", "examples/parent-inverse-orrery", "examples/png-exr-alpha", "examples/prop-origin-transform", diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index 48618b9..ffc4941 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -566,3 +566,16 @@ jobs: # Exits non-zero on failure. xvfb-run -a "$BLENDER" --background \ --python examples/degenerate-bevel-weld/degenerate_bevel_weld.py -- + + - name: Shipped example - modular kit snap (tile boundary contract) + run: | + set -euo pipefail + # Check only (no render): a 4x3x3 corridor segment; asserts 16 + # boundary verts on x in {0, TILE} within 1e-6, end-ring coincidence + # within 1e-6, a linked duplicate's joint positions matching within + # 1e-6, shell bbox == declared tile (dev 0), manifold shell except + # the 16 open-end edges, watertight detail parts contained inside + # the tile, and reuse hygiene (identity scales, kit names, pivot + # origins). Exits non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/modular-kit-snap/modular_kit_snap.py -- diff --git a/README.md b/README.md index 8f78c33..2521e12 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  40 examples + 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  41 examples

@@ -780,6 +780,26 @@ welds their loops. Companion to check first caught the weld) and [`mesh-hygiene-audit`](examples/mesh-hygiene-audit/) (topology gates). Seam markers are placed from live mesh data. + + + + +Modular kit snap: a corridor run of four tiling segments — dark steel walls with blue-gray panels, orange trim rails, and ceiling light strips converging on a glowing amber doorway, with every joint seamless because the boundary verts snap exactly to the tile grid + + + +### [modular-kit-snap](examples/modular-kit-snap/) + +A tiling corridor kit where the snap is the contract. **16 boundary verts** +on `x ∈ {0, 4}` within **1e-6 m**, opposing end loops coincident under the +tile offset, and a linked duplicate's joint positions matching within 1e-6 — +zero gap, zero overlap. Shell bbox equals the declared tile exactly, the +tube is manifold except its two open ends, and all twelve detail parts are +contained inside the tile. The unsnapped probe (3 mm skew) fails with the +measured error printed. Companion to +[`prop-origin-transform`](examples/prop-origin-transform/) (pivot discipline) +and [`mesh-hygiene-audit`](examples/mesh-hygiene-audit/) (topology gates). + diff --git a/ROADMAP.md b/ROADMAP.md index 38fa0b5..20e3f20 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,7 +97,6 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - Refresh the `slotted-actions-animation` skill against any 5.2 changes - Bump `blender_version_min` in the templates if 5.2 APIs are used - Additional snippets for asset library scripting, EXR baking, multi-file extensions -- Gallery coverage follow-ups from the GPv3 review: light-linking (`grease-pencil-rosette`, `armature-bend`, `text-version-stamp`, `image-pixels-testcard`, and `vse-cut-list` shipped first) - ~~UV-layer authoring witness~~ **SHIPPED** as `examples/uv-layer-grid/` — `create_grid(..., calc_uvs=True)` silent no-op without a pre-existing UV layer; closed-form UV fill + explicit assignment fallback; dual-panel render (flat texel (0,0) vs neon checker) - ~~Image save-format witness~~ **SHIPPED** as `examples/png-exr-alpha/` — float→PNG is RGBA16 and false-unpremultiplies as if associated-alpha (closed-form err 0.98 at RGB 0.02 / a=1/255); OpenEXR preserves float RGBA; byte→PNG is straight RGBA8; `EXR color_mode='RGB'` drops alpha - ~~Attribute domain witness~~ **SHIPPED** as `examples/attribute-domain-shear/` — POINT vs CORNER semantics on an 8-wedge pinwheel with one shared hub: CORNER authoring exact (hub corners disagree by face, err ≤ 1e-6), naive per-wedge POINT loop shears to the last write at hub + ring verts, measured deviation == palette closed form (0.751031); byte-identical on 4.5.11 and 5.1.2; crisp-vs-smeared dual pinwheel still — companion to `color-attribute-wheel` @@ -108,8 +107,9 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Vertex weight limit witness~~ **SHIPPED** as `examples/vertex-weight-limit/` — the 4-influence engine cap enforced via the data API (`v.groups` + `VertexGroup.remove` + renormalize); unit sums (measured 3e-8), pose preserved (4.9e-3), exact LBS from the mesh's own deform layer (2.7e-7), Root mount pinned - ~~Triangulate + tangent-space witness~~ **SHIPPED** as `examples/triangulate-tangents/` — `calc_tangents` aborts on any ngon (back cap must be an explicit fan); mikktspace matches the edge/UV-delta formula within welding tolerance on smooth fields (2.3e-6 measured); planar UVs on a cylindrical wall collapse tangents onto normals (dot 0.998); `MeshUVLoopLayer` handle dangles across `calc_tangents()` on 4.5 (471 phantom flips, silent exit 0) while the mikktspace math is byte-identical on 4.5.11 and 5.1.2 - UV-handle lifetime snippet: re-fetch attribute/UV layers by name after any CustomData-reallocating call (`calc_tangents`, `VertexGroup.add`, modifier edits) — held handles dangle silently on 4.5, survive by luck on 5.1 (found authoring `triangulate-tangents`) -- glTF skinned-mesh export witness: follow-up to `gltf-export-roundtrip` + `vertex-weight-limit` — **SHIPPED** as `examples/gltf-skin-roundtrip/` — `skins[0].joints` names every bone, JOINTS_0/WEIGHTS_0 with unit sums (3e-8), weights bit-exact, rest matrices to 2.4e-07, deformation to 4.8e-07; exporter welds duplicate loops (sorted-multiset comparisons mispair — compare by rest-key); unparented skinned meshes make the exporter bind an armature by name +- ~~glTF skinned-mesh export witness: follow-up to `gltf-export-roundtrip` + `vertex-weight-limit`~~ **SHIPPED** as `examples/gltf-skin-roundtrip/` — `skins[0].joints` names every bone, JOINTS_0/WEIGHTS_0 with unit sums (3e-8), weights bit-exact, rest matrices to 2.4e-07, deformation to 4.8e-07; exporter welds duplicate loops (sorted-multiset comparisons mispair — compare by rest-key); unparented skinned meshes make the exporter bind an armature by name - ~~Degenerate-bevel weld hazard~~ **SHIPPED** as `examples/degenerate-bevel-weld/` — at `offset == min_dim/2` the bevel band collapses to exactly 12 zero-area faces (4 min-axis edges × 3 segments) with 16 coincident verts (4 × (segments+1)) and min_area down 3.2e5×; a stdlib GLB re-parse counts **32 degenerate triangles crossing the export boundary** (MEASURED regression constant) versus 0 for the safe mesh — the loops an engine merge-by-distance welds; byte-identical on 4.5.11 and 5.1.2 +- ~~Modular kit snap witness~~ **SHIPPED** as `examples/modular-kit-snap/` — tiling corridor segment: 16 boundary verts on `x ∈ {0, TILE}` within 1e-6 m, end rings coincident under the tile offset (nearest-key match), linked-duplicate joint deviation 0.0, shell bbox == declared tile (dev 0.0), manifold except the 16 open-end edges, 12 detail parts contained inside the tile; unsnapped probe (3 mm skew, 2 mm nudge) fails at exits 3/4 with measured errors; sorted-zip ring matching mispairs displaced verts — match by nearest key (authoring hazard, fixed); byte-identical on 4.5.11 and 5.1.2 - ~~GAMMA_CROSS blend-curve witness~~ **SHIPPED** as `examples/vse-gamma-cross/` — the cross blends in a gamma-0.5 space: `((1-t)·√A + t·√B)²` with `t = (frame − start)/duration`, never 1 inside the effect; mid-cross dips 0.115 below the sRGB lerp from crimson/teal (closed form (0.341, 0.349, 0.463) confirmed per frame); AgX-default sampling poisons the fit (0.146 red-channel error, `view_transform='Standard'` mandatory); deleting a consumed input orphans-and-deletes the effect — follow-up to `vse-cut-list` - Falsy `bpy_prop_collection` trap snippet: an empty collection is falsy, so `editor.strips or editor.sequences` silently falls through to the legacy accessor on an empty timeline — always branch on `hasattr`; likely generalizes across the API (found authoring `vse-cut-list`) - ~~Collision compound witness~~ **SHIPPED** as `examples/collision-hull-proxy/` — game-prop collision as a compound of convex pieces, each a `bmesh.ops.convex_hull` of a coarse `sec(π/n)`-inflated cage (containment 4.4e-08, watertight, positive signed volume, Euler 2, per-piece 255-face budget: body 70, caps 60×3, compound 250); a hull of the dense render mesh measures 380 faces — over budget — which is why pipelines hull cages; proud details cost cage rows, concave grooves are free; byte-identical on 4.5.11 and 5.1.2 diff --git a/docs/gallery/assets/modular-kit-snap-hero.webp b/docs/gallery/assets/modular-kit-snap-hero.webp new file mode 100644 index 0000000..6c14840 Binary files /dev/null and b/docs/gallery/assets/modular-kit-snap-hero.webp differ diff --git a/docs/gallery/contact-sheets/modular-kit-snap.webp b/docs/gallery/contact-sheets/modular-kit-snap.webp new file mode 100644 index 0000000..9715cec Binary files /dev/null and b/docs/gallery/contact-sheets/modular-kit-snap.webp differ diff --git a/docs/gallery/index.html b/docs/gallery/index.html index c2d7d6a..3fda2ef 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -272,7 +272,7 @@

Examples Gallery

autocomplete="off" spellcheck="false" aria-label="Search examples" /> - 40 examples + 41 examples
@@ -762,6 +762,17 @@

degenerate-bevel-weld

View example
+
+ + modular-kit-snap — A tiling corridor kit whose open-end boundary verts snap to the tile grid, so instances at 4 m multiples join with zero gap or overlap. + +
+

modular-kit-snap

+

A tiling corridor kit whose open-end boundary verts snap to the tile grid, so instances at 4 m multiples join with zero gap or overlap.

+

witnesses 16 boundary verts on x∈{0,4} within 1e-6 m, linked-duplicate joint deviation 0.0, bbox == declared tile; unsnapped probe fails at 3e-3 m.

+ View example +
+
diff --git a/docs/gallery/modular-kit-snap/index.html b/docs/gallery/modular-kit-snap/index.html new file mode 100644 index 0000000..d50f3f2 --- /dev/null +++ b/docs/gallery/modular-kit-snap/index.html @@ -0,0 +1,934 @@ + + + + + + modular-kit-snap — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + +
+ Examples Gallery +
+ GitHub +
+
+
+

modular-kit-snap

+

A tiling corridor kit whose open-end boundary verts snap to the tile grid, so instances at 4 m multiples join with zero gap or overlap.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses 16 boundary verts on x∈{0,4} within 1e-6 m, linked-duplicate joint deviation 0.0, bbox == declared tile; unsnapped probe fails at 3e-3 m.
+
+
blender --background --python examples/modular-kit-snap/modular_kit_snap.py --
+ +
+
+

A runnable example building the asset a tiling modular kit lives or dies by: a corridor segment whose open-end boundary vertices sit exactly on the declared tile grid, so instances placed at 4 m multiples share boundary positions with no gap and no overlap. Interior geometry is free-form (beveled detail boxes); only the boundary is snapped — the snap is a deliberate authoring pass, and the check is what catches you skipping it.

+

The asset, for reuse: a 4 × 3 × 3 m corridor segment (hollow-rectangle shell plus twelve detail parts — frame rib, walkway plate, wall panels, trim rails, emissive light strips). Origin at the connection pivot (floor-center of the start edge, so instance *n* places at x = n·4), identity transforms by construction, datablocks under Kit.CorridorSeg.*, manifold everywhere except the two intentional open ends, and every detail part contained strictly inside the tile so it can never break a joint. Drop the hierarchy into a scene and array it along X.

+

Pipeline arc neighbors: watertight parametric solids in bmesh-gear, topology gates in mesh-hygiene-audit, origin/pivot discipline in prop-origin-transform, collision packaging in collision-hull-proxy.

+

What it witnesses (all closed form or independently re-derived):

+ +

What each check catches on failure (probed with an unsnapped variant built by the same code minus the snap pass — 3 mm end-ring skew, 2 mm y-nudge on two verts): boundary verts off the end planes, worst 3.000e-03 m (exit 3); opposing rings displaced 2.000e-03 (exit 4); tiled joint gap/overlap (exit 5); bbox off the declared tile by 3.000e-03 (exit 6); boundary edges torn off the rim (exit 7); non-watertight detail (exit 8); detail escaping the tile (exit 9); unapplied transforms, default names, or wandering origins (exit 11).

+

Version witness: check output is byte-identical on Blender 4.5.11 LTS and 5.1.2 — same counts, same zero measured deviations.

+

Render as proof: a four-segment run — the joints vanish. The falsification variant (--falsify) accumulates a 120 mm gap, 50 mm lateral jogs, and 40 mm floor steps at each joint: floor plates split with dark seams and the trim rails visibly break. The check measures the same failure class at 3 mm; the render exaggerates it to read at frame scale.

+

Framing deviation: the still is an interior corridor run — the envelope surrounds the camera on five sides and the tiling joints are the proof, so the subject reads as extending past the frame (the radiating-architecture class of VISUAL-STYLE Layer 1). check_framing measures and reports (fill 1.000/1.000, all-edge bleed) without enforcing, with the reason string at the call site.

+

Run

+
blender --background --python modular_kit_snap.py --
+blender --background --python modular_kit_snap.py -- --output corridor.png
+blender --background --python modular_kit_snap.py -- --falsify seams.png
+

Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1. The --output render path additionally measures framing against the Layer 1 band via examples/gallery_framing.py (reported under the documented deviation above) before writing the still.

+
+
+

Source

+
+ examples/modular-kit-snap/modular_kit_snap.py + View on GitHub → +
+
"""Modular kit snap — boundary vertices welded to the tile grid so segments tile seamlessly.
+
+Witnesses the contract a tiling modular kit lives or dies by: a corridor
+segment whose open-end boundary vertices sit EXACTLY on the declared tile
+grid, so instances placed at tile multiples share boundary positions with
+no gap and no overlap. Interior geometry is free-form (beveled detail);
+only the boundary is snapped — the snap is a deliberate authoring pass,
+not an accident of box modeling. An unsnapped variant built with the same
+code minus the snap pass carries a measured 3 mm joint error, which is
+what the check catches.
+
+The asset is built to be reused: geometry authored in kit space with the
+origin at the connection pivot (floor-center of the start edge), identity
+transforms by construction, clean named datablocks, manifold everywhere
+except the two intentional open ends, and all detail contained strictly
+inside the tile so it can never affect tiling.
+
+Check (all closed form or independently re-derived, nothing captured):
+
+1. Snap: exactly 16 boundary verts (8 per open end, closed form from the
+   hollow-rectangle profile), every one on its end plane x in {0, TILE}
+   within 1e-6 m; every boundary edge lies on an end plane.
+2. Loop coincidence: the two end rings matched by (y, z) key agree within
+   1e-6 — opposing loops are coincident under the tile offset.
+3. Tiling: a linked duplicate offset by (TILE, 0, 0) places its start ring
+   at world positions matching the original's end ring within 1e-6 — no
+   gap, no overlap at the joint.
+4. Bounding box: the shell's local bbox equals the declared tile size
+   exactly (0..TILE x +/-1.5 x 0..3, all float32-exact values), and the
+   whole-asset bbox matches it because every detail part is contained.
+5. Manifold: every edge has exactly 2 link faces except the 16 boundary
+   ring edges (1 face each) — the two open ends are the only boundaries.
+6. Reuse hygiene: identity object scales, names under Kit.CorridorSeg.*,
+   all part origins at the pivot.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+    blender --background --python modular_kit_snap.py --                 # check only
+    blender --background --python modular_kit_snap.py -- --output k.png  # + render
+    blender --background --python modular_kit_snap.py -- --falsify f.png # seam variant
+"""
+import bpy, bmesh, sys, os, math, argparse
+
+# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
+sys.dont_write_bytecode = True  # keep examples/__pycache__ out of the repo tree
+import gallery_framing
+
+TILE = 4.0                # tile length along X, metres
+WIDTH = 3.0               # corridor width along Y ( +/- 1.5 )
+HEIGHT = 3.0              # corridor height along Z
+WALL = 0.15               # shell thickness (floor / walls / ceiling)
+TOL = 1e-6                # snap / coincidence / tiling tolerance (metres)
+BBOX_TOL = 2e-6           # float32 rounding slack at the bbox extremes
+SKEW = 3e-3               # unsnapped variant: end-ring x error (3 mm)
+NUDGE = 2e-3              # unsnapped variant: two verts jogged in y (2 mm)
+EXPECT_BOUNDARY_VERTS = 16   # 8 per open end (hollow-rectangle profile)
+EXPECT_BOUNDARY_EDGES = 16   # 8 per open end
+
+# Hollow-rectangle profile (y, z): outer shell corners then inner bore corners,
+# ordered as one continuous ring so the extrusion's side faces come out quads.
+# The ring is the cross-section of the tube wall: outer bottom-left -> outer
+# bottom-right -> outer top-right -> outer top-left -> inner top-left -> ...
+OUTER = [(-WIDTH / 2, 0.0), (WIDTH / 2, 0.0),
+         (WIDTH / 2, HEIGHT), (-WIDTH / 2, HEIGHT)]
+INNER = [(-(WIDTH / 2 - WALL), HEIGHT - WALL), (WIDTH / 2 - WALL, HEIGHT - WALL),
+         (WIDTH / 2 - WALL, WALL), (-(WIDTH / 2 - WALL), WALL)]
+PROFILE = OUTER + INNER   # 8 points -> 8 boundary verts per open end
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+# ---------------------------------------------------------------------------
+# Asset construction (bmesh, kit space: origin = floor-center of the x=0 edge)
+# ---------------------------------------------------------------------------
+
+def build_shell(name, skew=0.0, nudge=0.0):
+    """The corridor tube: hollow-rectangle profile extruded over x in [0, TILE].
+
+    With skew=0 the end ring lands exactly on x == TILE (the snapped asset).
+    skew shifts the end ring in x (unsnapped variant), nudge jogs two of its
+    verts in y — the joint errors the check must catch.
+    """
+    me = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        ring_a = [bm.verts.new((0.0, y, z)) for (y, z) in PROFILE]
+        ring_b = []
+        for i, (y, z) in enumerate(PROFILE):
+            x = TILE + skew
+            if nudge and i in (1, 5):      # deterministic pair, jogged in y
+                y += nudge
+            ring_b.append(bm.verts.new((x, y, z)))
+        n = len(PROFILE)
+        for i in range(n):
+            a, b = i, (i + 1) % n
+            bm.faces.new((ring_a[a], ring_a[b], ring_b[b], ring_b[a]))
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    return me
+
+
+def _beveled_box(bm, dims, center, bevel=0.0, segments=2):
+    """One watertight box; small bevels are interior detail (never boundary)."""
+    bmesh.ops.create_cube(bm, size=1.0)
+    for v in bm.verts:
+        v.co.x = v.co.x * dims[0] + center[0]
+        v.co.y = v.co.y * dims[1] + center[1]
+        v.co.z = v.co.z * dims[2] + center[2]
+    if bevel > 0.0:
+        bmesh.ops.bevel(
+            bm, geom=list(bm.edges), offset=bevel, segments=segments,
+            profile=0.5, affect="EDGES", clamp_overlap=True,
+        )
+
+
+def build_detail(name, dims, center, bevel=0.0):
+    me = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        _beveled_box(bm, dims, center, bevel)
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    return me
+
+
+# Detail layout: (suffix, dims, center, bevel). Every box sits strictly inside
+# the tile — x in (0, TILE), |y| < WIDTH/2, z in (0, HEIGHT) — so detail can
+# never touch a boundary plane (check 6 proves it).
+def detail_layout():
+    inner_y = WIDTH / 2 - WALL                    # 1.35, the bore face
+    parts = [
+        # start-edge frame rib: two posts + a top beam, just inside x=0
+        ("Rib.Post.L", (0.24, 0.30, HEIGHT - 2 * WALL), (0.18, inner_y - 0.15, HEIGHT / 2), 0.02),
+        ("Rib.Post.R", (0.24, 0.30, HEIGHT - 2 * WALL), (0.18, -(inner_y - 0.15), HEIGHT / 2), 0.02),
+        ("Rib.Beam", (0.24, 2 * inner_y - 0.30, 0.34), (0.18, 0.0, HEIGHT - WALL - 0.17), 0.02),
+        # walkway floor plate
+        ("FloorPlate", (3.4, 1.8, 0.05), (TILE / 2, 0.0, WALL + 0.025), 0.015),
+        # wall panels, two per side, recessed-look slabs on the bore face
+        ("Panel.L.1", (1.15, 0.05, 1.15), (1.2, inner_y - 0.025, 1.55), 0.02),
+        ("Panel.L.2", (1.15, 0.05, 1.15), (2.8, inner_y - 0.025, 1.55), 0.02),
+        ("Panel.R.1", (1.15, 0.05, 1.15), (1.2, -(inner_y - 0.025), 1.55), 0.02),
+        ("Panel.R.2", (1.15, 0.05, 1.15), (2.8, -(inner_y - 0.025), 1.55), 0.02),
+        # orange trim rails — the lines that jog visibly when a joint steps
+        ("Trim.L", (3.7, 0.06, 0.09), (TILE / 2, inner_y - 0.03, 0.78), 0.015),
+        ("Trim.R", (3.7, 0.06, 0.09), (TILE / 2, -(inner_y - 0.03), 0.78), 0.015),
+        # emissive light strips along the wall/ceiling join
+        ("Light.L", (3.0, 0.05, 0.07), (TILE / 2, inner_y - 0.025, HEIGHT - WALL - 0.09), 0.01),
+        ("Light.R", (3.0, 0.05, 0.07), (TILE / 2, -(inner_y - 0.025), HEIGHT - WALL - 0.09), 0.01),
+    ]
+    return parts
+
+
+def build_kit_meshes(skew=0.0, nudge=0.0):
+    """All kit meshes, keyed by part suffix. Skew/nudge touch the shell only."""
+    meshes = {"Shell": build_shell("Kit.CorridorSeg.Shell", skew, nudge)}
+    for suffix, dims, center, bevel in detail_layout():
+        meshes[suffix] = build_detail(f"Kit.CorridorSeg.{suffix}", dims, center, bevel)
+    return meshes
+
+
+# ---------------------------------------------------------------------------
+# Check helpers
+# ---------------------------------------------------------------------------
+
+def boundary_verts(me):
+    """Verts of the open-end rings: x within TOL of an end plane candidate.
+    Returns (on_plane, off_plane) — off_plane must be empty in the snapped
+    asset and carries the measured error in the unsnapped one."""
+    on, off = [], []
+    for v in me.vertices:
+        if abs(v.co.x) <= 0.01 or abs(v.co.x - TILE) <= 0.01 + SKEW:
+            if abs(v.co.x) <= TOL or abs(v.co.x - TILE) <= TOL:
+                on.append(v)
+            else:
+                off.append(v)
+    return on, off
+
+
+def ring_key(co):
+    return (round(co.y, 6), round(co.z, 6))
+
+
+def max_nearest_dev(keys_a, keys_b):
+    """Worst displacement between two rings: for every key in A, the distance
+    to its nearest key in B, maximized. Robust to a displaced vert reordering
+    the ring (a sorted-zip would mispair and report a bogus cascade)."""
+    if not keys_a or not keys_b or len(keys_a) != len(keys_b):
+        return float("inf")
+    return max(min(math.dist(a, b) for b in keys_b) for a in keys_a)
+
+
+def mesh_bbox(me):
+    xs = [v.co.x for v in me.vertices]
+    ys = [v.co.y for v in me.vertices]
+    zs = [v.co.z for v in me.vertices]
+    return (min(xs), min(ys), min(zs)), (max(xs), max(ys), max(zs))
+
+
+def manifold_report(me):
+    """(boundary_edges, nonmanifold_edges) via bmesh link-face counts."""
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(me)
+        boundary = [e for e in bm.edges if len(e.link_faces) == 1]
+        nonmanifold = [e for e in bm.edges if len(e.link_faces) not in (1, 2)]
+        off_plane = [e for e in boundary
+                     if not all(abs(v.co.x) <= TOL or abs(v.co.x - TILE) <= TOL
+                                for v in e.verts)]
+        return len(boundary), len(nonmanifold), len(off_plane)
+    finally:
+        bm.free()
+
+
+def check():
+    meshes = build_kit_meshes()
+    shell = meshes["Shell"]
+    fails = []
+
+    def fail(code, msg):
+        print(f"ERROR ({code}): {msg}", file=sys.stderr)
+        fails.append(code)
+
+    # --- 1. snap: 16 boundary verts, all on x in {0, TILE} -----------------
+    on, off = boundary_verts(shell)
+    print(f"snap boundary_verts={len(on)} off_plane={len(off)} "
+          f"(closed form {EXPECT_BOUNDARY_VERTS})")
+    if len(on) != EXPECT_BOUNDARY_VERTS:
+        fail(3, f"boundary vert count {len(on)} != {EXPECT_BOUNDARY_VERTS}"
+                f"the profile or an end plane moved")
+    if off:
+        worst = max(min(abs(v.co.x), abs(v.co.x - TILE)) for v in off)
+        fail(3, f"{len(off)} boundary verts off the end planes, worst "
+                f"{worst:.3e} m > {TOL:.0e} — the snap pass was skipped")
+    worst_on = max((min(abs(v.co.x), abs(v.co.x - TILE)) for v in on), default=0.0)
+    print(f"snap max_dev={worst_on:.3e} tol={TOL:.0e}")
+
+    # --- 2. loop coincidence: end rings agree under the tile offset --------
+    ring_a = [ring_key(v.co) for v in on if abs(v.co.x) <= TOL]
+    ring_b = [ring_key(v.co) for v in on if abs(v.co.x - TILE) <= TOL]
+    co_dev = max_nearest_dev(ring_a, ring_b)
+    print(f"loop_coincidence rings={len(ring_a)}/{len(ring_b)} max_dev={co_dev:.3e} tol={TOL:.0e}")
+    if len(ring_a) != EXPECT_BOUNDARY_VERTS // 2 or len(ring_b) != EXPECT_BOUNDARY_VERTS // 2:
+        fail(4, "end rings do not partition evenly — profile asymmetry")
+    if co_dev > TOL:
+        fail(4, f"opposing end loops differ by {co_dev:.3e} > {TOL:.0e}"
+                f"segments would not butt cleanly")
+
+    # --- 3. tiling: linked duplicate shares boundary world positions -------
+    root = bpy.data.objects.new("Kit.CorridorSeg", None)
+    bpy.context.collection.objects.link(root)
+    shell_ob = bpy.data.objects.new("Kit.CorridorSeg.Shell", shell)
+    bpy.context.collection.objects.link(shell_ob)
+    shell_ob.parent = root
+    dup = shell_ob.copy()                       # linked duplicate (shares mesh)
+    dup.parent = None
+    dup.location = (TILE, 0.0, 0.0)
+    bpy.context.collection.objects.link(dup)
+    bpy.context.view_layer.update()
+    end_b_yz = [ring_key(shell_ob.matrix_world @ v.co) for v in on
+                if abs(v.co.x - TILE) <= TOL]
+    start_a_yz = [ring_key(dup.matrix_world @ v.co) for v in on
+                  if abs(v.co.x) <= TOL]
+    gap = max_nearest_dev(end_b_yz, start_a_yz)
+    x_gap = max((abs((dup.matrix_world @ v.co).x - TILE) for v in on
+                 if abs(v.co.x) <= TOL), default=float("inf"))
+    print(f"tiling joint gap/overlap yz_dev={gap:.3e} x_dev={x_gap:.3e} tol={TOL:.0e}")
+    if gap > TOL or x_gap > TOL:
+        fail(5, f"tiled instances do not share boundary positions "
+                f"(yz {gap:.3e}, x {x_gap:.3e}) — gap or overlap at the joint")
+
+    # --- 4. bbox == declared tile, for shell and for the whole asset -------
+    lo, hi = mesh_bbox(shell)
+    want_lo, want_hi = (0.0, -WIDTH / 2, 0.0), (TILE, WIDTH / 2, HEIGHT)
+    bbox_dev = max(max(abs(a - b) for a, b in zip(lo, want_lo)),
+                   max(abs(a - b) for a, b in zip(hi, want_hi)))
+    all_lo = [min(mesh_bbox(m)[0][i] for m in meshes.values()) for i in range(3)]
+    all_hi = [max(mesh_bbox(m)[1][i] for m in meshes.values()) for i in range(3)]
+    asset_dev = max(max(abs(a - b) for a, b in zip(all_lo, want_lo)),
+                    max(abs(a - b) for a, b in zip(all_hi, want_hi)))
+    print(f"bbox shell_dev={bbox_dev:.3e} asset_dev={asset_dev:.3e} tol={BBOX_TOL:.0e}")
+    if bbox_dev > BBOX_TOL:
+        fail(6, f"shell bbox deviates {bbox_dev:.3e} from the declared tile — "
+                f"footprint is not the contract size")
+    if asset_dev > BBOX_TOL:
+        fail(6, f"whole-asset bbox deviates {asset_dev:.3e} — detail escapes "
+                f"the tile envelope")
+
+    # --- 5. manifold except the two open ends -------------------------------
+    n_boundary, n_nonmanifold, n_off = manifold_report(shell)
+    print(f"manifold boundary_edges={n_boundary} (closed form "
+          f"{EXPECT_BOUNDARY_EDGES}) nonmanifold={n_nonmanifold} off_plane_edges={n_off}")
+    if n_boundary != EXPECT_BOUNDARY_EDGES:
+        fail(7, f"{n_boundary} boundary edges != {EXPECT_BOUNDARY_EDGES}"
+                f"the tube is not open exactly at its two ends")
+    if n_nonmanifold:
+        fail(7, f"{n_nonmanifold} edges with != 2 link faces (excluding open ends)")
+    if n_off:
+        fail(7, f"{n_off} boundary edges off the end planes — torn rim")
+
+    for suffix, me in meshes.items():
+        if suffix == "Shell":
+            continue
+        db, dn, _ = manifold_report(me)
+        if db or dn:
+            fail(8, f"detail part {suffix} not watertight "
+                    f"(boundary={db} nonmanifold={dn})")
+
+    # --- 6. detail containment: nothing touches the boundary planes --------
+    for suffix, me in meshes.items():
+        if suffix == "Shell":
+            continue
+        d_lo, d_hi = mesh_bbox(me)
+        outside = (d_lo[0] <= TOL or d_hi[0] >= TILE - TOL or
+                   d_lo[1] <= -WIDTH / 2 + TOL or d_hi[1] >= WIDTH / 2 - TOL or
+                   d_lo[2] <= -TOL or d_hi[2] >= HEIGHT - TOL)
+        if outside:
+            fail(9, f"detail part {suffix} reaches a tile boundary "
+                    f"{d_lo}..{d_hi} — it would break tiling")
+    print(f"detail_containment parts={len(meshes) - 1} all_inside=True")
+
+    # --- 7. reuse hygiene: transforms, names, origins -----------------------
+    parts_obs = []
+    for suffix, me in meshes.items():
+        ob = bpy.data.objects.new(me.name, me)
+        bpy.context.collection.objects.link(ob)
+        ob.parent = root
+        parts_obs.append(ob)
+    bpy.context.view_layer.update()
+    for ob in parts_obs:
+        if max(abs(s - 1.0) for s in ob.scale) > 0.0:
+            fail(11, f"{ob.name} scale {tuple(ob.scale)} not applied")
+        if not ob.name.startswith("Kit.CorridorSeg."):
+            fail(11, f"datablock name {ob.name!r} outside the kit namespace")
+        if max(abs(c) for c in ob.location) > 0.0:
+            fail(11, f"{ob.name} origin off the pivot {tuple(ob.location)}")
+    print(f"hygiene parts={len(parts_obs)} identity_scale=True origins_at_pivot=True")
+
+    if fails:
+        return fails[0]
+    print(f"modular-kit-snap OK tile={TILE}x{WIDTH}x{HEIGHT} "
+          f"boundary={len(on)}v/{n_boundary}e dev<={TOL:.0e} "
+          f"joint yz={gap:.3e} bbox_dev={bbox_dev:.3e}")
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# Render
+# ---------------------------------------------------------------------------
+
+def make_material(name, rgb, rough=0.45, metallic=0.6, emit=None, estr=0.0):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    b = mat.node_tree.nodes["Principled BSDF"]
+    b.inputs["Base Color"].default_value = (*rgb, 1.0)
+    b.inputs["Roughness"].default_value = rough
+    b.inputs["Metallic"].default_value = metallic
+    if emit is not None:
+        sock = b.inputs.get("Emission Color") or b.inputs["Emission"]
+        sock.default_value = (*emit, 1.0)
+        b.inputs["Emission Strength"].default_value = estr
+    return mat
+
+
+SEGMENT_MATS = {
+    "Shell": ("Shell", (0.10, 0.11, 0.13), 0.5, 0.8, None, 0.0),
+    "Rib": ("Rib", (0.05, 0.05, 0.06), 0.45, 0.85, None, 0.0),
+    "FloorPlate": ("FloorPlate", (0.07, 0.08, 0.09), 0.6, 0.7, None, 0.0),
+    "Panel": ("Panel", (0.19, 0.25, 0.33), 0.4, 0.6, None, 0.0),
+    "Trim": ("Trim", (0.85, 0.35, 0.08), 0.35, 0.3, None, 0.0),
+    "Light": ("LightStrip", (0.30, 0.25, 0.18), 0.5, 0.2, (1.0, 0.75, 0.4), 8.0),
+}
+
+
+def mat_for(suffix):
+    key = suffix.split(".")[0]
+    name, rgb, rough, metal, emit, estr = SEGMENT_MATS[key]
+    return make_material(name, rgb, rough, metal, emit, estr)
+
+
+def build_kit_objects(sc, x_offset, skew=0.0, nudge=0.0, y_jog=0.0, z_jog=0.0):
+    """One placed segment: root empty at (x_offset, y_jog, z_jog) + all parts."""
+    meshes = build_kit_meshes(skew, nudge)
+    root = bpy.data.objects.new(f"Kit.CorridorSeg", None)
+    root.location = (x_offset, y_jog, z_jog)
+    sc.collection.objects.link(root)
+    parts = []
+    for suffix, me in meshes.items():
+        if not me.materials:
+            me.materials.append(mat_for(suffix))
+        ob = bpy.data.objects.new(me.name, me)
+        sc.collection.objects.link(ob)
+        ob.parent = root
+        parts.append(ob)
+    return [root] + parts
+
+
+def build_studio(sc):
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = make_material("Studio", (0.03, 0.032, 0.037), rough=0.7, metallic=0.0)
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    sc.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)
+    sc.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,
+    )
+    sc.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)
+        sc.collection.objects.link(ob)
+
+    # exterior identity rig (VISUAL-STYLE Layer 2)
+    light("Key", (-3.5, -4.5, 5.5), 480.0, 4.5, (1.0, 0.96, 0.9), (48, 0, -35))
+    light("Fill", (5.0, -3.5, 2.5), 120.0, 9.0, (0.75, 0.85, 1.0), (65, 0, 50))
+    light("Rim", (1.5, 4.5, 3.5), 280.0, 3.0, (0.6, 0.78, 1.0), (-55, 0, 170))
+    light("Wedge", (2.5, 5.5, 4.0), 400.0, 6.0, (1.0, 0.72, 0.42), (-68, 0, 190))
+    # corridor fixtures: one warm area per segment under the light strips —
+    # the kit's own lighting language, designed with the asset
+    for i in range(4):
+        ld = bpy.data.lights.new(f"Fixture{i}", "AREA")
+        ld.energy = 130.0
+        ld.size = 3.2
+        ld.color = (1.0, 0.8, 0.55)
+        ob = bpy.data.objects.new(f"Fixture{i}", ld)
+        ob.location = (TILE * i + TILE / 2, 0.0, HEIGHT - WALL - 0.12)
+        ob.rotation_euler = (0.0, 0.0, 0.0)  # facing straight down (area -Z)
+        sc.collection.objects.link(ob)
+    return floor, wall
+
+
+def build_bulkhead(sc):
+    """Presentation focal: a lit door bulkhead capping the far end of the run.
+    Not part of the kit — the corridor's destination for this still."""
+    frame_mat = make_material("Bulkhead", (0.06, 0.06, 0.07), rough=0.4, metallic=0.85)
+    door_mat = make_material("BulkheadDoor", (0.30, 0.16, 0.07), rough=0.5,
+                             metallic=0.3, emit=(1.0, 0.42, 0.12), estr=0.6)
+    inner_y = WIDTH / 2 - WALL
+    end_x = TILE * 4
+    parts = []
+    for suffix, dims, center in [
+        ("Post.L", (0.5, 0.35, HEIGHT), (end_x + 0.25, inner_y - 0.17, HEIGHT / 2)),
+        ("Post.R", (0.5, 0.35, HEIGHT), (end_x + 0.25, -(inner_y - 0.17), HEIGHT / 2)),
+        ("Head", (0.5, 2 * inner_y, WALL + 0.35), (end_x + 0.25, 0.0, HEIGHT - (WALL + 0.35) / 2)),
+    ]:
+        me = build_detail(f"Bulkhead.{suffix}", dims, center, 0.03)
+        me.materials.append(frame_mat)
+        ob = bpy.data.objects.new(f"Bulkhead.{suffix}", me)
+        sc.collection.objects.link(ob)
+        parts.append(ob)
+    door = build_detail("Bulkhead.Door", (0.12, 1.6, 2.1), (end_x + 0.55, 0.0, 1.05))
+    door.materials.append(door_mat)
+    door_ob = bpy.data.objects.new("Bulkhead.Door", door)
+    sc.collection.objects.link(door_ob)
+    parts.append(door_ob)
+    # warm spill into the corridor, toward the camera
+    ld = bpy.data.lights.new("BulkheadSpill", "AREA")
+    ld.energy = 140.0
+    ld.size = 1.8
+    ld.color = (1.0, 0.62, 0.3)
+    ob = bpy.data.objects.new("BulkheadSpill", ld)
+    ob.location = (end_x - 0.6, 0.0, 1.5)
+    ob.rotation_euler = (math.radians(90), 0.0, math.radians(-90))  # face -X
+    sc.collection.objects.link(ob)
+    # cool rim from the mouth behind the camera: lifts the near right wall and
+    # the ceiling edge out of dead black without touching the warm pools
+    rd = bpy.data.lights.new("MouthRim", "AREA")
+    rd.energy = 65.0
+    rd.size = 2.0
+    rd.color = (0.62, 0.76, 1.0)
+    rob = bpy.data.objects.new("MouthRim", rd)
+    rob.location = (-1.2, -0.9, 2.2)
+    rob.rotation_euler = (math.radians(70), 0.0, math.radians(-70))
+    sc.collection.objects.link(rob)
+    return parts
+
+
+def render_still(path, engine, falsify=False):
+    """A four-segment run. Snapped: joints vanish. Falsified: each segment
+    accumulates a 120 mm x gap, alternating 50 mm y jogs, and 40 mm floor
+    steps, so every joint reads as a seam — the render-scale exaggeration of
+    the failure the check measures at 3 mm (probe_unsnapped)."""
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    sc = bpy.context.scene
+
+    all_parts = []
+    for i in range(4):
+        if falsify:
+            obs = build_kit_objects(
+                sc, TILE * i + i * 0.12,
+                y_jog=(0.05 if i % 2 else 0.0),
+                z_jog=(0.04 if i % 2 else 0.0))
+        else:
+            obs = build_kit_objects(sc, TILE * i)
+        all_parts.extend(obs[1:])   # mesh parts only, not the root empties
+    floor, wall = build_studio(sc)
+    bulkhead = build_bulkhead(sc)
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 25.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (-0.7, -0.42, 1.58)
+    sc.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (10.0, 0.35, 1.25)
+    sc.collection.objects.link(aim)
+    tr = cam.constraints.new("TRACK_TO")
+    tr.target = aim
+    tr.track_axis = "TRACK_NEGATIVE_Z"
+    tr.up_axis = "UP_Y"
+    sc.camera = cam
+
+    sc.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        sc.cycles.device = "CPU"
+        sc.cycles.samples = 64
+        sc.cycles.use_denoising = True
+    else:
+        try:
+            sc.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    sc.render.resolution_x = 1280
+    sc.render.resolution_y = 720
+    sc.render.image_settings.file_format = "PNG"
+    sc.render.filepath = path
+    # Standard, always — AgX would lift the stage toward grey (VISUAL-STYLE)
+    sc.view_settings.view_transform = "Standard"
+    # Layer 1 framing gate. The corridor surrounds the camera on five sides
+    # and reads as extending past the frame — a documented deviation class
+    # (radiating architecture), measured and reported by the helper.
+    fcode = gallery_framing.check_framing(
+        sc, cam,
+        hero=all_parts,
+        elements=all_parts + bulkhead,
+        stage=[floor, wall],
+        deviation="interior corridor run: the envelope surrounds the camera "
+                  "and the tiling joints are the proof; edge bleed is the design",
+    )
+    if fcode:
+        return fcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        print("ERROR: render produced no file", file=sys.stderr)
+        return 9
+    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, help="optional: render a still PNG here")
+    p.add_argument("--falsify", default=None,
+                   help="optional: render the unsnapped-joint seam variant here")
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"),
+                   help="render engine for --output/--falsify (cycles for GPU-less hosts)")
+    args = p.parse_args(argv)
+
+    print(f"binary version: {bpy.app.version} ({bpy.app.version_string})")
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    code = check()
+    if code:
+        return code
+
+    if args.output:
+        rcode = render_still(os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    if args.falsify:
+        rcode = render_still(os.path.abspath(args.falsify), args.engine, falsify=True)
+        if rcode:
+            return rcode
+        print(f"rendered falsified variant {args.falsify}")
+
+    print("modular-kit-snap OK")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as e:
+        import traceback
+
+        traceback.print_exc()
+        print(f"FATAL: {e}", file=sys.stderr)
+        sys.exit(1)
+
+
+
+ + + + + diff --git a/examples/gallery.json b/examples/gallery.json index 299ccc3..a11b291 100644 --- a/examples/gallery.json +++ b/examples/gallery.json @@ -185,8 +185,7 @@ "mesh", "materials", "attributes" - ], - "featured_rank": 8 + ] }, { "name": "parent-inverse-orrery", @@ -503,6 +502,21 @@ "mesh", "game-pipeline" ] + }, + { + "name": "modular-kit-snap", + "dir": "examples/modular-kit-snap", + "teaches": "A tiling corridor kit whose open-end boundary verts snap to the tile grid, so instances at 4 m multiples join with zero gap or overlap.", + "witnessesFix": "16 boundary verts on x∈{0,4} within 1e-6 m, linked-duplicate joint deviation 0.0, bbox == declared tile; unsnapped probe fails at 3e-3 m.", + "hero": "docs/gallery/assets/modular-kit-snap-hero.webp", + "preview": "examples/modular-kit-snap/preview.webp", + "tags": [ + "mesh", + "bmesh", + "game-pipeline", + "instancing" + ], + "featured_rank": 8 } ] } diff --git a/examples/modular-kit-snap/README.md b/examples/modular-kit-snap/README.md new file mode 100644 index 0000000..859c681 --- /dev/null +++ b/examples/modular-kit-snap/README.md @@ -0,0 +1,80 @@ +# Modular Kit Snap + +A runnable example building the asset a tiling modular kit lives or dies +by: a corridor segment whose open-end boundary vertices sit **exactly** on +the declared tile grid, so instances placed at 4 m multiples share boundary +positions with no gap and no overlap. Interior geometry is free-form +(beveled detail boxes); only the boundary is snapped — the snap is a +deliberate authoring pass, and the check is what catches you skipping it. + +**The asset, for reuse:** a 4 × 3 × 3 m corridor segment (hollow-rectangle +shell plus twelve detail parts — frame rib, walkway plate, wall panels, +trim rails, emissive light strips). Origin at the connection pivot +(floor-center of the start edge, so instance *n* places at `x = n·4`), +identity transforms by construction, datablocks under +`Kit.CorridorSeg.*`, manifold everywhere except the two intentional open +ends, and every detail part contained strictly inside the tile so it can +never break a joint. Drop the hierarchy into a scene and array it along X. + +**Pipeline arc neighbors:** watertight parametric solids in +[`bmesh-gear`](../bmesh-gear/), topology gates in +[`mesh-hygiene-audit`](../mesh-hygiene-audit/), origin/pivot discipline in +[`prop-origin-transform`](../prop-origin-transform/), collision packaging in +[`collision-hull-proxy`](../collision-hull-proxy/). + +**What it witnesses** (all closed form or independently re-derived): + +- **Snap.** Exactly **16** boundary verts (8 per open end, from the + hollow-rectangle profile), every one on its end plane `x ∈ {0, 4}` within + **1e-6 m**; every boundary edge lies on an end plane. +- **Loop coincidence.** The two end rings, matched by nearest (y, z) key, + agree within **1e-6** — opposing loops are coincident under the tile + offset. +- **Tiling.** A linked duplicate offset by `(4, 0, 0)` places its start ring + at world positions matching the original's end ring within **1e-6** — no + gap, no overlap at the joint. +- **Bounding box.** The shell's local bbox equals the declared tile exactly + (`0..4 × ±1.5 × 0..3`, all float32-exact values, deviation 0.0), and the + whole-asset bbox matches it because detail is contained. +- **Manifold.** Every edge has exactly 2 link faces except the **16** + boundary ring edges (1 face each) — the open ends are the only boundaries. +- **Reuse hygiene.** Identity scales, `Kit.CorridorSeg.*` names, all part + origins at the pivot. + +**What each check catches on failure** (probed with an unsnapped variant +built by the same code minus the snap pass — 3 mm end-ring skew, 2 mm +y-nudge on two verts): boundary verts off the end planes, worst **3.000e-03 m** +(exit 3); opposing rings displaced **2.000e-03** (exit 4); tiled joint +gap/overlap (exit 5); bbox off the declared tile by **3.000e-03** (exit 6); +boundary edges torn off the rim (exit 7); non-watertight detail (exit 8); +detail escaping the tile (exit 9); unapplied transforms, default names, or +wandering origins (exit 11). + +**Version witness:** check output is byte-identical on Blender 4.5.11 LTS +and 5.1.2 — same counts, same zero measured deviations. + +**Render as proof:** a four-segment run — the joints vanish. The +falsification variant (`--falsify`) accumulates a 120 mm gap, 50 mm lateral +jogs, and 40 mm floor steps at each joint: floor plates split with dark +seams and the trim rails visibly break. The check measures the same failure +class at 3 mm; the render exaggerates it to read at frame scale. + +**Framing deviation:** the still is an interior corridor run — the envelope +surrounds the camera on five sides and the tiling joints are the proof, so +the subject reads as extending past the frame (the radiating-architecture +class of VISUAL-STYLE Layer 1). `check_framing` measures and reports +(fill 1.000/1.000, all-edge bleed) without enforcing, with the reason +string at the call site. + +## Run + +```bash +blender --background --python modular_kit_snap.py -- +blender --background --python modular_kit_snap.py -- --output corridor.png +blender --background --python modular_kit_snap.py -- --falsify seams.png +``` + +Exits non-zero on failure. The `blender-smoke` workflow runs the check on +Blender 4.5 LTS and 5.1. The `--output` render path additionally measures +framing against the Layer 1 band via `examples/gallery_framing.py` (reported +under the documented deviation above) before writing the still. diff --git a/examples/modular-kit-snap/modular_kit_snap.py b/examples/modular-kit-snap/modular_kit_snap.py new file mode 100644 index 0000000..b3a2127 --- /dev/null +++ b/examples/modular-kit-snap/modular_kit_snap.py @@ -0,0 +1,612 @@ +"""Modular kit snap — boundary vertices welded to the tile grid so segments tile seamlessly. + +Witnesses the contract a tiling modular kit lives or dies by: a corridor +segment whose open-end boundary vertices sit EXACTLY on the declared tile +grid, so instances placed at tile multiples share boundary positions with +no gap and no overlap. Interior geometry is free-form (beveled detail); +only the boundary is snapped — the snap is a deliberate authoring pass, +not an accident of box modeling. An unsnapped variant built with the same +code minus the snap pass carries a measured 3 mm joint error, which is +what the check catches. + +The asset is built to be reused: geometry authored in kit space with the +origin at the connection pivot (floor-center of the start edge), identity +transforms by construction, clean named datablocks, manifold everywhere +except the two intentional open ends, and all detail contained strictly +inside the tile so it can never affect tiling. + +Check (all closed form or independently re-derived, nothing captured): + +1. Snap: exactly 16 boundary verts (8 per open end, closed form from the + hollow-rectangle profile), every one on its end plane x in {0, TILE} + within 1e-6 m; every boundary edge lies on an end plane. +2. Loop coincidence: the two end rings matched by (y, z) key agree within + 1e-6 — opposing loops are coincident under the tile offset. +3. Tiling: a linked duplicate offset by (TILE, 0, 0) places its start ring + at world positions matching the original's end ring within 1e-6 — no + gap, no overlap at the joint. +4. Bounding box: the shell's local bbox equals the declared tile size + exactly (0..TILE x +/-1.5 x 0..3, all float32-exact values), and the + whole-asset bbox matches it because every detail part is contained. +5. Manifold: every edge has exactly 2 link faces except the 16 boundary + ring edges (1 face each) — the two open ends are the only boundaries. +6. Reuse hygiene: identity object scales, names under Kit.CorridorSeg.*, + all part origins at the pivot. + +By default it runs only the correctness check (no render) — the CI smoke +check. Pass --output to also render a still: + + blender --background --python modular_kit_snap.py -- # check only + blender --background --python modular_kit_snap.py -- --output k.png # + render + blender --background --python modular_kit_snap.py -- --falsify f.png # seam variant +""" +import bpy, bmesh, sys, os, math, argparse + +# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)) +sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree +import gallery_framing + +TILE = 4.0 # tile length along X, metres +WIDTH = 3.0 # corridor width along Y ( +/- 1.5 ) +HEIGHT = 3.0 # corridor height along Z +WALL = 0.15 # shell thickness (floor / walls / ceiling) +TOL = 1e-6 # snap / coincidence / tiling tolerance (metres) +BBOX_TOL = 2e-6 # float32 rounding slack at the bbox extremes +SKEW = 3e-3 # unsnapped variant: end-ring x error (3 mm) +NUDGE = 2e-3 # unsnapped variant: two verts jogged in y (2 mm) +EXPECT_BOUNDARY_VERTS = 16 # 8 per open end (hollow-rectangle profile) +EXPECT_BOUNDARY_EDGES = 16 # 8 per open end + +# Hollow-rectangle profile (y, z): outer shell corners then inner bore corners, +# ordered as one continuous ring so the extrusion's side faces come out quads. +# The ring is the cross-section of the tube wall: outer bottom-left -> outer +# bottom-right -> outer top-right -> outer top-left -> inner top-left -> ... +OUTER = [(-WIDTH / 2, 0.0), (WIDTH / 2, 0.0), + (WIDTH / 2, HEIGHT), (-WIDTH / 2, HEIGHT)] +INNER = [(-(WIDTH / 2 - WALL), HEIGHT - WALL), (WIDTH / 2 - WALL, HEIGHT - WALL), + (WIDTH / 2 - WALL, WALL), (-(WIDTH / 2 - WALL), WALL)] +PROFILE = OUTER + INNER # 8 points -> 8 boundary verts per open end + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +# --------------------------------------------------------------------------- +# Asset construction (bmesh, kit space: origin = floor-center of the x=0 edge) +# --------------------------------------------------------------------------- + +def build_shell(name, skew=0.0, nudge=0.0): + """The corridor tube: hollow-rectangle profile extruded over x in [0, TILE]. + + With skew=0 the end ring lands exactly on x == TILE (the snapped asset). + skew shifts the end ring in x (unsnapped variant), nudge jogs two of its + verts in y — the joint errors the check must catch. + """ + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + ring_a = [bm.verts.new((0.0, y, z)) for (y, z) in PROFILE] + ring_b = [] + for i, (y, z) in enumerate(PROFILE): + x = TILE + skew + if nudge and i in (1, 5): # deterministic pair, jogged in y + y += nudge + ring_b.append(bm.verts.new((x, y, z))) + n = len(PROFILE) + for i in range(n): + a, b = i, (i + 1) % n + bm.faces.new((ring_a[a], ring_a[b], ring_b[b], ring_b[a])) + bm.to_mesh(me) + finally: + bm.free() + return me + + +def _beveled_box(bm, dims, center, bevel=0.0, segments=2): + """One watertight box; small bevels are interior detail (never boundary).""" + bmesh.ops.create_cube(bm, size=1.0) + for v in bm.verts: + v.co.x = v.co.x * dims[0] + center[0] + v.co.y = v.co.y * dims[1] + center[1] + v.co.z = v.co.z * dims[2] + center[2] + if bevel > 0.0: + bmesh.ops.bevel( + bm, geom=list(bm.edges), offset=bevel, segments=segments, + profile=0.5, affect="EDGES", clamp_overlap=True, + ) + + +def build_detail(name, dims, center, bevel=0.0): + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + _beveled_box(bm, dims, center, bevel) + bm.to_mesh(me) + finally: + bm.free() + return me + + +# Detail layout: (suffix, dims, center, bevel). Every box sits strictly inside +# the tile — x in (0, TILE), |y| < WIDTH/2, z in (0, HEIGHT) — so detail can +# never touch a boundary plane (check 6 proves it). +def detail_layout(): + inner_y = WIDTH / 2 - WALL # 1.35, the bore face + parts = [ + # start-edge frame rib: two posts + a top beam, just inside x=0 + ("Rib.Post.L", (0.24, 0.30, HEIGHT - 2 * WALL), (0.18, inner_y - 0.15, HEIGHT / 2), 0.02), + ("Rib.Post.R", (0.24, 0.30, HEIGHT - 2 * WALL), (0.18, -(inner_y - 0.15), HEIGHT / 2), 0.02), + ("Rib.Beam", (0.24, 2 * inner_y - 0.30, 0.34), (0.18, 0.0, HEIGHT - WALL - 0.17), 0.02), + # walkway floor plate + ("FloorPlate", (3.4, 1.8, 0.05), (TILE / 2, 0.0, WALL + 0.025), 0.015), + # wall panels, two per side, recessed-look slabs on the bore face + ("Panel.L.1", (1.15, 0.05, 1.15), (1.2, inner_y - 0.025, 1.55), 0.02), + ("Panel.L.2", (1.15, 0.05, 1.15), (2.8, inner_y - 0.025, 1.55), 0.02), + ("Panel.R.1", (1.15, 0.05, 1.15), (1.2, -(inner_y - 0.025), 1.55), 0.02), + ("Panel.R.2", (1.15, 0.05, 1.15), (2.8, -(inner_y - 0.025), 1.55), 0.02), + # orange trim rails — the lines that jog visibly when a joint steps + ("Trim.L", (3.7, 0.06, 0.09), (TILE / 2, inner_y - 0.03, 0.78), 0.015), + ("Trim.R", (3.7, 0.06, 0.09), (TILE / 2, -(inner_y - 0.03), 0.78), 0.015), + # emissive light strips along the wall/ceiling join + ("Light.L", (3.0, 0.05, 0.07), (TILE / 2, inner_y - 0.025, HEIGHT - WALL - 0.09), 0.01), + ("Light.R", (3.0, 0.05, 0.07), (TILE / 2, -(inner_y - 0.025), HEIGHT - WALL - 0.09), 0.01), + ] + return parts + + +def build_kit_meshes(skew=0.0, nudge=0.0): + """All kit meshes, keyed by part suffix. Skew/nudge touch the shell only.""" + meshes = {"Shell": build_shell("Kit.CorridorSeg.Shell", skew, nudge)} + for suffix, dims, center, bevel in detail_layout(): + meshes[suffix] = build_detail(f"Kit.CorridorSeg.{suffix}", dims, center, bevel) + return meshes + + +# --------------------------------------------------------------------------- +# Check helpers +# --------------------------------------------------------------------------- + +def boundary_verts(me): + """Verts of the open-end rings: x within TOL of an end plane candidate. + Returns (on_plane, off_plane) — off_plane must be empty in the snapped + asset and carries the measured error in the unsnapped one.""" + on, off = [], [] + for v in me.vertices: + if abs(v.co.x) <= 0.01 or abs(v.co.x - TILE) <= 0.01 + SKEW: + if abs(v.co.x) <= TOL or abs(v.co.x - TILE) <= TOL: + on.append(v) + else: + off.append(v) + return on, off + + +def ring_key(co): + return (round(co.y, 6), round(co.z, 6)) + + +def max_nearest_dev(keys_a, keys_b): + """Worst displacement between two rings: for every key in A, the distance + to its nearest key in B, maximized. Robust to a displaced vert reordering + the ring (a sorted-zip would mispair and report a bogus cascade).""" + if not keys_a or not keys_b or len(keys_a) != len(keys_b): + return float("inf") + return max(min(math.dist(a, b) for b in keys_b) for a in keys_a) + + +def mesh_bbox(me): + xs = [v.co.x for v in me.vertices] + ys = [v.co.y for v in me.vertices] + zs = [v.co.z for v in me.vertices] + return (min(xs), min(ys), min(zs)), (max(xs), max(ys), max(zs)) + + +def manifold_report(me): + """(boundary_edges, nonmanifold_edges) via bmesh link-face counts.""" + bm = bmesh.new() + try: + bm.from_mesh(me) + boundary = [e for e in bm.edges if len(e.link_faces) == 1] + nonmanifold = [e for e in bm.edges if len(e.link_faces) not in (1, 2)] + off_plane = [e for e in boundary + if not all(abs(v.co.x) <= TOL or abs(v.co.x - TILE) <= TOL + for v in e.verts)] + return len(boundary), len(nonmanifold), len(off_plane) + finally: + bm.free() + + +def check(): + meshes = build_kit_meshes() + shell = meshes["Shell"] + fails = [] + + def fail(code, msg): + print(f"ERROR ({code}): {msg}", file=sys.stderr) + fails.append(code) + + # --- 1. snap: 16 boundary verts, all on x in {0, TILE} ----------------- + on, off = boundary_verts(shell) + print(f"snap boundary_verts={len(on)} off_plane={len(off)} " + f"(closed form {EXPECT_BOUNDARY_VERTS})") + if len(on) != EXPECT_BOUNDARY_VERTS: + fail(3, f"boundary vert count {len(on)} != {EXPECT_BOUNDARY_VERTS} — " + f"the profile or an end plane moved") + if off: + worst = max(min(abs(v.co.x), abs(v.co.x - TILE)) for v in off) + fail(3, f"{len(off)} boundary verts off the end planes, worst " + f"{worst:.3e} m > {TOL:.0e} — the snap pass was skipped") + worst_on = max((min(abs(v.co.x), abs(v.co.x - TILE)) for v in on), default=0.0) + print(f"snap max_dev={worst_on:.3e} tol={TOL:.0e}") + + # --- 2. loop coincidence: end rings agree under the tile offset -------- + ring_a = [ring_key(v.co) for v in on if abs(v.co.x) <= TOL] + ring_b = [ring_key(v.co) for v in on if abs(v.co.x - TILE) <= TOL] + co_dev = max_nearest_dev(ring_a, ring_b) + print(f"loop_coincidence rings={len(ring_a)}/{len(ring_b)} max_dev={co_dev:.3e} tol={TOL:.0e}") + if len(ring_a) != EXPECT_BOUNDARY_VERTS // 2 or len(ring_b) != EXPECT_BOUNDARY_VERTS // 2: + fail(4, "end rings do not partition evenly — profile asymmetry") + if co_dev > TOL: + fail(4, f"opposing end loops differ by {co_dev:.3e} > {TOL:.0e} — " + f"segments would not butt cleanly") + + # --- 3. tiling: linked duplicate shares boundary world positions ------- + root = bpy.data.objects.new("Kit.CorridorSeg", None) + bpy.context.collection.objects.link(root) + shell_ob = bpy.data.objects.new("Kit.CorridorSeg.Shell", shell) + bpy.context.collection.objects.link(shell_ob) + shell_ob.parent = root + dup = shell_ob.copy() # linked duplicate (shares mesh) + dup.parent = None + dup.location = (TILE, 0.0, 0.0) + bpy.context.collection.objects.link(dup) + bpy.context.view_layer.update() + end_b_yz = [ring_key(shell_ob.matrix_world @ v.co) for v in on + if abs(v.co.x - TILE) <= TOL] + start_a_yz = [ring_key(dup.matrix_world @ v.co) for v in on + if abs(v.co.x) <= TOL] + gap = max_nearest_dev(end_b_yz, start_a_yz) + x_gap = max((abs((dup.matrix_world @ v.co).x - TILE) for v in on + if abs(v.co.x) <= TOL), default=float("inf")) + print(f"tiling joint gap/overlap yz_dev={gap:.3e} x_dev={x_gap:.3e} tol={TOL:.0e}") + if gap > TOL or x_gap > TOL: + fail(5, f"tiled instances do not share boundary positions " + f"(yz {gap:.3e}, x {x_gap:.3e}) — gap or overlap at the joint") + + # --- 4. bbox == declared tile, for shell and for the whole asset ------- + lo, hi = mesh_bbox(shell) + want_lo, want_hi = (0.0, -WIDTH / 2, 0.0), (TILE, WIDTH / 2, HEIGHT) + bbox_dev = max(max(abs(a - b) for a, b in zip(lo, want_lo)), + max(abs(a - b) for a, b in zip(hi, want_hi))) + all_lo = [min(mesh_bbox(m)[0][i] for m in meshes.values()) for i in range(3)] + all_hi = [max(mesh_bbox(m)[1][i] for m in meshes.values()) for i in range(3)] + asset_dev = max(max(abs(a - b) for a, b in zip(all_lo, want_lo)), + max(abs(a - b) for a, b in zip(all_hi, want_hi))) + print(f"bbox shell_dev={bbox_dev:.3e} asset_dev={asset_dev:.3e} tol={BBOX_TOL:.0e}") + if bbox_dev > BBOX_TOL: + fail(6, f"shell bbox deviates {bbox_dev:.3e} from the declared tile — " + f"footprint is not the contract size") + if asset_dev > BBOX_TOL: + fail(6, f"whole-asset bbox deviates {asset_dev:.3e} — detail escapes " + f"the tile envelope") + + # --- 5. manifold except the two open ends ------------------------------- + n_boundary, n_nonmanifold, n_off = manifold_report(shell) + print(f"manifold boundary_edges={n_boundary} (closed form " + f"{EXPECT_BOUNDARY_EDGES}) nonmanifold={n_nonmanifold} off_plane_edges={n_off}") + if n_boundary != EXPECT_BOUNDARY_EDGES: + fail(7, f"{n_boundary} boundary edges != {EXPECT_BOUNDARY_EDGES} — " + f"the tube is not open exactly at its two ends") + if n_nonmanifold: + fail(7, f"{n_nonmanifold} edges with != 2 link faces (excluding open ends)") + if n_off: + fail(7, f"{n_off} boundary edges off the end planes — torn rim") + + for suffix, me in meshes.items(): + if suffix == "Shell": + continue + db, dn, _ = manifold_report(me) + if db or dn: + fail(8, f"detail part {suffix} not watertight " + f"(boundary={db} nonmanifold={dn})") + + # --- 6. detail containment: nothing touches the boundary planes -------- + for suffix, me in meshes.items(): + if suffix == "Shell": + continue + d_lo, d_hi = mesh_bbox(me) + outside = (d_lo[0] <= TOL or d_hi[0] >= TILE - TOL or + d_lo[1] <= -WIDTH / 2 + TOL or d_hi[1] >= WIDTH / 2 - TOL or + d_lo[2] <= -TOL or d_hi[2] >= HEIGHT - TOL) + if outside: + fail(9, f"detail part {suffix} reaches a tile boundary " + f"{d_lo}..{d_hi} — it would break tiling") + print(f"detail_containment parts={len(meshes) - 1} all_inside=True") + + # --- 7. reuse hygiene: transforms, names, origins ----------------------- + parts_obs = [] + for suffix, me in meshes.items(): + ob = bpy.data.objects.new(me.name, me) + bpy.context.collection.objects.link(ob) + ob.parent = root + parts_obs.append(ob) + bpy.context.view_layer.update() + for ob in parts_obs: + if max(abs(s - 1.0) for s in ob.scale) > 0.0: + fail(11, f"{ob.name} scale {tuple(ob.scale)} not applied") + if not ob.name.startswith("Kit.CorridorSeg."): + fail(11, f"datablock name {ob.name!r} outside the kit namespace") + if max(abs(c) for c in ob.location) > 0.0: + fail(11, f"{ob.name} origin off the pivot {tuple(ob.location)}") + print(f"hygiene parts={len(parts_obs)} identity_scale=True origins_at_pivot=True") + + if fails: + return fails[0] + print(f"modular-kit-snap OK tile={TILE}x{WIDTH}x{HEIGHT} " + f"boundary={len(on)}v/{n_boundary}e dev<={TOL:.0e} " + f"joint yz={gap:.3e} bbox_dev={bbox_dev:.3e}") + return 0 + + +# --------------------------------------------------------------------------- +# Render +# --------------------------------------------------------------------------- + +def make_material(name, rgb, rough=0.45, metallic=0.6, emit=None, estr=0.0): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + b = mat.node_tree.nodes["Principled BSDF"] + b.inputs["Base Color"].default_value = (*rgb, 1.0) + b.inputs["Roughness"].default_value = rough + b.inputs["Metallic"].default_value = metallic + if emit is not None: + sock = b.inputs.get("Emission Color") or b.inputs["Emission"] + sock.default_value = (*emit, 1.0) + b.inputs["Emission Strength"].default_value = estr + return mat + + +SEGMENT_MATS = { + "Shell": ("Shell", (0.10, 0.11, 0.13), 0.5, 0.8, None, 0.0), + "Rib": ("Rib", (0.05, 0.05, 0.06), 0.45, 0.85, None, 0.0), + "FloorPlate": ("FloorPlate", (0.07, 0.08, 0.09), 0.6, 0.7, None, 0.0), + "Panel": ("Panel", (0.19, 0.25, 0.33), 0.4, 0.6, None, 0.0), + "Trim": ("Trim", (0.85, 0.35, 0.08), 0.35, 0.3, None, 0.0), + "Light": ("LightStrip", (0.30, 0.25, 0.18), 0.5, 0.2, (1.0, 0.75, 0.4), 8.0), +} + + +def mat_for(suffix): + key = suffix.split(".")[0] + name, rgb, rough, metal, emit, estr = SEGMENT_MATS[key] + return make_material(name, rgb, rough, metal, emit, estr) + + +def build_kit_objects(sc, x_offset, skew=0.0, nudge=0.0, y_jog=0.0, z_jog=0.0): + """One placed segment: root empty at (x_offset, y_jog, z_jog) + all parts.""" + meshes = build_kit_meshes(skew, nudge) + root = bpy.data.objects.new(f"Kit.CorridorSeg", None) + root.location = (x_offset, y_jog, z_jog) + sc.collection.objects.link(root) + parts = [] + for suffix, me in meshes.items(): + if not me.materials: + me.materials.append(mat_for(suffix)) + ob = bpy.data.objects.new(me.name, me) + sc.collection.objects.link(ob) + ob.parent = root + parts.append(ob) + return [root] + parts + + +def build_studio(sc): + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = make_material("Studio", (0.03, 0.032, 0.037), rough=0.7, metallic=0.0) + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + sc.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) + sc.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, + ) + sc.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) + sc.collection.objects.link(ob) + + # exterior identity rig (VISUAL-STYLE Layer 2) + light("Key", (-3.5, -4.5, 5.5), 480.0, 4.5, (1.0, 0.96, 0.9), (48, 0, -35)) + light("Fill", (5.0, -3.5, 2.5), 120.0, 9.0, (0.75, 0.85, 1.0), (65, 0, 50)) + light("Rim", (1.5, 4.5, 3.5), 280.0, 3.0, (0.6, 0.78, 1.0), (-55, 0, 170)) + light("Wedge", (2.5, 5.5, 4.0), 400.0, 6.0, (1.0, 0.72, 0.42), (-68, 0, 190)) + # corridor fixtures: one warm area per segment under the light strips — + # the kit's own lighting language, designed with the asset + for i in range(4): + ld = bpy.data.lights.new(f"Fixture{i}", "AREA") + ld.energy = 130.0 + ld.size = 3.2 + ld.color = (1.0, 0.8, 0.55) + ob = bpy.data.objects.new(f"Fixture{i}", ld) + ob.location = (TILE * i + TILE / 2, 0.0, HEIGHT - WALL - 0.12) + ob.rotation_euler = (0.0, 0.0, 0.0) # facing straight down (area -Z) + sc.collection.objects.link(ob) + return floor, wall + + +def build_bulkhead(sc): + """Presentation focal: a lit door bulkhead capping the far end of the run. + Not part of the kit — the corridor's destination for this still.""" + frame_mat = make_material("Bulkhead", (0.06, 0.06, 0.07), rough=0.4, metallic=0.85) + door_mat = make_material("BulkheadDoor", (0.30, 0.16, 0.07), rough=0.5, + metallic=0.3, emit=(1.0, 0.42, 0.12), estr=0.6) + inner_y = WIDTH / 2 - WALL + end_x = TILE * 4 + parts = [] + for suffix, dims, center in [ + ("Post.L", (0.5, 0.35, HEIGHT), (end_x + 0.25, inner_y - 0.17, HEIGHT / 2)), + ("Post.R", (0.5, 0.35, HEIGHT), (end_x + 0.25, -(inner_y - 0.17), HEIGHT / 2)), + ("Head", (0.5, 2 * inner_y, WALL + 0.35), (end_x + 0.25, 0.0, HEIGHT - (WALL + 0.35) / 2)), + ]: + me = build_detail(f"Bulkhead.{suffix}", dims, center, 0.03) + me.materials.append(frame_mat) + ob = bpy.data.objects.new(f"Bulkhead.{suffix}", me) + sc.collection.objects.link(ob) + parts.append(ob) + door = build_detail("Bulkhead.Door", (0.12, 1.6, 2.1), (end_x + 0.55, 0.0, 1.05)) + door.materials.append(door_mat) + door_ob = bpy.data.objects.new("Bulkhead.Door", door) + sc.collection.objects.link(door_ob) + parts.append(door_ob) + # warm spill into the corridor, toward the camera + ld = bpy.data.lights.new("BulkheadSpill", "AREA") + ld.energy = 140.0 + ld.size = 1.8 + ld.color = (1.0, 0.62, 0.3) + ob = bpy.data.objects.new("BulkheadSpill", ld) + ob.location = (end_x - 0.6, 0.0, 1.5) + ob.rotation_euler = (math.radians(90), 0.0, math.radians(-90)) # face -X + sc.collection.objects.link(ob) + # cool rim from the mouth behind the camera: lifts the near right wall and + # the ceiling edge out of dead black without touching the warm pools + rd = bpy.data.lights.new("MouthRim", "AREA") + rd.energy = 65.0 + rd.size = 2.0 + rd.color = (0.62, 0.76, 1.0) + rob = bpy.data.objects.new("MouthRim", rd) + rob.location = (-1.2, -0.9, 2.2) + rob.rotation_euler = (math.radians(70), 0.0, math.radians(-70)) + sc.collection.objects.link(rob) + return parts + + +def render_still(path, engine, falsify=False): + """A four-segment run. Snapped: joints vanish. Falsified: each segment + accumulates a 120 mm x gap, alternating 50 mm y jogs, and 40 mm floor + steps, so every joint reads as a seam — the render-scale exaggeration of + the failure the check measures at 3 mm (probe_unsnapped).""" + bpy.ops.wm.read_factory_settings(use_empty=True) + sc = bpy.context.scene + + all_parts = [] + for i in range(4): + if falsify: + obs = build_kit_objects( + sc, TILE * i + i * 0.12, + y_jog=(0.05 if i % 2 else 0.0), + z_jog=(0.04 if i % 2 else 0.0)) + else: + obs = build_kit_objects(sc, TILE * i) + all_parts.extend(obs[1:]) # mesh parts only, not the root empties + floor, wall = build_studio(sc) + bulkhead = build_bulkhead(sc) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 25.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (-0.7, -0.42, 1.58) + sc.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (10.0, 0.35, 1.25) + sc.collection.objects.link(aim) + tr = cam.constraints.new("TRACK_TO") + tr.target = aim + tr.track_axis = "TRACK_NEGATIVE_Z" + tr.up_axis = "UP_Y" + sc.camera = cam + + sc.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + sc.cycles.device = "CPU" + sc.cycles.samples = 64 + sc.cycles.use_denoising = True + else: + try: + sc.eevee.taa_render_samples = 64 + except AttributeError: + pass + sc.render.resolution_x = 1280 + sc.render.resolution_y = 720 + sc.render.image_settings.file_format = "PNG" + sc.render.filepath = path + # Standard, always — AgX would lift the stage toward grey (VISUAL-STYLE) + sc.view_settings.view_transform = "Standard" + # Layer 1 framing gate. The corridor surrounds the camera on five sides + # and reads as extending past the frame — a documented deviation class + # (radiating architecture), measured and reported by the helper. + fcode = gallery_framing.check_framing( + sc, cam, + hero=all_parts, + elements=all_parts + bulkhead, + stage=[floor, wall], + deviation="interior corridor run: the envelope surrounds the camera " + "and the tiling joints are the proof; edge bleed is the design", + ) + if fcode: + return fcode + bpy.ops.render.render(write_still=True) + if not (os.path.exists(path) and os.path.getsize(path) > 0): + print("ERROR: render produced no file", file=sys.stderr) + return 9 + 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, help="optional: render a still PNG here") + p.add_argument("--falsify", default=None, + help="optional: render the unsnapped-joint seam variant here") + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"), + help="render engine for --output/--falsify (cycles for GPU-less hosts)") + args = p.parse_args(argv) + + print(f"binary version: {bpy.app.version} ({bpy.app.version_string})") + bpy.ops.wm.read_factory_settings(use_empty=True) + code = check() + if code: + return code + + if args.output: + rcode = render_still(os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + if args.falsify: + rcode = render_still(os.path.abspath(args.falsify), args.engine, falsify=True) + if rcode: + return rcode + print(f"rendered falsified variant {args.falsify}") + + print("modular-kit-snap OK") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + import traceback + + traceback.print_exc() + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(1) diff --git a/examples/modular-kit-snap/preview.webp b/examples/modular-kit-snap/preview.webp new file mode 100644 index 0000000..669e504 Binary files /dev/null and b/examples/modular-kit-snap/preview.webp differ