From 8bc4f5efd7ae5c6a2d16ff099131fbf5e85b45e6 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:25:59 -0400 Subject: [PATCH 1/2] feat: witness VSE use_linear_modifiers removal across 4.5, 5.1, and 5.2 Naive getattr still works on 4.5.11 and 5.1.2 and raises AttributeError on 5.2.1. Version-guarded hasattr then read exits 0 on all three; --assume-present is red only where the RNA is gone. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 1 + AGENTS.md | 2 +- CLAUDE.md | 4 +- README.md | 20 ++- ROADMAP.md | 3 + examples/vse-linear-modifiers/README.md | 62 ++++++++ .../vse_linear_modifiers.py | 145 ++++++++++++++++++ tests/smoke/catalog.json | 3 +- 8 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 examples/vse-linear-modifiers/README.md create mode 100644 examples/vse-linear-modifiers/vse_linear_modifiers.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index ec93bd5..a43498f 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -131,6 +131,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..2fff6b8 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 56 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..3048239 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, 56 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 (56) 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..ec856b4 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  55 examples + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  56 examples

@@ -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 56 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. +
diff --git a/ROADMAP.md b/ROADMAP.md index 0063b0c..bab89f4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,6 +140,9 @@ 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 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/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..2296f11 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -69,5 +69,6 @@ {"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"} ] From 6fb136e6300bfdbf7efdb14a7d5a2c1b9a6df776 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:33:34 -0400 Subject: [PATCH 2/2] feat: witness GN Compare and Random Value socket identifier collapse on 5.2 Enabled-name lookup wires on 4.5.11, 5.1.2, and 5.2.1. Pre-5.2 identifiers Min_001 and A_INT are absent on 5.2; --legacy-ids is red only there. Regenerates gallery pages so the new still is in the index. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 1 + AGENTS.md | 2 +- CLAUDE.md | 4 +- README.md | 21 +- ROADMAP.md | 1 + docs/gallery/armature-bend/index.html | 17 +- .../gallery/assets/gn-socket-rename-hero.webp | Bin 0 -> 6524 bytes .../gallery/attribute-domain-shear/index.html | 20 +- docs/gallery/bmesh-gear/index.html | 26 +- docs/gallery/car-mirror-symmetry/index.html | 25 +- docs/gallery/collision-hull-proxy/index.html | 19 +- docs/gallery/color-attribute-wheel/index.html | 37 +- docs/gallery/compositor-glare/index.html | 22 +- .../gn-socket-rename-contact-sheet.webp | Bin 0 -> 35146 bytes .../cross-version-property-delete/index.html | 4 + docs/gallery/curve-bevel-arc/index.html | 23 +- docs/gallery/custom-normals-shade/index.html | 24 +- docs/gallery/damped-track-aim/index.html | 20 +- docs/gallery/degenerate-bevel-weld/index.html | 14 +- docs/gallery/depsgraph-export/index.html | 27 +- docs/gallery/driver-wave/index.html | 21 +- docs/gallery/export-preset-axis/index.html | 5 +- docs/gallery/gltf-export-roundtrip/index.html | 27 +- docs/gallery/gltf-skin-roundtrip/index.html | 23 +- docs/gallery/gn-instance-grid/index.html | 28 +- docs/gallery/gn-modifier-inputs/index.html | 29 +- docs/gallery/gn-sdf-remesh/index.html | 14 +- docs/gallery/gn-socket-rename/index.html | 741 ++++++++++++++++++ docs/gallery/gn-zone-iterate/index.html | 4 + docs/gallery/gp-lineart-contour/index.html | 23 +- docs/gallery/grease-pencil-rosette/index.html | 27 +- docs/gallery/image-pixels-testcard/index.html | 28 +- docs/gallery/index.html | 13 +- docs/gallery/light-link-studio/index.html | 21 +- docs/gallery/lightmap-uv-channel/index.html | 5 +- docs/gallery/lod-decimate-chain/index.html | 23 +- docs/gallery/mesh-hygiene-audit/index.html | 11 +- docs/gallery/modular-kit-snap/index.html | 5 +- docs/gallery/parent-inverse-orrery/index.html | 34 +- docs/gallery/png-exr-alpha/index.html | 33 +- docs/gallery/prop-origin-transform/index.html | 21 +- docs/gallery/shader-node-group/index.html | 24 +- docs/gallery/shape-key-blend/index.html | 20 +- .../sky-texture-sun-elevation/index.html | 27 +- docs/gallery/soccer-ball-goldberg/index.html | 18 +- docs/gallery/socket-attach-points/index.html | 6 +- docs/gallery/swatch-grid/index.html | 72 +- docs/gallery/temp-override-join/index.html | 25 +- docs/gallery/text-version-stamp/index.html | 28 +- docs/gallery/triangulate-tangents/index.html | 16 +- docs/gallery/turntable/index.html | 31 +- .../usd-export-evaluation-mode/index.html | 4 + docs/gallery/uv-layer-grid/index.html | 28 +- docs/gallery/vertex-color-ao/index.html | 6 +- docs/gallery/vertex-weight-limit/index.html | 41 +- docs/gallery/vse-cut-list/index.html | 60 +- docs/gallery/vse-gamma-cross/index.html | 25 +- docs/gallery/wave-displace/index.html | 13 +- examples/gallery.json | 11 + examples/gn-socket-rename/README.md | 76 ++ examples/gn-socket-rename/gn_socket_rename.py | 416 ++++++++++ examples/gn-socket-rename/preview.webp | Bin 0 -> 6030 bytes tests/smoke/catalog.json | 3 +- 63 files changed, 2161 insertions(+), 232 deletions(-) create mode 100644 docs/gallery/assets/gn-socket-rename-hero.webp create mode 100644 docs/gallery/contact-sheets/gn-socket-rename-contact-sheet.webp create mode 100644 docs/gallery/gn-socket-rename/index.html create mode 100644 examples/gn-socket-rename/README.md create mode 100644 examples/gn-socket-rename/gn_socket_rename.py create mode 100644 examples/gn-socket-rename/preview.webp diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index a43498f..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", diff --git a/AGENTS.md b/AGENTS.md index 2fff6b8..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 56 +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 3048239..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, 56 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 (56) +## 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 ec856b4..153f3a0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  56 examples + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  57 examples

@@ -36,7 +36,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, and 56 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. @@ -391,7 +391,7 @@ exits 0 on all three. `--assume-present` is red only on 5.2.

