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 twenty detail parts — frame rib, walkway plate, wall panel assemblies (backing plate + inset panel + four bolt heads each), 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. The render path also gates the asset through examples/gallery_asset_quality.py (naming, material variation, edge treatment — exit 11). An earlier revision shipped flat grey wall panels and was remodeled under that gate: each panel is now an assembly (backing plate + inset panel + four bolt heads), the palette shifted teal-slate so the card does not twin with lightmap-uv-channel's warm cart, and mean luminance was brought into the calibration range (82.7 → 66.1, ceiling 77.7) — no luminance deviation needed.

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
import gallery_asset_quality

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
EXPECT_PARTS = 21            # shell + 20 detail parts; asserted before the
                             # per-part loops below, which assert nothing on
                             # an empty dict

# 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),
        # 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),
    ]
    # wall panel assemblies: backing plate proud of the bore face, a lighter
    # inset panel — depth, not flat grey rectangles. Bolt heads are spheres,
    # built by _bolts_mesh in build_kit_meshes rather than as boxes.
    for side, sy in (("L", 1.0), ("R", -1.0)):
        for idx, px in ((1, 1.2), (2, 2.8)):
            parts.append((f"PanelBack.{side}.{idx}", (1.35, 0.04, 1.35),
                          (px, sy * (inner_y - 0.02), 1.55), 0.015))
            parts.append((f"Panel.{side}.{idx}", (1.15, 0.05, 1.15),
                          (px, sy * (inner_y - 0.045), 1.55), 0.02))
    return parts


def _bolts_mesh(name, positions, radius):
    """Bolt head spheres at positions — one mesh, disconnected islands."""
    me = bpy.data.meshes.new(name)
    bm = bmesh.new()
    try:
        for px, py, pz in positions:
            before_v = set(bm.verts)
            bmesh.ops.create_uvsphere(bm, u_segments=8, v_segments=6, radius=radius)
            for v in set(bm.verts) - before_v:
                v.co.x += px
                v.co.y += py
                v.co.z += pz
        bm.to_mesh(me)
    finally:
        bm.free()
    return me


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)
    # panel fixings: four bolt heads per panel, proud of the inset panel
    inner_y = WIDTH / 2 - WALL
    for side, sy in (("L", 1.0), ("R", -1.0)):
        for idx, px in ((1, 1.2), (2, 2.8)):
            positions = [(px + dx, sy * (inner_y - 0.07), 1.55 + dz)
                         for dx in (-0.5, 0.5) for dz in (-0.5, 0.5)]
            meshes[f"PanelBolts.{side}.{idx}"] = _bolts_mesh(
                f"Kit.CorridorSeg.PanelBolts.{side}.{idx}", positions, 0.022)
    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)

    if len(meshes) != EXPECT_PARTS:
        fail(11, f"kit built {len(meshes)} parts, expected {EXPECT_PARTS} - the "
                 f"per-part loops below would iterate over the wrong set")
        return fails[0]

    # --- 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.075, 0.095, 0.115), 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.06, 0.075, 0.09), 0.6, 0.7, None, 0.0),
    "Panel": ("Panel", (0.10, 0.21, 0.26), 0.42, 0.6, None, 0.0),
    "PanelBack": ("PanelBack", (0.06, 0.10, 0.13), 0.5, 0.75, None, 0.0),
    "PanelBolts": ("PanelBolts", (0.35, 0.38, 0.42), 0.3, 0.9, 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),
}


_mat_cache = {}


def mat_for(suffix):
    """One material per design key, shared across segments (an earlier
    revision instantiated a duplicate material per part per segment)."""
    key = suffix.split(".")[0]
    if key not in _mat_cache:
        name, rgb, rough, metal, emit, estr = SEGMENT_MATS[key]
        _mat_cache[key] = make_material(name, rgb, rough, metal, emit, estr)
    return _mat_cache[key]


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 = 85.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.5)
    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 = 90.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 = 45.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 = []
    aq_hero = None
    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)
        if aq_hero is None:
            aq_hero = obs[1:]         # the asset-quality gate scores one segment
        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
    aqcode = gallery_asset_quality.check_asset_quality(
        sc, cam, hero=aq_hero, stage=[floor, wall])
    if aqcode:
        return aqcode
    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)