Skip to content

Commit 4a4d25c

Browse files
TMHSDigitalclaude
andcommitted
fix(examples): redesign uv-layer-grid as framed tile panels on studio easels
The two lightbox displays read as a working and a dead monitor, so the flat-teal hazard panel looked broken rather than wrong-UV. Each panel is now a walnut-framed tile on an oak studio easel with a brass placard on the ledge: NO UV LAYER (calc_uvs alone -> flat texel (0,0) teal) and UV LAYER FIRST (pre-create + calc_uvs -> full checker). The face planes, texture and pixel witness are unchanged. The render path now runs the gallery_framing gate (exit 10); the missing-render-file check moves from 10 to 15. Evidence (live runs, local): - .scratch/blender-5.2.1-windows-x64/blender.exe (Blender 5.2.1 LTS): check-only exit 0; --precreate-on-hazard exits 3 ("calc_uvs=True without a pre-existing layer created 1 UV layer(s)"); --output passes the pixel witness: broken rgb=(0.037,0.271,0.371) spread 0.0078, fixed spread 0.7569. - .scratch/blender-4.5.11-windows-x64/blender.exe (Blender 4.5.11 LTS): check-only exit 0; --output exit 0 with identical pixel-witness numbers. - Framing: fill x=0.691 y=0.872, margins l=0.166 r=0.144 b=0.028 t=0.100. - Contact sheet (docs/gallery/contact-sheets/uv-layer-grid-contact-sheet.webp): candidate luma 0.247 / stage 0.200 / warmth -0.255 / sat 0.306, against armature-bend 0.279/0.213/-0.428/0.144, damped-track-aim 0.151/0.145/+0.277/0.231 and bmesh-gear 0.236/0.177/+0.303/0.250. Stage and luma fall inside the set's range. Warmth is negative because the cyan/teal panels dominate the brightest pixels in the lower half; the warm wedge pool shows on the wall at right. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 202d7ca commit 4a4d25c

7 files changed

Lines changed: 131 additions & 73 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ result is watertight (every edge borders exactly two faces).
371371
</tr>
372372
<tr>
373373
<td width="46%" valign="middle">
374-
<a href="examples/uv-layer-grid/"><img src="examples/uv-layer-grid/preview.webp" alt="UV layer grid: two framed lightbox displays on floor trays against a dark studio wall — left one flat teal face (the calc_uvs silent no-op hazard), right a saturated magenta-cyan checker (the pre-create repair), under a warm wall pool" /></a>
374+
<a href="examples/uv-layer-grid/"><img src="examples/uv-layer-grid/preview.webp" alt="UV layer grid: two walnut-framed tile panels on oak studio easels with brass placards — left, NO UV LAYER, one flat teal face (the calc_uvs silent no-op hazard), right, UV LAYER FIRST, a saturated magenta-cyan checker (the pre-create repair), under a warm wall pool" /></a>
375375
</td>
376376
<td valign="middle">
377377

9.98 KB
Loading
4.34 KB
Loading

‎examples/gallery.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@
301301
"name": "uv-layer-grid",
302302
"dir": "examples/uv-layer-grid",
303303
"teaches": "The UV-layer authoring hazard — bmesh.ops.create_grid(..., calc_uvs=True) is a silent no-op unless a UV layer already exists; without one an Image Texture samples texel (0,0) everywhere.",
304-
"alt": "Two lightbox panels: the left one flat teal with no UV layer, the right one a full magenta, cyan and green checker.",
304+
"alt": "Two walnut-framed tile panels on oak easels with brass placards: NO UV LAYER on the left, flat teal, and UV LAYER FIRST on the right, a full magenta, cyan and green checker.",
305305
"witnessesFix": "calc_uvs=True alone leaves 0 UV layers; pre-create + calc_uvs fills loops to the closed-form grid UVs ((x/size+1)/2, (y/size+1)/2) within 1e-6; explicit loop assignment is the calc_uvs-free fallback. The still is self-witnessing: the script re-reads its render and exits non-zero unless the hazard panel is one flat teal and the repaired panel a high-contrast checker.",
306306
"hero": "docs/gallery/assets/uv-layer-grid-hero.webp",
307307
"preview": "examples/uv-layer-grid/preview.webp",

‎examples/uv-layer-grid/README.md‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,16 @@ fill assert identically on Blender 4.5 LTS and 5.1. The only gate in the file
4747
is the EEVEE engine id for the optional render (`BLENDER_EEVEE_NEXT` on 4.x,
4848
`BLENDER_EEVEE` on 5.x).
4949