-Geometry Nodes — 5 examples +Geometry Nodes — 6 examples @@ -450,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. + + + + + diff --git a/ROADMAP.md b/ROADMAP.md index bab89f4..23642d4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -141,6 +141,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~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` 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.0 if 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 0000000000000000000000000000000000000000..c92db7eb06c6173f9a57dbe18f5220b1b06b94ee GIT binary patch literal 6524 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 z3attribute-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.

Run

blender --background --python attribute_domain_shear.py --
+blender --background --python attribute_domain_shear.py -- --no-overwrite
 blender --background --python attribute_domain_shear.py -- --output shear.png
 blender --background --python attribute_domain_shear.py -- --output shear.png --engine cycles
-

Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 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 else 1 + 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 @@

Source

return attr -def check(): +def check(overwrite=True): pal = palette() expect_shear = closed_form_shear(pal) print(f"palette K={K} closed_form_shear={expect_shear:.6f}") @@ -418,7 +424,7 @@

Source

# --- POINT: the shear, measured against the closed form --- me_p = build_fan() - attr_p = assign_point_naive(me_p, pal) + attr_p = assign_point_naive(me_p, pal, overwrite=overwrite) if len(attr_p.data) != len(me_p.vertices) or len(me_p.vertices) != K + 1: print(f"ERROR: POINT attr size {len(attr_p.data)} != verts {len(me_p.vertices)}", file=sys.stderr) @@ -663,11 +669,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("--no-overwrite", action="store_true", + help="write only the first POINT wedge (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(overwrite=not args.no_overwrite) if code: return code diff --git a/docs/gallery/bmesh-gear/index.html b/docs/gallery/bmesh-gear/index.html index c95a4e3..884b111 100644 --- a/docs/gallery/bmesh-gear/index.html +++ b/docs/gallery/bmesh-gear/index.html @@ -265,10 +265,16 @@

Run

# Cheap correctness check (no render) — the CI check:
 blender --background --python bmesh_gear.py --
 
+# Falsifier: skip the extrude. Must exit non-zero (topology).
+blender --background --python bmesh_gear.py -- --no-extrude
+
 # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
 blender --background --python bmesh_gear.py -- --output gear.png
 blender --background --python bmesh_gear.py -- --output gear.png --engine cycles
-

It exits non-zero on failure (topology mismatch or non-manifold edges). 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 (--no-extrude lands here) | | 4 | Non-manifold edges | | 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 --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)) + if not 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 @@

Source

return coords, hsv -def build_wheel(): +def build_wheel(point_domain=False): bpy.ops.wm.read_factory_settings(use_empty=True) coords, hsv = wheel_geometry() me = bpy.data.meshes.new("ColorWheel") @@ -358,16 +365,18 @@

Source

# 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) + if not 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 0000000000000000000000000000000000000000..601ca5c7fe741b1cbab45fe8a3507b411fb81f5c GIT binary patch literal 35146 zcmV(`K-0fcNk&FOi2wjsMM6+kP&gnqi2wkwp8}l$DgX*-0X|VCkVd1SrKX~ANdV9a z31@EqusIK&RBz?VMQ$N2(3N$6rT=yL%I~+=M&3Gnzx|y(3}W=pYKQQ>u;1C@sZO;| z-LHNAbN7GSAEJD|`d*$t(eV%C|L*yObjSVA^M9E?IKSEd^8a1#yY#RBAM$^{|3IIu zpR!)Jf4Cm-zrugS{Lc6t{hR$)?a#gs{tw%acwg{6NI${<;s5{b>+0qE#q3x6cj@8# z)BpeF|Mx@pcijX1&;Q@}9lZT4{k{Kb+~4~T%wIqMAo~aSKi_}M|K8@;55n{Sx!xqplQfBtjwQ{)8}-3rxbb|3gbC|0XXwNujORzxmdpdJ3B5-~8)QRGXK7{UmZd zNXtC$LGBnrE>gZ6m2H@4YDi(kuGMLQL-JpA@eX>IfVE9tb*P{ND>AYHImj(7wo^9& zGxnRda7WiFd|6{%T^4*B8S%hs8-=mTyuwL_DFw~hIQeNC49M!GSyM-1N7iw0nt}ji zlGr8~{KpDz;33FezGUpelca+tbGtd6Nhysuitj(MTXa+%=xh1%GpJS8_bMH?tS@lg zKdjCJcC&L~tVyW61J4w8{{SrdstniJTO-kVqh~4F zkd)o{9F#_DGX>{CA-^@iAn%DP-G-|XG&DtlfylC*%lYk6=!jV`RQwC9FIAd3Y+_!y z8{+WpT8fvSd<;beHmL9Kci5`ZkiVZyWvo?gnvcSAXq6S%f;!d~pa)XXEQYej#K-VT*a> z^c9rk+R$GSKuEu7#(01_NS=S!DrjBo`G+0GPYgu_N&zp`eSY)~CsKxa5L~63$$h<= z+C)Tf?m9ocJE;0-A)Slu*;ETyn^PImC_A?6;T*g9olZKQ+@_Ff&=T{~NH=c&O5c_% z+_5VZBqD4=+FXyU;js~<|99UEz5-YQJk$19&tD_xVj-X_^ar6A|Gp@;d>;h|QH1Fm zck%nmvYkIPqtj(>)Xs-4bW-6gm~^^->`!R;fN>J4Iac(UjVMKzG=ADpP#X+e&oHtf zty9CM-Qm6PAHdn;(Bu)o+#fVvy-b)n=P_nvCq2Hx?71wlch`);u0xg&#L|qIL;GEu z16@bMTiaF{3kje-%!=^P_&w`m?b7Ldb^07W2vVbd)EI zW^Y?yCk`t!+4UBz?XnxOfi!0_JGkgyTIeBI!bBB?Vra{6&Ux2ArOg;LZA-BH9KVf( zQ?MAJ`O?3j_h_J0;k2I)5D++Nam|~gg3g6vBgzOi%=NM1oHhay`}ICrPMf-W?=uXU zp`u}|#%xnCgCRE)XNxJ?YiNjcEZhsiiD+A@}(@}`mrnNM3OZ(#maaPUG}Z=EO?y> z( zpbRFzn!vJ%DFtB4msUBid81M{k$YDcZwkKdu8T6UUOSmU*YebuDCS^-$g6#R-W3Le zSsJc~SD>h#D8u^@y3h11uvFwhl;~Je`tt~prNSi8VMox)YbY7fha?waR+YsiWb~2E z`zWVd)1)7S2!o4KV}~69fp`j!rDJSB3eB}xz^uo4<$KmrL*>wE(@;lct|H9-Sv-a? zjJ^b4Cw+InrDOTms$T=&I{__C~3w$Z?iETp2TLOLpo) z;D|(LRu)7y7`FRCw>pSZChi}+()@~-^3Pa%OQHl(p63$uF&F11rX_?_k3V;K^lkNw zop%ZW7recIsRA}7W{wX|Y17XIAW%thu7D6uz0R6Xxl%)@b@}yu zO4jDANSp{-8?q7EvC#CQhMEY8f}-HB@R9-J#oYCQiC5*pqGHSif$9 zPC?ohH6$@pi>{=U$fHVDEcy>(Lo~SIWFTV+0(EX*WJ&6%v|6geg1ouoNxK{~HjrqVVa=sz1>1has)n2 zseH81GJ_rl1$wcU?*{G;ayPDK;@&qa-qyBezuqKL5&_SryMEkEn}h~zsO9$HX)FpZ z^>eHgG)T!U7X-+&e!`u0$RNv|Nvb}HK{!I#rG?=l;14iC+mliQr*Z4US6Y#;SW7rM z`Hnc-WC(otqVbfS*%iB3442bY3p>R-6_~IlQAwis-Hj2a{ah^ z?*3<4Kpc$~5nM+hS1q^@2JVOG(^=B1>zrl?gH4CPNrmbRG;L(yiT!66}`o-uKx zs3|pVx+}x+xp-+`xwJ&XeJ$n^m=VgC zp^WraFbxt8k{u-@5j7oAuc_m{I0V8wfQmES$RA9T1c!g0HOP=wb)t7~U3Y*st6&X&4}duuwV0|t{sml*7?WT?M(NNt z^42RMK>{!RK~S*cjjd1vWa9t&G0vOhuVJNj2O2&@+UxY}KT`s0Aodme>hB`%qmbst zP6$aZ{~-E@t2{im%BM`;)C%&mNbOsV4V-}?rk*|ltQd}61b_E5_ig2^rrZ5dV-4Eu zri3f^zO!jq{;tOX_!N&N;GH-jVlZqN?k&V=(}LZ(XDZ|;caSV{^5J)K(&VY2#QKP#dd zT%7HGS41?qIokZWb|TB5HxtGFj4IdvIejlmiLUFhy)nJ50BW_&Wv(OEMbw2NbQPUJEg>L>>nvtkQRDun}ZgPL9Gt z2IEHZnXk-lnl|inIE1tFejw#H>RvhGV_1^yM-24>qKpFLAOCkgdbq3%NdF-3{}Cm< ze*t`CAr%MO`B}NG!GRG1p|goZCKu^`J|sF8rcJzmAIiBO3g)Wo6Kp&Pm@L z>0LSN?t*)X=7PctxeTOlJ}ei>F;0%foN{mhg6Ri9k{ff97~U2nrF@S~;!zjyZBnRc z{;H46RjX5%Qc9XXaZXp@9q!qsQNm(qH6XIraBL%8E0tQ$C4-<-5Tkth6Xzwb))H0>wHaCX~1J5=raB5f^N$#!n zS*Ypcx%}&&&bj>SOkPYgv}j-7d|{1{a^d^_P>xHA#+BS0giJNqtcN}SmF2jZ_?ytY zT{Bf7y1y<7SFz3h*Xs%zZr0{bTF$SuMH-zfZ)BLi#)VrSe{eFOLp6HoGOTN{GF2Rv zC0eFL{41#jfG4h75|sekk9(xv$k-k7z)Tw6H_kmmnHB;&0U!VQ4ZonnUzU0 zY@i3LISR(NPXXrI)LTIPb|b)H6z9nw_&eiL!ItLHn#RdV+u>ZNxglmL$}gyn_FgfVWMyr{6segQM%)UJT}wK2?CJlUqyF zBB~pBpQ|WzeTv!(wU>*psRRt(bML>xj~)0f)BCm{PFQVWNA?$ZOr4RKc8l3*Cw!{| zb3B_siYUX(d<#p>b^)~obaN^&6iiv;#yH6gITAS|w(zV~t=XY#X_j5NcdVQt+9UXw zEeq^7%ng=lKe?Kz7*TwD2?!HCETpL;``P^B(N&#1D!-XZ?UHMmqWdXZ znHG!#!;sb>6X5QXv+$g}%6xS$rmO1qyK^PurEjvY#evCQ9W3B;ct6k?BC{P)#0?)GHU>=Ilpf{V%233Bz?G+Vt%M5AY%xGY)%a&T}@QJ(c{7%Drl+|M51vs=eyJYPHuu zA2Npx{S2FvpwV@uGDgOYgK%i5Z12hv?VWP8JX&JGJ0W6&h{d39X}T2bnjkn81l$hx z47qQNVh_D*W&kV%spTj?Pl-XrE2n$x9BAkdotAyJ_%JuI>@cFJeD!`{U}<-$SYMRr z71^Lo3ZD2^s+3KdA`m614yO!7MF2(&s0e3JQXYLRzZ=Br7Qy1D5QQs!NZ{ zmF`^wVqMkw+Xa4A4ur;7?`|^>IAoNS$A18Tz>%iw0xThBp|Z=CM2!@KT5yZ2O${Escjj4a_|0)8)KP`4F-gP<&j|2$eDQNhYcC? zY4{LbZpp4j`K@X5aYG=P#@+v`2LI- zpX%<2ZQBm}+>W3}ZON}UxzN9<7m5|amIu!Qz8`<_Si)y!(Cj1S$_9l=3~c7VH9C>` z;Y@MK9eL-5<>7pNflH>cE^&+o*}vSpvaR;HcxwStLmR>MA7a2bD+u4O@6h z!=3kz6Wm(Yp8TJ=93W?e+j!K_PvLgm6vK86;4$|y3e;>TTT8#e2uARc6&CpNmz=rE zgEWJbuxR)fUvkhp3;U!)VVezDUe_-F=U(2dFLp6VckGov&yT2d@Fcf3=jZ^cQ*#jZ z0092{<#hbtM&Dwcb;Uhk^UII+P!)2`B?5})alz{qY%qZ_Tfw`4avO#PJQ?Sy^6^Mn>OtPb2IbUPLi62|5A;E6a@pQR5I z1(EyJN3Y@ttV8yH@AA9M$dCe*fumTiEyh#zR3~Ba09p7LX0a>ByB;dcLoa;vX2iqt zWn2GHelq{0{AQifU<_TdWcDr{Q7LK=M?OTMdG0N`Oex+)HenviTq4mu4oZSNXcF?q z;olhsFfg}JnyIx7I%|k#{tS?aEFJo|JA2-M;Pxur2gq4&87Jd80)a=6QWn+@?WhL) zfk8&P9d^DWRi%L#-IXkJh_usVogHrYi(p>Eg`y&eP=|LF6fsr&bnz7IbD%|1R##L1 zay0=e?V@6*Ds;>?Y5jWcl6kfU37o^n@P_w4Twi zuRe5KvF1{7;f|=CZfB`v5$;*fSSZt>5{&+XBm37+hy^_L{(cXT0ZRY?00000ECah6 z!kWJHIOm--*pp~8<|S?UJak7leg*p*DtzU5f34aV==PQq>}l_HSl#rb&1QcPcrJ0y zia%`iZ%^+nD-voA7Cpb4Ln&Yc+Ewg%QsutHV{~vQK{e9JKd(uJlWeZd2DR?;2T=om zpSjPxN~s+jfsu`h*i-9jeM&R!917-do8sJD^}M_9(33kT=h?eNOC%UI!yi7~Y2bg# zCdSD?jqW1tHX2#*e4SAZz@#Whcm`?KaYG(XLEuoNWxkirP;*)VB|a_O^AWWL)fu&H zmX!lf&H^g$6Ye;c^8a!`#f?5zY8gbXU%o) z!mk5cdCx{Yt96>;bP>tOM%5-9BSyl*o)F3Jh;Tgh z05zw3FGmgv3$@ln7s*J`A|25Ussk2d#9gYqUMm_S)+@aFVK6`L)Jl1`sau>KI6Gl} z`rq#4|F6HTGGgz98j!QOkY#qg=Hwo-A&|Bx=P~QQLN0ab_L8`h10jwff#uDh0$QzP zb4qS>6+ZI35bTl-Q<352ZK70(i*B#}6y8AjU2p&Z008%qadKWSu_DYArya+w2$aZK z!!(D~RjuU9>TIkpH#z0j)U(6wa59c^F1Eo6Z|q$j3r6=_ z4>j~|e>D~pP2m3%9HFMJV*?ySLLq5T7{#_2$+uBe$2#L7++z7#;rQ&j8LyBl@D6-HTp4~#;>aD~k@KGnl- zG;avaK<0sc#mTZOLhqX0>|;wUyr?)L#UVwrFNp4-dQrX{XWKnStzviN0wD}lG>EMC z0jwHRdJxA8?&O_pRjw3_})8V+eZ_B!M8 zb=vrVLsk~p!fnb4d14=wlopnt!@u~|W;$X9ncpE#&T!(v3!>BBbIlX``UId6Lrh$9MlHrB5ps(6LG3yw0Sqv*yHS`}Un2fVd7u8K=O0ocDxR)0&w&d$bk+c05JRZEDi5>;;O zT22^6o4unI0WKd!3}vU(RJP}tA3ZnkaQUcSLNsd)Fz~`U%9641;|0n{k;UW_kidxa zhtmB>modY?xV)qS{G9MHm*`78;nZR4Q5cC*+MUEouF_nZ**G|1P6}R0u08dSGaAYm z+Jy2qiap%>Z^oj#yz&R$bd$=A7iIQX+z)>uT@{xi@;##RBT=>X!=Suoh2au?_{zLO z=Q=TmMgXP(QFvSkJ_(mnE<(csBGHM@g!p>2&rSgKK^%j9j*EbXdRK*@V-D>Q?+!8+ z>Z`vlDV*sCP9eb;=s>iot306a7vOUZUQl!dF=FkG+(HZA?212Rn5}0gt-n~o&-va}ow=2PdKI6HX2o%;! z>NH*7@HQKng}WpTDLfRw2T*53?5$p z>^(R1or+ga+k4qjw3-ZO1xFyIM_Gz71*j8bMV8(`amUe_yU>^NakwhM%!P0R0000E zLJf=)@6po<({r@e-|CrZqEguq&Rbpt4fN%xlTT% z6Mqi6WdXH|(1eGue`Ga}Fy^~qjXFLR7rFRtO37QtCtz+Q0pTINY{zu0B>m=0>*&ID zi@iHSV#=>%a;KfBF}%~3!PFKKUU3qs1)wuPNp7hUeCE+%Q{@hw`$B#_$y*tuN2=aRe_~a_pw@e3Kf%-q85m(8(nU)Hb zgHSjm_{}dD>B}X6dRNTLJTH6z#Mk5qxWwE(YapqneNF@^qaO(&A`5SUGS>RgM`tX3 zI5z6tbO(x$I4M2jC+N{LVo;0g1S1sq`M+&qye~cL9q`8~d$fXCUYAkqe$9p9}S8v3BsFRMx3^+O%uC?t~>+$XwU)p}7qA?SU+OJJ_EvSeHeH*TZeoq5ZWam2jR1Yr&UqUR`d7@JAq497-ugyre}SkajC-p8nFua@$U zec!l$VIbgWMTz7%@iHmrd^RNV$Feh#I$x!O?i96T*N z;S>-dr=gxZF@#!Z=+8C(y{0~jHu89iasfy|LRLhSU}39mRQ2F@F?K*3PZ|%)lCGy_ zQcTh$DIZwqNBsbWZw45ytOLc0se74)aMr)3Qe<$Hye>7qhsg}ldECmh)p9y4gsc0V z%Fz)Cu}Ht$Idx4Kb3~MQ1JXL5p_yd58wk;Ee`JdRyUFreacu_|KXyqVmd1#XL+N_b z&9wgrg>?L9Efk@xLLJbBsm)*^(dm;O8zk2j8Tig#Kc((4hLt*`Sw4UL*Hi+d>*P}J zbXSBFbDLKLHeSNbro~scQq2{XD2%)1J>Px+#iT82y`R2?2qu`xLAHLfLz)FpNlC`# zuX~r(Q90trCpRzfv6b_5=#%u5qR;0XVUp->m|#a5-c zE64IL!%m~_B^|X6_;9Oj*UK_?Ap9Uv4`kGt4)g^OMlbtSqN1-goyxIQ+pUnR0adTK z8ivFhnSSDYvqE0rORNbh`~)(&m5j88|LPKDzQ-n?Nw7BRow~C2wZ1S?ITvRuu>mi8 zlN#pB_Pp5n7a};uo8=G!-r|z#d3g6$Y`7L5;B*`11r*osl0<@%bd&~}9Jb?F5TMck zxhl1>M)by6n{w{!y^4WlAL&Z4Kr`weqc0BHs)%%JZAWpA8&#~rpY!1ZLoXxQzxHBD zTYa^}n|-&~2F1`wRt?}G`*J8naC+h)YrM%COiHCma-L8}eWB4lKyeS^008I*;WI1W zl$WZwwy2Tuyaye%eT!c;x$wg#y~$FPGWo$gDGLMM2!-e06W`@#Hg-)C3l|noru;sq z$6XPgeF6Fh*IH3{OoOFRWY3yt^&P@2+PP6lw@q~?dRswhgT;BCMfFIzQpTqX)U>dd zTV~O?ZZfQt)d(=!s%86X2;RaFmAUZsVDZluxBayzv>oq36U@>Re|2hGrZ%Rw?bQOQ z6M(}U>Y49_qa5KbJ=S0p(maeoDhwQMNf^C*C)BNw{Z#=+7yCoGp?2Hr2=Dp`<|hJ& zt>a0Muc>}N$Auya0=muhO!e$neWxy00crftC$Wc{wiz((#a8k!1?83)(6MmymH=wk zr5Adc-u;~jun?5!i{hP-m|mVNs#rJwf})38Dv`TJCv|wP9%&0ll@K?l!Hp{(Ygkfh zkHmZ>(=1*`{z{WT8+nkXn$~11M$VVS1kMYZRpTW7m0f&Wpc*04-;Sqwk{p9@pp-wiLsBkyF@;jl0i#+j5;03Uo##b`dzr_nus zG;_&SJk)i=uMq9U28HNn1TV1QqN6h;p#0F`M(g)XDqxBX1rfmrgmp=om(>G#h2-pn zWCj} zaxNyX|FS}h0`QRH$M@XqhCFd_YQ>4HO7vfTs@|~1A$I+R$tPAUP}p25ge;Fxi9f3+ z01MAo#e(EkT@|D+HsR6YI$=Akd>teN+eiwVLfhGm|D8Xy?ig}HZnRRgQ%v&XS>j%@ z3_rjRBfxKF00{tvY-xgRn>~-3qeq}J;;&W_M`xY78P!M#o;#AGLmn}#xv!K=z@cs* z+qYHO897o4P8H}1P_-Uq4YZO--6oB1X;$2oOAI8&l3yhy&OJxvF=O+?cw8OL6tS8) zRBmI})?~6!$qzK#%HaPbnFp)LvG6aLkMh@BOc;-6B&6XhN8~64Y9z?=Pu_j@(8{`u zE$y&)$0lJ8SbRAI!@r+fA`cY}%gP3>Jo=IQsX%Yq33TagqO4iT&7vS&LpPXH2alb= zvIZ7UK}AkP6DA%-@%W%5=AET1qi*SBhZ>navmObosU%ZX6c4mCNJ>=Me)Ne8XyF^L zXVSbJXrON2h1|5rv&>g~T{YET}m1;!EFMNmO-X~8g?HC$yJc|iEZXt4Ug{@T-f7tRkt7OYQ2Y11uAkr9H^|}((kxNY%-(97Vd%NW|3izw9Qe|Ajx?Uwl*O! z3H|{y`jKjAgOfVvYk}bYTWc-Fcv>TGq-SK#G%Vq-Pf##frl^Tv4eEqzclCeIxyGoH+vsQjA+;x-X}RA_y$Abu*XWvXF!e)soc$zwNa2q)J0o|o`Xyq#^A3Omq}LVB#MZC5(DAp3#-Jo*s4qE9{z25V!KJ1$-qo+$?p`Tsk)T@o)!a$G zsQ%JJ&q&n}7|zyA%nGZc4a?S4R34c@(kaDPI&Jv~995x&7={c`-x1Nj?7ao2%Z{ML zNPDfi?6O-aQstOi%&2~H}@{SsABvHj&%5o z*Ol3Lje+dpgKi3$FZbc1h`0MN00nTnM}+7C(Nx<(WB|>-?XX`uDEa(gYsjU+vu>_Y z@JfQ`_m(j&#b@kEsB&|~l!06Dz5H&XF@o{xxcb$=?&j`KY7v%Y=JFcc1RaPun1sJ9 z5M99Bz)NOmdXHDqI_cO*lEmqLZeXTDD`Cv1^3REZ6Q8#CT~VN>YVyfrOLh0=S9Sv- z=q`X#hG-(6tBWjo((kU7Cy|F(9RXU8%crHIxlD@MCa<$`2S&BQa{M}lm$i0En5Rzl zri86OoDkK4gye74;$Yu%;36g%7%UiEY}!>-RVZ!D@#>1ckyusttX=Eg{#m{T#lOl!H3 z#C7<$@HQ!Yg7Q~=e`lxwA`+udwjQ`)Cqj;%@VhEHZhhlt=0f?0)c z+lZTcqwU}kYjsiU+bc-OR{9mG%Ev^_{ojltQTzD;d;O^!Chicp^4`!D4G(84M%ctGSgubzf%vM50}FJiQtCkmtyZj4 zxoNo)nti(3`S@>z+Yxh${F%M&`Fkk7a-$+p#NfR~7)X_qBRi5@XVGTCSH~}wkB(hw zNeGYA({~yfGze2U#!`P=!=~t;_S+Za=B%=NrOK4dLab+j480Cd{no6N^hT|BCK$70 zz@qG8-WyRgr=x??L92JVZA@IvLW<1vXI#j<^LfhP0PMoPYTUWOrv1sDrD)X-P_p}$ zlGhet`FnOQJ47R-KW@7me!H{0rc3I`F^&Tn$&!EO&m3Ix8>OIrG(nN9HuBXCPEr$+ zVu);%IP0*hvw+;(Q4LPuDsc;$XCUP$bQt)G{cOg2;1zg2&ks$`%3lCmhQ>@e5>wtZ!^MJrRffN}&? z0vLGw$A`RHZuLu*?JXjH13Htfi}jN2Q4=)U?;f5CfO8<2&@JR~M3})z0L~HRVs?O}Bjl#*_O&RZ$Dy*%LGYJ~`HL zT4HAQ^ZIc}p}`(i&quNSfRFo(FBWkUZH$r65fh49L-&OYdjtUtAXIK0=g>GXo)82! z5JF0scu!+36d(P$5oXaow~MzoGLZ`&1OWJO4zqx3 zTE18Z_>U<F=z;& z-)0t0BU3`O7IB34SQ(k7y}JgT2(U0jDm5@2%FT%V^{rI0`vtQ$Pe++A{6V_!prMPbbagO_D(p z(tyl5EmTf-*L->%h-`R#fR?gnm?ZDcT!~54d?)vOBQH@D=HBwk&T(5U3-IXJ6Pht*62n;7b1~wWA?&Vvf)Jv&tb0CB zJX>~T>YpEE#d9o`a)weB3k4bu$av?$k6>tn*{aG|UM|In?42>oq!Xo-zESPs717N? z@CBw7eEY+PoFeN3s873UQUPT`N!+JJeTd9X^!G$vZYfnRO39y^ z@UPubGI%**ao)Cd-1L5LSZ07JfjB@e$+W!M1z&bU-!<61*RA0T(N;nF{Lw$wBQSpa z;FS@QHN^PHod|`-?(n-6QClUidR*PTgyxlmVbO`^ZR-=ca?D6(K9Ofyq%r#HM;nzl z5JUYZpo84i(MD-;(j1_Jg!?hr6D|7n7bgj;?U@^0)+MkevWPhqEWQH(y)JrnAhKUO z;aw$Dbc5bt@1WPJsL#g%V;dj0_AAzZeTUQj6Z?QawlC0u3oH#AVIQi&Vnth|u5DG~ z&pPCWK(u#BWVuYV0+78M)laP}Y$NPG7ljWo*{k`ei(L+d`HQgt4eygpz2*kRLiaGD zTGL4OCCyKm_Uvk*xPXQKjR8I03JH>8jCB1<=6ch;nD(>rtG-UrP611d#SY(<2zn;B z6fTSlg8Qowx9$%EU3F9>Yw8ti&)Q^x&tRAA=j(oK%I1_FjjLh$qv?TBSN=VhMN`YN zQJxxqt8F*R1Gx9GMpnkm2DA30NlIYAzZKZ*>9F~4m*HBvSx`f4s}oU(S-m$v^`~E(E+fnM zeL}76TxSq^jEK2e4Q2k$NW6-n_n@skI)*X`mntX<4WU4r@GRESrU$2Jwj3Ie8;2lt zK}Oy%QIOy|*5kktAHQfWV)$7Jwf4W^xm5%*fxYI{IQ88uF@3;X@1j{27Otr?SZ!L3$|Wn`VNH@IbBUG&uIwp5kkcd zvs^%h0=Gq|%N27n$4WDM@C?d)dj>n^l`cJ>e+_sX2htmSOGqPmCX}4~A81Aam~b{A zbdiGpOI_3_UWzB3+L80ULUYkGERDv$fHki+XE#lW$eI(UJ4{cdEzX6=8FwTe960N` z{?ePWyNcnT9UOHND{2vYQs!22KfKPuot<+!`iL(y>9VAn+jkiI^y+Q=NGG?k*>U~F z)RGu~UnQmF} z_Th)t$}Zxb?P6R=p<$-W(PKhSue_987RrcCjP%${cAXQ@Vg93^x6LZ!h6RbNR-2|G z)3nITC#nr%{X#Rk;2z1qEDi*?w|BFH$Lb%0RYc+DZg% zPxV{zZ8(XHz?A#b@&BsZ^c)KXYYr)a<&`7!71}Krj%U+|2AZwHu-&4Aioxjzo>4Or zv*gza%3!M=31-ig;N$eLZ9N5%Zus`!9>I|FRJ>71LB?7y;U`C#s}V4fwzL0ZhKJhT z0PSgD>5vQ!p1u_+2OGP6u1SnSw+P$v-;yWO)>LiLh8J$E%4_D}$*|9BiXlvHeU$oaRTp&d0r<4$Loj3pr@qk-?{4QTBo zLO6yh%OtK)PnI|W%D0qXj|_f|*&TAm9FHMqKv^c&xC?n4cu~6zH0N&@y#IFIg~`P& z9i!0}mDzR`%*!YzTC0hFU!Od#D7vKXEIwOW{)?aD*`m-Rk|I3%$#+-XZM~_973w{b z^NMR?kKbv3|Mg{uOC#V8X|u=OzEla-DPRM(d$|XkI?~C$0&!O!+4-9i3%~7tL*`<> zZzB?uL@61oDsf@*)_?1OFFW*1kRr}dsT5*LVe|YSZMct)eL$@=5FC3Hov7&b!so|) zKy5j{TN0FLv(7wVSvAui^-`q8&Llu^$uR-Ix*`e-lplwh>K7`_VOLcUuaE)o#G^CK ziVe_U1cva+QrDX#>6Gh|+7crYO@F|a^#cAr&xS*}*w!;dZk>6d*yxhx)H)X>^zzC; z_Wld?R(mxW#FbV0`1z`qB82+D7?1Jl-_jpNT}4VxqxI`SSE+F8Del__0A*az@z`fp z#{t~_y1-p(tx+Uup+rQ=0S#6x(I{=)BLTncb<8HE?#$`{+cmcRHC4&0q|^}2{^0~X z*172W)KS7MmV9=qibk<|wvGN)ME3cR%lN_6{Ad?28(1Vj{12peXZF|qWWC+t7R!ye z-BVZ(bq4N3p`AGLlyHI&=nLSqP>|lOq9_eaJ~-PGxfbt!32~&4ZYB8Sd#0$zC-XN? zEeB+i-ySbyGvpu4U2H1uo33TZ^rC)K<-Y!D18pjZ#u(FJDYdGamy~o_a|H~0lg83R z93{(9j;19qukh)od$6HQKq8;(IAsa~a&A0YcJOd>I3lOFs-`wL%-L<JrU{}1a8N@Kh`Sv#b!7A(3Ch!^a*-wLC%82zCewb=iZ%$V@InxQ^%;5hZ> zV!9CVBW^Z^>6AdP409$A6JJ`+{scZ01QrX}|JJLBNF{5>V{&B+k3s(yn)10l-Rqub zrxaF5B;a1jDx{Pt74k@KJRfz=1=kYL%OFYE-1sJz&vV zK+6Ex@M}|(Y<0vRyZdr7>rT5r6RtvRHLq{mm=77YT4VWcgoX$CG=vBK4@DbVR}KZj z&g>Elb`RHKxE<`KH#;BX_W~vL!vg(7QOY(Fwt? z2ERDx$Jk0sWLLJdw6ER!(XA566?hXDbHs6j(!39sBth9-yob1I=75Ty31%UOlrm0fpMngvu zKtXv@H0OYDI2Jmm$7^ueMePIv%C3v{%$y?C?{DlCBxc5jWqMLW8HUOjSi&&X{%`Zx z;07aP>H-CzHN(;6xlMHXf=WXx11BziLbM0 zXS(?NGgwrJG*OGuNk)(RsKxs~URqqw?WvLx`_1**zA z_@ediztG~Ndz>Q?q?hl335k$EESuy@@Y^q?kz4|?F!jW+akS*^uo!Du8{od@+G?%1E?I@Nd}78RXj5Yne{!4eKI z;dmbHs6s-u(E6&PUcD-itd+iU?jORSMSzW~ zQsVFDyzfkxdesYOLlL{CQ(VKVY3qk$lvak^a`BbKi`;Nq-C3w)jcPF7Rs;HrofrTP zF!Emb#S#SOKT-86H2OT<;rK%!g#;mHlQ++0zcC7_!P6N`8k6Lo=qqpB_+*7NZDzYQ z*wUT>uvV{6Y@xY;FMJUZcpA_hkJaxBx^|O#SI+^s@b7<_N4#Q{DtQ-PrA=Rv%h64R zqO%I?E)S$g51aCmyG@<5vhe`2$8G5?n&P{khV`VgGDxQt5Genm*7z0A zEb4iK*PpI56#etiQDl^a{7?BjeO0-D_UCBK#5coL4EWo?kygq!6m=9`I;pjL9_-^U z-OBEPQiv0-B%T~Ji1w8gD9A`%ZNOAzT5QrOm~{Y`WewN8FQ0miLY@AMsqYO>6Y$_P z*q(rV)JP9sBn@$$M*&ukx}Prg`u>xN5uw9uy85iTbogcvx?r}<^P-K4iqu%8u*=1UCvGV;;y_Z&KPQcHl3;AySuk29 z%EasdI6%k0o)OL<&IP4@?=l3KsIux-pq1v2*wWJ@AL)&^S5QVl!!Z3L9w5M`-RW@W zguEcn5Q7cn7AL0u z$n_ooxK%&4o+!TW?HY|ML%krz1}eA$@@JXLZ_|%xSr7D{5$p3^Velg`et*ez}>5 ztje5QeA)VR&OEE7R;s`9f~V?L{J_1($#xkC!i{Ff3B$KI%_Th1hgG_7R^FoLO zajmj-C&KFS*>j&PcQe#)C?zd;f4m+qwo0npd_l_>FBSo_76UlD~9Wj*0Pn(=&A zw#$O40i;DXP=INcc3v5EkGu=(f9}(i2NmN@?b4+iUZ}90Tbm$L-u=w(GLcvdMe=8( z)?=~$9DomJsTKa}lc|i$I&L@Tn$a)q4#dg_bc1a&C8JOC`&*wQ8~gKi?sN75r5UKA zt6joKITrdf@NN3WIebAJKt-xF@_CIj%dx2XvRhh>kFmY7IT^cb|1ds=l(cS;%NHcm zadV_mUzam!y8v3nn|c*VN@a| z#%RtU+03+NTV)SBTu!U8sC}pGe|JsVbtN!qVA)6{-z0Pq@9p-=C_+$XV}ya~udVdY zn@|Qzj%~_1Y>1(S6jc`!)I6QPuNnyFMg%ft)M}Ico_{BrC91$2k*oNTf;PI_@`B=q zJl|`7Bj~{BvR1t=c{Z-92D3B*%joB6>D0j~mbDM=pYjo`?&Av*-k5<~l`IdSn$B=2 zUquV|>=A?;5TZ}9An>p`ZI4YZLZvU=w%lQrC31V7RP%FG#-Rl{r#($RRVYDa9g!~O zHu7a$RO2ANj7sIvFlO0RUB^+2SOm;8c_n>Q*Z`Z=$A>5$6PP@wbzOBFWWm$;O-GC;i(J4N;2CZPWoIx24$`Qc*1%q6gZ1oD9-Idteq^Fc*-A8~cwz-=-H+K&R3yOP2o93i)-a++@pC%nF*a?=6C4zp@3@WUMD07H7 z1tiDN?69(3YrLD7O_ho~QOvcY3{_RM`l5HA*bYvN0dY$jH`*`gtTG8B_GIhoSDsb!(cS5t;VpWJqLOZK*7$;eBE;WZzq+sOn7CZ{_g0EKKq$8aUPTA{QF6`%8Yyw;iPgz)BNopfD zfqq6w6W8>@^U)&z1t0~zm^hEXRvyMr)~X1y-Z?S*JIMXfLLX}*br5FWx*g-&lokS# zlrVMv*#FcFRLEz;l4`M!JkF1pDp33Z=*;W#>IxlEXWmp>+9)L$JxPD+Bi%KDrcO=4 zmdp~>%@f;yPdtf>Qv<%+=38zyC2WtFq6@kwB%5%{n2Z)7X5BjfsfPA)Meq){_9z`= zKXaZNVE4Io6wE-#Q%jV@LSPQH-W;T0Mw{totkPffs}n|7`Fpn~%osl*7l77`>EB$Z z=>RWPImEs=e7?`CbE8IA$*!rUqBiLAoj0ck|A02i1!`;e6$*({R#VihsazwqNqNO# z9b04!VVX_kUvE#Y^EUZU7-V>TpSrao&(02sNO46|r$9XjdMl)s&BBk`O}d1NV@=B|wP`b!AQ~|(>Qnd-h|0H6^l><#1LUM>Y(nma20JcP1+c|z>Z%GH3 zD(jOYoou=noU4N8KYYzyJf4p2_sP`ar=E*upS=loHU4?WmNx)p;BbJ;F9JK;SzV4HrISI|Bv@6)2Kt!ysIsNYf?@&r5y)OE%Ej|x(#h@Vre@I<@Q8mHL zPd}IimF# zzXnn#DmmXx%DTiN1<>4M>ma>j>M){P)PYI^k>V4%0id7$h^n4!=v()V9%0WE*Py<$WZ}5!Lc${l z6_l$$1#^RD zLK{E92;}J5D<_62kIn3%EGgxk10WF5`XjQW1i7DBwiZ2Vg{>w0A1CyKw>42Fi4fM# z7y~dqCmBAu2R{8n2l`TOxz#|U=36FFwgF67nmte?O(9|g z40vdBJz5FIy2$?Q(qu_?Zf!k*y82n;N3U410LR3NYwEh^%EK*8?o;J{C&_0sB1iYS z9D;`go8;HH@Dy8>8==P%R55F&adnJn5SBav|BzF}aQ^V| z%Gg81%S-#~8x{0DQbbasv`x?)qmy`c6W~qEU$d_9H5SRiya$mFa}IxZSoQI~*@OBK~CY*l%R9~h2!$1S<2mW5z^ zQ^J)Vo}U$su`jKz;1?Rm;$bG$mYS@PHn~nU#2^cKotkZ+=3+JPewiJM`AGigxls)Zb(cOpoS$+Jqe&$0mjS&|zdnQgk_jm2G=Y%9oe=EQ>xR~>E}Et?{Ek(M(u zzU-KGf{()0q71QJLYY8$DiG4v>qq<%^8?Q!VfT;u`Dq$TB04Wdg>|mrEhlhhn}KVf zQw~d?vbuGo0Z@elrW9R!E1!ok^ANcz!k+iss=E_droWyPKmR@#mN^F19Ssnss?cbq;yBvA8sW?cIM45y|>ydPrxmH zBBdC=($}!Dk}UfgV?n_GX`zdI-SE6l>DFzG3~hEH92qGtpQ-83_w8j6t~L;N(hZl? zBaS7*t%a7J3WeH5^vPZ7V>adYQg$F$pSfZ3X=W}_h!_0=f!~J0_=SekQ6y^r1mB#4 z0l*?9TaY7h7@n-#JU&>64Xe69dd*t`yputH4n#$|6$jHXiCFdp3$_*5rE|8x?KoOg zND8uf}n;>J3#4j9%wB&J{sQ=fb}If*+-0V|v1o?B;{2AzA=_eL2=DGhGWk{#PhYzny5& z5W>xadGRvjGl0cl2I@WURqbl5e@hA!I1WcSNczmHaRZlbh^A==3_nY1_n!8`g5jYP zZmYz&d4i~&Qp2!-^bIO(UF2Ge2XkU`n}y5%H~-D8fwFkEJ|9DeTOlL+JGJLa^Uj0Jl3qP*C+{$U}((H{VHF3fbm_(Cg$| zz&Yp!Lq`^y3ix&}>$}q?HAe>w;3MAi3HXsJj(=J zvIHM#RDEXGL9-TZ>@2Z-ODz`zZayrzmG}bVqEZ7+86X6IpVPPny2?OyAw0Z62!8*U zvdSceW?0d3El-P%+1JD2O}b8|Ne1y92wp5%0M5@36odMuKWjBRO-1Yk|NfdazsC_2 ztK-xH?WhE<%qkww$zEC;r+MFj=WTR5LnLNmPm65%TQENa&_EG6(;aImbTuHs59yo^ z7GMPVs01atX6hm7v=@p$v(k3$)ELySkwHA09fCV zlR2La6Kmx~h%sg2DQt2xLUrE05EdWU53KuWNqE;%&X^RWwI?QT_HQk%GzJX;*oGWM z`;C^&)4BmR48P4vxo!7l^&Lk#+kortW;!`8)tj=<-7)Pv!?KFa2c?Ses2WyTsr>n1 zcK5kZDs^T+!%!*18S!{RL0U9@?KfocX>(Zv5QWnXOJ2DJRKI#eh~+0Hjv5;XMImCs zTu+v9w4gU8=$HIVx5UCEnj@{WBJygb?*J@19sp#1aNo#^+SrW)Z8~ z<%yB2zT<(f7p9oA>R6%Bu=LnTQF*Ra%FSeH9>$EqB|JRGeqD$K@y}jU1aXi)hGVji z(Eh7mh+ROvj@C1h>zUd{!-W?Ty6FR*4aKr0duD{GZO~L0cj45jBI}XWd7Eh%bO#6* zCJ^Q2tKCL^{B>+O^eGGvYO@KO=%~NDN-mVw={0H#OCn=0VKGlyz!C4?o~c*DS>YBs{T&%w81;Ut%ZQGP4YfF#^FGo*TfCyom?(vg!Xab03ul@ zz}u!nsDJ!by6sgK?VOZH*wx*~9?%$?YYkeyNMDzPQez%(p10dVVoyS;z=Tfj2)Gf0 z7gFeizGPT6qDKGnbu;BeDMh6rhGC=7#y%;Q!5)v2d4I z3N&KjU>I&ihB!SsY}F9u;D{WnlY5>bmG%o8X|gllwrseJ$d%u!+@+_@fOA#--w$8b;$D}tlj_-WP8oi69gj={-#@Y=9C7WrUJr@Fz~0}CO=oFDMQ93t zJ<&1Usq^l;4Zbo7K5NGi;b~$e*ppjJ2hTlXN#bH_;8e=G4go==eUr%HkU9ig3%J}I zFbV{!l!TUf*i}9UMQ8veZmC;5Qd}PmsNS64Ng=1BRXv8Z!(6(d9-KUXTgwBL)di|m z+XA#9(|cB1MH9bhP}keY9#(o@x3&SC&YUm3IUqaL zh zL_JyTAnKRIA7`A8=Jsu_YAo`i!`QCUPf53{3P-VN%22Q0ug?G*wuI`Z3XAX_%FNwD z+NWq&Ns|?(nQ5f`viU^5>4C<|55HVzJv?Z#>`k`DWq;IKzxanC2#|Digd=5XiSlLm zl`S@)640!jE%P8XYfPjjhyJoBB#q5;b(dc^a=FC35|3r7k=(k{)4dvuKa(9LK$NgV4TDlGtuhtYEgs1bKTzzREFP%7 zV=X2_x;w5X;o`J~UgVHrz%U;IQ-Y8CO%DW0$B=O7EE@w2Mf5uv31Ys6Mk^12V^}-^fpYIp+9yQGXz1 z;(*7j#U+sc$byKz3JyYae`UJ&Tw#@}sQACG`00#_kd{H|?P;Mwl!L-TIpc(NR zW-`a!bPJnmZ#wGrSyKAe3&h{}&a`zz6Xwct7ayudt#9q-d%`p=GCDjg(zuoe3I8VZ zIN`9FV= zXf|7?lxsnBRZr6#uRRMm2j+dp^DE~~JbN)~*t{$kbHTOL^8Ky&e#thF2sF26mxL>K zv@i*ZMo8jf6Q3_vMdBihvApq5%sNP-9*k1F`Hl~5yHGy%z0bK&M;HsmV1HEf{8IaR^_r@nbreaDD%<&-Yii8R#3y-Mi!-U^`Q% zn}}{#TN=eeyPl*7oUFnWfJqN*Cp`cM-S4m23|`{eMCC)jkt#VT^klrKb`A&Oek%Bd zfZXEn7@v}2*67+`z3d({F>wlXYKa;cUF|N~E@e%6u-0=voVwkFdGEw)^2-(;%H`NTG>oFmnV!bpkUxp&+ujFLxbg=h<+3uou(Jp!0~$| zAuMOLTbpTc?uc^qYc16(lZ9C)LU{X$h?$FwBhq0ob|SfY7O9CL{v+Erf@N|!GGrhF zP##BxGkT;bxLDl?bYzL`j$r?sLlRr6^t|El#OeHF{9K;wSc7$=VGBtQhnp7VW`F5p zopmHrNo8m4hO|Pye)4j}6SV6kQS(?{1q;^zFce-2D(em4hFb3e{GF`9>D6-RQ45%G z7&d*WGu?IbpD@;= zVjV!YZh>&xlRnP~J_Q%}!M^jEsq+Z2y|YlE+T3!e7}{`}FY-e~F95%;4=>)z2k(rtaFILwnObXq!=#&Ge4W)SGm6;RV`aCj z=xBU&re6Vyur&bPIP)pPx$RrpQ7f-sf=Me>e1`p1VzsLMpS<@n8Fkix&M_O6_%5#K zhT}FnhyV^|H^mJ@d$iM+*TG4!jZSORLgSvCGgtm1QPd)~c|9Gtvf6Y%CUe{9mGe)R zP~%zXdsP0Cjqj<_U8VnG4=QG7J?I2~?Pl}bnLlv1`?VCW{qRwTGMih))uST?>UKP^ zunVn&MAaUcw>-_`X~BI3>)HqF>5D!{Ly=wpSEA`|D@0{G1%CkPN0Tbd6myWQFOAw2C5++IrR6bonte*`==?PINL98F-; zs`ChSlU!%^b>mEfYEqUjIt)lFQqxb?mXK5ti7`Y8#Qs0`IOB8-C4pD6m#=LDCwMR@ zcm|GVf?P6qX3c7Ce{$5RG%UGb2=>{>+>-C-bT_gCDBL6Z>BtM5_(A# z=|-JWC*T{%ZW{ny^}s+1TsiapTe+HK^B-B$SHh{fQasIAyNXaU$ZIyg=e|U*A;hduO3{e%dg z6WO?5x&KRYeO_)nN5M!f99T9oaTy?|I@|7@Y=_P2Ez%|meB3fP+)|`Z-8#AaS`41sl+W?-e)XeeHAwg==AGW&}+>#jL_I<)6Gw4+mvI~dod4eN~?*lZ@ z7P7YB0Q%;X*Nw`;JSx-TXn}e23M~KZ_v1Nw;eMURL+CS}yoNJ}to55D3Ur80U2B5} zXy0oJf*!91L!}6m)?V4*j7PFEQd)Y(q3%G}jW+xN6<2RE=_Zz4LGrx{j$CU;!BJGB zu3GOzkD^vG@kqP2ey<>lqB!fS}U}4Z{Hyn zK_(gl#`l`6f(&wjBi@$#6-)6QY6QtqoPoE7Tq_PbmxyONp9A=>`5}+G?WY)3&HNeO z3iOQ3J!Waa+NO58LmJ^X zN*5`{AhJ{)U-%Kcal8P8Z5ee@ju!s9>=Y&~o%D*WU)k}p5fjBl%;?u9XuF0ED6b^h z_y@0MH>~Fn{*$MMfP+af(~|()%531If?sR&#sjJJXi;Epq9ev0%sCjF7D!})z_^5) z)YA;Sbz}{>?$SdN$R`h+Z_&iOXkki`*@R}sc8PQ4011lsV~aeT>PV@j2-_KuzwS%F z8n*^Hke_$w+qsviJ>_^$LAZmoQOWk50p0I}SD?<8zp10rU+sZSP;vg_F^QK-N>_4e z!N~-7skoSZ{ao|M6X7PTu3%mrJw2Cd|L;a$p??s*qvpk~=s0o5<-cA#-y$2`%oO#D z@m{6C8$Sb(T=JBJqG!Ca>2g8c4b}pkPs@yzTS2uqs(nAN#pjdLN4V!$idxb$vk91i zAOV~^Y3|MsPQ)`2Ao`BLjh!eDooTx4P-a*+@*k~#(F6L*>RitYd~=+TX(+8GEnq`> z|xAFbur|P+QF@?<<&*N=BO^tOcjv@i&z%I68!5VU>?P!p$Xz3 zh++G4=#vHn$M3A;UZ*qiL5~BWI)q~Bfx1EXfJ)`kfEc0{j|BP=FwW-zFOc%z0*e1* zqBJpJD4gpI@Cj{e>feey{)#i%izn0m&*)5VpF250ib{J}QAG;*1rwR5Bz`Mudok|R zddI6y0OmLK;#L$acZ^kd$d}xKmtR=yKNc=Dp?(q`BMGuoUk9ZT#XJJ=N7YiG9CM;J zn0u}gu+g>X4&*SCaYkgjL>3`bz$nR-lR+G)5cY-j1y#rjBpOi3A18CZHR>;K{077l zc(kX60jqhl9M65VAer`nQ)Qb=W<7S7-fAJsqhLa&4xn#1Kwx7lzK_T)mw~I@UhZ~a>#=s91uF6`Xby_} z{c5KGptj5lu)O(gCwQUStKyrhw8@it>oN|j_Fpu`W{bz0ntEM=aM{%vx6;cg z<*W$}Sx~Ko#lDEXw8?k0IF|sL{}u9%8l7+pH5Oy3i=+}O z^^YX<4IQf+`?#)(@|+@GY9AsTcV4sZ`wr8eTH>J`v<_BiwnV0s4iM>Cb)cgBUOtRt zo1=vz&FzG(wRB*2eh@d8cnn<~7sGGhT#wO7u-^rsNc)CYiX3yZxU?tFekMJ3GcmEGet z89aNKvexMr;XjAq5AQ6Ozamp_``#32Gnf#H!3EkmGh$=zvqZhKKQ#i+0j+<44Oq$M zq8XpwLosX@creVfU!exsXFLl$UvIi>W1z%_L{WKqBTsFwHzdnXYt5j1J(L}ms6^95U z)4UBVFV)3r*j1{lf1d9OW*i#~dErkUVeuxny!zMN{7ZdrO^f$c+m?H} z@Q)ogLcfLm^2(iRAt5Z%X>Z&lB?WO$pDq<0^&tuX7`0Wz~k!aGGU9DbjZJ)D) zf^Vqr?uhksqDpbsr5`zSP~Vh`yDqYi`W~iK^8eFo+V*-1v5{j_O&2Ld0oZ{3Dd_ks5f!p@5-_t9 z*ww)hvbcgJPvVSmNJ>hATO`;gzF%cnMCJWz(9^ovdj}e^F0jckeoKD(p#%IARvK`$h@~Fbdskqv`Ao#3{V;G& zX17yx1jK(mS|cDLfmXO=*70}9dP~I(jg?+$Llmb(_Rw1Tc*FE%`f}?~nhDLrrFd8P zUx&)HKI#?R(<_=&daop3c(A0)QOd)ePMuh7AKp*Zfl82kVtETE0!tGqHx{?$l97^% zQ^YwN-#W|nTzRwUtdWh32P_JA<6w43oj~1J8Q;HS9Cy+p)f|tiili%6)!ze^sv!Q+ zQ60+y#NQ#{;|aYu8o^_hw9MCWZw&(&UYo%`MMy`O)9Dv$Np^Q4ApG7GDRA*mGJx3d z^m$;u-E}KF>{OD{L@S4KCOzmdP9r&5+o*?NKSGaRzVW;7#z#?@Qp%5v0EtXQ9qfd$ za{pL{j&{e||DM?Vlx-ZdZ$THF8J)@SM7V}vFMtb3I()yMopnXyXhnlIw67NenwJwG z9`JkGo;Wg^;9@2Xd(h)4QNyQ(T}TSZ6(wJ=XfXujhO+X)^)&YLAS228E56R~qjW

8D-#@9^fI+soZV{V3Tc{g(W0JfnW7>eXo3I1iH>kQHX69 zpS?<=2(bsWI*HeS|EX|m^Y=w9wboHacy`E#eq-!{X6ecQwVEUd;axdqriZW%1lk0MTqQKnZr4$1A&1Onm|7>xiWc&^|6DY-IW8YZSE@Wq zpB^iQolEb`&33;WreB)6VvmgK@S;w9Jl#UX>I)*#?|>%BrRGNjG^nZ45w z)fkFPqO}M7+rBJ%`7T8$S;wS}m^W|<3%}-?39X0qt`l9dfsPwGubgu8IrSm3_uA#X zGXB;~Ger%>mK{Qdh;u{X=>8SxSBg&_F)n(d*U|UNJ#U@3W&?mUonnlHA-XYCqB3~T zw$xP0H>ehQ3kD!R=;YJh2mXQrVDP@W2+nT+D#!FWe{>qJsj94=p?=K(I>t*vyck*W zHG$Og_SaGWu<-B7FAJt z7h_QbvCGAqEy=)!2!DP--COeK2;GvLs0qvBRZc!z-TrzUR%X3#1D5Q_?DDKjKg1E~-Fdpu$BO^rC zL+g!_mIj2>Ua8)6MbwRla@MGpn%ds8z_P}}@X0pvYkKIpTC4LY3mV{UZuJN8lb<%v z*CN~wzLDr3OrX#?RaKagG*Wf&Ch?upAF;&o%(j1VaP2u$12U?y^hK;;W^=4roKGt& zfIen6s)GeVo&SAL3xG_zp4a5Tzr}=(*GBcCswf+(0hqh^jkwH~2!*KmPo~{K@dNjZ)eL5>|Ms1}N>~NW3n&2yu#2fi=_k@NKpp=5!L4#C!*$tCGmq4)f#7oHw!2fXd%uV& z3uF6hq?B9jEor+mdk{pj)o;2bn)!0bUoc_7uZJtmIT&ZvrW)&j%ED3Yob%|2?z*QG zx>@Ok@>Q`-gp}mX@g(ictw4dY8#p&+OG7M~Z~&M5Xo&klSaR;J;(9*nzL?3pXz52y zWvz{~TMMkf(9+jauJ+Nj1kcb7gOL@ tLabI;GHW$hezfE=2Ss+2bK28XVWyl-- zB&^V4O_gAZO2Wvn;lx|j-V-XuB1DLR;ugOY6&{;Sqx?8VCn0KtN}~ zjTavB+|^WEz}sIMY=BwH8_yw6S_xhy2v^d@U0rw`mL5{M^NgY@?}2UhtTMV%9t=sx zifXfHW+H!UPeUu6?xabO>1n1ug5v65#ms5*uR~sMAVVLxj#3$0{^nVv@F`pb9@lQE^An0Ncd9lo;n$i6H}qk3zJB2#V#OINL;lSt0R8c>W21_726|D zgzRiL!5hTBJT*AQ5TF=QA972l&|f%ZNz45Aeuf9cju@q}A2bPBFVyT>_dNs?hB_}f z`v(Qm7*Ye^J&!_JTve(=UGG)d*iI=l z3wXUohE(FM#x=EfoDYJjO^2a`8jxJ3dX(4@Ny^34v6}VaM8;mAM zZqFK$kKEn0rK~-r5GC_w;fUh?Ce3`|cCw1X^7z{_vDdf9w`Kp2ljt3Jx1QXyy=1r3 z*fU@9Q4Ym*-hpXR9kgHa`vYWzB=rW{=m_kGoAe?&ZR-d=Y~*YvRa%+NsXj$2axkC^ z(L%JOWnbtAPG57N|J3m)*9ke!Q#lM;c>~CzRsC93NLPA%eAZPFxy^Vq$pl-MxaF6N zwZGC4YcsFb%Hnwz$~jx`*ha@fQ55E9r@IuHf3KY=N!BQT|s0= zuacq;96%V{*;b+-A^8Ws0u%LCe%nj?>vs{CZgC+4XcF-IyU#>nfyItch*0#_uiwL0%fTzB-L6P-rMnT$) zSy8>=qfF9vczmNqva!z^17iHE{gNsuyj`+kb*hra6hM8xCQ3BFWe3=}Y3G%G|0Pk~ zC=30MAW|6UU?*NCR{23y)Ka7~>;&w8AeZR(^*MZnP!#C~*%o6ZXa}d-f%D~51vci` zR#|N<)PC%=LYKzDq#BrNrYKt3%46bgU^H=f|3LMf;xTK;Eel56c>Kw7 z2(=%xux13CbFRNcKbENrdX^n?#r5vf{ayAV-`85X7C7WC9C%|m_n+Tpq(byvfe<9ah#<2jMT;Uv zK9*3%0>Sko-DB#h^QIOa0(N-BGSpnfh&CPKOAcUbHX~7ECjX|dg^jVx))LDQjV$8r z-raU2UD27PB*aZPJuH<4QqOsGmoZY8&s7Zr3$CwU##+^h zU4zpC-+AUDCnX?&700F>-{wK>3qmnsz7}t^9GoA=cR;%7sL(1lFu(7l9WtW zc|&oY_CfLYuJU!At5XTbM|4-Phs8SGIo*%kG95mK5iny4c@WK&&6?n@4Of-Yg`o^y zg<3_JihIYrsosDK9Z5x)%Ytb{7z^eS^HTC>#5tM1M`_Bnwg&2K<9Z5UcTuZnJm6HZ z2J;d?w(6q!ru3kKj$Kvom$?y^pdvbKzh)_@tF8HzkJ|ot!TXDmBwwg=MFHH2<}3>o|uHj`q;%D~?Eac-{ryT~6g$Aj698Vb%Ee`8j|e zwY_FtoDbG+#-hW(B&9688g77~wuHrDMdNNnomSF)#+)PVic`SP)t}x}fAWl4VJ`SF zHak$rUejZvFZoYY&l?d~;Xq@t<>wD}Q1ig^4_o(Ako_mQ)#xK3^L8(&_YH0UDbPG2 zkvB-PVCXQh2#B%L*E@QO^tzn6xu_y$XJhWH9X$lF-K>eb(`O=w>hI0W^s-yRjDbxY z8Ch&&>?~lnxaz_~#KCA3rJ{C(mR0!qf%2G1*Hs;gFP{I{z>5{htFj?N1q31*^>9rW z!&u}H(0{uoEy{g=cPaPfLR#gd0{C1)F}L>S0d6-{srl--@d1^rA!TNp))`xiVb=n* zHU{Eeqji`Hmk89S02*6v3IUf`Ilo~X{!7(OO&uaO6y9tFxu>Qz@2UxfQ6Uh_D@Rfm zn~u0@fQoQ;nP^W~K}HA2JZv**lJxl_?f{6<-olh{N-WKHf_h1vl9YYhS5Dc725Bij z6Y=$CpGr}mjZVsUaJVOwtzNA|9p3!#eJX(fAyFtMm|vr5sOm~m@>takVh7TvAHCH- z0&msZe5{Os-5o#vq^$AzUxja(B+O}|<9EW(wVW!4{@dm*zbH(jvh?v(dNM%h;;IVw z7A24o_)^zM)TLV30wsPIrTMs;H=Z)*giGEONX6Ht5A#Y_xcO_{&ay{*#5t+t(M22f zOx0Ilhh35AMG^2mq=V;zgEpD#RAl5Yx&C%7d))JN(5W z6+Lb@`UV|ysx%5c>bvor#@6T>^JmOTKk*O#DEeE%LiX~)e6wKJ>ajvUH-*kUqn$eoV@T<10nB%O<23t2_Wv~~po)N{yNDO0 zyh(VcAN5C2x@Q^Rsg2U=NtpdP60>#`m11f0$N>~zxt0uL&eNTCICCd;s2rql_~wk}4j%dS4R}yoUWg@U*dJY6OY$&+L42 zINk`CXu_@wIc#~DY2oFh+kWnv)PJ0*$4NrZBcMM?d%wk{>y!z>X`~EOw^eohG;of~ zMZV=9(pmN*PUNPp1tEp7?m0P*;~>%@SQJ5chj{&VZl_i1E!67{sX+4t`p^He`bTtO ztce_{K|JHf`x+x;&D!!*3D#pJ02NINVVbsmo$oN^+Fc&og%euA-(agUQ;~AjS4op{ zpViwbv$CuycfZEx90fCq9B3-5-$bhBYMBbhkNgw`xbSX@y>f&6_eK0NHVf|&FBzUK zP;!3xoE&!Zv1ZzO$EL~an&L-@6J#sBOz67iZIDB%|0`KGRzr!DZwi+k@UjeL>kymZ zWXR!lmGNw8ckoG@tesS)u;B{eS{wdJ;$EgZav{HP#uo+8v5%OixLIXQs~xgS>4+1;e0$!IDi+wk?U&8=G@@r3e2A&-p$giwMp~g2kIm8^MffmiO>S z8D?dh9{K?)xOq#%ikf@~eNtr_d1|P${%v%*jO+HRUOzz?25&2AQ<@wAbmV%{pOpV# zMp!^IQ+*@@aqP(n(-l*w1vVR0FxLAatSLOYi|n zx@QxKr;blR^84EuNA;hQg{i)vb8a7Dk}H}-Ay-H9G|riM|_x@MWLLTrc2T_$&QL*`OY$HXU$mPlP`Vv*hne(W?d#n4FWn z+9)X_;BRnV^??FMVi2I)S;M-AB!hIeE;U(1Fa00!%Wk}Sip4Gg5Y1GKXP^#JAuvtd z3Vo__(~|Qe!k6bOVx24G9o_04SQ`W^QslRAE$kC__l^!UF+*@Zh)Rvwxa84rLM5)7 zdHE#dFB$oNY_<9caAgebK)ES-S{$Wyz}`aqacB(WK;Z+_O&Y(zA6u}7dedT}P9(+; z&4>t9(iJU_Rn}$rl5-YQZX!BILIJZH+;_)Kps@*fgGKIc$WpXou3v(eSI8I&mpM`X zjKUTVy(?d#fmJJR(%DS~^^CLg9d5++z6o9n$Z1iGh{$5_GSs|X6-G6j!Ecs>y2Xju zpqtD9_3K$i!9Q&Jd4|7PDg(kMOpwwT{j*CFM1{YZpJBR*1jK$B0#C2)dGLKdcvzGj zqIo;j0R*{eH&}g_3^5UHFjpw3Ri>RE|4@lZUpCeQBz>>vU>6K)DM+pe=l7K(jd#SV zp%YA_xpnD4#5QV9V_n}BLZi(%__kPk?$GfS6X*hNZe)*=CMlolu39KkJHD ztlfS}{}!%nQXTYr2#NwNUIW<-00Nio#&Za8UP1gWogg^Gs*1ZkPDC) z?9QXiUQObo;udm!AMuI@v;EmAzb?2C4M`qT;EB|UTw!Ce>(oz%ZD#LNZkKLmAMw2b zzr`AZI1wnM@~w9lfc~wt6a_HxiIar`7^f^+YXo z(-)dWX-9&h1%Q3cKJ~B0o64d$s2f(zKYg?0iiCR-@w}S-S^%T>2!%*>kBNt!ZsT80y{aXl9 zN4eFQ1ZT~j=>0O&^pR-N*~4cDJ+3%yp-s6!Q-efic6yV zR8sr-c3?|--0uVVXL za`a`pV*(Uqo7HZx*>30GAwfG0r6Lq%Qn*CxnRitx2mF?_oK{&_$4b4LI)-dy6u6_V z+ig309R^3qOy>JA#NX}XWaAy1p?J;uxa~p@pP%qfCZ@MkHc&2nd3 z*#kXR37>aWH$h=RrO#WXwZz4T{O_TjQ&0YzMjXbqbnGO2syE!?3Q`P_(=tS8vcqHm zQh^W%5xNbhOf)50?n;Z4@4FvB^g_eFAo4~rvCVl@X%q+n4jsm8B}=Q zWt?(&rnO}j)+VoI0O1GV3=aRmI9Z72W?PSV+{FL=dDeCH#}pnI=Lhy%WKlap1VRLy zFz53hH%6-9&G%1{X>%EQuUw(n6Ylae__ZYYrn)D@EC`AunaCmLvJi4py*E_ z1G;ZWfUm<#sRPm2#n01KKhIe-7D`?%53uL-?Wng`P( zYyQ0hRAwjn+u00{p>;i8INw6y#@UU*zMx+-H8gEK53>5_2cinorT`JHBq32%Y709S z-@ciRt=a8M-{CDQF7ikT%xb`LjKDb;Jh zwHB_u+B2IA22c-WM5dGvf+xS9&vYb+#ABGU#turiE0C#cjcSxseoTNXD=~d)udu=U zYa%;amr^$wvi+lhAP8Cc25qkW2df5PNIDrr#Dj<&IY`}hvXTJXgTrkbLaMZ~i$iM} zW`L$p&#WqD!baHZh87o*p6OT=;ByYo;P+h}!Ya^4jM#TtqAje8<;%tT4wXALi+MUb z)3;lk)x#9GrDYpRh((p&vRHG>fuMzjN#UpVr7@tHYGhay@)?y4I zrV+5P^-q6QIAv+&z7+Ov_wGFL5fMeY2yiw6!s`@tuqT`2#XuV1VPAzvI)6}{x(Q9a z=?J?%4L*}9G?ocgQe1GY`-5dVb5m6?d{Fir*b-~P0*5vpWrpb*s+IzCVQxy&VHl#O zaS0XLe2Nl-b5D=Y+7a)?J&?}uX1DG726H4_sdwgmQq><`JbO%4lu(4;~ zCVPE7h=iRI+-)9r1phl1?NQ?=50WH<3m%jfaxR;uxXBnGFh&k z*c2(^(zmj}tQ#F4qQoUsn zo3mT0&UOjF77rQMs2rTx$%(?U=S2++q*r@)KWn`i2~wUbk5b92rpt9W7LK-Kpj4NS z&umKs7UO+I7Wozc!sbYBG60R5C1F;4YHY5F6?0V3c1_32#3Oup>d6C#9=1AK_#&w5N(xLqUYqNn+QIkm7W5Xaa-~Cj{ zz~~Z!qK0nf=0r&@N|Y0^@4uh>uZ+j~wBY!;#w`fC_Yw1ShWH8%4%FO49?O$jXr8)c zeoy)CutWy<%^KC?xDOBgINYM=f13RrWN8`)2o%vTzW?^>G2@7>|1eXu^4-CpR zm3`c>)W}-gyrpZTU+V>>j#4@x*MrVsX@bAQCi7MCK>8UohKE}0TYNCcnx0=DoE}vq zo(0ftD4Q|q2Rj^g(wZ4Sio!gTSIyMRf=;w&iF}>88Qt=Ri(1(Y{ge!g zbATJx2-Hz3m2P#3vv9<^{j5{YRf5&G z2oWcN--2R+gHxlHB?J4UtmG?GPj5BxBT&gS)RPC22`GyyKeQ=O!7 zfFy4~qo=`^On=SeLJ2Cbfc#cr0#ze5+;Ww;VB%h;wuAy{L#<>Jh`$uy3BLjoF~m#v z1!G{+#HozYSOCJIwg;=!oXphCv60}B=Lxzj>l_Q4{s0#T0e$XQ3iN0l*&x!6@j~qs zrm5uhekv40rLbTFSAs*n%IB833BI{_Z4B8eLTu3!ff4SYlsJ$R7M0o{Z@!^3@VN;^ zXOHP?uajNkY(g;kysqwiCu*Vw-B0j2I;!cZ9Gu2<|AD)l_ST^~#44&mGrvw{_ zse}=gs}U^K{T+Lc5*r-lP80}1f8Nbi`4n76FPYRun blender --background --python cross_version_property_delete.py -- --output tags.png

--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.

Source

diff --git a/docs/gallery/curve-bevel-arc/index.html b/docs/gallery/curve-bevel-arc/index.html index 8e652c9..95d7927 100644 --- a/docs/gallery/curve-bevel-arc/index.html +++ b/docs/gallery/curve-bevel-arc/index.html @@ -265,11 +265,16 @@

Run

# 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

return 0 -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 = 0 for 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 @@

Source

con.name = "AimCore" con.target = core con.track_axis = "TRACK_Z" + if mute: + con.mute = True needles.append(ob) bpy.context.view_layer.update() @@ -656,9 +668,11 @@

Source

choices=("eevee", "cycles"), help="render engine when --output is set", ) + p.add_argument("--mute", action="store_true", + help="mute every DAMPED_TRACK (must fail)") args = p.parse_args(argv) - core, needles = build() + core, needles = build(mute=args.mute) code = check(core, needles) if code != 0: return code diff --git a/docs/gallery/degenerate-bevel-weld/index.html b/docs/gallery/degenerate-bevel-weld/index.html index fc1de04..b785a9f 100644 --- a/docs/gallery/degenerate-bevel-weld/index.html +++ b/docs/gallery/degenerate-bevel-weld/index.html @@ -268,9 +268,13 @@

degenerate-bevel-weld

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.

Run

blender --background --python degenerate_bevel_weld.py --
+blender --background --python degenerate_bevel_weld.py -- --both-safe
 blender --background --python degenerate_bevel_weld.py -- --output bevel.png
 blender --background --python degenerate_bevel_weld.py -- --output bevel.png --engine cycles
-

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, + ) if not (os.path.exists(out) and os.path.getsize(out) > 0): print("ERROR: no OBJ written", file=sys.stderr) return 4 @@ -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

return 1.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 @@

Source

obj.scale = (BASE, BASE, 1.0) fcu = obj.driver_add("scale", 2) fcu.driver.type = 'SCRIPTED' - fcu.driver.expression = f"wave_scale({i})" + fcu.driver.expression = "1.0" if flat_expr else f"wave_scale({i})" bpy.context.collection.objects.link(obj) objs.append(obj) return objs @@ -471,9 +480,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("--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.

Source

diff --git a/docs/gallery/gltf-export-roundtrip/index.html b/docs/gallery/gltf-export-roundtrip/index.html index bbe9d62..a3ba3de 100644 --- a/docs/gallery/gltf-export-roundtrip/index.html +++ b/docs/gallery/gltf-export-roundtrip/index.html @@ -270,10 +270,16 @@

Run

# 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 @@

Source

check. Pass --output to also render a still: blender --background --python gltf_export_roundtrip.py -- # check only + blender --background --python gltf_export_roundtrip.py -- --no-yup # must fail blender --background --python gltf_export_roundtrip.py -- --output c.png # + render """ import bpy, bmesh, sys, os, math, json, struct, shutil, tempfile, argparse @@ -527,7 +537,7 @@

Source

# --------------------------------------------------------------------------- # 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 not in 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 @@

Source

assign_weights(obj, part_of) arm = build_rig(obj) bpy.context.view_layer.update() - code = check(obj, arm, part_of) + code = check(obj, arm, part_of, no_skins=args.no_skins) if code: return code diff --git a/docs/gallery/gn-instance-grid/index.html b/docs/gallery/gn-instance-grid/index.html index 8dab5d8..aa5d168 100644 --- a/docs/gallery/gn-instance-grid/index.html +++ b/docs/gallery/gn-instance-grid/index.html @@ -265,10 +265,16 @@

Run

# 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 @@

Source

grid = tree.nodes.new('GeometryNodeMeshGrid') grid.inputs["Size X"].default_value = GRID_SIZE grid.inputs["Size Y"].default_value = GRID_SIZE - grid.inputs["Vertices X"].default_value = GRID_X - grid.inputs["Vertices Y"].default_value = GRID_Y + grid.inputs["Vertices X"].default_value = grid_x + grid.inputs["Vertices Y"].default_value = grid_y cube = tree.nodes.new('GeometryNodeMeshCube') cube.inputs["Size"].default_value = (CUBE_SIZE, CUBE_SIZE, CUBE_SIZE) @@ -353,7 +364,7 @@

Source

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 = 1 if 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) if not ident: print("ERROR: Scale input identifier missing on the tree interface", @@ -476,8 +486,9 @@

Source

return 4 for 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 else None tree, link_valid = build_remesh_via_sdf(material=src_mat) - obj.modifiers.new("sdf", 'NODES').node_group = tree + if not 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 is not None] 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 + + + + + + + + + + + + + + + + +
+ +
+

gn-socket-rename

+

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
+
+
blender --background --python examples/gn-socket-rename/gn_socket_rename.py --
+ +
+
+

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.

+

Run

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

+
+
+

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 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)
+
+
+
+ +
+
+ generated from examples/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/docs/gallery/gn-zone-iterate/index.html b/docs/gallery/gn-zone-iterate/index.html index 07a80c9..168d13d 100644 --- a/docs/gallery/gn-zone-iterate/index.html +++ b/docs/gallery/gn-zone-iterate/index.html @@ -271,6 +271,10 @@

