Examples Gallery
GitHub

lightmap-uv-channel

A market cart carrying the two-channel UV contract for baked lighting: UV0 untouched, UVLight packed with no overlaps and a respected margin.

Rendered headless by the example itself — click to zoom.

witnesses Per-part UVMap/UVLight with active vs active_render pinned (ops clear both flags), UV0 drift 0.0, 3680 islands with 0 SAT overlaps, min island distance 0.00401.
blender --background --python examples/lightmap-uv-channel/lightmap_uv_channel.py --

A runnable example building the second UV layer engines require for baked lighting, on an asset built to be reused: a market cart assembled from 21 named, watertight parts — a plank-grooved bed with side rails, corner brackets, and fasteners, real wheel assemblies (disc + hub + iron band + bolt ring) on a capped axle, four posts, and a ribbed canvas canopy. Ground-level pivot at z=0, identity transforms by construction, Cart.* datablocks. (An earlier revision shipped a soft uniform-tan cart with lumpy polygon wheels and was remodeled under the asset-quality gate; its wheel material was also silently never applied — a name-key lookup bug the exact per-part mapping now replaces.) UV0 is the texture channel (deterministic dominant-axis box projection per face); UVLight is the baked-lighting atlas (smart_projectlightmap_pack).

What it witnesses (all independently derived, nothing trusted from the packer):

What each check catches on failure: wrong layer names/count (exit 3); flags not re-established after the ops (exit 4); channel 0 touched by the UV1 unwrap (exit 5 — probed via the clobber trap, drift 2.068); UV1 loops outside the unit square (exit 6); overlapping islands (exit 7 — probed by translating an island onto a neighbor, 15 SAT hits); margin floor broken (exit 8 — probed with MARGIN_DIV=0, measured 2e-5 < 0.001); non-watertight part (exit 9).

Authoring hazards pinned while building this (probes in .scratch-style bisect scripts; the reasons the check code re-fetches layers by name and re-asserts flags):

Pipeline arc neighbors: UV-layer authoring in uv-layer-grid, tangent-space from UVs in triangulate-tangents, export round-trip in gltf-export-roundtrip, pivot discipline in prop-origin-transform.

Version witness: check output is byte-identical on Blender 4.5.11 LTS and 5.1.2 (21 parts, 3680 islands, drift 0, overlap 0, min island distance 0.00401). The UV *layout* is packer-version-dependent by design; the contracts are layout-independent invariants.

Render as proof: the cart beside its enlarged UV1 atlas board — the Bed's packed lightmap built from live UV data, so a change in the atlas moves the board geometry. The falsification variant (--falsify) translates the second-largest island onto the largest: a big emissive-red island visibly stacked over the atlas (15 SAT hits in the check probe). The render path also gates the asset itself through examples/gallery_asset_quality.py (naming, material variation, edge treatment — exit 11): 21 named parts, 5 materials, right-angle share 0.069.

Run

blender --background --python lightmap_uv_channel.py --
blender --background --python lightmap_uv_channel.py -- --output atlas.png
blender --background --python lightmap_uv_channel.py -- --falsify overlap.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 (exit 10 on violation) before writing the still.

Source

