Skip to content

Commit 2efe86b

Browse files
TMHSDigitalclaude
andcommitted
fix(examples): redesign lightmap-uv-channel as a spoked, shafted cart beside a framed atlas display
The cart ran on solid wooden discs with nothing at its open end, so it read as a box on two coins that would tip. Each wheel is now a felloe ring (`Wheel.*.Disc`, rebuilt with `_ring`) plus eight spokes (new `Wheel.*.Spokes` parts via `_spokes`) inside the iron tyre. New draw shafts with a crossbar and a prop leg (`Shafts`, via `_beams`) give it an unhitched stance. Every new part goes through the same UV1 contract as the rest: lightmap-packed, zero overlap, minimum island distance, watertight. The part count is now 24 (was 21). Render path: procedural grain for bed wood (along the planks), dark wood, canvas weave and iron pitting over Object coordinates. The UV1 atlas is now a designed display: a slate face in a walnut frame on a plinth with a brass "UV1 LIGHTMAP" plate, plus a faint quarter grid marking the [0,1] square the bounds check holds. The islands are still built from the live packed UVs. Evidence (live runs, local): - .scratch/blender-5.2.1-windows-x64/blender.exe (Blender 5.2.1 LTS): check exit 0: parts=24, islands=3428, uv0_drift=0, overlap=0, min island distance 0.00401 on every part (new Spokes/Shafts included). --output: framing fill y=0.756, margins >= 0.050; aq_ok (materials n=5, edge90 0.138). --falsify renders the dragged-island overlap. - .scratch/blender-4.5.11-windows-x64/blender.exe (Blender 4.5.11 LTS): check exit 0, identical line (parts=24 islands=3428). - The check code is unchanged and the existing falsifiers (exits 3-9) are not re-run; the new parts only add rows to the same loop. - Contact sheet: candidate luma 0.207 / stage 0.193 / warmth +0.109 / sat 0.153, against armature-bend 0.243/0.192/-0.144/0.237, damped-track-aim 0.237/0.218/+0.105/0.178, and bmesh-gear 0.236/0.177/+0.303/0.250. - Asset sheet (docs/gallery/asset-sheets/lightmap-uv-channel.webp): the cart reads as designed beside the hydrant, drone, well, wheelbarrow and shelf; not the least-designed object. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 8cf6499 commit 2efe86b

8 files changed

Lines changed: 193 additions & 22 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1161,7 +1161,7 @@ and [`mesh-hygiene-audit`](examples/mesh-hygiene-audit/) (topology gates).
11611161
</tr>
11621162
<tr>
11631163
<td width="46%" valign="middle">
1164-
<a href="examples/lightmap-uv-channel/"><img src="examples/lightmap-uv-channel/preview.webp" alt="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" /></a>
1164+
<a href="examples/lightmap-uv-channel/"><img src="examples/lightmap-uv-channel/preview.webp" alt="Lightmap UV channel: a wooden market cart on spoked wheels with a cream canvas canopy beside a walnut-framed atlas display glowing with the bed's packed lightmap islands — proof the second UV layer packs with no overlaps, channel zero untouched" /></a>
11651165
</td>
11661166
<td valign="middle">
11671167

12.4 KB
Loading
7.87 KB
Loading
424 Bytes
Loading

‎examples/gallery.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -588,7 +588,7 @@
588588
"name": "lightmap-uv-channel",
589589
"dir": "examples/lightmap-uv-channel",
590590
"teaches": "A market cart carrying the two-channel UV contract for baked lighting: UV0 untouched, UVLight packed with no overlaps and a respected margin.",
591-
"alt": "A wooden covered cart, its four posts carrying an arched canvas canopy, beside a board showing its packed lightmap UV atlas as coloured islands.",
591+
"alt": "A covered wooden cart on spoked wheels with draw shafts, beside a walnut-framed slate display showing its packed lightmap UV atlas as coloured islands on a quarter grid.",
592592
"witnessesFix": "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.",
593593
"hero": "docs/gallery/assets/lightmap-uv-channel-hero.webp",
594594
"preview": "examples/lightmap-uv-channel/preview.webp",