50-
**Render:** two framed lightbox displays on floor trays (rear kick legs,
51-
status LED) sharing one neon checker image, shot at a slight 3/4. Left is the
52-
hazard (no UV layer) — flat teal of texel (0, 0). Right is the repair
53-
(pre-create + `calc_uvs`) — full magenta/cyan checker. If the UV contract
50+
**Render:** two oak studio easels, each holding a walnut-framed tile panel that
51+
shares one neon checker image, with a brass placard on the easel ledge naming the
52+
authoring path. Left, `NO UV LAYER`, is the hazard — `calc_uvs=True` alone, so the
53+
whole panel is the flat teal of texel (0, 0). Right, `UV LAYER FIRST`, is the repair
54+
(pre-create + `calc_uvs`) — the full magenta/cyan checker. If the UV contract
5455
failed, both panels would read the same — and the still is not just an
5556
illustration: the script re-reads its own render and exits non-zero unless
56-
the pixels prove the flat-vs-checker split (measured on 5.1.2: hazard spread
57-
`0.0078`, repair spread `0.7412`; identical on 4.5.11).
57+
the pixels prove the flat-vs-checker split (measured on 5.2.1: hazard spread
58+
`0.0078`, repair spread `0.7569`; identical on 4.5.11). The render path gates
59+
framing through `examples/gallery_framing.py` (exit 10) before writing the still.
5860

5961
## Run
6062

@@ -73,8 +75,8 @@ blender --background --python uv_layer_grid.py -- --output uv.png --engine cycle
7375
## Exit codes
7476

7577
Per-script sequential checks. `9` is a valid check code; there is no rule
76-
against it. This example does not call the gallery framing helper; `10`
77-
is the missing-render-file check.
78+
against it. `10` is the shared gallery framing gate; `15` is the
79+
missing-render-file check.
7880

7981
| Code | Meaning |
8082
| --- | --- |
@@ -88,11 +90,12 @@ is the missing-render-file check.
8890
| 7 | Mesh UV round-trip error |
8991
| 8 | calc_uvs=False unexpectedly created a UV layer |
9092
| 9 | Explicit UV assignment error |
91-
| 10 | `--output` produced no file |
93+
| 10 | Framing gate violation on the `--output` path (`gallery_framing`) |
9294
| 11 | Broken panel is not flat |
9395
| 12 | Broken panel is not texel-(0,0) teal |
9496
| 13 | Repaired panel is not a checker |
9597
| 14 | Broken and repaired panels render identically |
98+
| 15 | `--output` produced no file |
9699

97100
The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
98101
(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).
10.1 KB
Loading

‎examples/uv-layer-grid/uv_layer_grid.py‎

Lines changed: 117 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
import bpy, bmesh, sys, os, math, argparse
2222
from mathutils import Vector
2323

24+
# Shared Layer 1 framing measurement (render path only) — see
25+
# gallery_framing.py for the __file__-relative import shim this relies on.
26+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
27+
import gallery_framing # noqa: E402
28+
2429
SEG = 8
2530
SIZE = 1.0
2631
UV_TOL = 1e-6
@@ -247,24 +252,44 @@ def render_still(path, engine):
247252
scene = bpy.context.scene
248253
card = make_uv_testcard("UVCard")
249254

250-
# --- Staging materials: quiet and dark, the panel faces carry the color --
251-
def pbr(name, base, rough, metal=0.0):
255+
# --- Staging materials: wood easels, walnut frames, brass placards -----
256+
def pbr(name, base, rough, metal=0.0, noise=None):
252257
m = bpy.data.materials.new(name)
253258
m.use_nodes = True
254-
b = m.node_tree.nodes["Principled BSDF"]
259+
nt = m.node_tree
260+
b = nt.nodes["Principled BSDF"]
255261
b.inputs["Base Color"].default_value = (*base, 1.0)
256262
b.inputs["Roughness"].default_value = rough
257263
b.inputs["Metallic"].default_value = metal
264+
if noise:
265+
# grain/wear: a stretched noise mottles base color and roughness
266+
scale, amount = noise
267+
coord = nt.nodes.new("ShaderNodeTexCoord")
268+
mapping = nt.nodes.new("ShaderNodeMapping")
269+
mapping.inputs["Scale"].default_value = (1.0, 1.0, 9.0)
270+
tex = nt.nodes.new("ShaderNodeTexNoise")
271+
tex.inputs["Scale"].default_value = scale
272+
tex.inputs["Detail"].default_value = 8.0
273+
ramp = nt.nodes.new("ShaderNodeValToRGB")
274+
ramp.color_ramp.elements[0].position = 0.35
275+
ramp.color_ramp.elements[1].position = 0.68
276+
ramp.color_ramp.elements[0].color = (*(c * (1 - amount) for c in base), 1.0)
277+
ramp.color_ramp.elements[1].color = (*(min(1.0, c * (1 + amount)) for c in base), 1.0)
278+
nt.links.new(coord.outputs["Object"], mapping.inputs["Vector"])
279+
nt.links.new(mapping.outputs["Vector"], tex.inputs["Vector"])
280+
nt.links.new(tex.outputs["Fac"], ramp.inputs["Fac"])
281+
nt.links.new(ramp.outputs["Color"], b.inputs["Base Color"])
258282
return m
259283

