From 7e2736a1e58438f59ad890db08017945ec354fcc Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Sat, 18 Jul 2026 16:13:35 -0400 Subject: [PATCH] feat: add color-attribute-wheel example Witnesses the modern color-attributes API (mesh.color_attributes.new on the CORNER domain) that AI code routinely swaps for the deprecated vertex_colors alias, and the domain-size trap that follows: a CORNER attribute is sized to loop count, not vertex count, so per-vertex data must be expanded across face corners before writing. Also asserts the Attribute node is actually linked to Base Color, since AI code frequently creates the attribute but forgets to wire it into the shader. --- .cursor-plugin/plugin.json | 1 + .github/workflows/blender-smoke.yml | 11 + AGENTS.md | 4 +- CLAUDE.md | 6 +- README.md | 20 +- .../assets/color-attribute-wheel-hero.webp | Bin 0 -> 16348 bytes docs/gallery/color-attribute-wheel/index.html | 532 ++++++++++++++++++ docs/gallery/index.html | 12 + examples/color-attribute-wheel/README.md | 35 ++ .../color_attribute_wheel.py | 290 ++++++++++ examples/color-attribute-wheel/preview.webp | Bin 0 -> 12964 bytes examples/gallery.json | 13 + 12 files changed, 917 insertions(+), 7 deletions(-) create mode 100644 docs/gallery/assets/color-attribute-wheel-hero.webp create mode 100644 docs/gallery/color-attribute-wheel/index.html create mode 100644 examples/color-attribute-wheel/README.md create mode 100644 examples/color-attribute-wheel/color_attribute_wheel.py create mode 100644 examples/color-attribute-wheel/preview.webp 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 0000000000000000000000000000000000000000..405971f42a2a187d243625e16c21d526540e89bb GIT binary patch literal 16348 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 literal 0 HcmV?d00001 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 +
+