‎examples/lightmap-uv-channel/README.md‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
A runnable example building the second UV layer engines require for baked
44
lighting, on an asset built to be reused: a market cart assembled from
5-
21 named, watertight parts — a plank-grooved bed with side rails, corner
6-
brackets, and fasteners, real wheel assemblies (disc + hub + iron band +
7-
bolt ring) on a capped axle, four posts, and a ribbed canvas canopy. Each
5+
24 named, watertight parts — a plank-grooved bed with side rails, corner
6+
brackets, and fasteners, spoked wheel assemblies (felloe ring + eight
7+
spokes + hub + iron tyre + bolt ring) on a capped axle, draw shafts with a
8+
crossbar and a prop leg, four posts, and a ribbed canvas canopy. Each
89
post rises from inside the bed into the canopy's underside, its top
910
sheared to the arc at its own station for a uniform 12 mm bite. Fixed
1011
1.35 m posts used to stop 0.33 m short of the arc, so the canopy floated.
@@ -70,19 +71,23 @@ layers by name and re-asserts flags):
7071
[`prop-origin-transform`](../prop-origin-transform/).
7172

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

77-
**Render as proof:** the cart beside its enlarged UV1 atlas board — the
78+
**Render as proof:** the cart beside its enlarged UV1 atlas display — a
79+
slate face in a walnut frame on a plinth with a brass `UV1 LIGHTMAP`
80+
plate, a faint quarter grid marking the [0,1] square the bounds check
81+
holds — the
7882
Bed's packed lightmap built from live UV data, so a change in the atlas
7983
moves the board geometry. The falsification variant (`--falsify`)
8084
translates the second-largest island onto the largest: a big emissive-red
8185
island visibly stacked over the atlas (15 SAT hits in the check probe).
8286
The render path also gates the asset itself through
8387
`examples/gallery_asset_quality.py` (naming, material variation, edge
84-
treatment — exit 11): 21 named parts, 5 materials, right-angle share
85-
0.069.
88+
treatment — exit 11): 24 named parts, 5 materials, right-angle share
89+
0.138. Wood, canvas and iron carry procedural grain, weave and pitting
90+
over Object coordinates; no check reads a material.
8691

8792
## Run
8893

‎examples/lightmap-uv-channel/lightmap_uv_channel.py‎