Run

blender --background --python gn_zone_iterate.py --
 blender --background --python gn_zone_iterate.py -- --output zones.png

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.

Source

diff --git a/docs/gallery/gp-lineart-contour/index.html b/docs/gallery/gp-lineart-contour/index.html index 7995916..2b31828 100644 --- a/docs/gallery/gp-lineart-contour/index.html +++ b/docs/gallery/gp-lineart-contour/index.html @@ -268,10 +268,15 @@

Run

# 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 @@

Source

choices=("eevee", "cycles"), help="render engine for --output", ) + p.add_argument( + "--no-contour", + action="store_true", + help="falsifier: use_contour=False, still assert True", + ) args = p.parse_args(argv) print(f"binary version: {bpy.app.version} ({bpy.app.version_string})") sc, crystal, la_ob, mod = build_scene() + if args.no_contour: + mod.use_contour = False + code = check(sc, crystal, la_ob, mod) if code: return code diff --git a/docs/gallery/grease-pencil-rosette/index.html b/docs/gallery/grease-pencil-rosette/index.html index 50ff5a6..be6b95b 100644 --- a/docs/gallery/grease-pencil-rosette/index.html +++ b/docs/gallery/grease-pencil-rosette/index.html @@ -267,10 +267,16 @@

Run

# 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 @@