examples/lightmap-uv-channel/lightmap_uv_channel.py View on GitHub →
"""Lightmap UV channel — the second UV layer engines need for baked lighting.

Witnesses the two-channel UV contract a bake pipeline depends on, on an
asset built to be reused (a market cart: bed, axle, two wheels, four posts,
arched canvas canopy — ground-level pivot, identity transforms, named
datablocks, watertight parts):

1. **The flag trap.** `uv_layers.new()` does NOT move the active flags:
   the first layer keeps `active` + `active_render`, so a UV op runs against
   channel 0 unless you move `active` yourself. Worse, the edit-mode UV ops
   CLEAR both flags — they must be re-asserted after the unwrap/pack
   sequence. The check pins: per part, exactly two layers named UVMap +
   UVLight, with `active == UVLight` (edit target) and
   `active_render == UVMap` (render target) at the end.
2. **Channel zero is untouched.** UV0 loops are snapshotted before the
   UVLight unwrap and compared after: max abs diff == 0 (tol 1e-6).
3. **Bounds.** Every UVLight loop sits inside [0, 1] within 1e-5.
4. **Non-overlap, independently computed.** UV1 triangles are binned into a
   spatial hash and tested pairwise with exact strict SAT (epsilon-separated,
   so shared edges/touching don't count) — never trusted from the packer.
5. **Margin.** lightmap_pack MARGIN_DIV semantics measured live: nominal
   margin == MARGIN_DIV * 0.01 UV units; the check requires the measured
   min island-pair distance >= half the nominal margin, printed.
6. **Island census.** Connected components of welded UV verts, counted
   independently, printed per part; loops conserved (no UV data lost).

Authoring hazards pinned while building this (see code comments):
- Holding a `MeshUVLoopLayer` reference across edit-mode UV ops and then
  iterating its `.data` SEGFAULTS Blender 4.5.11 headless
  (EXCEPTION_ACCESS_VIOLATION, repro 5/5); the same read survives on 5.1.2.
  Always re-fetch `mesh.uv_layers[name]` after CustomData-reallocating ops.
- `bpy.ops.uv.lightmap_pack` silently packs NOTHING (exit OK) when the
  active layer has no UVs yet — unwrap first, then pack.

By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:

    blender --background --python lightmap_uv_channel.py --                 # check only
    blender --background --python lightmap_uv_channel.py -- --output u.png  # + render
    blender --background --python lightmap_uv_channel.py -- --falsify f.png # overlapping atlas
"""
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

TOL = 1e-6            # UV0 preservation tolerance
BOUNDS_TOL = 1e-5     # UV1 unit-square slack
MARGIN_DIV = 0.2      # packer request; nominal margin = MARGIN_DIV * 0.01
MARGIN_FACTOR = 0.5   # require measured min island distance >= this * nominal
SAT_EPS = 1e-9        # strict-separation epsilon: touching is not overlap
UV_WELD = 1e-6        # uv vert weld for island connectivity
BIN_N = 256           # spatial hash resolution over [0,1]^2

LAYER0 = "UVMap"
LAYER1 = "UVLight"


def eevee_engine_id():
    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"


# ---------------------------------------------------------------------------
# Asset construction (bmesh, ground pivot at z=0; cart runs along X)
# ---------------------------------------------------------------------------

def _beveled_box(bm, dims, center, bevel=0.0, segments=2):
    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 _box(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


def _wheel_disc(name, radius, width, center):
    me = bpy.data.meshes.new(name)
    bm = bmesh.new()
    try:
        bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=24,
                              radius1=radius, radius2=radius, depth=width)
        # cone axis is Z; the wheel rolls around Y -> rotate verts, applied in data
        for v in bm.verts:
            x, y, z = v.co.x, v.co.y, v.co.z
            v.co = (x + center[0], z + center[1], y + center[2])
        bmesh.ops.bevel(bm, geom=list(bm.edges), offset=0.02, segments=2,
                        profile=0.5, affect="EDGES", clamp_overlap=True)
        bm.to_mesh(me)
    finally:
        bm.free()
    return me


def _ring(name, r_out, r_in, width, center, segments=24):
    """Flat annulus (iron tire band) spun from a 4-vert profile, watertight."""
    me = bpy.data.meshes.new(name)
    bm = bmesh.new()
    try:
        hw = width / 2
        prof = [(r_in, -hw), (r_out, -hw), (r_out, hw), (r_in, hw)]
        vs = [bm.verts.new((r, 0.0, z)) for r, z in prof]
        es = [bm.edges.new((vs[i], vs[(i + 1) % 4])) for i in range(4)]
        bmesh.ops.spin(bm, geom=vs + es, cent=(0.0, 0.0, 0.0),
                       axis=(0.0, 0.0, 1.0), dvec=(0.0, 0.0, 0.0),
                       angle=2 * math.pi, steps=segments, use_merge=True)
        for v in bm.verts:
            x, y, z = v.co.x, v.co.y, v.co.z
            v.co = (x + center[0], z + center[1], y + center[2])
        bm.to_mesh(me)
    finally:
        bm.free()
    return me


