diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 684e8f9..9441059 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -75,6 +75,7 @@ "examples/gltf-export-roundtrip", "examples/gltf-skin-roundtrip", "examples/gn-instance-grid", + "examples/gn-modifier-inputs", "examples/gn-sdf-remesh", "examples/gp-lineart-contour", "examples/grease-pencil-rosette", diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index f698d17..b2bd7ea 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -1,7 +1,8 @@ name: Blender Smoke Test # Executes the snippets' and skills' headline examples inside REAL Blender, headless, -# on the current stable (5.1.x) and the active LTS (4.5.x), and fails on any error or +# on the current stable (5.2.x LTS) and the fallback LTS (4.5.x) on every PR, +# plus 5.1.x on the weekly cron, and fails on any error or # empty-output assertion. py_compile (in validate.yml) cannot catch API-level regressions # like the EEVEE-id inversion, the slotted-actions boundary, the driver TypeError, or the # dead SDF link -- this gate runs the code so those surface in CI, not in users' files. @@ -24,13 +25,11 @@ jobs: smoke: name: Blender ${{ matrix.series }} smoke runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 45 strategy: fail-fast: false matrix: - include: - - series: "5.1" # current stable - - series: "4.5" # active LTS + series: ${{ github.event_name == 'schedule' && fromJSON('["5.2","5.1","4.5"]') || fromJSON('["5.2","4.5"]') }} steps: - uses: actions/checkout@v7 @@ -214,6 +213,15 @@ jobs: xvfb-run -a "$BLENDER" --background \ --python examples/gn-instance-grid/gn_instance_grid.py -- + - name: Shipped example - GN modifier inputs (5.1 dict vs 5.2 RNA) + run: | + set -euo pipefail + # Frame-independent check only (no render): one GN tree, three modifier + # copies; writes Scale 1/2/3 via version-appropriate path; asserts + # readback and evaluated Z-extent match. Exits non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/gn-modifier-inputs/gn_modifier_inputs.py -- + - name: Shipped example - shape-key blend (data API + evaluated mesh) run: | set -euo pipefail diff --git a/AGENTS.md b/AGENTS.md index 2afae39..4d98124 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,13 +14,13 @@ repeats the other. ## Repository overview Skills, rules, snippets, starter templates, and runnable smoke-gated examples -for Blender Python development. The repo targets **Blender 5.1** (current -stable) with a **Blender 4.5 LTS** fallback. There is no MCP server. It ships +for Blender Python development. The repo targets **Blender 5.2 LTS** (current +stable) with a **Blender 4.5 LTS** fallback. **Blender 5.1** is prior stable. There is no MCP server. It ships a `.cursor-plugin/plugin.json` manifest so the ecosystem drift checker classifies it as a `cursor-plugin`. This is content the AI loads when the user asks Blender questions or works on Blender add-ons in Cursor or Claude Code. -The content base is 12 skills, 6 rules, 2 templates, 17 snippets, and 44 +The content base is 12 skills, 6 rules, 2 templates, 17 snippets, and 45 examples (counts are CI-enforced against README.md and the manifest). The full inventory tables and per-item purposes live in `CLAUDE.md`. Example anatomy and authoring rules: copy `examples/bmesh-gear/`; the render look is specified @@ -35,7 +35,7 @@ Blender-Developer-Tools/ rules/.mdc # 6 rule files templates// # 2 starter templates snippets/.py # 17 standalone Python snippets - examples// # 40 runnable smoke-gated examples (+ gallery.json) + examples// # 45 runnable smoke-gated examples (+ gallery.json) examples/gallery_framing.py # shared Layer 1 framing measurement (render path only) scripts/build_gallery.py # generates docs/gallery/ (stdlib only) scripts/site/ # vendored landing-page build (build_site.py + template) @@ -76,7 +76,7 @@ Blender-Developer-Tools/ - **Smoke jobs do not re-run on the merge SHA.** `blender-smoke.yml` triggers on `pull_request` (plus a weekly schedule and manual dispatch) — there is no `push` trigger. The correct post-merge evidence for example changes is: - both Blender smoke jobs (4.5 LTS and 5.1) passed on the PR head SHA that + both Blender smoke jobs (5.2 LTS and 4.5 LTS) passed on the PR head SHA that became the sole squash-merged commit, with the actual binary versions confirmed in the job logs. - **Post-merge, verify green on `main`:** Release (`release.yml`), Validate @@ -97,12 +97,13 @@ Blender-Developer-Tools/ ## Blender version targeting -- Primary: **Blender 5.1.x** (current stable). All examples assume 5.1 +- Primary: **Blender 5.2 LTS** (current stable). All examples assume 5.2 unless otherwise stated. +- Prior stable: **Blender 5.1**. Skills document 5.1-only contracts where they + still matter; weekly smoke keeps a 5.1 leg. - Fallback: **Blender 4.5 LTS**. Skills and the extension template note 4.5 compatibility where it matters (slotted actions bridge, property delete, - manifest fields). -- Future: a 5.2 LTS sweep is planned for July 2026 (see `ROADMAP.md`). + manifest fields, NodesModifier dict inputs). When a 4.x and 5.x API genuinely diverge, skills must show both code paths, not just the 5.x one. The `slotted-actions-animation` skill is the load-bearing @@ -155,8 +156,8 @@ way, and a one-paragraph rationale. 30 to 80 lines is the right size. and the manifest `version` must equal `VERSION`. The release pipeline owns the manifest `version` line (see `release.yml` below) — never hand-edit it. - `blender-smoke.yml` executes every shipped example (check-only, no render) - plus snippet/template smoke tests inside REAL headless Blender, on both - 4.5 LTS and 5.1, on every PR and a weekly schedule. A new example is not + plus snippet/template smoke tests inside REAL headless Blender, on + 5.2 LTS and 4.5 LTS for every PR (5.1 on the weekly cron). A new example is not shipped until it has a step here. - `drift-check.yml` consumes `Developer-Tools-Directory/.github/actions/ drift-check@v1.15` to enforce ecosystem standards-version markers. @@ -176,7 +177,8 @@ way, and a one-paragraph rationale. 30 to 80 lines is the right size. ## Where to look for canonical references -- Blender 5.1 Python API: https://docs.blender.org/api/current/ +- Blender 5.2 LTS Python API: https://docs.blender.org/api/current/ +- Blender 5.1 Python API: https://docs.blender.org/api/5.1/ - Blender 4.5 LTS Python API: https://docs.blender.org/api/4.5/ - Extensions Platform reference: https://docs.blender.org/manual/en/latest/advanced/extensions/index.html - Release notes (`developer.blender.org`): https://developer.blender.org/ diff --git a/CLAUDE.md b/CLAUDE.md index 7647c72..a703c1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -The **Blender Developer Tools** repository is at **v0.46.1**. It packages skills, rules, snippets, starter templates, and runnable smoke-gated examples for Blender Python development with Cursor and Claude Code. Coverage targets **Blender 5.1** (current stable) with **Blender 4.5 LTS** fallback. There is no MCP server; content is consumed directly by the AI when working in Blender add-on or scripting projects. +The **Blender Developer Tools** repository is at **v0.46.1**. It packages skills, rules, snippets, starter templates, and runnable smoke-gated examples for Blender Python development with Cursor and Claude Code. Coverage targets **Blender 5.2 LTS** (current stable) with **Blender 4.5 LTS** fallback. **Blender 5.1** is prior stable. There is no MCP server; content is consumed directly by the AI when working in Blender add-on or scripting projects. **Version:** 0.46.1 **License:** CC-BY-NC-ND-4.0 @@ -21,7 +21,7 @@ skills//SKILL.md - AI workflow definitions, 12 total rules/.mdc - Anti-pattern rules, 6 total templates// - Starter projects, 2 total snippets/.py - Standalone code patterns, 17 total -examples// - Runnable smoke-gated examples, 44 total (+ gallery.json) +examples// - Runnable smoke-gated examples, 45 total (+ gallery.json) scripts/build_gallery.py - Regenerates docs/gallery/ from gallery.json (stdlib only) scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders @@ -82,11 +82,11 @@ v0.1.0: canonical object creation and deletion, depsgraph evaluated mesh, bmesh v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namespace`, application handler registration, shader node group with cross-version `interface` API, `foreach_get` bulk vertex read, version-branch skeleton, and USD export with `evaluation_mode='RENDER'`. -## Examples (44) +## Examples (45) Runnable scripts at `examples//`, each asserting a real API contract with deterministic checks (exit non-zero on failure) and optionally rendering a still via -`--output`. All of them run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`; +`--output`. All of them run headless on Blender 5.2 LTS and 4.5 LTS in `blender-smoke.yml` (5.1 on the weekly cron); their renders ship in the site gallery at `docs/gallery/`. `examples/gallery.json` is the gallery's source of truth. When authoring a new one, copy the anatomy of `examples/bmesh-gear/` (script structure, README shape, dark-studio render recipe) and @@ -100,8 +100,8 @@ cross-example import mechanism (see its docstring). ## Blender Runtime Discovery -- Local Blender binaries: check `.scratch/` at the repo root **first** — some machines have no system Blender install, and a prior agent run downloads official releases there (e.g. `.scratch/5.1/blender-5.1.x-.../blender[.exe]`). Then check system installs. Do not probe blindly; locate the binary, run it, and state the **exact binary path and the version the binary itself reports** in every report. -- **5.1 is the local check version. 4.5 LTS is exercised by CI when unavailable locally.** 4.4 is not a substitute for 4.5 and must never be reported as 4.5. +- Local Blender binaries: check `.scratch/` at the repo root **first** — some machines have no system Blender install, and a prior agent run downloads official releases there (e.g. `.scratch/5.2/blender-5.2.x-.../blender[.exe]`). Then check system installs. Do not probe blindly; locate the binary, run it, and state the **exact binary path and the version the binary itself reports** in every report. +- **5.2 LTS is the local check version. 4.5 LTS is exercised by CI when unavailable locally.** 4.4 is not a substitute for 4.5 and must never be reported as 4.5. - **CI floats within each series:** `blender-smoke.yml` resolves the highest published point release at run time (`sort -V | tail -1` on the download listing), so a local 4.5.x may lag CI — its 4.5 job ran 4.5.12 LTS on PR #107 while local `.scratch/` held 4.5.11. Either way, state the exact version the binary reports. - If `.scratch/` lacks a needed version, download an official release from download.blender.org into it. `.scratch` is gitignored. - In scripts, version-branch on the `bpy.app.version` tuple, never on `bpy.app.version_string` — it reads e.g. `"4.5.11 LTS"`, not bare semver. @@ -127,7 +127,7 @@ Stage with **explicit paths only** — never `git add -A` or `git add .`. Cursor - **Asset-sheet gate (asset-type examples — game props/kits):** composite the hero asset rendered alone (neutral three-quarter view, plain studio lighting, no staging tricks, no labels, no comparison props) beside the pinned asset-quality reference set — currently `collision-hull-proxy`, `custom-normals-shade`, `vertex-weight-limit`, `lod-decimate-chain` — rendered the same way; commit under `docs/gallery/asset-sheets/`, link it in the PR body, and report a verdict. The asset ships only if it is not identifiable as the least-designed object in that lineup — a strong scene can carry a weak model; this gate removes the scene. **This list is the canonical home of the reference set** — update it here when a new asset outclasses a member. The measurable floors behind the gate (naming, material variation, edge treatment) live in `examples/gallery_asset_quality.py` — render path only, same call pattern as `gallery_framing`, exit 11 on violation — with the calibration table and dropped-floor evidence in `docs/VISUAL-STYLE.md` § Asset quality. - **Falsification:** every check must be proven to fail once — break the contract, observe the non-zero exit, restore — with the probe and the measured error reported in the PR body. An assertion that cannot fail witnesses nothing. - **After gallery regeneration** (`python scripts/build_gallery.py`), read the **generated HTML** character by character — the `` text and witnesses callouts in `docs/gallery/index.html` and `docs/gallery//index.html` — not just `examples/gallery.json`. Precedent: the `teaches.split(".")[0]` bug truncated 14/21 card alts at dotted API paths like `bmesh.ops` while the source JSON looked fine (fixed in PR #68). -- **Playwright gallery captures:** gallery `` tags lazy-load, so force them first (`document.querySelectorAll('img').forEach(i => i.loading = 'eager')`, then wait). **Scroll the target card into view and take a viewport capture** — `scrollIntoView({block:'center', behavior:'instant'})`, short wait, `browser_take_screenshot` with `fullPage` omitted. A `fullPage` capture is NOT a workaround: on a tall gallery page it renders every card image blank even when the images are verified loaded (`complete === true`, `naturalWidth === 1280`, `opacity === 1`) — measured on the 44-card grid at 1425x4516. Verify load state via `browser_evaluate` rather than trusting the pixels. +- **Playwright gallery captures:** gallery `` tags lazy-load, so force them first (`document.querySelectorAll('img').forEach(i => i.loading = 'eager')`, then wait). **Scroll the target card into view and take a viewport capture** — `scrollIntoView({block:'center', behavior:'instant'})`, short wait, `browser_take_screenshot` with `fullPage` omitted. A `fullPage` capture is NOT a workaround: on a tall gallery page it renders every card image blank even when the images are verified loaded (`complete === true`, `naturalWidth === 1280`, `opacity === 1`) — measured on the 45-card grid at 1425x4516. Verify load state via `browser_evaluate` rather than trusting the pixels. ## Example-Run Process @@ -149,7 +149,7 @@ The AI consumes content via: ## Key Conventions -- **Blender versions**: 5.1 primary, 4.5 LTS fallback. Skills must show both code paths when 4.x and 5.x APIs diverge. +- **Blender versions**: 5.2 LTS primary, 5.1 prior stable, 4.5 LTS fallback. Skills must show both code paths when 4.x and 5.x APIs diverge, and the 5.1-vs-5.2 NodesModifier input split. - **Properties as annotations**: `my_prop: bpy.props.FloatProperty(...)` (correct), not `my_prop = bpy.props.FloatProperty(...)` (deprecated). - **bmesh memory**: every `bmesh.new()` must be paired with `bm.free()` in a `try`/`finally`. - **No `bpy.ops` in tight loops**: use `bpy.data.*` and `bmesh` for bulk work. @@ -160,7 +160,8 @@ The AI consumes content via: | Area | URL | | --- | --- | -| Python API (5.1) | https://docs.blender.org/api/current/ | +| Python API (5.2 LTS) | https://docs.blender.org/api/current/ | +| Python API (5.1) | https://docs.blender.org/api/5.1/ | | Python API (4.5 LTS) | https://docs.blender.org/api/4.5/ | | Extensions Platform | https://docs.blender.org/manual/en/latest/advanced/extensions/index.html | | Release notes | https://developer.blender.org/ | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 46defdd..4f133ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,7 +88,7 @@ templates/ ## Blender Version Targeting -Content targets **Blender 5.1** as primary, with **Blender 4.5 LTS** as fallback. When the API differs, branch on `bpy.app.version` and document both paths. Example: +Content targets **Blender 5.2 LTS** as primary, **Blender 5.1** as prior stable, and **Blender 4.5 LTS** as fallback. When the API differs, branch on `bpy.app.version` and document both paths. Example: ```python if bpy.app.version >= (5, 0, 0): diff --git a/README.md b/README.md index ed7be3c..d9c10fc 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,12 @@