Source

return bpy.data.grease_pencils_v3.new(name) # 4.5 LTS: GPv3 lives at _v3 -def build_rosette(): +def build_rosette(open_strokes=False): bpy.ops.wm.read_factory_settings(use_empty=True) gp = gp_data_new("Rosette") layer = gp.layers.new("Ink") @@ -355,8 +366,9 @@

Source

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 = 0 for 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 += 4 return 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 @@

Examples Gallery

autocomplete="off" spellcheck="false" aria-label="Search examples" /> - 50 examples + 51 examples
@@ -873,6 +873,17 @@

bake-normal-high-to-low

View example
+
+ + gn-socket-rename — 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 + +
+

gn-socket-rename

+

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

+ View example +
+
diff --git a/docs/gallery/light-link-studio/index.html b/docs/gallery/light-link-studio/index.html index c333e86..4944885 100644 --- a/docs/gallery/light-link-studio/index.html +++ b/docs/gallery/light-link-studio/index.html @@ -269,9 +269,15 @@

Run

# 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

return 4 # contract 1 (assignment round-trip through the API itself) - key.light_linking.receiver_collection = hero_c + key.light_linking.receiver_collection = None if 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 @@

Source

argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] p = argparse.ArgumentParser() p.add_argument("--output", default=None, help="optional: render a still PNG here") + p.add_argument("--skip-link", action="store_true", + help="falsifier: leave receiver_collection unset, still assert hero_c") args = p.parse_args(argv) bpy.ops.wm.read_factory_settings(use_empty=True) sc = bpy.context.scene hero, decoy, hero_c, key = build_studio(sc) - code = check(sc, key, hero_c, hero, decoy) + code = check(sc, key, hero_c, hero, decoy, skip_link=args.skip_link) + if code: return code diff --git a/docs/gallery/lightmap-uv-channel/index.html b/docs/gallery/lightmap-uv-channel/index.html index d98f5a7..5f7f2d5 100644 --- a/docs/gallery/lightmap-uv-channel/index.html +++ b/docs/gallery/lightmap-uv-channel/index.html @@ -272,7 +272,10 @@