def _bolt_ring(name, count, ring_r, bolt_r, center):
    """count bolt heads on a ring around Y — one mesh, disconnected islands."""
    me = bpy.data.meshes.new(name)
    bm = bmesh.new()
    try:
        for i in range(count):
            a = 2 * math.pi * i / count
            cx, cz = center[0] + ring_r * math.cos(a), center[2] + ring_r * math.sin(a)
            before_v = set(bm.verts)
            bmesh.ops.create_uvsphere(bm, u_segments=8, v_segments=6, radius=bolt_r)
            for v in set(bm.verts) - before_v:
                v.co.x += cx
                v.co.y += center[1]
                v.co.z += cz
        bm.to_mesh(me)
    finally:
        bm.free()
    return me


def _arc_band(bm, xc, width, radius, thickness, z0, segments):
    """One arched sheet spanning x in [xc, xc+width], solidified with rims."""
    sweep = math.radians(120.0)
    a0 = math.radians(90.0) - sweep / 2
    outer, inner = [], []
    for i in range(segments + 1):
        a = a0 + sweep * i / segments
        ca, sa = math.cos(a), math.sin(a)
        outer.append(bm.verts.new((xc, radius * ca, z0 + radius * sa)))
        inner.append(bm.verts.new((xc, (radius - thickness) * ca,
                                   z0 + (radius - thickness) * sa)))
    def band(ring_o, ring_i):
        for i in range(segments):
            a, b = i, i + 1
            bm.faces.new((ring_o[a], ring_o[b], ring_i[b], ring_i[a]))
    outer_x2 = [bm.verts.new((v.co.x + width, v.co.y, v.co.z)) for v in outer]
    inner_x2 = [bm.verts.new((v.co.x + width, v.co.y, v.co.z)) for v in inner]
    band(outer, inner)               # underside (thickness face)
    band(inner_x2, outer_x2)         # top face
    for i in range(segments):        # the two long rims
        a, b = i, i + 1
        bm.faces.new((outer[a], outer_x2[a], outer_x2[b], outer[b]))
        bm.faces.new((inner[a], inner[b], inner_x2[b], inner_x2[a]))
    bm.faces.new((outer[0], inner[0], inner_x2[0], outer_x2[0]))
    bm.faces.new((outer[-1], outer_x2[-1], inner_x2[-1], inner[-1]))


def _canopy(name, width, radius, thickness, z0, segments=14):
    """Arched canvas sheet: an arc band solidified with a rim, watertight."""
    me = bpy.data.meshes.new(name)
    bm = bmesh.new()
    try:
        _arc_band(bm, -width / 2, width, radius, thickness, z0, segments)
        bm.to_mesh(me)
    finally:
        bm.free()
    return me


def _canopy_ribs(name, positions, width, radius, thickness, z0, segments=14):
    """Thin wooden ribs following the canopy arc, one mesh, N islands."""
    me = bpy.data.meshes.new(name)
    bm = bmesh.new()
    try:
        for px in positions:
            _arc_band(bm, px - width / 2, width, radius, thickness, z0, segments)
        bm.to_mesh(me)
    finally:
        bm.free()
    return me


def _bolts(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 _multi_box(name, specs):
    """Several beveled boxes as one mesh (disconnected islands, each
    watertight). specs: (dims, center, bevel)."""
    me = bpy.data.meshes.new(name)
    bm = bmesh.new()
    try:
        for dims, center, bevel in specs:
            before_e = set(bm.edges)
            before_v = set(bm.verts)
            bmesh.ops.create_cube(bm, size=1.0)
            new_edges = list(set(bm.edges) - before_e)
            for v in set(bm.verts) - before_v:
                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=new_edges, offset=bevel, segments=2,
                                profile=0.5, affect="EDGES", clamp_overlap=True)
        bm.to_mesh(me)
    finally:
        bm.free()
    return me


