Skip to content

Commit 0fff770

Browse files
authored
fix: make uv-layer-grid self-witness its render and restage the clipped hero (#86)
The still was the visual proof of the hazard but nothing asserted its pixels, and both panels were clipped by the frame. The script now re-reads its own render at each panel's projected center — the hazard panel must be one flat teal (spread <= 0.02, b>g>r) and the repair a high-contrast checker (spread >= 0.25) — falsified twice with measured errors. Restaged per VISUAL-STYLE: both panels fully in frame and grounded, contained warm wedge, mean luminance 0.274 inside the calibration range. Verified live on 5.1.2 and 4.5.11 LTS. Signed-off-by: fOuttaMyPaint <TMhospitalitystrategies@gmail.com>
1 parent efa8516 commit 0fff770

9 files changed

Lines changed: 205 additions & 41 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,8 @@ result is watertight (every edge borders exactly two faces).
213213

214214
`bmesh.ops.create_grid(..., calc_uvs=True)` is a silent no-op unless a UV layer already
215215
exists — without one an Image Texture samples texel (0, 0) everywhere. Asserts the hazard,
216-
the pre-create repair against closed-form grid UVs, and an explicit assignment fallback.
216+
the pre-create repair against closed-form grid UVs, and an explicit assignment fallback —
217+
then re-reads its own render and fails unless the pixels prove the flat-vs-checker split.
217218

218219
</td>
219220
</tr>
1.76 KB
Loading
49.3 KB
Loading

docs/gallery/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ <h2><a href="png-exr-alpha/">png-exr-alpha</a></h2>
443443
<div class="card-body">
444444
<h2><a href="uv-layer-grid/">uv-layer-grid</a></h2>
445445
<p class="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.</p>
446-
<p class="witnesses"><span class="tag">witnesses</span> 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.</p>
446+
<p class="witnesses"><span class="tag">witnesses</span> 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.</p>
447447
<a class="card-link" href="uv-layer-grid/">View example <span aria-hidden="true">&rarr;</span></a>
448448
</div>
449449
</article>

docs/gallery/uv-layer-grid/index.html

Lines changed: 97 additions & 20 deletions
Large diffs are not rendered by default.

examples/gallery.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@
264264
"name": "uv-layer-grid",
265265
"dir": "examples/uv-layer-grid",
266266
"teaches": "The UV-layer authoring hazard \u2014 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.",
267-
"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.",
267+
"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.",
268268
"hero": "docs/gallery/assets/uv-layer-grid-hero.webp",
269269
"preview": "examples/uv-layer-grid/preview.webp",
270270
"tags": [

examples/uv-layer-grid/README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ lifted into its own smoke-gated witness so the contract cannot quietly drift.
3535
error `0.1`).
3636
- *Mesh persistence* — UV layer or loop data lost across `to_mesh`.
3737
- *Explicit path* — loop assignment or the closed form itself regressing.
38+
- *Pixel witness* (with `--output`) — the saved PNG is read back and probed
39+
at each panel's projected center: the hazard panel must be one flat teal
40+
(per-channel spread ≤ 0.02, ordering b>g>r), the repaired panel a
41+
high-contrast checker (spread ≥ 0.25). Falsified twice: secretly giving the
42+
hazard panel UVs exited 11 with measured spread `0.7333`; repainting texel
43+
(0,0) magenta exited 12 with measured rgb `(0.927, 0.118, 0.536)`.
3844

3945
**Version divergence:** none probed — the silent no-op and closed-form UV
4046
fill assert identically on Blender 4.5 LTS and 5.1. The only gate in the file
@@ -44,7 +50,10 @@ is the EEVEE engine id for the optional render (`BLENDER_EEVEE_NEXT` on 4.x,
4450
**Render:** two easel panels sharing one neon checker image. Left is the
4551
hazard (no UV layer) — flat teal of texel (0, 0). Right is the repair
4652
(pre-create + `calc_uvs`) — full magenta/cyan checker. If the UV contract
47-
failed, both panels would read the same.
53+
failed, both panels would read the same — and the still is not just an
54+
illustration: the script re-reads its own render and exits non-zero unless
55+
the pixels prove the flat-vs-checker split (measured on 5.1.2: hazard spread
56+
`0.0078`, repair spread `0.7294`).
4857

4958
## Run
5059

-1.81 KB
Loading

examples/uv-layer-grid/uv_layer_grid.py

Lines changed: 94 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
repair path against a closed-form UV grid, and an explicit loop-assignment
1111
fallback that does not depend on `calc_uvs` at all. Pass --output to also
1212
render a still that stages the broken (flat) panel beside the repaired
13-
(checker) panel so the failure mode is visible at a glance:
13+
(checker) panel — and then *witnesses the render itself*: the saved PNG is
14+
read back and probed at each panel's projected center, asserting the broken
15+
panel is one flat teal (texel (0,0)) while the repaired panel carries both
16+
checker colors. If the UV contract failed, the pixels would say so:
1417
1518
blender --background --python uv_layer_grid.py --
1619
blender --background --python uv_layer_grid.py -- --output uv.png
@@ -166,7 +169,7 @@ def eevee_engine_id():
166169

167170

168171
def make_uv_testcard(name, w=256, h=256):
169-
"""Neon checker + corner marker. Texel (0,0) is a flat teal so a missing UV
172+
"""Neon checker whose single texel (0,0) is a flat teal, so a missing UV
170173
layer reads as one solid color instead of a bright false-positive checker."""
171174
img = bpy.data.images.new(name, w, h, alpha=False)
172175
px = [0.0] * (w * h * 4)
@@ -243,15 +246,13 @@ def render_still(path, engine):
243246

244247
# Left: the hazard — calc_uvs alone, no UV layer → flat teal of texel (0,0).
245248
broken = textured_plane("Broken", card, with_uvs=False)
246-
broken.location = (-1.22, 0.0, 1.18)
247-
broken.rotation_euler = (math.radians(62), 0.0, math.radians(10))
248-
broken.scale = (1.15, 1.15, 1.15)
249+
broken.location = (-1.18, 0.0, 0.94)
250+
broken.rotation_euler = (math.radians(62), 0.0, math.radians(8))
249251

250252
# Right: the repair — pre-create UV layer, then calc_uvs fills it.
251253
fixed = textured_plane("Fixed", card, with_uvs=True)
252-
fixed.location = (1.22, 0.0, 1.18)
253-
fixed.rotation_euler = (math.radians(62), 0.0, math.radians(-10))
254-
fixed.scale = (1.15, 1.15, 1.15)
254+
fixed.location = (1.18, 0.0, 0.94)
255+
fixed.rotation_euler = (math.radians(62), 0.0, math.radians(-8))
255256

256257
floor_me = bpy.data.meshes.new("Floor")
257258
bm = bmesh.new()
@@ -290,20 +291,21 @@ def light(name, loc, energy, size, col, rot):
290291
ob.rotation_euler = tuple(math.radians(a) for a in rot)
291292
scene.collection.objects.link(ob)
292293

293-
light("Key", (-3.8, -4.8, 5.8), 360.0, 4.5, (1.0, 0.96, 0.9), (52, 0, -32))
294-
light("Fill", (4.8, -2.8, 1.6), 70.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52))
295-
light("Rim", (0.2, 4.0, 2.8), 220.0, 3.2, (0.6, 0.78, 1.0), (-62, 0, 178))
296-
# Between the panels and the wall so it only rakes the backdrop.
297-
light("Wedge", (0.5, 5.2, 2.8), 520.0, 7.0, (1.0, 0.72, 0.42), (-78, 0, 180))
294+
light("Key", (-3.8, -4.8, 5.8), 300.0, 4.5, (1.0, 0.96, 0.9), (52, 0, -32))
295+
light("Fill", (4.8, -2.8, 1.6), 55.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52))
296+
light("Rim", (0.2, 4.0, 2.8), 90.0, 3.2, (0.6, 0.78, 1.0), (-62, 0, 178))
297+
# Between the panels and the wall so it only rakes the backdrop: a
298+
# contained warm pool, corners falling off to dark.
299+
light("Wedge", (0.3, 5.4, 1.9), 380.0, 3.6, (1.0, 0.68, 0.38), (-72, 0, 180))
298300

299301
aim = bpy.data.objects.new("Aim", None)
300-
aim.location = (0.0, 0.0, 1.1)
302+
aim.location = (0.0, 0.0, 0.95)
301303
scene.collection.objects.link(aim)
302304

303305
cam_data = bpy.data.cameras.new("Cam")
304306
cam_data.lens = 45.0
305307
cam = bpy.data.objects.new("Cam", cam_data)
306-
cam.location = (0.0, -5.4, 2.2)
308+
cam.location = (0.0, -6.6, 2.1)
307309
con = cam.constraints.new("TRACK_TO")
308310
con.target = aim
309311
con.track_axis = "TRACK_NEGATIVE_Z"
@@ -329,6 +331,77 @@ def light(name, loc, energy, size, col, rot):
329331
return os.path.exists(path) and os.path.getsize(path) > 0
330332

331333

334+
def patch_stats(px, w, h, cx, cy, half):
335+
"""Mean RGB and per-channel min/max spread of a (2*half)^2 pixel patch."""
336+
sums = [0.0, 0.0, 0.0]
337+
lo = [1.0, 1.0, 1.0]
338+
hi = [0.0, 0.0, 0.0]
339+
n = 0
340+
for y in range(max(0, cy - half), min(h, cy + half)):
341+
for x in range(max(0, cx - half), min(w, cx + half)):
342+
i = (y * w + x) * 4
343+
for c in range(3):
344+
v = px[i + c]
345+
sums[c] += v
346+
lo[c] = min(lo[c], v)
347+
hi[c] = max(hi[c], v)
348+
n += 1
349+
mean = [s / n for s in sums]
350+
spread = max(hi[c] - lo[c] for c in range(3))
351+
return mean, spread
352+
353+
354+
def verify_still(path):
355+
"""Witness the render itself: the broken panel must be one flat teal, the
356+
repaired panel a high-contrast checker. Probes the saved PNG at each
357+
panel's projected center, so a failed UV contract cannot ship a still."""
358+
from bpy_extras.object_utils import world_to_camera_view
359+
360+
scene = bpy.context.scene
361+
bpy.context.view_layer.update()
362+
img = bpy.data.images.load(path)
363+
try:
364+
w, h = img.size
365+
px = [0.0] * (w * h * 4)
366+
img.pixels.foreach_get(px)
367+
finally:
368+
bpy.data.images.remove(img)
369+
370+
half = max(8, int(w * 0.055)) # ~70 px at 1280: spans >1 checker cell
371+
stats = {}
372+
for name in ("Broken", "Fixed"):
373+
obj = bpy.data.objects[name]
374+
ndc = world_to_camera_view(scene, scene.camera, obj.matrix_world.translation)
375+
cx, cy = int(ndc.x * w), int(ndc.y * h)
376+
stats[name] = patch_stats(px, w, h, cx, cy, half)
377+
378+
(b_mean, b_spread), (f_mean, f_spread) = stats["Broken"], stats["Fixed"]
379+
print(
380+
f"pixel witness: broken mean rgb=({b_mean[0]:.3f},{b_mean[1]:.3f},"
381+
f"{b_mean[2]:.3f}) spread={b_spread:.4f}; fixed spread={f_spread:.4f}"
382+
)
383+
if b_spread > 0.02:
384+
return fail(
385+
f"broken panel is not flat (spread {b_spread:.4f} > 0.02) — "
386+
f"the hazard did not render as one color",
387+
11,
388+
)
389+
if not (b_mean[2] > b_mean[1] > b_mean[0]):
390+
return fail(
391+
f"broken panel is not the teal of texel (0,0) "
392+
f"(expected b>g>r, got rgb=({b_mean[0]:.3f},{b_mean[1]:.3f},{b_mean[2]:.3f}))",
393+
12,
394+
)
395+
if f_spread < 0.25:
396+
return fail(
397+
f"repaired panel is not a checker (spread {f_spread:.4f} < 0.25)",
398+
13,
399+
)
400+
if abs(f_mean[0] - b_mean[0]) < 0.1 and f_spread - b_spread < 0.2:
401+
return fail("broken and repaired panels render identically", 14)
402+
return 0
403+
404+
332405
def main():
333406
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
334407
p = argparse.ArgumentParser()
@@ -344,9 +417,13 @@ def main():
344417
return code
345418
if args.output:
346419
# check() already emptied the scene; rebuild for the still.
347-
if not render_still(os.path.abspath(args.output), args.engine):
420+
out = os.path.abspath(args.output)
421+
if not render_still(out, args.engine):
348422
return fail(f"render produced no file at {args.output}", 10)
349-
print(f"wrote {args.output}")
423+
code = verify_still(out)
424+
if code != 0:
425+
return code
426+
print(f"wrote {args.output} (pixel witness passed)")
350427
return 0
351428

352429

0 commit comments

Comments
 (0)