diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 8ef9afd..000b4f8 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -60,10 +60,11 @@ ], "examples": [ "examples/armature-bend", + "examples/attribute-domain-shear", "examples/bmesh-gear", "examples/car-mirror-symmetry", - "examples/color-attribute-wheel", "examples/collision-hull-proxy", + "examples/color-attribute-wheel", "examples/compositor-glare", "examples/curve-bevel-arc", "examples/custom-normals-shade", diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index d232c3a..521d82e 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -542,3 +542,15 @@ jobs: # non-zero on failure. xvfb-run -a "$BLENDER" --background \ --python examples/car-mirror-symmetry/car_mirror_symmetry.py -- + + - name: Shipped example - attribute domain shear (POINT vs CORNER semantics) + run: | + set -euo pipefail + # Check only (no render): an 8-wedge pinwheel around one shared hub + # vertex; asserts CORNER attr sizing (3K loops) and exact per-wedge + # hub colors, POINT sizing (K+1 verts), and that a naive per-wedge + # POINT loop shears to the last write at every shared vertex with + # the measured deviation equal to the palette closed form. Exits + # non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/attribute-domain-shear/attribute_domain_shear.py -- diff --git a/AGENTS.md b/AGENTS.md index 046c12a..5cb742e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ a `.cursor-plugin/plugin.json` manifest so the ecosystem drift checker classifies it as a `cursor-plugin`. This is content the AI loads when the user asks Blender questions or works on Blender add-ons in Cursor or Claude Code. -The content base is 12 skills, 6 rules, 2 templates, 17 snippets, and 23 +The content base is 12 skills, 6 rules, 2 templates, 17 snippets, and 39 examples (counts are CI-enforced against README.md and the manifest). The full inventory tables and per-item purposes live in `CLAUDE.md`. Example anatomy and authoring rules: copy `examples/bmesh-gear/`; the render look is specified @@ -35,7 +35,7 @@ Blender-Developer-Tools/ rules/.mdc # 6 rule files templates// # 2 starter templates snippets/.py # 17 standalone Python snippets - examples// # 23 runnable smoke-gated examples (+ gallery.json) + examples// # 39 runnable smoke-gated examples (+ gallery.json) examples/gallery_framing.py # shared Layer 1 framing measurement (render path only) scripts/build_gallery.py # generates docs/gallery/ (stdlib only) scripts/site/ # vendored landing-page build (build_site.py + template) diff --git a/CLAUDE.md b/CLAUDE.md index 4259c6c..ca11478 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,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, 23 total (+ gallery.json) +examples// - Runnable smoke-gated examples, 39 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 @@ -82,11 +82,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 (23) +## Examples (39) 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-three run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`; +`--output`. All thirty-nine 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 2a49cd9..b8a33bf 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  38 examples + 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  39 examples

@@ -739,6 +739,27 @@ Companion to [`depsgraph-export`](examples/depsgraph-export/) (evaluated-vs- original) and [`shape-key-blend`](examples/shape-key-blend/) (non-destructive evaluation). Break the mirror and the render is literally half a car. + + + + +Attribute domain shear: two eight-petal pinwheels side by side — left CORNER-domain with crisp colored petals to the hub, right naive POINT-domain smeared into a swirl, proving last-write-wins shear at shared vertices + + + +### [attribute-domain-shear](examples/attribute-domain-shear/) + +What `POINT` versus `CORNER` **means** on `Mesh.color_attributes` once a mesh +has shared vertices. An eight-wedge pinwheel around one raised hub: CORNER +authoring holds each wedge's exact color (hub corners disagree by face, +err ≤ 1e-6), while the naive per-wedge POINT loop rewrites every shared +vertex once per neighbor and the **last write wins** — the hub reads +palette[K−1], and the measured shear matches the palette closed form +(0.751031) exactly. Companion to +[`color-attribute-wheel`](examples/color-attribute-wheel/) (domain sizing, +`active_color`, the shader Attribute node). The broken state is in-frame: +the right pinwheel is the falsification variant. + diff --git a/ROADMAP.md b/ROADMAP.md index 52abe8a..22fef76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -100,7 +100,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - Gallery coverage follow-ups from the GPv3 review: light-linking (`grease-pencil-rosette`, `armature-bend`, `text-version-stamp`, `image-pixels-testcard`, and `vse-cut-list` shipped first) - ~~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~~ **SHIPPED** as `examples/png-exr-alpha/` — float→PNG is RGBA16 and false-unpremultiplies as if associated-alpha (closed-form err 0.98 at RGB 0.02 / a=1/255); OpenEXR preserves float RGBA; byte→PNG is straight RGBA8; `EXR color_mode='RGB'` drops alpha -- 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` +- ~~Attribute domain witness~~ **SHIPPED** as `examples/attribute-domain-shear/` — POINT vs CORNER semantics on an 8-wedge pinwheel with one shared hub: CORNER authoring exact (hub corners disagree by face, err ≤ 1e-6), naive per-wedge POINT loop shears to the last write at hub + ring verts, measured deviation == palette closed form (0.751031); byte-identical on 4.5.11 and 5.1.2; crisp-vs-smeared dual pinwheel still — companion to `color-attribute-wheel` - ~~Light-linking collection witness~~ **SHIPPED** as `examples/light-link-studio/` — the API is `obj.light_linking` on the light OBJECT (`ld.light_linking` is an AttributeError on both versions); linked ratio 4.0x at projected-center luminance samples, unlink raises the decoy 244–251% with 0.0% hero drift in the same two-render check; EEVEE Next honors linking too (5.5x on 4.5.11, 5.9x on 5.1.2), Cycles pinned for deterministic samples - ~~VSE sequences-to-strips witness~~ **SHIPPED** as `examples/vse-cut-list/` — `.sequences` removed on 5.x (4.5 bridges to `.strips`), `new_effect` end kwarg `frame_end=` (4.5) vs `length=` (5.x), `frame_final_*` deprecated in favor of `left_handle`/`right_handle`/`duration`, TRANSFORM effect type removed, GAMMA_CROSS clamps to the source overlap, effect strips consume inputs only when stacked above them, same-scene scene strips render transparent; save/reload round-trip + tiny-render pixel witness - ~~glTF export round-trip witness~~ **SHIPPED** as `examples/gltf-export-roundtrip/` — `export_yup` bakes `(x,y,z)→(x,z,−y)` into vertex data with no node rotation (probed identical on 4.5.11 and 5.1.2), `export_apply` ships the evaluated mesh (one disk vertex per evaluated loop), TEXCOORD_0 is V-flipped on disk, per-triangle material bindings survive; exporter/importer RNA signatures byte-identical between 4.5.11 and 5.1.2 (guarded against future renames); `Mesh.calc_normals()` removal on 5.x surfaced during authoring @@ -127,7 +127,6 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - Bicycle-wheel radial witness: spokes via radial Array with object offset — exact angular spacing, spoke count, hub/rim concentricity, every evaluated spoke endpoint on its computed rim coordinate - ~~mesh-hygiene-audit witness~~ **SHIPPED** as `examples/mesh-hygiene-audit/` — engine-ingest topology checklist on a stepped street electrical pedestal: no ngons, no loose verts, manifold edges (exactly 2 faces), no zero-area faces (`min_area` printed), positive signed volume, Euler `V−E+F==2` (24/44/22, volume 0.989248); byte-identical on 4.5.11 and 5.1.2; dual-panel dirty vs clean still - UV atlas pack witness — lightmap/unique UV island packing with non-overlap + utilization closed forms (deferred behind hygiene/origin; overlaps `uv-layer-grid` / `triangulate-tangents` / `gltf-export-roundtrip` UV arc) -- 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` - ~~Gallery framing recomposition worklist~~ **DRAINED**: all four recomposition cases restaged and gated with `check_framing` — depsgraph-export (0.984x/touch→0.812x; cube+dome recentered, aim-based camera), vertex-weight-limit (touch B→0.867y; more frontal camera, lower aim, base in frame), triangulate-tangents (0.334x→0.833y; 2x-class move-in plus Key/Glint trim that also cleared a pre-existing dome blowout), text-version-stamp (0.663x→0.719x; moved in with aim on the stamp center, dead third gone). The seven marginal cases were camera-nudged in the preceding sweep; deviations documented in #108 (damped-track-aim, wave-displace); sky-texture-sun-elevation is a measurement artifact (world subject — report-only if wired). Remaining: nothing on the framing worklist — future examples inherit the gate at authoring time. diff --git a/docs/VISUAL-STYLE.md b/docs/VISUAL-STYLE.md index 01bacd7..cccf588 100644 --- a/docs/VISUAL-STYLE.md +++ b/docs/VISUAL-STYLE.md @@ -85,6 +85,18 @@ the default stage. - The image must witness the API: if the contract failed, the render should visibly break. If the render would look the same whether the code worked or not, the scene design is wrong, not merely unpolished. +- **Numeric-only contracts are a narrow, declared exception.** Some contracts + have no visual signature: the broken state renders pixel-identical because + the evidence is a count, a cap, or a buffer invariant, not a shape. Then + the example says so in its README, the render's job is to depict the + subject and its setup legibly — so the reader can see what the numbers + describe — and the falsification pass is satisfied by naming why no visual + difference exists. Precedents: `vertex-weight-limit` (the prune preserves + the pose by design, so the 4-influence cap is numeric) and + `triangulate-tangents` (the ring sweep is UV/bump-driven, so a + tangent-field break does not move pixels). This is for contracts that + genuinely cannot be seen, not for scenes that were not designed hard + enough: where a visual witness is possible, it is required. - Presentation may be staged; evidence may not. When the witnessed artifact is itself an image (a sequencer frame, a pixel buffer, a baked texture), the authentic pixels must appear unaltered — mount them in the scene diff --git a/docs/gallery/assets/attribute-domain-shear-hero.webp b/docs/gallery/assets/attribute-domain-shear-hero.webp new file mode 100644 index 0000000..6bf8f32 Binary files /dev/null and b/docs/gallery/assets/attribute-domain-shear-hero.webp differ diff --git a/docs/gallery/attribute-domain-shear/index.html b/docs/gallery/attribute-domain-shear/index.html new file mode 100644 index 0000000..c8e5813 --- /dev/null +++ b/docs/gallery/attribute-domain-shear/index.html @@ -0,0 +1,656 @@ + + + + + + attribute-domain-shear — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + +

+
+

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:

+
  • 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

+
+ 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)
+
+
+
+ +
+
+ generated from examples/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/docs/gallery/contact-sheets/attribute-domain-shear-contact-sheet.webp b/docs/gallery/contact-sheets/attribute-domain-shear-contact-sheet.webp new file mode 100644 index 0000000..3cec318 Binary files /dev/null and b/docs/gallery/contact-sheets/attribute-domain-shear-contact-sheet.webp differ diff --git a/docs/gallery/index.html b/docs/gallery/index.html index c96b510..5a90d56 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -631,6 +631,17 @@

car-mirror-symmetry

View example +
+ + 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… + +
+

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.

+

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.

+ View example +
+