def build_cart_meshes():
    """The cart as an assembly of named, watertight parts: real wheel
    assemblies (disc + hub + iron band + bolt ring), plank-grooved bed with
    side rails, corner brackets, and fasteners, and a ribbed canvas canopy."""
    parts = {}
    parts["Bed"] = _box("Cart.Bed", (2.2, 1.2, 0.22), (0.0, 0.0, 0.85), 0.03)
    parts["Bed.Rails"] = _multi_box("Cart.Bed.Rails", [
        ((2.2, 0.06, 0.18), (0.0, 0.57, 1.02), 0.015),
        ((2.2, 0.06, 0.18), (0.0, -0.57, 1.02), 0.015)])
    parts["Bed.Grooves"] = _multi_box("Cart.Bed.Grooves", [
        ((0.035, 1.14, 0.015), (x, 0.0, 0.9575), 0.0)
        for x in (-0.66, -0.22, 0.22, 0.66)])
    parts["Bed.Brackets"] = _multi_box("Cart.Bed.Brackets", [
        ((0.03, 0.13, 0.26), (sx * 1.105, sy * 0.535, 0.85), 0.008)
        for sx in (-1, 1) for sy in (-1, 1)])
    parts["Bed.Bolts"] = _bolts("Cart.Bed.Bolts", [
        (sx * 1.125, sy * 0.535, z) for sx in (-1, 1) for sy in (-1, 1)
        for z in (0.78, 0.92)], 0.018)
    parts["Axle"] = _wheel_disc("Cart.Axle", 0.05, 1.56, (0.55, 0.0, 0.5))
    parts["Axle.Caps"] = _multi_box("Cart.Axle.Caps", [
        ((0.14, 0.05, 0.14), (0.55, sy * 0.80, 0.5), 0.02) for sy in (-1, 1)])
    for tag, sy in (("L", 1.0), ("R", -1.0)):
        y = sy * 0.68
        parts[f"Wheel.{tag}.Disc"] = _wheel_disc(
            f"Cart.Wheel.{tag}.Disc", 0.45, 0.10, (0.55, y, 0.45))
        parts[f"Wheel.{tag}.Hub"] = _wheel_disc(
            f"Cart.Wheel.{tag}.Hub", 0.14, 0.16, (0.55, y, 0.45))
        parts[f"Wheel.{tag}.Band"] = _ring(
            f"Cart.Wheel.{tag}.Band", 0.47, 0.435, 0.10, (0.55, y, 0.45))
        parts[f"Wheel.{tag}.Bolts"] = _bolt_ring(
            f"Cart.Wheel.{tag}.Bolts", 6, 0.09, 0.02, (0.55, y + sy * 0.085, 0.45))
    for tag, (px, py) in (("FL", (0.95, 0.48)), ("FR", (0.95, -0.48)),
                          ("RL", (-0.95, 0.48)), ("RR", (-0.95, -0.48))):
        parts[f"Post.{tag}"] = _box(f"Cart.Post.{tag}", (0.09, 0.09, 1.35),
                                    (px, py, 1.55), 0.015)
    parts["Canopy"] = _canopy("Cart.Canopy", 2.3, 0.85, 0.04, 1.9)
    parts["Canopy.Ribs"] = _canopy_ribs("Cart.Canopy.Ribs", (-0.75, 0.0, 0.75),
                                        0.07, 0.875, 0.035, 1.9)
    return parts


# ---------------------------------------------------------------------------
# UV authoring
# ---------------------------------------------------------------------------

def write_uv0_box_projection(me):
    """Channel 0: deterministic dominant-axis box projection per face.
    Deliberately un-normalized (world-scale UVs) — this is the texture
    channel; only UV1 is asserted inside [0,1]."""
    layer = me.uv_layers[LAYER0]
    for poly in me.polygons:
        n = poly.normal
        ax = max(range(3), key=lambda i: abs(n[i]))
        for li in poly.loop_indices:
            co = me.vertices[me.loops[li].vertex_index].co
            layer.data[li].uv = ((co.y, co.z) if ax == 0
                                 else (co.x, co.z) if ax == 1 else (co.x, co.y))


def snapshot(layer):
    return [tuple(d.uv) for d in layer.data]


