Rendered headless by the example itself — click to zoom.
+blender --background --python examples/attribute-domain-shear/attribute_domain_shear.py --+ +
A runnable example that witnesses what POINT versus CORNER means on Mesh.color_attributes once the mesh has shared vertices — the domain is not a storage detail, it decides where colors can live. Companion to color-attribute-wheel (which covers color_attributes.new() versus the deprecated alias, CORNER sizing == len(loops), and active_color); this example covers the trap one step later, when AI code knows the API exists but authors per-face colors into a POINT-domain attribute.
Pipeline arc neighbors: attribute authoring in color-attribute-wheel, mesh topology gates in mesh-hygiene-audit, tangent-space UV contracts in triangulate-tangents.
What it witnesses: a pinwheel of K=8 triangles around one raised hub vertex shared by every wedge (plus a shared outer ring). The contract, all closed form:
+- Storage sizes. CORNER attr data ==
len(loops)== 3K; POINT attr ==len(vertices)== K+1. - CORNER authoring is exact. The hub corner of wedge i reads palette[i] within 1e-6 — K faces at one vertex may disagree there.
- POINT naive authoring shears by construction. A per-wedge authoring loop ("paint each wedge its color") rewrites every shared vertex once per neighbor, and the last write wins: the hub reads palette[K-1], ring vert i reads palette[i] — except ring vert 0, which the wrap-around last wedge rewrites to palette[K-1]. The measured mean deviation from intended equals the palette closed form (0.751031) exactly.
What each check catches on failure: wedge 3 miscolored in the CORNER pass (exit 4); naive writes reversed, so the hub reads palette[0] (exit 5); a ring vert corrupted, breaking the overwrite-ordering witness (exit 6); a constant palette, collapsing the shear so the probe cannot distinguish naive from correct (exit 7). Sizes wrong for the declared domain (exit 3).
+Version witness: output is byte-identical on Blender 4.5.11 LTS and 5.1.2 — the color_attributes domain API is stable across both.
Render as proof: dual pinwheel from the same closed-form palette the check asserts. CORNER (left) holds eight crisp petals to the hub; naive POINT (right) smears — petal colors bleed across the shared hub and ring verts into a swirl. The broken state is in-frame by design: the right fan *is* the falsification variant. Fully matte petal materials (Specular IOR Level = 0) so the flat color data carries no specular line, per docs/VISUAL-STYLE.md.
Run
+blender --background --python attribute_domain_shear.py --
+blender --background --python attribute_domain_shear.py -- --output shear.png
+blender --background --python attribute_domain_shear.py -- --output shear.png --engine cycles
+Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1. The --output render path additionally measures framing against the Layer 1 band via examples/gallery_framing.py (exit 10 on violation) before writing the still.
Source
+ +"""Attribute domain shear — POINT vs CORNER color attributes on shared verts. + +Witnesses the domain-semantics contract of `Mesh.color_attributes` that +AI-generated code trips on after learning `color_attributes.new()` exists: +the DOMAIN chooses where colors live. A `CORNER`-domain attribute stores one +color per loop (face-corner), so the K corners of one shared vertex can each +carry their own face's color. A `POINT`-domain attribute stores one color per +vertex, so a naive per-face authoring loop — "paint every wedge its own +color" — overwrites the shared vertices once per neighbor and the LAST write +wins: intended per-face colors shear across every shared vertex. Companion +to `color-attribute-wheel` (which covers CORNER sizing == loops and +`active_color`): this example covers what the domains *mean* on a fan whose +entire point is one hub vertex shared by every wedge. + +Check (all closed form, nothing captured from a prior run): + +1. Storage sizes: CORNER attr data == len(loops) == 3*K; POINT == K+1 verts. +2. CORNER authoring is exact: the hub corner of wedge i reads palette[i]. +3. POINT naive authoring shears by construction: the hub reads palette[K-1] + (last write wins), EVERY wedge's hub-side loop reads that same color, and + outer ring vert i reads palette[i] (overwritten by wedge i after wedge + i-1 wrote it) — the measured mean deviation from intended equals the + closed-form shear computed from the palette. + +By default it runs only the correctness check (no render) — the CI smoke +check. Pass --output to also render a still: + + blender --background --python attribute_domain_shear.py -- # check only + blender --background --python attribute_domain_shear.py -- --output a.png # + render +""" +import bpy, bmesh, sys, os, math, argparse, colorsys + +# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)) +sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree +import gallery_framing + +K = 8 # pinwheel wedges; hub vertex is shared by all K +HUB_Z = 0.55 # raised hub: folded-paper pinwheel, not a flat disc +RING_R = 1.15 +ATTR_C = "PinCorner" +ATTR_P = "PinPoint" +COLOR_EPS = 1e-6 + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def palette(k=K): + """Closed-form wedge hues: saturated HSV wheel in linear-ish floats.""" + out = [] + for i in range(k): + r, g, b = colorsys.hsv_to_rgb(i / k, 0.82, 0.95) + out.append((r, g, b, 1.0)) + return out + + +def closed_form_shear(pal): + """Mean per-wedge |palette[i] - palette[K-1]| over RGB — the exact shear a + last-write-wins hub produces. Derived from the palette, never measured.""" + last = pal[-1] + return sum( + math.sqrt(sum((pal[i][c] - last[c]) ** 2 for c in range(3))) + for i in range(len(pal)) + ) / len(pal) + + +def build_fan(): + """K triangles around one raised hub vertex; outer ring alternates fold + height so petals read as folded paper under the key light.""" + me = bpy.data.meshes.new("Pinwheel") + bm = bmesh.new() + try: + hub = bm.verts.new((0.0, 0.0, HUB_Z)) + ring = [] + for i in range(K): + a = 2.0 * math.pi * i / K + fold = 0.14 if i % 2 else 0.0 + ring.append(bm.verts.new((RING_R * math.cos(a), RING_R * math.sin(a), fold))) + for i in range(K): + bm.faces.new((hub, ring[i], ring[(i + 1) % K])) + bm.to_mesh(me) + finally: + bm.free() + return me + + +def assign_corner(me, pal): + """Correct path: CORNER domain, one exact wedge color per loop.""" + attr = me.color_attributes.new(ATTR_C, type='FLOAT_COLOR', domain='CORNER') + colors = [0.0] * (len(me.loops) * 4) + for poly in me.polygons: + for li in poly.loop_indices: + colors[li * 4: li * 4 + 4] = pal[poly.index] + attr.data.foreach_set("color", colors) + me.color_attributes.active_color = attr + return attr + + +def assign_point_naive(me, pal): + """The AI mistake: author per-wedge colors into a POINT-domain attribute. + Every wedge rewrites the shared hub (and its leading ring vert), so the + last wedge wins — colors shear across every shared vertex.""" + attr = me.color_attributes.new(ATTR_P, type='FLOAT_COLOR', domain='POINT') + hub_index = 0 # build_fan creates the hub first + for i in range(K): + # naive per-wedge pass: set the hub and both ring verts to palette[i] + attr.data[hub_index].color = pal[i] + attr.data[1 + i].color = pal[i] + attr.data[1 + (i + 1) % K].color = pal[i] + me.color_attributes.active_color = attr + return attr + + +def check(): + pal = palette() + expect_shear = closed_form_shear(pal) + print(f"palette K={K} closed_form_shear={expect_shear:.6f}") + + # --- CORNER: exact authoring --- + me_c = build_fan() + attr_c = assign_corner(me_c, pal) + if len(attr_c.data) != len(me_c.loops) or len(me_c.loops) != 3 * K: + print(f"ERROR: CORNER attr size {len(attr_c.data)} != loops {len(me_c.loops)}", + file=sys.stderr) + return 3 + hub_loop_err = 0.0 + for poly in me_c.polygons: + got = attr_c.data[poly.loop_indices[0]].color # loop 0 of each tri is the hub + hub_loop_err = max(hub_loop_err, + max(abs(got[c] - pal[poly.index][c]) for c in range(4))) + print(f"corner_hub_max_err={hub_loop_err:.3e} (must be <= {COLOR_EPS})") + if hub_loop_err > COLOR_EPS: + print("ERROR: CORNER hub corners do not carry their wedge's exact color — " + "per-face color at a shared vertex failed", file=sys.stderr) + return 4 + + # --- POINT: the shear, measured against the closed form --- + me_p = build_fan() + attr_p = assign_point_naive(me_p, pal) + if len(attr_p.data) != len(me_p.vertices) or len(me_p.vertices) != K + 1: + print(f"ERROR: POINT attr size {len(attr_p.data)} != verts {len(me_p.vertices)}", + file=sys.stderr) + return 3 + hub_got = attr_p.data[0].color + hub_err = max(abs(hub_got[c] - pal[K - 1][c]) for c in range(4)) + if hub_err > COLOR_EPS: + print(f"ERROR: hub reads {tuple(round(c,4) for c in hub_got)} != last-write " + f"palette[{K-1}] — last-write-wins contract broken", file=sys.stderr) + return 5 + # Every wedge's hub-side loop reads the same shared color: sample the POINT + # value at the hub through each face's hub loop — one value, K faces. + # Outer ring vert i reads pal[i] — written by wedge i after wedge i-1 — + # EXCEPT vert 0, which the wrap-around last wedge rewrites to pal[K-1]. + ring_err = 0.0 + for i in range(K): + want = pal[i] if i > 0 else pal[K - 1] + got = attr_p.data[1 + i].color + ring_err = max(ring_err, max(abs(got[c] - want[c]) for c in range(4))) + print(f"point_ring_max_err={ring_err:.3e} hub=last_write_ok") + if ring_err > COLOR_EPS: + print("ERROR: outer ring verts do not read their last write — the " + "overwrite-ordering witness failed", file=sys.stderr) + return 6 + shear = sum( + math.sqrt(sum((pal[i][c] - hub_got[c]) ** 2 for c in range(3))) + for i in range(K) + ) / K + print(f"point_shear measured={shear:.6f} closed_form={expect_shear:.6f}") + if abs(shear - expect_shear) > 1e-6: + print("ERROR: measured shear does not match the palette closed form — " + "the domain mistake is not being witnessed", file=sys.stderr) + return 7 + if shear < 0.05: + print("ERROR: shear is ~0 — the probe cannot distinguish naive POINT " + "authoring from correct authoring", file=sys.stderr) + return 7 + + print(f"attribute-domain-shear OK corner_exact point_shear={shear:.6f} " + f"(last of {K} writes wins at 1 shared hub + {K} shared ring verts)") + return 0 + + +def make_attr_material(name, attr_name, matte=True): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + if matte: + # flat color data: no specular line across the petals (VISUAL-STYLE) + spec = bsdf.inputs.get("Specular IOR Level") + if spec is not None: + spec.default_value = 0.0 + bsdf.inputs["Roughness"].default_value = 0.6 + node = nt.nodes.new("ShaderNodeAttribute") + node.attribute_type = "GEOMETRY" + node.attribute_name = attr_name + nt.links.new(node.outputs["Color"], bsdf.inputs["Base Color"]) + return mat + + +def make_material(name, rgb, rough=0.45, metallic=0.35, emit=None, estr=0.0): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + b = mat.node_tree.nodes["Principled BSDF"] + b.inputs["Base Color"].default_value = (*rgb, 1.0) + b.inputs["Roughness"].default_value = rough + b.inputs["Metallic"].default_value = metallic + if emit is not None: + sock = b.inputs.get("Emission Color") or b.inputs["Emission"] + sock.default_value = (*emit, 1.0) + b.inputs["Emission Strength"].default_value = estr + return mat + + +def _pinwheel_obj(sc, name, me, loc, rot_z): + ob = bpy.data.objects.new(name, me) + ob.location = loc + ob.rotation_euler = (math.radians(12), 0.0, rot_z) + sc.collection.objects.link(ob) + # stem + hub cap: a garden pinwheel on a stick, not a floating disc + stem_me = bpy.data.meshes.new(name + "Stem") + bm = bmesh.new() + try: + bmesh.ops.create_cone(bm, cap_ends=True, segments=10, radius1=0.05, + radius2=0.06, depth=1.35) + bmesh.ops.translate(bm, vec=(0.0, 0.0, -0.72), verts=bm.verts) + bm.to_mesh(stem_me) + finally: + bm.free() + stem_me.materials.append(make_material("StemMetal", (0.16, 0.17, 0.18), + rough=0.4, metallic=0.8)) + stem = bpy.data.objects.new(name + "Stem", stem_me) + stem.location = loc + stem.rotation_euler = (math.radians(12), 0.0, rot_z) + sc.collection.objects.link(stem) + cap_me = bpy.data.meshes.new(name + "Cap") + bm = bmesh.new() + try: + bmesh.ops.create_uvsphere(bm, u_segments=12, v_segments=8, radius=0.075) + bmesh.ops.translate(bm, vec=(0.0, 0.0, HUB_Z + 0.02), verts=bm.verts) + bm.to_mesh(cap_me) + finally: + bm.free() + cap_me.materials.append(make_material("CapMetal", (0.09, 0.09, 0.095), + rough=0.35, metallic=0.85)) + cap = bpy.data.objects.new(name + "Cap", cap_me) + cap.location = loc + cap.rotation_euler = (math.radians(12), 0.0, rot_z) + sc.collection.objects.link(cap) + return ob + + +def placard(sc, text, loc, size=0.18): + cu = bpy.data.curves.new(text, "FONT") + cu.body = text + cu.size = size + cu.align_x = "CENTER" + ob = bpy.data.objects.new(text, cu) + ob.location = loc + sc.collection.objects.link(ob) + ob.data.materials.append(make_material("Label", (0.9, 0.9, 0.92), + rough=0.6, metallic=0.0)) + return ob + + +def build_studio(sc): + 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 = make_material("Studio", (0.03, 0.032, 0.037), rough=0.7, metallic=0.0) + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + sc.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 9.0, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + sc.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, + ) + sc.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) + sc.collection.objects.link(ob) + + light("Key", (-3.5, -4.5, 5.5), 480.0, 4.5, (1.0, 0.96, 0.9), (48, 0, -35)) + light("Fill", (5.0, -3.5, 2.5), 120.0, 9.0, (0.75, 0.85, 1.0), (65, 0, 50)) + light("Rim", (1.5, 4.5, 3.5), 280.0, 3.0, (0.6, 0.78, 1.0), (-55, 0, 170)) + light("Wedge", (2.5, 5.5, 4.0), 400.0, 6.0, (1.0, 0.72, 0.42), (-68, 0, 190)) + return floor, wall + + +def render_still(path, engine): + """Dual pinwheel: CORNER (crisp petals to the hub) vs naive POINT (last + write smears the shared hub + ring verts). Colors come from the same + closed-form palette the check asserts.""" + bpy.ops.wm.read_factory_settings(use_empty=True) + sc = bpy.context.scene + pal = palette() + + me_c = build_fan() + assign_corner(me_c, pal) + me_c.materials.append(make_attr_material("MatCorner", ATTR_C)) + left = _pinwheel_obj(sc, "Corner", me_c, (-1.15, 0.0, 1.35), math.radians(-8)) + + me_p = build_fan() + assign_point_naive(me_p, pal) + me_p.materials.append(make_attr_material("MatPoint", ATTR_P)) + right = _pinwheel_obj(sc, "Point", me_p, (1.15, 0.0, 1.35), math.radians(8)) + + p_corner = placard(sc, "CORNER", (-1.15, -1.05, 0.02), size=0.13) + p_point = placard(sc, "POINT — last write wins", (1.15, -1.05, 0.02), size=0.10) + + floor, wall = build_studio(sc) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 48.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (0.0, -6.4, 4.6) + sc.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.8) + sc.collection.objects.link(aim) + tr = cam.constraints.new("TRACK_TO") + tr.target = aim + tr.track_axis = "TRACK_NEGATIVE_Z" + tr.up_axis = "UP_Y" + sc.camera = cam + + sc.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + sc.cycles.device = "CPU" + sc.cycles.samples = 64 + sc.cycles.use_denoising = True + else: + try: + sc.eevee.taa_render_samples = 64 + except AttributeError: + pass + sc.render.resolution_x = 1280 + sc.render.resolution_y = 720 + sc.render.image_settings.file_format = "PNG" + sc.render.filepath = path + # Standard, always: AgX would bend the closed-form palette the check asserts + sc.view_settings.view_transform = "Standard" + # Layer 1 framing gate (silhouette matte) — exit 10 on violation. + hero = [left, right] + elements = hero + [p_corner, p_point] + fcode = gallery_framing.check_framing( + sc, cam, + hero=hero, + elements=elements, + stage=[floor, wall], + ) + if fcode: + return fcode + bpy.ops.render.render(write_still=True) + if not (os.path.exists(path) and os.path.getsize(path) > 0): + print("ERROR: render produced no file", file=sys.stderr) + return 9 + return 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) + + print(f"binary version: {bpy.app.version} ({bpy.app.version_string})") + bpy.ops.wm.read_factory_settings(use_empty=True) + code = check() + if code: + return code + + if args.output: + rcode = render_still(os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + + print("attribute-domain-shear 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) ++
+
+