Run

blender --background --python lightmap_uv_channel.py --
 blender --background --python lightmap_uv_channel.py -- --output atlas.png
 blender --background --python lightmap_uv_channel.py -- --falsify overlap.png
-

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.

Source

diff --git a/docs/gallery/lod-decimate-chain/index.html b/docs/gallery/lod-decimate-chain/index.html index 100b595..6a92508 100644 --- a/docs/gallery/lod-decimate-chain/index.html +++ b/docs/gallery/lod-decimate-chain/index.html @@ -269,10 +269,16 @@

Run

# 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 @@

Source

return mod -def check(rocket, lod1, lod2): +def check(rocket, lod1, lod2, no_decimate=False): me = rocket.data want_v, want_f, want_t = closed_form_counts() got = (len(me.vertices), len(me.polygons)) @@ -507,7 +518,8 @@

Source

measured = [] for lod, ratio in ((lod1, LODS[0]), (lod2, LODS[1])): - add_decimate(lod, ratio) + if not 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.

Run

blender --background --python mesh_hygiene_audit.py --
+blender --background --python mesh_hygiene_audit.py -- --inject-ngon
 blender --background --python mesh_hygiene_audit.py -- --output hygiene.png
 blender --background --python mesh_hygiene_audit.py -- --output hygiene.png --engine cycles