def author_uv1(ob, clobber=False):
    """Channel 1: smart_project then lightmap_pack into the ACTIVE layer.

    clobber=False: the correct pipeline — active moved to UVLight first, so
    channel 0 survives. clobber=True: the classic trap — active left on
    UVMap, so the unwrap+pack lands on channel 0 and destroys it.
    """
    me = ob.data
    if not clobber:
        for layer in me.uv_layers:
            layer.active = (layer.name == LAYER1)
            layer.active_render = (layer.name == LAYER0)
    bpy.context.view_layer.objects.active = ob
    ob.select_set(True)
    bpy.ops.object.mode_set(mode="EDIT")
    bpy.ops.mesh.select_all(action="SELECT")
    bpy.ops.uv.smart_project(angle_limit=1.1519, island_margin=0.0,
                             area_weight=False, correct_aspect=True,
                             scale_to_bounds=False)
    bpy.ops.uv.lightmap_pack(PREF_CONTEXT="ALL_FACES", PREF_PACK_IN_ONE=True,
                             PREF_NEW_UVLAYER=False, PREF_MARGIN_DIV=MARGIN_DIV)
    bpy.ops.object.mode_set(mode="OBJECT")
    ob.select_set(False)
    # HAZARD (pinned while authoring): the ops above CLEAR layer.active and
    # layer.active_render — re-assert them or downstream bakes/renders pick
    # whatever Blender falls back to. And never read a held layer handle
    # here: iterating a stale MeshUVLoopLayer.data segfaults 4.5.11.
    if not clobber:
        for layer in me.uv_layers:
            layer.active = (layer.name == LAYER1)
            layer.active_render = (layer.name == LAYER0)


# ---------------------------------------------------------------------------
# Independent UV1 geometry analysis (never trusted from the packer)
# ---------------------------------------------------------------------------

def uv_tris(me, layer_name):
    """UV1 triangles per face (fan), plus a weld map for island connectivity."""
    layer = me.uv_layers[layer_name]
    tris = []
    for poly in me.polygons:
        pts = [tuple(layer.data[li].uv) for li in poly.loop_indices]
        for i in range(1, len(pts) - 1):
            tris.append((pts[0], pts[i], pts[i + 1]))
    return tris


def islands(me, layer_name):
    """Connected components of UV verts welded at UV_WELD precision."""
    layer = me.uv_layers[layer_name]
    parent = {}

    def find(k):
        parent.setdefault(k, k)
        while parent[k] != k:
            parent[k] = parent[parent[k]]
            k = parent[k]
        return k

    def key(uv):
        return (round(uv[0] / UV_WELD), round(uv[1] / UV_WELD))

    for poly in me.polygons:
        ks = [find(key(tuple(layer.data[li].uv))) for li in poly.loop_indices]
        for k in ks[1:]:
            parent[find(k)] = ks[0]
    comps = {}
    for poly in me.polygons:
        for li in poly.loop_indices:
            comps.setdefault(find(key(tuple(layer.data[li].uv))), len(comps))
    return len(comps)


def _tri_overlap(t1, t2):
    """Strict 2D SAT: True only for area-positive overlap (SAT_EPS-separated,
    so shared edges and touching corners do not count)."""
    for tri in (t1, t2):
        for i in range(3):
            x1, y1 = tri[i]
            x2, y2 = tri[(i + 1) % 3]
            nx, ny = -(y2 - y1), (x2 - x1)
            nlen = math.hypot(nx, ny)
            if nlen < 1e-12:
                continue
            nx, ny = nx / nlen, ny / nlen
            p1 = [nx * p[0] + ny * p[1] for p in t1]
            p2 = [nx * p[0] + ny * p[1] for p in t2]
            if min(max(p1), max(p2)) - max(min(p1), min(p2)) < SAT_EPS:
                return False
    return True


def overlap_report(tris):
    """(overlapping pair count, worst measured overlap depth) via binned SAT."""
    bins = {}
    def bounds(t):
        return (min(p[0] for p in t), min(p[1] for p in t),
                max(p[0] for p in t), max(p[1] for p in t))
    for i, t in enumerate(tris):
        x0, y0, x1, y1 = bounds(t)
        for bx in range(max(0, int(x0 * BIN_N)), min(BIN_N - 1, int(x1 * BIN_N)) + 1):
            for by in range(max(0, int(y0 * BIN_N)), min(BIN_N - 1, int(y1 * BIN_N)) + 1):
                bins.setdefault((bx, by), []).append(i)
    pairs = set()
    hits = 0
    worst = 0.0
    for members in bins.values():
        for a in range(len(members)):
            for b in range(a + 1, len(members)):
                i, j = members[a], members[b]
                if (i, j) in pairs:
                    continue
                pairs.add((i, j))
                if _tri_overlap(tris[i], tris[j]):
                    hits += 1
    return hits


