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 0000000..b44dbeb Binary files /dev/null and b/docs/gallery/assets/uv-layer-grid-hero.webp differ 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 0000000..4f6aa26 Binary files /dev/null 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 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. +