260-
frame_mat = pbr("Frame", (0.05, 0.053, 0.06), 0.32, 0.65)
261-
stand_mat = pbr("Stand", (0.032, 0.035, 0.042), 0.45, 0.4)
262-
led_mat = pbr("Led", (0.0, 0.0, 0.0), 0.6)
263-
led_b = led_mat.node_tree.nodes["Principled BSDF"]
264-
led_b.inputs["Emission Color"].default_value = (1.0, 0.45, 0.12, 1.0)
265-
led_b.inputs["Emission Strength"].default_value = 4.0
284+
oak = pbr("EaselOak", (0.42, 0.22, 0.09), 0.55, noise=(16.0, 0.22))
285+
walnut = pbr("FrameWalnut", (0.13, 0.055, 0.025), 0.38, noise=(24.0, 0.20))
286+
backing = pbr("Backing", (0.035, 0.03, 0.028), 0.8)
287+
brass = pbr("PlacardBrass", (0.80, 0.58, 0.26), 0.28, metal=1.0)
288+
ink = pbr("PlacardInk", (0.012, 0.011, 0.010), 0.5)
289+
290+
parts = []
266291

267-
def box(name, dims, loc, rot, mat, bevel=0.0):
292+
def box(name, dims, loc, rot, mat, bevel=0.0, parent=None):
268293
dx, dy, dz = (d * 0.5 for d in dims)
269294
verts = [
270295
(-dx, -dy, -dz), (dx, -dy, -dz), (dx, dy, -dz), (-dx, dy, -dz),
@@ -280,75 +305,97 @@ def box(name, dims, loc, rot, mat, bevel=0.0):
280305
ob = bpy.data.objects.new(name, me)
281306
ob.location = loc
282307
ob.rotation_euler = rot
308+
ob.parent = parent
283309
scene.collection.objects.link(ob)
284310
if bevel > 0.0:
285311
mod = ob.modifiers.new("Edge", "BEVEL")
286312
mod.width = bevel
287-
mod.segments = 2
313+
mod.segments = 3
314+
mod.limit_method = "NONE"
315+
parts.append(ob)
288316
return ob
289317

290-
def bar_between(name, p1, p2, width, mat):
318+
def bar_between(name, p1, p2, width, depth, mat):
291319
a, b = Vector(p1), Vector(p2)
292320
d = b - a
293-
ob = box(name, (width, width, d.length), (a + b) * 0.5, (0, 0, 0), mat, 0.01)
321+
ob = box(name, (width, depth, d.length), (a + b) * 0.5, (0, 0, 0), mat, 0.012)
294322
ob.rotation_mode = "QUATERNION"
295323
ob.rotation_quaternion = d.to_track_quat("Z", "Y")
296324
return ob
297325

298-
# --- Two framed lightbox displays, each on a floor tray with a rear
299-
# kick leg. The face planes keep their origins at the face centers (the
300-
# pixel witness probes the projected centers), with a ~0.2-unit bezel
301-
# between the face edge and the frame so the probed patch stays on data.
302-
LEAN = math.radians(75.0) # 15° back off vertical, easel-like
326+
def label(name, text, loc, parent):
327+
cu = bpy.data.curves.new(name, "FONT")
328+
cu.body = text
329+
cu.align_x = "CENTER"
330+
cu.align_y = "CENTER"
331+
cu.size = 0.15
332+
cu.extrude = 0.004
333+
cu.space_character = 1.12
334+
ob = bpy.data.objects.new(name, cu)
335+
ob.data.materials.append(ink)
336+
ob.location = loc
337+
ob.parent = parent
338+
scene.collection.objects.link(ob)
339+
parts.append(ob)
340+
return ob
341+
342+
# --- Two studio easels, each holding a walnut-framed tile panel. The face
343+
# planes keep their origins at the face centers (the pixel witness probes
344+
# the projected centers); the panel rests on the easel ledge, and a brass
345+
# placard on the ledge names which authoring path produced the panel.
346+
LEAN = math.radians(76.0) # 14 degrees back off vertical, easel-like
347+
LEDGE_Z = 0.62 # world height of the ledge underside
303348

304-
def display(name, with_uvs, cx, cy, yaw_deg):
349+
def display(name, with_uvs, cx, cy, yaw_deg, caption):
305350
yaw = math.radians(yaw_deg)
306351
asm = bpy.data.objects.new(name + "Asm", None)
307352
asm.rotation_euler = (LEAN, 0.0, yaw)
308353
rot = asm.rotation_euler.to_matrix()
309-
# Face-center height such that the frame's bottom edge rests in tray.
310-
contact = rot @ Vector((0.0, -1.17, 0.02))
311-
asm.location = (cx, cy, 0.07 - contact.z)
354+
# local +Y runs up the panel, local +Z faces the camera
355+
ledge_low = rot @ Vector((0.0, -1.49, 0.0))
356+
asm.location = (cx, cy, LEDGE_Z - ledge_low.z)
312357
scene.collection.objects.link(asm)
358+
base = Vector(asm.location)
313359

314-
# Left: the hazard — calc_uvs alone, no UV layer → flat teal of
360+
# Left: the hazard — calc_uvs alone, no UV layer -> flat teal of
315361
# texel (0,0). Right: the repair — pre-create, then calc_uvs fills.
316362
face = textured_plane(name, card, with_uvs)
317363
face.parent = asm
318-
face.location = (0.0, 0.0, 0.075)
319-
320-
plate = box(name + "Back", (2.34, 2.34, 0.14), (0, 0, 0), (0, 0, 0),
321-
frame_mat, 0.02)
322-
plate.parent = asm
323-
bezels = (
324-
((2.34, 0.17, 0.20), (0.0, 1.085, 0.02)),
325-
((2.34, 0.17, 0.20), (0.0, -1.085, 0.02)),
326-
((0.17, 2.0, 0.20), (1.085, 0.0, 0.02)),
327-
((0.17, 2.0, 0.20), (-1.085, 0.0, 0.02)),
328-
)
329-
for i, (dims, loc) in enumerate(bezels):
330-
bez = box(f"{name}Bez{i}", dims, loc, (0, 0, 0), frame_mat, 0.02)
331-
bez.parent = asm
332-
led = box(name + "Led", (0.06, 0.03, 0.035), (0.92, -1.085, 0.128),
333-
(0, 0, 0), led_mat, 0.008)
334-
led.parent = asm
335-
336-
base = asm.location
337-
box(name + "Tray", (2.0, 0.32, 0.10),
338-
(base.x + contact.x, base.y + contact.y, 0.05),
339-
(0, 0, yaw), stand_mat, 0.02)
364+
face.location = (0.0, 0.0, 0.06)
365+
parts.append(face)
366+
367+
box(name + "Board", (2.26, 2.26, 0.07), (0, 0, 0.0), (0, 0, 0), backing,
368+
0.01, asm)
369+
for i, (dims, loc) in enumerate((
370+
((2.40, 0.20, 0.17), (0.0, 1.10, 0.07)),
371+
((2.40, 0.20, 0.17), (0.0, -1.10, 0.07)),
372+
((0.20, 2.00, 0.17), (1.10, 0.0, 0.07)),
373+
((0.20, 2.00, 0.17), (-1.10, 0.0, 0.07)),
374+
)):
375+
box(f"{name}Frame{i}", dims, loc, (0, 0, 0), walnut, 0.035, asm)
376+
# ledge the frame stands on, and the brass placard on its front face
377+
box(name + "Ledge", (2.70, 0.30, 0.34), (0.0, -1.34, 0.10), (0, 0, 0),
378+
oak, 0.025, asm)
379+
box(name + "Placard", (1.70, 0.21, 0.02), (0.0, -1.34, 0.275), (0, 0, 0),
380+
brass, 0.008, asm)
381+
label(name + "Caption", caption, (0.0, -1.345, 0.287), asm)
382+
# easel: two splayed front legs behind the panel plane, a rear prop
383+
for side in (-1.0, 1.0):
384+
top = base + rot @ Vector((side * 0.34, 1.62, -0.10))
385+
splay = (rot @ Vector((side * 0.22, -1.0, 0.0))).normalized()
386+
t = top.z / -splay.z
387+
bar_between(f"{name}Leg{side:+.0f}", top, top + splay * t, 0.085, 0.05, oak)
340388
back = rot @ Vector((0.0, 0.0, -1.0))
341389
back.z = 0.0
342390
back.normalize()
343-
for side in (-0.75, 0.75):
344-
attach = base + rot @ Vector((side, 0.55, -0.12))
345-
foot = (base.x + contact.x + side * math.cos(yaw) + back.x * 0.85,
346-
base.y + contact.y - side * math.sin(yaw) + back.y * 0.85,
347-
0.02)
348-
bar_between(f"{name}Leg{side:+.2f}", attach, foot, 0.06, stand_mat)
391+
rear_top = base + rot @ Vector((0.0, 1.52, -0.14))
392+
rear_foot = Vector((rear_top.x, rear_top.y, 0.0)) + back * 1.45
393+
bar_between(name + "Rear", rear_top, rear_foot, 0.075, 0.05, oak)
394+
box(name + "Head", (0.86, 0.12, 0.10), tuple(base + rot @ Vector((0.0, 1.58, -0.09))),
395+
asm.rotation_euler, oak, 0.02)
349396

350-
display("Broken", False, -1.38, 0.30, 6.0)
351-
display("Fixed", True, 1.45, -0.10, 9.0)
397+
display("Broken", False, -1.45, 0.25, 7.0, "NO UV LAYER")
398+
display("Fixed", True, 1.45, -0.10, 10.0, "UV LAYER FIRST")
352399

353400
floor_me = bpy.data.meshes.new("Floor")
354401
bm = bmesh.new()
@@ -391,19 +438,18 @@ def light(name, loc, energy, size, col, rot):
391438
light("Key", (-4.4, -4.2, 6.4), 340.0, 5.0, (1.0, 0.96, 0.9), (60, 0, -32))
392439
light("Fill", (4.8, -2.8, 1.6), 80.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52))
393440
light("Rim", (0.2, 4.6, 3.0), 250.0, 3.5, (0.6, 0.78, 1.0), (-42, 0, 178))
394-
# Uplight between the displays and the wall, raking UP the backdrop: the
395-
# visible wall band above the panel tops only spans z≈2.8–3.4, so the
396-
# warm pool is aimed to live there instead of hiding behind the panels.
397-
light("Wedge", (-2.2, 5.6, 2.3), 480.0, 3.0, (1.0, 0.76, 0.5), (-117, 0, 180))
441+
# Warm wedge raking the backdrop between and beside the easels, so the
442+
# pool reads in the gaps around the panels (docs/VISUAL-STYLE.md).
443+
light("Wedge", (1.2, 4.2, 4.6), 620.0, 5.0, (1.0, 0.72, 0.44), (-62, 0, 172))
398444