def min_island_distance(me, layer_name):
    """Min distance between distinct islands' UV edges (exact seg-seg)."""
    layer = me.uv_layers[layer_name]
    parent = {}

    def find(k):
        parent.setdefault(k, k)
        while parent[k] != k:
            parent[k] = parent[parent[k]]
            k = parent[k]
        return k

    def key(uv):
        return (round(uv[0] / UV_WELD), round(uv[1] / UV_WELD))

    for poly in me.polygons:
        ks = [find(key(tuple(layer.data[li].uv))) for li in poly.loop_indices]
        for k in ks[1:]:
            parent[find(k)] = ks[0]
    edges = {}  # island root -> list of segments
    for poly in me.polygons:
        pts = [tuple(layer.data[li].uv) for li in poly.loop_indices]
        root = find(key(pts[0]))
        for i in range(len(pts)):
            edges.setdefault(root, []).append((pts[i], pts[(i + 1) % len(pts)]))

    def seg_dist(s1, s2):
        def pt_seg(p, s):
            (x, y), ((ax, ay), (bx, by)) = p, s
            dx, dy = bx - ax, by - ay
            L2 = dx * dx + dy * dy
            if L2 < 1e-18:
                return math.hypot(x - ax, y - ay)
            t = max(0.0, min(1.0, ((x - ax) * dx + (y - ay) * dy) / L2))
            return math.hypot(x - (ax + t * dx), y - (ay + t * dy))
        if _seg_intersect(s1, s2):
            return 0.0
        return min(pt_seg(s1[0], s2), pt_seg(s1[1], s2),
                   pt_seg(s2[0], s1), pt_seg(s2[1], s1))

    def _seg_intersect(s1, s2):
        (ax, ay), (bx, by) = s1
        (cx, cy), (dx, dy) = s2
        def cross(ox, oy, px, py, qx, qy):
            return (px - ox) * (qy - oy) - (py - oy) * (qx - ox)
        d1 = cross(cx, cy, dx, dy, ax, ay)
        d2 = cross(cx, cy, dx, dy, bx, by)
        d3 = cross(ax, ay, bx, by, cx, cy)
        d4 = cross(ax, ay, bx, by, dx, dy)
        return ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (d4 > 0))

    roots = sorted(edges)
    best = float("inf")
    for i in range(len(roots)):
        for j in range(i + 1, len(roots)):
            for s1 in edges[roots[i]]:
                for s2 in edges[roots[j]]:
                    d = seg_dist(s1, s2)
                    if d < best:
                        best = d
    return best


# ---------------------------------------------------------------------------
# Check
# ---------------------------------------------------------------------------

