From 68bf1f2640a918b66c0de0c9047998364fc8666f Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Sat, 18 Jul 2026 22:47:23 -0400 Subject: [PATCH] feat: add uv-layer-grid example witnessing create_grid calc_uvs silent no-op AI code trusts calc_uvs=True and ships flat Image Textures; this smoke-gated witness proves the pre-create repair against closed-form UVs and stages the failure mode beside the fix. Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 1 + .github/workflows/blender-smoke.yml | 12 + AGENTS.md | 4 +- CLAUDE.md | 6 +- README.md | 20 +- ROADMAP.md | 3 +- docs/gallery/assets/uv-layer-grid-hero.webp | Bin 0 -> 18284 bytes docs/gallery/index.html | 12 + docs/gallery/uv-layer-grid/index.html | 608 ++++++++++++++++++++ examples/gallery.json | 22 +- examples/uv-layer-grid/README.md | 62 ++ examples/uv-layer-grid/preview.webp | Bin 0 -> 16472 bytes examples/uv-layer-grid/uv_layer_grid.py | 360 ++++++++++++ 13 files changed, 1097 insertions(+), 13 deletions(-) create mode 100644 docs/gallery/assets/uv-layer-grid-hero.webp create mode 100644 docs/gallery/uv-layer-grid/index.html create mode 100644 examples/uv-layer-grid/README.md create mode 100644 examples/uv-layer-grid/preview.webp create mode 100644 examples/uv-layer-grid/uv_layer_grid.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index aca7d64..8065d8d 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -78,6 +78,7 @@ "examples/temp-override-join", "examples/text-version-stamp", "examples/turntable", + "examples/uv-layer-grid", "examples/wave-displace" ] } diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index bc00e7c..9278523 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -326,3 +326,15 @@ jobs: # it. Exits non-zero on failure. xvfb-run -a "$BLENDER" --background \ --python examples/image-pixels-testcard/image_pixels_testcard.py -- + + - name: Shipped example - uv layer grid (calc_uvs silent no-op) + run: | + set -euo pipefail + # Check only (no render): create_grid(..., calc_uvs=True) without a + # pre-existing UV layer leaves 0 layers (silent no-op); pre-create + + # calc_uvs fills loops to the closed-form grid UVs + # ((x/size+1)/2, (y/size+1)/2) within 1e-6 including mesh persistence; + # explicit loop assignment is the calc_uvs-free fallback. Exits + # non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/uv-layer-grid/uv_layer_grid.py -- diff --git a/AGENTS.md b/AGENTS.md index 2e2cd40..2f37d62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ The content base (counts are CI-enforced against README.md and the manifest): - 2 templates: `extension-addon-template` for Extensions Platform add-ons, and `headless-batch-script-template` for unattended batch jobs. - 17 snippets covering canonical patterns. -- 20 examples under `examples//`: runnable scripts that assert a real +- 21 examples under `examples//`: runnable scripts that assert a real API contract with deterministic checks, exit non-zero on failure, and optionally render a still via `--output`. Each is executed headless on Blender 4.5 LTS and 5.1 by `blender-smoke.yml`; its render ships in the @@ -42,7 +42,7 @@ Blender-Developer-Tools/ rules/.mdc # 6 rule files templates// # 2 starter templates snippets/.py # 17 standalone Python snippets - examples// # 20 runnable smoke-gated examples (+ gallery.json) + examples// # 21 runnable smoke-gated examples (+ gallery.json) scripts/build_gallery.py # generates docs/gallery/ (stdlib only) scripts/site/ # vendored landing-page build (build_site.py + template) docs/gallery/ # committed generated gallery pages + hero assets diff --git a/CLAUDE.md b/CLAUDE.md index a13b054..6e24190 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ skills//SKILL.md - AI workflow definitions, 12 total rules/.mdc - Anti-pattern rules, 6 total templates// - Starter projects, 2 total snippets/.py - Standalone code patterns, 17 total -examples// - Runnable smoke-gated examples, 20 total (+ gallery.json) +examples// - Runnable smoke-gated examples, 21 total (+ gallery.json) scripts/build_gallery.py - Regenerates docs/gallery/ from gallery.json (stdlib only) scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders @@ -80,11 +80,11 @@ v0.1.0: canonical object creation and deletion, depsgraph evaluated mesh, bmesh v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namespace`, application handler registration, shader node group with cross-version `interface` API, `foreach_get` bulk vertex read, version-branch skeleton, and USD export with `evaluation_mode='RENDER'`. -## Examples (20) +## Examples (21) Runnable scripts at `examples//`, each asserting a real API contract with deterministic checks (exit non-zero on failure) and optionally rendering a still via -`--output`. All twenty run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`; +`--output`. All twenty-one run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`; their renders ship in the site gallery at `docs/gallery/`. `examples/gallery.json` is the gallery's source of truth. When authoring a new one, copy the anatomy of `examples/bmesh-gear/` (script structure, README shape, dark-studio render recipe) and diff --git a/README.md b/README.md index b2bb747..3e3e4a4 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  20 examples + 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  21 examples

