diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index ec93bd5..bd012bd 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -101,6 +101,7 @@
"examples/gn-instance-grid",
"examples/gn-modifier-inputs",
"examples/gn-sdf-remesh",
+ "examples/gn-socket-rename",
"examples/gn-zone-iterate",
"examples/gp-lineart-contour",
"examples/grease-pencil-rosette",
@@ -131,6 +132,7 @@
"examples/vertex-weight-limit",
"examples/vse-cut-list",
"examples/vse-gamma-cross",
+ "examples/vse-linear-modifiers",
"examples/wave-displace"
]
}
diff --git a/AGENTS.md b/AGENTS.md
index dba8cee..5fbc6bb 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 16 skills, 9 rules, 3 templates, 27 snippets, and 55
+The content base is 16 skills, 9 rules, 3 templates, 27 snippets, and 57
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
diff --git a/CLAUDE.md b/CLAUDE.md
index f4377aa..eb0ecc8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -21,7 +21,7 @@ skills//SKILL.md - AI workflow definitions, 16 total
rules/.mdc - Anti-pattern rules, 9 total
templates// - Starter projects, 3 total
snippets/.py - Standalone code patterns, 27 total
-examples// - Runnable smoke-gated examples, 55 total (+ gallery.json)
+examples// - Runnable smoke-gated examples, 57 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
@@ -99,7 +99,7 @@ v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namesp
AI asset pipeline track: `decimate_to_budget.py`, `convex_hull_collider.py`, `lod_chain.py` (helper duplicated, not imported), `gltf_draco_export.py`, `export_preset_unity.py`, `export_preset_godot.py`, `export_preset_unreal.py`, `setup_bake_target_image.py`, `bake_normal_high_to_low.py`, `save_baked_image.py`.
-## Examples (55)
+## Examples (57)
Runnable scripts at `examples//`, each asserting a real API contract with
deterministic checks (exit non-zero on failure) and optionally rendering a still via
diff --git a/README.md b/README.md
index 9619686..153f3a0 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
@@ -36,7 +36,7 @@
## Overview
-This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, and 55 examples** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable.
+This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, and 57 examples** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable.
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.
@@ -203,7 +203,7 @@ Gallery still is a dual-elevation diptych so the contract reads at thumbnail sca
-Mesh, curves & text — 11 examples
+Mesh, curves & text — 12 examples
@@ -370,6 +370,20 @@ inside the effect. Per-frame sample renders assert the closed form (mid dips
0.115 below the sRGB lerp), and the AgX-default sampling trap is documented
(`view_transform = 'Standard'` is mandatory for any pixel witness).
+
+
+
+
+check-only, no gallery still — no geometry
+
+
+
+### [vse-linear-modifiers](examples/vse-linear-modifiers/)
+
+`ColorStrip.use_linear_modifiers` is a bool on 4.5.11 and 5.1.2; the same
+getattr is `AttributeError` on 5.2.1. Version-guarded `hasattr` then read
+exits 0 on all three. `--assume-present` is red only on 5.2.
+
@@ -377,7 +391,7 @@ inside the effect. Per-frame sample renders assert the closed form (mid dips
-Geometry Nodes — 5 examples
+Geometry Nodes — 6 examples
@@ -436,6 +450,21 @@ evaluated cubes against closed forms — Repeat `8×(1+N)` with X-centers at
nodes exist. Unpaired evaluates empty; For Each's main Geometry socket is a
passthrough.
+
+
+
+
+
+
+
+
+### [gn-socket-rename](examples/gn-socket-rename/)
+
+Compare INT and Random Value FLOAT socket identifiers collapsed in 5.2
+(`A_INT` / `Min_001` gone; `A` / `Min` reused). Enabled-name lookup wires
+on 4.5, 5.1, and 5.2. Asserts 16 verts and POINT `gauge_h=1.80` on eight
+column verts. `--legacy-ids` is red only on 5.2.
+
diff --git a/ROADMAP.md b/ROADMAP.md
index 0063b0c..23642d4 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -140,6 +140,10 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- ~~Vertex-colour AO witness~~ **SHIPPED** as `examples/vertex-color-ao/` — baked occlusion in a colour attribute on a stone village well, checked against a closed form rather than a captured value: the cosine-weighted hemisphere integral for an infinitely wide wall of height H at distance d is `AO = 1 − ½(1 − 1/√(1+k²))`, `k = H/d`, matched to **6.760e-04** across k = 60.0…0.60 (gate 2.5e-03, QMC noise ~1/√n at 4096 samples); unoccluded plate bakes to **exactly 1.0**; strictly monotone 0.508301 → 0.928467; asset values in [0,1] with spread 1.0; **`BYTE_COLOR` is sRGB-encoded 8-bit, not linear** (0.735 → 0.7379107, peak round-trip error 3.782e-03, matching an independent encode/quantise/decode model to 3.189e-07) while `FLOAT_COLOR` is exact; both survive depsgraph evaluation at deviation 0.0; `bmesh.ops.bevel` offsets along **cached** face normals and flips outward past 90° of staleness (12 mm below ground on a ring of boxes, fixed by `normal_update()`); point-domain AO needs subdivision or the crevice gradient never reaches the attribute; `color_attributes` enumeration order differs between 4.5.11 and 5.1.2 — look up by name; byte-identical on 4.5.11 and 5.1.2 (6966 verts)
- ~~High-to-low tangent normal bake~~ **SHIPPED** as `examples/bake-normal-high-to-low/` — Cycles CPU `type='NORMAL'` cage bake onto a `DECIMATE COLLAPSE` hatch; statistical gates (detail frac 0.7211 / MAD 0.09356 vs flat 0.0000 / 0.00277); `--flat-source` exits 5; RNA identical on 4.5.11, 5.1.2, 5.2.1; byte-identity is not the contract
- UV atlas **utilization** witness — coverage/wasted-texel closed forms for a packed lightmap atlas (the non-overlap, unit-square, margin, and active/active_render contracts shipped in `lightmap-uv-channel`; utilization is the remaining unbuilt slice of the old "UV atlas pack" candidate)
+- ~~VSE `use_linear_modifiers` removal~~ **SHIPPED** as `examples/vse-linear-modifiers/` — bool on 4.5.11 and 5.1.2, `AttributeError` on 5.2.1; `--assume-present` red only on 5.2; pathology, no gallery still
+- ~~GN Compare / Random Value socket identifier rename~~ **SHIPPED** as `examples/gn-socket-rename/` — `A_INT` / `Min_001` present on 4.5.11 and 5.1.2, absent on 5.2.1; enabled-name lookup wires all three; `--legacy-ids` red only on 5.2
+- GN evaluated `to_mesh().name` no longer equals the original mesh name (5.2 contract change)
+- `MeshAutomaskingSettings` move: old `Brush` automasking attributes gone on 5.2
- ~~GAMMA_CROSS blend-curve witness~~ **SHIPPED** as `examples/vse-gamma-cross/` — the cross blends in a gamma-0.5 space: `((1-t)·√A + t·√B)²` with `t = (frame − start)/duration`, never 1 inside the effect; mid-cross dips 0.115 below the sRGB lerp from crimson/teal (closed form (0.341, 0.349, 0.463) confirmed per frame); AgX-default sampling poisons the fit (0.146 red-channel error, `view_transform='Standard'` mandatory); deleting a consumed input orphans-and-deletes the effect — follow-up to `vse-cut-list`
- Falsy `bpy_prop_collection` trap snippet: an empty collection is falsy, so `editor.strips or editor.sequences` silently falls through to the legacy accessor on an empty timeline — always branch on `hasattr`; likely generalizes across the API (found authoring `vse-cut-list`)
- ~~Collision compound witness~~ **SHIPPED** as `examples/collision-hull-proxy/` — game-prop collision as a compound of convex pieces, each a `bmesh.ops.convex_hull` of a coarse `sec(π/n)`-inflated cage (containment 4.4e-08, watertight, positive signed volume, Euler 2, per-piece 255-face budget: body 70, caps 60×3, compound 250); a hull of the dense render mesh measures 380 faces — over budget — which is why pipelines hull cages; proud details cost cage rows, concave grooves are free; byte-identical on 4.5.11 and 5.1.2
diff --git a/docs/gallery/armature-bend/index.html b/docs/gallery/armature-bend/index.html
index 0b4e2d2..586f459 100644
--- a/docs/gallery/armature-bend/index.html
+++ b/docs/gallery/armature-bend/index.html
@@ -268,10 +268,16 @@
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python armature_bend.py --
+# Falsifier: rest pose. Must exit non-zero (tip deflection).
+blender --background --python armature_bend.py -- --zero-curl
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python armature_bend.py -- --output bend.png
blender --background --python armature_bend.py -- --output bend.png --engine cycles
-
It exits non-zero on failure (edit-bone lifetime violation, LBS deviation, moved root ring, or an undeformed tip). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | edit_bones populated in object mode | | 4 | Edit-mode bone chain off closed form | | 5 | Evaluated vertex count changed | | 6 | Evaluated mesh off closed-form LBS | | 7 | Root ring moved | | 8 | Tip did not deflect (--zero-curl lands here) | | 9 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --zero-curl.
Source
@@ -300,10 +306,14 @@
Source
The same API works unchanged on Blender 4.5 LTS and 5.1 — no version gate is
needed, which this example demonstrates by running identically on both.
+``--zero-curl`` leaves every pose bone at rest and still asserts the tip
+deflects. That is the falsifier (``--same-axis`` in export-preset-axis).
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python armature_bend.py -- # check only
+ blender --background --python armature_bend.py -- --zero-curl # must fail
blender --background --python armature_bend.py -- --output b.png # + render
"""
import bpy, bmesh, sys, os, math, argparse
@@ -575,10 +585,13 @@
Source
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)")
+ p.add_argument("--zero-curl", action="store_true",
+ help="leave pose bones at rest (must fail)")
args = p.parse_args(argv)
bpy.ops.wm.read_factory_settings(use_empty=True)
- tube, arm = build_rig(CURL_DEG)
+ curl = 0.0if args.zero_curl else CURL_DEG
+ tube, arm = build_rig(curl)
code = check(tube, arm)
if code:
return code
diff --git a/docs/gallery/assets/gn-socket-rename-hero.webp b/docs/gallery/assets/gn-socket-rename-hero.webp
new file mode 100644
index 0000000..c92db7e
Binary files /dev/null and b/docs/gallery/assets/gn-socket-rename-hero.webp differ
diff --git a/docs/gallery/attribute-domain-shear/index.html b/docs/gallery/attribute-domain-shear/index.html
index ed4cd65..5f4fa4a 100644
--- a/docs/gallery/attribute-domain-shear/index.html
+++ b/docs/gallery/attribute-domain-shear/index.html
@@ -268,9 +268,13 @@
attribute-domain-shear
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.
Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS. 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.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | CORNER or POINT attribute size wrong | | 4 | CORNER hub corners off wedge color | | 5 | POINT hub is not last-write (--no-overwrite lands here) | | 6 | Outer ring verts off last-write order | | 7 | Measured shear off palette closed form, or ~0 | | 9 | --output produced no file | | 10 | Gallery framing violation |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --no-overwrite.
Source
@@ -306,6 +310,7 @@
Source
check. Pass --output to also render a still:
blender --background --python attribute_domain_shear.py -- # check only
+ blender --background --python attribute_domain_shear.py -- --no-overwrite # must fail
blender --background --python attribute_domain_shear.py -- --output a.png # + render
"""
import bpy, bmesh, sys, os, math, argparse, colorsys
@@ -378,13 +383,14 @@
Source
return attr
-def assign_point_naive(me, pal):
+def assign_point_naive(me, pal, overwrite=True):
"""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):
+ last = K if overwrite else1
+ for i in range(last):
# 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]
@@ -393,7 +399,7 @@
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --no-extrude.
Source
@@ -285,10 +291,15 @@
Source
verts = 2 x (4 x teeth), faces = sides + 2 caps, edges = 3 x profile — and
that the mesh is watertight (every edge borders exactly 2 faces).
+``--no-extrude`` skips the face-region extrude and still runs the closed-form
+count check, so verts stay at one ring. That is the falsifier
+(``--same-axis`` in export-preset-axis).
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python bmesh_gear.py -- # check only
+ blender --background --python bmesh_gear.py -- --no-extrude # must fail
blender --background --python bmesh_gear.py -- --output g.png # + render
"""
import bpy, bmesh, sys, os, math, argparse
@@ -317,16 +328,17 @@
Source
return [(r * math.cos(a), r * math.sin(a), 0.0) for a, r in coords]
-def build_gear():
+def build_gear(no_extrude=False):
bpy.ops.wm.read_factory_settings(use_empty=True)
me = bpy.data.meshes.new("Gear")
bm = bmesh.new()
try:
verts = [bm.verts.new(co) for co in gear_profile()]
face = bm.faces.new(verts)
- ext = bmesh.ops.extrude_face_region(bm, geom=[face])
- top_verts = [e for e in ext["geom"] if isinstance(e, bmesh.types.BMVert)]
- bmesh.ops.translate(bm, verts=top_verts, vec=(0.0, 0.0, DEPTH))
+ ifnot no_extrude:
+ ext = bmesh.ops.extrude_face_region(bm, geom=[face])
+ top_verts = [e for e in ext["geom"] if isinstance(e, bmesh.types.BMVert)]
+ bmesh.ops.translate(bm, verts=top_verts, vec=(0.0, 0.0, DEPTH))
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
bm.to_mesh(me)
finally:
@@ -471,9 +483,11 @@
Source
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)")
+ p.add_argument("--no-extrude", action="store_true",
+ help="skip the face-region extrude (must fail)")
args = p.parse_args(argv)
- obj = build_gear()
+ obj = build_gear(no_extrude=args.no_extrude)
code = check(obj)
if code:
return code
diff --git a/docs/gallery/car-mirror-symmetry/index.html b/docs/gallery/car-mirror-symmetry/index.html
index bf23558..1253633 100644
--- a/docs/gallery/car-mirror-symmetry/index.html
+++ b/docs/gallery/car-mirror-symmetry/index.html
@@ -269,10 +269,16 @@
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python car_mirror_symmetry.py --
+# Falsifier: Mirror X off. Must exit non-zero (evaluated verts stay at n).
+blender --background --python car_mirror_symmetry.py -- --no-mirror
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python car_mirror_symmetry.py -- --output car.png
blender --background --python car_mirror_symmetry.py -- --output car.png --engine cycles
-
It exits non-zero on failure (applied mirror, doubled centerline, unwelded seam, broken symmetry, or a mirrored part off its plane origin). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Body datablock is not the authored half | | 4 | Authored centerline vert count ≠ 28 | | 5 | Evaluated verts ≠ 2n − c (--no-mirror lands here) | | 6 | Evaluated on-plane verts ≠ centerline; also --output produced no file | | 7 | Evaluated Euler characteristic ≠ 2 | | 8 | Non-manifold edges in the evaluated shell | | 9 | Evaluated verts lack a mirrored partner | | 10 | Mirror partner deviation above tolerance | | 11 | Evaluated bbox not symmetric about X | | 12 | Mirrored-part origin off the plane | | 13 | Mirrored-part datablock is not the authored half | | 14 | Mirrored-part evaluated counts did not double | | 15 | Mirrored-part partner check failed | | 16 | Mirrored-part evaluated mesh stayed on one side |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --no-mirror.
Source
@@ -292,10 +298,15 @@
Source
and the wheels mirror about their object origins sitting ON the symmetry
plane. Failure is dramatically visible: a car with one side missing.
+``--no-mirror`` turns off the Mirror X axis on every mirrored object and
+still runs the evaluated-count check, so the half-car fails ``2n − c``.
+That is the falsifier (``--same-axis`` in export-preset-axis).
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python car_mirror_symmetry.py -- # check only
+ blender --background --python car_mirror_symmetry.py -- --no-mirror # must fail
blender --background --python car_mirror_symmetry.py -- --output c.png # + render
"""
import bpy, bmesh, sys, os, math, argparse
@@ -551,7 +562,13 @@
Source
return dev, lone
-def check(objs):
+def check(objs, no_mirror=False):
+ if no_mirror:
+ for ob in [objs["body"]] + [w for w, *_ in objs["mirrored"]]:
+ for mod in ob.modifiers:
+ if mod.type == 'MIRROR':
+ mod.use_axis[0] = False
+
body = objs["body"]
me = body.data
@@ -784,10 +801,12 @@
Source
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)")
+ p.add_argument("--no-mirror", action="store_true",
+ help="turn off Mirror X (must fail)")
args = p.parse_args(argv)
objs = build_car()
- code = check(objs)
+ code = check(objs, no_mirror=args.no_mirror)
if code:
return code
diff --git a/docs/gallery/collision-hull-proxy/index.html b/docs/gallery/collision-hull-proxy/index.html
index 015ec53..80c1714 100644
--- a/docs/gallery/collision-hull-proxy/index.html
+++ b/docs/gallery/collision-hull-proxy/index.html
@@ -271,10 +271,16 @@
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python collision_hull_proxy.py --
+# Falsifier: shrink hull vertices. Must exit non-zero (containment).
+blender --background --python collision_hull_proxy.py -- --shrink-hull
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python collision_hull_proxy.py -- --output hydrant.png
blender --background --python collision_hull_proxy.py -- --output hydrant.png --engine cycles
-
It exits non-zero on failure (render geometry escaping a hull, inverted winding, a non-watertight or non-convex piece, Euler drift, or a piece over the 255-face budget). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Render vertex escapes its hull (--shrink-hull lands here) | | 4 | Hull edge does not border exactly two faces | | 5 | Signed volume ≤ 0 (inverted winding) | | 6 | Hull vertex off its own face plane | | 7 | Euler characteristic ≠ 2 | | 8 | Piece over the 255-face collision budget | | 9 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --shrink-hull.
Source
@@ -308,10 +314,15 @@
Source
details (grooves) never touch the hull; proud details cost hull faces. That
trade-off IS the collision-authoring lesson.
+``--shrink-hull`` scales each hull's vertices by 0.5 and still runs the
+containment plane test, so render verts escape. That is the falsifier
+(``--same-axis`` in export-preset-axis).
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python collision_hull_proxy.py -- # check only
+ blender --background --python collision_hull_proxy.py -- --shrink-hull # must fail
blender --background --python collision_hull_proxy.py -- --output h.png # + render
"""
import bpy, bmesh, sys, os, math, argparse
@@ -769,12 +780,18 @@
Source
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)")
+ p.add_argument("--shrink-hull", action="store_true",
+ help="scale hull vertices by 0.5 (must fail)")
args = p.parse_args(argv)
groups = build_hydrant()
pieces = []
for g in groups:
hull = build_hull(f"{g['name']}Hull", collect_points(g["cage"]))
+ if args.shrink_hull:
+ for v in hull.data.vertices:
+ v.co *= 0.5
+ hull.data.update()
pieces.append((g["name"], hull, collect_points(g["render"])))
code = check(pieces)
if code:
diff --git a/docs/gallery/color-attribute-wheel/index.html b/docs/gallery/color-attribute-wheel/index.html
index 13b5ec5..a2bf982 100644
--- a/docs/gallery/color-attribute-wheel/index.html
+++ b/docs/gallery/color-attribute-wheel/index.html
@@ -265,10 +265,16 @@
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python color_attribute_wheel.py --
+# Falsifier: POINT-domain attribute. Must exit non-zero.
+blender --background --python color_attribute_wheel.py -- --point-domain
+
# 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 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Topology ≠ closed form | | 4 | Color attribute missing | | 5 | Domain/type ≠ CORNER/FLOAT_COLOR (--point-domain lands here) | | 6 | Attribute sized to verts, not loops | | 7 | active_color not set | | 8 | Probe loop color off HSV closed form | | 9 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --point-domain.
Source
@@ -296,6 +302,7 @@
Source
check. Pass --output to also render a still:
blender --background --python color_attribute_wheel.py -- # check only
+ blender --background --python color_attribute_wheel.py -- --point-domain # must fail
blender --background --python color_attribute_wheel.py -- --output w.png # + render
"""
import bpy, bmesh, sys, os, math, colorsys, argparse
@@ -334,7 +341,7 @@
# 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)
+ domain = 'POINT'if point_domain else'CORNER'
+ attr = me.color_attributes.new(ATTR_NAME, type='FLOAT_COLOR', domain=domain)
+ ifnot point_domain:
+ 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)
@@ -561,9 +570,11 @@
Source
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)")
+ p.add_argument("--point-domain", action="store_true",
+ help="create a POINT-domain color attribute (must fail)")
args = p.parse_args(argv)
- obj, hsv = build_wheel()
+ obj, hsv = build_wheel(point_domain=args.point_domain)
code = check(obj, hsv)
if code:
return code
diff --git a/docs/gallery/compositor-glare/index.html b/docs/gallery/compositor-glare/index.html
index e9765d0..f0e7047 100644
--- a/docs/gallery/compositor-glare/index.html
+++ b/docs/gallery/compositor-glare/index.html
@@ -266,10 +266,16 @@
Run
# Cheap correctness check (two tiny renders) — the CI check:
blender --background --python compositor_glare.py --
+# Falsifier: Threshold=10.0. Must exit non-zero (Threshold==1.0).
+blender --background --python compositor_glare.py -- --threshold-high
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python compositor_glare.py -- --output rings.png
blender --background --python compositor_glare.py -- --output rings.png --engine cycles
-
It exits non-zero on failure (wrong tree plumbing, wrong Glare configuration, a missing or non-falling halo, or halo pixels that appear without the compositor). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Compositor tree / Glare / Threshold / link-chain contract (--threshold-high lands here) | | 4 | Check render missing, tube dark, no halo, or halo does not fall off | | 5 | Halo present with compositing off | | 6 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --threshold-high.
Source
@@ -296,12 +302,17 @@
Source
the ring silhouette, falls off strictly with distance, and vanishes entirely
when `scene.render.use_compositing` is off.
+``--threshold-high`` sets the shared ``Threshold`` input to 10.0 and still
+asserts it equals 1.0. That is the falsifier (``--same-axis`` in
+export-preset-axis). The 4.x/5.x Type-menu shim is untouched.
+
By default it runs only the correctness check (two 96x54 single-sample Cycles
renders, compositor on vs off) — the CI smoke check. Pass --output to also
render a still:
- blender --background --python compositor_glare.py -- # check only
- blender --background --python compositor_glare.py -- --output n.png # + render
+ blender --background --python compositor_glare.py -- # check only
+ blender --background --python compositor_glare.py -- --threshold-high # must fail
+ blender --background --python compositor_glare.py -- --output n.png # + render
"""
import bpy
import sys
@@ -616,9 +627,14 @@
Source
help="render engine for --output (cycles for GPU-less hosts)")
p.add_argument("--samples", type=int, default=32, help="--output sample count")
p.add_argument("--width", type=int, default=1280, help="--output width; height is width*9/16")
+ p.add_argument("--threshold-high", action="store_true",
+ help="falsifier: Threshold=10.0, still assert Threshold==1.0")
args = p.parse_args(argv)
scene, tree, glare = build_scene()
+ if args.threshold_high:
+ glare.inputs['Threshold'].default_value = 10.0
+
code = check_structure(scene, tree, glare)
if code:
return code
diff --git a/docs/gallery/contact-sheets/gn-socket-rename-contact-sheet.webp b/docs/gallery/contact-sheets/gn-socket-rename-contact-sheet.webp
new file mode 100644
index 0000000..601ca5c
Binary files /dev/null and b/docs/gallery/contact-sheets/gn-socket-rename-contact-sheet.webp differ
diff --git a/docs/gallery/cross-version-property-delete/index.html b/docs/gallery/cross-version-property-delete/index.html
index d38ab59..da699cc 100644
--- a/docs/gallery/cross-version-property-delete/index.html
+++ b/docs/gallery/cross-version-property-delete/index.html
@@ -269,6 +269,10 @@
--skip-delete and --unset-instead are falsification switches: both leave the Clear plate tagged and exit 7.
The --output render path measures framing against the Layer 1 band via examples/gallery_framing.py (exit 10 on violation) before writing the still.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage; also an active object existed after the data-API build | | 3 | Custom ID property did not land | | 4 | property_unset on a custom ID key did not TypeError, or it deleted the key | | 5 | del did not report removal | | 6 | Keep plate lost the ID property | | 7 | Clear plate still has the ID property (--skip-delete / --unset-instead land here) | | 10 | Gallery framing violation | | 12 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output, --skip-delete, or --unset-instead.
# Cheap correctness check (no render) — the CI check:
blender --background --python curve_bevel_arc.py --
+# Falsifier: uncapped tube. Must exit non-zero (use_fill_caps).
+blender --background --python curve_bevel_arc.py -- --no-caps
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python curve_bevel_arc.py -- --output arc.png
blender --background --python curve_bevel_arc.py -- --output arc.png --engine cycles
-
It exits non-zero on failure (wrong point count, bevel, caps, topology, or span). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
-
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.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is also the shared framing helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Spline type is not BEZIER | | 4 | Bezier point count ≠ 8 | | 5 | bevel_depth ≠ 0.15 | | 6 | use_fill_caps is False (--no-caps lands here) | | 7 | Evaluated vert/face count off measured tessellation | | 8 | Tube does not rest on the floor | | 9 | Tube height ≠ 2 × bevel | | 10 | X span off closed form; also gallery framing violation | | 11 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --no-caps.
Source
@@ -288,10 +293,14 @@
Source
has no simple closed form, so those two constants pin today's behavior (see
EXPECT_VERTS below for how to re-measure if a future Blender retessellates).
+``--no-caps`` leaves ``use_fill_caps`` False and still asserts the ends are
+capped. That is the falsifier (``--same-axis`` in export-preset-axis).
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python curve_bevel_arc.py -- # check only
+ blender --background --python curve_bevel_arc.py -- --no-caps # must fail
blender --background --python curve_bevel_arc.py -- --output c.png # + render
"""
import bpy, bmesh, sys, os, math, argparse
@@ -314,14 +323,15 @@
Source
EXPECT_FACES = 1028
-def build():
+def build(no_caps=False):
bpy.ops.wm.read_factory_settings(use_empty=True)
curve = bpy.data.curves.new("Arc", 'CURVE')
curve.dimensions = '3D'
curve.bevel_depth = BEVEL
curve.bevel_resolution = BEVEL_RES
curve.resolution_u = RES_U
- curve.use_fill_caps = True# solid ends — not a hollow pipe
+ curve.use_fill_caps = not no_caps # solid ends — not a hollow pipe
+
spline = curve.splines.new('BEZIER')
spline.bezier_points.add(N_POINTS - 1) # one point exists already
@@ -510,9 +520,12 @@
Source
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)")
+ p.add_argument("--no-caps", action="store_true",
+ help="falsifier: use_fill_caps=False, still assert caps")
args = p.parse_args(argv)
- obj = build()
+ obj = build(no_caps=args.no_caps)
+
code = check(obj)
if code:
return code
diff --git a/docs/gallery/custom-normals-shade/index.html b/docs/gallery/custom-normals-shade/index.html
index 226d28e..8292e3a 100644
--- a/docs/gallery/custom-normals-shade/index.html
+++ b/docs/gallery/custom-normals-shade/index.html
@@ -271,10 +271,16 @@
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python custom_normals_shade.py --
+# Falsifier: mark sharp at 20° while auditing 30°. Must exit non-zero.
+blender --background --python custom_normals_shade.py -- --mismatch-angle
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python custom_normals_shade.py -- --output cans.png
blender --background --python custom_normals_shade.py -- --output cans.png --engine cycles
-
It exits non-zero on failure (legacy API resurrected, sharp-set/dihedral mismatch, broken normal welds, custom normals lost or dequantized in evaluation, or legacy-operator divergence drift). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Legacy shading API present, or modern path missing | | 4 | Non-manifold edges (dihedral test undefined) | | 5 | Sharp set ≠ independent dihedral (--mismatch-angle lands here) | | 6 | Evaluated loop normals not welded/split as the sharp set promises | | 7 | Custom split normals lost or dequantized in evaluation | | 8 | shade_auto_smooth operator behavior drifted from the version split | | 9 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --mismatch-angle.
Source
@@ -310,11 +316,16 @@
Source
that ignores the return set; on 5.1 it FINISHES and adds
the NODES modifier. The portable path is the data API.
+``--mismatch-angle`` marks sharp at 20° and still audits against the 30°
+dihedral set, so the sharp-set match fails. That is the falsifier
+(``--same-axis`` in export-preset-axis).
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still (the same can shaded flat /
smooth-everywhere / by-angle, so a broken path reads as faceting or smear):
blender --background --python custom_normals_shade.py -- # check only
+ blender --background --python custom_normals_shade.py -- --mismatch-angle
blender --background --python custom_normals_shade.py -- --output c.png # + render
"""
import bpy, bmesh, sys, os, math, argparse
@@ -528,15 +539,16 @@
Source
return0
-def check_by_angle(objs):
+def check_by_angle(objs, mismatch_angle=False):
"""set_sharp_from_angle must mark exactly the edges whose independently
recomputed dihedral crosses the threshold — on every checked mesh."""
+ mark = math.radians(20.0) if mismatch_angle else ANGLE
total_sharp = total_manifold = 0for obj in objs:
me = obj.data
for p in me.polygons:
p.use_smooth = True
- me.set_sharp_from_angle(angle=ANGLE)
+ me.set_sharp_from_angle(angle=mark)
dih, nonmanifold = manifold_dihedrals(me)
if nonmanifold:
print(f"ERROR: {obj.name}: {nonmanifold} non-manifold edge(s) — the "
@@ -841,13 +853,17 @@
Source
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)")
+ p.add_argument("--mismatch-angle", action="store_true",
+ help="mark sharp at 20° while auditing 30° (must fail)")
args = p.parse_args(argv)
bpy.ops.wm.read_factory_settings(use_empty=True)
can = build_jerry_can()
for step in (lambda: check_api_surface(can["shell"].data),
- lambda: check_by_angle([can["shell"], can["rib"], can["neck"]]),
+ lambda: check_by_angle(
+ [can["shell"], can["rib"], can["neck"]],
+ mismatch_angle=args.mismatch_angle),
lambda: check_normal_welds(can["shell"]),
lambda: check_custom_normals_roundtrip(can["shell"]),
check_legacy_operator):
diff --git a/docs/gallery/damped-track-aim/index.html b/docs/gallery/damped-track-aim/index.html
index 4c73bf6..8f67a35 100644
--- a/docs/gallery/damped-track-aim/index.html
+++ b/docs/gallery/damped-track-aim/index.html
@@ -267,10 +267,16 @@
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python damped_track_aim.py --
+# Falsifier: mute every constraint. Must exit non-zero.
+blender --background --python damped_track_aim.py -- --mute
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python damped_track_aim.py -- --output aim.png
blender --background --python damped_track_aim.py -- --output aim.png --engine cycles
-
It exits non-zero on failure (wrong constraint type/target/axis, or evaluated aim outside the angular epsilon). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Needle count ≠ 12 | | 4 | Needle does not carry exactly one DAMPED_TRACK | | 5 | Constraint target is not Core | | 6 | track_axis is not TRACK_Z | | 7 | Constraint muted or influence < 1 (--mute lands here) | | 8 | TRACK_TO still present | | 9 | Evaluated aim dot below 0.998 | | 10 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --mute.
Source
@@ -292,10 +298,14 @@
Source
constraint is missing, muted, mistyped as TRACK_TO, or the axis is flipped,
the dot product fails.
+``--mute`` mutes every DAMPED_TRACK and still asserts unmute. That is the
+falsifier (``--same-axis`` in export-preset-axis).
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python damped_track_aim.py -- # check only
+ blender --background --python damped_track_aim.py -- --mute # must fail
blender --background --python damped_track_aim.py -- --output aim.png
"""
import bpy, bmesh, sys, os, math, argparse
@@ -441,7 +451,7 @@
Source
return mat
-def build():
+def build(mute=False):
bpy.ops.wm.read_factory_settings(use_empty=True)
col = bpy.context.collection
@@ -466,6 +476,8 @@
Render as proof: dual tray from the same DIMS/offsets the check asserts. Left keeps a soft rounded bevel; right collapses to a knife edge, with an emissive seam marker at every zero-area face centroid — the markers are placed from live mesh data, so a change in the data moves them. The broken state is in-frame by design.
Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS. 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.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Safe bevel produced zero-area faces | | 4 | Degenerate bevel zero-area count ≠ closed form (--both-safe lands here) | | 5 | min_area collapse under 1e5× | | 6 | Coincident-position count off the closed form | | 7 | Safe GLB carries degenerate triangles | | 8 | Degenerate GLB triangle or position count drifted | | 9 | --output produced no file | | 10 | Gallery framing violation |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --both-safe.
Source
@@ -430,10 +434,10 @@
Source
return path
-def check():
+def check(both_safe=False):
tmp = tempfile.mkdtemp(prefix="bevelweld_")
safe = beveled_box(DIMS, SAFE_OFFSET)
- degen = beveled_box(DIMS, DEGEN_OFFSET)
+ degen = beveled_box(DIMS, SAFE_OFFSET if both_safe else DEGEN_OFFSET)
# --- 1. threshold: zero-area faces flip on at offset == half min dim ---
safe_za = zero_area_count(safe)
@@ -671,11 +675,13 @@
Source
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)")
+ p.add_argument("--both-safe", action="store_true",
+ help="bevel the degenerate box at the safe offset (must fail)")
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()
+ code = check(both_safe=args.both_safe)
if code:
return code
diff --git a/docs/gallery/depsgraph-export/index.html b/docs/gallery/depsgraph-export/index.html
index caabf57..907e908 100644
--- a/docs/gallery/depsgraph-export/index.html
+++ b/docs/gallery/depsgraph-export/index.html
@@ -265,13 +265,20 @@
Run
# Cheap correctness check (writes an OBJ to a temp path, asserts the counts) — the CI check:
blender --background --python depsgraph_export.py --
+# Falsifier: apply_modifiers=False. Must exit non-zero (export ≠ evaluated).
+blender --background --python depsgraph_export.py -- --unevaluated
+
# Also render a still of base vs evaluated (EEVEE on a GPU host; cycles on GPU-less hosts):
blender --background --python depsgraph_export.py -- --output depsgraph.png
blender --background --python depsgraph_export.py -- --output depsgraph.png --engine cycles
# Write the exported OBJ to a specific path:
blender --background --python depsgraph_export.py -- --obj exported.obj
-
It exits non-zero on failure (modifier not applied, or exported count ≠ evaluated count). The blender-smoke workflow runs this check on Blender 5.2 LTS and 4.5 LTS: base 8 → evaluated/exported 98 vertices with a 2-level SUBSURF.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Evaluated mesh did not apply the modifier | | 4 | No OBJ written | | 5 | Export vert count ≠ evaluated (--unevaluated lands here) | | 6 | --output produced no file | | 10 | Gallery framing violation |
+
--obj is a path selector, not a falsifier.
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output, --obj, or --unevaluated.
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.
@@ -288,10 +295,16 @@
Source
wm.obj_export, and asserts the exported vertex count equals the EVALUATED
(modifier-applied) count and is strictly greater than the base.
+``--unevaluated`` exports with ``apply_modifiers=False`` and still asserts
+the OBJ vertex count equals the depsgraph-evaluated mesh. That is the
+falsifier (``--same-axis`` in export-preset-axis). ``--obj`` is a path
+selector, not a falsifier.
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python depsgraph_export.py -- # check only
+ blender --background --python depsgraph_export.py -- --unevaluated # must fail
blender --background --python depsgraph_export.py -- --output d.png # + render
"""
import bpy, bmesh, sys, os, math, argparse, tempfile
@@ -317,7 +330,7 @@
Source
return obj
-def check(obj, obj_path):
+def check(obj, obj_path, unevaluated=False):
base = len(obj.data.vertices)
# depsgraph lifetime contract: evaluate, read, then release with to_mesh_clear
@@ -330,7 +343,11 @@
Source
out = obj_path or os.path.join(tempfile.gettempdir(), "depsgraph_export.obj")
os.makedirs(os.path.dirname(os.path.abspath(out)) or".", exist_ok=True)
# obj_export writes the evaluated (modifier-applied) geometry by default
- bpy.ops.wm.obj_export(filepath=out, export_selected_objects=False)
+ bpy.ops.wm.obj_export(
+ filepath=out,
+ export_selected_objects=False,
+ apply_modifiers=not unevaluated,
+ )
ifnot (os.path.exists(out) and os.path.getsize(out) > 0):
print("ERROR: no OBJ written", file=sys.stderr)
return4
@@ -489,10 +506,12 @@
Source
help="render engine for --output (cycles for GPU-less hosts)")
p.add_argument("--obj", default=None,
help="optional: write the exported OBJ here (else a temp path)")
+ p.add_argument("--unevaluated", action="store_true",
+ help="export with apply_modifiers=False (must fail)")
args = p.parse_args(argv)
obj = build()
- code = check(obj, args.obj)
+ code = check(obj, args.obj, unevaluated=args.unevaluated)
if code:
return code
diff --git a/docs/gallery/driver-wave/index.html b/docs/gallery/driver-wave/index.html
index 6d33d13..e18de9f 100644
--- a/docs/gallery/driver-wave/index.html
+++ b/docs/gallery/driver-wave/index.html
@@ -266,11 +266,16 @@
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python driver_wave.py --
+# Falsifier: constant 1.0 expression. Must exit non-zero.
+blender --background --python driver_wave.py -- --flat-expr
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python driver_wave.py -- --output driver.png
blender --background --python driver_wave.py -- --output driver.png --engine cycles
-
It exits non-zero on failure (driven value wrong, or the flush-back disagreed). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
-
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.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Evaluated Z scale ≠ wave_scale (--flat-expr lands here) | | 4 | Original datablock was not flushed | | 6 | --output produced no file | | 10 | Gallery framing violation |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --flat-expr.
Source
@@ -288,10 +293,14 @@
Source
for display, so both must agree). Asserts both against the closed-form
profile. Exits non-zero on failure.
+``--flat-expr`` drives Z scale with ``1.0`` and still asserts ``wave_scale``.
+That is the falsifier (``--same-axis`` in export-preset-axis).
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python driver_wave.py -- # check only
+ blender --background --python driver_wave.py -- --flat-expr # must fail
blender --background --python driver_wave.py -- --output d.png # + render
"""
import bpy, bmesh, sys, os, math, argparse
@@ -311,7 +320,7 @@
Source
return1.4 + math.sin(i * 0.6)
-def build_columns():
+def build_columns(flat_expr=False):
bpy.ops.wm.read_factory_settings(use_empty=True)
# driver_namespace entries do not persist in .blend files; real add-ons# re-register them from a load_post handler. Headless, registering before
@@ -334,7 +343,7 @@
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)")
+ p.add_argument("--flat-expr", action="store_true",
+ help="drive Z scale with 1.0 (must fail)")
args = p.parse_args(argv)
- objs = build_columns()
+ objs = build_columns(flat_expr=args.flat_expr)
code = check(objs)
if code:
return code
diff --git a/docs/gallery/export-preset-axis/index.html b/docs/gallery/export-preset-axis/index.html
index ac5bd06..e9c907f 100644
--- a/docs/gallery/export-preset-axis/index.html
+++ b/docs/gallery/export-preset-axis/index.html
@@ -274,7 +274,10 @@
Run
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python export_preset_axis.py -- --output beacon.png
blender --background --python export_preset_axis.py -- --output beacon.png --engine cycles
-
It exits non-zero on failure (RNA drift, source not Z-dominant, Unity disk not converted, Godot disk not Z-up, Unity not standing, Godot not lying, orientations equal). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron).
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage; also exporter RNA missing expected glTF kwargs | | 3 | Source mast is not Z-dominant, or tip drifted | | 4 | glTF reimport produced no mesh | | 5 | Unity disk POSITION is not (x, z, -y), or Unity node has rotation | | 6 | Godot disk POSITION is not raw Z-up; also --output produced no file | | 7 | Unity reimport is not standing | | 8 | Godot reimport is not lying along Y, or reimported tip mismatch | | 9 | Reimported orientations did not differ (--same-axis lands here) | | 10 | Gallery framing violation | | 11 | --same-axis did not collapse the axis difference |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --same-axis.
# Cheap correctness check (no render) — the CI check:
blender --background --python gltf_export_roundtrip.py --
+# Falsifier: export_yup=False. Must exit non-zero (bbox is Z-up on disk).
+blender --background --python gltf_export_roundtrip.py -- --no-yup
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python gltf_export_roundtrip.py -- --output crate.png
blender --background --python gltf_export_roundtrip.py -- --output crate.png --engine cycles
-
It exits non-zero on failure (RNA kwarg drift, cage drift, missing on-disk conversion, vertex-split drift, or any round-trip excursion beyond tolerance). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Exporter/importer RNA kwargs drifted | | 4 | Base cage drifted from its closed form | | 5 | Authored UVs drifted from the box-map closed form | | 6 | Bevel produced no evaluated geometry | | 7 | On-disk node/mesh/generator contract drifted | | 8 | On-disk primitive/material binding count drifted | | 9 | On-disk POSITION bounds ≠ axis-converted bbox (--no-yup lands here) | | 10 | On-disk POSITION count ≠ evaluated loop count | | 11 | On-disk UV V-flip failed | | 12 | Re-import did not produce exactly one mesh | | 13 | Re-imported object carries a transform | | 14 | Material names drifted on re-import | | 15 | Re-import vert count ≠ evaluated loop count | | 16 | Round-trip position drift | | 17 | Round-trip normal drift | | 18 | Round-trip UV drift | | 19 | Re-import triangle count drifted | | 20 | Per-triangle material bindings drifted | | 21 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --no-yup.
Source
@@ -291,8 +297,11 @@
Source
data itself — (x, y, z) -> (x, z, -y) on disk — with no node rotation.
The check parses the exported .gltf JSON and asserts the POSITION accessor
bounds equal the axis-converted evaluated bounding box, and that the node
- carries neither rotation nor scale. Exporting with ``export_yup=False``
- writes raw Z-up data that every engine will display lying on its back.
+ carries neither rotation nor scale. ``--no-yup`` exports with
+ ``export_yup=False`` and still runs that bbox check, so the +Y-up
+ conversion fails. That is the falsifier (``--same-axis`` in
+ export-preset-axis). Exporting with ``export_yup=False`` writes raw Z-up
+ data that every engine will display lying on its back.
2. Modifiers ship evaluated geometry. ``export_apply=True`` applies the
crate's bevel modifier: the re-imported mesh matches the
depsgraph-evaluated mesh, not the base cage. With ``export_apply=False``
@@ -315,6 +324,7 @@
# ---------------------------------------------------------------------------# The check. Distinct exit codes per contract; measured maxima printed on success.# ---------------------------------------------------------------------------
-def check(crate):
+def check(crate, export_kwargs):
# contract 0 (version guard): every kwarg we rely on still exists.
exp_props = {p.identifier for p in bpy.ops.export_scene.gltf.get_rna_type().properties}
imp_props = {p.identifier for p in bpy.ops.import_scene.gltf.get_rna_type().properties}
@@ -568,7 +578,7 @@
Source
tmp = tempfile.mkdtemp(prefix="gltf_roundtrip_")
try:
path = os.path.join(tmp, "crate.gltf").replace("\\", "/")
- bpy.ops.export_scene.gltf(filepath=path, **EXPORT_KWARGS)
+ bpy.ops.export_scene.gltf(filepath=path, **export_kwargs)
# contract 1 (on disk): +Y-up is baked into vertex data, no node transform
g, acc_floats = read_gltf(path)
@@ -875,13 +885,18 @@
Source
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)")
+ p.add_argument("--no-yup", action="store_true",
+ help="export with export_yup=False (must fail)")
args = p.parse_args(argv)
bpy.ops.wm.read_factory_settings(use_empty=True)
crate = build_crate()
for m in make_materials():
crate.data.materials.append(m)
- code = check(crate)
+ kwargs = dict(EXPORT_KWARGS)
+ if args.no_yup:
+ kwargs["export_yup"] = False
+ code = check(crate, kwargs)
if code:
return code
diff --git a/docs/gallery/gltf-skin-roundtrip/index.html b/docs/gallery/gltf-skin-roundtrip/index.html
index a21ac0e..efbcbc1 100644
--- a/docs/gallery/gltf-skin-roundtrip/index.html
+++ b/docs/gallery/gltf-skin-roundtrip/index.html
@@ -270,10 +270,16 @@
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python gltf_skin_roundtrip.py --
+# Falsifier: export_skins=False. Must exit non-zero.
+blender --background --python gltf_skin_roundtrip.py -- --no-skins
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python gltf_skin_roundtrip.py -- --output scorp.png
blender --background --python gltf_skin_roundtrip.py -- --output scorp.png --engine cycles
-
It exits non-zero on failure (missing skin, joint drift, weight-sum drift, skeleton drift, weight excursion, or deformation excursion). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Exporter RNA missing expected kwargs | | 4 | Vertex group count ≠ bone count | | 5 | Disk skins ≠ 1 (--no-skins lands here) | | 6 | Skin joints ≠ bone names | | 7 | Missing JOINTS_0/WEIGHTS_0, or accessor length mismatch | | 8 | Disk weight sums off 1.0 | | 9 | Disk verts exceed evaluated loops | | 10 | Armature count after import ≠ 1 | | 11 | Bone count drifted | | 12 | Named bone lost | | 13 | Bone parent drifted | | 14 | Rest matrices drifted | | 15 | Skinned mesh count after import ≠ 1 | | 16 | Re-import vert count ≠ disk | | 17 | Vertex group names drifted | | 18 | Weight round-trip drifted | | 19 | Deformation round-trip drifted | | 20 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --no-skins.
Source
@@ -307,10 +313,14 @@
Source
The skins pipeline is stable between Blender 4.5 LTS and 5.1 (exporter RNA
is byte-identical, verified on both) — the example runs identically on both.
+``--no-skins`` exports with ``export_skins=False`` and still asserts one
+skin on disk. That is the falsifier (``--same-axis`` in export-preset-axis).
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python gltf_skin_roundtrip.py -- # check only
+ blender --background --python gltf_skin_roundtrip.py -- --no-skins # must fail
blender --background --python gltf_skin_roundtrip.py -- --output s.png # + render
"""
import bpy, bmesh, sys, os, math, json, struct, shutil, tempfile, argparse
@@ -530,7 +540,7 @@
Source
return g, accessor_floats, accessor_uints
-def check(obj, arm, part_of):
+def check(obj, arm, part_of, no_skins=False):
me = obj.data
exp_props = {p.identifier for p in bpy.ops.export_scene.gltf.get_rna_type().properties}
missing = [k for k in EXPORT_KWARGS if k notin exp_props]
@@ -573,7 +583,10 @@
Source
tmp = tempfile.mkdtemp(prefix="gltf_skin_")
try:
path = os.path.join(tmp, "scorp.gltf").replace("\\", "/")
- bpy.ops.export_scene.gltf(filepath=path, **EXPORT_KWARGS)
+ kw = dict(EXPORT_KWARGS)
+ if no_skins:
+ kw["export_skins"] = False
+ bpy.ops.export_scene.gltf(filepath=path, **kw)
# contract 1 (on disk): the skin carries every bone, joints named
g, acc_f, acc_u = read_gltf(path)
@@ -839,6 +852,8 @@
Source
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)")
+ p.add_argument("--no-skins", action="store_true",
+ help="export with export_skins=False (must fail)")
args = p.parse_args(argv)
bpy.ops.wm.read_factory_settings(use_empty=True)
@@ -848,7 +863,7 @@
# Cheap correctness check (no render) — the CI check:
blender --background --python gn_instance_grid.py --
+# Falsifier: 1×1 grid. Must exit non-zero (corner instance missing).
+blender --background --python gn_instance_grid.py -- --one-cell
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python gn_instance_grid.py -- --output grid.png
blender --background --python gn_instance_grid.py -- --output grid.png --engine cycles
-
It exits non-zero on failure (wrong carrier, topology mismatch, missing material, or misplaced corner). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Carrier vertex count ≠ 1 | | 4 | Corner instance vert count ≠ 8 (--one-cell lands here) | | 5 | Evaluated topology ≠ 72 verts / 54 faces | | 6 | Set Material did not carry Lime | | 7 | Corner instance center off the closed-form grid point | | 8 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --one-cell.
Source
@@ -286,10 +292,15 @@
Source
left as empty instance geometry, and that a corner instance sits at its
closed-form grid coordinate.
+``--one-cell`` builds a 1x1 grid and still asserts 3x3 realized topology,
+so the corner instance is missing. That is the falsifier
+(``--same-axis`` in export-preset-axis).
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python gn_instance_grid.py -- # check only
+ blender --background --python gn_instance_grid.py -- --one-cell # must fail
blender --background --python gn_instance_grid.py -- --output g.png # + render
"""
import bpy, bmesh, sys, os, math, argparse
@@ -309,7 +320,7 @@
Source
CORNER_CENTER = (GRID_HALF, GRID_HALF, CUBE_SIZE / 2)
-def build_instance_grid_tree(material=None):
+def build_instance_grid_tree(material=None, grid_x=GRID_X, grid_y=GRID_Y):
tree = bpy.data.node_groups.new("InstanceGrid", 'GeometryNodeTree')
# generative: no Group Input — the tree owns the geometry
tree.interface.new_socket(
@@ -320,8 +331,8 @@
return tree
-def build():
+def build(one_cell=False):
bpy.ops.wm.read_factory_settings(use_empty=True)
# carrier mesh is unused by the generative tree; one vertex is enough
me = bpy.data.meshes.new("Carrier")
@@ -367,7 +378,8 @@
Source
bsdf.inputs["Base Color"].default_value = (0.22, 0.95, 0.06, 1.0) # lime
bsdf.inputs["Roughness"].default_value = 0.22
- tree = build_instance_grid_tree(material=mat)
+ gx = gy = 1if one_cell else GRID_X
+ tree = build_instance_grid_tree(material=mat, grid_x=gx, grid_y=gy)
mod = obj.modifiers.new("instance_grid", 'NODES')
mod.node_group = tree
return obj, mat
@@ -524,9 +536,11 @@
Source
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)")
+ p.add_argument("--one-cell", action="store_true",
+ help="instance a 1x1 grid (must fail)")
args = p.parse_args(argv)
- obj, _mat = build()
+ obj, _mat = build(one_cell=args.one_cell)
code = check(obj)
if code:
return code
diff --git a/docs/gallery/gn-modifier-inputs/index.html b/docs/gallery/gn-modifier-inputs/index.html
index d4decfd..1b19ce8 100644
--- a/docs/gallery/gn-modifier-inputs/index.html
+++ b/docs/gallery/gn-modifier-inputs/index.html
@@ -266,14 +266,20 @@
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python gn_modifier_inputs.py --
-# Force one side of the split (must fail on the other series):
+# Portable falsifier: write 1.0 to every modifier. Must exit non-zero.
+blender --background --python gn_modifier_inputs.py -- --same-scale
+
+# Force one side of the split (must fail on the other series, not all three):
blender --background --python gn_modifier_inputs.py -- --api dict
blender --background --python gn_modifier_inputs.py -- --api rna
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python gn_modifier_inputs.py -- --output stairs.png
blender --background --python gn_modifier_inputs.py -- --output stairs.png --engine cycles
-
It exits non-zero on failure (missing identifier, write/read raise, readback mismatch, evaluated Z-extent ≠ scale, or three extents not distinct). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron).
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Scale input identifier missing on the tree interface | | 4 | Modifiers do not share one node_group | | 5 | Version-path write raised (--api dict on 5.2, --api rna on 4.5) | | 6 | Version-path read raised | | 7 | Readback ≠ intended scale (--same-scale lands here) | | 8 | Evaluated Z-extent ≠ intended scale | | 9 | Evaluated mesh not sitting on z=0 | | 10 | Gallery framing violation | | 11 | Evaluated extents not distinct | | 12 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output, --same-scale, or --api dict/rna.
Falsification
| Probe | Binary | Result | | --- | --- | --- | | --api dict | 5.2.1 LTS | exit 5, TypeError: id properties not supported for this type | | --api rna | 4.5.11 LTS | exit 5, AttributeError: 'NodesModifier' object has no attribute 'properties' | | --api auto | 5.2.1, 5.1.2, 4.5.11 | exit 0, extents 1.000 / 2.000 / 3.000 |
@@ -295,10 +301,14 @@
Source
4.5 LTS and 5.1 write ``mod[identifier] = value``. 5.2+ removed ID
properties on NodesModifier — that assignment raises TypeError — and
the replacement is ``mod.properties.inputs.<identifier>.value``.
-``--api dict`` / ``--api rna`` force one side so the witness can fail
-on purpose.
+``--api dict`` / ``--api rna`` force one side of the 5.1/5.2 split — they
+fail on the *other* series, not on every binary. ``--same-scale`` writes
+1.0 to every modifier and still asserts 1 / 2 / 3, so the second cube's
+readback fails on all three. That is the portable falsifier
+(``--same-axis`` in export-preset-axis).
blender --background --python gn_modifier_inputs.py --
+ blender --background --python gn_modifier_inputs.py -- --same-scale
blender --background --python gn_modifier_inputs.py -- --api dict
blender --background --python gn_modifier_inputs.py -- --output s.png
"""
@@ -463,7 +473,7 @@
Source
ev.to_mesh_clear()
-def check(tree, objs, mods, api):
+def check(tree, objs, mods, api, same_scale=False):
ident = scale_identifier(tree)
ifnot ident:
print("ERROR: Scale input identifier missing on the tree interface",
@@ -476,8 +486,9 @@
Source
return4for obj, mod, scale in zip(objs, mods, SCALES):
+ written = SCALES[0] if same_scale else scale
try:
- set_mod_input(mod, ident, scale, api)
+ set_mod_input(mod, ident, written, api)
except Exception as e:
print(
f"ERROR: {api} write of {scale} on {obj.name} raised "
@@ -653,11 +664,15 @@
Source
"--api", default="auto", choices=("auto", "dict", "rna"),
help="force the 5.1 dict path, the 5.2 RNA path, or pick from bpy.app.version",
)
+ p.add_argument(
+ "--same-scale", action="store_true",
+ help="write 1.0 to every modifier (must fail)",
+ )
args = p.parse_args(argv)
tree, objs, mods = build()
api = _api_choice(args.api)
- code = check(tree, objs, mods, api)
+ code = check(tree, objs, mods, api, same_scale=args.same_scale)
if code:
return code
diff --git a/docs/gallery/gn-sdf-remesh/index.html b/docs/gallery/gn-sdf-remesh/index.html
index 5654ae4..f89a29f 100644
--- a/docs/gallery/gn-sdf-remesh/index.html
+++ b/docs/gallery/gn-sdf-remesh/index.html
@@ -266,10 +266,16 @@
Run
# Cheap correctness check only (no render) — the CI smoke check:
blender --background --python gn_sdf_remesh.py --
+# Falsifier: no SDF modifier. Must exit non-zero (evaluated == base).
+blender --background --python gn_sdf_remesh.py -- --no-sdf
+
# Also render the remeshed result (EEVEE on a GPU host; --engine cycles on GPU-less hosts):
blender --background --python gn_sdf_remesh.py -- --output remesh.png
blender --background --python gn_sdf_remesh.py -- --output remesh.png --engine cycles
-
By default it runs only the frame-independent correctness check: the depsgraph-evaluated vertex count must be > 0 AND differ from the base mesh (the remesh produced geometry). It exits non-zero on failure — the same check the blender-smoke workflow runs on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | SDF remesh produced no/unchanged geometry (--no-sdf lands here) | | 4 | --output produced no file | | 5 | Wrong-era EEVEE engine id was accepted | | 6 | Input material dropped by remesh |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --no-sdf.
Source
@@ -290,6 +296,7 @@
Source
both builds.
blender --background --python gn_sdf_remesh.py -- # correctness check only
+ blender --background --python gn_sdf_remesh.py -- --no-sdf # must fail
blender --background --python gn_sdf_remesh.py -- --output r.png # also render the result
blender --background --python gn_sdf_remesh.py -- --output r.png --engine cycles # GPU-less
"""
@@ -387,6 +394,8 @@
Source
p = argparse.ArgumentParser()
p.add_argument("--output", default=None, help="optional: render the remeshed result to this PNG")
p.add_argument("--engine", choices=["auto", "cycles"], default="auto")
+ p.add_argument("--no-sdf", action="store_true",
+ help="skip attaching the SDF remesh modifier (must fail)")
args = p.parse_args(argv)
# EEVEE-id inversion witnessed for real: the OTHER era's id must be
@@ -404,7 +413,8 @@
Source
base = len(obj.data.vertices)
src_mat = obj.data.materials[0] if obj.data.materials elseNone
tree, link_valid = build_remesh_via_sdf(material=src_mat)
- obj.modifiers.new("sdf", 'NODES').node_group = tree
+ ifnot args.no_sdf:
+ obj.modifiers.new("sdf", 'NODES').node_group = tree
dg = bpy.context.evaluated_depsgraph_get(); ev = obj.evaluated_get(dg)
m = ev.to_mesh(); evc = len(m.vertices)
mat_names = [mm.name for mm in m.materials if mm isnotNone]
diff --git a/docs/gallery/gn-socket-rename/index.html b/docs/gallery/gn-socket-rename/index.html
new file mode 100644
index 0000000..cb71c22
--- /dev/null
+++ b/docs/gallery/gn-socket-rename/index.html
@@ -0,0 +1,741 @@
+
+
+
+
+
+ gn-socket-rename — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
Compare and Random Value socket identifiers collapsed onto reused names in 5.2; enabled-name lookup wires on 4.5, 5.1, and 5.2
+
+
+
+
Rendered headless by the example itself — click to zoom.
+
witnesses A_INT and Min_001 present on 4.5.11 and 5.1.2, absent on 5.2.1. --legacy-ids exits 5 only on 5.2. Evaluated 16 verts and gauge_h 1.80 on eight column verts
A jo-block (steel plinth + copper column) whose Geometry Nodes tree witnesses the 5.2 socket-identifier collapse on FunctionNodeCompare and FunctionNodeRandomValue. Follows geometry-nodes-python.
+
On 4.5.11 and 5.1.2, Compare INT exposes A_INT / B_INT and Random Value FLOAT exposes Min_001 / Max_001 / Value_001. On 5.2.1 those identifiers are gone — the live sockets reuse A / B and Min / Max / Value. Looking up the unique *enabled* socket of a given name wires on all three. Hard-coding the pre-5.2 identifiers raises on 5.2.
+
What it witnesses: identifier-agnostic wiring produces a 16-vert evaluated mesh (plinth + column) with POINT gauge_h == 1.80 on exactly the eight column verts. Compare INT 7 > 2 switches the column in (vert count). Random Value FLOAT with min=max=HEIGHT is stored as a named attribute (Random is a field; using it as a constant Size source evaluates to 0 — hazard found while authoring). zmax 1.94 is the construction axis, not the Random axis.
+
Scaffolding matches cross-version-property-delete (check() returns, argparse naive-API flag, FATAL wrapper). That example does not version-branch, so the per-version inventory assert is the gn-modifier-inputs shape.
+
What failure each check would catch:
+
exit 4 — identifier inventory wrong for this Blender
exit 5 — pre-5.2 identifier lookup failed (--legacy-ids on 5.2)
exit 6 — enabled-name lookup failed
exit 7 — Compare did not switch the column in (8 verts)
exit 8 — column not sitting on the plinth
exit 9 — gauge_h not 1.80 on eight verts (Random unwired)
+
--legacy-ids is the falsifier: wire by Min_001 / A_INT. It exits 0 on 4.5.11 and 5.1.2 (those identifiers still exist) and 5 on 5.2.1. Unlike --same-axis, it is not red on every binary.
+
No SMOKE_SKIP. Do not pass deviation= to check_framing.
The --output render path measures framing against the Layer 1 band via examples/gallery_framing.py (exit 10 on violation) before writing the still.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 4 | Identifier inventory wrong for this Blender version | | 5 | Pre-5.2 identifier missing (--legacy-ids on 5.2) | | 6 | Enabled-name socket lookup failed | | 7 | Evaluated vert count off (Compare did not switch the column in) | | 8 | Evaluated zmax off closed form | | 9 | POINT gauge_h not 1.80 on eight column verts | | 10 | Gallery framing violation | | 12 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --legacy-ids.
+
+
+
Source
+
+ examples/gn-socket-rename/gn_socket_rename.py
+ View on GitHub →
+
+
"""GN Compare / Random Value socket identifier rename — a runnable example.
+
+Witnesses the 5.2 collapse of typed sockets onto reused identifiers.
+``FunctionNodeRandomValue`` FLOAT used ``Min_001`` / ``Max_001`` / ``Value_001``
+on 4.5 LTS and 5.1; those identifiers are gone on 5.2 (``Min`` / ``Max`` /
+``Value``). ``FunctionNodeCompare`` INT used ``A_INT`` / ``B_INT``; 5.2 reuses
+``A`` / ``B``. Identifier-agnostic lookup (the unique *enabled* socket of a
+given name) wires on all three. Hard-coding the pre-5.2 identifiers fails
+on 5.2.
+
+The tree is a jo-block: a plinth plus a column gated by Compare INT 7>2.
+Random Value FLOAT (min=max=HEIGHT, so the value is a closed form, not an
+RNG draw) is stored as POINT ``gauge_h`` on the column. Vert count is the
+Compare axis (16 vs 8). ``gauge_h == HEIGHT`` on exactly eight verts is the
+Random axis — count-only is green with Random unwired (the Store default
+is 0). Random Value is a field; wiring it as a constant Size source
+evaluates to 0, which is why the Store is the witness.
+
+ blender --background --python gn_socket_rename.py --
+ blender --background --python gn_socket_rename.py -- --legacy-ids
+ blender --background --python gn_socket_rename.py -- --output g.png
+"""
+import argparse
+import math
+import os
+import sys
+
+import bmesh
+import bpy
+
+sys.path.insert(
+ 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)
+)
+sys.dont_write_bytecode = True
+import gallery_framing # noqa: E402
+
+RENAMED_AT = (5, 2, 0)
+HEIGHT = 1.80
+COLUMN_XY = 0.55
+PLINTH_SIZE = (1.35, 1.35, 0.14)
+CMP_A = 7
+CMP_B = 2
+ZMAX_OK = PLINTH_SIZE[2] + HEIGHT # 1.94
+VERTS_OK = 16
+VERTS_NO_COLUMN = 8
+ATTR_NAME = "gauge_h"
+ATTR_EPS = 1e-5
+EXTENT_EPS = 1e-4
+
+LEGACY_RV_MIN = "Min_001"
+LEGACY_RV_MAX = "Max_001"
+LEGACY_RV_OUT = "Value_001"
+LEGACY_CMP_A = "A_INT"
+LEGACY_CMP_B = "B_INT"
+
+
+def fail(msg, code):
+ print(f"ERROR: {msg}", file=sys.stderr)
+ return code
+
+
+def ident_sock(node, collection, identifier):
+ for s in getattr(node, collection):
+ if s.identifier == identifier:
+ return s
+ raise LookupError(
+ f"{node.bl_idname} has no {collection} identifier {identifier!r}"
+ )
+
+
+def enabled_sock(node, collection, name):
+ found = [
+ s for s in getattr(node, collection)
+ if s.name == name and s.enabled
+ ]
+ if len(found) != 1:
+ raise LookupError(
+ f"{node.bl_idname}{collection} enabled name={name!r}"
+ f"count={len(found)}"
+ )
+ return found[0]
+
+
+def identifiers(node, collection):
+ return {s.identifier for s in getattr(node, collection)}
+
+
+def eval_mesh(obj):
+ dg = bpy.context.evaluated_depsgraph_get()
+ ev = obj.evaluated_get(dg)
+ me = ev.to_mesh()
+ try:
+ coords = [(v.co.x, v.co.y, v.co.z) for v in me.vertices]
+ nfaces = len(me.polygons)
+ attr = me.attributes.get(ATTR_NAME)
+ if attr isNone:
+ attr_vals = []
+ else:
+ attr_vals = [0.0] * len(me.vertices)
+ attr.data.foreach_get("value", attr_vals)
+ finally:
+ ev.to_mesh_clear()
+ return coords, nfaces, attr_vals
+
+
+def sock_pair(node, collection, name, legacy_id, legacy):
+ if legacy:
+ return ident_sock(node, collection, legacy_id)
+ return enabled_sock(node, collection, name)
+
+
+def build_tree(material_plinth, material_column, legacy_ids):
+ tree = bpy.data.node_groups.new("SocketRenameGauge", "GeometryNodeTree")
+ tree.interface.new_socket(
+ name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry",
+ )
+ go = tree.nodes.new("NodeGroupOutput")
+
+ plinth = tree.nodes.new("GeometryNodeMeshCube")
+ plinth.inputs["Size"].default_value = PLINTH_SIZE
+ plinth_xf = tree.nodes.new("GeometryNodeTransform")
+ plinth_xf.inputs["Translation"].default_value = (
+ 0.0, 0.0, PLINTH_SIZE[2] / 2.0,
+ )
+ tree.links.new(plinth.outputs["Mesh"], plinth_xf.inputs["Geometry"])
+ sm_p = tree.nodes.new("GeometryNodeSetMaterial")
+ sm_p.inputs["Material"].default_value = material_plinth
+ tree.links.new(plinth_xf.outputs["Geometry"], sm_p.inputs["Geometry"])
+
+ column = tree.nodes.new("GeometryNodeMeshCube")
+ column.inputs["Size"].default_value = (COLUMN_XY, COLUMN_XY, HEIGHT)
+ rv = tree.nodes.new("FunctionNodeRandomValue")
+ rv.data_type = "FLOAT"
+ cmp = tree.nodes.new("FunctionNodeCompare")
+ cmp.data_type = "INT"
+ cmp.operation = "GREATER_THAN"
+
+ smin = sock_pair(rv, "inputs", "Min", LEGACY_RV_MIN, legacy_ids)
+ smax = sock_pair(rv, "inputs", "Max", LEGACY_RV_MAX, legacy_ids)
+ sout = sock_pair(rv, "outputs", "Value", LEGACY_RV_OUT, legacy_ids)
+ smin.default_value = HEIGHT
+ smax.default_value = HEIGHT
+
+ sa = sock_pair(cmp, "inputs", "A", LEGACY_CMP_A, legacy_ids)
+ sb = sock_pair(cmp, "inputs", "B", LEGACY_CMP_B, legacy_ids)
+ sa.default_value = CMP_A
+ sb.default_value = CMP_B
+ cmp_out = enabled_sock(cmp, "outputs", "Result")
+
+ col_xf = tree.nodes.new("GeometryNodeTransform")
+ col_xf.inputs["Translation"].default_value = (
+ 0.0, 0.0, PLINTH_SIZE[2] + HEIGHT / 2.0,
+ )
+ tree.links.new(column.outputs["Mesh"], col_xf.inputs["Geometry"])
+ store = tree.nodes.new("GeometryNodeStoreNamedAttribute")
+ store.data_type = "FLOAT"
+ store.domain = "POINT"
+ store.inputs["Name"].default_value = ATTR_NAME
+ tree.links.new(col_xf.outputs["Geometry"], store.inputs["Geometry"])
+ tree.links.new(sout, store.inputs["Value"])
+ sm_c = tree.nodes.new("GeometryNodeSetMaterial")
+ sm_c.inputs["Material"].default_value = material_column
+ tree.links.new(store.outputs["Geometry"], sm_c.inputs["Geometry"])
+
+ sw = tree.nodes.new("GeometryNodeSwitch")
+ sw.input_type = "GEOMETRY"
+ tree.links.new(cmp_out, enabled_sock(sw, "inputs", "Switch"))
+ tree.links.new(sm_c.outputs["Geometry"], enabled_sock(sw, "inputs", "True"))
+
+ join = tree.nodes.new("GeometryNodeJoinGeometry")
+ tree.links.new(sm_p.outputs["Geometry"], join.inputs[0])
+ tree.links.new(enabled_sock(sw, "outputs", "Output"), join.inputs[0])
+
+ shade = tree.nodes.new("GeometryNodeSetShadeSmooth")
+ shade.inputs["Shade Smooth"].default_value = False
+ tree.links.new(join.outputs["Geometry"], shade.inputs["Geometry"])
+ tree.links.new(shade.outputs["Geometry"], go.inputs["Geometry"])
+ return tree, rv, cmp
+
+
+def check_inventory(rv, cmp):
+ ver = bpy.app.version
+ legacy = ver < RENAMED_AT
+ rv_in = identifiers(rv, "inputs")
+ rv_out = identifiers(rv, "outputs")
+ cmp_in = identifiers(cmp, "inputs")
+ print(
+ f"blender={ver} legacy_ids_present_expected={legacy}"
+ f"rv_in={sorted(rv_in)} rv_out={sorted(rv_out)}"
+ f"cmp_in={sorted(cmp_in)}"
+ )
+ old_rv = {LEGACY_RV_MIN, LEGACY_RV_MAX} <= rv_in and LEGACY_RV_OUT in rv_out
+ old_cmp = {LEGACY_CMP_A, LEGACY_CMP_B} <= cmp_in
+ print(f"old_random_ids={old_rv} old_compare_ids={old_cmp}")
+ if legacy:
+ ifnot old_rv:
+ return fail("pre-5.2 Random Value identifiers missing", 4)
+ ifnot old_cmp:
+ return fail("pre-5.2 Compare INT identifiers missing", 4)
+ else:
+ if old_rv:
+ return fail("pre-5.2 Random Value identifiers still present on 5.2+", 4)
+ if old_cmp:
+ return fail("pre-5.2 Compare INT identifiers still present on 5.2+", 4)
+ return0
+
+
+def check(obj, rv, cmp, legacy_ids):
+ code = check_inventory(rv, cmp)
+ if code:
+ return code
+
+ bpy.context.view_layer.update()
+ coords, nfaces, attr_vals = eval_mesh(obj)
+ nverts = len(coords)
+ zmax = max(c[2] for c in coords) if coords else float("-inf")
+ n_hi = sum(1for v in attr_vals if abs(v - HEIGHT) <= ATTR_EPS)
+ n_lo = sum(1for v in attr_vals if abs(v) <= ATTR_EPS)
+ print(
+ f"legacy_ids={legacy_ids} verts={nverts} faces={nfaces}"
+ f"zmax={zmax:.6f} zmax_ok={ZMAX_OK:.6f}"
+ f"gauge_h_hi={n_hi} gauge_h_lo={n_lo} attr_n={len(attr_vals)}"
+ )
+ if nverts != VERTS_OK:
+ return fail(
+ f"evaluated verts {nverts} != {VERTS_OK}"
+ f"(Compare did not switch the column in; no-column={VERTS_NO_COLUMN})",
+ 7,
+ )
+ if abs(zmax - ZMAX_OK) > EXTENT_EPS:
+ return fail(
+ f"evaluated zmax {zmax:.6f} != {ZMAX_OK:.6f}",
+ 8,
+ )
+ if n_hi != 8or n_lo != 8:
+ return fail(
+ f"POINT {ATTR_NAME} hi={n_hi} lo={n_lo} (want 8/8 at {HEIGHT}); "
+ "Random Value did not land on the column",
+ 9,
+ )
+ return0
+
+
+def principled(name, color, metallic, roughness, emission=None):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ bsdf = mat.node_tree.nodes["Principled BSDF"]
+ bsdf.inputs["Base Color"].default_value = color
+ bsdf.inputs["Metallic"].default_value = metallic
+ bsdf.inputs["Roughness"].default_value = roughness
+ if emission isnotNone:
+ sock = bsdf.inputs.get("Emission Color") or bsdf.inputs["Emission"]
+ sock.default_value = emission
+ bsdf.inputs["Emission Strength"].default_value = 0.12
+ return mat
+
+
+def build(legacy_ids):
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ steel = principled("PlinthSteel", (0.22, 0.23, 0.26, 1.0), 1.0, 0.32)
+ copper = principled(
+ "ColumnCopper", (0.93, 0.42, 0.08, 1.0), 1.0, 0.22,
+ emission=(0.93, 0.42, 0.08, 1.0),
+ )
+ try:
+ tree, rv, cmp = build_tree(steel, copper, legacy_ids)
+ except LookupError as exc:
+ returnNone, None, None, fail(str(exc), 5if legacy_ids else6)
+ me = bpy.data.meshes.new("Gauge")
+ obj = bpy.data.objects.new("Gauge", me)
+ bpy.context.collection.objects.link(obj)
+ mod = obj.modifiers.new("GN", "NODES")
+ mod.node_group = tree
+ obj.rotation_euler = (0.0, 0.0, math.radians(38))
+ return obj, rv, cmp, 0
+
+
+def eevee_engine_id():
+ return"BLENDER_EEVEE"if bpy.app.version >= (5, 0, 0) else"BLENDER_EEVEE_NEXT"
+
+
+def render_still(obj, path, engine):
+ scene = bpy.context.scene
+
+ 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 = principled("Studio", (0.03, 0.032, 0.037, 1.0), 0.0, 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, 9.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
+
+ aim = bpy.data.objects.new("Aim", None)
+ aim.location = (0.0, 0.0, ZMAX_OK * 0.45)
+ aim.hide_render = True
+ scene.collection.objects.link(aim)
+
+ def light(name, loc, energy, size, col):
+ 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
+ scene.collection.objects.link(ob)
+ lc = ob.constraints.new("TRACK_TO")
+ lc.target = aim
+ lc.track_axis = "TRACK_NEGATIVE_Z"
+ lc.up_axis = "UP_Y"
+
+ light("Key", (-3.4, -4.6, 5.4), 680.0, 4.2, (1.0, 0.96, 0.9))
+ light("Fill", (4.8, -3.0, 2.2), 140.0, 8.0, (0.75, 0.85, 1.0))
+ light("Rim", (0.2, 5.8, 3.4), 300.0, 3.2, (0.6, 0.78, 1.0))
+ light("Glint", (1.8, -4.8, 5.6), 900.0, 0.85, (1.0, 0.90, 0.70))
+ wedge = bpy.data.lights.new("Wedge", "AREA")
+ wedge.energy = 480.0
+ wedge.size = 6.0
+ wedge.color = (1.0, 0.72, 0.42)
+ wob = bpy.data.objects.new("Wedge", wedge)
+ wob.location = (2.2, 5.2, 3.8)
+ wob.rotation_euler = (math.radians(-68), 0.0, math.radians(190))
+ scene.collection.objects.link(wob)
+
+ cam_data = bpy.data.cameras.new("Cam")
+ cam_data.lens = 50.0
+ cam = bpy.data.objects.new("Cam", cam_data)
+ cam.location = (4.15, -5.85, 2.55)
+ scene.collection.objects.link(cam)
+ scene.camera = cam
+ track = cam.constraints.new("TRACK_TO")
+ track.target = aim
+ track.track_axis = "TRACK_NEGATIVE_Z"
+ track.up_axis = "UP_Y"
+
+ scene.render.engine = "CYCLES"if engine == "cycles"else eevee_engine_id()
+ if engine == "cycles":
+ scene.cycles.samples = 32
+ 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
+ scene.view_settings.view_transform = "Standard"
+
+ fcode = gallery_framing.check_framing(
+ scene, cam,
+ hero=[obj],
+ elements=[obj],
+ stage=[floor, wall],
+ )
+ if fcode:
+ return fcode
+ bpy.ops.render.render(write_still=True)
+ ifnot (os.path.exists(path) and os.path.getsize(path) > 0):
+ print("ERROR: render produced no file", file=sys.stderr)
+ return12
+ return0
+
+
+def main():
+ argv = sys.argv[sys.argv.index("--") + 1:] if"--"in sys.argv else []
+ p = argparse.ArgumentParser()
+ p.add_argument("--output", default=None)
+ p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+ p.add_argument(
+ "--legacy-ids",
+ action="store_true",
+ help="falsification: wire Compare/Random Value by pre-5.2 identifiers",
+ )
+ args = p.parse_args(argv)
+
+ obj, rv, cmp, code = build(args.legacy_ids)
+ if code:
+ return code
+
+ code = check(obj, rv, cmp, args.legacy_ids)
+ if code:
+ return code
+
+ if args.output:
+ rcode = render_still(obj, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
+ print(f"rendered still {args.output}")
+
+ print("gn-socket-rename OK")
+ return0
+
+
+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)
+
The --output render path measures framing via examples/gallery_framing.py (exit 10 on violation).
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage; also carrier mesh was rewritten | | 3 | Repeat evaluated vert/face count off closed form | | 4 | Repeat X-centers off closed form | | 5 | For Each evaluated vert/face count off closed form | | 6 | For Each Z-centers off closed form | | 10 | Gallery framing violation | | 12 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output, --no-offset, --unpair-foreach, or --unpair-repeat.
# Depsgraph contour check — the CI check:
blender --background --python gp_lineart_contour.py --
+# Falsifier: use_contour off. Must exit non-zero.
+blender --background --python gp_lineart_contour.py -- --no-contour
+
# Also render the gallery still:
blender --background --python gp_lineart_contour.py -- --output lineart.png
-
It exits non-zero on failure. The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
-
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.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage; also GPv3 collection address contract | | 3 | LINEART thickness / radius trap for this Blender | | 4 | LINEART type, source, or use_contour (--no-contour lands here) | | 5 | Evaluated contour too thin | | 6 | Cleared source_object still produced strokes | | 7 | Contour+crease off still produced strokes, or restore failed | | 8 | --output produced no file | | 10 | Gallery framing violation |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --no-contour.
Source
@@ -298,9 +303,15 @@
Source
The check evaluates the modifier through the depsgraph (no bake required)
and asserts stroke/point lower bounds against the known failure modes above.
+``--no-contour`` clears ``use_contour`` after the modifier is built and
+still asserts it is True. That is the falsifier (``--same-axis`` in
+export-preset-axis). The GPv3 address shim and the thickness/radius trap
+are untouched.
+
By default it runs the correctness check only. Pass --output to render:
blender --background --python gp_lineart_contour.py --
+ blender --background --python gp_lineart_contour.py -- --no-contour
blender --background --python gp_lineart_contour.py -- --output l.png
"""
import bpy, bmesh, sys, os, math, argparse
@@ -696,10 +707,18 @@
# Cheap correctness check (no render) — the CI check:
blender --background --python grease_pencil_rosette.py --
+# Falsifier: open strokes. Must exit non-zero (cyclic).
+blender --background --python grease_pencil_rosette.py -- --open-strokes
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python grease_pencil_rosette.py -- --output rosette.png
blender --background --python grease_pencil_rosette.py -- --output rosette.png --engine cycles
-
It exits non-zero on failure (wrong version gate, structural mismatch, missing attribute layers, or attribute-buffer deviation from the closed form). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | GPv3 address / legacy trap for this Blender | | 4 | Object type, layer count, or frame number | | 5 | Stroke topology or cyclic (--open-strokes lands here) | | 6 | Lazy attribute layers missing or wrong domain/type | | 7 | Position buffer length or closed-form round-trip | | 8 | Grease Pencil material missing gpencil settings | | 9 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --open-strokes.
Source
@@ -299,11 +305,16 @@
Source
materialization, and a closed-form round-trip of every position through the
raw POINT attribute buffer.
+``--open-strokes`` leaves every stroke non-cyclic and still asserts they
+are cyclic. That is the falsifier (``--same-axis`` in export-preset-axis).
+The GPv3 address shim is untouched.
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
- blender --background --python grease_pencil_rosette.py -- # check only
- blender --background --python grease_pencil_rosette.py -- --output r.png # + render
+ blender --background --python grease_pencil_rosette.py -- # check only
+ blender --background --python grease_pencil_rosette.py -- --open-strokes # must fail
+ blender --background --python grease_pencil_rosette.py -- --output r.png # + render
"""
import bpy, sys, os, math, argparse, colorsys
@@ -346,7 +357,7 @@
drawing.add_strokes([POINTS] * RINGS)
for ring, stroke in enumerate(drawing.strokes):
- stroke.cyclic = True
+ stroke.cyclic = not open_strokes
for i, pt in enumerate(stroke.points):
+
pt.position = rose_point(ring, i)
pt.radius = point_radius(ring, i)
pt.opacity = 1.0
@@ -547,9 +559,12 @@
Source
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)")
+ p.add_argument("--open-strokes", action="store_true",
+ help="falsifier: strokes not cyclic, still assert cyclic")
args = p.parse_args(argv)
- obj = build_rosette()
+ obj = build_rosette(open_strokes=args.open_strokes)
+
code = check(obj)
if code:
return code
diff --git a/docs/gallery/image-pixels-testcard/index.html b/docs/gallery/image-pixels-testcard/index.html
index c470ade..ba654d5 100644
--- a/docs/gallery/image-pixels-testcard/index.html
+++ b/docs/gallery/image-pixels-testcard/index.html
@@ -270,10 +270,17 @@
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python image_pixels_testcard.py --
+# Falsifier: write the card top-down. Must exit non-zero (byte round-trip).
+blender --background --python image_pixels_testcard.py -- --wrong-origin
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python image_pixels_testcard.py -- --output card.png
blender --background --python image_pixels_testcard.py -- --output card.png --engine cycles
-
It exits non-zero on failure and prints every measured error and tolerance on success, so CI logs carry the numbers. The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS. In the render, Closest interpolation keeps the pixel grid honest — the jagged circle edge is the 512 × 288 buffer itself, and the white marker in the PLUGE row sits at the bottom-left because that is where pixel (0, 0) lives. The monitor is staged as a designed object — beveled dark-polymer case, machined metal stand, teal power LED — on the dark studio stage from docs/VISUAL-STYLE.md (Standard view transform; warm key, cool fill and rim; a warm pool raking the back wall). The screen stays emissive and matte — specular off, emission strength 1.0 — so the card's values read exactly.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Pixel buffer is not always RGBA | | 4 | Byte round-trip vs closed-form card (--wrong-origin lands here) | | 5 | Float-buffer round-trip failed | | 6 | scale() did not reallocate, or stale-size read succeeded | | 7 | save() source/buffer-drop contract drifted | | 8 | save_render() flipped source or disturbed the buffer | | 9 | Byte PNG save/reload error | | 10 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --wrong-origin.
+
In the render, Closest interpolation keeps the pixel grid honest — the jagged circle edge is the 512 × 288 buffer itself, and the white marker in the PLUGE row sits at the bottom-left because that is where pixel (0, 0) lives. The monitor is staged as a designed object — beveled dark-polymer case, machined metal stand, teal power LED — on the dark studio stage from docs/VISUAL-STYLE.md (Standard view transform; warm key, cool fill and rim; a warm pool raking the back wall). The screen stays emissive and matte — specular off, emission strength 1.0 — so the card's values read exactly.
Source
@@ -301,10 +308,15 @@
Source
original datablock. `save_render()` writes the same PNG but leaves
`source` == 'GENERATED' and the buffer intact and exact.
+``--wrong-origin`` writes the card top-down and still compares against
+the bottom-left closed form, so the byte round-trip fails. That is the
+falsifier (``--same-axis`` in export-preset-axis).
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python image_pixels_testcard.py -- # check only
+ blender --background --python image_pixels_testcard.py -- --wrong-origin # must fail
blender --background --python image_pixels_testcard.py -- --output t.png # + render
"""
import bpy, sys, os, math, argparse, tempfile
@@ -349,14 +361,15 @@
Source
return r, g, b, 1.0
-def flat_pattern():
+def flat_pattern(flip_origin=False):
"""The whole card as one flat RGBA buffer in pixel-buffer order:
row-major from the BOTTOM row up, 4 floats per pixel."""
buf = [0.0] * (W * H * 4)
i = 0for y in range(H):
+ y_src = (H - 1 - y) if flip_origin else y
for x in range(W):
- buf[i:i + 4] = pattern(x, y)
+ buf[i:i + 4] = pattern(x, y_src)
i += 4return buf
@@ -366,9 +379,10 @@
Source
return code
-def check():
+def check(wrong_origin=False):
bpy.ops.wm.read_factory_settings(use_empty=True)
expected = flat_pattern()
+ written = flat_pattern(flip_origin=True) if wrong_origin else expected
# -- buffer geometry: always RGBA, even with alpha=False ----------------
img = bpy.data.images.new("TestCard", W, H, alpha=False)
@@ -382,7 +396,7 @@
Source
pass# -- byte image: one bulk write, quantized round-trip --------------------
- img.pixels.foreach_set(expected)
+ img.pixels.foreach_set(written)
got = [0.0] * (W * H * 4)
img.pixels.foreach_get(got)
byte_err = max(abs(a - b) for a, b in zip(expected, got))
@@ -653,9 +667,11 @@
Source
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)")
+ p.add_argument("--wrong-origin", action="store_true",
+ help="write the card top-down (must fail)")
args = p.parse_args(argv)
- code = check()
+ code = check(wrong_origin=args.wrong_origin)
if code:
return code
diff --git a/docs/gallery/index.html b/docs/gallery/index.html
index 1a846a7..0c2f7dd 100644
--- a/docs/gallery/index.html
+++ b/docs/gallery/index.html
@@ -272,7 +272,7 @@
Compare and Random Value socket identifiers collapsed onto reused names in 5.2; enabled-name lookup wires on 4.5, 5.1, and 5.2
+
witnesses A_INT and Min_001 present on 4.5.11 and 5.1.2, absent on 5.2.1. --legacy-ids exits 5 only on 5.2. Evaluated 16 verts and gauge_h 1.80 on eight column verts
# Two-render correctness check (tiny Cycles CPU renders) — the CI check:
blender --background --python light_link_studio.py --
+# Falsifier: receiver_collection unset. Must exit non-zero (assignment).
+blender --background --python light_link_studio.py -- --skip-link
+
# Also render the gallery still (Cycles, deterministic samples):
blender --background --python light_link_studio.py -- --output linked.png
-
It exits non-zero on failure (API moved, assignment lost, ratio below gate, insufficient unlink rise, or hero drift). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Light datablock gained light_linking | | 4 | Light objects lost light_linking | | 5 | receiver_collection assignment did not read back (--skip-link lands here) | | 6 | Linked state: hero dark or hero/decoy ratio below gate | | 7 | Decoy rise below gate when unlinked | | 8 | Hero drifted across the unlink | | 9 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --skip-link.
Source
@@ -301,10 +307,14 @@
Source
4.5 LTS and 5.1 (ObjectLightLinking with
receiver_collection/blocker_collection on both).
+``--skip-link`` leaves ``receiver_collection`` unset and still asserts the
+hero collection. That is the falsifier (``--same-axis`` in export-preset-axis).
+
By default it runs the two-render correctness check (no gallery still) — the
CI smoke check. Pass --output to also render a still:
blender --background --python light_link_studio.py -- # check only
+ blender --background --python light_link_studio.py -- --skip-link # must fail
blender --background --python light_link_studio.py -- --output l.png # + render
"""
import bpy, bmesh, sys, os, math, argparse, tempfile, shutil
@@ -569,7 +579,7 @@
Source
return out[0], out[1]
-def check(sc, key, hero_c, hero, decoy):
+def check(sc, key, hero_c, hero, decoy, skip_link=False):
# contract 0 (RNA guard): the API is on the light OBJECT
ld = key.data
if hasattr(ld, "light_linking"):
@@ -581,7 +591,7 @@
Source
return4# contract 1 (assignment round-trip through the API itself)
- key.light_linking.receiver_collection = hero_c
+ key.light_linking.receiver_collection = Noneif skip_link else hero_c
if key.light_linking.receiver_collection != hero_c:
print("ERROR: receiver_collection assignment did not read back",
file=sys.stderr)
@@ -679,12 +689,15 @@
Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS. 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.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper. 11 is the shared asset-quality helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | UV layer names ≠ UV0 / UV1 | | 4 | UVMap / UV1 render/clone flags wrong after re-assert | | 5 | Channel 0 (UV0) touched by the UV1 unwrap | | 6 | UV1 loops outside [0,1] | | 7 | SAT overlap self-test failed, or UV1 triangle pairs overlap | | 8 | Min island distance below margin | | 9 | Part is not watertight | | 10 | Gallery framing violation; also --output produced no file | | 11 | Gallery asset-quality violation |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --falsify.
# Cheap correctness check (no render) — the CI check:
blender --background --python lod_decimate_chain.py --
+# Falsifier: no Decimate on the LOD copies. Must exit non-zero (no reduction).
+blender --background --python lod_decimate_chain.py -- --no-decimate
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python lod_decimate_chain.py -- --output rocket.png
blender --background --python lod_decimate_chain.py -- --output rocket.png --engine cycles
-
It exits non-zero on failure (base-topology drift, no reduction, a mutated original datablock, a ratio-bounds excursion, or silhouette loss). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Base topology ≠ closed form | | 4 | Base bbox ≠ closed form | | 5 | LOD0 evaluated counts ≠ base | | 6 | Evaluated tris ≥ base (--no-decimate lands here) | | 7 | Original datablock mutated after evaluation | | 8 | LOD tris outside ratio bounds | | 9 | LOD bbox lost silhouette-critical dimensions | | 10 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --no-decimate.
Source
@@ -305,10 +311,15 @@
Source
between Blender 4.5 LTS and 5.1 — the example runs identically on both, which
is itself the version witness.
+``--no-decimate`` leaves the LOD copies without a Decimate modifier and
+still runs the reduction check, so evaluated tris equal the base. That is
+the falsifier (``--same-axis`` in export-preset-axis).
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python lod_decimate_chain.py -- # check only
+ blender --background --python lod_decimate_chain.py -- --no-decimate # must fail
blender --background --python lod_decimate_chain.py -- --output r.png # + render
"""
import bpy, bmesh, sys, os, math, argparse
@@ -477,7 +488,7 @@
measured = []
for lod, ratio in ((lod1, LODS[0]), (lod2, LODS[1])):
- add_decimate(lod, ratio)
+ ifnot no_decimate:
+ add_decimate(lod, ratio)
snap = eval_mesh(lod)
# contract 2: triangle count lands near ratio * base, within bounds
target = ratio * want_t
@@ -678,6 +690,8 @@
Source
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)")
+ p.add_argument("--no-decimate", action="store_true",
+ help="skip adding Decimate to the LOD copies (must fail)")
args = p.parse_args(argv)
bpy.ops.wm.read_factory_settings(use_empty=True)
@@ -688,7 +702,8 @@
Source
for m in mats:
r.data.materials.append(m)
rockets.append(r)
- code = check(rockets[0], rockets[1], rockets[2])
+ code = check(rockets[0], rockets[1], rockets[2],
+ no_decimate=args.no_decimate)
if code:
return code
diff --git a/docs/gallery/mesh-hygiene-audit/index.html b/docs/gallery/mesh-hygiene-audit/index.html
index edf556a..92e7fd9 100644
--- a/docs/gallery/mesh-hygiene-audit/index.html
+++ b/docs/gallery/mesh-hygiene-audit/index.html
@@ -270,9 +270,13 @@
mesh-hygiene-audit
Not depicted in the still (check-proven only): ngon dissolve, zero-area collapse, and full-mesh winding invert — they do not read as geometry at thumbnail scale without faking annotation.
Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS. 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.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --inject-ngon.
Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS. The --output render path additionally measures framing against the Layer 1 band via examples/gallery_framing.py (reported under the documented deviation above) before writing the still.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper. 11 is the shared asset-quality helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Boundary vert count or end-plane membership | | 4 | End rings do not partition evenly, or opposing loops differ | | 5 | Tiled instances do not share boundary positions | | 6 | Shell or whole-asset bbox off the declared tile | | 7 | Boundary edge count, non-manifold edges, or rim off the end planes | | 8 | Detail part not watertight | | 9 | Detail part reaches a tile boundary; also --output produced no file | | 11 | Kit part count, unapplied scale, namespace, or origin; also gallery asset-quality violation |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --falsify.
# Cheap correctness check (no render) — the CI check:
blender --background --python parent_inverse_orrery.py --
+# Falsifier: parent without MPI. Must exit non-zero (orbit closed form).
+blender --background --python parent_inverse_orrery.py -- --skip-mpi
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python parent_inverse_orrery.py -- --output orrery.png
blender --background --python parent_inverse_orrery.py -- --output orrery.png --engine cycles
-
It exits non-zero on failure (no jump from the trap, keep-world error, stale-matrix contract broken, or an orbit off its closed form). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Bare parenting did not jump | | 4 | Keep-world idiom off | | 5 | Stale matrix_world contract broken | | 6 | Planet off closed-form orbit (--skip-mpi lands here) | | 7 | Moon off closed-form orbit | | 8 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --skip-mpi.
Source
@@ -295,10 +301,15 @@
Source
every planet and the moon must land on the closed-form orbit position
(rotation about the column axis, composed per hierarchy level).
+``--skip-mpi`` parents without ``matrix_parent_inverse`` and still asserts
+closed-form orbits. That is the falsifier (``--same-axis`` in
+export-preset-axis).
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python parent_inverse_orrery.py -- # check only
+ blender --background --python parent_inverse_orrery.py -- --skip-mpi # must fail
blender --background --python parent_inverse_orrery.py -- --output o.png # + render
"""
import bpy, bmesh, sys, os, math, argparse
@@ -352,13 +363,14 @@
Source
return obj
-def parent_keep_world(child, parent):
+def parent_keep_world(child, parent, skip_mpi=False):
"""The idiom this example witnesses: parent without moving the child."""
child.parent = parent
- child.matrix_parent_inverse = parent.matrix_world.inverted()
+ ifnot skip_mpi:
+ child.matrix_parent_inverse = parent.matrix_world.inverted()
-def build_orrery():
+def build_orrery(skip_mpi=False):
"""Author the whole hierarchy with bpy.data (no object-mode operators)."""
bpy.ops.wm.read_factory_settings(use_empty=True)
@@ -380,8 +392,8 @@
Source
# everything is placed at its theta=0 WORLD position first, then# parented with the keep-world idiom -- nothing may move here
bpy.context.view_layer.update()
- parent_keep_world(arm, pivot)
- parent_keep_world(planet, pivot)
+ parent_keep_world(arm, pivot, skip_mpi=skip_mpi)
+ parent_keep_world(planet, pivot, skip_mpi=skip_mpi)
rig["planets"][name] = {
"pivot": pivot, "planet": planet, "angle": math.radians(angle),
"p0": Vector((radius, 0.0, height)),
@@ -396,9 +408,9 @@
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)")
+ p.add_argument("--skip-mpi", action="store_true",
+ help="parent without matrix_parent_inverse (must fail)")
args = p.parse_args(argv)
- rig = build_orrery()
+ rig = build_orrery(skip_mpi=args.skip_mpi)
code = check(rig)
if code:
return code
diff --git a/docs/gallery/png-exr-alpha/index.html b/docs/gallery/png-exr-alpha/index.html
index 1ba4183..f1fc5f1 100644
--- a/docs/gallery/png-exr-alpha/index.html
+++ b/docs/gallery/png-exr-alpha/index.html
@@ -271,11 +271,16 @@
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python png_exr_alpha.py --
+# Falsifier: opaque authored alpha. Must exit non-zero (float→PNG error floor).
+blender --background --python png_exr_alpha.py -- --opaque-alpha
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python png_exr_alpha.py -- --output alpha.png
blender --background --python png_exr_alpha.py -- --output alpha.png --engine cycles
-
It exits non-zero on failure and prints every measured error and tolerance on success, so CI logs carry the numbers. The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
-
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.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is also the shared framing helper.
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --opaque-alpha.
Source
@@ -295,11 +300,14 @@
Source
with straight alpha and only pays ordinary quantization.
AI-generated Blender code commonly trusts `Image.save()` to PNG for float
-RGBA scratch buffers (masks, ID mattes, AOVs). Pass --output to also render
-a staged still of two framed verification displays: left is the PNG-mangled
-reload, right is the EXR-clean reload.
+RGBA scratch buffers (masks, ID mattes, AOVs). ``--opaque-alpha`` authors
+every pixel at alpha 1.0 and still asserts the float→PNG error floor.
+That is the falsifier (``--same-axis`` in export-preset-axis). Pass --output
+to also render a staged still of two framed verification displays: left is
+the PNG-mangled reload, right is the EXR-clean reload.
blender --background --python png_exr_alpha.py --
+ blender --background --python png_exr_alpha.py -- --opaque-alpha
blender --background --python png_exr_alpha.py -- --output alpha.png
"""
import bpy
@@ -371,11 +379,12 @@
Source
return (q8(r), q8(g), q8(b), q8(a))
-def fill_pattern(img):
+def fill_pattern(img, opaque_alpha=False):
buf = [0.0] * (W * H * 4)
+ alphas = [1.0] * W if opaque_alpha else ALPHAS
for y, rgb in enumerate(COLORS):
r, g, b = rgb
- for x, a in enumerate(ALPHAS):
+ for x, a in enumerate(alphas):
i = (y * W + x) * 4
buf[i : i + 4] = [r, g, b, a]
img.pixels.foreach_set(buf)
@@ -454,7 +463,7 @@
Source
returnNone, None
-def check():
+def check(opaque_alpha=False):
bpy.ops.wm.read_factory_settings(use_empty=True)
# --- Closed-form worst case on this palette (independent of Blender) ---
@@ -483,7 +492,7 @@
"--engine", default="eevee", choices=("eevee", "cycles"),
help="render engine for --output (cycles for GPU-less hosts)",
)
+ p.add_argument(
+ "--opaque-alpha", action="store_true",
+ help="falsifier: author alpha=1.0, still assert the float→PNG error floor",
+ )
args = p.parse_args(argv)
- code = check()
+ code = check(opaque_alpha=args.opaque_alpha)
if code != 0:
return code
if args.output:
diff --git a/docs/gallery/prop-origin-transform/index.html b/docs/gallery/prop-origin-transform/index.html
index 0b56aa5..606e0ab 100644
--- a/docs/gallery/prop-origin-transform/index.html
+++ b/docs/gallery/prop-origin-transform/index.html
@@ -269,9 +269,13 @@
prop-origin-transform
The render is a dual panel: left TRAP (bare parent — flanged conduit teleports off a deep shadowed mount socket) vs right MPI KEEP (same metal fitting seated in the socket), with emissive origin markers at each pedestal's base. Accessory and pedestal stay in one material family so displacement — not color — carries the proof. Check closed forms are unchanged.
Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS. 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.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Stale matrix_world contract broken | | 4 | World bbox moved across bake | | 5 | Scale after bake is not (1,1,1) | | 6 | Origin not at local base center | | 7 | Bare parenting did not jump | | 8 | MPI did not restore world location (--skip-mpi lands here) | | 9 | --output produced no file | | 10 | Gallery framing violation |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --skip-mpi.
Source
@@ -288,10 +292,14 @@
Source
`parent-inverse-orrery` (MPI idiom + stale `matrix_world`) without retreading
orbits — subject is a street utility pedestal with a bolted conduit accessory.
+``--skip-mpi`` parents the accessory without MPI and still asserts the restore.
+That is the falsifier (``--same-axis`` in export-preset-axis).
+
By default it runs only the correctness check (no render). Pass --output
to also render a still:
blender --background --python prop_origin_transform.py --
+ blender --background --python prop_origin_transform.py -- --skip-mpi
blender --background --python prop_origin_transform.py -- --output o.png
"""
import bpy, bmesh, sys, os, math, argparse
@@ -538,7 +546,7 @@
# Cheap correctness check (no render) — the CI check:
blender --background --python shader_node_group.py --
+# Falsifier: identical instance Tints. Must exit non-zero.
+blender --background --python shader_node_group.py -- --same-tint
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python shader_node_group.py -- --output spheres.png
blender --background --python shader_node_group.py -- --output spheres.png --engine cycles
-
It exits non-zero on failure (missing interface sockets, unshared group, or identical instance parameters). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
-
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.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --same-tint.
Source
@@ -287,10 +292,15 @@
Source
datablock (users == 2), and their instance-level Tint values differ — the
whole point of grouping.
+``--same-tint`` copies SphereA's Tint onto both instances and still asserts
+the values differ. That is the falsifier (``--same-axis`` in
+export-preset-axis).
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python shader_node_group.py -- # check only
+ blender --background --python shader_node_group.py -- --same-tint # must fail
blender --background --python shader_node_group.py -- --output s.png # + render
"""
import bpy, bmesh, sys, os, math, argparse
@@ -338,7 +348,7 @@
Source
return mat
-def build_scene():
+def build_scene(same_tint=False):
bpy.ops.wm.read_factory_settings(use_empty=True)
tree = build_group()
objs = []
@@ -354,7 +364,8 @@
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)")
+ p.add_argument("--same-tint", action="store_true",
+ help="falsifier: identical instance Tints, still assert they differ")
args = p.parse_args(argv)
- tree, objs = build_scene()
+ tree, objs = build_scene(same_tint=args.same_tint)
+
code = check(tree, objs)
if code:
return code
diff --git a/docs/gallery/shape-key-blend/index.html b/docs/gallery/shape-key-blend/index.html
index c0b6c59..4e89243 100644
--- a/docs/gallery/shape-key-blend/index.html
+++ b/docs/gallery/shape-key-blend/index.html
@@ -265,10 +265,16 @@
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python shape_key_blend.py --
+# Falsifier: Tall.value = 0. Must exit non-zero.
+blender --background --python shape_key_blend.py -- --zero-blend
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python shape_key_blend.py -- --output blend.png
blender --background --python shape_key_blend.py -- --output blend.png --engine cycles
-
It exits non-zero on failure (missing keys, wrong value, per-vert blend mismatch, or flare miss). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | No shape keys on mesh | | 4 | Key names ≠ Basis, Tall | | 5 | Tall.value ≠ 0.5 (--zero-blend lands here) | | 6 | Undeformed mesh.vertices not at Basis | | 7 | Evaluated vert off closed-form blend | | 8 | Evaluated Z span off closed form | | 9 | Top flare off closed form | | 10 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --zero-blend.
Source
@@ -288,10 +294,14 @@
Source
The Tall key both lifts and flares the top face, so the silhouette is a
truncated pyramid — clearly a blend, not a uniformly scaled box.
+``--zero-blend`` sets Tall.value to 0 and still asserts the 0.5 closed form.
+That is the falsifier (``--same-axis`` in export-preset-axis).
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python shape_key_blend.py -- # check only
+ blender --background --python shape_key_blend.py -- --zero-blend # must fail
blender --background --python shape_key_blend.py -- --output s.png # + render
"""
import bpy, bmesh, sys, os, math, argparse
@@ -306,7 +316,7 @@
Source
EXPECT_TOP_HALF = HALF + BLEND * FLARE # |x| and |y| of top verts
-def build():
+def build(zero_blend=False):
bpy.ops.wm.read_factory_settings(use_empty=True)
me = bpy.data.meshes.new("Block")
bm = bmesh.new()
@@ -329,7 +339,7 @@
Source
# flare top face outward so the blend reads as a taper, not a box
co.x = math.copysign(HALF + FLARE, co.x)
co.y = math.copysign(HALF + FLARE, co.y)
- tall.value = BLEND
+ tall.value = 0.0if zero_blend else BLEND
return obj
@@ -523,9 +533,11 @@
Source
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)")
+ p.add_argument("--zero-blend", action="store_true",
+ help="set Tall.value to 0 (must fail)")
args = p.parse_args(argv)
- obj = build()
+ obj = build(zero_blend=args.zero_blend)
code = check(obj)
if code:
return code
diff --git a/docs/gallery/sky-texture-sun-elevation/index.html b/docs/gallery/sky-texture-sun-elevation/index.html
index 2b5ec9f..a7d7c80 100644
--- a/docs/gallery/sky-texture-sun-elevation/index.html
+++ b/docs/gallery/sky-texture-sun-elevation/index.html
@@ -272,9 +272,15 @@
Run
# Zenith-luminance correctness check (tiny Cycles CPU EXR probes) — the CI check:
blender --background --python sky_texture_sun_elevation.py --
+# Falsifier: Sky→Background unlinked. Must exit non-zero (world links).
+blender --background --python sky_texture_sun_elevation.py -- --unlink-sky
+
# Also render the gallery diptych (Cycles):
blender --background --python sky_texture_sun_elevation.py -- --output sky.png
-
It exits non-zero on failure. The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | sky_type is not the identifier for this Blender | | 4 | sun_elevation round-trip failed | | 5 | dust_density / aerosol_density / NISHITA / MULTIPLE_SCATTERING trap | | 6 | Sky → Background → World Output links broken (--unlink-sky lands here) | | 7 | High-elevation zenith luma below floor | | 8 | Zenith luma did not rise with elevation | | 9 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --unlink-sky.
Source
@@ -296,10 +302,15 @@
Source
4. ``sun_elevation`` is load-bearing: raising it brightens zenith luminance
— proven with two tiny Cycles EXR probes (straight-up camera) in one check.
+``--unlink-sky`` drops the Sky → Background Color link and still asserts
+the chain. That is the falsifier (``--same-axis`` in export-preset-axis).
+The sky_type / dust_density version traps are untouched.
+
By default it runs the correctness check (tiny Cycles CPU renders, no gallery
still). Pass --output to also render a still:
blender --background --python sky_texture_sun_elevation.py --
+ blender --background --python sky_texture_sun_elevation.py -- --unlink-sky
blender --background --python sky_texture_sun_elevation.py -- --output s.png
"""
import bpy, bmesh, sys, os, math, argparse, tempfile, shutil
@@ -588,7 +599,12 @@
Source
return0
-def check(sc, world, sky, bg):
+def check(sc, world, sky, bg, unlink_sky=False):
+ if unlink_sky:
+ nt = world.node_tree
+ for link in list(nt.links):
+ if link.from_node == sky and link.to_node.name == "Background":
+ nt.links.remove(link)
code = check_links(world)
if code:
return code
@@ -786,11 +802,16 @@
Source
choices=("eevee", "cycles"),
help="render engine for --output (cycles default: sky is a Cycles strength)",
)
+ p.add_argument(
+ "--unlink-sky",
+ action="store_true",
+ help="falsifier: drop Sky→Background link, still assert the chain",
+ )
args = p.parse_args(argv)
print(f"binary version: {bpy.app.version} ({bpy.app.version_string})")
sc, world, sky, bg = build_scene()
- code = check(sc, world, sky, bg)
+ code = check(sc, world, sky, bg, unlink_sky=args.unlink_sky)
if code:
return code
diff --git a/docs/gallery/soccer-ball-goldberg/index.html b/docs/gallery/soccer-ball-goldberg/index.html
index 0c9fa2f..ab4b251 100644
--- a/docs/gallery/soccer-ball-goldberg/index.html
+++ b/docs/gallery/soccer-ball-goldberg/index.html
@@ -270,10 +270,16 @@
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python soccer_ball_goldberg.py --
+# Falsifier: swap pentagon/hexagon slots. Must exit non-zero.
+blender --background --python soccer_ball_goldberg.py -- --invert-bind
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python soccer_ball_goldberg.py -- --output ball.png
blender --background --python soccer_ball_goldberg.py -- --output ball.png --engine cycles
-
It exits non-zero on failure (topology, census, degree, edge uniformity, planarity, circumsphere, or panel binding). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Topology ≠ 60/90/32 | | 4 | Euler characteristic ≠ 2 | | 5 | Face census ≠ 12 pentagons + 20 hexagons | | 6 | Vertex degree not uniform 3; also --output produced no file | | 7 | Non-manifold edges | | 8 | Edge lengths not uniform | | 9 | Face planarity off | | 10 | Centroid off origin | | 11 | Circumradius not uniform | | 12 | Panel material count ≠ 2 | | 13 | Panel binding not by vertex count (--invert-bind lands here) |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --invert-bind.
Source
@@ -296,6 +302,7 @@
Source
check. Pass --output to also render a still:
blender --background --python soccer_ball_goldberg.py -- # check only
+ blender --background --python soccer_ball_goldberg.py -- --invert-bind # must fail
blender --background --python soccer_ball_goldberg.py -- --output b.png # + render
"""
import bpy, bmesh, sys, os, math, argparse
@@ -350,7 +357,7 @@
Source
prev_face, current = face, nxt
-def build_ball():
+def build_ball(invert_bind=False):
"""Truncate a bmesh icosphere at 1/3 per edge into the Goldberg ball.
The icosphere is the topology source: cut points are computed per edge,
@@ -433,7 +440,8 @@
Source
# builder that assigns "first 12 faces black" passes only by luck of# bmesh face ordering, and the check below must catch itfor poly in me.polygons:
- poly.material_index = 1if len(poly.vertices) == 5else0
+ pent = len(poly.vertices) == 5
+ poly.material_index = (0if pent else1) if invert_bind else (1if pent else0)
return obj
@@ -675,9 +683,11 @@
Source
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)")
+ p.add_argument("--invert-bind", action="store_true",
+ help="swap pentagon/hexagon material slots (must fail)")
args = p.parse_args(argv)
- obj = build_ball()
+ obj = build_ball(invert_bind=args.invert_bind)
code = check(obj)
if code:
return code
diff --git a/docs/gallery/socket-attach-points/index.html b/docs/gallery/socket-attach-points/index.html
index 4517035..b7979a8 100644
--- a/docs/gallery/socket-attach-points/index.html
+++ b/docs/gallery/socket-attach-points/index.html
@@ -278,7 +278,11 @@
Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS. The --output render path additionally gates framing via examples/gallery_framing.py (fill 0.881x, margins 0.066/0.053/0.122/0.106, no edge touched) and the asset floors via examples/gallery_asset_quality.py (32 materials, edge900.027, no default names). The --falsify render is a diagnostic, not a gallery hero, so it takes a documented framing deviation — its whole point is that the modules leave the frame.
+
The --output render path additionally gates framing via examples/gallery_framing.py (fill 0.881x, margins 0.066/0.053/0.122/0.106, no edge touched) and the asset floors via examples/gallery_asset_quality.py (32 materials, edge900.027, no default names). The --falsify render is a diagnostic, not a gallery hero, so it takes a documented framing deviation — its whole point is that the modules leave the frame.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper. 11 is the shared asset-quality helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Socket world matrix, orthonormal basis, or determinant | | 4 | Socket pad normal, origin, or up-axis rule | | 5 | Module seating / mount axis / leftover local transform | | 6 | Population, namespace, default datablock name, SKT_ prefix, or skid plane | | 7 | Re-pose: socket or module lost the root | | 8 | Parent-inverse clear, child scale pushdown, or post-apply module drift | | 9 | --output produced no file | | 10 | Gallery framing violation | | 11 | Gallery asset-quality violation |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output, --falsify, or --probe.
# Cheap correctness check (materials + engine-id witness, no render):
blender --background --python swatch_grid.py --
+# Falsifier: same RGB on every swatch. Must exit non-zero.
+blender --background --python swatch_grid.py -- --same-base
+
# Render and pixel-verify with the build's EEVEE engine (needs a GPU/display):
blender --background --python swatch_grid.py -- --output swatch.png
# GPU-less / CI hosts: render the pixels with Cycles (CPU). The EEVEE id is still
# asserted; only the final pixels use Cycles.
blender --background --python swatch_grid.py -- --output swatch.png --engine cycles --samples 16 --width 960
-
The script is deterministic and dependency-light (fixed camera and layout, no HDRI, no network). It exits non-zero on any failure, including a render that comes out uniformly black or without the expected six distinct swatch regions — the same honest check the CI smoke gate runs on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Distinct swatch colors ≠ 6 (--same-base lands here); also render not six distinct regions | | 4 | --output produced no file | | 5 | Wrong-era EEVEE engine id was accepted | | 10 | Gallery framing violation |
+
--no-verify was a skip-flag and has been removed. Pixel verification always runs when --output is passed.
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --same-base.
Verified
Runs headless on Blender 4.5.10 LTS and 5.1.1; exercised on both by the blender-smoke workflow on every PR and weekly schedule. 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.
@@ -292,9 +299,12 @@
Source
on 4.2-4.5, and the chosen id is asserted against the build before rendering.
By default it runs only the correctness check (no render) — the CI smoke check.
-Pass --output to also render and pixel-verify a still:
+Pass --output to also render and pixel-verify a still. ``--same-base`` writes
+the same RGB to every swatch and still asserts six distinct colors, so the
+count fails. That is the falsifier (``--same-axis`` in export-preset-axis).
blender --background --python swatch_grid.py -- # check only
+ blender --background --python swatch_grid.py -- --same-base # must fail
blender --background --python swatch_grid.py -- --output swatch.png
blender --background --python swatch_grid.py -- --output s.png --engine cycles --samples 8 --width 640
@@ -467,6 +477,39 @@
Source
bpy.context.scene.world = world
+def swatch_rgb(mat):
+ for node in mat.node_tree.nodes:
+ if node.type == "BSDF_PRINCIPLED":
+ c = node.inputs["Base Color"].default_value
+ return (round(c[0], 4), round(c[1], 4), round(c[2], 4))
+ if node.type == "EMISSION":
+ c = node.inputs["Color"].default_value
+ return (round(c[0], 4), round(c[1], 4), round(c[2], 4))
+ returnNone
+
+
+def flatten_swatch_colors(mats):
+ gray = (0.5, 0.5, 0.5, 1.0)
+ for mat in mats:
+ for node in mat.node_tree.nodes:
+ if node.type == "BSDF_PRINCIPLED":
+ node.inputs["Base Color"].default_value = gray
+ elif node.type == "EMISSION":
+ node.inputs["Color"].default_value = gray
+
+
+def check_distinct_swatches(mats):
+ colors = [swatch_rgb(m) for m in mats]
+ if len(set(colors)) != MATERIAL_COUNT:
+ print(
+ f"ERROR: distinct swatch colors {len(set(colors))} != "
+ f"{MATERIAL_COUNT} (got {colors})",
+ file=sys.stderr,
+ )
+ return3
+ return0
+
+
def verify_png(path):
"""Honest capture: not uniformly black AND distinct swatch regions == MATERIAL_COUNT."""
img = bpy.data.images.load(path)
@@ -494,12 +537,18 @@
Source
help="auto/eevee use the version-correct EEVEE id; cycles for GPU-less hosts")
p.add_argument("--samples", type=int, default=32)
p.add_argument("--width", type=int, default=1280)
- p.add_argument("--no-verify", action="store_true")
+ p.add_argument("--same-base", action="store_true",
+ help="write the same RGB to every swatch (must fail)")
args = p.parse_args(argv)
# Empty the factory file FIRST so the materials we create below survive.
bpy.ops.wm.read_factory_settings(use_empty=True)
mats, specular_socket = build_materials()
+ if args.same_base:
+ flatten_swatch_colors(mats)
+ dcode = check_distinct_swatches(mats)
+ if dcode:
+ return dcode
build_scene(mats)
sc = bpy.context.scene
@@ -555,15 +604,14 @@
Source
return4
print(f"rendered {args.output} with {render_engine} ({os.path.getsize(args.output)} bytes)")
- ifnot args.no_verify:
- gmax, regions = verify_png(args.output)
- non_black = gmax > 0.05
- regions_ok = regions == MATERIAL_COUNT
- print(f"verify: max_pixel={gmax:.3f} non_black={non_black}"
- f"distinct_regions={regions} materials={MATERIAL_COUNT} ok={regions_ok}")
- ifnot (non_black and regions_ok):
- print("ERROR: render failed verification (black or wrong region count)", file=sys.stderr)
- return3
+ gmax, regions = verify_png(args.output)
+ non_black = gmax > 0.05
+ regions_ok = regions == MATERIAL_COUNT
+ print(f"verify: max_pixel={gmax:.3f} non_black={non_black}"
+ f"distinct_regions={regions} materials={MATERIAL_COUNT} ok={regions_ok}")
+ ifnot (non_black and regions_ok):
+ print("ERROR: render failed verification (black or wrong region count)", file=sys.stderr)
+ return3
print("swatch-grid OK")
return0
diff --git a/docs/gallery/temp-override-join/index.html b/docs/gallery/temp-override-join/index.html
index bb4e81a..a5952e0 100644
--- a/docs/gallery/temp-override-join/index.html
+++ b/docs/gallery/temp-override-join/index.html
@@ -265,10 +265,16 @@
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python temp_override_join.py --
+# Falsifier: join without temp_override. Must exit non-zero.
+blender --background --python temp_override_join.py -- --no-override
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python temp_override_join.py -- --output join.png
blender --background --python temp_override_join.py -- --output join.png --engine cycles
-
It exits non-zero on failure (wrong object count, topology mismatch, sources still alive, or incomplete Z span). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh object count after join ≠ 1 (--no-override lands here) | | 4 | Joined target is not the sole remaining mesh | | 5 | Topology ≠ 24 verts / 18 faces | | 6 | Source objects still present | | 7 | Local Z span did not cover all steps | | 8 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --no-override.
Source
@@ -286,10 +292,15 @@
Source
that only the target remains, and that the local Z span spans all three steps
(proving every source contributed geometry).
+``--no-override`` calls ``object.join`` without ``temp_override``. If the
+operator raises, that is caught and the existing object-count check still
+runs. That is the falsifier (``--same-axis`` in export-preset-axis).
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python temp_override_join.py -- # check only
+ blender --background --python temp_override_join.py -- --no-override # must fail
blender --background --python temp_override_join.py -- --output j.png # + render
"""
import bpy, bmesh, sys, os, math, argparse
@@ -476,12 +487,22 @@
Source
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)")
+ p.add_argument("--no-override", action="store_true",
+ help="join without temp_override (must fail)")
args = p.parse_args(argv)
objs = build_cubes()
target, sources = objs[0], objs[1:]
source_names = [s.name for s in sources]
- joined = join_with_temp_override(target, sources)
+ if args.no_override:
+ try:
+ bpy.ops.object.join()
+ except RuntimeError as exc:
+ print(f"join without override: {type(exc).__name__}: {exc}",
+ file=sys.stderr)
+ joined = target
+ else:
+ joined = join_with_temp_override(target, sources)
code = check(joined, source_names)
if code:
return code
diff --git a/docs/gallery/text-version-stamp/index.html b/docs/gallery/text-version-stamp/index.html
index ca6e502..71d1145 100644
--- a/docs/gallery/text-version-stamp/index.html
+++ b/docs/gallery/text-version-stamp/index.html
@@ -266,11 +266,17 @@
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python text_version_stamp.py --
+# Falsifier: body is not version_string. Must exit non-zero.
+blender --background --python text_version_stamp.py -- --wrong-body
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python text_version_stamp.py -- --output stamp.png
blender --background --python text_version_stamp.py -- --output stamp.png --engine cycles
-
It exits non-zero on failure (wrong subclass, missing font, body/version mismatch, non-planar flat text, extrude/bevel closed form off, geometry not regenerating, or a to_mesh_clear() reference surviving). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS. The render scales the stamp to a constant width, so the frame holds for any version-string length.
-
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.
+
The render scales the stamp to a constant width, so the frame holds for any version-string length.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Not a FONT TextCurve with the built-in Bfont | | 4 | body is not the live version_string (--wrong-body lands here) | | 5 | Flat text is not a filled planar mesh | | 6 | Extrude / bevel closed form failed | | 7 | Appending characters did not widen the text | | 8 | Mesh survived to_mesh_clear() | | 9 | --output produced no file | | 10 | Gallery framing violation |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --wrong-body.
Source
@@ -295,11 +301,16 @@
Source
`to_mesh_clear()` the returned Mesh reference is dead and any access raises
ReferenceError.
+``--wrong-body`` assigns a string that is not ``version_string`` and still
+asserts the body is the live version. That is the falsifier (``--same-axis``
+in export-preset-axis).
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
- blender --background --python text_version_stamp.py -- # check only
- blender --background --python text_version_stamp.py -- --output v.png # + render
+ blender --background --python text_version_stamp.py -- # check only
+ blender --background --python text_version_stamp.py -- --wrong-body # must fail
+ blender --background --python text_version_stamp.py -- --output v.png # + render
"""
import bpy, sys, os, math, argparse
@@ -313,10 +324,10 @@
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)")
+ p.add_argument("--wrong-body", action="store_true",
+ help="falsifier: body is not version_string, still assert it is")
args = p.parse_args(argv)
- obj = build_stamp()
+ obj = build_stamp(wrong_body=args.wrong_body)
+
code = check(obj)
if code:
return code
diff --git a/docs/gallery/triangulate-tangents/index.html b/docs/gallery/triangulate-tangents/index.html
index 25b3fde..869ea9a 100644
--- a/docs/gallery/triangulate-tangents/index.html
+++ b/docs/gallery/triangulate-tangents/index.html
@@ -270,11 +270,16 @@
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python triangulate_tangents.py --
+# Falsifier: every UV at (0, 0). Must exit non-zero (authored UV closed form).
+blender --background --python triangulate_tangents.py -- --zero-uv
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python triangulate_tangents.py -- --output buckler.png
blender --background --python triangulate_tangents.py -- --output buckler.png --engine cycles
-
It exits non-zero on failure (topology drift, reallocated UV layer, non-orthonormal basis, bitangent-convention drift, formula excursion, or a flip inside a smooth field). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
-
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.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Loop-triangle count ≠ closed form | | 4 | Re-fetched UV layer drifted from the polar field (--zero-uv lands here) | | 5 | Tangent basis not orthonormal | | 6 | Bitangent ≠ sign × (n × t) | | 7 | Tangents deviate from the edge/UV closed form | | 8 | Flipped tangent inside a clean triangle | | 10 | Gallery framing violation; also --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --zero-uv.
Source
@@ -315,6 +320,7 @@
Source
check. Pass --output to also render a still:
blender --background --python triangulate_tangents.py -- # check only
+ blender --background --python triangulate_tangents.py -- --zero-uv # must fail
blender --background --python triangulate_tangents.py -- --output s.png # + render
"""
import bpy, bmesh, sys, os, math, argparse
@@ -712,10 +718,16 @@
Source
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)")
+ p.add_argument("--zero-uv", action="store_true",
+ help="write every UV to (0, 0) (must fail)")
args = p.parse_args(argv)
bpy.ops.wm.read_factory_settings(use_empty=True)
obj, n_dome = build_buckler()
+ if args.zero_uv:
+ uv = obj.data.uv_layers["UVMap"]
+ for loop in uv.data:
+ loop.uv = (0.0, 0.0)
code = check(obj, n_dome)
if code:
return code
diff --git a/docs/gallery/turntable/index.html b/docs/gallery/turntable/index.html
index ff5a4a5..098c4a9 100644
--- a/docs/gallery/turntable/index.html
+++ b/docs/gallery/turntable/index.html
@@ -265,10 +265,17 @@
Run
# Cheap correctness check only (no render) — the CI smoke check:
blender --background --python turntable.py --
+# Falsifier: no rotation keys. Must exit non-zero (keys drive playback).
+blender --background --python turntable.py -- --no-keys
+
# Also render one still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python turntable.py -- --output turntable.png
blender --background --python turntable.py -- --output turntable.png --engine cycles
-
By default it runs only the frame-independent correctness check: it inserts the rotation keys, samples the object's Z rotation at frame 1 vs a later frame, and asserts they differ (the keys drive playback). It exits non-zero on failure — the same check the blender-smoke workflow runs on Blender 5.2 LTS and 4.5 LTS. --output additionally renders a still; the full animated loop is a showcase extra, not part of the CI check.
+
By default it runs only the frame-independent correctness check: it inserts the rotation keys, samples the object's Z rotation at frame 1 vs a later frame, and asserts they differ (the keys drive playback). --output additionally renders a still; the full animated loop is a showcase extra, not part of the CI check.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Rotation keys do not drive playback (--no-keys lands here) | | 4 | --output produced no file | | 5 | Wrong-era EEVEE engine id was accepted |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --no-keys.
Source
@@ -288,7 +295,12 @@
Source
they DIFFER -- proving the keys drive playback. Exits non-zero on failure. This is the check
the CI smoke gate runs on both builds.
+``--no-keys`` skips inserting the rotation keys and still asserts they drive playback.
+That is the falsifier (``--same-axis`` in export-preset-axis). The EEVEE-id
+era check is untouched.
+
blender --background --python turntable.py -- # correctness check only
+ blender --background --python turntable.py -- --no-keys # must fail
blender --background --python turntable.py -- --output t.png # also render one still
blender --background --python turntable.py -- --output t.png --engine cycles # GPU-less
"""
@@ -307,7 +319,7 @@
p = argparse.ArgumentParser()
p.add_argument("--output", default=None, help="optional: render one still to this PNG")
p.add_argument("--engine", choices=["auto", "cycles"], default="auto")
+ p.add_argument("--no-keys", action="store_true",
+ help="falsifier: skip rotation keys, still assert they drive playback")
args = p.parse_args(argv)
# the EEVEE-id mapping is asserted regardless of whether we render: the
@@ -413,7 +428,7 @@
Source
pass# correctly rejected
bpy.context.scene.render.engine = eid # raises TypeError if the helper's id is invalid
- obj = build()
+ obj = build(no_keys=args.no_keys)
ifnot correctness(obj):
print("ERROR: rotation keys do not drive playback", file=sys.stderr); return3
diff --git a/docs/gallery/usd-export-evaluation-mode/index.html b/docs/gallery/usd-export-evaluation-mode/index.html
index b81ae09..682d1ad 100644
--- a/docs/gallery/usd-export-evaluation-mode/index.html
+++ b/docs/gallery/usd-export-evaluation-mode/index.html
@@ -269,6 +269,10 @@
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage; also base cube verts ≠ 8 | | 3 | VIEWPORT TESSELLATE point/face count off closed form | | 4 | RENDER TESSELLATE point/face count or scheme off closed form | | 5 | BEST_MATCH cage point count or scheme off | | 6 | RENDER and VIEWPORT USDA point counts are identical | | 10 | Gallery framing violation | | 12 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output, --evaluation-mode, or --subdivision.
# Cheap correctness check (no render) — the CI check:
blender --background --python uv_layer_grid.py --
+# Falsifier: pre-create a UV layer on the silent-no-op probe. Must exit non-zero.
+blender --background --python uv_layer_grid.py -- --precreate-on-hazard
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python uv_layer_grid.py -- --output uv.png
blender --background --python uv_layer_grid.py -- --output uv.png --engine cycles
-
It exits non-zero on failure and prints every measured error and tolerance on success, so CI logs carry the numbers. The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. This example does not call the gallery framing helper; 10 is the missing-render-file check.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Silent-no-op hazard gone (--precreate-on-hazard lands here) | | 4 | Grid topology drifted | | 5 | Pre-create + calc_uvs did not persist one UV layer | | 6 | calc_uvs closed-form error | | 7 | Mesh UV round-trip error | | 8 | calc_uvs=False unexpectedly created a UV layer | | 9 | Explicit UV assignment error | | 10 | --output produced no file | | 11 | Broken panel is not flat | | 12 | Broken panel is not texel-(0,0) teal | | 13 | Repaired panel is not a checker | | 14 | Broken and repaired panels render identically |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --precreate-on-hazard.
Source
@@ -292,14 +298,14 @@
Source
The check proves the silent no-op, then the pre-create + `calc_uvs=True`
repair path against a closed-form UV grid, and an explicit loop-assignment
-fallback that does not depend on `calc_uvs` at all. Pass --output to also
-render a still that stages the broken (flat) panel beside the repaired
-(checker) panel — and then *witnesses the render itself*: the saved PNG is
-read back and probed at each panel's projected center, asserting the broken
-panel is one flat teal (texel (0,0)) while the repaired panel carries both
-checker colors. If the UV contract failed, the pixels would say so:
+fallback that does not depend on `calc_uvs` at all.
+
+``--precreate-on-hazard`` creates the UV layer on the silent-no-op probe
+and still asserts zero layers, so the hazard check fails. That is the
+falsifier (``--same-axis`` in export-preset-axis).
blender --background --python uv_layer_grid.py --
+ blender --background --python uv_layer_grid.py -- --precreate-on-hazard
blender --background --python uv_layer_grid.py -- --output uv.png
"""
import bpy, bmesh, sys, os, math, argparse
@@ -330,13 +336,15 @@
Source
return err
-def check():
+def check(precreate_on_hazard=False):
bpy.ops.wm.read_factory_settings(use_empty=True)
# --- 1. The hazard: calc_uvs=True is a silent no-op without a UV layer ---
me_bad = bpy.data.meshes.new("NoPreUV")
bm = bmesh.new()
try:
+ if precreate_on_hazard:
+ bm.loops.layers.uv.new("UVMap")
bmesh.ops.create_grid(
bm, x_segments=SEG, y_segments=SEG, size=SIZE, calc_uvs=True,
)
@@ -790,9 +798,11 @@
Source
"--engine", default="eevee", choices=("eevee", "cycles"),
help="render engine for --output (cycles for GPU-less hosts)",
)
+ p.add_argument("--precreate-on-hazard", action="store_true",
+ help="pre-create a UV layer on the silent-no-op probe (must fail)")
args = p.parse_args(argv)
- code = check()
+ code = check(precreate_on_hazard=args.precreate_on_hazard)
if code != 0:
return code
if args.output:
diff --git a/docs/gallery/vertex-color-ao/index.html b/docs/gallery/vertex-color-ao/index.html
index d5416e9..ca0a222 100644
--- a/docs/gallery/vertex-color-ao/index.html
+++ b/docs/gallery/vertex-color-ao/index.html
@@ -277,7 +277,11 @@
Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (the calibration rig gets 4096 samples over six points, the asset a cheap 64, so the whole check is ~1 s). The --output render path additionally gates framing via examples/gallery_framing.py (fill 0.839y, margins 0.241/0.238/0.072/0.089, no edge touched) and the asset floors via examples/gallery_asset_quality.py (11 materials, edge900.152, no default names).
+
The --output render path additionally gates framing via examples/gallery_framing.py (fill 0.839y, margins 0.241/0.238/0.072/0.089, no edge touched) and the asset floors via examples/gallery_asset_quality.py (11 materials, edge900.152, no default names).
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper. 11 is the shared asset-quality helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Calibration AO off closed form | | 4 | Unoccluded plate is not 1.0, or AO is not strictly increasing with distance | | 5 | Bake empty, out of range, or spread too small | | 6 | FLOAT_COLOR / BYTE_COLOR round-trip contract | | 7 | Colour attributes missing, retyped, or drifted under the depsgraph | | 8 | Asset part count, unapplied scale, namespace, render attribute, or ground plane | | 9 | --output produced no file | | 10 | Gallery framing violation | | 11 | Gallery asset-quality violation |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --falsify.
# Cheap correctness check (no render) — the CI check:
blender --background --python vertex_weight_limit.py --
+# Falsifier: skip the 4-influence prune. Must exit non-zero.
+blender --background --python vertex_weight_limit.py -- --skip-limit
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python vertex_weight_limit.py -- --output arm.png
blender --background --python vertex_weight_limit.py -- --output arm.png --engine cycles
-
It exits non-zero on failure (vacuous authoring, a vertex over the cap, broken weight sums, pose damaged by pruning, LBS drift, or a moved Root mount). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
-
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.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper; it is also the missing-render code.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Pre-limit max influences ≠ 5 | | 4 | Vertex over the 4-influence cap (--skip-limit lands here) | | 5 | Limit changed nothing | | 6 | Weight sums off 1.0 after renormalize | | 7 | Pose damaged by pruning, or evaluated vert count changed | | 8 | Evaluated mesh off LBS over limited weights | | 9 | Root-weighted mount moved | | 10 | Gallery framing violation; also --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --skip-limit.
Source
@@ -306,10 +311,15 @@
Source
between Blender 4.5 LTS and 5.1 — the example runs identically on both, which
is itself the version witness.
+``--skip-limit`` leaves the five-influence flex weights in place and still
+asserts the engine cap. That is the falsifier (``--same-axis`` in
+export-preset-axis).
+
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python vertex_weight_limit.py -- # check only
+ blender --background --python vertex_weight_limit.py -- --skip-limit # must fail
blender --background --python vertex_weight_limit.py -- --output a.png # + render
"""
import bpy, bmesh, sys, os, math, argparse
@@ -545,7 +555,7 @@
Source
ob_eval.to_mesh_clear()
-def check(obj, arm, groups, pose_before):
+def check(obj, arm, groups, pose_before, skip_limit=False):
me = obj.data
# pre-limit witness: the flex cuffs really carry five influences
@@ -557,16 +567,17 @@
Source
# the limit, through the data API: keep top-4, drop the rest, renormalize
changed = 0
- for v in me.vertices:
- gs = sorted(v.groups, key=lambda g: -g.weight)
- if len(gs) > MAX_INFLUENCES:
- changed += 1
- for g in gs[MAX_INFLUENCES:]:
- groups[BONES[g.group]].remove([v.index])
- kept = [g for g in v.groups]
- total = sum(g.weight for g in kept)
- for g in kept:
- groups[BONES[g.group]].add([v.index], g.weight / total, 'REPLACE')
+ ifnot skip_limit:
+ for v in me.vertices:
+ gs = sorted(v.groups, key=lambda g: -g.weight)
+ if len(gs) > MAX_INFLUENCES:
+ changed += 1
+ for g in gs[MAX_INFLUENCES:]:
+ groups[BONES[g.group]].remove([v.index])
+ kept = [g for g in v.groups]
+ total = sum(g.weight for g in kept)
+ for g in kept:
+ groups[BONES[g.group]].add([v.index], g.weight / total, 'REPLACE')
# contract 1: no vertex exceeds the engine limit
post_max = max(len(v.groups) for v in me.vertices)
@@ -764,6 +775,8 @@
Source
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)")
+ p.add_argument("--skip-limit", action="store_true",
+ help="skip the 4-influence prune (must fail)")
args = p.parse_args(argv)
bpy.ops.wm.read_factory_settings(use_empty=True)
@@ -774,7 +787,7 @@
*Accessor* — .sequences returning on 5.x, or the 4.5 bridge seeing different contents (falsified: the legacy path on 5.1.1 raises AttributeError: 'SequenceEditor' object has no attribute 'sequences').
*Creation* — Blender re-accepting a removed kwarg silently, or the TRANSFORM enum returning (falsified: calling new_effect(frame_end=...) on 5.1.1 raises TypeError: ... expected (name, type, channel, frame_start, length, input1, input2)).
*Spans* — end-inclusive vs end-exclusive off-by-one, retiming drift (falsified: expecting GC span (25, 34) exited 5 with measured GC span (25, 33, 8) != closed form (25, 34, 9) — note the cross was *clamped* to the 8-frame overlap, itself a witnessed behavior).
*Wiring* — swapped or dropped cross inputs (falsified: input1=t2, input2=t1 exited 7 with measured inputs=(T2, T1), expected T1 -> T2).
*Round-trip* — serialization dropping strip data (falsified: corrupting the text strip before save exited 10 via the reloaded re-assert).
*Pixels* — a broken span or cell transform dropping a cell, a frozen cross, or a consumed input leaking back into the composite (found by authoring: GC below T1/T2 let T2 paint the whole wall teal).
Hazards discovered while authoring (all witnessed by the checks above):
A GAMMA_CROSS asked to outlast its inputs' overlap is silently clamped to the overlap — request length 9 over a (25, 33) overlap, get (25, 33).
A scene strip pointing at its own scene is a feedback loop and renders transparent (alpha 0) — the "stage" is silently absent. Source a separate scene.
Effect strips consume their inputs: input strips never composite on their own channel, and the effect's transform applies on top of the inputs' transforms. Consumption requires the effect on a channel above its inputs; below, they keep painting independently.
An empty bpy_prop_collection is falsy — se.strips or se.sequences silently falls through to the legacy accessor on an empty timeline. Always branch on hasattr.
-
Version divergence: the whole example is the divergence — gated on the bpy.app.version tuple (>= (5, 0, 0)), never on version_string ("4.5.11 LTS" is not bare semver). Each side asserts its own canonical contract plus the other side's removal/bridge state. Measured values are identical on 4.5.11 and 5.1.1, including the pixel witness.
+
Version divergence: the whole example is the divergence — gated on the bpy.app.version tuple (>= (5, 0, 0)), never on version_string ("4.5.11 LTS" is not bare semver). Each side asserts its own canonical contract plus the other side's removal/bridge state. Measured values are identical on 4.5.11 and 5.1.2, including the pixel witness. 5.2 LTS COLOR strips bake readonly width/height from the scene render resolution at new_effect time (4.5/5.1 have no intrinsic size — scale is a fraction of the output frame). The pixel check sets 96×54 *before* building the cut list; creating at factory 1920×1080 then rendering 96×54 makes 0.36-scaled cells larger than the output, C covers the frame, and TL samples amber (0.949, 0.62, 0.102) instead of crimson.
Render: the program wall *is* the sequencer output at frame 29 (mid cross) — crimson A, teal B, amber long-runner C, and the 50/50 cross blend, over the Stage scene strip showing the dark studio. An off-by-one in end-exclusive span math drops its cell to the dark stage; the caption strip carries the closed form. The hero presents that authentic frame on a reference monitor in a dark-studio editing bay — the pixels on the screen are the genuine sequencer output (evidence); only the bay around them is staged (presentation). Rendered locally with EEVEE (GPU host); the checks and --check-pixels need no GPU (Cycles CPU).
Framing measurement
The gallery still renders from the dedicated Bay scene (camera BayCam), not bpy.context.scene — a framing measurement with examples/gallery_framing.py must be handed that (scene, camera) pair explicitly.
@@ -274,13 +274,19 @@
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python vse_cut_list.py --
+# Falsifier: GC T2 -> T1. Must exit non-zero (wiring). `--check-pixels` is a
+# second witness, not this falsifier.
+blender --background --python vse_cut_list.py -- --swap-inputs
+
# Compositing witness on a tiny render (Cycles CPU, CI-safe):
blender --background --python vse_cut_list.py -- --check-pixels --engine cycles
# Also render the still (EEVEE on a GPU host; --engine cycles on GPU-less):
blender --background --python vse_cut_list.py -- --output vse.png
-
It exits non-zero on failure and prints every measured value and tolerance on success, so CI logs carry the numbers. The blender-smoke workflow runs the check and the pixel witness on Blender 5.2 LTS and 4.5 LTS.
-
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.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is also the shared framing helper.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | .sequences accessor contract for this Blender | | 4 | Wrong-era new_effect end kwarg or TRANSFORM type did not TypeError | | 5 | Strip span or channel off closed form | | 6 | frame_final_* deprecation bridge for this Blender | | 7 | GC wiring or strip .type enum (--swap-inputs lands here) | | 8 | Scene strip span or Stage source | | 9 | Mosaic transform, compositing defaults, text body, or strip color | | 10 | Save/reload round-trip re-assert failed; also gallery framing violation | | 11 | --output produced no file | | 12 | --check-pixels compositing contract |
+
The blender-smoke workflow runs the check and the pixel witness on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --swap-inputs.
Source
@@ -321,6 +327,10 @@
Source
The timeline below therefore feeds the cross a dedicated source pair
(T1/T2) that owns no mosaic cell, on channels directly under GC.
+``--swap-inputs`` wires GC as T2 -> T1 and still asserts T1 -> T2. That is
+the falsifier (``--same-axis`` in export-preset-axis). ``--check-pixels``
+remains a second witness, not a falsifier.
+
The check builds a deterministic cut list, asserts every span against its
closed form on each version's canonical accessors, then proves the spans,
wiring, colors, and transforms survive a save/reload round-trip. Pass
@@ -330,6 +340,7 @@
def check_pixels(engine):
"""Compositing witness on a tiny render: cell centers carry their strip
colors, the cross cell is the blend (neither source), and a margin point
- is stage-dark — if T1 composited independently it would be crimson."""
+ is stage-dark — if T1 composited independently it would be crimson.
+
+ 5.2 COLOR strips bake readonly ``width``/``height`` from the scene render
+ resolution at ``new_effect`` time. Transform scale/offset are then in
+ that media space, not the later output size. 4.5/5.1 COLOR strips have
+ no intrinsic size — scale is always a fraction of the output frame.
+ Set the tiny resolution *before* building the cut list or C (channel 4)
+ covers the 96×54 frame and TL samples amber.
+ """
sc = bpy.context.scene
+ sc.render.resolution_x = PXW
+ sc.render.resolution_y = PXH
build_cut_list(sc, pix=PXW / RENDER_W)
+ if bpy.app.version >= (5, 2, 0):
+ a = strips_coll(sc.sequence_editor).get("A")
+ if (a.width, a.height) != (PXW, PXH):
+ return fail(
+ f"5.2 COLOR size {(a.width, a.height)} != render {(PXW, PXH)}"
+ f"— width/height bake at new_effect drifted",
+ 12,
+ )
build_stage(bpy.data.scenes["Stage"])
tmp = tempfile.mkdtemp(prefix="vse_pixels_")
path = os.path.join(tmp, "px.png")
@@ -956,9 +996,13 @@
Source
"--check-pixels", action="store_true",
help="also assert the compositing contract on a tiny render",
)
+ p.add_argument(
+ "--swap-inputs", action="store_true",
+ help="falsifier: GC T2 -> T1, still assert T1 -> T2",
+ )
args = p.parse_args(argv)
- code = run_checks()
+ code = run_checks(swap_inputs=args.swap_inputs)
if code != 0:
return code
if args.check_pixels:
diff --git a/docs/gallery/vse-gamma-cross/index.html b/docs/gallery/vse-gamma-cross/index.html
index cd15655..786764f 100644
--- a/docs/gallery/vse-gamma-cross/index.html
+++ b/docs/gallery/vse-gamma-cross/index.html
@@ -269,10 +269,16 @@
Run
# Correctness check (tiny per-frame sample renders) — the CI check:
blender --background --python vse_gamma_cross.py --
+# Falsifier: GC T2 -> T1. Must exit non-zero (inputs).
+blender --background --python vse_gamma_cross.py -- --swap-inputs
+
# Also render the calibration lightbox still (EEVEE on a GPU host; cycles on GPU-less):
blender --background --python vse_gamma_cross.py -- --output bench.png
blender --background --python vse_gamma_cross.py -- --output bench.png --engine cycles
-
It exits non-zero on failure (span drift, wrong inputs, t-convention drift, a sample off the closed form, or a missing gamma dip). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | GC span off closed form | | 4 | GC inputs are not T1 → T2 (--swap-inputs lands here) | | 5 | Sample t convention drifted | | 6 | Cross sample off the gamma-0.5 closed form | | 7 | Mid-cross lerp deviation missing (naive mix) | | 8 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --swap-inputs.
Source
@@ -303,11 +309,15 @@
Source
The blend math is identical on Blender 4.5 LTS and 5.1 (every sample matches
to the quantization step).
+``--swap-inputs`` wires GC as T2 -> T1 and still asserts T1 -> T2. That is
+the falsifier (``--same-axis`` in export-preset-axis).
+
By default it runs only the correctness check (no gallery render) — the CI
smoke check. Pass --output to also render a still:
- blender --background --python vse_gamma_cross.py -- # check only
- blender --background --python vse_gamma_cross.py -- --output g.png # + render
+ blender --background --python vse_gamma_cross.py -- # check only
+ blender --background --python vse_gamma_cross.py -- --swap-inputs # must fail
+ blender --background --python vse_gamma_cross.py -- --output g.png # + render
"""
import bpy, sys, os, math, argparse, tempfile, shutil
@@ -351,7 +361,7 @@
Source
return s.frame_final_start, s.frame_final_end, s.frame_final_duration
-def build_cross(sc):
+def build_cross(sc, swap_inputs=False):
"""The two-strip cross: T1/T2 consumed by the GAMMA_CROSS above them
(effect strips consume inputs only from below — vse-cut-list's wiring)."""
sc.frame_start = SPAN[0]
@@ -362,7 +372,8 @@
# Cheap correctness check (no render) — the CI check:
blender --background --python wave_displace.py --
+# Falsifier: skip the foreach_set displacement. Must exit non-zero (z-span).
+blender --background --python wave_displace.py -- --flat
+
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python wave_displace.py -- --output wave.png
blender --background --python wave_displace.py -- --output wave.png --engine cycles
-
It exits non-zero on failure (span wrong, or any vertex off the closed form). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
+
Exit codes
+
Per-script sequential checks. 9 is a valid check code; there is no rule against it.
+
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 4 | Z-span not in the closed-form band (--flat lands here) | | 5 | A vertex is off the closed-form wave | | 6 | --output produced no file |
+
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --flat.
Source
@@ -292,6 +298,7 @@
Source
check. Pass --output to also render a still:
blender --background --python wave_displace.py -- # check only
+ blender --background --python wave_displace.py -- --flat # must fail
blender --background --python wave_displace.py -- --output w.png # + render
"""
import bpy, bmesh, sys, os, math, argparse
@@ -413,10 +420,12 @@
Source
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)")
+ p.add_argument("--flat", action="store_true",
+ help="skip the foreach_set displacement (must fail)")
args = p.parse_args(argv)
obj = build_grid()
- n = displace(obj.data)
+ n = len(obj.data.vertices) if args.flat else displace(obj.data)
code = check(obj, n)
if code:
return code
diff --git a/examples/gallery.json b/examples/gallery.json
index d9f9258..2fef485 100644
--- a/examples/gallery.json
+++ b/examples/gallery.json
@@ -627,6 +627,17 @@
"tags": [
"mesh"
]
+ },
+ {
+ "name": "gn-socket-rename",
+ "dir": "examples/gn-socket-rename",
+ "teaches": "Compare and Random Value socket identifiers collapsed onto reused names in 5.2; enabled-name lookup wires on 4.5, 5.1, and 5.2",
+ "witnessesFix": "A_INT and Min_001 present on 4.5.11 and 5.1.2, absent on 5.2.1. --legacy-ids exits 5 only on 5.2. Evaluated 16 verts and gauge_h 1.80 on eight column verts",
+ "hero": "docs/gallery/assets/gn-socket-rename-hero.webp",
+ "preview": "examples/gn-socket-rename/preview.webp",
+ "tags": [
+ "geometry-nodes"
+ ]
}
]
}
diff --git a/examples/gn-socket-rename/README.md b/examples/gn-socket-rename/README.md
new file mode 100644
index 0000000..65d1fe5
--- /dev/null
+++ b/examples/gn-socket-rename/README.md
@@ -0,0 +1,76 @@
+# GN socket identifier rename
+
+A jo-block (steel plinth + copper column) whose Geometry Nodes tree
+witnesses the 5.2 socket-identifier collapse on
+`FunctionNodeCompare` and `FunctionNodeRandomValue`. Follows
+[`geometry-nodes-python`](../../skills/geometry-nodes-python/SKILL.md).
+
+On 4.5.11 and 5.1.2, Compare INT exposes `A_INT` / `B_INT` and Random
+Value FLOAT exposes `Min_001` / `Max_001` / `Value_001`. On 5.2.1 those
+identifiers are gone — the live sockets reuse `A` / `B` and `Min` /
+`Max` / `Value`. Looking up the unique *enabled* socket of a given name
+wires on all three. Hard-coding the pre-5.2 identifiers raises on 5.2.
+
+**What it witnesses:** identifier-agnostic wiring produces a 16-vert
+evaluated mesh (plinth + column) with POINT `gauge_h == 1.80` on exactly
+the eight column verts. Compare INT `7 > 2` switches the column in
+(vert count). Random Value FLOAT with min=max=`HEIGHT` is stored as a
+named attribute (Random is a field; using it as a constant Size source
+evaluates to 0 — hazard found while authoring). zmax `1.94` is the
+construction axis, not the Random axis.
+
+Scaffolding matches
+[`cross-version-property-delete`](../cross-version-property-delete/)
+(`check()` returns, argparse naive-API flag, FATAL wrapper). That
+example does **not** version-branch, so the per-version inventory
+assert is the `gn-modifier-inputs` shape.
+
+**What failure each check would catch:**
+
+- exit 4 — identifier inventory wrong for this Blender
+- exit 5 — pre-5.2 identifier lookup failed (`--legacy-ids` on 5.2)
+- exit 6 — enabled-name lookup failed
+- exit 7 — Compare did not switch the column in (8 verts)
+- exit 8 — column not sitting on the plinth
+- exit 9 — `gauge_h` not 1.80 on eight verts (Random unwired)
+
+`--legacy-ids` is the falsifier: wire by `Min_001` / `A_INT`. It exits
+**0 on 4.5.11 and 5.1.2** (those identifiers still exist) and **5 on
+5.2.1**. Unlike `--same-axis`, it is not red on every binary.
+
+No `SMOKE_SKIP`. Do not pass `deviation=` to `check_framing`.
+
+## Run
+
+```bash
+blender --background --python gn_socket_rename.py --
+blender --background --python gn_socket_rename.py -- --legacy-ids
+blender --background --python gn_socket_rename.py -- --output gauge.png
+```
+
+The `--output` render path measures framing against the Layer 1 band via
+`examples/gallery_framing.py` (exit 10 on violation) before writing the
+still.
+
+## Exit codes
+
+Per-script sequential checks. `9` is a valid check code; there is no rule
+against it. `10` is the shared framing helper.
+
+| Code | Meaning |
+| --- | --- |
+| 0 | Success |
+| 1 | Uncaught exception (FATAL wrapper) |
+| 2 | argparse / usage |
+| 4 | Identifier inventory wrong for this Blender version |
+| 5 | Pre-5.2 identifier missing (`--legacy-ids` on 5.2) |
+| 6 | Enabled-name socket lookup failed |
+| 7 | Evaluated vert count off (Compare did not switch the column in) |
+| 8 | Evaluated zmax off closed form |
+| 9 | POINT `gauge_h` not 1.80 on eight column verts |
+| 10 | Gallery framing violation |
+| 12 | `--output` produced no file |
+
+The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
+(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).
+Smoke does not pass `--output` or `--legacy-ids`.
diff --git a/examples/gn-socket-rename/gn_socket_rename.py b/examples/gn-socket-rename/gn_socket_rename.py
new file mode 100644
index 0000000..f8c2d44
--- /dev/null
+++ b/examples/gn-socket-rename/gn_socket_rename.py
@@ -0,0 +1,416 @@
+"""GN Compare / Random Value socket identifier rename — a runnable example.
+
+Witnesses the 5.2 collapse of typed sockets onto reused identifiers.
+``FunctionNodeRandomValue`` FLOAT used ``Min_001`` / ``Max_001`` / ``Value_001``
+on 4.5 LTS and 5.1; those identifiers are gone on 5.2 (``Min`` / ``Max`` /
+``Value``). ``FunctionNodeCompare`` INT used ``A_INT`` / ``B_INT``; 5.2 reuses
+``A`` / ``B``. Identifier-agnostic lookup (the unique *enabled* socket of a
+given name) wires on all three. Hard-coding the pre-5.2 identifiers fails
+on 5.2.
+
+The tree is a jo-block: a plinth plus a column gated by Compare INT 7>2.
+Random Value FLOAT (min=max=HEIGHT, so the value is a closed form, not an
+RNG draw) is stored as POINT ``gauge_h`` on the column. Vert count is the
+Compare axis (16 vs 8). ``gauge_h == HEIGHT`` on exactly eight verts is the
+Random axis — count-only is green with Random unwired (the Store default
+is 0). Random Value is a field; wiring it as a constant Size source
+evaluates to 0, which is why the Store is the witness.
+
+ blender --background --python gn_socket_rename.py --
+ blender --background --python gn_socket_rename.py -- --legacy-ids
+ blender --background --python gn_socket_rename.py -- --output g.png
+"""
+import argparse
+import math
+import os
+import sys
+
+import bmesh
+import bpy
+
+sys.path.insert(
+ 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)
+)
+sys.dont_write_bytecode = True
+import gallery_framing # noqa: E402
+
+RENAMED_AT = (5, 2, 0)
+HEIGHT = 1.80
+COLUMN_XY = 0.55
+PLINTH_SIZE = (1.35, 1.35, 0.14)
+CMP_A = 7
+CMP_B = 2
+ZMAX_OK = PLINTH_SIZE[2] + HEIGHT # 1.94
+VERTS_OK = 16
+VERTS_NO_COLUMN = 8
+ATTR_NAME = "gauge_h"
+ATTR_EPS = 1e-5
+EXTENT_EPS = 1e-4
+
+LEGACY_RV_MIN = "Min_001"
+LEGACY_RV_MAX = "Max_001"
+LEGACY_RV_OUT = "Value_001"
+LEGACY_CMP_A = "A_INT"
+LEGACY_CMP_B = "B_INT"
+
+
+def fail(msg, code):
+ print(f"ERROR: {msg}", file=sys.stderr)
+ return code
+
+
+def ident_sock(node, collection, identifier):
+ for s in getattr(node, collection):
+ if s.identifier == identifier:
+ return s
+ raise LookupError(
+ f"{node.bl_idname} has no {collection} identifier {identifier!r}"
+ )
+
+
+def enabled_sock(node, collection, name):
+ found = [
+ s for s in getattr(node, collection)
+ if s.name == name and s.enabled
+ ]
+ if len(found) != 1:
+ raise LookupError(
+ f"{node.bl_idname} {collection} enabled name={name!r} "
+ f"count={len(found)}"
+ )
+ return found[0]
+
+
+def identifiers(node, collection):
+ return {s.identifier for s in getattr(node, collection)}
+
+
+def eval_mesh(obj):
+ dg = bpy.context.evaluated_depsgraph_get()
+ ev = obj.evaluated_get(dg)
+ me = ev.to_mesh()
+ try:
+ coords = [(v.co.x, v.co.y, v.co.z) for v in me.vertices]
+ nfaces = len(me.polygons)
+ attr = me.attributes.get(ATTR_NAME)
+ if attr is None:
+ attr_vals = []
+ else:
+ attr_vals = [0.0] * len(me.vertices)
+ attr.data.foreach_get("value", attr_vals)
+ finally:
+ ev.to_mesh_clear()
+ return coords, nfaces, attr_vals
+
+
+def sock_pair(node, collection, name, legacy_id, legacy):
+ if legacy:
+ return ident_sock(node, collection, legacy_id)
+ return enabled_sock(node, collection, name)
+
+
+def build_tree(material_plinth, material_column, legacy_ids):
+ tree = bpy.data.node_groups.new("SocketRenameGauge", "GeometryNodeTree")
+ tree.interface.new_socket(
+ name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry",
+ )
+ go = tree.nodes.new("NodeGroupOutput")
+
+ plinth = tree.nodes.new("GeometryNodeMeshCube")
+ plinth.inputs["Size"].default_value = PLINTH_SIZE
+ plinth_xf = tree.nodes.new("GeometryNodeTransform")
+ plinth_xf.inputs["Translation"].default_value = (
+ 0.0, 0.0, PLINTH_SIZE[2] / 2.0,
+ )
+ tree.links.new(plinth.outputs["Mesh"], plinth_xf.inputs["Geometry"])
+ sm_p = tree.nodes.new("GeometryNodeSetMaterial")
+ sm_p.inputs["Material"].default_value = material_plinth
+ tree.links.new(plinth_xf.outputs["Geometry"], sm_p.inputs["Geometry"])
+
+ column = tree.nodes.new("GeometryNodeMeshCube")
+ column.inputs["Size"].default_value = (COLUMN_XY, COLUMN_XY, HEIGHT)
+ rv = tree.nodes.new("FunctionNodeRandomValue")
+ rv.data_type = "FLOAT"
+ cmp = tree.nodes.new("FunctionNodeCompare")
+ cmp.data_type = "INT"
+ cmp.operation = "GREATER_THAN"
+
+ smin = sock_pair(rv, "inputs", "Min", LEGACY_RV_MIN, legacy_ids)
+ smax = sock_pair(rv, "inputs", "Max", LEGACY_RV_MAX, legacy_ids)
+ sout = sock_pair(rv, "outputs", "Value", LEGACY_RV_OUT, legacy_ids)
+ smin.default_value = HEIGHT
+ smax.default_value = HEIGHT
+
+ sa = sock_pair(cmp, "inputs", "A", LEGACY_CMP_A, legacy_ids)
+ sb = sock_pair(cmp, "inputs", "B", LEGACY_CMP_B, legacy_ids)
+ sa.default_value = CMP_A
+ sb.default_value = CMP_B
+ cmp_out = enabled_sock(cmp, "outputs", "Result")
+
+ col_xf = tree.nodes.new("GeometryNodeTransform")
+ col_xf.inputs["Translation"].default_value = (
+ 0.0, 0.0, PLINTH_SIZE[2] + HEIGHT / 2.0,
+ )
+ tree.links.new(column.outputs["Mesh"], col_xf.inputs["Geometry"])
+ store = tree.nodes.new("GeometryNodeStoreNamedAttribute")
+ store.data_type = "FLOAT"
+ store.domain = "POINT"
+ store.inputs["Name"].default_value = ATTR_NAME
+ tree.links.new(col_xf.outputs["Geometry"], store.inputs["Geometry"])
+ tree.links.new(sout, store.inputs["Value"])
+ sm_c = tree.nodes.new("GeometryNodeSetMaterial")
+ sm_c.inputs["Material"].default_value = material_column
+ tree.links.new(store.outputs["Geometry"], sm_c.inputs["Geometry"])
+
+ sw = tree.nodes.new("GeometryNodeSwitch")
+ sw.input_type = "GEOMETRY"
+ tree.links.new(cmp_out, enabled_sock(sw, "inputs", "Switch"))
+ tree.links.new(sm_c.outputs["Geometry"], enabled_sock(sw, "inputs", "True"))
+
+ join = tree.nodes.new("GeometryNodeJoinGeometry")
+ tree.links.new(sm_p.outputs["Geometry"], join.inputs[0])
+ tree.links.new(enabled_sock(sw, "outputs", "Output"), join.inputs[0])
+
+ shade = tree.nodes.new("GeometryNodeSetShadeSmooth")
+ shade.inputs["Shade Smooth"].default_value = False
+ tree.links.new(join.outputs["Geometry"], shade.inputs["Geometry"])
+ tree.links.new(shade.outputs["Geometry"], go.inputs["Geometry"])
+ return tree, rv, cmp
+
+
+def check_inventory(rv, cmp):
+ ver = bpy.app.version
+ legacy = ver < RENAMED_AT
+ rv_in = identifiers(rv, "inputs")
+ rv_out = identifiers(rv, "outputs")
+ cmp_in = identifiers(cmp, "inputs")
+ print(
+ f"blender={ver} legacy_ids_present_expected={legacy} "
+ f"rv_in={sorted(rv_in)} rv_out={sorted(rv_out)} "
+ f"cmp_in={sorted(cmp_in)}"
+ )
+ old_rv = {LEGACY_RV_MIN, LEGACY_RV_MAX} <= rv_in and LEGACY_RV_OUT in rv_out
+ old_cmp = {LEGACY_CMP_A, LEGACY_CMP_B} <= cmp_in
+ print(f"old_random_ids={old_rv} old_compare_ids={old_cmp}")
+ if legacy:
+ if not old_rv:
+ return fail("pre-5.2 Random Value identifiers missing", 4)
+ if not old_cmp:
+ return fail("pre-5.2 Compare INT identifiers missing", 4)
+ else:
+ if old_rv:
+ return fail("pre-5.2 Random Value identifiers still present on 5.2+", 4)
+ if old_cmp:
+ return fail("pre-5.2 Compare INT identifiers still present on 5.2+", 4)
+ return 0
+
+
+def check(obj, rv, cmp, legacy_ids):
+ code = check_inventory(rv, cmp)
+ if code:
+ return code
+
+ bpy.context.view_layer.update()
+ coords, nfaces, attr_vals = eval_mesh(obj)
+ nverts = len(coords)
+ zmax = max(c[2] for c in coords) if coords else float("-inf")
+ n_hi = sum(1 for v in attr_vals if abs(v - HEIGHT) <= ATTR_EPS)
+ n_lo = sum(1 for v in attr_vals if abs(v) <= ATTR_EPS)
+ print(
+ f"legacy_ids={legacy_ids} verts={nverts} faces={nfaces} "
+ f"zmax={zmax:.6f} zmax_ok={ZMAX_OK:.6f} "
+ f"gauge_h_hi={n_hi} gauge_h_lo={n_lo} attr_n={len(attr_vals)}"
+ )
+ if nverts != VERTS_OK:
+ return fail(
+ f"evaluated verts {nverts} != {VERTS_OK} "
+ f"(Compare did not switch the column in; no-column={VERTS_NO_COLUMN})",
+ 7,
+ )
+ if abs(zmax - ZMAX_OK) > EXTENT_EPS:
+ return fail(
+ f"evaluated zmax {zmax:.6f} != {ZMAX_OK:.6f}",
+ 8,
+ )
+ if n_hi != 8 or n_lo != 8:
+ return fail(
+ f"POINT {ATTR_NAME} hi={n_hi} lo={n_lo} (want 8/8 at {HEIGHT}); "
+ "Random Value did not land on the column",
+ 9,
+ )
+ return 0
+
+
+def principled(name, color, metallic, roughness, emission=None):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ bsdf = mat.node_tree.nodes["Principled BSDF"]
+ bsdf.inputs["Base Color"].default_value = color
+ bsdf.inputs["Metallic"].default_value = metallic
+ bsdf.inputs["Roughness"].default_value = roughness
+ if emission is not None:
+ sock = bsdf.inputs.get("Emission Color") or bsdf.inputs["Emission"]
+ sock.default_value = emission
+ bsdf.inputs["Emission Strength"].default_value = 0.12
+ return mat
+
+
+def build(legacy_ids):
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ steel = principled("PlinthSteel", (0.22, 0.23, 0.26, 1.0), 1.0, 0.32)
+ copper = principled(
+ "ColumnCopper", (0.93, 0.42, 0.08, 1.0), 1.0, 0.22,
+ emission=(0.93, 0.42, 0.08, 1.0),
+ )
+ try:
+ tree, rv, cmp = build_tree(steel, copper, legacy_ids)
+ except LookupError as exc:
+ return None, None, None, fail(str(exc), 5 if legacy_ids else 6)
+ me = bpy.data.meshes.new("Gauge")
+ obj = bpy.data.objects.new("Gauge", me)
+ bpy.context.collection.objects.link(obj)
+ mod = obj.modifiers.new("GN", "NODES")
+ mod.node_group = tree
+ obj.rotation_euler = (0.0, 0.0, math.radians(38))
+ return obj, rv, cmp, 0
+
+
+def eevee_engine_id():
+ return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def render_still(obj, path, engine):
+ scene = bpy.context.scene
+
+ 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 = principled("Studio", (0.03, 0.032, 0.037, 1.0), 0.0, 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, 9.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
+
+ aim = bpy.data.objects.new("Aim", None)
+ aim.location = (0.0, 0.0, ZMAX_OK * 0.45)
+ aim.hide_render = True
+ scene.collection.objects.link(aim)
+
+ def light(name, loc, energy, size, col):
+ 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
+ scene.collection.objects.link(ob)
+ lc = ob.constraints.new("TRACK_TO")
+ lc.target = aim
+ lc.track_axis = "TRACK_NEGATIVE_Z"
+ lc.up_axis = "UP_Y"
+
+ light("Key", (-3.4, -4.6, 5.4), 680.0, 4.2, (1.0, 0.96, 0.9))
+ light("Fill", (4.8, -3.0, 2.2), 140.0, 8.0, (0.75, 0.85, 1.0))
+ light("Rim", (0.2, 5.8, 3.4), 300.0, 3.2, (0.6, 0.78, 1.0))
+ light("Glint", (1.8, -4.8, 5.6), 900.0, 0.85, (1.0, 0.90, 0.70))
+ wedge = bpy.data.lights.new("Wedge", "AREA")
+ wedge.energy = 480.0
+ wedge.size = 6.0
+ wedge.color = (1.0, 0.72, 0.42)
+ wob = bpy.data.objects.new("Wedge", wedge)
+ wob.location = (2.2, 5.2, 3.8)
+ wob.rotation_euler = (math.radians(-68), 0.0, math.radians(190))
+ scene.collection.objects.link(wob)
+
+ cam_data = bpy.data.cameras.new("Cam")
+ cam_data.lens = 50.0
+ cam = bpy.data.objects.new("Cam", cam_data)
+ cam.location = (4.15, -5.85, 2.55)
+ scene.collection.objects.link(cam)
+ scene.camera = cam
+ track = cam.constraints.new("TRACK_TO")
+ track.target = aim
+ track.track_axis = "TRACK_NEGATIVE_Z"
+ track.up_axis = "UP_Y"
+
+ scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+ if engine == "cycles":
+ scene.cycles.samples = 32
+ 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
+ scene.view_settings.view_transform = "Standard"
+
+ fcode = gallery_framing.check_framing(
+ scene, cam,
+ hero=[obj],
+ elements=[obj],
+ 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 12
+ 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)
+ p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+ p.add_argument(
+ "--legacy-ids",
+ action="store_true",
+ help="falsification: wire Compare/Random Value by pre-5.2 identifiers",
+ )
+ args = p.parse_args(argv)
+
+ obj, rv, cmp, code = build(args.legacy_ids)
+ if code:
+ return code
+
+ code = check(obj, rv, cmp, args.legacy_ids)
+ if code:
+ return code
+
+ if args.output:
+ rcode = render_still(obj, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
+ print(f"rendered still {args.output}")
+
+ print("gn-socket-rename 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)
diff --git a/examples/gn-socket-rename/preview.webp b/examples/gn-socket-rename/preview.webp
new file mode 100644
index 0000000..f55d1e2
Binary files /dev/null and b/examples/gn-socket-rename/preview.webp differ
diff --git a/examples/vse-linear-modifiers/README.md b/examples/vse-linear-modifiers/README.md
new file mode 100644
index 0000000..b34973b
--- /dev/null
+++ b/examples/vse-linear-modifiers/README.md
@@ -0,0 +1,62 @@
+# VSE linear modifiers attribute
+
+Pathology witness for the 5.2 removal of `Sequence.use_linear_modifiers`.
+A COLOR strip is enough; there is no geometry and **no gallery still**
+(same class as [`exit-pre-sidecar`](../exit-pre-sidecar/),
+[`ngon-triangulate`](../ngon-triangulate/)).
+
+Follows [`vse-python`](../../skills/vse-python/SKILL.md) and the
+version-gated assertion shape of
+[`gn-modifier-inputs`](../gn-modifier-inputs/) (per-version contract, exit
+0 on every matrix leg). Scaffolding matches
+[`cross-version-property-delete`](../cross-version-property-delete/)
+(`check()` returns, argparse naive-API flag, FATAL wrapper). That example
+does **not** version-branch — `del` is the same on 4.5 and 5.x — so the
+gate itself is copied from `gn-modifier-inputs`, not from `del`.
+
+**What it witnesses:** `ColorStrip.use_linear_modifiers` is a bool you can
+set on 4.5.11 and 5.1.2. The same getattr/setattr is `AttributeError` on
+5.2.1. `hasattr` then read never raises on any of the three.
+
+**What failure each check would catch:**
+
+- exit 3 — COLOR strip never landed
+- exit 4 — attribute missing where the naive/legacy path requires it
+ (`--assume-present` on 5.2 lands here)
+- exit 5 — attribute still present, or getattr silent, on 5.2+
+- exit 6 — setattr did not round-trip on 4.5 / 5.1
+- exit 7 — the `hasattr` guard still raised
+
+`--assume-present` is the falsifier: skip the version gate and demand the
+4.5 RNA. It exits **0 on 4.5.11 and 5.1.2** (the old API still works) and
+**4 on 5.2.1**. That is unlike `--same-axis`, which is red on every
+binary.
+
+No `SMOKE_SKIP`. Every matrix leg exercises the contract.
+
+## Run
+
+```bash
+blender --background --python vse_linear_modifiers.py --
+blender --background --python vse_linear_modifiers.py -- --assume-present
+```
+
+## Exit codes
+
+Per-script sequential checks. `9` is a valid check code; there is no rule
+against it.
+
+| Code | Meaning |
+| --- | --- |
+| 0 | Success |
+| 1 | Uncaught exception (FATAL wrapper) |
+| 2 | argparse / usage |
+| 3 | COLOR strip was not created |
+| 4 | `use_linear_modifiers` missing when required (`--assume-present` on 5.2) |
+| 5 | Attribute still present or getattr silent on 5.2+ |
+| 6 | setattr round-trip failed |
+| 7 | `hasattr`-guarded read raised |
+
+The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
+(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).
+Smoke does not pass `--assume-present`.
diff --git a/examples/vse-linear-modifiers/vse_linear_modifiers.py b/examples/vse-linear-modifiers/vse_linear_modifiers.py
new file mode 100644
index 0000000..d51651f
--- /dev/null
+++ b/examples/vse-linear-modifiers/vse_linear_modifiers.py
@@ -0,0 +1,145 @@
+"""VSE strip.use_linear_modifiers removal — a runnable example.
+
+Witnesses the 5.2 removal of ``Sequence.use_linear_modifiers``. Access is a
+bool on 4.5 LTS and 5.1; the same getattr/setattr raises AttributeError on
+5.2. Version-guarded code (``hasattr`` then read) does not raise on any of
+the three. Pathology: no geometry, no gallery still.
+
+The default path asserts the behavior that is correct for *this* Blender —
+present-and-bool below 5.2, AttributeError on 5.2+ — and still exits 0 on
+all three. ``--assume-present`` is the naive script: read the attribute
+unconditionally. That is red only on 5.2, where the attribute is gone.
+
+ blender --background --python vse_linear_modifiers.py --
+ blender --background --python vse_linear_modifiers.py -- --assume-present
+"""
+import argparse
+import sys
+
+import bpy
+
+ATTR = "use_linear_modifiers"
+REMOVED_AT = (5, 2, 0)
+
+
+def fail(msg, code):
+ print(f"ERROR: {msg}", file=sys.stderr)
+ return code
+
+
+def strips_coll(se):
+ return se.strips if hasattr(se, "strips") else se.sequences
+
+
+def new_color(coll, name, channel, start, length):
+ """COLOR strip covering [start, start+length) — version-correct end kwarg."""
+ if bpy.app.version >= (5, 0, 0):
+ return coll.new_effect(
+ name=name, type="COLOR", channel=channel,
+ frame_start=start, length=length,
+ )
+ return coll.new_effect(
+ name=name, type="COLOR", channel=channel,
+ frame_start=start, frame_end=start + length,
+ )
+
+
+def guarded_read(strip):
+ """Version-safe reader: never raises. None means the RNA is gone."""
+ if hasattr(strip, ATTR):
+ return getattr(strip, ATTR)
+ return None
+
+
+def check(strip, assume_present=False):
+ ver = bpy.app.version
+ legacy = ver < REMOVED_AT
+ require_present = assume_present or legacy
+ print(
+ f"blender={ver} assume_present={assume_present} "
+ f"legacy={legacy} require_present={require_present}"
+ )
+
+ present = hasattr(strip, ATTR)
+ print(f"hasattr_{ATTR}={present}")
+
+ if require_present:
+ if not present:
+ return fail(
+ f"{ATTR} missing on {ver} — naive/legacy path requires it",
+ 4,
+ )
+ try:
+ val = getattr(strip, ATTR)
+ except AttributeError as exc:
+ return fail(f"{ATTR} getattr raised on {ver}: {exc}", 4)
+ if type(val) is not bool:
+ return fail(f"{ATTR} is {type(val).__name__}={val!r}, not bool", 4)
+ flipped = not val
+ setattr(strip, ATTR, flipped)
+ if getattr(strip, ATTR) is not flipped:
+ return fail(f"{ATTR} setattr did not round-trip", 6)
+ setattr(strip, ATTR, val)
+ print(f"{ATTR}={val} round_trip_ok")
+ else:
+ if present:
+ return fail(
+ f"{ATTR} still present on {ver}; 5.2+ must have removed it",
+ 5,
+ )
+ try:
+ getattr(strip, ATTR)
+ return fail(f"{ATTR} getattr returned instead of AttributeError", 5)
+ except AttributeError as exc:
+ print(f"getattr AttributeError={exc}")
+
+ try:
+ guarded = guarded_read(strip)
+ except AttributeError as exc:
+ return fail(f"hasattr-guarded read still raised: {exc}", 7)
+ print(f"guarded_read={guarded!r}")
+ if require_present and guarded is None:
+ return fail("guarded read returned None while the attribute exists", 7)
+ if not require_present and guarded is not None:
+ return fail("guarded read returned a value on 5.2+", 7)
+ return 0
+
+
+def build():
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ scene = bpy.context.scene
+ se = scene.sequence_editor_create()
+ coll = strips_coll(se)
+ strip = new_color(coll, "ProbeColor", channel=1, start=1, length=24)
+ return strip
+
+
+def main():
+ argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+ p = argparse.ArgumentParser()
+ p.add_argument(
+ "--assume-present",
+ action="store_true",
+ help="falsification: read use_linear_modifiers as if it still exists",
+ )
+ args = p.parse_args(argv)
+
+ strip = build()
+ if strip is None or strip.bl_rna.identifier != "ColorStrip":
+ return fail("COLOR strip was not created", 3)
+
+ code = check(strip, assume_present=args.assume_present)
+ if code:
+ return code
+ print("vse-linear-modifiers 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)
diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json
index d18d5d9..98e42b7 100644
--- a/tests/smoke/catalog.json
+++ b/tests/smoke/catalog.json
@@ -69,5 +69,7 @@
{"name": "ngon-triangulate", "script": "examples/ngon-triangulate/ngon_triangulate.py"},
{"name": "unapplied-scale-gltf", "script": "examples/unapplied-scale-gltf/unapplied_scale_gltf.py"},
{"name": "coincident-vert-weld", "script": "examples/coincident-vert-weld/coincident_vert_weld.py"},
- {"name": "bake-normal-high-to-low", "script": "examples/bake-normal-high-to-low/bake_normal_high_to_low.py"}
+ {"name": "bake-normal-high-to-low", "script": "examples/bake-normal-high-to-low/bake_normal_high_to_low.py"},
+ {"name": "vse-linear-modifiers", "script": "examples/vse-linear-modifiers/vse_linear_modifiers.py"},
+ {"name": "gn-socket-rename", "script": "examples/gn-socket-rename/gn_socket_rename.py"}
]