def check():
    meshes = build_cart_meshes()
    fails = []

    def fail(code, msg):
        print(f"ERROR ({code}): {msg}", file=sys.stderr)
        fails.append(code)

    nominal_margin = MARGIN_DIV * 0.01
    total_islands = 0
    for suffix, me in meshes.items():
        l0 = me.uv_layers.new(name=LAYER0)
        me.uv_layers.new(name=LAYER1)
        write_uv0_box_projection(me)
        uv0_before = snapshot(me.uv_layers[LAYER0])

        ob = bpy.data.objects.new(me.name, me)
        bpy.context.collection.objects.link(ob)
        author_uv1(ob)

        # re-fetch by name after CustomData-reallocating ops (see header)
        layers = me.uv_layers
        names = [l.name for l in layers]
        if names != [LAYER0, LAYER1]:
            fail(3, f"{suffix}: layers {names} != {[LAYER0, LAYER1]}")
        act = layers[LAYER1].active
        rnd = layers[LAYER0].active_render
        if not act or not rnd:
            fail(4, f"{suffix}: flags wrong after re-assert — "
                    f"{LAYER1}.active={act}, {LAYER0}.active_render={rnd} "
                    f"(edit-mode UV ops clear both; re-assert after the sequence)")

        uv0_after = snapshot(layers[LAYER0])
        drift = max((max(abs(a - b) for a, b in zip(u0, u1))
                     for u0, u1 in zip(uv0_before, uv0_after)), default=0.0)
        if len(uv0_before) != len(uv0_after) or drift > TOL:
            fail(5, f"{suffix}: channel 0 touched by the UV1 unwrap "
                    f"(loops {len(uv0_before)}->{len(uv0_after)}, drift {drift:.3e})")

        tris = uv_tris(me, LAYER1)
        out = [uv for t in tris for uv in t
               if not (-BOUNDS_TOL <= uv[0] <= 1 + BOUNDS_TOL
                       and -BOUNDS_TOL <= uv[1] <= 1 + BOUNDS_TOL)]
        if out:
            fail(6, f"{suffix}: {len(out)} UV1 loops outside [0,1], "
                    f"worst {out[0]}")

        hits = overlap_report(tris)
        if hits:
            fail(7, f"{suffix}: {hits} overlapping UV1 triangle pairs "
                    f"(independent SAT scan, not the packer's word)")

        n_islands = islands(me, LAYER1)
        total_islands += n_islands
        dist = min_island_distance(me, LAYER1)
        need = MARGIN_FACTOR * nominal_margin
        if dist < need:
            fail(8, f"{suffix}: min island distance {dist:.5f} < {need:.5f} "
                    f"(half of nominal margin {nominal_margin})")
        print(f"part {suffix}: loops={len(me.loops)} tris={len(tris)} "
              f"islands={n_islands} uv0_drift={drift:.1e} "
              f"overlap={hits} min_island_dist={dist:.5f} "
              f"flags ok={act and rnd}")

        # every part watertight
        bm = bmesh.new()
        try:
            bm.from_mesh(me)
            n_boundary = sum(1 for e in bm.edges if len(e.link_faces) != 2)
        finally:
            bm.free()
        if n_boundary:
            fail(9, f"{suffix}: {n_boundary} non-two-face edges — not watertight")

    if fails:
        return fails[0]
    print(f"lightmap-uv-channel OK parts={len(meshes)} islands={total_islands} "
          f"margin>={MARGIN_FACTOR}*{nominal_margin} uv0_drift=0 overlap=0")
    return 0


# ---------------------------------------------------------------------------
# Render
# ---------------------------------------------------------------------------

def make_material(name, rgb, rough=0.6, metallic=0.0, 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


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)

    light("Key", (-3.5, -4.5, 5.5), 430.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), 300.0, 3.0, (0.6, 0.78, 1.0), (-55, 0, 170))
    light("Wedge", (2.5, 5.5, 4.0), 420.0, 6.0, (1.0, 0.72, 0.42), (-68, 0, 190))
    return floor, wall


