diff --git a/README.md b/README.md index 64cfb5e..ab4aaac 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,8 @@ result is watertight (every edge borders exactly two faces). `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. Asserts the hazard, -the pre-create repair against closed-form grid UVs, and an explicit assignment fallback. +the pre-create repair against closed-form grid UVs, and an explicit assignment fallback — +then re-reads its own render and fails unless the pixels prove the flat-vs-checker split. diff --git a/docs/gallery/assets/uv-layer-grid-hero.webp b/docs/gallery/assets/uv-layer-grid-hero.webp index b44dbeb..9de2150 100644 Binary files a/docs/gallery/assets/uv-layer-grid-hero.webp and b/docs/gallery/assets/uv-layer-grid-hero.webp differ diff --git a/docs/gallery/contact-sheets/uv-layer-grid-restage.webp b/docs/gallery/contact-sheets/uv-layer-grid-restage.webp new file mode 100644 index 0000000..966ca21 Binary files /dev/null and b/docs/gallery/contact-sheets/uv-layer-grid-restage.webp differ diff --git a/docs/gallery/index.html b/docs/gallery/index.html index a66c34e..ab02d70 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -443,7 +443,7 @@

png-exr-alpha

uv-layer-grid

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.

-

witnesses 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.

+

witnesses 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.

View example
diff --git a/docs/gallery/uv-layer-grid/index.html b/docs/gallery/uv-layer-grid/index.html index 69b6189..e4a6058 100644 --- a/docs/gallery/uv-layer-grid/index.html +++ b/docs/gallery/uv-layer-grid/index.html @@ -177,7 +177,7 @@

uv-layer-grid

uv-layer-grid render

Rendered headless by the example itself — click to zoom.

-
witnesses 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.
+
witnesses 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.
blender --background --python examples/uv-layer-grid/uv_layer_grid.py --
@@ -188,9 +188,9 @@

uv-layer-grid

What it witnesses: three related contracts on the same grid topology (SEG×SEG faces, (SEG+1)² verts, size 1.0 → coords in [-1, 1]):

1. Silent no-opcreate_grid(..., calc_uvs=True) with no prior bm.loops.layers.uv.new(...) leaves len(bm.loops.layers.uv) == 0 and mesh.uv_layers empty after to_mesh. 2. Pre-create repair — create the UV layer first, then calc_uvs=True fills every loop. UVs must match the closed form u = (x/size + 1)/2, v = (y/size + 1)/2 within 1e-6, both on the BMesh and after persisting to mesh.uv_layers.active.data. 3. Explicit assignment fallbackcalc_uvs=False, then uv.new("UVMap") and a loop write of the same closed form. Does not depend on calc_uvs at all; same tolerance.

What each check catches on failure:

- +

Version divergence: none probed — the silent no-op and closed-form UV fill assert identically on Blender 4.5 LTS and 5.1. The only gate in the file is the EEVEE engine id for the optional render (BLENDER_EEVEE_NEXT on 4.x, BLENDER_EEVEE on 5.x).

-

Render: two easel panels sharing one neon checker image. Left is the hazard (no UV layer) — flat teal of texel (0, 0). Right is the repair (pre-create + calc_uvs) — full magenta/cyan checker. If the UV contract failed, both panels would read the same.

+

Render: two easel panels sharing one neon checker image. Left is the hazard (no UV layer) — flat teal of texel (0, 0). Right is the repair (pre-create + calc_uvs) — full magenta/cyan checker. If the UV contract failed, both panels would read the same — and the still is not just an illustration: the script re-reads its own render and exits non-zero unless the pixels prove the flat-vs-checker split (measured on 5.1.2: hazard spread 0.0078, repair spread 0.7294).

Run

# Cheap correctness check (no render) — the CI check:
 blender --background --python uv_layer_grid.py --
@@ -218,7 +218,10 @@ 

Source

repair path against a closed-form UV grid, and an explicit loop-assignment fallback that does not depend on `calc_uvs` at all. Pass --output to also render a still that stages the broken (flat) panel beside the repaired -(checker) panel so the failure mode is visible at a glance: +(checker) panel — and then *witnesses the render itself*: the saved PNG is +read back and probed at each panel's projected center, asserting the broken +panel is one flat teal (texel (0,0)) while the repaired panel carries both +checker colors. If the UV contract failed, the pixels would say so: blender --background --python uv_layer_grid.py -- blender --background --python uv_layer_grid.py -- --output uv.png @@ -374,7 +377,7 @@

Source

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

Source

# Left: the hazard — calc_uvs alone, no UV layer → flat teal of texel (0,0). broken = textured_plane("Broken", card, with_uvs=False) - broken.location = (-1.22, 0.0, 1.18) - broken.rotation_euler = (math.radians(62), 0.0, math.radians(10)) - broken.scale = (1.15, 1.15, 1.15) + broken.location = (-1.18, 0.0, 0.94) + broken.rotation_euler = (math.radians(62), 0.0, math.radians(8)) # Right: the repair — pre-create UV layer, then calc_uvs fills it. fixed = textured_plane("Fixed", card, with_uvs=True) - fixed.location = (1.22, 0.0, 1.18) - fixed.rotation_euler = (math.radians(62), 0.0, math.radians(-10)) - fixed.scale = (1.15, 1.15, 1.15) + fixed.location = (1.18, 0.0, 0.94) + fixed.rotation_euler = (math.radians(62), 0.0, math.radians(-8)) floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() @@ -498,20 +499,21 @@

Source

ob.rotation_euler = tuple(math.radians(a) for a in rot) scene.collection.objects.link(ob) - light("Key", (-3.8, -4.8, 5.8), 360.0, 4.5, (1.0, 0.96, 0.9), (52, 0, -32)) - light("Fill", (4.8, -2.8, 1.6), 70.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52)) - light("Rim", (0.2, 4.0, 2.8), 220.0, 3.2, (0.6, 0.78, 1.0), (-62, 0, 178)) - # Between the panels and the wall so it only rakes the backdrop. - light("Wedge", (0.5, 5.2, 2.8), 520.0, 7.0, (1.0, 0.72, 0.42), (-78, 0, 180)) + light("Key", (-3.8, -4.8, 5.8), 300.0, 4.5, (1.0, 0.96, 0.9), (52, 0, -32)) + light("Fill", (4.8, -2.8, 1.6), 55.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52)) + light("Rim", (0.2, 4.0, 2.8), 90.0, 3.2, (0.6, 0.78, 1.0), (-62, 0, 178)) + # Between the panels and the wall so it only rakes the backdrop: a + # contained warm pool, corners falling off to dark. + light("Wedge", (0.3, 5.4, 1.9), 380.0, 3.6, (1.0, 0.68, 0.38), (-72, 0, 180)) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 1.1) + aim.location = (0.0, 0.0, 0.95) scene.collection.objects.link(aim) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 45.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (0.0, -5.4, 2.2) + cam.location = (0.0, -6.6, 2.1) con = cam.constraints.new("TRACK_TO") con.target = aim con.track_axis = "TRACK_NEGATIVE_Z" @@ -537,6 +539,77 @@

Source

return os.path.exists(path) and os.path.getsize(path) > 0 +def patch_stats(px, w, h, cx, cy, half): + """Mean RGB and per-channel min/max spread of a (2*half)^2 pixel patch.""" + sums = [0.0, 0.0, 0.0] + lo = [1.0, 1.0, 1.0] + hi = [0.0, 0.0, 0.0] + n = 0 + for y in range(max(0, cy - half), min(h, cy + half)): + for x in range(max(0, cx - half), min(w, cx + half)): + i = (y * w + x) * 4 + for c in range(3): + v = px[i + c] + sums[c] += v + lo[c] = min(lo[c], v) + hi[c] = max(hi[c], v) + n += 1 + mean = [s / n for s in sums] + spread = max(hi[c] - lo[c] for c in range(3)) + return mean, spread + + +def verify_still(path): + """Witness the render itself: the broken panel must be one flat teal, the + repaired panel a high-contrast checker. Probes the saved PNG at each + panel's projected center, so a failed UV contract cannot ship a still.""" + from bpy_extras.object_utils import world_to_camera_view + + scene = bpy.context.scene + bpy.context.view_layer.update() + img = bpy.data.images.load(path) + try: + w, h = img.size + px = [0.0] * (w * h * 4) + img.pixels.foreach_get(px) + finally: + bpy.data.images.remove(img) + + half = max(8, int(w * 0.055)) # ~70 px at 1280: spans >1 checker cell + stats = {} + for name in ("Broken", "Fixed"): + obj = bpy.data.objects[name] + ndc = world_to_camera_view(scene, scene.camera, obj.matrix_world.translation) + cx, cy = int(ndc.x * w), int(ndc.y * h) + stats[name] = patch_stats(px, w, h, cx, cy, half) + + (b_mean, b_spread), (f_mean, f_spread) = stats["Broken"], stats["Fixed"] + print( + f"pixel witness: broken mean rgb=({b_mean[0]:.3f},{b_mean[1]:.3f}," + f"{b_mean[2]:.3f}) spread={b_spread:.4f}; fixed spread={f_spread:.4f}" + ) + if b_spread > 0.02: + return fail( + f"broken panel is not flat (spread {b_spread:.4f} > 0.02) — " + f"the hazard did not render as one color", + 11, + ) + if not (b_mean[2] > b_mean[1] > b_mean[0]): + return fail( + f"broken panel is not the teal of texel (0,0) " + f"(expected b>g>r, got rgb=({b_mean[0]:.3f},{b_mean[1]:.3f},{b_mean[2]:.3f}))", + 12, + ) + if f_spread < 0.25: + return fail( + f"repaired panel is not a checker (spread {f_spread:.4f} < 0.25)", + 13, + ) + if abs(f_mean[0] - b_mean[0]) < 0.1 and f_spread - b_spread < 0.2: + return fail("broken and repaired panels render identically", 14) + return 0 + + def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] p = argparse.ArgumentParser() @@ -552,9 +625,13 @@

Source

return code if args.output: # check() already emptied the scene; rebuild for the still. - if not render_still(os.path.abspath(args.output), args.engine): + out = os.path.abspath(args.output) + if not render_still(out, args.engine): return fail(f"render produced no file at {args.output}", 10) - print(f"wrote {args.output}") + code = verify_still(out) + if code != 0: + return code + print(f"wrote {args.output} (pixel witness passed)") return 0 diff --git a/examples/gallery.json b/examples/gallery.json index 64f24e1..9465623 100644 --- a/examples/gallery.json +++ b/examples/gallery.json @@ -264,7 +264,7 @@ "name": "uv-layer-grid", "dir": "examples/uv-layer-grid", "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.", - "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.", + "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.", "hero": "docs/gallery/assets/uv-layer-grid-hero.webp", "preview": "examples/uv-layer-grid/preview.webp", "tags": [ diff --git a/examples/uv-layer-grid/README.md b/examples/uv-layer-grid/README.md index 897cecb..1c05d18 100644 --- a/examples/uv-layer-grid/README.md +++ b/examples/uv-layer-grid/README.md @@ -35,6 +35,12 @@ lifted into its own smoke-gated witness so the contract cannot quietly drift. error `0.1`). - *Mesh persistence* — UV layer or loop data lost across `to_mesh`. - *Explicit path* — loop assignment or the closed form itself regressing. +- *Pixel witness* (with `--output`) — the saved PNG is read back and probed + at each panel's projected center: the hazard panel must be one flat teal + (per-channel spread ≤ 0.02, ordering b>g>r), the repaired panel a + high-contrast checker (spread ≥ 0.25). Falsified twice: secretly giving the + hazard panel UVs exited 11 with measured spread `0.7333`; repainting texel + (0,0) magenta exited 12 with measured rgb `(0.927, 0.118, 0.536)`. **Version divergence:** none probed — the silent no-op and closed-form UV 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, **Render:** two easel panels sharing one neon checker image. Left is the hazard (no UV layer) — flat teal of texel (0, 0). Right is the repair (pre-create + `calc_uvs`) — full magenta/cyan checker. If the UV contract -failed, both panels would read the same. +failed, both panels would read the same — and the still is not just an +illustration: the script re-reads its own render and exits non-zero unless +the pixels prove the flat-vs-checker split (measured on 5.1.2: hazard spread +`0.0078`, repair spread `0.7294`). ## Run diff --git a/examples/uv-layer-grid/preview.webp b/examples/uv-layer-grid/preview.webp index 4f6aa26..943731b 100644 Binary files a/examples/uv-layer-grid/preview.webp and b/examples/uv-layer-grid/preview.webp differ diff --git a/examples/uv-layer-grid/uv_layer_grid.py b/examples/uv-layer-grid/uv_layer_grid.py index 6d5beb5..019e725 100644 --- a/examples/uv-layer-grid/uv_layer_grid.py +++ b/examples/uv-layer-grid/uv_layer_grid.py @@ -10,7 +10,10 @@ repair path against a closed-form UV grid, and an explicit loop-assignment fallback that does not depend on `calc_uvs` at all. Pass --output to also render a still that stages the broken (flat) panel beside the repaired -(checker) panel so the failure mode is visible at a glance: +(checker) panel — and then *witnesses the render itself*: the saved PNG is +read back and probed at each panel's projected center, asserting the broken +panel is one flat teal (texel (0,0)) while the repaired panel carries both +checker colors. If the UV contract failed, the pixels would say so: blender --background --python uv_layer_grid.py -- blender --background --python uv_layer_grid.py -- --output uv.png @@ -166,7 +169,7 @@ def eevee_engine_id(): def make_uv_testcard(name, w=256, h=256): - """Neon checker + corner marker. Texel (0,0) is a flat teal so a missing UV + """Neon checker whose single texel (0,0) is a flat teal, so a missing UV layer reads as one solid color instead of a bright false-positive checker.""" img = bpy.data.images.new(name, w, h, alpha=False) px = [0.0] * (w * h * 4) @@ -243,15 +246,13 @@ def render_still(path, engine): # Left: the hazard — calc_uvs alone, no UV layer → flat teal of texel (0,0). broken = textured_plane("Broken", card, with_uvs=False) - broken.location = (-1.22, 0.0, 1.18) - broken.rotation_euler = (math.radians(62), 0.0, math.radians(10)) - broken.scale = (1.15, 1.15, 1.15) + broken.location = (-1.18, 0.0, 0.94) + broken.rotation_euler = (math.radians(62), 0.0, math.radians(8)) # Right: the repair — pre-create UV layer, then calc_uvs fills it. fixed = textured_plane("Fixed", card, with_uvs=True) - fixed.location = (1.22, 0.0, 1.18) - fixed.rotation_euler = (math.radians(62), 0.0, math.radians(-10)) - fixed.scale = (1.15, 1.15, 1.15) + fixed.location = (1.18, 0.0, 0.94) + fixed.rotation_euler = (math.radians(62), 0.0, math.radians(-8)) floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() @@ -290,20 +291,21 @@ def light(name, loc, energy, size, col, rot): ob.rotation_euler = tuple(math.radians(a) for a in rot) scene.collection.objects.link(ob) - light("Key", (-3.8, -4.8, 5.8), 360.0, 4.5, (1.0, 0.96, 0.9), (52, 0, -32)) - light("Fill", (4.8, -2.8, 1.6), 70.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52)) - light("Rim", (0.2, 4.0, 2.8), 220.0, 3.2, (0.6, 0.78, 1.0), (-62, 0, 178)) - # Between the panels and the wall so it only rakes the backdrop. - light("Wedge", (0.5, 5.2, 2.8), 520.0, 7.0, (1.0, 0.72, 0.42), (-78, 0, 180)) + light("Key", (-3.8, -4.8, 5.8), 300.0, 4.5, (1.0, 0.96, 0.9), (52, 0, -32)) + light("Fill", (4.8, -2.8, 1.6), 55.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52)) + light("Rim", (0.2, 4.0, 2.8), 90.0, 3.2, (0.6, 0.78, 1.0), (-62, 0, 178)) + # Between the panels and the wall so it only rakes the backdrop: a + # contained warm pool, corners falling off to dark. + light("Wedge", (0.3, 5.4, 1.9), 380.0, 3.6, (1.0, 0.68, 0.38), (-72, 0, 180)) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 1.1) + aim.location = (0.0, 0.0, 0.95) scene.collection.objects.link(aim) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 45.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (0.0, -5.4, 2.2) + cam.location = (0.0, -6.6, 2.1) con = cam.constraints.new("TRACK_TO") con.target = aim con.track_axis = "TRACK_NEGATIVE_Z" @@ -329,6 +331,77 @@ def light(name, loc, energy, size, col, rot): return os.path.exists(path) and os.path.getsize(path) > 0 +def patch_stats(px, w, h, cx, cy, half): + """Mean RGB and per-channel min/max spread of a (2*half)^2 pixel patch.""" + sums = [0.0, 0.0, 0.0] + lo = [1.0, 1.0, 1.0] + hi = [0.0, 0.0, 0.0] + n = 0 + for y in range(max(0, cy - half), min(h, cy + half)): + for x in range(max(0, cx - half), min(w, cx + half)): + i = (y * w + x) * 4 + for c in range(3): + v = px[i + c] + sums[c] += v + lo[c] = min(lo[c], v) + hi[c] = max(hi[c], v) + n += 1 + mean = [s / n for s in sums] + spread = max(hi[c] - lo[c] for c in range(3)) + return mean, spread + + +def verify_still(path): + """Witness the render itself: the broken panel must be one flat teal, the + repaired panel a high-contrast checker. Probes the saved PNG at each + panel's projected center, so a failed UV contract cannot ship a still.""" + from bpy_extras.object_utils import world_to_camera_view + + scene = bpy.context.scene + bpy.context.view_layer.update() + img = bpy.data.images.load(path) + try: + w, h = img.size + px = [0.0] * (w * h * 4) + img.pixels.foreach_get(px) + finally: + bpy.data.images.remove(img) + + half = max(8, int(w * 0.055)) # ~70 px at 1280: spans >1 checker cell + stats = {} + for name in ("Broken", "Fixed"): + obj = bpy.data.objects[name] + ndc = world_to_camera_view(scene, scene.camera, obj.matrix_world.translation) + cx, cy = int(ndc.x * w), int(ndc.y * h) + stats[name] = patch_stats(px, w, h, cx, cy, half) + + (b_mean, b_spread), (f_mean, f_spread) = stats["Broken"], stats["Fixed"] + print( + f"pixel witness: broken mean rgb=({b_mean[0]:.3f},{b_mean[1]:.3f}," + f"{b_mean[2]:.3f}) spread={b_spread:.4f}; fixed spread={f_spread:.4f}" + ) + if b_spread > 0.02: + return fail( + f"broken panel is not flat (spread {b_spread:.4f} > 0.02) — " + f"the hazard did not render as one color", + 11, + ) + if not (b_mean[2] > b_mean[1] > b_mean[0]): + return fail( + f"broken panel is not the teal of texel (0,0) " + f"(expected b>g>r, got rgb=({b_mean[0]:.3f},{b_mean[1]:.3f},{b_mean[2]:.3f}))", + 12, + ) + if f_spread < 0.25: + return fail( + f"repaired panel is not a checker (spread {f_spread:.4f} < 0.25)", + 13, + ) + if abs(f_mean[0] - b_mean[0]) < 0.1 and f_spread - b_spread < 0.2: + return fail("broken and repaired panels render identically", 14) + return 0 + + def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] p = argparse.ArgumentParser() @@ -344,9 +417,13 @@ def main(): return code if args.output: # check() already emptied the scene; rebuild for the still. - if not render_still(os.path.abspath(args.output), args.engine): + out = os.path.abspath(args.output) + if not render_still(out, args.engine): return fail(f"render produced no file at {args.output}", 10) - print(f"wrote {args.output}") + code = verify_still(out) + if code != 0: + return code + print(f"wrote {args.output} (pixel witness passed)") return 0