-

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 | Ngon present (--inject-ngon lands here) | | 4 | Loose vertices | | 5 | Non-manifold or boundary edges | | 6 | Zero-area faces | | 7 | Signed volume ≤ 0 | | 8 | Euler characteristic ≠ 2 | | 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 --inject-ngon.

Source

@@ -297,6 +301,7 @@

Source

to also render a still: blender --background --python mesh_hygiene_audit.py -- + blender --background --python mesh_hygiene_audit.py -- --inject-ngon blender --background --python mesh_hygiene_audit.py -- --output h.png """ import bpy, bmesh, sys, os, math, argparse @@ -859,10 +864,14 @@

Source

"--engine", default="eevee", choices=("eevee", "cycles"), help="render engine for --output", ) + p.add_argument("--inject-ngon", action="store_true", + help="dissolve one edge into an ngon (must fail)") args = p.parse_args(argv) print(f"binary version: {bpy.app.version} ({bpy.app.version_string})") sc, ob = build_scene() + if args.inject_ngon: + inject_defect(ob.data, "ngon") code = check(ob.data) if code: return code diff --git a/docs/gallery/modular-kit-snap/index.html b/docs/gallery/modular-kit-snap/index.html index 00c0916..142766f 100644 --- a/docs/gallery/modular-kit-snap/index.html +++ b/docs/gallery/modular-kit-snap/index.html @@ -272,7 +272,10 @@

Run

blender --background --python modular_kit_snap.py --
 blender --background --python modular_kit_snap.py -- --output corridor.png
 blender --background --python modular_kit_snap.py -- --falsify seams.png
-

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.

Source

diff --git a/docs/gallery/parent-inverse-orrery/index.html b/docs/gallery/parent-inverse-orrery/index.html index f0c45c7..c29a139 100644 --- a/docs/gallery/parent-inverse-orrery/index.html +++ b/docs/gallery/parent-inverse-orrery/index.html @@ -267,10 +267,16 @@

Run

# 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() + if not 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 @@

Source

moon = sphere("Moon", MOON_R) moon.location = pc0 + Vector((MOON_OFFSET, 0.0, 0.0)) bpy.context.view_layer.update() - parent_keep_world(moon_pivot, host["planet"]) - parent_keep_world(rod, moon_pivot) - parent_keep_world(moon, moon_pivot) + parent_keep_world(moon_pivot, host["planet"], skip_mpi=skip_mpi) + parent_keep_world(rod, moon_pivot, skip_mpi=skip_mpi) + parent_keep_world(moon, moon_pivot, skip_mpi=skip_mpi) rig["moon"] = {"pivot": moon_pivot, "moon": moon, "angle": math.radians(MOON_ANGLE), "pc0": pc0, "m0": pc0 + Vector((MOON_OFFSET, 0.0, 0.0))} @@ -603,9 +615,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("--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.

+

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage; also closed-form palette no longer stresses unpremul | | 3 | float_buffer=True image reports is_float=False | | 4 | Float→PNG IHDR is not RGBA16 | | 5 | Float→PNG RGB error below floor (--opaque-alpha lands here) | | 6 | Float→PNG disagrees with closed-form false-unpremul model | | 7 | Float→EXR round-trip above tolerance | | 8 | float_buffer=False image reports is_float=True | | 9 | Byte→PNG IHDR is not RGBA8 | | 10 | Byte→PNG disagrees with straight-alpha 8-bit model; also gallery framing violation | | 11 | Byte→PNG stress cell looks false-unpremul-mangled | | 12 | EXR color_mode=RGB alpha-drop contract | | 13 | --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 --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

return None, 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 @@

Source

img_f = new_float_image("FloatSrc") if not img_f.is_float: return fail("float_buffer=True image reports is_float=False", 3) - orig = fill_pattern(img_f) + orig = fill_pattern(img_f, opaque_alpha=opaque_alpha) png_got, png_path = save_and_reload(img_f, "PNG", "png") bit, color = png_bit_depth(png_path) @@ -936,9 +945,13 @@

Source

"--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.

Run

blender --background --python prop_origin_transform.py --
+blender --background --python prop_origin_transform.py -- --skip-mpi
 blender --background --python prop_origin_transform.py -- --output origin.png
 blender --background --python prop_origin_transform.py -- --output origin.png --engine cycles
-

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 @@

Source

return before, after -def check(prop, acc): +def check(prop, acc, skip_mpi=False): """Assert origin/scale bake + MPI accessory contract. Exit 3–8 on failure.""" view_layer = bpy.context.view_layer @@ -622,8 +630,9 @@

Source

) return 7 - acc.matrix_parent_inverse = prop.matrix_world.inverted() - view_layer.update() + if not skip_mpi: + acc.matrix_parent_inverse = prop.matrix_world.inverted() + view_layer.update() err = (acc.matrix_world.translation - w0).length print(f"mpi_restore_err={err:.3e}") if err > MPI_EPS: @@ -889,11 +898,13 @@

Source

p = argparse.ArgumentParser() p.add_argument("--output", default=None) p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) + p.add_argument("--skip-mpi", action="store_true", + help="parent the accessory without MPI (must fail)") args = p.parse_args(argv) print(f"binary version: {bpy.app.version} ({bpy.app.version_string})") sc, prop, acc = build_scene() - code = check(prop, acc) + code = check(prop, acc, skip_mpi=args.skip_mpi) if code: return code diff --git a/docs/gallery/shader-node-group/index.html b/docs/gallery/shader-node-group/index.html index a093cfd..17c8444 100644 --- a/docs/gallery/shader-node-group/index.html +++ b/docs/gallery/shader-node-group/index.html @@ -265,11 +265,16 @@

Run

# 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.

+

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Interface sockets missing Tint / Roughness / Shader | | 4 | Group datablock users ≠ 2 | | 5 | Instance points at a different node tree | | 6 | Instance Tint values identical (--same-tint lands here) | | 7 | --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 --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 @@

Source

obj.location = (-1.35 + i * 2.7, 0.0, 1.0) for poly in me.polygons: poly.use_smooth = True - me.materials.append(material_from_group(f"Mat.{name}", tree, tint)) + inst_tint = TINTS["SphereA"] if same_tint else tint + me.materials.append(material_from_group(f"Mat.{name}", tree, inst_tint)) bpy.context.collection.objects.link(obj) objs.append(obj) return tree, objs @@ -478,9 +489,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("--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.0 if 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

return 0 -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 it for poly in me.polygons: - poly.material_index = 1 if len(poly.vertices) == 5 else 0 + pent = len(poly.vertices) == 5 + poly.material_index = (0 if pent else 1) if invert_bind else (1 if pent else 0) 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 @@

Run

blender --background --python socket_attach_points.py -- --output drone.png blender --background --python socket_attach_points.py -- --falsify adrift.png blender --background --python socket_attach_points.py -- --probe -

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, edge90 0.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, edge90 0.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.

Source

diff --git a/docs/gallery/swatch-grid/index.html b/docs/gallery/swatch-grid/index.html index b89f5e7..dfcac1c 100644 --- a/docs/gallery/swatch-grid/index.html +++ b/docs/gallery/swatch-grid/index.html @@ -266,13 +266,20 @@

Run

# 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 @@

Source

TOL = 1e-4 -def build_stamp(): +def build_stamp(wrong_body=False): bpy.ops.wm.read_factory_settings(use_empty=True) txt = bpy.data.curves.new("VersionStamp", type='FONT') - txt.body = bpy.app.version_string # the self-documenting payload + txt.body = "not-a-version" if wrong_body else bpy.app.version_string txt.align_x = 'CENTER' txt.align_y = 'CENTER' obj = bpy.data.objects.new("VersionStamp", txt) @@ -565,9 +576,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("--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 @@

Source

strip = layer.strips[0] if layer.strips else layer.strips.new(type='KEYFRAME') return strip.channelbag(slot, ensure=True) -def build(): +def build(no_keys=False): bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.mesh.primitive_monkey_add(location=(0, 0, 1.0)) obj = bpy.context.active_object @@ -326,11 +338,12 @@

Source

slot = act.slots.new(id_type='OBJECT', name=obj.name); obj.animation_data.action_slot = slot cbag = get_channelbag_for_slot(act, slot) fc = cbag.fcurves.new("rotation_euler", index=2) - fc.keyframe_points.insert(1, 0.0) - fc.keyframe_points.insert(FRAMES, math.radians(360)) - for kp in fc.keyframe_points: - kp.interpolation = 'LINEAR' - fc.update() + if not no_keys: + fc.keyframe_points.insert(1, 0.0) + fc.keyframe_points.insert(FRAMES, math.radians(360)) + for kp in fc.keyframe_points: + kp.interpolation = 'LINEAR' + fc.update() return obj def correctness(obj): @@ -400,6 +413,8 @@

Source

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) if not correctness(obj): print("ERROR: rotation keys do not drive playback", file=sys.stderr); return 3 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 @@

usd-export-evaluation-mode

Run

blender --background --python usd_export_evaluation_mode.py --
 blender --background --python usd_export_evaluation_mode.py -- --output u.png
+

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 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.

Source

diff --git a/docs/gallery/uv-layer-grid/index.html b/docs/gallery/uv-layer-grid/index.html index 4803539..68d0895 100644 --- a/docs/gallery/uv-layer-grid/index.html +++ b/docs/gallery/uv-layer-grid/index.html @@ -271,10 +271,16 @@

Run

# 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 @@

Run

blender --background --python vertex_color_ao.py --
 blender --background --python vertex_color_ao.py -- --output well.png
 blender --background --python vertex_color_ao.py -- --falsify inverted.png
-

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, edge90 0.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, edge90 0.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.

Source

diff --git a/docs/gallery/vertex-weight-limit/index.html b/docs/gallery/vertex-weight-limit/index.html index e1ae726..41099fa 100644 --- a/docs/gallery/vertex-weight-limit/index.html +++ b/docs/gallery/vertex-weight-limit/index.html @@ -269,11 +269,16 @@

Run

# 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') + if not 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 @@

Source

arm = build_rig(obj) bpy.context.view_layer.update() pose_before = eval_positions(obj) - code = check(obj, arm, groups, pose_before) + code = check(obj, arm, groups, pose_before, skip_limit=args.skip_limit) if code: return code diff --git a/docs/gallery/vse-cut-list/index.html b/docs/gallery/vse-cut-list/index.html index 6bd4936..a55ad98 100644 --- a/docs/gallery/vse-cut-list/index.html +++ b/docs/gallery/vse-cut-list/index.html @@ -266,7 +266,7 @@

vse-cut-list

  • *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 @@

Source

tiny render (cell colors and input consumption), the way CI does: blender --background --python vse_cut_list.py -- + blender --background --python vse_cut_list.py -- --swap-inputs blender --background --python vse_cut_list.py -- --check-pixels blender --background --python vse_cut_list.py -- --output vse.png """ @@ -366,6 +377,13 @@