Lines changed: 179 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
blender --background --python lightmap_uv_channel.py -- --falsify f.png # overlapping atlas
4141
"""
4242
import bpy, bmesh, sys, os, math, argparse
43+
from mathutils import Vector
4344

4445
# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py
4546
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
@@ -218,6 +219,61 @@ def _bolts(name, positions, radius):
218219
return me
219220

220221

222+
def _spokes(name, count, r0, r1, thick, center):
223+
"""count straight spokes from r0 to r1 in the XZ plane around a Y axle
224+
through center: one mesh, disconnected watertight boxes."""
225+
me = bpy.data.meshes.new(name)
226+
bm = bmesh.new()
227+
try:
228+
for i in range(count):
229+
a = 2 * math.pi * (i + 0.5) / count
230+
ca, sa = math.cos(a), math.sin(a)
231+
before_v = set(bm.verts)
232+
bmesh.ops.create_cube(bm, size=1.0)
233+
for v in set(bm.verts) - before_v:
234+
# box along local X from r0 to r1, then rotate about Y
235+
x = v.co.x * (r1 - r0) + 0.5 * (r0 + r1)
236+
y = v.co.y * thick * 0.8
237+
z = v.co.z * thick
238+
v.co = (center[0] + x * ca - z * sa, center[1] + y,
239+
center[2] + x * sa + z * ca)
240+
bm.to_mesh(me)
241+
finally:
242+
bm.free()
243+
return me
244+
245+
246+
def _beams(name, segments, w):
247+
"""Square-section beams between point pairs: one mesh, disconnected
248+
watertight boxes. segments: [(p0, p1), ...]."""
249+
me = bpy.data.meshes.new(name)
250+
bm = bmesh.new()
251+
try:
252+
for p0, p1 in segments:
253+
a, b = Vector(p0), Vector(p1)
254+
axis = (b - a).normalized()
255+
side = axis.cross(Vector((0.0, 0.0, 1.0)))
256+
if side.length < 1e-6:
257+
side = Vector((1.0, 0.0, 0.0))
258+
side.normalize()
259+
up = side.cross(axis).normalized()
260+
h = w / 2
261+
corners = [side * sx * h + up * sz * h
262+
for sx, sz in ((-1, -1), (1, -1), (1, 1), (-1, 1))]
263+
r0 = [bm.verts.new(a + c) for c in corners]
264+
r1 = [bm.verts.new(b + c) for c in corners]
265+
bm.faces.new(list(reversed(r0)))
266+
bm.faces.new(r1)
267+
for i in range(4):
268+
j = (i + 1) % 4
269+
bm.faces.new((r0[i], r0[j], r1[j], r1[i]))
270+
bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
271+
bm.to_mesh(me)
272+
finally:
273+
bm.free()
274+
return me
275+
276+
221277
def _multi_box(name, specs):
222278
"""Several beveled boxes as one mesh (disconnected islands, each
223279
watertight). specs: (dims, center, bevel)."""
@@ -265,8 +321,12 @@ def build_cart_meshes():
265321
((0.14, 0.05, 0.14), (0.55, sy * 0.80, 0.5), 0.02) for sy in (-1, 1)])
266322
for tag, sy in (("L", 1.0), ("R", -1.0)):
267323
y = sy * 0.68
268-
parts[f"Wheel.{tag}.Disc"] = _wheel_disc(
269-
f"Cart.Wheel.{tag}.Disc", 0.45, 0.10, (0.55, y, 0.45))
324+
# spoked wheel: a wooden felloe ring inside the iron tyre, eight
325+
# spokes, and the hub; the Disc part is the felloe (was a solid disc)
326+
parts[f"Wheel.{tag}.Disc"] = _ring(
327+
f"Cart.Wheel.{tag}.Disc", 0.438, 0.37, 0.09, (0.55, y, 0.45), 32)
328+
parts[f"Wheel.{tag}.Spokes"] = _spokes(
329+
f"Cart.Wheel.{tag}.Spokes", 8, 0.12, 0.385, 0.045, (0.55, y, 0.45))
270330
parts[f"Wheel.{tag}.Hub"] = _wheel_disc(
271331
f"Cart.Wheel.{tag}.Hub", 0.14, 0.16, (0.55, y, 0.45))
272332
parts[f"Wheel.{tag}.Band"] = _ring(
@@ -292,6 +352,14 @@ def build_cart_meshes():
292352
v.co.z += arc - top
293353
me.update()
294354
parts[f"Post.{tag}"] = me
355+
# draw shafts off the open (-X) end, a crossbar, and a prop leg the
356+
# cart rests on when unhitched: without them a two-wheeled cart tips
357+
parts["Shafts"] = _beams("Cart.Shafts", [
358+
((-0.95, 0.40, 0.80), (-2.35, 0.36, 0.52)),
359+
((-0.95, -0.40, 0.80), (-2.35, -0.36, 0.52)),
360+
((-1.90, 0.40, 0.61), (-1.90, -0.40, 0.61)),
361+
((-1.05, 0.0, 0.76), (-1.05, 0.0, 0.0)),
362+
], 0.07)
295363
parts["Canopy"] = _canopy("Cart.Canopy", 2.3, canopy_r, canopy_t, canopy_z0)
296364
parts["Canopy.Ribs"] = _canopy_ribs("Cart.Canopy.Ribs", (-0.75, 0.0, 0.75),
297365
0.07, 0.875, 0.035, 1.9)
@@ -613,17 +681,56 @@ def fail(code, msg):
613681
# Render
614682
# ---------------------------------------------------------------------------
615683

616-
def make_material(name, rgb, rough=0.6, metallic=0.0, emit=None, estr=0.0):
684+
def make_material(name, rgb, rough=0.6, metallic=0.0, emit=None, estr=0.0,
685+
grain=None, bump=0.0):
686+
"""grain=(stretch_xyz, scale, amount): noise over Object coordinates,
687+
squeezed along the axes the grain should run, multiplied into the base
688+
colour and roughness. bump>0 adds a fine noise bump (canvas weave, iron
689+
pitting). Render only - no check reads a material."""
617690
mat = bpy.data.materials.new(name)
618691
mat.use_nodes = True
619-
b = mat.node_tree.nodes["Principled BSDF"]
692+
nt = mat.node_tree
693+
b = nt.nodes["Principled BSDF"]
620694
b.inputs["Base Color"].default_value = (*rgb, 1.0)
621695
b.inputs["Roughness"].default_value = rough
622696
b.inputs["Metallic"].default_value = metallic
623697
if emit is not None:
624698
sock = b.inputs.get("Emission Color") or b.inputs["Emission"]
625699
sock.default_value = (*emit, 1.0)
626700
b.inputs["Emission Strength"].default_value = estr
701+
if grain or bump:
702+
coord = nt.nodes.new("ShaderNodeTexCoord").outputs["Object"]
703+
if grain:
704+
stretch, scale, amount = grain
705+
mapping = nt.nodes.new("ShaderNodeMapping")
706+
mapping.inputs["Scale"].default_value = stretch
707+
nt.links.new(coord, mapping.inputs["Vector"])
708+
noise = nt.nodes.new("ShaderNodeTexNoise")
709+
noise.inputs["Scale"].default_value = scale
710+
noise.inputs["Detail"].default_value = 7.0
711+
nt.links.new(mapping.outputs["Vector"], noise.inputs["Vector"])
712+
ramp = nt.nodes.new("ShaderNodeValToRGB")
713+
ramp.color_ramp.elements[0].position = 0.32
714+
ramp.color_ramp.elements[1].position = 0.70
715+
ramp.color_ramp.elements[0].color = tuple(c * (1 - amount) for c in rgb) + (1.0,)
716+
ramp.color_ramp.elements[1].color = tuple(min(1.0, c * (1 + amount)) for c in rgb) + (1.0,)
717+
nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
718+
nt.links.new(ramp.outputs["Color"], b.inputs["Base Color"])
719+
rmap = nt.nodes.new("ShaderNodeMapRange")
720+
rmap.inputs["To Min"].default_value = rough + 0.12
721+
rmap.inputs["To Max"].default_value = rough - 0.08
722+
nt.links.new(noise.outputs["Fac"], rmap.inputs["Value"])
723+
nt.links.new(rmap.outputs["Result"], b.inputs["Roughness"])
724+
if bump:
725+
fine = nt.nodes.new("ShaderNodeTexNoise")
726+
fine.inputs["Scale"].default_value = 180.0
727+
fine.inputs["Detail"].default_value = 4.0
728+
nt.links.new(coord, fine.inputs["Vector"])
729+
bn = nt.nodes.new("ShaderNodeBump")
730+
bn.inputs["Strength"].default_value = bump
731+
bn.inputs["Distance"].default_value = 0.003
732+
nt.links.new(fine.outputs["Fac"], bn.inputs["Height"])
733+
nt.links.new(bn.outputs["Normal"], b.inputs["Normal"])
627734
return mat
628735

629736

@@ -675,20 +782,26 @@ def render_still(path, engine, falsify=False):
675782
bpy.ops.wm.read_factory_settings(use_empty=True)
676783
sc = bpy.context.scene
677784

678-
wood = make_material("Wood", (0.38, 0.22, 0.10), rough=0.55)
679-
darkwood = make_material("DarkWood", (0.16, 0.10, 0.05), rough=0.7)
680-
iron = make_material("Iron", (0.26, 0.26, 0.30), rough=0.3, metallic=0.9)
681-
canvas = make_material("Canvas", (0.66, 0.56, 0.40), rough=0.9)
785+
# bed planks run along X: grain squeezed in X streaks along the planks
786+
wood = make_material("Wood", (0.40, 0.22, 0.095), rough=0.55,
787+
grain=((0.06, 1.0, 1.0), 26.0, 0.35))
788+
darkwood = make_material("DarkWood", (0.19, 0.11, 0.05), rough=0.66,
789+
grain=((1.0, 1.0, 0.08), 30.0, 0.30))
790+
iron = make_material("Iron", (0.20, 0.20, 0.22), rough=0.38, metallic=0.9,
791+
grain=((1.0, 1.0, 1.0), 14.0, 0.45), bump=0.25)
792+
canvas = make_material("Canvas", (0.70, 0.60, 0.42), rough=0.92,
793+
grain=((1.0, 0.4, 1.0), 5.0, 0.18), bump=0.45)
682794
groovemat = make_material("Groove", (0.05, 0.04, 0.03), rough=0.9)
683795

684796
mat_by_part = {
685797
"Bed": wood, "Bed.Rails": wood, "Bed.Grooves": groovemat,
686798
"Bed.Brackets": iron, "Bed.Bolts": iron,
687799
"Axle": iron, "Axle.Caps": iron,
688-
"Canopy": canvas, "Canopy.Ribs": darkwood,
800+
"Canopy": canvas, "Canopy.Ribs": darkwood, "Shafts": darkwood,
689801
}
690802
for tag in ("L", "R"):
691803
mat_by_part[f"Wheel.{tag}.Disc"] = darkwood
804+
mat_by_part[f"Wheel.{tag}.Spokes"] = darkwood
692805
mat_by_part[f"Wheel.{tag}.Hub"] = darkwood
693806
mat_by_part[f"Wheel.{tag}.Band"] = iron
694807
mat_by_part[f"Wheel.{tag}.Bolts"] = iron
@@ -727,12 +840,65 @@ def render_still(path, engine, falsify=False):
727840

728841
# atlas board: the Bed's live UV1 as flat island polygons mapped onto the
729842
# board face — a change in the packed atlas moves the board geometry
730-
board_mat = make_material("Board", (0.04, 0.04, 0.05), rough=0.5, metallic=0.4)
843+
# The display: a slate atlas face in a walnut frame, standing on a walnut
844+
# plinth that carries a brass nameplate, with a faint quarter grid under
845+
# the islands so the [0,1] square the check bounds is visible.
846+
bx, by, bz = 2.05, 0.55, 1.30
847+
board_mat = make_material("Board", (0.030, 0.032, 0.036), rough=0.62,
848+
grain=((1.0, 1.0, 1.0), 9.0, 0.25))
731849
board = _box("Atlas.Board", (0.08, 2.2, 2.0), (0.0, 0.0, 0.0), 0.02)
732850
board.materials.append(board_mat)
733851
board_ob = bpy.data.objects.new("Atlas.Board", board)
734-
board_ob.location = (2.05, 0.55, 1.15)
852+
board_ob.location = (bx, by, bz)
735853
sc.collection.objects.link(board_ob)
854+
walnut = make_material("Walnut", (0.13, 0.065, 0.03), rough=0.5,
855+
grain=((1.0, 1.0, 1.0), 60.0, 0.16))
856+
brass = make_material("Brass", (0.80, 0.58, 0.26), rough=0.28, metallic=1.0,
857+
grain=((1.0, 1.0, 1.0), 40.0, 0.12))
858+
fw = 0.09
859+
frame = _multi_box("Atlas.Frame", [
860+
((0.14, 2.2 + 2 * fw, fw), (0.0, 0.0, 1.0 + fw / 2), 0.012),
861+
((0.14, 2.2 + 2 * fw, fw), (0.0, 0.0, -1.0 - fw / 2), 0.012),
862+
((0.14, fw, 2.0), (0.0, 1.1 + fw / 2, 0.0), 0.012),
863+
((0.14, fw, 2.0), (0.0, -1.1 - fw / 2, 0.0), 0.012)])
864+
frame.materials.append(walnut)
865+
frame_ob = bpy.data.objects.new("Atlas.Frame", frame)
866+
frame_ob.location = (bx, by, bz)
867+
sc.collection.objects.link(frame_ob)
868+
plinth_top = bz - 1.0 - fw
869+
plinth = _box("Atlas.Plinth", (0.46, 2.55, plinth_top),
870+
(bx, by, plinth_top / 2), 0.02)
871+
plinth.materials.append(walnut)
872+
plinth_ob = bpy.data.objects.new("Atlas.Plinth", plinth)
873+
sc.collection.objects.link(plinth_ob)
874+
plate = _box("Atlas.Plate", (0.012, 1.1, 0.13), (0.0, 0.0, 0.0), 0.004)
875+
plate.materials.append(brass)
876+
plate_ob = bpy.data.objects.new("Atlas.Plate", plate)
877+
plate_ob.location = (bx + 0.236, by, plinth_top / 2)
878+
sc.collection.objects.link(plate_ob)
879+
cu = bpy.data.curves.new("Atlas.Label", "FONT")
880+
cu.body = "UV1 LIGHTMAP"
881+
cu.size = 0.085
882+
cu.align_x, cu.align_y = "CENTER", "CENTER"
883+
cu.extrude = 0.002
884+
cu.materials.append(make_material("Ink", (0.02, 0.018, 0.015), rough=0.6))
885+
label_ob = bpy.data.objects.new("Atlas.Label", cu)
886+
label_ob.location = (bx + 0.243, by, plinth_top / 2)
887+
label_ob.rotation_euler = (math.radians(90), 0.0, math.radians(90))
888+
sc.collection.objects.link(label_ob)
889+
grid_mat = make_material("Grid", (0.12, 0.13, 0.15), rough=0.5,
890+
emit=(0.45, 0.5, 0.6), estr=0.35)
891+
lines = []
892+
for k in range(5):
893+
t = (k / 4 - 0.5) * 1.9
894+
lines.append(((0.004, 0.006, 1.9), (0.0, t, 0.0), 0.0))
895+
lines.append(((0.004, 1.9, 0.006), (0.0, 0.0, t), 0.0))
896+
grid = _multi_box("Atlas.Grid", lines)
897+
grid.materials.append(grid_mat)
898+
grid_ob = bpy.data.objects.new("Atlas.Grid", grid)
899+
grid_ob.location = (bx + 0.042, by, bz)
900+
sc.collection.objects.link(grid_ob)
901+
display = [board_ob, frame_ob, plinth_ob, plate_ob, label_ob, grid_ob]
736902

737903
bed = meshes["Bed"]
738904
layer = bed.uv_layers[LAYER1]
@@ -757,7 +923,7 @@ def render_still(path, engine, falsify=False):
757923
me.materials.append(make_material(f"IslandMat{fi}", rgb, rough=0.5,
758924
emit=rgb, estr=1.6 if defect else 0.5))
759925
ob = bpy.data.objects.new(f"Atlas.Island.{fi}", me)
760-
ob.location = (2.05 + 0.045 + (0.004 if fi in dragged else 0.0), 0.55, 1.15)
926+
ob.location = (bx + 0.045 + (0.004 if fi in dragged else 0.0), by, bz)
761927
sc.collection.objects.link(ob)
762928
iso_obs.append(ob)
763929

@@ -795,7 +961,7 @@ def render_still(path, engine, falsify=False):
795961
sc.view_settings.view_transform = "Standard"
796962
hero = parts_obs
797963
fcode = gallery_framing.check_framing(
798-
sc, cam, hero=hero, elements=hero + [board_ob] + iso_obs,
964+
sc, cam, hero=hero, elements=hero + display + iso_obs,
799965
stage=[floor, wall],
800966
)
801967
if fcode:
8.61 KB
Loading

0 commit comments

Comments
 (0)