399445
aim = bpy.data.objects.new("Aim", None)
400-
aim.location = (0.1, 0.05, 1.0)
446+
aim.location = (0.05, 0.05, 1.85)
401447
scene.collection.objects.link(aim)
402448

403449
cam_data = bpy.data.cameras.new("Cam")
404450
cam_data.lens = 48.0
405451
cam = bpy.data.objects.new("Cam", cam_data)
406-
cam.location = (3.1, -8.35, 2.2)
452+
cam.location = (3.2, -10.2, 2.9)
407453
con = cam.constraints.new("TRACK_TO")
408454
con.target = aim
409455
con.track_axis = "TRACK_NEGATIVE_Z"
@@ -425,8 +471,14 @@ def light(name, loc, energy, size, col, rot):
425471
scene.render.filepath = path
426472
# AgX desaturates the neon checker toward pastel — Standard keeps it honest.
427473
scene.view_settings.view_transform = "Standard"
474+
bpy.context.view_layer.update()
475+
# Layer 1 framing gate before the beauty render (exit 10 on violation)
476+
fcode = gallery_framing.check_framing(scene, cam, hero=parts, elements=parts,
477+
stage=[floor, wall])
478+
if fcode:
479+
return fcode
428480
bpy.ops.render.render(write_still=True)
429-
return os.path.exists(path) and os.path.getsize(path) > 0
481+
return 0 if os.path.exists(path) and os.path.getsize(path) > 0 else 15
430482

431483

432484
def patch_stats(px, w, h, cx, cy, half):
@@ -518,8 +570,11 @@ def main():
518570
if args.output:
519571
# check() already emptied the scene; rebuild for the still.
520572
out = os.path.abspath(args.output)
521-
if not render_still(out, args.engine):
522-
return fail(f"render produced no file at {args.output}", 10)
573+
rcode = render_still(out, args.engine)
574+
if rcode == 15:
575+
return fail(f"render produced no file at {args.output}", 15)
576+
if rcode:
577+
return rcode
523578
code = verify_still(out)
524579
if code != 0:
525580
return code

0 commit comments

Comments
 (0)