Examples Gallery
GitHub

attribute-domain-shear

POINT vs CORNER color-attribute domains on a shared-vertex pinwheel: CORNER stays exact per face while a naive per-wedge POINT loop shears to the last write at the hub. Crisp petals versus a smeared swirl in one frame; the measured shear equals the palette closed form.

Rendered headless by the example itself — click to zoom.

witnesses Authoring per-face colors into a POINT-domain attribute overwrites shared vertices once per neighbor; the last write wins and intended colors shear across every shared vertex.
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:

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

examples/attribute-domain-shear/attribute_domain_shear.py View on GitHub →
"""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)