Validate - Blender smoke tests + Blender smoke tests Drift check

- 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  44 examples + 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  45 examples

@@ -36,7 +36,7 @@ ## Overview -This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 44 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support. +This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 45 runnable 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. @@ -65,14 +65,14 @@ blender --background --python examples/bmesh-gear/bmesh_gear.py -- | Version | Status | | --- | --- | -| Blender 5.1.x | Primary target (all examples assume 5.1) | +| Blender 5.2 LTS | Primary target (current stable; all examples assume 5.2 unless a 4.5 path is shown) | +| Blender 5.1 | Prior stable (weekly smoke only) | | Blender 4.5 LTS | Fallback supported (skills show both code paths where 4.x and 5.x APIs diverge) | -| Blender 5.2 LTS | Sweep planned for July 2026 (see [ROADMAP.md](ROADMAP.md)) | ## Examples Runnable, smoke-gated demos live in [`examples/`](examples/) — each is executed headless on -both Blender 4.5 LTS and 5.1 by the `blender-smoke` workflow, so the screenshots reflect code +Blender 5.2 LTS and 4.5 LTS by the `blender-smoke` workflow (5.1 on the weekly cron), so the screenshots reflect code that actually runs. Browse them all with filters and full-size renders in the **[examples gallery](https://tmhsdigital.github.io/Blender-Developer-Tools/gallery/)**, or expand a category below. @@ -362,7 +362,7 @@ inside the effect. Per-frame sample renders assert the closed form (mid dips

