diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 45219e1..fb054a7 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -60,6 +60,7 @@ ], "examples": [ "examples/bmesh-gear", + "examples/color-attribute-wheel", "examples/compositor-glare", "examples/curve-bevel-arc", "examples/damped-track-aim", diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index 15789c1..cc86777 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -253,3 +253,14 @@ jobs: # aligns toward the core (dot ≥ 0.998). Exits non-zero on failure. xvfb-run -a "$BLENDER" --background \ --python examples/damped-track-aim/damped_track_aim.py -- + + - name: Shipped example - color attribute wheel (color_attributes CORNER domain) + run: | + set -euo pipefail + # Frame-independent check only (no render): an HSV wheel disc colored via + # mesh.color_attributes.new() on the CORNER domain; asserts the attribute + # is sized to loop count (not vertex count), is active_color, and probe + # loops match the closed-form HSV for their vertex. Exits non-zero on + # failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/color-attribute-wheel/color_attribute_wheel.py -- diff --git a/AGENTS.md b/AGENTS.md index 0c480bf..c1d4aa1 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. -- 14 examples under `examples//`: runnable scripts that assert a real +- 15 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 @@ -41,7 +41,7 @@ Blender-Developer-Tools/ rules/.mdc # 6 rule files templates// # 2 starter templates snippets/.py # 17 standalone Python snippets - examples// # 14 runnable smoke-gated examples (+ gallery.json) + examples// # 15 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 191c92f..d492689 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, 14 total (+ gallery.json) +examples// - Runnable smoke-gated examples, 15 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 (14) +## Examples (15) 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 fourteen run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`; +`--output`. All fifteen 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 bd51873..2808bec 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,14 @@

- 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  14 examples + 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  15 examples

--- ## Overview -This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 14 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 15 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. @@ -248,6 +248,22 @@ Aim constraints via the data API — `Object.constraints.new('DAMPED_TRACK')` wi Asserts twelve unmuted Damped Track constraints and evaluated local `+Z` alignment toward the core (dot ≥ 0.998). + + + + +Color attribute wheel: an HSV color wheel disc with a white center fading into a vivid rainbow rim, resting on a dark studio floor + + + +### [color-attribute-wheel](examples/color-attribute-wheel/) + +The modern color-attributes API — `mesh.color_attributes.new()` on the `CORNER` +domain, not the deprecated `vertex_colors` alias, filled by expanding per-vertex +HSV across face corners with `foreach_get`/`foreach_set`. Asserts the attribute +is sized to loop count (not vertex count), is `active_color`, and that the +shader `Attribute` node is actually linked to Base Color. + diff --git a/docs/gallery/assets/color-attribute-wheel-hero.webp b/docs/gallery/assets/color-attribute-wheel-hero.webp new file mode 100644 index 0000000..405971f Binary files /dev/null and b/docs/gallery/assets/color-attribute-wheel-hero.webp differ diff --git a/docs/gallery/color-attribute-wheel/index.html b/docs/gallery/color-attribute-wheel/index.html new file mode 100644 index 0000000..113fd03 --- /dev/null +++ b/docs/gallery/color-attribute-wheel/index.html @@ -0,0 +1,532 @@ + + + + + + color-attribute-wheel — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

color-attribute-wheel

+

The modern color-attributes API — mesh.color_attributes.new() on the CORNER domain, filled by expanding per-vertex HSV across face corners with foreach_get/foreach_set, then wired into a shader Attribute node.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses CORNER-domain attributes are sized to loop count, not vertex count; the check asserts that, that active_color points at it, and that the Attribute node is actually linked to Base Color, not just present.
+
+
blender --background --python examples/color-attribute-wheel/color_attribute_wheel.py --
+ +
+
+

A runnable example that builds an HSV color wheel disc entirely with bmesh and colors it with Mesh.color_attributes.new() — the modern attributes API, not the deprecated Mesh.vertex_colors alias AI code keeps reaching for. It witnesses the domain trap that comes with it: a CORNER-domain attribute is sized to len(mesh.loops), not len(mesh.vertices), so per-vertex data has to be expanded across face corners before it is written. The material wires the same attribute into a Shader Attribute node (attribute_type='GEOMETRY') feeding Base Color — the step AI code most often skips, leaving the mesh gray even when the attribute data is correct.

+

What it witnesses: a FLOAT_COLOR attribute on the CORNER domain, filled with one foreach_get (loop → vertex index) and one foreach_set (loop color), never a per-loop Python assignment. The check asserts the attribute is sized to the loop count and *not* the vertex count, that it is color_attributes.active_color (so a renderer or exporter actually picks it up), and that several probe loops match the closed-form HSV value for the vertex they reference. A separate check in the render path confirms the Attribute node is actually linked to Base Color, not just present in the node tree.

+

Run

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

It exits non-zero on failure (missing/mis-sized/mis-domained attribute, wrong active attribute, a probe color mismatch, or an unlinked Attribute node). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.

+
+
+

Source

+
+ examples/color-attribute-wheel/color_attribute_wheel.py + View on GitHub → +
+
"""HSV color-attribute wheel -- a runnable example.
+
+Witnesses the modern color-attribute contract that AI-generated Blender code
+routinely gets wrong: `Mesh.color_attributes.new()` (not the deprecated
+`Mesh.vertex_colors.new()` alias), and the domain trap that comes with it --
+a `CORNER`-domain attribute is sized to `len(mesh.loops)`, not
+`len(mesh.vertices)`, so code that fills it with a per-vertex-sized buffer
+either raises or silently miscolors every shared vertex. This example builds
+a polar disc where every vertex carries one hue/saturation pair, expands that
+per-vertex data across face corners with one `foreach_get` (loop ->
+vertex_index) and one `foreach_set` (loop color), and marks the attribute
+`active_color` so it is the one a renderer or exporter actually picks up. The
+material wires the same attribute into a Shader `Attribute` node
+(`attribute_type='GEOMETRY'`) feeding Base Color -- a step AI code frequently
+skips, leaving the mesh gray even though the attribute data is correct.
+
+By default it runs only the correctness check (no render) -- the CI smoke
+check. Pass --output to also render a still:
+
+    blender --background --python color_attribute_wheel.py --                 # check only
+    blender --background --python color_attribute_wheel.py -- --output w.png  # + render
+"""
+import bpy, bmesh, sys, os, math, colorsys, argparse
+from array import array
+
+RINGS = 14
+SEGMENTS = 72
+R_OUTER = 1.6
+
+N_VERTS = 1 + RINGS * SEGMENTS
+N_FACES = SEGMENTS + (RINGS - 1) * SEGMENTS
+N_LOOPS = 3 * SEGMENTS + 4 * (RINGS - 1) * SEGMENTS
+ATTR_NAME = "Hue"
+
+
+def vidx(r, s):
+    """Vertex index for ring r (0 = center) and segment s, matching build order."""
+    return 0 if r == 0 else 1 + (r - 1) * SEGMENTS + (s % SEGMENTS)
+
+
+def wheel_geometry():
+    """Vertex coords and per-vertex (h, s, v) in the same order as vidx()."""
+    coords = [(0.0, 0.0, 0.0)]
+    hsv = [(0.0, 0.0, 1.0)]  # center: fully desaturated, white
+    for r in range(1, RINGS + 1):
+        radius = R_OUTER * r / RINGS
+        sat = min(1.0, (r / RINGS) * 1.4)
+        for s in range(SEGMENTS):
+            angle = 2.0 * math.pi * s / SEGMENTS
+            coords.append((radius * math.cos(angle), radius * math.sin(angle), 0.0))
+            hsv.append((s / SEGMENTS, sat, 1.0))
+    return coords, hsv
+
+
+def build_wheel():
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    coords, hsv = wheel_geometry()
+    me = bpy.data.meshes.new("ColorWheel")
+    bm = bmesh.new()
+    try:
+        verts = [bm.verts.new(co) for co in coords]
+        for s in range(SEGMENTS):
+            bm.faces.new([verts[vidx(0, 0)], verts[vidx(1, s)], verts[vidx(1, s + 1)]])
+        for r in range(1, RINGS):
+            for s in range(SEGMENTS):
+                bm.faces.new([
+                    verts[vidx(r, s)], verts[vidx(r, s + 1)],
+                    verts[vidx(r + 1, s + 1)], verts[vidx(r + 1, s)],
+                ])
+        bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
+        bm.to_mesh(me)
+    finally:
+        bm.free()  # the always-free-bmesh contract
+
+    # The contract this example witnesses: a CORNER-domain color attribute,
+    # created via color_attributes (not the deprecated vertex_colors alias),
+    # sized to loops -- then filled by expanding per-vertex HSV across corners
+    # with bulk foreach_get / foreach_set, never a per-loop Python assignment.
+    attr = me.color_attributes.new(ATTR_NAME, type='FLOAT_COLOR', domain='CORNER')
+    n_loops = len(me.loops)
+    loop_vert = array('i', [0]) * n_loops
+    me.loops.foreach_get("vertex_index", loop_vert)
+    flat = array('f', [0.0]) * (n_loops * 4)
+    for i, vi in enumerate(loop_vert):
+        h, s, v = hsv[vi]
+        r, g, b = colorsys.hsv_to_rgb(h, s, v)
+        flat[i * 4], flat[i * 4 + 1], flat[i * 4 + 2], flat[i * 4 + 3] = r, g, b, 1.0
+    attr.data.foreach_set("color", flat)
+    me.color_attributes.active_color = attr  # the step AI code most often forgets
+
+    obj = bpy.data.objects.new("ColorWheel", me)
+    bpy.context.collection.objects.link(obj)
+    return obj, hsv
+
+
+def check(obj, hsv):
+    me = obj.data
+    got = (len(me.vertices), len(me.polygons), len(me.loops))
+    expect = (N_VERTS, N_FACES, N_LOOPS)
+    if got != expect:
+        print(f"ERROR: topology (verts,faces,loops)={got} != expected {expect}", file=sys.stderr)
+        return 3
+
+    attr = me.color_attributes.get(ATTR_NAME)
+    if attr is None:
+        print(f"ERROR: color attribute '{ATTR_NAME}' missing", file=sys.stderr)
+        return 4
+    if attr.domain != 'CORNER' or attr.data_type != 'FLOAT_COLOR':
+        print(f"ERROR: attribute domain/type = {attr.domain}/{attr.data_type}, "
+              f"expected CORNER/FLOAT_COLOR", file=sys.stderr)
+        return 5
+    if len(attr.data) != len(me.loops) or len(attr.data) == len(me.vertices):
+        print(f"ERROR: attribute is sized {len(attr.data)}, expected loop count "
+              f"{len(me.loops)} and distinct from vertex count {len(me.vertices)} "
+              f"-- CORNER domain must not be POINT-sized", file=sys.stderr)
+        return 6
+
+    active = me.color_attributes.active_color
+    if active is None or active.name != attr.name:
+        print(f"ERROR: active_color is "
+              f"{active.name if active else None!r}, expected {ATTR_NAME!r}", file=sys.stderr)
+        return 7
+
+    n_loops = len(me.loops)
+    loop_vert = array('i', [0]) * n_loops
+    me.loops.foreach_get("vertex_index", loop_vert)
+    colors = array('f', [0.0]) * (n_loops * 4)
+    attr.data.foreach_get("color", colors)
+    probes = sorted({0, SEGMENTS + 1, n_loops // 2, n_loops - 1})
+    for li in probes:
+        vi = loop_vert[li]
+        h, s, v = hsv[vi]
+        er, eg, eb = colorsys.hsv_to_rgb(h, s, v)
+        gr, gg, gb, ga = colors[li * 4:li * 4 + 4]
+        if max(abs(gr - er), abs(gg - eg), abs(gb - eb), abs(ga - 1.0)) > 1e-5:
+            print(f"ERROR: loop {li} (vertex {vi}) color {(gr, gg, gb, ga)} != "
+                  f"expected {(er, eg, eb, 1.0)}", file=sys.stderr)
+            return 8
+
+    print(f"verts={got[0]} faces={got[1]} loops={got[2]} attribute='{attr.name}' "
+          f"domain={attr.domain} active=True probes_ok={len(probes)}")
+    return 0
+
+
+def eevee_engine_id():
+    return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
+
+
+def build_material():
+    mat = bpy.data.materials.new("Wheel")
+    mat.use_nodes = True
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    attr_node = nt.nodes.new('ShaderNodeAttribute')
+    attr_node.attribute_type = 'GEOMETRY'
+    attr_node.attribute_name = ATTR_NAME
+    nt.links.new(attr_node.outputs["Color"], bsdf.inputs["Base Color"])
+    if "Emission Color" in bsdf.inputs:  # Principled gained built-in emission in 4.x+
+        nt.links.new(attr_node.outputs["Color"], bsdf.inputs["Emission Color"])
+        bsdf.inputs["Emission Strength"].default_value = 0.12
+    bsdf.inputs["Roughness"].default_value = 0.62
+
+    # The step AI code most often skips: the attribute must actually be wired
+    # into the shader, not just present on the mesh.
+    wired = any(
+        link.from_node.name == attr_node.name
+        and link.from_socket.name == "Color"
+        and link.to_socket == bsdf.inputs["Base Color"]
+        for link in nt.links
+    )
+    if not wired:
+        print("ERROR: Attribute node is not linked to Base Color", file=sys.stderr)
+        return None
+    return mat
+
+
+def render_still(obj, path, engine):
+    scene = bpy.context.scene
+    for poly in obj.data.polygons:
+        poly.use_smooth = True
+
+    mat = build_material()
+    if mat is None:
+        return False
+    obj.data.materials.append(mat)
+    obj.location = (0.0, 0.0, 0.55)
+    obj.rotation_euler = (math.radians(14), 0.0, math.radians(12))
+
+    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, 7.0, 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)
+
+    # a bright soft key from above reads the hue ring clearly; a low cool fill
+    # keeps the shadow side legible; a faint warm rim separates the disc edge
+    # from the dark backdrop without washing out the attribute colors.
+    light("Key", (-2.0, -3.0, 5.5), 380.0, 11.0, (1.0, 0.98, 0.96), (58, 0, -28))
+    light("Fill", (4.5, -2.5, 1.6), 140.0, 9.0, (0.78, 0.86, 1.0), (68, 0, 55))
+    light("Rim", (0.5, 3.6, 2.2), 170.0, 4.0, (1.0, 0.78, 0.55), (-70, 0, 175))
+
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = obj.location
+    scene.collection.objects.link(aim)
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (0.0, -7.4, 3.6)
+    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 (the 4.x/5.x default) compresses bright regions toward white, which
+    # would hide exactly the saturation gradient this example is showing off.
+    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)
+
+    obj, hsv = build_wheel()
+    code = check(obj, hsv)
+    if code:
+        return code
+
+    if args.output:
+        if not render_still(obj, os.path.abspath(args.output), args.engine):
+            print("ERROR: render produced no file", file=sys.stderr)
+            return 9
+        print(f"rendered still {args.output}")
+
+    print("color-attribute-wheel OK")
+    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/docs/gallery/index.html b/docs/gallery/index.html index 9cc8f0e..af564d3 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -175,6 +175,7 @@

Examples Gallery

+
+ + color-attribute-wheel — The modern color-attributes API — mesh + +
+

color-attribute-wheel

+

The modern color-attributes API — mesh.color_attributes.new() on the CORNER domain, filled by expanding per-vertex HSV across face corners with foreach_get/foreach_set, then wired into a shader Attribute node.

+

witnesses CORNER-domain attributes are sized to loop count, not vertex count; the check asserts that, that active_color points at it, and that the Attribute node is actually linked to Base Color, not just present.

+ View example +
+