diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 01f46aa..e2118ec 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -80,6 +80,7 @@ "examples/grease-pencil-rosette", "examples/image-pixels-testcard", "examples/light-link-studio", + "examples/lightmap-uv-channel", "examples/lod-decimate-chain", "examples/mesh-hygiene-audit", "examples/modular-kit-snap", diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index ffc4941..788fb2f 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -579,3 +579,17 @@ jobs: # origins). Exits non-zero on failure. xvfb-run -a "$BLENDER" --background \ --python examples/modular-kit-snap/modular_kit_snap.py -- + + - name: Shipped example - lightmap UV channel (two-layer bake contract) + run: | + set -euo pipefail + # Check only (no render): a 9-part market cart; asserts per-part + # UVMap/UVLight layers with active == UVLight and + # active_render == UVMap re-asserted after the ops (they clear + # both flags), UV0 loops untouched by the UV1 unwrap (drift 0), + # every UV1 loop inside [0,1], zero overlapping islands via an + # independent binned SAT scan, measured min island distance >= + # half the nominal pack margin, and watertight parts. Exits + # non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/lightmap-uv-channel/lightmap_uv_channel.py -- diff --git a/README.md b/README.md index 2521e12..d0eec88 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

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

@@ -800,6 +800,26 @@ measured error printed. Companion to [`prop-origin-transform`](examples/prop-origin-transform/) (pivot discipline) and [`mesh-hygiene-audit`](examples/mesh-hygiene-audit/) (topology gates). + + + + +Lightmap UV channel: a wooden market cart with a cream canvas canopy beside a dark atlas board glowing with the bed's packed lightmap islands — proof the second UV layer packs with no overlaps, channel zero untouched + + + +### [lightmap-uv-channel](examples/lightmap-uv-channel/) + +The second UV layer engines need for baked lighting, on a reusable market +cart. Per-part `UVMap`/`UVLight` with **`active` vs `active_render` pinned** +(the edit-mode UV ops clear both flags — re-assert or lose them), channel +zero untouched by the second unwrap (drift **0.0**, the clobber probe +measures **2.068**), every island inside [0,1], **0** overlapping islands +by an independent SAT scan, and a measured min island distance of +**0.00401** against the stated margin. Companion to +[`uv-layer-grid`](examples/uv-layer-grid/) (UV authoring) and +[`triangulate-tangents`](examples/triangulate-tangents/) (tangents from UVs). + diff --git a/ROADMAP.md b/ROADMAP.md index 5f1b9e3..9a23921 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -106,10 +106,12 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~LOD decimate chain witness~~ **SHIPPED** as `examples/lod-decimate-chain/` — Decimate COLLAPSE evaluated through the depsgraph is non-destructive (obj.data keeps closed-form counts), evaluated tris hit `ratio × base` within 5% (measured 0.0–0.44%), silhouette bbox survives within 1e-3 (measured 7.7e-6); a stacked Decimate halves the effective ratio, an aggressive 0.02 ratio collapses the nose tip — both caught failure modes - ~~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`) +- 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`; **confirmed harder authoring `lightmap-uv-channel`: iterating a held `MeshUVLoopLayer.data` after edit-mode UV ops segfaults 4.5.11 headless, EXCEPTION_ACCESS_VIOLATION 5/5, survives on 5.1.2**) - ~~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 +- ~~Lightmap UV channel witness~~ **SHIPPED** as `examples/lightmap-uv-channel/` — two-channel UV contract on a market cart: per-part `UVMap`/`UVLight` with `active`/`active_render` re-asserted after the ops (edit-mode UV ops clear both flags; `uv_layers.new()` never moves them), UV0 drift 0.0 (clobber trap measured 2.068), every UV1 loop in [0,1], 0 overlapping islands via independent binned strict-SAT (15 hits when falsified), min island distance 0.00401 vs nominal margin 0.002 (`MARGIN_DIV × 0.01` semantics measured live); held `MeshUVLoopLayer.data` after edit-mode UV ops segfaults 4.5.11 (5/5, EXCEPTION_ACCESS_VIOLATION), silent no-op pack without prior unwrap; byte-identical on 4.5.11 and 5.1.2 (1114 islands) +- UV atlas **utilization** witness — coverage/wasted-texel closed forms for a packed lightmap atlas (the non-overlap, unit-square, margin, and active/active_render contracts shipped in `lightmap-uv-channel`; utilization is the remaining unbuilt slice of the old "UV atlas pack" candidate) - ~~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 @@ -126,7 +128,6 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - Dice-pair per-face witness: material/attribute assignment driven by face normal direction, opposite-faces-sum-to-7 as an independently computed invariant, pip placement from a closed-form lattice - Bicycle-wheel radial witness: spokes via radial Array with object offset — exact angular spacing, spoke count, hub/rim concentricity, every evaluated spoke endpoint on its computed rim coordinate - ~~mesh-hygiene-audit witness~~ **SHIPPED** as `examples/mesh-hygiene-audit/` — engine-ingest topology checklist on a stepped street electrical pedestal: no ngons, no loose verts, manifold edges (exactly 2 faces), no zero-area faces (`min_area` printed), positive signed volume, Euler `V−E+F==2` (24/44/22, volume 0.989248); byte-identical on 4.5.11 and 5.1.2; dual-panel dirty vs clean still -- UV atlas pack witness — lightmap/unique UV island packing with non-overlap + utilization closed forms (deferred behind hygiene/origin; overlaps `uv-layer-grid` / `triangulate-tangents` / `gltf-export-roundtrip` UV arc) - ~~Gallery framing recomposition worklist~~ **DRAINED**: all four recomposition cases restaged and gated with `check_framing` — depsgraph-export (0.984x/touch→0.812x; cube+dome recentered, aim-based camera), vertex-weight-limit (touch B→0.867y; more frontal camera, lower aim, base in frame), triangulate-tangents (0.334x→0.833y; 2x-class move-in plus Key/Glint trim that also cleared a pre-existing dome blowout), text-version-stamp (0.663x→0.719x; moved in with aim on the stamp center, dead third gone). The seven marginal cases were camera-nudged in the preceding sweep; deviations documented in #108 (damped-track-aim, wave-displace); sky-texture-sun-elevation is a measurement artifact (world subject — report-only if wired). Remaining: nothing on the framing worklist — future examples inherit the gate at authoring time. diff --git a/docs/gallery/assets/lightmap-uv-channel-hero.webp b/docs/gallery/assets/lightmap-uv-channel-hero.webp new file mode 100644 index 0000000..f83e1af Binary files /dev/null and b/docs/gallery/assets/lightmap-uv-channel-hero.webp differ diff --git a/docs/gallery/contact-sheets/lightmap-uv-channel.webp b/docs/gallery/contact-sheets/lightmap-uv-channel.webp new file mode 100644 index 0000000..26db47f Binary files /dev/null and b/docs/gallery/contact-sheets/lightmap-uv-channel.webp differ diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 3fda2ef..85bcdcc 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" /> - 41 examples + 42 examples
@@ -773,6 +773,17 @@