def render_still(path, engine, falsify=False):
    """The cart beside its UV1 atlas board — island polygons built from the
    LIVE packed UVs, so a change in the atlas moves the board. Falsified:
    one island dragged onto another, overlap marked in emissive red."""
    bpy.ops.wm.read_factory_settings(use_empty=True)
    sc = bpy.context.scene

    wood = make_material("Wood", (0.38, 0.22, 0.10), rough=0.55)
    darkwood = make_material("DarkWood", (0.16, 0.10, 0.05), rough=0.7)
    iron = make_material("Iron", (0.26, 0.26, 0.30), rough=0.3, metallic=0.9)
    canvas = make_material("Canvas", (0.66, 0.56, 0.40), rough=0.9)
    groovemat = make_material("Groove", (0.05, 0.04, 0.03), rough=0.9)

    mat_by_part = {
        "Bed": wood, "Bed.Rails": wood, "Bed.Grooves": groovemat,
        "Bed.Brackets": iron, "Bed.Bolts": iron,
        "Axle": iron, "Axle.Caps": iron,
        "Canopy": canvas, "Canopy.Ribs": darkwood,
    }
    for tag in ("L", "R"):
        mat_by_part[f"Wheel.{tag}.Disc"] = darkwood
        mat_by_part[f"Wheel.{tag}.Hub"] = darkwood
        mat_by_part[f"Wheel.{tag}.Band"] = iron
        mat_by_part[f"Wheel.{tag}.Bolts"] = iron
    for tag in ("FL", "FR", "RL", "RR"):
        mat_by_part[f"Post.{tag}"] = darkwood

    parts_obs = []
    meshes = build_cart_meshes()
    for suffix, me in meshes.items():
        me.materials.append(mat_by_part[suffix])
        me.uv_layers.new(name=LAYER0)
        me.uv_layers.new(name=LAYER1)
        write_uv0_box_projection(me)
        ob = bpy.data.objects.new(me.name, me)
        sc.collection.objects.link(ob)
        author_uv1(ob, clobber=False)
        parts_obs.append(ob)

    # falsify: translate the Bed's SECOND-LARGEST UV1 island, shape intact,
    # onto the largest island's anchor — a big visible overlap, marked
    # emissive red on the board (tiny-strip overlaps would not read)
    dragged = set()
    if falsify:
        bed = meshes["Bed"]
        layer = bed.uv_layers[LAYER1]
        polys = sorted(bed.polygons, key=lambda p: p.area, reverse=True)
        big, second = polys[0], polys[1]
        anchor = tuple(layer.data[big.loop_indices[0]].uv)
        dragged.add(second.index)
        first = second.loop_indices[0]
        du = (anchor[0] - layer.data[first].uv[0],
              anchor[1] - layer.data[first].uv[1])
        for li in second.loop_indices:
            u, v = layer.data[li].uv
            layer.data[li].uv = (u + du[0], v + du[1])

    # atlas board: the Bed's live UV1 as flat island polygons mapped onto the
    # board face — a change in the packed atlas moves the board geometry
    board_mat = make_material("Board", (0.04, 0.04, 0.05), rough=0.5, metallic=0.4)
    board = _box("Atlas.Board", (0.08, 2.2, 2.0), (0.0, 0.0, 0.0), 0.02)
    board.materials.append(board_mat)
    board_ob = bpy.data.objects.new("Atlas.Board", board)
    board_ob.location = (2.05, 0.55, 1.15)
    sc.collection.objects.link(board_ob)

    bed = meshes["Bed"]
    layer = bed.uv_layers[LAYER1]
    palette = [(0.95, 0.45, 0.15), (0.2, 0.75, 0.85), (0.55, 0.85, 0.3),
               (0.85, 0.3, 0.5), (0.95, 0.8, 0.25), (0.5, 0.55, 0.95),
               (0.7, 0.4, 0.9), (0.4, 0.85, 0.6)]
    iso_obs = []
    for fi, poly in enumerate(bed.polygons):
        pts = [tuple(layer.data[li].uv) for li in poly.loop_indices]
        me = bpy.data.meshes.new(f"Atlas.Island.{fi}")
        bm = bmesh.new()
        try:
            # board face: +X side; UV [0,1]^2 -> y,z on the face (u -> -y, v -> z)
            vs = [bm.verts.new((0.0, (0.5 - uv[0]) * 1.9, (uv[1] - 0.5) * 1.9))
                  for uv in pts]
            bm.faces.new(vs)
            bm.to_mesh(me)
        finally:
            bm.free()
        defect = fi in dragged
        rgb = (1.0, 0.12, 0.08) if defect else palette[fi % len(palette)]
        me.materials.append(make_material(f"IslandMat{fi}", rgb, rough=0.5,
                                          emit=rgb, estr=1.6 if defect else 0.5))
        ob = bpy.data.objects.new(f"Atlas.Island.{fi}", me)
        ob.location = (2.05 + 0.045 + (0.004 if fi in dragged else 0.0), 0.55, 1.15)
        sc.collection.objects.link(ob)
        iso_obs.append(ob)

    floor, wall = build_studio(sc)

    cam_data = bpy.data.cameras.new("Cam")
    cam_data.lens = 47.0
    cam = bpy.data.objects.new("Cam", cam_data)
    cam.location = (5.9, -7.2, 2.8)
    sc.collection.objects.link(cam)
    aim = bpy.data.objects.new("Aim", None)
    aim.location = (0.85, 0.1, 1.05)
    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"
    hero = parts_obs
    fcode = gallery_framing.check_framing(
        sc, cam, hero=hero, elements=hero + [board_ob] + iso_obs,
        stage=[floor, wall],
    )
    if fcode:
        return fcode
    aqcode = gallery_asset_quality.check_asset_quality(
        sc, cam, hero=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 10
    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 overlapping-atlas 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("lightmap-uv-channel 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)