Source

CELL_OX = 244.4 # 28 px crosshair gap, ~165 px side margins at 1280x720 CELL_OY = 143.6 # 28 px gap, ~87 px top/bottom margins XF_TOL = 1e-3 # float32 transform read-back tolerance +# Visual mosaic (A top-left, B top-right, C bottom-left, GC bottom-right). +MOSAIC = ( + ("A", -CELL_OX, CELL_OY), + ("B", CELL_OX, CELL_OY), + ("C", -CELL_OX, -CELL_OY), + ("GC", CELL_OX, -CELL_OY), +) A_RGB = (0.85, 0.10, 0.22) # crimson B_RGB = (0.06, 0.75, 0.80) # teal @@ -567,8 +585,7 @@

Source

# 9. Mosaic transforms + compositing defaults persist on the strips. xf_err = 0.0 - for name, ox, oy in (("A", -CELL_OX, CELL_OY), ("B", CELL_OX, CELL_OY), - ("C", -CELL_OX, -CELL_OY), ("GC", CELL_OX, -CELL_OY)): + for name, ox, oy in MOSAIC: t = coll.get(name).transform xf_err = max(xf_err, abs(t.scale_x - CELL_SCALE), abs(t.scale_y - CELL_SCALE), @@ -598,10 +615,15 @@

