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.
+witnesses Per-part UVMap/UVLight with active vs active_render pinned (ops clear both flags), UV0 drift 0.0, 3680 islands with 0 SAT overlaps, min island distance 0.00401.
View example
Rendered headless by the example itself — click to zoom.
-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_project → lightmap_pack).
A runnable example building the second UV layer engines require for baked lighting, on an asset built to be reused: a market cart assembled from 21 named, watertight parts — a plank-grooved bed with side rails, corner brackets, and fasteners, real wheel assemblies (disc + hub + iron band + bolt ring) on a capped axle, four posts, and a ribbed canvas canopy. Ground-level pivot at z=0, identity transforms by construction, Cart.* datablocks. (An earlier revision shipped a soft uniform-tan cart with lumpy polygon wheels and was remodeled under the asset-quality gate; its wheel material was also silently never applied — a name-key lookup bug the exact per-part mapping now replaces.) UV0 is the texture channel (deterministic dominant-axis box projection per face); UVLight is the baked-lighting atlas (smart_project → lightmap_pack).
What it witnesses (all independently derived, nothing trusted from the packer):
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).[0, 1] within 1e-5.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).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):
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, 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).
Version witness: check output is byte-identical on Blender 4.5.11 LTS and 5.1.2 (21 parts, 3680 islands, drift 0, overlap 0, min island distance 0.00401). The UV *layout* is packer-version-dependent by design; the contracts are layout-independent invariants.
+Render as proof: the cart beside its enlarged UV1 atlas board — the Bed's packed lightmap built from live UV data, so a change in the atlas moves the board geometry. The falsification variant (--falsify) translates the second-largest island onto the largest: a big emissive-red island visibly stacked over the atlas (15 SAT hits in the check probe). The render path also gates the asset itself through examples/gallery_asset_quality.py (naming, material variation, edge treatment — exit 11): 21 named parts, 5 materials, right-angle share 0.069.
blender --background --python lightmap_uv_channel.py --
blender --background --python lightmap_uv_channel.py -- --output atlas.png
@@ -327,6 +327,7 @@ Source
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree
import gallery_framing
+import gallery_asset_quality
TOL = 1e-6 # UV0 preservation tolerance
BOUNDS_TOL = 1e-5 # UV1 unit-square slack
@@ -370,11 +371,11 @@ Source
return me
-def _wheel(name, radius, width, center):
+def _wheel_disc(name, radius, width, center):
me = bpy.data.meshes.new(name)
bm = bmesh.new()
try:
- bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=20,
+ bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=24,
radius1=radius, radius2=radius, depth=width)
# cone axis is Z; the wheel rolls around Y -> rotate verts, applied in data
for v in bm.verts:
@@ -388,37 +389,135 @@ Source
return me
+def _ring(name, r_out, r_in, width, center, segments=24):
+ """Flat annulus (iron tire band) spun from a 4-vert profile, watertight."""
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ hw = width / 2
+ prof = [(r_in, -hw), (r_out, -hw), (r_out, hw), (r_in, hw)]
+ vs = [bm.verts.new((r, 0.0, z)) for r, z in prof]
+ es = [bm.edges.new((vs[i], vs[(i + 1) % 4])) for i in range(4)]
+ bmesh.ops.spin(bm, geom=vs + es, cent=(0.0, 0.0, 0.0),
+ axis=(0.0, 0.0, 1.0), dvec=(0.0, 0.0, 0.0),
+ angle=2 * math.pi, steps=segments, use_merge=True)
+ for v in bm.verts:
+ x, y, z = v.co.x, v.co.y, v.co.z
+ v.co = (x + center[0], z + center[1], y + center[2])
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ return me
+
+
+def _bolt_ring(name, count, ring_r, bolt_r, center):
+ """count bolt heads on a ring around Y — one mesh, disconnected islands."""
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ for i in range(count):
+ a = 2 * math.pi * i / count
+ cx, cz = center[0] + ring_r * math.cos(a), center[2] + ring_r * math.sin(a)
+ before_v = set(bm.verts)
+ bmesh.ops.create_uvsphere(bm, u_segments=8, v_segments=6, radius=bolt_r)
+ for v in set(bm.verts) - before_v:
+ v.co.x += cx
+ v.co.y += center[1]
+ v.co.z += cz
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ return me
+
+
+def _arc_band(bm, xc, width, radius, thickness, z0, segments):
+ """One arched sheet spanning x in [xc, xc+width], solidified with rims."""
+ sweep = math.radians(120.0)
+ a0 = math.radians(90.0) - sweep / 2
+ outer, inner = [], []
+ for i in range(segments + 1):
+ a = a0 + sweep * i / segments
+ ca, sa = math.cos(a), math.sin(a)
+ outer.append(bm.verts.new((xc, radius * ca, z0 + radius * sa)))
+ inner.append(bm.verts.new((xc, (radius - thickness) * ca,
+ z0 + (radius - thickness) * sa)))
+ def band(ring_o, ring_i):
+ for i in range(segments):
+ a, b = i, i + 1
+ bm.faces.new((ring_o[a], ring_o[b], ring_i[b], ring_i[a]))
+ outer_x2 = [bm.verts.new((v.co.x + width, v.co.y, v.co.z)) for v in outer]
+ inner_x2 = [bm.verts.new((v.co.x + width, v.co.y, v.co.z)) for v in inner]
+ band(outer, inner) # underside (thickness face)
+ band(inner_x2, outer_x2) # top face
+ for i in range(segments): # the two long rims
+ a, b = i, i + 1
+ bm.faces.new((outer[a], outer_x2[a], outer_x2[b], outer[b]))
+ bm.faces.new((inner[a], inner[b], inner_x2[b], inner_x2[a]))
+ bm.faces.new((outer[0], inner[0], inner_x2[0], outer_x2[0]))
+ bm.faces.new((outer[-1], outer_x2[-1], inner_x2[-1], inner[-1]))
+
+
def _canopy(name, width, radius, thickness, z0, segments=14):
"""Arched canvas sheet: an arc band solidified with a rim, watertight."""
me = bpy.data.meshes.new(name)
bm = bmesh.new()
try:
- 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]))
+ _arc_band(bm, -width / 2, width, radius, thickness, z0, segments)
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ return me
+
+
+def _canopy_ribs(name, positions, width, radius, thickness, z0, segments=14):
+ """Thin wooden ribs following the canopy arc, one mesh, N islands."""
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ for px in positions:
+ _arc_band(bm, px - width / 2, width, radius, thickness, z0, segments)
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ return me
+
+
+def _bolts(name, positions, radius):
+ """Bolt head spheres at positions — one mesh, disconnected islands."""
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ for px, py, pz in positions:
+ before_v = set(bm.verts)
+ bmesh.ops.create_uvsphere(bm, u_segments=8, v_segments=6, radius=radius)
+ for v in set(bm.verts) - before_v:
+ v.co.x += px
+ v.co.y += py
+ v.co.z += pz
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ return me
+
+
+def _multi_box(name, specs):
+ """Several beveled boxes as one mesh (disconnected islands, each
+ watertight). specs: (dims, center, bevel)."""
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ for dims, center, bevel in specs:
+ before_e = set(bm.edges)
+ before_v = set(bm.verts)
+ bmesh.ops.create_cube(bm, size=1.0)
+ new_edges = list(set(bm.edges) - before_e)
+ for v in set(bm.verts) - before_v:
+ v.co.x = v.co.x * dims[0] + center[0]
+ v.co.y = v.co.y * dims[1] + center[1]
+ v.co.z = v.co.z * dims[2] + center[2]
+ if bevel > 0.0:
+ bmesh.ops.bevel(bm, geom=new_edges, offset=bevel, segments=2,
+ profile=0.5, affect="EDGES", clamp_overlap=True)
bm.to_mesh(me)
finally:
bm.free()
@@ -426,16 +525,43 @@ Source
def build_cart_meshes():
+ """The cart as an assembly of named, watertight parts: real wheel
+ assemblies (disc + hub + iron band + bolt ring), plank-grooved bed with
+ side rails, corner brackets, and fasteners, and a ribbed canvas canopy."""
parts = {}
parts["Bed"] = _box("Cart.Bed", (2.2, 1.2, 0.22), (0.0, 0.0, 0.85), 0.03)
- parts["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))
+ parts["Bed.Rails"] = _multi_box("Cart.Bed.Rails", [
+ ((2.2, 0.06, 0.18), (0.0, 0.57, 1.02), 0.015),
+ ((2.2, 0.06, 0.18), (0.0, -0.57, 1.02), 0.015)])
+ parts["Bed.Grooves"] = _multi_box("Cart.Bed.Grooves", [
+ ((0.035, 1.14, 0.015), (x, 0.0, 0.9575), 0.0)
+ for x in (-0.66, -0.22, 0.22, 0.66)])
+ parts["Bed.Brackets"] = _multi_box("Cart.Bed.Brackets", [
+ ((0.03, 0.13, 0.26), (sx * 1.105, sy * 0.535, 0.85), 0.008)
+ for sx in (-1, 1) for sy in (-1, 1)])
+ parts["Bed.Bolts"] = _bolts("Cart.Bed.Bolts", [
+ (sx * 1.125, sy * 0.535, z) for sx in (-1, 1) for sy in (-1, 1)
+ for z in (0.78, 0.92)], 0.018)
+ parts["Axle"] = _wheel_disc("Cart.Axle", 0.05, 1.56, (0.55, 0.0, 0.5))
+ parts["Axle.Caps"] = _multi_box("Cart.Axle.Caps", [
+ ((0.14, 0.05, 0.14), (0.55, sy * 0.80, 0.5), 0.02) for sy in (-1, 1)])
+ for tag, sy in (("L", 1.0), ("R", -1.0)):
+ y = sy * 0.68
+ parts[f"Wheel.{tag}.Disc"] = _wheel_disc(
+ f"Cart.Wheel.{tag}.Disc", 0.45, 0.10, (0.55, y, 0.45))
+ parts[f"Wheel.{tag}.Hub"] = _wheel_disc(
+ f"Cart.Wheel.{tag}.Hub", 0.14, 0.16, (0.55, y, 0.45))
+ parts[f"Wheel.{tag}.Band"] = _ring(
+ f"Cart.Wheel.{tag}.Band", 0.47, 0.435, 0.10, (0.55, y, 0.45))
+ parts[f"Wheel.{tag}.Bolts"] = _bolt_ring(
+ f"Cart.Wheel.{tag}.Bolts", 6, 0.09, 0.02, (0.55, y + sy * 0.085, 0.45))
for tag, (px, py) in (("FL", (0.95, 0.48)), ("FR", (0.95, -0.48)),
("RL", (-0.95, 0.48)), ("RR", (-0.95, -0.48))):
parts[f"Post.{tag}"] = _box(f"Cart.Post.{tag}", (0.09, 0.09, 1.35),
(px, py, 1.55), 0.015)
parts["Canopy"] = _canopy("Cart.Canopy", 2.3, 0.85, 0.04, 1.9)
+ parts["Canopy.Ribs"] = _canopy_ribs("Cart.Canopy.Ribs", (-0.75, 0.0, 0.75),
+ 0.07, 0.875, 0.035, 1.9)
return parts
@@ -794,18 +920,30 @@ Source
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)
+ wood = make_material("Wood", (0.38, 0.22, 0.10), rough=0.55)
darkwood = make_material("DarkWood", (0.16, 0.10, 0.05), rough=0.7)
- iron = make_material("Iron", (0.18, 0.18, 0.20), rough=0.35, metallic=0.9)
+ iron = make_material("Iron", (0.26, 0.26, 0.30), rough=0.3, metallic=0.9)
canvas = make_material("Canvas", (0.66, 0.56, 0.40), rough=0.9)
+ groovemat = make_material("Groove", (0.05, 0.04, 0.03), rough=0.9)
+
+ mat_by_part = {
+ "Bed": wood, "Bed.Rails": wood, "Bed.Grooves": groovemat,
+ "Bed.Brackets": iron, "Bed.Bolts": iron,
+ "Axle": iron, "Axle.Caps": iron,
+ "Canopy": canvas, "Canopy.Ribs": darkwood,
+ }
+ for tag in ("L", "R"):
+ mat_by_part[f"Wheel.{tag}.Disc"] = darkwood
+ mat_by_part[f"Wheel.{tag}.Hub"] = darkwood
+ mat_by_part[f"Wheel.{tag}.Band"] = iron
+ mat_by_part[f"Wheel.{tag}.Bolts"] = iron
+ for tag in ("FL", "FR", "RL", "RR"):
+ mat_by_part[f"Post.{tag}"] = darkwood
- 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.materials.append(mat_by_part[suffix])
me.uv_layers.new(name=LAYER0)
me.uv_layers.new(name=LAYER1)
write_uv0_box_projection(me)
@@ -835,10 +973,10 @@ Source
# 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 = _box("Atlas.Board", (0.08, 2.2, 2.0), (0.0, 0.0, 0.0), 0.02)
board.materials.append(board_mat)
board_ob = bpy.data.objects.new("Atlas.Board", board)
- board_ob.location = (1.75, 0.55, 1.0)
+ board_ob.location = (2.05, 0.55, 1.15)
sc.collection.objects.link(board_ob)
bed = meshes["Bed"]
@@ -853,7 +991,7 @@ Source
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))
+ vs = [bm.verts.new((0.0, (0.5 - uv[0]) * 1.9, (uv[1] - 0.5) * 1.9))
for uv in pts]
bm.faces.new(vs)
bm.to_mesh(me)
@@ -864,7 +1002,7 @@ Source
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)
+ ob.location = (2.05 + 0.045 + (0.004 if fi in dragged else 0.0), 0.55, 1.15)
sc.collection.objects.link(ob)
iso_obs.append(ob)
@@ -873,10 +1011,10 @@ Source
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)
+ cam.location = (5.9, -7.2, 2.8)
sc.collection.objects.link(cam)
aim = bpy.data.objects.new("Aim", None)
- aim.location = (0.75, 0.1, 1.08)
+ aim.location = (0.85, 0.1, 1.05)
sc.collection.objects.link(aim)
tr = cam.constraints.new("TRACK_TO")
tr.target = aim
@@ -907,6 +1045,10 @@ Source
)
if fcode:
return fcode
+ aqcode = gallery_asset_quality.check_asset_quality(
+ sc, cam, hero=hero, stage=[floor, wall])
+ if aqcode:
+ return aqcode
bpy.ops.render.render(write_still=True)
if not (os.path.exists(path) and os.path.getsize(path) > 0):
print("ERROR: render produced no file", file=sys.stderr)
diff --git a/docs/gallery/modular-kit-snap/index.html b/docs/gallery/modular-kit-snap/index.html
index d50f3f2..3bc9d91 100644
--- a/docs/gallery/modular-kit-snap/index.html
+++ b/docs/gallery/modular-kit-snap/index.html
@@ -260,13 +260,13 @@ modular-kit-snap
A runnable example building the asset a tiling modular kit lives or dies by: a corridor segment whose open-end boundary vertices sit exactly on the declared tile grid, so instances placed at 4 m multiples share boundary positions with no gap and no overlap. Interior geometry is free-form (beveled detail boxes); only the boundary is snapped — the snap is a deliberate authoring pass, and the check is what catches you skipping it.
-The asset, for reuse: a 4 × 3 × 3 m corridor segment (hollow-rectangle shell plus twelve detail parts — frame rib, walkway plate, wall panels, trim rails, emissive light strips). Origin at the connection pivot (floor-center of the start edge, so instance *n* places at x = n·4), identity transforms by construction, datablocks under Kit.CorridorSeg.*, manifold everywhere except the two intentional open ends, and every detail part contained strictly inside the tile so it can never break a joint. Drop the hierarchy into a scene and array it along X.
+The asset, for reuse: a 4 × 3 × 3 m corridor segment (hollow-rectangle shell plus twenty detail parts — frame rib, walkway plate, wall panel assemblies (backing plate + inset panel + four bolt heads each), trim rails, emissive light strips). Origin at the connection pivot (floor-center of the start edge, so instance *n* places at x = n·4), identity transforms by construction, datablocks under Kit.CorridorSeg.*, manifold everywhere except the two intentional open ends, and every detail part contained strictly inside the tile so it can never break a joint. Drop the hierarchy into a scene and array it along X.
Pipeline arc neighbors: watertight parametric solids in bmesh-gear, topology gates in mesh-hygiene-audit, origin/pivot discipline in prop-origin-transform, collision packaging in collision-hull-proxy.
What it witnesses (all closed form or independently re-derived):
- Snap. Exactly 16 boundary verts (8 per open end, from the hollow-rectangle profile), every one on its end plane
x ∈ {0, 4} within 1e-6 m; every boundary edge lies on an end plane. - Loop coincidence. The two end rings, matched by nearest (y, z) key, agree within 1e-6 — opposing loops are coincident under the tile offset.
- Tiling. A linked duplicate offset by
(4, 0, 0) places its start ring at world positions matching the original's end ring within 1e-6 — no gap, no overlap at the joint. - Bounding box. The shell's local bbox equals the declared tile exactly (
0..4 × ±1.5 × 0..3, all float32-exact values, deviation 0.0), and the whole-asset bbox matches it because detail is contained. - Manifold. Every edge has exactly 2 link faces except the 16 boundary ring edges (1 face each) — the open ends are the only boundaries.
- Reuse hygiene. Identity scales,
Kit.CorridorSeg.* names, all part origins at the pivot.
What each check catches on failure (probed with an unsnapped variant built by the same code minus the snap pass — 3 mm end-ring skew, 2 mm y-nudge on two verts): boundary verts off the end planes, worst 3.000e-03 m (exit 3); opposing rings displaced 2.000e-03 (exit 4); tiled joint gap/overlap (exit 5); bbox off the declared tile by 3.000e-03 (exit 6); boundary edges torn off the rim (exit 7); non-watertight detail (exit 8); detail escaping the tile (exit 9); unapplied transforms, default names, or wandering origins (exit 11).
Version witness: check output is byte-identical on Blender 4.5.11 LTS and 5.1.2 — same counts, same zero measured deviations.
-Render as proof: a four-segment run — the joints vanish. The falsification variant (--falsify) accumulates a 120 mm gap, 50 mm lateral jogs, and 40 mm floor steps at each joint: floor plates split with dark seams and the trim rails visibly break. The check measures the same failure class at 3 mm; the render exaggerates it to read at frame scale.
+Render as proof: a four-segment run — the joints vanish. The falsification variant (--falsify) accumulates a 120 mm gap, 50 mm lateral jogs, and 40 mm floor steps at each joint: floor plates split with dark seams and the trim rails visibly break. The check measures the same failure class at 3 mm; the render exaggerates it to read at frame scale. The render path also gates the asset through examples/gallery_asset_quality.py (naming, material variation, edge treatment — exit 11). An earlier revision shipped flat grey wall panels and was remodeled under that gate: each panel is now an assembly (backing plate + inset panel + four bolt heads), the palette shifted teal-slate so the card does not twin with lightmap-uv-channel's warm cart, and mean luminance was brought into the calibration range (82.7 → 66.1, ceiling 77.7) — no luminance deviation needed.
Framing deviation: the still is an interior corridor run — the envelope surrounds the camera on five sides and the tiling joints are the proof, so the subject reads as extending past the frame (the radiating-architecture class of VISUAL-STYLE Layer 1). check_framing measures and reports (fill 1.000/1.000, all-edge bleed) without enforcing, with the reason string at the call site.
Run
blender --background --python modular_kit_snap.py --
@@ -328,6 +328,7 @@ Source
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree
import gallery_framing
+import gallery_asset_quality
TILE = 4.0 # tile length along X, metres
WIDTH = 3.0 # corridor width along Y ( +/- 1.5 )
@@ -423,11 +424,6 @@ Source
("Rib.Beam", (0.24, 2 * inner_y - 0.30, 0.34), (0.18, 0.0, HEIGHT - WALL - 0.17), 0.02),
# walkway floor plate
("FloorPlate", (3.4, 1.8, 0.05), (TILE / 2, 0.0, WALL + 0.025), 0.015),
- # wall panels, two per side, recessed-look slabs on the bore face
- ("Panel.L.1", (1.15, 0.05, 1.15), (1.2, inner_y - 0.025, 1.55), 0.02),
- ("Panel.L.2", (1.15, 0.05, 1.15), (2.8, inner_y - 0.025, 1.55), 0.02),
- ("Panel.R.1", (1.15, 0.05, 1.15), (1.2, -(inner_y - 0.025), 1.55), 0.02),
- ("Panel.R.2", (1.15, 0.05, 1.15), (2.8, -(inner_y - 0.025), 1.55), 0.02),
# orange trim rails — the lines that jog visibly when a joint steps
("Trim.L", (3.7, 0.06, 0.09), (TILE / 2, inner_y - 0.03, 0.78), 0.015),
("Trim.R", (3.7, 0.06, 0.09), (TILE / 2, -(inner_y - 0.03), 0.78), 0.015),
@@ -435,14 +431,49 @@ Source
("Light.L", (3.0, 0.05, 0.07), (TILE / 2, inner_y - 0.025, HEIGHT - WALL - 0.09), 0.01),
("Light.R", (3.0, 0.05, 0.07), (TILE / 2, -(inner_y - 0.025), HEIGHT - WALL - 0.09), 0.01),
]
+ # wall panel assemblies: backing plate proud of the bore face, a lighter
+ # inset panel — depth, not flat grey rectangles. Bolt heads are spheres,
+ # built by _bolts_mesh in build_kit_meshes rather than as boxes.
+ for side, sy in (("L", 1.0), ("R", -1.0)):
+ for idx, px in ((1, 1.2), (2, 2.8)):
+ parts.append((f"PanelBack.{side}.{idx}", (1.35, 0.04, 1.35),
+ (px, sy * (inner_y - 0.02), 1.55), 0.015))
+ parts.append((f"Panel.{side}.{idx}", (1.15, 0.05, 1.15),
+ (px, sy * (inner_y - 0.045), 1.55), 0.02))
return parts
+def _bolts_mesh(name, positions, radius):
+ """Bolt head spheres at positions — one mesh, disconnected islands."""
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ for px, py, pz in positions:
+ before_v = set(bm.verts)
+ bmesh.ops.create_uvsphere(bm, u_segments=8, v_segments=6, radius=radius)
+ for v in set(bm.verts) - before_v:
+ v.co.x += px
+ v.co.y += py
+ v.co.z += pz
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ return me
+
+
def build_kit_meshes(skew=0.0, nudge=0.0):
"""All kit meshes, keyed by part suffix. Skew/nudge touch the shell only."""
meshes = {"Shell": build_shell("Kit.CorridorSeg.Shell", skew, nudge)}
for suffix, dims, center, bevel in detail_layout():
meshes[suffix] = build_detail(f"Kit.CorridorSeg.{suffix}", dims, center, bevel)
+ # panel fixings: four bolt heads per panel, proud of the inset panel
+ inner_y = WIDTH / 2 - WALL
+ for side, sy in (("L", 1.0), ("R", -1.0)):
+ for idx, px in ((1, 1.2), (2, 2.8)):
+ positions = [(px + dx, sy * (inner_y - 0.07), 1.55 + dz)
+ for dx in (-0.5, 0.5) for dz in (-0.5, 0.5)]
+ meshes[f"PanelBolts.{side}.{idx}"] = _bolts_mesh(
+ f"Kit.CorridorSeg.PanelBolts.{side}.{idx}", positions, 0.022)
return meshes
@@ -650,19 +681,28 @@ Source
SEGMENT_MATS = {
- "Shell": ("Shell", (0.10, 0.11, 0.13), 0.5, 0.8, None, 0.0),
+ "Shell": ("Shell", (0.075, 0.095, 0.115), 0.5, 0.8, None, 0.0),
"Rib": ("Rib", (0.05, 0.05, 0.06), 0.45, 0.85, None, 0.0),
- "FloorPlate": ("FloorPlate", (0.07, 0.08, 0.09), 0.6, 0.7, None, 0.0),
- "Panel": ("Panel", (0.19, 0.25, 0.33), 0.4, 0.6, None, 0.0),
+ "FloorPlate": ("FloorPlate", (0.06, 0.075, 0.09), 0.6, 0.7, None, 0.0),
+ "Panel": ("Panel", (0.10, 0.21, 0.26), 0.42, 0.6, None, 0.0),
+ "PanelBack": ("PanelBack", (0.06, 0.10, 0.13), 0.5, 0.75, None, 0.0),
+ "PanelBolts": ("PanelBolts", (0.35, 0.38, 0.42), 0.3, 0.9, None, 0.0),
"Trim": ("Trim", (0.85, 0.35, 0.08), 0.35, 0.3, None, 0.0),
"Light": ("LightStrip", (0.30, 0.25, 0.18), 0.5, 0.2, (1.0, 0.75, 0.4), 8.0),
}
+_mat_cache = {}
+
+
def mat_for(suffix):
+ """One material per design key, shared across segments (an earlier
+ revision instantiated a duplicate material per part per segment)."""
key = suffix.split(".")[0]
- name, rgb, rough, metal, emit, estr = SEGMENT_MATS[key]
- return make_material(name, rgb, rough, metal, emit, estr)
+ if key not in _mat_cache:
+ name, rgb, rough, metal, emit, estr = SEGMENT_MATS[key]
+ _mat_cache[key] = make_material(name, rgb, rough, metal, emit, estr)
+ return _mat_cache[key]
def build_kit_objects(sc, x_offset, skew=0.0, nudge=0.0, y_jog=0.0, z_jog=0.0):
@@ -725,7 +765,7 @@ Source
# the kit's own lighting language, designed with the asset
for i in range(4):
ld = bpy.data.lights.new(f"Fixture{i}", "AREA")
- ld.energy = 130.0
+ ld.energy = 85.0
ld.size = 3.2
ld.color = (1.0, 0.8, 0.55)
ob = bpy.data.objects.new(f"Fixture{i}", ld)
@@ -740,7 +780,7 @@ Source
Not part of the kit — the corridor's destination for this still."""
frame_mat = make_material("Bulkhead", (0.06, 0.06, 0.07), rough=0.4, metallic=0.85)
door_mat = make_material("BulkheadDoor", (0.30, 0.16, 0.07), rough=0.5,
- metallic=0.3, emit=(1.0, 0.42, 0.12), estr=0.6)
+ metallic=0.3, emit=(1.0, 0.42, 0.12), estr=0.5)
inner_y = WIDTH / 2 - WALL
end_x = TILE * 4
parts = []
@@ -761,7 +801,7 @@ Source
parts.append(door_ob)
# warm spill into the corridor, toward the camera
ld = bpy.data.lights.new("BulkheadSpill", "AREA")
- ld.energy = 140.0
+ ld.energy = 90.0
ld.size = 1.8
ld.color = (1.0, 0.62, 0.3)
ob = bpy.data.objects.new("BulkheadSpill", ld)
@@ -771,7 +811,7 @@ Source
# cool rim from the mouth behind the camera: lifts the near right wall and
# the ceiling edge out of dead black without touching the warm pools
rd = bpy.data.lights.new("MouthRim", "AREA")
- rd.energy = 65.0
+ rd.energy = 45.0
rd.size = 2.0
rd.color = (0.62, 0.76, 1.0)
rob = bpy.data.objects.new("MouthRim", rd)
@@ -790,6 +830,7 @@ Source
sc = bpy.context.scene
all_parts = []
+ aq_hero = None
for i in range(4):
if falsify:
obs = build_kit_objects(
@@ -798,6 +839,8 @@ Source
z_jog=(0.04 if i % 2 else 0.0))
else:
obs = build_kit_objects(sc, TILE * i)
+ if aq_hero is None:
+ aq_hero = obs[1:] # the asset-quality gate scores one segment
all_parts.extend(obs[1:]) # mesh parts only, not the root empties
floor, wall = build_studio(sc)
bulkhead = build_bulkhead(sc)
@@ -845,6 +888,10 @@ Source
)
if fcode:
return fcode
+ aqcode = gallery_asset_quality.check_asset_quality(
+ sc, cam, hero=aq_hero, stage=[floor, wall])
+ if aqcode:
+ return aqcode
bpy.ops.render.render(write_still=True)
if not (os.path.exists(path) and os.path.getsize(path) > 0):
print("ERROR: render produced no file", file=sys.stderr)
diff --git a/examples/gallery.json b/examples/gallery.json
index d0c484e..60a2ae8 100644
--- a/examples/gallery.json
+++ b/examples/gallery.json
@@ -522,7 +522,7 @@
"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.",
+ "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.",
"hero": "docs/gallery/assets/lightmap-uv-channel-hero.webp",
"preview": "examples/lightmap-uv-channel/preview.webp",
"tags": [
diff --git a/examples/gallery_asset_quality.py b/examples/gallery_asset_quality.py
new file mode 100644
index 0000000..8e56fbe
--- /dev/null
+++ b/examples/gallery_asset_quality.py
@@ -0,0 +1,313 @@
+"""Gallery asset-quality measurement — the numeric form of the Layer 1
+"modeled with intent" rule (docs/VISUAL-STYLE.md).
+
+gallery_framing measures where the subject sits in the frame. This module
+measures the subject itself: whether the model reads as designed rather
+than placeholder. Same call pattern: render-path only (the check-only path
+never imports it), printed measured values, non-zero exit on violation.
+
+Floors (calibrated against the gallery's own best- and weakest-modeled
+assets — the calibration table lives in docs/VISUAL-STYLE.md):
+
+- **materials** — distinct materials across the hero's parts. A single
+ flat Principled slot across an entire prop is the gallery's most
+ reliable placeholder predictor.
+- **parts** — named mesh parts the hero is assembled from, excluding
+ default datablock names (Cube, Sphere, Plane, ...). Designed assets are
+ assembled, not dumped.
+- **edge90** — fraction of manifold mesh edges whose dihedral angle is
+ 90° ± EDGE90_DEG. Unbroken right angles everywhere means no bevel, no
+ chamfer, no shading break — manufactured props catch light on treated
+ edges. (Fraction, not count: big props carry honest structural corners.)
+- **compactness** — silhouette perimeter²/area from an alpha matte of the
+ hero (the same EEVEE matte approach gallery_framing uses for fill).
+ Scale-invariant: a rectangle scores ~16–20, a circle ~12.6, a prop with
+ fixtures, cutouts, and protrusions scores higher.
+
+The floors are a cheap filter, not the standard — see the Goodhart note in
+docs/VISUAL-STYLE.md. Bolting meaningless greebles onto a box satisfies
+every number here and still fails the asset sheet, which is the deciding
+gate.
+
+Sharing mechanism matches gallery_framing: consumers add the examples
+directory to sys.path from their own __file__ and import this module. The
+check-only path of every example stays free of it, so smoke runtimes are
+unaffected.
+"""
+import math
+import os
+import sys
+import tempfile
+
+import bpy
+import bmesh
+
+EXIT_ASSET_QUALITY = 11
+
+# Calibrated against reference assets (collision-hull-proxy,
+# custom-normals-shade, vertex-weight-limit, lod-decimate-chain) and the
+# gallery's weakest; the measurement table lives in docs/VISUAL-STYLE.md.
+MATERIALS_MIN = 2 # for heroes with >= 2 parts (see scope note below)
+DOMINANT_MAT_MAX = 0.75 # dominant material's share of hero parts (parts >= 2)
+EDGE90_DEG = 2.0 # window around a right angle, degrees
+EDGE90_MAX_FRAC = 0.75 # gear teeth peak at 0.667; raw boxes score 1.0
+COMPACTNESS_MIN = None # DROPPED as a gate floor: soccer-ball 10.0,
+ # bmesh-gear 18.3, turntable 19.1 are genuinely
+ # good low-scorers. Printed as information only.
+PARTS_MIN = None # DROPPED: vertex-weight-limit's mech arm is a
+ # single skinned mesh and is reference-quality.
+ # Naming (no default names) is kept instead.
+
+MATTE_WIDTH = 320
+ALPHA_THRESHOLD = 0.5
+
+DEFAULT_NAMES = {
+ "cube", "sphere", "uvsphere", "plane", "cylinder", "cone", "torus",
+ "grid", "monkey", "suzanne", "icosphere", "circle", "beziercurve",
+}
+
+
+def _eevee_id():
+ return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def _as_list(objs):
+ if objs is None:
+ return []
+ if isinstance(objs, bpy.types.Object):
+ return [objs]
+ return [ob for ob in objs if ob is not None]
+
+
+def _hero_meshes(hero):
+ meshes = []
+ for ob in hero:
+ if ob.type == "MESH" and ob.data is not None:
+ meshes.append((ob.name, ob.data))
+ return meshes
+
+
+def measure_parts(hero):
+ """(part count, offending default-ish names)."""
+ meshes = _hero_meshes(hero)
+ bad = [name for name, _ in meshes
+ if name.lower().split(".")[0] in DEFAULT_NAMES]
+ return len(meshes), bad
+
+
+def measure_materials(hero):
+ """(distinct materials across all hero slots, names, dominant share,
+ dominant name). Distinct counts every slot; the dominant share counts
+ the first slot per part — the flat-single-material placeholder signal
+ on assembled assets."""
+ from collections import Counter
+ distinct = set()
+ primary = Counter()
+ for _, me in _hero_meshes(hero):
+ slots = [m for m in me.materials if m is not None]
+ for slot in slots:
+ distinct.add(slot.name)
+ primary[slots[0].name if slots else ""] += 1
+ total = sum(primary.values()) or 1
+ dominant_name, dominant_n = primary.most_common(1)[0]
+ return len(distinct), sorted(distinct), dominant_n / total, dominant_name
+
+
+def measure_edge90(hero):
+ """Fraction of two-face edges with dihedral angle in 90 ± EDGE90_DEG.
+
+ Boundary edges (open kit ends, sheet rims) are excluded — they have no
+ dihedral to treat. Angles are face-normal angles on manifold edges.
+ """
+ total = 0
+ right = 0
+ for _, me in _hero_meshes(hero):
+ bm = bmesh.new()
+ try:
+ bm.from_mesh(me)
+ for e in bm.edges:
+ if len(e.link_faces) != 2:
+ continue
+ total += 1
+ n1 = e.link_faces[0].normal
+ n2 = e.link_faces[1].normal
+ ang = math.degrees(n1.angle(n2))
+ if abs(ang - 90.0) <= EDGE90_DEG:
+ right += 1
+ finally:
+ bm.free()
+ return (right / total) if total else 0.0, right, total
+
+
+def _matte_render(scene, camera, hide, path, width, height):
+ """One small EEVEE alpha-matte render; caller-hidden state restored.
+ Mirrors gallery_framing._matte_render's approach (stage hidden,
+ film_transparent, low samples)."""
+ rd = scene.render
+ saved = {
+ "engine": rd.engine,
+ "res": (rd.resolution_x, rd.resolution_y, rd.resolution_percentage),
+ "film": rd.film_transparent,
+ "filepath": rd.filepath,
+ "fmt": rd.image_settings.file_format,
+ "cmode": rd.image_settings.color_mode,
+ "cam": scene.camera,
+ }
+ touched = []
+ try:
+ try:
+ saved["samples"] = scene.eevee.taa_render_samples
+ except AttributeError:
+ saved["samples"] = None
+ rd.engine = _eevee_id()
+ rd.resolution_x, rd.resolution_y, rd.resolution_percentage = width, height, 100
+ rd.film_transparent = True
+ rd.image_settings.file_format = "PNG"
+ rd.image_settings.color_mode = "RGBA"
+ rd.filepath = path
+ scene.camera = camera
+ for ob in hide:
+ if not ob.hide_render:
+ touched.append(ob)
+ ob.hide_render = True
+ if saved["samples"] is not None:
+ scene.eevee.taa_render_samples = 8
+ bpy.ops.render.render(write_still=True, scene=scene.name)
+ finally:
+ for ob in touched:
+ ob.hide_render = False
+ rd.engine = saved["engine"]
+ rd.resolution_x, rd.resolution_y, rd.resolution_percentage = saved["res"]
+ rd.film_transparent = saved["film"]
+ rd.image_settings.file_format = saved["fmt"]
+ rd.image_settings.color_mode = saved["cmode"]
+ rd.filepath = saved["filepath"]
+ scene.camera = saved["cam"]
+ if saved["samples"] is not None:
+ scene.eevee.taa_render_samples = saved["samples"]
+
+
+def measure_compactness(scene, camera, hero, stage=()):
+ """Silhouette perimeter²/area from an alpha matte of the hero alone.
+
+ Scale- and framing-invariant: the same shape scores the same fill or
+ far. Rectangle ~16-20, circle ~12.6, fixtures/cutouts raise it.
+ """
+ render_types = {"MESH", "CURVE", "SURFACE", "FONT", "META", "VOLUME",
+ "GPENCIL", "GREASEPENCIL"}
+ stage_set = set(_as_list(stage))
+ hero_set = set(_as_list(hero))
+ hide = [ob for ob in scene.objects
+ if ob.type in render_types and ob not in hero_set] + [
+ ob for ob in stage_set if ob.type in render_types]
+ hide = list({id(ob): ob for ob in hide}.values())
+
+ rd = scene.render
+ aspect_h = max(1, round(MATTE_WIDTH * rd.resolution_y / max(1, rd.resolution_x)))
+ fd, tmp = tempfile.mkstemp(suffix=".png", prefix="aq_matte_")
+ os.close(fd)
+ try:
+ _matte_render(scene, camera, hide, tmp, MATTE_WIDTH, aspect_h)
+ img = bpy.data.images.load(tmp, check_existing=False)
+ try:
+ w, h = img.size
+ px = img.pixels[:]
+ finally:
+ bpy.data.images.remove(img)
+ finally:
+ if os.path.exists(tmp):
+ os.remove(tmp)
+
+ mask = [[px[(y * w + x) * 4 + 3] > ALPHA_THRESHOLD for x in range(w)]
+ for y in range(h)]
+ area = 0
+ perim = 0
+ for y in range(h):
+ for x in range(w):
+ if not mask[y][x]:
+ continue
+ area += 1
+ if (x == 0 or not mask[y][x - 1]) or (x == w - 1 or not mask[y][x + 1]) \
+ or (y == 0 or not mask[y - 1][x]) or (y == h - 1 or not mask[y + 1][x]):
+ perim += 1
+ if area == 0:
+ return 0.0, 0, 0
+ return perim * perim / area, perim, area
+
+
+class AssetQualityResult:
+ def __init__(self):
+ self.parts = 0
+ self.bad_names = []
+ self.materials = 0
+ self.mat_names = []
+ self.dominant_share = 0.0
+ self.dominant_name = ""
+ self.edge90 = 0.0
+ self.edge_right = 0
+ self.edge_total = 0
+ self.compactness = 0.0
+ self.perimeter = 0
+ self.area = 0
+
+ def report(self):
+ def mark(ok):
+ return "ok" if ok else "FAIL"
+ name_ok = not self.bad_names
+ mat_ok = self.parts < 2 or (self.materials >= MATERIALS_MIN
+ and self.dominant_share <= DOMINANT_MAT_MAX)
+ edge_ok = self.edge90 <= EDGE90_MAX_FRAC
+ return "\n".join([
+ f"aq_naming default_names={self.bad_names or 'none'} {mark(name_ok)}",
+ f"aq_materials n={self.materials} floor={MATERIALS_MIN} "
+ f"dominant={self.dominant_name}@{self.dominant_share:.2f} "
+ f"ceiling={DOMINANT_MAT_MAX} (applies at parts>=2, n={self.parts}) "
+ f"{mark(mat_ok)}",
+ f"aq_edge90 frac={self.edge90:.3f} ({self.edge_right}/{self.edge_total}) "
+ f"ceiling={EDGE90_MAX_FRAC} {mark(edge_ok)}",
+ f"aq_compactness {self.compactness:.1f} (perim={self.perimeter} "
+ f"area={self.area}) informational-only",
+ ])
+
+
+def measure_asset_quality(scene, camera, hero, stage=()):
+ """Measure the floors for one staged hero. Returns AssetQualityResult."""
+ hero = _as_list(hero)
+ res = AssetQualityResult()
+ res.parts, res.bad_names = measure_parts(hero)
+ (res.materials, res.mat_names,
+ res.dominant_share, res.dominant_name) = measure_materials(hero)
+ res.edge90, res.edge_right, res.edge_total = measure_edge90(hero)
+ res.compactness, res.perimeter, res.area = measure_compactness(
+ scene, camera, hero, stage=stage)
+ return res
+
+
+def check_asset_quality(scene, camera, hero, stage=(), *,
+ materials_min=MATERIALS_MIN, dominant_max=DOMINANT_MAT_MAX,
+ edge90_max=EDGE90_MAX_FRAC):
+ """Measure, print, gate: 0 pass, EXIT_ASSET_QUALITY (11) on violation.
+
+ Render path only — never call from an example's check-only path.
+ Floors: no default datablock names; for assembled heroes (parts >= 2)
+ at least two materials with the dominant share under the ceiling;
+ right-angle edge fraction under the ceiling. Compactness is measured
+ and printed but never gated (dropped: fails genuinely good simple
+ subjects — see module docstring and docs/VISUAL-STYLE.md).
+ """
+ res = measure_asset_quality(scene, camera, hero, stage=stage)
+ print(res.report())
+ failures = []
+ if res.bad_names:
+ failures.append(f"default datablock names {res.bad_names}")
+ if res.parts >= 2 and res.materials < materials_min:
+ failures.append(f"materials {res.materials}<{materials_min} "
+ f"on a {res.parts}-part hero")
+ if res.edge90 > edge90_max:
+ failures.append(f"edge90 {res.edge90:.3f}>{edge90_max}")
+ if failures:
+ print("ERROR: asset-quality violation — " + "; ".join(failures),
+ file=sys.stderr)
+ return EXIT_ASSET_QUALITY
+ print("aq_ok")
+ return 0
diff --git a/examples/lightmap-uv-channel/README.md b/examples/lightmap-uv-channel/README.md
index 61b8654..66314ee 100644
--- a/examples/lightmap-uv-channel/README.md
+++ b/examples/lightmap-uv-channel/README.md
@@ -1,10 +1,15 @@
# 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
+lighting, on an asset built to be reused: a market cart assembled from
+21 named, watertight parts — a plank-grooved bed with side rails, corner
+brackets, and fasteners, real wheel assemblies (disc + hub + iron band +
+bolt ring) on a capped axle, four posts, and a ribbed canvas canopy.
+Ground-level pivot at `z=0`, identity transforms by construction, `Cart.*`
+datablocks. (An earlier revision shipped a soft uniform-tan cart with
+lumpy polygon wheels and was remodeled under the asset-quality gate;
+its wheel material was also silently never applied — a name-key lookup
+bug the exact per-part mapping now replaces.) UV0 is the texture channel (deterministic dominant-axis box
projection per face); UVLight is the baked-lighting atlas
(`smart_project` → `lightmap_pack`).
@@ -60,15 +65,19 @@ layers by name and re-asserts flags):
[`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
+and 5.1.2 (21 parts, 3680 islands, drift 0, overlap 0, min island distance
0.00401). The UV *layout* is packer-version-dependent by design; the
contracts are layout-independent invariants.
-**Render as proof:** the cart beside its 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).
+**Render as proof:** the cart beside its enlarged UV1 atlas board — the
+Bed's packed lightmap built from live UV data, so a change in the atlas
+moves the board geometry. The falsification variant (`--falsify`)
+translates the second-largest island onto the largest: a big emissive-red
+island visibly stacked over the atlas (15 SAT hits in the check probe).
+The render path also gates the asset itself through
+`examples/gallery_asset_quality.py` (naming, material variation, edge
+treatment — exit 11): 21 named parts, 5 materials, right-angle share
+0.069.
## Run
diff --git a/examples/lightmap-uv-channel/lightmap_uv_channel.py b/examples/lightmap-uv-channel/lightmap_uv_channel.py
index d47404a..9a3bb6a 100644
--- a/examples/lightmap-uv-channel/lightmap_uv_channel.py
+++ b/examples/lightmap-uv-channel/lightmap_uv_channel.py
@@ -45,6 +45,7 @@
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree
import gallery_framing
+import gallery_asset_quality
TOL = 1e-6 # UV0 preservation tolerance
BOUNDS_TOL = 1e-5 # UV1 unit-square slack
@@ -88,11 +89,11 @@ def _box(name, dims, center, bevel=0.0):
return me
-def _wheel(name, radius, width, center):
+def _wheel_disc(name, radius, width, center):
me = bpy.data.meshes.new(name)
bm = bmesh.new()
try:
- bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=20,
+ bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=24,
radius1=radius, radius2=radius, depth=width)
# cone axis is Z; the wheel rolls around Y -> rotate verts, applied in data
for v in bm.verts:
@@ -106,37 +107,135 @@ def _wheel(name, radius, width, center):
return me
+def _ring(name, r_out, r_in, width, center, segments=24):
+ """Flat annulus (iron tire band) spun from a 4-vert profile, watertight."""
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ hw = width / 2
+ prof = [(r_in, -hw), (r_out, -hw), (r_out, hw), (r_in, hw)]
+ vs = [bm.verts.new((r, 0.0, z)) for r, z in prof]
+ es = [bm.edges.new((vs[i], vs[(i + 1) % 4])) for i in range(4)]
+ bmesh.ops.spin(bm, geom=vs + es, cent=(0.0, 0.0, 0.0),
+ axis=(0.0, 0.0, 1.0), dvec=(0.0, 0.0, 0.0),
+ angle=2 * math.pi, steps=segments, use_merge=True)
+ for v in bm.verts:
+ x, y, z = v.co.x, v.co.y, v.co.z
+ v.co = (x + center[0], z + center[1], y + center[2])
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ return me
+
+
+def _bolt_ring(name, count, ring_r, bolt_r, center):
+ """count bolt heads on a ring around Y — one mesh, disconnected islands."""
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ for i in range(count):
+ a = 2 * math.pi * i / count
+ cx, cz = center[0] + ring_r * math.cos(a), center[2] + ring_r * math.sin(a)
+ before_v = set(bm.verts)
+ bmesh.ops.create_uvsphere(bm, u_segments=8, v_segments=6, radius=bolt_r)
+ for v in set(bm.verts) - before_v:
+ v.co.x += cx
+ v.co.y += center[1]
+ v.co.z += cz
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ return me
+
+
+def _arc_band(bm, xc, width, radius, thickness, z0, segments):
+ """One arched sheet spanning x in [xc, xc+width], solidified with rims."""
+ sweep = math.radians(120.0)
+ a0 = math.radians(90.0) - sweep / 2
+ outer, inner = [], []
+ for i in range(segments + 1):
+ a = a0 + sweep * i / segments
+ ca, sa = math.cos(a), math.sin(a)
+ outer.append(bm.verts.new((xc, radius * ca, z0 + radius * sa)))
+ inner.append(bm.verts.new((xc, (radius - thickness) * ca,
+ z0 + (radius - thickness) * sa)))
+ def band(ring_o, ring_i):
+ for i in range(segments):
+ a, b = i, i + 1
+ bm.faces.new((ring_o[a], ring_o[b], ring_i[b], ring_i[a]))
+ outer_x2 = [bm.verts.new((v.co.x + width, v.co.y, v.co.z)) for v in outer]
+ inner_x2 = [bm.verts.new((v.co.x + width, v.co.y, v.co.z)) for v in inner]
+ band(outer, inner) # underside (thickness face)
+ band(inner_x2, outer_x2) # top face
+ for i in range(segments): # the two long rims
+ a, b = i, i + 1
+ bm.faces.new((outer[a], outer_x2[a], outer_x2[b], outer[b]))
+ bm.faces.new((inner[a], inner[b], inner_x2[b], inner_x2[a]))
+ bm.faces.new((outer[0], inner[0], inner_x2[0], outer_x2[0]))
+ bm.faces.new((outer[-1], outer_x2[-1], inner_x2[-1], inner[-1]))
+
+
def _canopy(name, width, radius, thickness, z0, segments=14):
"""Arched canvas sheet: an arc band solidified with a rim, watertight."""
me = bpy.data.meshes.new(name)
bm = bmesh.new()
try:
- 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]))
+ _arc_band(bm, -width / 2, width, radius, thickness, z0, segments)
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ return me
+
+
+def _canopy_ribs(name, positions, width, radius, thickness, z0, segments=14):
+ """Thin wooden ribs following the canopy arc, one mesh, N islands."""
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ for px in positions:
+ _arc_band(bm, px - width / 2, width, radius, thickness, z0, segments)
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ return me
+
+
+def _bolts(name, positions, radius):
+ """Bolt head spheres at positions — one mesh, disconnected islands."""
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ for px, py, pz in positions:
+ before_v = set(bm.verts)
+ bmesh.ops.create_uvsphere(bm, u_segments=8, v_segments=6, radius=radius)
+ for v in set(bm.verts) - before_v:
+ v.co.x += px
+ v.co.y += py
+ v.co.z += pz
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ return me
+
+
+def _multi_box(name, specs):
+ """Several beveled boxes as one mesh (disconnected islands, each
+ watertight). specs: (dims, center, bevel)."""
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ for dims, center, bevel in specs:
+ before_e = set(bm.edges)
+ before_v = set(bm.verts)
+ bmesh.ops.create_cube(bm, size=1.0)
+ new_edges = list(set(bm.edges) - before_e)
+ for v in set(bm.verts) - before_v:
+ v.co.x = v.co.x * dims[0] + center[0]
+ v.co.y = v.co.y * dims[1] + center[1]
+ v.co.z = v.co.z * dims[2] + center[2]
+ if bevel > 0.0:
+ bmesh.ops.bevel(bm, geom=new_edges, offset=bevel, segments=2,
+ profile=0.5, affect="EDGES", clamp_overlap=True)
bm.to_mesh(me)
finally:
bm.free()
@@ -144,16 +243,43 @@ def band(off_x, ring_o, ring_i):
def build_cart_meshes():
+ """The cart as an assembly of named, watertight parts: real wheel
+ assemblies (disc + hub + iron band + bolt ring), plank-grooved bed with
+ side rails, corner brackets, and fasteners, and a ribbed canvas canopy."""
parts = {}
parts["Bed"] = _box("Cart.Bed", (2.2, 1.2, 0.22), (0.0, 0.0, 0.85), 0.03)
- parts["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))
+ parts["Bed.Rails"] = _multi_box("Cart.Bed.Rails", [
+ ((2.2, 0.06, 0.18), (0.0, 0.57, 1.02), 0.015),
+ ((2.2, 0.06, 0.18), (0.0, -0.57, 1.02), 0.015)])
+ parts["Bed.Grooves"] = _multi_box("Cart.Bed.Grooves", [
+ ((0.035, 1.14, 0.015), (x, 0.0, 0.9575), 0.0)
+ for x in (-0.66, -0.22, 0.22, 0.66)])
+ parts["Bed.Brackets"] = _multi_box("Cart.Bed.Brackets", [
+ ((0.03, 0.13, 0.26), (sx * 1.105, sy * 0.535, 0.85), 0.008)
+ for sx in (-1, 1) for sy in (-1, 1)])
+ parts["Bed.Bolts"] = _bolts("Cart.Bed.Bolts", [
+ (sx * 1.125, sy * 0.535, z) for sx in (-1, 1) for sy in (-1, 1)
+ for z in (0.78, 0.92)], 0.018)
+ parts["Axle"] = _wheel_disc("Cart.Axle", 0.05, 1.56, (0.55, 0.0, 0.5))
+ parts["Axle.Caps"] = _multi_box("Cart.Axle.Caps", [
+ ((0.14, 0.05, 0.14), (0.55, sy * 0.80, 0.5), 0.02) for sy in (-1, 1)])
+ for tag, sy in (("L", 1.0), ("R", -1.0)):
+ y = sy * 0.68
+ parts[f"Wheel.{tag}.Disc"] = _wheel_disc(
+ f"Cart.Wheel.{tag}.Disc", 0.45, 0.10, (0.55, y, 0.45))
+ parts[f"Wheel.{tag}.Hub"] = _wheel_disc(
+ f"Cart.Wheel.{tag}.Hub", 0.14, 0.16, (0.55, y, 0.45))
+ parts[f"Wheel.{tag}.Band"] = _ring(
+ f"Cart.Wheel.{tag}.Band", 0.47, 0.435, 0.10, (0.55, y, 0.45))
+ parts[f"Wheel.{tag}.Bolts"] = _bolt_ring(
+ f"Cart.Wheel.{tag}.Bolts", 6, 0.09, 0.02, (0.55, y + sy * 0.085, 0.45))
for tag, (px, py) in (("FL", (0.95, 0.48)), ("FR", (0.95, -0.48)),
("RL", (-0.95, 0.48)), ("RR", (-0.95, -0.48))):
parts[f"Post.{tag}"] = _box(f"Cart.Post.{tag}", (0.09, 0.09, 1.35),
(px, py, 1.55), 0.015)
parts["Canopy"] = _canopy("Cart.Canopy", 2.3, 0.85, 0.04, 1.9)
+ parts["Canopy.Ribs"] = _canopy_ribs("Cart.Canopy.Ribs", (-0.75, 0.0, 0.75),
+ 0.07, 0.875, 0.035, 1.9)
return parts
@@ -512,18 +638,30 @@ def render_still(path, engine, falsify=False):
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)
+ wood = make_material("Wood", (0.38, 0.22, 0.10), rough=0.55)
darkwood = make_material("DarkWood", (0.16, 0.10, 0.05), rough=0.7)
- iron = make_material("Iron", (0.18, 0.18, 0.20), rough=0.35, metallic=0.9)
+ iron = make_material("Iron", (0.26, 0.26, 0.30), rough=0.3, metallic=0.9)
canvas = make_material("Canvas", (0.66, 0.56, 0.40), rough=0.9)
+ groovemat = make_material("Groove", (0.05, 0.04, 0.03), rough=0.9)
+
+ mat_by_part = {
+ "Bed": wood, "Bed.Rails": wood, "Bed.Grooves": groovemat,
+ "Bed.Brackets": iron, "Bed.Bolts": iron,
+ "Axle": iron, "Axle.Caps": iron,
+ "Canopy": canvas, "Canopy.Ribs": darkwood,
+ }
+ for tag in ("L", "R"):
+ mat_by_part[f"Wheel.{tag}.Disc"] = darkwood
+ mat_by_part[f"Wheel.{tag}.Hub"] = darkwood
+ mat_by_part[f"Wheel.{tag}.Band"] = iron
+ mat_by_part[f"Wheel.{tag}.Bolts"] = iron
+ for tag in ("FL", "FR", "RL", "RR"):
+ mat_by_part[f"Post.{tag}"] = darkwood
- 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.materials.append(mat_by_part[suffix])
me.uv_layers.new(name=LAYER0)
me.uv_layers.new(name=LAYER1)
write_uv0_box_projection(me)
@@ -553,10 +691,10 @@ def render_still(path, engine, falsify=False):
# 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 = _box("Atlas.Board", (0.08, 2.2, 2.0), (0.0, 0.0, 0.0), 0.02)
board.materials.append(board_mat)
board_ob = bpy.data.objects.new("Atlas.Board", board)
- board_ob.location = (1.75, 0.55, 1.0)
+ board_ob.location = (2.05, 0.55, 1.15)
sc.collection.objects.link(board_ob)
bed = meshes["Bed"]
@@ -571,7 +709,7 @@ def render_still(path, engine, falsify=False):
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))
+ vs = [bm.verts.new((0.0, (0.5 - uv[0]) * 1.9, (uv[1] - 0.5) * 1.9))
for uv in pts]
bm.faces.new(vs)
bm.to_mesh(me)
@@ -582,7 +720,7 @@ def render_still(path, engine, falsify=False):
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)
+ ob.location = (2.05 + 0.045 + (0.004 if fi in dragged else 0.0), 0.55, 1.15)
sc.collection.objects.link(ob)
iso_obs.append(ob)
@@ -591,10 +729,10 @@ def render_still(path, engine, falsify=False):
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)
+ cam.location = (5.9, -7.2, 2.8)
sc.collection.objects.link(cam)
aim = bpy.data.objects.new("Aim", None)
- aim.location = (0.75, 0.1, 1.08)
+ aim.location = (0.85, 0.1, 1.05)
sc.collection.objects.link(aim)
tr = cam.constraints.new("TRACK_TO")
tr.target = aim
@@ -625,6 +763,10 @@ def render_still(path, engine, falsify=False):
)
if fcode:
return fcode
+ aqcode = gallery_asset_quality.check_asset_quality(
+ sc, cam, hero=hero, stage=[floor, wall])
+ if aqcode:
+ return aqcode
bpy.ops.render.render(write_still=True)
if not (os.path.exists(path) and os.path.getsize(path) > 0):
print("ERROR: render produced no file", file=sys.stderr)
diff --git a/examples/lightmap-uv-channel/preview.webp b/examples/lightmap-uv-channel/preview.webp
index 0902233..2365629 100644
Binary files a/examples/lightmap-uv-channel/preview.webp and b/examples/lightmap-uv-channel/preview.webp differ
diff --git a/examples/modular-kit-snap/README.md b/examples/modular-kit-snap/README.md
index 859c681..8f0a7ba 100644
--- a/examples/modular-kit-snap/README.md
+++ b/examples/modular-kit-snap/README.md
@@ -8,8 +8,9 @@ positions with no gap and no overlap. Interior geometry is free-form
deliberate authoring pass, and the check is what catches you skipping it.
**The asset, for reuse:** a 4 × 3 × 3 m corridor segment (hollow-rectangle
-shell plus twelve detail parts — frame rib, walkway plate, wall panels,
-trim rails, emissive light strips). Origin at the connection pivot
+shell plus twenty detail parts — frame rib, walkway plate, wall panel
+assemblies (backing plate + inset panel + four bolt heads each), trim
+rails, emissive light strips). Origin at the connection pivot
(floor-center of the start edge, so instance *n* places at `x = n·4`),
identity transforms by construction, datablocks under
`Kit.CorridorSeg.*`, manifold everywhere except the two intentional open
@@ -57,7 +58,15 @@ and 5.1.2 — same counts, same zero measured deviations.
falsification variant (`--falsify`) accumulates a 120 mm gap, 50 mm lateral
jogs, and 40 mm floor steps at each joint: floor plates split with dark
seams and the trim rails visibly break. The check measures the same failure
-class at 3 mm; the render exaggerates it to read at frame scale.
+class at 3 mm; the render exaggerates it to read at frame scale. The render
+path also gates the asset through `examples/gallery_asset_quality.py`
+(naming, material variation, edge treatment — exit 11). An earlier revision
+shipped flat grey wall panels and was remodeled under that gate: each panel
+is now an assembly (backing plate + inset panel + four bolt heads), the
+palette shifted teal-slate so the card does not twin with
+`lightmap-uv-channel`'s warm cart, and mean luminance was brought into the
+calibration range (82.7 → 66.1, ceiling 77.7) — no luminance deviation
+needed.
**Framing deviation:** the still is an interior corridor run — the envelope
surrounds the camera on five sides and the tiling joints are the proof, so
diff --git a/examples/modular-kit-snap/modular_kit_snap.py b/examples/modular-kit-snap/modular_kit_snap.py
index b3a2127..d3bd73e 100644
--- a/examples/modular-kit-snap/modular_kit_snap.py
+++ b/examples/modular-kit-snap/modular_kit_snap.py
@@ -46,6 +46,7 @@
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree
import gallery_framing
+import gallery_asset_quality
TILE = 4.0 # tile length along X, metres
WIDTH = 3.0 # corridor width along Y ( +/- 1.5 )
@@ -141,11 +142,6 @@ def detail_layout():
("Rib.Beam", (0.24, 2 * inner_y - 0.30, 0.34), (0.18, 0.0, HEIGHT - WALL - 0.17), 0.02),
# walkway floor plate
("FloorPlate", (3.4, 1.8, 0.05), (TILE / 2, 0.0, WALL + 0.025), 0.015),
- # wall panels, two per side, recessed-look slabs on the bore face
- ("Panel.L.1", (1.15, 0.05, 1.15), (1.2, inner_y - 0.025, 1.55), 0.02),
- ("Panel.L.2", (1.15, 0.05, 1.15), (2.8, inner_y - 0.025, 1.55), 0.02),
- ("Panel.R.1", (1.15, 0.05, 1.15), (1.2, -(inner_y - 0.025), 1.55), 0.02),
- ("Panel.R.2", (1.15, 0.05, 1.15), (2.8, -(inner_y - 0.025), 1.55), 0.02),
# orange trim rails — the lines that jog visibly when a joint steps
("Trim.L", (3.7, 0.06, 0.09), (TILE / 2, inner_y - 0.03, 0.78), 0.015),
("Trim.R", (3.7, 0.06, 0.09), (TILE / 2, -(inner_y - 0.03), 0.78), 0.015),
@@ -153,14 +149,49 @@ def detail_layout():
("Light.L", (3.0, 0.05, 0.07), (TILE / 2, inner_y - 0.025, HEIGHT - WALL - 0.09), 0.01),
("Light.R", (3.0, 0.05, 0.07), (TILE / 2, -(inner_y - 0.025), HEIGHT - WALL - 0.09), 0.01),
]
+ # wall panel assemblies: backing plate proud of the bore face, a lighter
+ # inset panel — depth, not flat grey rectangles. Bolt heads are spheres,
+ # built by _bolts_mesh in build_kit_meshes rather than as boxes.
+ for side, sy in (("L", 1.0), ("R", -1.0)):
+ for idx, px in ((1, 1.2), (2, 2.8)):
+ parts.append((f"PanelBack.{side}.{idx}", (1.35, 0.04, 1.35),
+ (px, sy * (inner_y - 0.02), 1.55), 0.015))
+ parts.append((f"Panel.{side}.{idx}", (1.15, 0.05, 1.15),
+ (px, sy * (inner_y - 0.045), 1.55), 0.02))
return parts
+def _bolts_mesh(name, positions, radius):
+ """Bolt head spheres at positions — one mesh, disconnected islands."""
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ for px, py, pz in positions:
+ before_v = set(bm.verts)
+ bmesh.ops.create_uvsphere(bm, u_segments=8, v_segments=6, radius=radius)
+ for v in set(bm.verts) - before_v:
+ v.co.x += px
+ v.co.y += py
+ v.co.z += pz
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ return me
+
+
def build_kit_meshes(skew=0.0, nudge=0.0):
"""All kit meshes, keyed by part suffix. Skew/nudge touch the shell only."""
meshes = {"Shell": build_shell("Kit.CorridorSeg.Shell", skew, nudge)}
for suffix, dims, center, bevel in detail_layout():
meshes[suffix] = build_detail(f"Kit.CorridorSeg.{suffix}", dims, center, bevel)
+ # panel fixings: four bolt heads per panel, proud of the inset panel
+ inner_y = WIDTH / 2 - WALL
+ for side, sy in (("L", 1.0), ("R", -1.0)):
+ for idx, px in ((1, 1.2), (2, 2.8)):
+ positions = [(px + dx, sy * (inner_y - 0.07), 1.55 + dz)
+ for dx in (-0.5, 0.5) for dz in (-0.5, 0.5)]
+ meshes[f"PanelBolts.{side}.{idx}"] = _bolts_mesh(
+ f"Kit.CorridorSeg.PanelBolts.{side}.{idx}", positions, 0.022)
return meshes
@@ -368,19 +399,28 @@ def make_material(name, rgb, rough=0.45, metallic=0.6, emit=None, estr=0.0):
SEGMENT_MATS = {
- "Shell": ("Shell", (0.10, 0.11, 0.13), 0.5, 0.8, None, 0.0),
+ "Shell": ("Shell", (0.075, 0.095, 0.115), 0.5, 0.8, None, 0.0),
"Rib": ("Rib", (0.05, 0.05, 0.06), 0.45, 0.85, None, 0.0),
- "FloorPlate": ("FloorPlate", (0.07, 0.08, 0.09), 0.6, 0.7, None, 0.0),
- "Panel": ("Panel", (0.19, 0.25, 0.33), 0.4, 0.6, None, 0.0),
+ "FloorPlate": ("FloorPlate", (0.06, 0.075, 0.09), 0.6, 0.7, None, 0.0),
+ "Panel": ("Panel", (0.10, 0.21, 0.26), 0.42, 0.6, None, 0.0),
+ "PanelBack": ("PanelBack", (0.06, 0.10, 0.13), 0.5, 0.75, None, 0.0),
+ "PanelBolts": ("PanelBolts", (0.35, 0.38, 0.42), 0.3, 0.9, None, 0.0),
"Trim": ("Trim", (0.85, 0.35, 0.08), 0.35, 0.3, None, 0.0),
"Light": ("LightStrip", (0.30, 0.25, 0.18), 0.5, 0.2, (1.0, 0.75, 0.4), 8.0),
}
+_mat_cache = {}
+
+
def mat_for(suffix):
+ """One material per design key, shared across segments (an earlier
+ revision instantiated a duplicate material per part per segment)."""
key = suffix.split(".")[0]
- name, rgb, rough, metal, emit, estr = SEGMENT_MATS[key]
- return make_material(name, rgb, rough, metal, emit, estr)
+ if key not in _mat_cache:
+ name, rgb, rough, metal, emit, estr = SEGMENT_MATS[key]
+ _mat_cache[key] = make_material(name, rgb, rough, metal, emit, estr)
+ return _mat_cache[key]
def build_kit_objects(sc, x_offset, skew=0.0, nudge=0.0, y_jog=0.0, z_jog=0.0):
@@ -443,7 +483,7 @@ def light(name, loc, energy, size, col, rot):
# the kit's own lighting language, designed with the asset
for i in range(4):
ld = bpy.data.lights.new(f"Fixture{i}", "AREA")
- ld.energy = 130.0
+ ld.energy = 85.0
ld.size = 3.2
ld.color = (1.0, 0.8, 0.55)
ob = bpy.data.objects.new(f"Fixture{i}", ld)
@@ -458,7 +498,7 @@ def build_bulkhead(sc):
Not part of the kit — the corridor's destination for this still."""
frame_mat = make_material("Bulkhead", (0.06, 0.06, 0.07), rough=0.4, metallic=0.85)
door_mat = make_material("BulkheadDoor", (0.30, 0.16, 0.07), rough=0.5,
- metallic=0.3, emit=(1.0, 0.42, 0.12), estr=0.6)
+ metallic=0.3, emit=(1.0, 0.42, 0.12), estr=0.5)
inner_y = WIDTH / 2 - WALL
end_x = TILE * 4
parts = []
@@ -479,7 +519,7 @@ def build_bulkhead(sc):
parts.append(door_ob)
# warm spill into the corridor, toward the camera
ld = bpy.data.lights.new("BulkheadSpill", "AREA")
- ld.energy = 140.0
+ ld.energy = 90.0
ld.size = 1.8
ld.color = (1.0, 0.62, 0.3)
ob = bpy.data.objects.new("BulkheadSpill", ld)
@@ -489,7 +529,7 @@ def build_bulkhead(sc):
# cool rim from the mouth behind the camera: lifts the near right wall and
# the ceiling edge out of dead black without touching the warm pools
rd = bpy.data.lights.new("MouthRim", "AREA")
- rd.energy = 65.0
+ rd.energy = 45.0
rd.size = 2.0
rd.color = (0.62, 0.76, 1.0)
rob = bpy.data.objects.new("MouthRim", rd)
@@ -508,6 +548,7 @@ def render_still(path, engine, falsify=False):
sc = bpy.context.scene
all_parts = []
+ aq_hero = None
for i in range(4):
if falsify:
obs = build_kit_objects(
@@ -516,6 +557,8 @@ def render_still(path, engine, falsify=False):
z_jog=(0.04 if i % 2 else 0.0))
else:
obs = build_kit_objects(sc, TILE * i)
+ if aq_hero is None:
+ aq_hero = obs[1:] # the asset-quality gate scores one segment
all_parts.extend(obs[1:]) # mesh parts only, not the root empties
floor, wall = build_studio(sc)
bulkhead = build_bulkhead(sc)
@@ -563,6 +606,10 @@ def render_still(path, engine, falsify=False):
)
if fcode:
return fcode
+ aqcode = gallery_asset_quality.check_asset_quality(
+ sc, cam, hero=aq_hero, stage=[floor, wall])
+ if aqcode:
+ return aqcode
bpy.ops.render.render(write_still=True)
if not (os.path.exists(path) and os.path.getsize(path) > 0):
print("ERROR: render produced no file", file=sys.stderr)
diff --git a/examples/modular-kit-snap/preview.webp b/examples/modular-kit-snap/preview.webp
index 669e504..e597df3 100644
Binary files a/examples/modular-kit-snap/preview.webp and b/examples/modular-kit-snap/preview.webp differ