@@ -36,7 +36,7 @@ ## Overview -This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 20 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support. +This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 21 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support. The content is consumed by AI coding agents (Cursor, Claude Code, any MCP-capable client) when working on Blender add-ons, geometry nodes scripts, batch pipelines, or animation tooling. There is no build step. Edit the markdown and Python files directly. @@ -171,7 +171,7 @@ non-destructive path.

-Mesh, curves & text — 6 examples +Mesh, curves & text — 7 examples @@ -186,6 +186,20 @@ A 14-tooth gear built entirely with bmesh — with `bm.free()` in a `try`/`final ownership contract demands. Asserts the closed-form vert/edge/face counts and that the result is watertight (every edge borders exactly two faces). + + + + + diff --git a/ROADMAP.md b/ROADMAP.md index b1b0152..b63376b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -98,8 +98,9 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - Bump `blender_version_min` in the templates if 5.2 APIs are used - Additional snippets for asset library scripting, EXR baking, multi-file extensions - Gallery coverage follow-ups from the GPv3 review: light-linking, VSE sequences-to-strips witness (`grease-pencil-rosette`, `armature-bend`, `text-version-stamp`, and `image-pixels-testcard` shipped first) -- UV-layer authoring witness: `bmesh.ops.create_grid(calc_uvs=True)` silently creates no UV layer unless one already exists, and an Image Texture without UVs samples texel (0,0) everywhere (found while authoring `image-pixels-testcard`) +- ~~UV-layer authoring witness~~ **SHIPPED** as `examples/uv-layer-grid/` — `create_grid(..., calc_uvs=True)` silent no-op without a pre-existing UV layer; closed-form UV fill + explicit assignment fallback; dual-panel render (flat texel (0,0) vs neon checker) - Image save-format witness: float images saved to PNG with varying alpha suffer premultiply quantization (~0.98 worst-case round-trip error at 8-bit alpha extremes); EXR vs PNG storage contract +- Attribute domain witness: writing a POINT-domain color attribute and reading it as if it were CORNER (or vice versa) silently shears colors across shared verts — companion to `color-attribute-wheel` ## Future (uncommitted) diff --git a/docs/gallery/assets/uv-layer-grid-hero.webp b/docs/gallery/assets/uv-layer-grid-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..b44dbeb0b9e5b2e87d412fed51aea61cf3891ff8 GIT binary patch literal 18284 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 91c6f88..85f3202 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -199,6 +199,7 @@

Examples Gallery

+
+
+ + uv-layer-grid — The UV-layer authoring hazard — bmesh + +
+

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.

+ View example +
+
diff --git a/docs/gallery/uv-layer-grid/index.html b/docs/gallery/uv-layer-grid/index.html new file mode 100644 index 0000000..69b6189 --- /dev/null +++ b/docs/gallery/uv-layer-grid/index.html @@ -0,0 +1,608 @@ + + + + + + uv-layer-grid — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

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.

+
+
+ +

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

A runnable example that witnesses the UV-layer authoring hazard behind bmesh.ops.create_grid(..., calc_uvs=True): the flag is a silent no-op unless a UV layer already exists on the BMesh. AI-generated Blender code commonly trusts calc_uvs=True, wires an Image Texture, and ships a mesh that renders as one flat color — every fragment samples texel (0, 0).

+

Found while authoring image-pixels-testcard; lifted into its own smoke-gated witness so the contract cannot quietly drift.

+

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:

+
  • *Silent no-op* — calc_uvs=True starts creating a layer on its own (falsified: expecting n_layers == 0 fails if a layer appears; temporarily pre-creating a layer before the hazard probe exits 3).
  • *Topology* — create_grid segment/size contract drift (wrong face/vert counts).
  • *Closed-form UVs* — calc_uvs filling a different parameterization, or a one-texel shift (falsified: adding 0.1 to every U exited 6 with measured error 0.1).
  • *Mesh persistence* — UV layer or loop data lost across to_mesh.
  • *Explicit path* — loop assignment or the closed form itself regressing.
+

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.

+

Run

+
# Cheap correctness check (no render) — the CI check:
+blender --background --python uv_layer_grid.py --
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python uv_layer_grid.py -- --output uv.png
+blender --background --python uv_layer_grid.py -- --output uv.png --engine cycles
+

It exits non-zero on failure and prints every measured error and tolerance on success, so CI logs carry the numbers. The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.

+
+
+

Source

+
+ examples/uv-layer-grid/uv_layer_grid.py + View on GitHub → +
+
"""UV-layer authoring hazard — a runnable example.
+
+Witnesses that `bmesh.ops.create_grid(..., calc_uvs=True)` silently creates
+*no* UV layer unless one already exists on the BMesh. AI-generated Blender
+code commonly trusts `calc_uvs=True` and then wires an Image Texture; without
+a UV layer every fragment samples texel (0, 0) and the mesh renders as one
+flat color.
+
+The check proves the silent no-op, then the pre-create + `calc_uvs=True`
+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:
+
+    blender --background --python uv_layer_grid.py --
+    blender --background --python uv_layer_grid.py -- --output uv.png
+"""
+import bpy, bmesh, sys, os, math, argparse
+
+SEG = 8
+SIZE = 1.0
+UV_TOL = 1e-6
+
+
+def expect_uv(co):
+    """Closed-form UV for create_grid verts spanning [-SIZE, SIZE] in X/Y."""
+    return ((co.x / SIZE) + 1.0) * 0.5, ((co.y / SIZE) + 1.0) * 0.5
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def max_uv_err(bm, uv_layer):
+    err = 0.0
+    for face in bm.faces:
+        for loop in face.loops:
+            ex, ey = expect_uv(loop.vert.co)
+            got = loop[uv_layer].uv
+            err = max(err, abs(got.x - ex), abs(got.y - ey))
+    return err
+
+
+def check():
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+
+    # --- 1. The hazard: calc_uvs=True is a silent no-op without a UV layer ---
+    me_bad = bpy.data.meshes.new("NoPreUV")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(
+            bm, x_segments=SEG, y_segments=SEG, size=SIZE, calc_uvs=True,
+        )
+        n_layers = len(bm.loops.layers.uv)
+        n_faces = len(bm.faces)
+        n_verts = len(bm.verts)
+        bm.to_mesh(me_bad)
+    finally:
+        bm.free()
+
+    if n_layers != 0:
+        return fail(
+            f"calc_uvs=True without a pre-existing layer created {n_layers} "
+            f"UV layer(s) — the silent-no-op hazard is gone (or changed)",
+            3,
+        )
+    if len(me_bad.uv_layers) != 0:
+        return fail(
+            f"mesh gained {len(me_bad.uv_layers)} UV layer(s) after "
+            f"calc_uvs=True with no pre-create",
+            3,
+        )
+    expect_faces = SEG * SEG
+    expect_verts = (SEG + 1) * (SEG + 1)
+    if (n_faces, n_verts) != (expect_faces, expect_verts):
+        return fail(
+            f"grid topology {(n_verts, n_faces)} != "
+            f"expected {(expect_verts, expect_faces)}",
+            4,
+        )
+
+    # --- 2. Repair: pre-create the layer, then calc_uvs=True fills it -----
+    me_ok = bpy.data.meshes.new("PreUV")
+    bm = bmesh.new()
+    try:
+        bm.loops.layers.uv.new("UVMap")
+        bmesh.ops.create_grid(
+            bm, x_segments=SEG, y_segments=SEG, size=SIZE, calc_uvs=True,
+        )
+        if len(bm.loops.layers.uv) != 1:
+            return fail(
+                f"pre-create + calc_uvs left {len(bm.loops.layers.uv)} "
+                f"layers (expected 1)",
+                5,
+            )
+        uv = bm.loops.layers.uv.active
+        calc_err = max_uv_err(bm, uv)
+        bm.to_mesh(me_ok)
+    finally:
+        bm.free()
+
+    if calc_err > UV_TOL:
+        return fail(
+            f"calc_uvs closed-form error {calc_err:.2e} > {UV_TOL:.0e}",
+            6,
+        )
+    if len(me_ok.uv_layers) != 1 or me_ok.uv_layers.active is None:
+        return fail("pre-create path did not persist a UV layer on the mesh", 5)
+
+    # Mesh-side round-trip of the same closed form (loop data, not bmesh).
+    mesh_err = 0.0
+    uv_data = me_ok.uv_layers.active.data
+    for poly in me_ok.polygons:
+        for li in poly.loop_indices:
+            vi = me_ok.loops[li].vertex_index
+            ex, ey = expect_uv(me_ok.vertices[vi].co)
+            got = uv_data[li].uv
+            mesh_err = max(mesh_err, abs(got.x - ex), abs(got.y - ey))
+    if mesh_err > UV_TOL:
+        return fail(
+            f"mesh UV round-trip error {mesh_err:.2e} > {UV_TOL:.0e}",
+            7,
+        )
+
+    # --- 3. Explicit assignment fallback (does not rely on calc_uvs) -------
+    me_ex = bpy.data.meshes.new("ExplicitUV")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(
+            bm, x_segments=SEG, y_segments=SEG, size=SIZE, calc_uvs=False,
+        )
+        if len(bm.loops.layers.uv) != 0:
+            return fail(
+                "create_grid(calc_uvs=False) unexpectedly created a UV layer",
+                8,
+            )
+        uv = bm.loops.layers.uv.new("UVMap")
+        for face in bm.faces:
+            for loop in face.loops:
+                loop[uv].uv = expect_uv(loop.vert.co)
+        explicit_err = max_uv_err(bm, uv)
+        bm.to_mesh(me_ex)
+    finally:
+        bm.free()
+
+    if explicit_err > UV_TOL:
+        return fail(
+            f"explicit UV assignment error {explicit_err:.2e} > {UV_TOL:.0e}",
+            9,
+        )
+
+    print(
+        f"hazard confirmed: calc_uvs=True alone → 0 UV layers "
+        f"(grid {n_verts}v/{n_faces}f); "
+        f"pre-create + calc_uvs max err {calc_err:.2e} "
+        f"(mesh round-trip {mesh_err:.2e}); "
+        f"explicit assign max err {explicit_err:.2e} "
+        f"(tol {UV_TOL:.0e})"
+    )
+    return 0
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def make_uv_testcard(name, w=256, h=256):
+    """Neon checker + corner marker. 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)
+    cell = w // 8
+    for y in range(h):
+        for x in range(w):
+            i = (y * w + x) * 4
+            cx, cy = x // cell, y // cell
+            # bottom-left origin: (0,0) is the first pixel the shader samples
+            # when no UV layer exists.
+            if x == 0 and y == 0:
+                # Distinct flat teal — readable as "wrong" at thumbnail scale,
+                # still obviously not the checker.
+                px[i:i + 4] = [0.04, 0.28, 0.38, 1.0]
+                continue
+            if (cx + cy) % 2 == 0:
+                # warm magenta
+                px[i:i + 4] = [0.95, 0.12, 0.55, 1.0]
+            else:
+                # electric cyan
+                px[i:i + 4] = [0.08, 0.85, 0.95, 1.0]
+            # saturation wash by U so the gradient is readable at thumbnail
+            u = x / (w - 1)
+            px[i] = min(1.0, px[i] * (0.55 + 0.45 * u))
+            px[i + 2] = min(1.0, px[i + 2] * (0.55 + 0.45 * (1.0 - u)))
+    img.pixels.foreach_set(px)
+    return img
+
+
+def specular_off(bsdf):
+    if "Specular IOR Level" in bsdf.inputs:
+        bsdf.inputs["Specular IOR Level"].default_value = 0.0
+    elif "Specular" in bsdf.inputs:
+        bsdf.inputs["Specular"].default_value = 0.0
+
+
+def textured_plane(name, image, with_uvs):
+    """Unit grid plane (+/- SIZE). with_uvs=False leaves the hazard in place."""
+    me = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        if with_uvs:
+            bm.loops.layers.uv.new("UVMap")
+        bmesh.ops.create_grid(
+            bm, x_segments=SEG, y_segments=SEG, size=SIZE, calc_uvs=with_uvs,
+        )
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+
+    mat = bpy.data.materials.new(name + "Mat")
+    mat.use_nodes = True
+    nodes, links = mat.node_tree.nodes, mat.node_tree.links
+    tex = nodes.new("ShaderNodeTexImage")
+    tex.image = image
+    tex.interpolation = "Closest"
+    bsdf = nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = (0.0, 0.0, 0.0, 1.0)
+    bsdf.inputs["Roughness"].default_value = 1.0
+    specular_off(bsdf)
+    # Emission so the checker reads as authored color, not lit albedo.
+    links.new(tex.outputs["Color"], bsdf.inputs["Emission Color"])
+    bsdf.inputs["Emission Strength"].default_value = 0.95
+    me.materials.append(mat)
+
+    obj = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(obj)
+    return obj
+
+
+def render_still(path, engine):
+    scene = bpy.context.scene
+    card = make_uv_testcard("UVCard")
+
+    # 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)
+
+    # 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)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Studio")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 6.8, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02, 0.021, 0.025, 1.0,
+    )
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        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))
+
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 1.1)
+    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)
+    con = cam.constraints.new("TRACK_TO")
+    con.target = aim
+    con.track_axis = "TRACK_NEGATIVE_Z"
+    con.up_axis = "UP_Y"
+    scene.collection.objects.link(cam)
+    scene.camera = cam
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 64
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = "PNG"
+    scene.render.filepath = path
+    # AgX desaturates the neon checker toward pastel — Standard keeps it honest.
+    scene.view_settings.view_transform = "Standard"
+    bpy.ops.render.render(write_still=True)
+    return os.path.exists(path) and os.path.getsize(path) > 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None, help="optional: render a still PNG here")
+    p.add_argument(
+        "--engine", default="eevee", choices=("eevee", "cycles"),
+        help="render engine for --output (cycles for GPU-less hosts)",
+    )
+    args = p.parse_args(argv)
+
+    code = check()
+    if code != 0:
+        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):
+            return fail(f"render produced no file at {args.output}", 10)
+        print(f"wrote {args.output}")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as e:
+        import traceback
+        traceback.print_exc()
+        print(f"FATAL: {e}", file=sys.stderr)
+        sys.exit(1)
+
+
+
+ +
+
+ generated from examples/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/examples/gallery.json b/examples/gallery.json index 83694a0..5e0fa7c 100644 --- a/examples/gallery.json +++ b/examples/gallery.json @@ -224,8 +224,8 @@ { "name": "text-version-stamp", "dir": "examples/text-version-stamp", - "teaches": "The TextCurve data API — curves.new(type='FONT'), live body text from bpy.app.version_string, extrude and bevel_depth solids, and evaluated-mesh conversion — so every render self-documents which Blender produced it.", - "witnessesFix": "TextCurve solids are exactly predictable: evaluated z-extent = 2 × (extrude + bevel_depth), bevel widens the outline by 2 × bevel_depth, editing body regenerates geometry — and version_string is not bare semver on LTS (\"4.5.11 LTS\"), so parse bpy.app.version instead.", + "teaches": "The TextCurve data API \u2014 curves.new(type='FONT'), live body text from bpy.app.version_string, extrude and bevel_depth solids, and evaluated-mesh conversion \u2014 so every render self-documents which Blender produced it.", + "witnessesFix": "TextCurve solids are exactly predictable: evaluated z-extent = 2 \u00d7 (extrude + bevel_depth), bevel widens the outline by 2 \u00d7 bevel_depth, editing body regenerates geometry \u2014 and version_string is not bare semver on LTS (\"4.5.11 LTS\"), so parse bpy.app.version instead.", "hero": "docs/gallery/assets/text-version-stamp-hero.webp", "preview": "examples/text-version-stamp/preview.webp", "tags": [ @@ -237,8 +237,8 @@ { "name": "image-pixels-testcard", "dir": "examples/image-pixels-testcard", - "teaches": "The Image pixel-buffer contract — a procedural broadcast test card written into bpy.data.images.new() with one pixels.foreach_set (589,824 floats), byte vs float_buffer storage, scale() reallocation, and the save() vs save_render() lifecycle.", - "witnessesFix": "pixels is always flat RGBA (channels == 4 even with alpha=False), byte storage quantizes at exactly ≤ 0.5/255 and strictly > 0, stale-size bulk reads raise after scale() — and save() silently flips source to FILE and drops the buffer, so later pixels reads come from whatever sits on disk (proven with an imposter file).", + "teaches": "The Image pixel-buffer contract \u2014 a procedural broadcast test card written into bpy.data.images.new() with one pixels.foreach_set (589,824 floats), byte vs float_buffer storage, scale() reallocation, and the save() vs save_render() lifecycle.", + "witnessesFix": "pixels is always flat RGBA (channels == 4 even with alpha=False), byte storage quantizes at exactly \u2264 0.5/255 and strictly > 0, stale-size bulk reads raise after scale() \u2014 and save() silently flips source to FILE and drops the buffer, so later pixels reads come from whatever sits on disk (proven with an imposter file).", "hero": "docs/gallery/assets/image-pixels-testcard-hero.webp", "preview": "examples/image-pixels-testcard/preview.webp", "tags": [ @@ -246,6 +246,20 @@ "performance", "rendering" ] + }, + { + "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.", + "hero": "docs/gallery/assets/uv-layer-grid-hero.webp", + "preview": "examples/uv-layer-grid/preview.webp", + "tags": [ + "mesh", + "bmesh", + "uv", + "materials" + ] } ] } diff --git a/examples/uv-layer-grid/README.md b/examples/uv-layer-grid/README.md new file mode 100644 index 0000000..897cecb --- /dev/null +++ b/examples/uv-layer-grid/README.md @@ -0,0 +1,62 @@ +# UV Layer Grid + +A runnable example that witnesses the UV-layer authoring hazard behind +`bmesh.ops.create_grid(..., calc_uvs=True)`: the flag is a **silent no-op** +unless a UV layer already exists on the BMesh. AI-generated Blender code +commonly trusts `calc_uvs=True`, wires an Image Texture, and ships a mesh that +renders as one flat color — every fragment samples texel (0, 0). + +Found while authoring [`image-pixels-testcard`](../image-pixels-testcard/); +lifted into its own smoke-gated witness so the contract cannot quietly drift. + +**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-op** — `create_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 fallback** — `calc_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:** + +- *Silent no-op* — `calc_uvs=True` starts creating a layer on its own + (falsified: expecting `n_layers == 0` fails if a layer appears; temporarily + pre-creating a layer before the hazard probe exits 3). +- *Topology* — `create_grid` segment/size contract drift (wrong face/vert + counts). +- *Closed-form UVs* — `calc_uvs` filling a different parameterization, or a + one-texel shift (falsified: adding `0.1` to every U exited 6 with measured + error `0.1`). +- *Mesh persistence* — UV layer or loop data lost across `to_mesh`. +- *Explicit path* — loop assignment or the closed form itself regressing. + +**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. + +## Run + +```bash +# Cheap correctness check (no render) — the CI check: +blender --background --python uv_layer_grid.py -- + +# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): +blender --background --python uv_layer_grid.py -- --output uv.png +blender --background --python uv_layer_grid.py -- --output uv.png --engine cycles +``` + +It exits non-zero on failure and prints every measured error and tolerance on +success, so CI logs carry the numbers. The `blender-smoke` workflow runs the +check on Blender 4.5 LTS and 5.1. diff --git a/examples/uv-layer-grid/preview.webp b/examples/uv-layer-grid/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f6aa26e132511659aa043cbd1b14c590189944b GIT binary patch literal 16472 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 literal 0 HcmV?d00001 diff --git a/examples/uv-layer-grid/uv_layer_grid.py b/examples/uv-layer-grid/uv_layer_grid.py new file mode 100644 index 0000000..6d5beb5 --- /dev/null +++ b/examples/uv-layer-grid/uv_layer_grid.py @@ -0,0 +1,360 @@ +"""UV-layer authoring hazard — a runnable example. + +Witnesses that `bmesh.ops.create_grid(..., calc_uvs=True)` silently creates +*no* UV layer unless one already exists on the BMesh. AI-generated Blender +code commonly trusts `calc_uvs=True` and then wires an Image Texture; without +a UV layer every fragment samples texel (0, 0) and the mesh renders as one +flat color. + +The check proves the silent no-op, then the pre-create + `calc_uvs=True` +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: + + blender --background --python uv_layer_grid.py -- + blender --background --python uv_layer_grid.py -- --output uv.png +""" +import bpy, bmesh, sys, os, math, argparse + +SEG = 8 +SIZE = 1.0 +UV_TOL = 1e-6 + + +def expect_uv(co): + """Closed-form UV for create_grid verts spanning [-SIZE, SIZE] in X/Y.""" + return ((co.x / SIZE) + 1.0) * 0.5, ((co.y / SIZE) + 1.0) * 0.5 + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def max_uv_err(bm, uv_layer): + err = 0.0 + for face in bm.faces: + for loop in face.loops: + ex, ey = expect_uv(loop.vert.co) + got = loop[uv_layer].uv + err = max(err, abs(got.x - ex), abs(got.y - ey)) + return err + + +def check(): + bpy.ops.wm.read_factory_settings(use_empty=True) + + # --- 1. The hazard: calc_uvs=True is a silent no-op without a UV layer --- + me_bad = bpy.data.meshes.new("NoPreUV") + bm = bmesh.new() + try: + bmesh.ops.create_grid( + bm, x_segments=SEG, y_segments=SEG, size=SIZE, calc_uvs=True, + ) + n_layers = len(bm.loops.layers.uv) + n_faces = len(bm.faces) + n_verts = len(bm.verts) + bm.to_mesh(me_bad) + finally: + bm.free() + + if n_layers != 0: + return fail( + f"calc_uvs=True without a pre-existing layer created {n_layers} " + f"UV layer(s) — the silent-no-op hazard is gone (or changed)", + 3, + ) + if len(me_bad.uv_layers) != 0: + return fail( + f"mesh gained {len(me_bad.uv_layers)} UV layer(s) after " + f"calc_uvs=True with no pre-create", + 3, + ) + expect_faces = SEG * SEG + expect_verts = (SEG + 1) * (SEG + 1) + if (n_faces, n_verts) != (expect_faces, expect_verts): + return fail( + f"grid topology {(n_verts, n_faces)} != " + f"expected {(expect_verts, expect_faces)}", + 4, + ) + + # --- 2. Repair: pre-create the layer, then calc_uvs=True fills it ----- + me_ok = bpy.data.meshes.new("PreUV") + bm = bmesh.new() + try: + bm.loops.layers.uv.new("UVMap") + bmesh.ops.create_grid( + bm, x_segments=SEG, y_segments=SEG, size=SIZE, calc_uvs=True, + ) + if len(bm.loops.layers.uv) != 1: + return fail( + f"pre-create + calc_uvs left {len(bm.loops.layers.uv)} " + f"layers (expected 1)", + 5, + ) + uv = bm.loops.layers.uv.active + calc_err = max_uv_err(bm, uv) + bm.to_mesh(me_ok) + finally: + bm.free() + + if calc_err > UV_TOL: + return fail( + f"calc_uvs closed-form error {calc_err:.2e} > {UV_TOL:.0e}", + 6, + ) + if len(me_ok.uv_layers) != 1 or me_ok.uv_layers.active is None: + return fail("pre-create path did not persist a UV layer on the mesh", 5) + + # Mesh-side round-trip of the same closed form (loop data, not bmesh). + mesh_err = 0.0 + uv_data = me_ok.uv_layers.active.data + for poly in me_ok.polygons: + for li in poly.loop_indices: + vi = me_ok.loops[li].vertex_index + ex, ey = expect_uv(me_ok.vertices[vi].co) + got = uv_data[li].uv + mesh_err = max(mesh_err, abs(got.x - ex), abs(got.y - ey)) + if mesh_err > UV_TOL: + return fail( + f"mesh UV round-trip error {mesh_err:.2e} > {UV_TOL:.0e}", + 7, + ) + + # --- 3. Explicit assignment fallback (does not rely on calc_uvs) ------- + me_ex = bpy.data.meshes.new("ExplicitUV") + bm = bmesh.new() + try: + bmesh.ops.create_grid( + bm, x_segments=SEG, y_segments=SEG, size=SIZE, calc_uvs=False, + ) + if len(bm.loops.layers.uv) != 0: + return fail( + "create_grid(calc_uvs=False) unexpectedly created a UV layer", + 8, + ) + uv = bm.loops.layers.uv.new("UVMap") + for face in bm.faces: + for loop in face.loops: + loop[uv].uv = expect_uv(loop.vert.co) + explicit_err = max_uv_err(bm, uv) + bm.to_mesh(me_ex) + finally: + bm.free() + + if explicit_err > UV_TOL: + return fail( + f"explicit UV assignment error {explicit_err:.2e} > {UV_TOL:.0e}", + 9, + ) + + print( + f"hazard confirmed: calc_uvs=True alone → 0 UV layers " + f"(grid {n_verts}v/{n_faces}f); " + f"pre-create + calc_uvs max err {calc_err:.2e} " + f"(mesh round-trip {mesh_err:.2e}); " + f"explicit assign max err {explicit_err:.2e} " + f"(tol {UV_TOL:.0e})" + ) + return 0 + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def make_uv_testcard(name, w=256, h=256): + """Neon checker + corner marker. 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) + cell = w // 8 + for y in range(h): + for x in range(w): + i = (y * w + x) * 4 + cx, cy = x // cell, y // cell + # bottom-left origin: (0,0) is the first pixel the shader samples + # when no UV layer exists. + if x == 0 and y == 0: + # Distinct flat teal — readable as "wrong" at thumbnail scale, + # still obviously not the checker. + px[i:i + 4] = [0.04, 0.28, 0.38, 1.0] + continue + if (cx + cy) % 2 == 0: + # warm magenta + px[i:i + 4] = [0.95, 0.12, 0.55, 1.0] + else: + # electric cyan + px[i:i + 4] = [0.08, 0.85, 0.95, 1.0] + # saturation wash by U so the gradient is readable at thumbnail + u = x / (w - 1) + px[i] = min(1.0, px[i] * (0.55 + 0.45 * u)) + px[i + 2] = min(1.0, px[i + 2] * (0.55 + 0.45 * (1.0 - u))) + img.pixels.foreach_set(px) + return img + + +def specular_off(bsdf): + if "Specular IOR Level" in bsdf.inputs: + bsdf.inputs["Specular IOR Level"].default_value = 0.0 + elif "Specular" in bsdf.inputs: + bsdf.inputs["Specular"].default_value = 0.0 + + +def textured_plane(name, image, with_uvs): + """Unit grid plane (+/- SIZE). with_uvs=False leaves the hazard in place.""" + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + if with_uvs: + bm.loops.layers.uv.new("UVMap") + bmesh.ops.create_grid( + bm, x_segments=SEG, y_segments=SEG, size=SIZE, calc_uvs=with_uvs, + ) + bm.to_mesh(me) + finally: + bm.free() + + mat = bpy.data.materials.new(name + "Mat") + mat.use_nodes = True + nodes, links = mat.node_tree.nodes, mat.node_tree.links + tex = nodes.new("ShaderNodeTexImage") + tex.image = image + tex.interpolation = "Closest" + bsdf = nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = (0.0, 0.0, 0.0, 1.0) + bsdf.inputs["Roughness"].default_value = 1.0 + specular_off(bsdf) + # Emission so the checker reads as authored color, not lit albedo. + links.new(tex.outputs["Color"], bsdf.inputs["Emission Color"]) + bsdf.inputs["Emission Strength"].default_value = 0.95 + me.materials.append(mat) + + obj = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(obj) + return obj + + +def render_still(path, engine): + scene = bpy.context.scene + card = make_uv_testcard("UVCard") + + # 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) + + # 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) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Studio") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 6.8, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.02, 0.021, 0.025, 1.0, + ) + scene.world = world + + def light(name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy = energy + ld.size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + 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)) + + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 1.1) + 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) + con = cam.constraints.new("TRACK_TO") + con.target = aim + con.track_axis = "TRACK_NEGATIVE_Z" + con.up_axis = "UP_Y" + scene.collection.objects.link(cam) + scene.camera = cam + + scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + scene.cycles.samples = 64 + else: + try: + scene.eevee.taa_render_samples = 64 + except AttributeError: + pass + scene.render.resolution_x = 1280 + scene.render.resolution_y = 720 + scene.render.image_settings.file_format = "PNG" + scene.render.filepath = path + # AgX desaturates the neon checker toward pastel — Standard keeps it honest. + scene.view_settings.view_transform = "Standard" + bpy.ops.render.render(write_still=True) + return os.path.exists(path) and os.path.getsize(path) > 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None, help="optional: render a still PNG here") + p.add_argument( + "--engine", default="eevee", choices=("eevee", "cycles"), + help="render engine for --output (cycles for GPU-less hosts)", + ) + args = p.parse_args(argv) + + code = check() + if code != 0: + 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): + return fail(f"render produced no file at {args.output}", 10) + print(f"wrote {args.output}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + import traceback + traceback.print_exc() + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(1)
+UV layer grid: two easel panels on a dark studio floor — left a flat teal square from a missing UV layer sampling texel (0,0), right a magenta/cyan neon checker proving pre-create + calc_uvs + + +### [uv-layer-grid](examples/uv-layer-grid/) + +`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. +