-Geometry Nodes — 2 examples +Geometry Nodes — 3 examples @@ -391,6 +391,20 @@ A generative Geometry Nodes tree — Mesh Grid → Instance on Points → Realiz attached as a `NODES` modifier with no Group Input. Asserts evaluated topology is verts = 72, faces = 54, and `Set Material` carries the lime accent. + + + + +
+GN modifier inputs: three cubes in teal, copper, and coral scaled to 1, 2, and 3 meters by a shared Geometry Nodes tree, proving per-modifier Scale writes land on 5.2 RNA and on 4.5 dict assignment + + +### [gn-modifier-inputs](examples/gn-modifier-inputs/) + +One Geometry Nodes tree, three modifier copies. Writes a Float Scale input through +`mod.properties.inputs` on 5.2 and `mod[identifier]` on 4.5/5.1. Asserts readback and +evaluated Z-extent equal 1 / 2 / 3. The 5.1 dict form raises TypeError on 5.2. +
@@ -906,7 +920,8 @@ Each snippet is a standalone Python file under `snippets/`. They are not loaded | Resource | Use it for | | --- | --- | -| [Blender 5.1 Python API](https://docs.blender.org/api/current/) | Authoritative reference for current stable APIs | +| [Blender 5.2 LTS Python API](https://docs.blender.org/api/current/) | Authoritative reference for current stable APIs | +| [Blender 5.1 Python API](https://docs.blender.org/api/5.1/) | Prior stable | | [Blender 4.5 LTS Python API](https://docs.blender.org/api/4.5/) | LTS reference when targeting 4.5 | | [Extensions Platform manual](https://docs.blender.org/manual/en/latest/advanced/extensions/index.html) | `blender_manifest.toml` schema, hosting, install flow | | [developer.blender.org](https://developer.blender.org/) | Release notes, breaking change tracking, design docs | @@ -915,8 +930,7 @@ When community content (Stack Overflow, older add-on source) conflicts with the ## Roadmap -See [ROADMAP.md](ROADMAP.md) for the candidate pool and what ships next — including the -planned Blender 5.2 LTS sweep (July 2026). Releases are cut automatically from +See [ROADMAP.md](ROADMAP.md) for the candidate pool and what ships next. Releases are cut automatically from conventional commits; the full history lives in [CHANGELOG.md](CHANGELOG.md). ## Contributing diff --git a/ROADMAP.md b/ROADMAP.md index e900573..46bf7b2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,7 +15,8 @@ derives the actual version from conventional-commit types. | Materials, drivers, migration | 12 | 6 | 2 | 17 | Shipped (v0.2.0) | | Examples and demos (smoke-gated) | 12 | 6 | 2 | 17 | Shipped (v0.3.0) | | More examples (turntable, SDF remesh) | 12 | 6 | 2 | 17 | Shipped (v0.4.0) | -| 5.2 LTS sweep, modal operators, USD | — | — | — | — | Upcoming | +| 5.2 LTS targeting, GN modifier inputs | 12 | 6 | 2 | 17 | Shipped | +| Modal operators, USD, mathutils | — | — | — | — | Upcoming | | Stable | — | — | — | — | Upcoming | ## v0.1.0 - Foundation @@ -93,9 +94,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - `modal-operators` skill -- `invoke` returning `RUNNING_MODAL`, the `modal()` event handler, modal cancellation patterns - `usd-pipelines` skill -- USD export options, `evaluation_mode`, instancing, the USD vs glTF tradeoffs - `mathutils-patterns` skill -- `mathutils.Vector`, `Matrix`, `Quaternion`, common transforms, the `@` operator -- Blender 5.2 LTS sweep (after the 5.2 LTS release in mid-2026) -- Refresh the `slotted-actions-animation` skill against any 5.2 changes -- Bump `blender_version_min` in the templates if 5.2 APIs are used +- ~~Blender 5.2 LTS sweep~~ **SHIPPED** as targeting + smoke matrix (5.2 + 4.5 on PRs, 5.1 weekly) plus `examples/gn-modifier-inputs/` — NodesModifier dict assignment raises TypeError on 5.2; RNA `mod.properties.inputs..value` is the replacement - Additional snippets for asset library scripting, EXR baking, multi-file extensions - ~~UV-layer authoring witness~~ **SHIPPED** as `examples/uv-layer-grid/` — `create_grid(..., calc_uvs=True)` silent no-op without a pre-existing UV layer; closed-form UV fill + explicit assignment fallback; dual-panel render (flat texel (0,0) vs neon checker) - ~~Image save-format witness~~ **SHIPPED** as `examples/png-exr-alpha/` — float→PNG is RGBA16 and false-unpremultiplies as if associated-alpha (closed-form err 0.98 at RGB 0.02 / a=1/255); OpenEXR preserves float RGBA; byte→PNG is straight RGBA8; `EXR color_mode='RGB'` drops alpha diff --git a/docs/gallery/armature-bend/index.html b/docs/gallery/armature-bend/index.html index 7a1031d..0b4e2d2 100644 --- a/docs/gallery/armature-bend/index.html +++ b/docs/gallery/armature-bend/index.html @@ -271,7 +271,7 @@

Run

# 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 4.5 LTS and 5.1.

+

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.

Source

diff --git a/docs/gallery/assets/gn-modifier-inputs-hero.webp b/docs/gallery/assets/gn-modifier-inputs-hero.webp new file mode 100644 index 0000000..4da5f22 Binary files /dev/null and b/docs/gallery/assets/gn-modifier-inputs-hero.webp differ diff --git a/docs/gallery/attribute-domain-shear/index.html b/docs/gallery/attribute-domain-shear/index.html index ac3e0c0..ed4cd65 100644 --- a/docs/gallery/attribute-domain-shear/index.html +++ b/docs/gallery/attribute-domain-shear/index.html @@ -270,7 +270,7 @@

Run

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

Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1. The --output render path additionally measures framing against the Layer 1 band via examples/gallery_framing.py (exit 10 on violation) before writing the still.

+

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.

Source

diff --git a/docs/gallery/bmesh-gear/index.html b/docs/gallery/bmesh-gear/index.html index bf181c7..c95a4e3 100644 --- a/docs/gallery/bmesh-gear/index.html +++ b/docs/gallery/bmesh-gear/index.html @@ -268,7 +268,7 @@

Run

# 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 4.5 LTS and 5.1.

+

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.

Source

diff --git a/docs/gallery/car-mirror-symmetry/index.html b/docs/gallery/car-mirror-symmetry/index.html index 97cac45..bf23558 100644 --- a/docs/gallery/car-mirror-symmetry/index.html +++ b/docs/gallery/car-mirror-symmetry/index.html @@ -272,7 +272,7 @@

Run

# 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 4.5 LTS and 5.1.

+

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.

Source

diff --git a/docs/gallery/collision-hull-proxy/index.html b/docs/gallery/collision-hull-proxy/index.html index ed49a3c..015ec53 100644 --- a/docs/gallery/collision-hull-proxy/index.html +++ b/docs/gallery/collision-hull-proxy/index.html @@ -274,7 +274,7 @@

Run

# 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 4.5 LTS and 5.1.

+

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.

Source

diff --git a/docs/gallery/color-attribute-wheel/index.html b/docs/gallery/color-attribute-wheel/index.html index 492d2f5..13b5ec5 100644 --- a/docs/gallery/color-attribute-wheel/index.html +++ b/docs/gallery/color-attribute-wheel/index.html @@ -268,7 +268,7 @@

Run

# 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 4.5 LTS and 5.1.

+

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.

Source

diff --git a/docs/gallery/compositor-glare/index.html b/docs/gallery/compositor-glare/index.html index db5456d..e9765d0 100644 --- a/docs/gallery/compositor-glare/index.html +++ b/docs/gallery/compositor-glare/index.html @@ -269,7 +269,7 @@

Run

# 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 4.5 LTS and 5.1.

+

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.

Source

diff --git a/docs/gallery/contact-sheets/gn-modifier-inputs-contact-sheet.webp b/docs/gallery/contact-sheets/gn-modifier-inputs-contact-sheet.webp new file mode 100644 index 0000000..7b07520 Binary files /dev/null and b/docs/gallery/contact-sheets/gn-modifier-inputs-contact-sheet.webp differ diff --git a/docs/gallery/curve-bevel-arc/index.html b/docs/gallery/curve-bevel-arc/index.html index 3bc0b25..8e652c9 100644 --- a/docs/gallery/curve-bevel-arc/index.html +++ b/docs/gallery/curve-bevel-arc/index.html @@ -268,7 +268,7 @@

Run

# 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 4.5 LTS and 5.1.

+

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.

diff --git a/docs/gallery/custom-normals-shade/index.html b/docs/gallery/custom-normals-shade/index.html index edca46c..226d28e 100644 --- a/docs/gallery/custom-normals-shade/index.html +++ b/docs/gallery/custom-normals-shade/index.html @@ -274,7 +274,7 @@

Run

# 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 4.5 LTS and 5.1.

+

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.

Source

diff --git a/docs/gallery/damped-track-aim/index.html b/docs/gallery/damped-track-aim/index.html index 6f5ec41..4c73bf6 100644 --- a/docs/gallery/damped-track-aim/index.html +++ b/docs/gallery/damped-track-aim/index.html @@ -270,7 +270,7 @@

Run

# 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 4.5 LTS and 5.1.

+

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.

Source

diff --git a/docs/gallery/degenerate-bevel-weld/index.html b/docs/gallery/degenerate-bevel-weld/index.html index a6dbacf..fc1de04 100644 --- a/docs/gallery/degenerate-bevel-weld/index.html +++ b/docs/gallery/degenerate-bevel-weld/index.html @@ -270,7 +270,7 @@

Run

blender --background --python degenerate_bevel_weld.py --
 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 4.5 LTS and 5.1. The --output render path additionally measures framing against the Layer 1 band via examples/gallery_framing.py (exit 10 on violation) before writing the still.

+

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.

Source

diff --git a/docs/gallery/depsgraph-export/index.html b/docs/gallery/depsgraph-export/index.html index 52830ad..caabf57 100644 --- a/docs/gallery/depsgraph-export/index.html +++ b/docs/gallery/depsgraph-export/index.html @@ -271,7 +271,7 @@

Run

# 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 4.5 LTS and 5.1: base 8 → evaluated/exported 98 vertices with a 2-level SUBSURF.

+

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.

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.

diff --git a/docs/gallery/driver-wave/index.html b/docs/gallery/driver-wave/index.html index 12465f4..6d33d13 100644 --- a/docs/gallery/driver-wave/index.html +++ b/docs/gallery/driver-wave/index.html @@ -269,7 +269,7 @@

Run

# 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 4.5 LTS and 5.1.

+

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.

diff --git a/docs/gallery/gltf-export-roundtrip/index.html b/docs/gallery/gltf-export-roundtrip/index.html index d466e06..bbe9d62 100644 --- a/docs/gallery/gltf-export-roundtrip/index.html +++ b/docs/gallery/gltf-export-roundtrip/index.html @@ -273,7 +273,7 @@

Run

# 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 4.5 LTS and 5.1.

+

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.

Source

diff --git a/docs/gallery/gltf-skin-roundtrip/index.html b/docs/gallery/gltf-skin-roundtrip/index.html index 91e1d78..a21ac0e 100644 --- a/docs/gallery/gltf-skin-roundtrip/index.html +++ b/docs/gallery/gltf-skin-roundtrip/index.html @@ -273,7 +273,7 @@

Run

# 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 4.5 LTS and 5.1.

+

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.

Source

diff --git a/docs/gallery/gn-instance-grid/index.html b/docs/gallery/gn-instance-grid/index.html index 4f69e10..8dab5d8 100644 --- a/docs/gallery/gn-instance-grid/index.html +++ b/docs/gallery/gn-instance-grid/index.html @@ -268,7 +268,7 @@

Run

# 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 4.5 LTS and 5.1.

+

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.

Source

diff --git a/docs/gallery/gn-modifier-inputs/index.html b/docs/gallery/gn-modifier-inputs/index.html new file mode 100644 index 0000000..d4decfd --- /dev/null +++ b/docs/gallery/gn-modifier-inputs/index.html @@ -0,0 +1,721 @@ + + + + + + gn-modifier-inputs — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

gn-modifier-inputs

+

Per-modifier Geometry Nodes Float inputs on a shared tree — 4.5/5.1 write mod[identifier], 5.2 writes mod.properties.inputs.Socket_1.value.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses The 5.1 dict form raises TypeError on 5.2 rather than silently no-opping; RNA properties are AttributeError on 4.5. Readback and evaluated Z-extent match written scales 1/2/3.
+
+
blender --background --python examples/gn-modifier-inputs/gn_modifier_inputs.py --
+ +
+
+

A runnable example that attaches one Geometry Nodes tree to three cubes and writes a per-modifier Float Scale input through the version-appropriate API. The tree scales a 1 m cube by that value and lifts it onto the floor. Evaluated Z-extent must match the written scale (1 / 2 / 3). That is the closed form: if the write did not land, the three cubes collapse to the socket default and the extents are no longer distinct.

+

What it witnesses: Blender 5.2 removed ID-property assignment on NodesModifier. mod["Socket_1"] = 2.0 raises TypeError rather than silently no-opping. 4.5 LTS and 5.1 still require that dict form; mod.properties does not exist there (AttributeError). 5.2+ writes mod.properties.inputs.Socket_1.value. After the write, the depsgraph must be updated or evaluated_get still sees the previous scale.

+

Follows geometry-nodes-python.

+

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

+

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 |

+
+
+

Source

+
+ examples/gn-modifier-inputs/gn_modifier_inputs.py + View on GitHub → +
+
"""Geometry Nodes per-modifier input write — a runnable example.
+
+Witnesses the 5.1→5.2 removal of dict assignment on a NODES modifier.
+A shared GeometryNodeTree exposes a Float "Scale" socket. Three carrier
+cubes each get their own modifier instance of that tree. The check writes
+1.0 / 2.0 / 3.0 through the version-appropriate path, reads the value
+back, and asserts the evaluated Z-extent equals the written scale
+(closed form: a 1 m cube scaled by S and lifted by S/2 spans [0, S]).
+
+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.
+
+    blender --background --python gn_modifier_inputs.py --
+    blender --background --python gn_modifier_inputs.py -- --api dict
+    blender --background --python gn_modifier_inputs.py -- --output s.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)
+)
+import gallery_framing  # noqa: E402
+
+CUBE_SIZE = 1.0
+SCALES = (1.0, 2.0, 3.0)
+# Half-widths 0.5 / 1.0 / 1.5; keep a clear gap, keep the 3 m cube off the right edge.
+XS = (-2.2, 0.15, 2.45)
+COLORS = (
+    (0.05, 0.62, 0.58, 1.0),  # teal
+    (0.82, 0.38, 0.08, 1.0),  # copper
+    (0.86, 0.18, 0.22, 1.0),  # coral
+)
+READBACK_EPS = 1e-6
+EXTENT_EPS = 1e-4
+INPUT_NAME = "Scale"
+
+
+def _api_choice(explicit):
+    if explicit != "auto":
+        return explicit
+    return "rna" if bpy.app.version >= (5, 2, 0) else "dict"
+
+
+def scale_identifier(tree):
+    for item in tree.interface.items_tree:
+        if getattr(item, "item_type", "SOCKET") not in ("SOCKET",):
+            continue
+        if getattr(item, "in_out", None) == "INPUT" and item.name == INPUT_NAME:
+            return item.identifier
+    return None
+
+
+def set_mod_input(mod, ident, value, api):
+    if api == "dict":
+        mod[ident] = value
+        return
+    sock = getattr(mod.properties.inputs, ident)
+    sock.value = value
+
+
+def get_mod_input(mod, ident, api):
+    if api == "dict":
+        return float(mod[ident])
+    sock = getattr(mod.properties.inputs, ident)
+    return float(sock.value)
+
+
+def make_material(name, color):
+    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["Roughness"].default_value = 0.28
+    bsdf.inputs["Metallic"].default_value = 0.35
+    return mat
+
+
+def make_cube_mesh(name):
+    me = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_cube(bm, size=CUBE_SIZE)
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    return me
+
+
+def build_scale_tree(material=None):
+    tree = bpy.data.node_groups.new("ModifierScale", "GeometryNodeTree")
+    tree.interface.new_socket(
+        name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry"
+    )
+    scale_sock = tree.interface.new_socket(
+        name=INPUT_NAME, in_out="INPUT", socket_type="NodeSocketFloat"
+    )
+    scale_sock.default_value = 1.0
+    tree.interface.new_socket(
+        name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry"
+    )
+    gi = tree.nodes.new("NodeGroupInput")
+    go = tree.nodes.new("NodeGroupOutput")
+    combine = tree.nodes.new("ShaderNodeCombineXYZ")
+    xform = tree.nodes.new("GeometryNodeTransform")
+    shade = tree.nodes.new("GeometryNodeSetShadeSmooth")
+    shade.inputs["Shade Smooth"].default_value = False
+
+    tree.links.new(gi.outputs[INPUT_NAME], combine.inputs["X"])
+    tree.links.new(gi.outputs[INPUT_NAME], combine.inputs["Y"])
+    tree.links.new(gi.outputs[INPUT_NAME], combine.inputs["Z"])
+    tree.links.new(gi.outputs["Geometry"], xform.inputs["Geometry"])
+    tree.links.new(combine.outputs["Vector"], xform.inputs["Scale"])
+    # Lift by S/2 so a cube of extent S sits on z=0.
+    # Translation is a vector; drive Z from the same Scale socket via Combine.
+    lift = tree.nodes.new("ShaderNodeCombineXYZ")
+    scale_half = tree.nodes.new("ShaderNodeMath")
+    scale_half.operation = "MULTIPLY"
+    scale_half.inputs[1].default_value = 0.5
+    tree.links.new(gi.outputs[INPUT_NAME], scale_half.inputs[0])
+    tree.links.new(scale_half.outputs[0], lift.inputs["Z"])
+    tree.links.new(lift.outputs["Vector"], xform.inputs["Translation"])
+
+    tree.links.new(xform.outputs["Geometry"], shade.inputs["Geometry"])
+    out_socket = shade.outputs["Geometry"]
+    if material is not None:
+        set_mat = tree.nodes.new("GeometryNodeSetMaterial")
+        set_mat.inputs["Material"].default_value = material
+        tree.links.new(out_socket, set_mat.inputs["Geometry"])
+        out_socket = set_mat.outputs["Geometry"]
+    tree.links.new(out_socket, go.inputs["Geometry"])
+    return tree
+
+
+def build():
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    mats = [
+        make_material("TealScale", COLORS[0]),
+        make_material("CopperScale", COLORS[1]),
+        make_material("CoralScale", COLORS[2]),
+    ]
+    # One shared tree; Set Material is applied per-object via the mesh slot
+    # rather than inside the group so the group stays parameter-only.
+    tree = build_scale_tree(material=None)
+    objs = []
+    mods = []
+    for i, (x, mat) in enumerate(zip(XS, mats)):
+        me = make_cube_mesh(f"Carrier{i}")
+        me.materials.append(mat)
+        obj = bpy.data.objects.new(f"Scale{int(SCALES[i])}", me)
+        obj.location = (x, 0.0, 0.0)
+        bpy.context.collection.objects.link(obj)
+        mod = obj.modifiers.new("scale_input", "NODES")
+        mod.node_group = tree
+        objs.append(obj)
+        mods.append(mod)
+    return tree, objs, mods
+
+
+def evaluated_z_extent(obj):
+    dg = bpy.context.evaluated_depsgraph_get()
+    ev = obj.evaluated_get(dg)
+    em = ev.to_mesh()
+    try:
+        zs = [v.co.z for v in em.vertices]
+        if not zs:
+            return 0.0, 0.0, 0
+        return min(zs), max(zs), len(em.vertices)
+    finally:
+        ev.to_mesh_clear()
+
+
+def check(tree, objs, mods, api):
+    ident = scale_identifier(tree)
+    if not ident:
+        print("ERROR: Scale input identifier missing on the tree interface",
+              file=sys.stderr)
+        return 3
+    print(f"api={api} blender={bpy.app.version} identifier={ident}")
+
+    if len({mod.node_group for mod in mods}) != 1:
+        print("ERROR: modifiers do not share one node_group", file=sys.stderr)
+        return 4
+
+    for obj, mod, scale in zip(objs, mods, SCALES):
+        try:
+            set_mod_input(mod, ident, scale, api)
+        except Exception as e:
+            print(
+                f"ERROR: {api} write of {scale} on {obj.name} raised "
+                f"{type(e).__name__}: {e}",
+                file=sys.stderr,
+            )
+            return 5
+        obj.update_tag()
+        bpy.context.view_layer.update()
+        try:
+            got = get_mod_input(mod, ident, api)
+        except Exception as e:
+            print(
+                f"ERROR: {api} read of {obj.name} raised "
+                f"{type(e).__name__}: {e}",
+                file=sys.stderr,
+            )
+            return 6
+        if abs(got - scale) > READBACK_EPS:
+            print(
+                f"ERROR: readback {got} != written {scale} on {obj.name}",
+                file=sys.stderr,
+            )
+            return 7
+
+        zmin, zmax, nverts = evaluated_z_extent(obj)
+        extent = zmax - zmin
+        if abs(extent - scale) > EXTENT_EPS:
+            print(
+                f"ERROR: evaluated Z-extent {extent:.6f} != scale {scale} "
+                f"on {obj.name} (z=[{zmin:.4f},{zmax:.4f}] verts={nverts})",
+                file=sys.stderr,
+            )
+            return 8
+        if abs(zmin) > EXTENT_EPS:
+            print(
+                f"ERROR: evaluated mesh not sitting on z=0 "
+                f"({obj.name} zmin={zmin:.4f})",
+                file=sys.stderr,
+            )
+            return 9
+        print(
+            f"{obj.name} scale={scale} readback={got:.6f} "
+            f"z_extent={extent:.6f} zmin={zmin:.6f} verts={nverts}"
+        )
+
+    extents = []
+    for obj in objs:
+        zmin, zmax, _ = evaluated_z_extent(obj)
+        extents.append(zmax - zmin)
+    if len(set(round(e, 4) for e in extents)) != 3:
+        print(
+            f"ERROR: evaluated extents not distinct {extents}"
+            "per-modifier copies did not land",
+            file=sys.stderr,
+        )
+        return 11
+    return 0
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def render_still(objs, 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 = bpy.data.materials.new("Studio")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 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.35, 0.0, 1.25)
+    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.8, -5.0, 6.2), 560.0, 5.0, (1.0, 0.96, 0.9))
+    light("Fill", (5.4, -3.2, 2.4), 110.0, 8.0, (0.75, 0.85, 1.0))
+    light("Rim", (0.4, 6.4, 3.8), 280.0, 3.5, (0.6, 0.78, 1.0))
+    wedge = bpy.data.lights.new("Wedge", "AREA")
+    wedge.energy = 420.0
+    wedge.size = 6.0
+    wedge.color = (1.0, 0.76, 0.5)
+    wob = bpy.data.objects.new("Wedge", wedge)
+    wob.location = (2.4, 5.6, 4.2)
+    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 = (8.1, -10.8, 5.05)
+    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=list(objs),
+        elements=list(objs),
+        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, 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(
+        "--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",
+    )
+    args = p.parse_args(argv)
+
+    tree, objs, mods = build()
+    api = _api_choice(args.api)
+    code = check(tree, objs, mods, api)
+    if code:
+        return code
+
+    if args.output:
+        rcode = render_still(objs, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+
+    print("gn-modifier-inputs 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-sdf-remesh/index.html b/docs/gallery/gn-sdf-remesh/index.html index 18bad45..5654ae4 100644 --- a/docs/gallery/gn-sdf-remesh/index.html +++ b/docs/gallery/gn-sdf-remesh/index.html @@ -269,7 +269,7 @@

Run

# 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 4.5 LTS and 5.1.

+

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.

Source

diff --git a/docs/gallery/gp-lineart-contour/index.html b/docs/gallery/gp-lineart-contour/index.html index 3f351d1..7995916 100644 --- a/docs/gallery/gp-lineart-contour/index.html +++ b/docs/gallery/gp-lineart-contour/index.html @@ -270,7 +270,7 @@

Run

# 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 4.5 LTS and 5.1.

+

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.

diff --git a/docs/gallery/grease-pencil-rosette/index.html b/docs/gallery/grease-pencil-rosette/index.html index 9450d65..50ff5a6 100644 --- a/docs/gallery/grease-pencil-rosette/index.html +++ b/docs/gallery/grease-pencil-rosette/index.html @@ -270,7 +270,7 @@

Run

# 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 4.5 LTS and 5.1.

+

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.

Source

diff --git a/docs/gallery/image-pixels-testcard/index.html b/docs/gallery/image-pixels-testcard/index.html index c220454..c470ade 100644 --- a/docs/gallery/image-pixels-testcard/index.html +++ b/docs/gallery/image-pixels-testcard/index.html @@ -273,7 +273,7 @@

Run

# 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 4.5 LTS and 5.1. 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.

+

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.

Source

diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 0947929..7cd1d1a 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -4,18 +4,18 @@ Examples Gallery — Blender Developer Tools - + - + - +