Source

return check(bpy.context.scene) -def run_checks(): +def run_checks(swap_inputs=False): bpy.ops.wm.read_factory_settings(use_empty=True) sc = bpy.context.scene build_cut_list(sc) + if swap_inputs: + coll = strips_coll(sc.sequence_editor) + gc = coll.get("GC") + gc.input_1 = coll.get("T2") + gc.input_2 = coll.get("T1") code = check(sc) if code != 0: return code @@ -888,9 +910,27 @@

Source

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 @@

Source

t1.color = A_RGB t2 = new_effect(coll, "T2", "COLOR", 2, SPAN) t2.color = B_RGB - gc = new_effect(coll, "GC", "GAMMA_CROSS", 3, SPAN, input1=t1, input2=t2) + src1, src2 = (t2, t1) if swap_inputs else (t1, t2) + gc = new_effect(coll, "GC", "GAMMA_CROSS", 3, SPAN, input1=src1, input2=src2) return gc @@ -693,11 +704,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("--swap-inputs", action="store_true", + help="falsifier: GC T2 -> T1, still assert T1 -> T2") args = p.parse_args(argv) bpy.ops.wm.read_factory_settings(use_empty=True) sc = bpy.context.scene - gc = build_cross(sc) + gc = build_cross(sc, swap_inputs=args.swap_inputs) code = check(sc, gc) if code: return code diff --git a/docs/gallery/wave-displace/index.html b/docs/gallery/wave-displace/index.html index f46fba2..aeb4d2d 100644 --- a/docs/gallery/wave-displace/index.html +++ b/docs/gallery/wave-displace/index.html @@ -267,10 +267,16 @@

Run

# 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 0000000000000000000000000000000000000000..f55d1e2bc0fbfa28adaadc5f94e022dd9890d548 GIT binary patch literal 6030 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
+GN socket rename: a copper jo-block column on a steel plinth in a dark studio, the column switched in by Compare and tagged by Random Value, warm wedge on the back wall + + +### [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. +