modular-kit-snap

View example
+
+ + 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. + +
+

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.

+

witnesses Per-part UVMap/UVLight with active vs active_render pinned (ops clear both flags), UV0 drift 0.0, 1114 islands with 0 SAT overlaps, min island distance 0.00401.

+ View example +
+
diff --git a/docs/gallery/lightmap-uv-channel/index.html b/docs/gallery/lightmap-uv-channel/index.html new file mode 100644 index 0000000..8c2215e --- /dev/null +++ b/docs/gallery/lightmap-uv-channel/index.html @@ -0,0 +1,996 @@ + + + + + + lightmap-uv-channel — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + +
+ 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, 1114 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 (bed, axle, two wheels, four posts, arched canvas canopy — ground-level pivot at z=0, identity transforms by construction, Cart.* datablocks, watertight parts throughout). 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 (9 parts, 1114 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 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).

+

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
+
+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(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=20,
+                              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 _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:
+        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((-width / 2, radius * ca, z0 + radius * sa)))
+            inner.append(bm.verts.new((-width / 2, (radius - thickness) * ca,
+                                       z0 + (radius - thickness) * sa)))
+        def band(off_x, 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(0, outer, inner)          # underside (thickness face)
+        band(width, 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]))
+        for ring in ((outer, outer_x2), (inner, inner_x2)):  # end caps
+            for i in (0, segments):
+                pass
+        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]))
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    return me
+
+
+def build_cart_meshes():
+    parts = {}
+    parts["Bed"] = _box("Cart.Bed", (2.2, 1.2, 0.22), (0.0, 0.0, 0.85), 0.03)
+    parts["Axle"] = _wheel("Cart.Axle", 0.05, 1.5, (0.55, 0.0, 0.5))
+    parts["Wheel.L"] = _wheel("Cart.Wheel.L", 0.45, 0.12, (0.55, 0.68, 0.45))
+    parts["Wheel.R"] = _wheel("Cart.Wheel.R", 0.45, 0.12, (0.55, -0.68, 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)
+    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.42, 0.26, 0.13), rough=0.6)
+    darkwood = make_material("DarkWood", (0.16, 0.10, 0.05), rough=0.7)
+    iron = make_material("Iron", (0.18, 0.18, 0.20), rough=0.35, metallic=0.9)
+    canvas = make_material("Canvas", (0.66, 0.56, 0.40), rough=0.9)
+
+    mats = {"Bed": wood, "Axle": iron, "Wheel.L": darkwood, "Wheel.R": darkwood,
+            "Canopy": canvas}
+    parts_obs = []
+    meshes = build_cart_meshes()
+    for suffix, me in meshes.items():
+        me.materials.append(mats.get(suffix.split(".")[0], wood)
+                            if not suffix.startswith("Post") else wood)
+        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.07, 1.7, 1.7), (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 = (1.75, 0.55, 1.0)
+    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.5, (uv[1] - 0.5) * 1.5))
+                  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 = (1.75 + 0.037 + (0.004 if fi in dragged else 0.0), 0.55, 1.0)
+        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.4, -6.6, 2.7)
+    sc.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.75, 0.1, 1.08)
+    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
+    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)
+
+
+
+ + + + + diff --git a/examples/gallery.json b/examples/gallery.json index a11b291..d0c484e 100644 --- a/examples/gallery.json +++ b/examples/gallery.json @@ -517,6 +517,19 @@ "instancing" ], "featured_rank": 8 + }, + { + "name": "lightmap-uv-channel", + "dir": "examples/lightmap-uv-channel", + "teaches": "A market cart carrying the two-channel UV contract for baked lighting: UV0 untouched, UVLight packed with no overlaps and a respected margin.", + "witnessesFix": "Per-part UVMap/UVLight with active vs active_render pinned (ops clear both flags), UV0 drift 0.0, 1114 islands with 0 SAT overlaps, min island distance 0.00401.", + "hero": "docs/gallery/assets/lightmap-uv-channel-hero.webp", + "preview": "examples/lightmap-uv-channel/preview.webp", + "tags": [ + "uv", + "game-pipeline", + "render" + ] } ] } diff --git a/examples/lightmap-uv-channel/README.md b/examples/lightmap-uv-channel/README.md new file mode 100644 index 0000000..61b8654 --- /dev/null +++ b/examples/lightmap-uv-channel/README.md @@ -0,0 +1,84 @@ +# Lightmap UV Channel + +A runnable example building the second UV layer engines require for baked +lighting, on an asset built to be reused: a market cart (bed, axle, two +wheels, four posts, arched canvas canopy — ground-level pivot at `z=0`, +identity transforms by construction, `Cart.*` datablocks, watertight parts +throughout). UV0 is the texture channel (deterministic dominant-axis box +projection per face); UVLight is the baked-lighting atlas +(`smart_project` → `lightmap_pack`). + +**What it witnesses** (all independently derived, nothing trusted from the +packer): + +- **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 (probed: unwrap+pack with the + flags unmoved destroys channel 0, measured drift **2.068**). Worse, the + edit-mode UV ops **clear both flags** — the sequence must re-assert them. + The check pins, per part: exactly two layers `UVMap`/`UVLight`, + `active == UVLight` (edit target), `active_render == UVMap` (render + target). +- **Channel zero untouched.** UV0 loops snapshotted before the UVLight + unwrap, compared after: drift **0.0** on every part (tol 1e-6). +- **Bounds.** Every UVLight loop inside `[0, 1]` within 1e-5. +- **Non-overlap.** UV1 triangles binned into a 256² spatial hash and tested + pairwise with strict SAT (epsilon-separated, so shared edges don't count): + **0** overlapping pairs. +- **Margin.** `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 — measured **0.00401** + (≈ 2× the nominal 0.002, the packer's per-island margin applied twice). +- **Island census + watertight parts.** Connected components counted + independently per part; loops conserved; every part manifold. + +**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): + +- Holding a `MeshUVLoopLayer` reference across edit-mode UV ops and then + iterating its `.data` **segfaults Blender 4.5.11 headless** + (`EXCEPTION_ACCESS_VIOLATION`, 5/5 repro); the same read survives on + 5.1.2. Re-fetch `mesh.uv_layers[name]` after CustomData-reallocating + calls. Second confirmation of the UV-handle lifetime hazard found + authoring `triangulate-tangents`. +- `bpy.ops.uv.lightmap_pack` **silently packs nothing** (exit OK) when the + active layer has no UVs yet — unwrap first, then pack. + +**Pipeline arc neighbors:** UV-layer authoring in +[`uv-layer-grid`](../uv-layer-grid/), tangent-space from UVs in +[`triangulate-tangents`](../triangulate-tangents/), export round-trip in +[`gltf-export-roundtrip`](../gltf-export-roundtrip/), pivot discipline in +[`prop-origin-transform`](../prop-origin-transform/). + +**Version witness:** check output is byte-identical on Blender 4.5.11 LTS +and 5.1.2 (9 parts, 1114 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 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). + +## Run + +```bash +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. diff --git a/examples/lightmap-uv-channel/lightmap_uv_channel.py b/examples/lightmap-uv-channel/lightmap_uv_channel.py new file mode 100644 index 0000000..d47404a --- /dev/null +++ b/examples/lightmap-uv-channel/lightmap_uv_channel.py @@ -0,0 +1,674 @@ +"""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 + +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(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=20, + 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 _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: + 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((-width / 2, radius * ca, z0 + radius * sa))) + inner.append(bm.verts.new((-width / 2, (radius - thickness) * ca, + z0 + (radius - thickness) * sa))) + def band(off_x, 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(0, outer, inner) # underside (thickness face) + band(width, 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])) + for ring in ((outer, outer_x2), (inner, inner_x2)): # end caps + for i in (0, segments): + pass + 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])) + bm.to_mesh(me) + finally: + bm.free() + return me + + +def build_cart_meshes(): + parts = {} + parts["Bed"] = _box("Cart.Bed", (2.2, 1.2, 0.22), (0.0, 0.0, 0.85), 0.03) + parts["Axle"] = _wheel("Cart.Axle", 0.05, 1.5, (0.55, 0.0, 0.5)) + parts["Wheel.L"] = _wheel("Cart.Wheel.L", 0.45, 0.12, (0.55, 0.68, 0.45)) + parts["Wheel.R"] = _wheel("Cart.Wheel.R", 0.45, 0.12, (0.55, -0.68, 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) + 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.42, 0.26, 0.13), rough=0.6) + darkwood = make_material("DarkWood", (0.16, 0.10, 0.05), rough=0.7) + iron = make_material("Iron", (0.18, 0.18, 0.20), rough=0.35, metallic=0.9) + canvas = make_material("Canvas", (0.66, 0.56, 0.40), rough=0.9) + + mats = {"Bed": wood, "Axle": iron, "Wheel.L": darkwood, "Wheel.R": darkwood, + "Canopy": canvas} + parts_obs = [] + meshes = build_cart_meshes() + for suffix, me in meshes.items(): + me.materials.append(mats.get(suffix.split(".")[0], wood) + if not suffix.startswith("Post") else wood) + 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.07, 1.7, 1.7), (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 = (1.75, 0.55, 1.0) + 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.5, (uv[1] - 0.5) * 1.5)) + 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 = (1.75 + 0.037 + (0.004 if fi in dragged else 0.0), 0.55, 1.0) + 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.4, -6.6, 2.7) + sc.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.75, 0.1, 1.08) + 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 + 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) diff --git a/examples/lightmap-uv-channel/preview.webp b/examples/lightmap-uv-channel/preview.webp new file mode 100644 index 0000000..0902233 Binary files /dev/null and b/examples/lightmap-uv-channel/preview.webp differ