diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 7e5ee501..0229f8ae 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -136,7 +136,9 @@ "examples/vse-cut-list", "examples/vse-gamma-cross", "examples/vse-linear-modifiers", - "examples/wave-displace" + "examples/wave-displace", + "examples/ray-cast-space", + "examples/lattice-deform" ], "showcase": [ "showcase/shipping-crate", diff --git a/AGENTS.md b/AGENTS.md index 5cda6954..4069700f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,8 @@ 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, 60 -examples, and 67 showcase pieces (counts are CI-enforced against README.md) +The content base is 16 skills, 9 rules, 3 templates, 27 snippets, 62 +examples, and 72 showcase pieces (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/`; showcase conventions: `showcase/README.md`. The render look is specified @@ -36,7 +36,7 @@ Blender-Developer-Tools/ rules/.mdc # 9 rule files templates// # 3 starter templates snippets/.py # 27 standalone Python snippets - examples// # 60 runnable smoke-gated examples (+ gallery.json) + examples// # 62 runnable smoke-gated examples (+ gallery.json) examples/gallery_framing.py # shared Layer 1 framing measurement (render path only) showcase// # budget-conformance props (sibling of examples/) showcase/gallery.json # this tree's gallery index; merged into docs/gallery/ diff --git a/CLAUDE.md b/CLAUDE.md index 118707c0..48c7a44f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,8 +21,8 @@ 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, 60 total (+ gallery.json) -showcase// - Budget-conformance props, 67 pieces, each with a gallery `category` (sibling of examples/; see showcase/README.md § Categories) +examples// - Runnable smoke-gated examples, 62 total (+ gallery.json) +showcase// - Budget-conformance props, 72 pieces, each with a gallery `category` (sibling of examples/; see showcase/README.md § Categories) scripts/build_gallery.py - Regenerates docs/gallery/ from examples/gallery.json + showcase/gallery.json scripts/site/ - Vendored landing-page build (Jinja2); tokens.css is the shared palette tests/check_site_links.py - Internal link/anchor/alt gate over the built site (docs/) @@ -105,12 +105,12 @@ 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 (60) +## Examples (62) 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 5.2 LTS and 4.5 LTS in `blender-smoke.yml` (5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch); -**52 of the 60 ship a render in the site gallery** at `docs/gallery/`. The other +**54 of the 62 ship a render in the site gallery** at `docs/gallery/`. The other eight are **check-only**: they carry no `--output` path, no gallery entry, and no hero asset. The criterion is whether the contract is expressible in pixels. An example is check-only when its witness is a data or state fact that no scene diff --git a/README.md b/README.md index 7876e01b..3acd3cbd 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  60 examples  •  72 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  62 examples  •  72 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 60 examples, and 72 showcase pieces** 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, 62 examples, and 72 showcase pieces** 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 reading these files directly from a checkout — **there is no MCP server in this repository, and none is required**. Cursor applies `rules/*.mdc` automatically wherever their scope globs match and takes skills by name in chat; Claude Code reads `skills/` and `rules/` from the project workspace, or from this repo kept as a referenced checkout. Any agent that can read files in a workspace can use it the same way. There is no build step for the content — edit the Markdown and Python files directly. @@ -75,7 +75,7 @@ blender --background --python examples/bmesh-gear/bmesh_gear.py -- ## Falsifiers -Every one of the 60 examples carries a **falsifier**: a flag that changes the +Every one of the 62 examples carries a **falsifier**: a flag that changes the input so a real assertion fails. It never disables the assertion, skips the check, or short-circuits to an error — it feeds the script something the contract says must not pass, and the same check that guards the happy path @@ -451,7 +451,7 @@ Gallery still is a sky-lit obelisk diptych (8° dusk | 55° midday) so the contr

-Mesh, curves & text — 13 examples +Mesh, curves & text — 15 examples @@ -648,6 +648,37 @@ check-only, no gallery still — no geometry 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. + + + + + + + + +
+Ray cast space: a teal-checkered block turned and stretched on a walnut plinth, five orange rays from brass emitter balls ending in orange hit rings on three of its faces, and one red ray from the world-coordinate trap striking it from somewhere else, on a dark studio floor + + +### [ray-cast-space](examples/ray-cast-space/) + +`Object.ray_cast` vs `Scene.ray_cast` — five closed-form rays on a translated, Z-rotated, +non-uniformly scaled block. `Scene.ray_cast` hits each world point and normal exactly. +`Object.ray_cast`, fed `matrix_world.inverted()` origins and 3×3-only directions, hits the +same polygons and maps back within 5e-7 m. Raw world coordinates handed to `Object.ray_cast` +land 0.287 m or more away (`--world-to-object` exits 4). + +
+Lattice deform: two checker-glazed columns on a walnut plinth, each in a steel-rod cage - the left upright in its rest cage, the right sheared and tapered where two orange cage corners have been pulled out + + +### [lattice-deform](examples/lattice-deform/) + +A 2×2×2 Lattice modifier set to `'KEY_LINEAR'`, with two corners moved through +`LatticePoint.co_deform`. Witnesses that every evaluated vert lands on the trilinear +blend of the eight deformed corners, and that the default `'KEY_BSPLINE'` misses it +(`--bspline` exits 4). +
diff --git a/ROADMAP.md b/ROADMAP.md index 6ac6b529..26688729 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -163,13 +163,14 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - `persistent` app-handler witness — handlers registered without `@bpy.app.handlers.persistent` are dropped by `wm.read_homefile`/file load while persistent ones survive; assert the registered-handler set before and after a reload (silent loss AI code hits) - Link vs append witness — `bpy.data.libraries.load(link=True)` yields a linked, non-editable datablock (`library` set, `is_editable` False) while append yields a local copy; write a temp .blend with `bpy.data.libraries.write`, then assert both paths - Orphan purge witness — `bpy.data.orphans_purge(do_recursive=...)` removes exactly the zero-user datablocks computed independently beforehand, and `bpy.data.batch_remove` removes a given set in one call (counts closed-form) -- Local vs world ray-cast witness — `Object.ray_cast` takes object-local origin/direction while `Scene.ray_cast` takes world space; a transformed target hit at closed-form points both ways, and passing world coords to `Object.ray_cast` misses (the trap) +- ~~Local vs world ray-cast witness~~ **SHIPPED** as `examples/ray-cast-space/` — five closed-form rays on a translated, Z-rotated, non-uniformly scaled block: `Scene.ray_cast` exact in world space, `Object.ray_cast` exact through `matrix_world.inverted()` (3×3-only directions, inverse-transpose normals, same polygon index); world coords fed to `Object.ray_cast` land ≥ 0.287 m off; `--world-to-object` exits 4 - `mathutils.bvhtree` witness — `BVHTree.FromObject` on evaluated geometry: `find_nearest` distances and `overlap` pairs match closed forms on a known arrangement - Curves datablock witness — the modern `bpy.types.Curves` hair API (`add_curves`, `position` attribute, `curve_offset_data`) vs legacy particle hair: point and curve counts closed-form, every root on the emitter surface - Non-Color normal-map witness — an image used as a normal map must be `colorspace_settings.name = 'Non-Color'`; a baked/sampled value round-trips only then, and sRGB shifts it by the transfer curve (a silent shading error AI code ships) - Solidify even-thickness witness — `use_even_offset` keeps shell thickness constant at sharp corners, while without it the corner thins by 1/cos(θ/2): measured thickness at a known angle matches both closed forms - Boolean solver witness — EXACT solver union/difference volumes match closed forms on overlapping primitives, and the result is manifold; FAST on coplanar faces is the documented failure case -- Lattice deform witness — a 2×2×2 lattice with one displaced point deforms interior verts by exact trilinear weights (`interpolation_type_u/v/w = 'KEY_LINEAR'`) +- ~~Lattice deform witness — a 2×2×2 lattice with one displaced point deforms interior verts by exact trilinear weights (`interpolation_type_u/v/w = 'KEY_LINEAR'`)~~ **SHIPPED** as `examples/lattice-deform/` — 602 evaluated verts on the trilinear closed form (5.6e-7) through a translated, non-uniformly scaled lattice; `--bspline` (the default interpolation) exits 4 at 0.2145 +- Lattice `use_outside` / outside-vertex witness — verts outside a KEY_LINEAR lattice's volume: assert the documented clamp or extrapolation closed form, and `use_outside` (deform only the outer points) against an interior-only control move - FBX unit-scale witness — `export_scene.fbx` with `apply_unit_scale` / `apply_scale_options` changes exported coordinates by the closed-form ×100 cm factor; re-import restores metres only on the matching option - Collection-instance witness — `instance_type = 'COLLECTION'` instances appear only in `depsgraph.object_instances` (never in `scene.objects`); instance world matrices match the closed-form offsets - Asset-marking witness — `ID.asset_mark()`, `asset_data.tags` and catalog UUIDs written to `blender_assets.cats.txt`, re-read from a saved library file (check-only if no legible still) diff --git a/docs/gallery/armature-bend/index.html b/docs/gallery/armature-bend/index.html index 7b928953..6166426e 100644 --- a/docs/gallery/armature-bend/index.html +++ b/docs/gallery/armature-bend/index.html @@ -45,7 +45,7 @@

Armature Bend

Rigging end to end in the data API — edit_bones chain construction, name-bound vertex groups with smoothstep blend zones, posing, and depsgraph evaluation — bending a ribbed bellows hose through rest, half, and full curl.

- + @@ -1050,7 +1050,7 @@

LOD Decimate Chain

- +
diff --git a/docs/gallery/assets/lattice-deform-hero-640.webp b/docs/gallery/assets/lattice-deform-hero-640.webp new file mode 100644 index 00000000..3581f632 Binary files /dev/null and b/docs/gallery/assets/lattice-deform-hero-640.webp differ diff --git a/docs/gallery/assets/lattice-deform-hero.webp b/docs/gallery/assets/lattice-deform-hero.webp new file mode 100644 index 00000000..0bb76764 Binary files /dev/null and b/docs/gallery/assets/lattice-deform-hero.webp differ diff --git a/docs/gallery/assets/ray-cast-space-hero-640.webp b/docs/gallery/assets/ray-cast-space-hero-640.webp new file mode 100644 index 00000000..eb5ac803 Binary files /dev/null and b/docs/gallery/assets/ray-cast-space-hero-640.webp differ diff --git a/docs/gallery/assets/ray-cast-space-hero.webp b/docs/gallery/assets/ray-cast-space-hero.webp new file mode 100644 index 00000000..f4f7320c Binary files /dev/null and b/docs/gallery/assets/ray-cast-space-hero.webp differ diff --git a/docs/gallery/attribute-domain-shear/index.html b/docs/gallery/attribute-domain-shear/index.html index 823bd7f2..196440db 100644 --- a/docs/gallery/attribute-domain-shear/index.html +++ b/docs/gallery/attribute-domain-shear/index.html @@ -45,7 +45,7 @@

Attribute Domain Shear

POINT vs CORNER color-attribute domains on a shared-vertex fan: CORNER stays exact per face while a naive per-wedge POINT loop shears to the last write at the hub. A striped parasol keeps crisp stripes under CORNER and smears, with one panel turned the wrong color, under POINT; the measured shear equals the palette closed form.

- + @@ -1507,7 +1507,7 @@

Custom Normals Shade< - +

diff --git a/docs/gallery/bake-normal-high-to-low/index.html b/docs/gallery/bake-normal-high-to-low/index.html index 4734231a..e199dcd5 100644 --- a/docs/gallery/bake-normal-high-to-low/index.html +++ b/docs/gallery/bake-normal-high-to-low/index.html @@ -45,7 +45,7 @@

Bake Normal High To Low

A collapse-decimated hatch plate receiving a Cycles cage-baked tangent normal map from a ribbed high-poly source

- + @@ -1409,7 +1409,7 @@

Shape Key Blend

- +
diff --git a/docs/gallery/bmesh-gear/index.html b/docs/gallery/bmesh-gear/index.html index 18d8dc31..d756b011 100644 --- a/docs/gallery/bmesh-gear/index.html +++ b/docs/gallery/bmesh-gear/index.html @@ -45,7 +45,7 @@

BMesh Gear

A 14-tooth gear built entirely with bmesh — profile ring, face, extrude — with bm.free() in a try/finally, exactly as the ownership contract demands.

- + @@ -1229,7 +1229,7 @@

Mesh Hygiene Audit

- +
diff --git a/docs/gallery/car-mirror-symmetry/index.html b/docs/gallery/car-mirror-symmetry/index.html index 6a5ab59b..3084e15b 100644 --- a/docs/gallery/car-mirror-symmetry/index.html +++ b/docs/gallery/car-mirror-symmetry/index.html @@ -45,7 +45,7 @@

Car Mirror Symmetry

A stylized hatchback lofted as one half (52 stations, 13-point rings) and completed by the Mirror modifier, evaluated through the depsgraph. Wheels, lamps, grille, door mirrors and handles mirror about object origins parked on the symmetry plane; the grille is authored as a half and welded on it.

- + @@ -1883,34 +1883,34 @@ - +
diff --git a/docs/gallery/collision-hull-proxy/index.html b/docs/gallery/collision-hull-proxy/index.html index 0f5eef1b..45db1ffd 100644 --- a/docs/gallery/collision-hull-proxy/index.html +++ b/docs/gallery/collision-hull-proxy/index.html @@ -45,7 +45,7 @@

Collision Hull Proxy

A fire hydrant street prop inside its compound collision shell: four convex pieces hulled by bmesh.ops.convex_hull from a coarse inflated cage. The dense render mesh is never hulled - its hull would measure 380 faces, over the 255-face per-piece engine budget. Closed-form plane tests prove containment, convexity, watertightness, outward winding, and Euler characteristic 2 per piece.

- + @@ -1169,7 +1169,7 @@

Mesh Hygiene Audit

- +
diff --git a/docs/gallery/color-attribute-wheel/index.html b/docs/gallery/color-attribute-wheel/index.html index 6a4365d1..14b5bb2d 100644 --- a/docs/gallery/color-attribute-wheel/index.html +++ b/docs/gallery/color-attribute-wheel/index.html @@ -45,7 +45,7 @@

Color Attribute Wheel

The modern color-attributes API — mesh.color_attributes.new() on the CORNER domain, filled by expanding per-vertex HSV across face corners with foreach_get/foreach_set, then wired into a shader Attribute node.

- + @@ -1039,7 +1039,7 @@

Attribute Domain Shear< - +

diff --git a/docs/gallery/compositor-glare/index.html b/docs/gallery/compositor-glare/index.html index 55034ea5..73ad9cf9 100644 --- a/docs/gallery/compositor-glare/index.html +++ b/docs/gallery/compositor-glare/index.html @@ -45,7 +45,7 @@

Compositor Glare

Bloom where it actually lives — a compositor Glare (Fog Glow) node fed by Render Layers, wired via scene.compositing_node_group on 5.x and scene.node_tree on 4.x, with the Glare node's legacy properties vs 5.x menu sockets.

- + @@ -874,7 +874,7 @@

Image Pixels Testcard - +

diff --git a/docs/gallery/contact-sheets/lattice-deform-contact-sheet.webp b/docs/gallery/contact-sheets/lattice-deform-contact-sheet.webp new file mode 100644 index 00000000..7d0d971b Binary files /dev/null and b/docs/gallery/contact-sheets/lattice-deform-contact-sheet.webp differ diff --git a/docs/gallery/contact-sheets/ray-cast-space-contact-sheet.webp b/docs/gallery/contact-sheets/ray-cast-space-contact-sheet.webp new file mode 100644 index 00000000..10f11940 Binary files /dev/null and b/docs/gallery/contact-sheets/ray-cast-space-contact-sheet.webp differ diff --git a/docs/gallery/cross-version-property-delete/index.html b/docs/gallery/cross-version-property-delete/index.html index 5a746e3b..bba5b652 100644 --- a/docs/gallery/cross-version-property-delete/index.html +++ b/docs/gallery/cross-version-property-delete/index.html @@ -45,7 +45,7 @@

Cross Version Property Delete

Custom ID properties are removed with del, not property_unset. The IDs are built through bpy.data.objects.new so the check does not depend on active_object.

- + @@ -1009,7 +1009,7 @@

Temp Override Join

- +
diff --git a/docs/gallery/curve-bevel-arc/index.html b/docs/gallery/curve-bevel-arc/index.html index 8f8f53c9..54eaee86 100644 --- a/docs/gallery/curve-bevel-arc/index.html +++ b/docs/gallery/curve-bevel-arc/index.html @@ -45,7 +45,7 @@

Curve Bevel Arc

A beveled Bezier semicircle authored on bpy.types.Curve — splines.new('BEZIER'), bezier_points, bevel_depth, use_fill_caps — so the curve renders as a solid tube without a prior mesh conversion.

- + @@ -1194,7 +1194,7 @@

Text Version Stamp

- +
diff --git a/docs/gallery/custom-normals-shade/index.html b/docs/gallery/custom-normals-shade/index.html index 6c832456..f77388c3 100644 --- a/docs/gallery/custom-normals-shade/index.html +++ b/docs/gallery/custom-normals-shade/index.html @@ -45,7 +45,7 @@

Custom Normals Shade

A jerry can prop shaded three ways to prove the post-4.1 shading contract: hard edges are mesh data, landing exactly where the dihedral crosses. Face smooth flags plus a sharp_edge attribute, verified against an independently recomputed dihedral test, and per-loop custom normals surviving depsgraph evaluation within their int16 storage quantization (3.904e-05 over 8196 loops, not float-exact).

- + @@ -2033,7 +2033,7 @@

Vertex Color AO

- +
diff --git a/docs/gallery/damped-track-aim/index.html b/docs/gallery/damped-track-aim/index.html index 709e2474..0e2f2624 100644 --- a/docs/gallery/damped-track-aim/index.html +++ b/docs/gallery/damped-track-aim/index.html @@ -45,7 +45,7 @@

Damped Track Aim

Aim constraints via the data API — Object.constraints.new('DAMPED_TRACK') with target and TRACK_Z, not bpy.ops.object.constraint_add in a headless loop. Gallery still: twelve spotlight heads on stands, all swung onto one glowing orb.

- + @@ -1030,7 +1030,7 @@

Driver Wave

- +
diff --git a/docs/gallery/degenerate-bevel-weld/index.html b/docs/gallery/degenerate-bevel-weld/index.html index cad2dd91..a1e89370 100644 --- a/docs/gallery/degenerate-bevel-weld/index.html +++ b/docs/gallery/degenerate-bevel-weld/index.html @@ -45,7 +45,7 @@

Degenerate Bevel Weld

Bevel offset >= half the min box dimension collapses the band into zero-area faces — and they ship: a stdlib GLB re-parse counts the degenerate triangles crossing the export boundary. Two rugged cases whose shells are the check's meshes: flat end panel versus a rolled knife ridge, the collapsed seam traced hot from live mesh data.

- + @@ -1369,7 +1369,7 @@

BMesh Gear

- +
diff --git a/docs/gallery/depsgraph-export/index.html b/docs/gallery/depsgraph-export/index.html index 35eb6d14..e9f64cb5 100644 --- a/docs/gallery/depsgraph-export/index.html +++ b/docs/gallery/depsgraph-export/index.html @@ -45,7 +45,7 @@

Depsgraph Export

The depsgraph lifetime contract — evaluated_get().to_mesh() paired with to_mesh_clear() — measured against an OBJ export of the same object.

- + @@ -1416,7 +1416,7 @@

Text Version Stamp

- +
diff --git a/docs/gallery/driver-wave/index.html b/docs/gallery/driver-wave/index.html index 4802b898..c39c17a5 100644 --- a/docs/gallery/driver-wave/index.html +++ b/docs/gallery/driver-wave/index.html @@ -45,7 +45,7 @@

Driver Wave

A driver_namespace function driving sixteen organ-pipe heights through SCRIPTED drivers — the sine skyline of the pipe tops is entirely driver-evaluated.

- + @@ -901,7 +901,7 @@

Damped Track Aim

- +
diff --git a/docs/gallery/export-preset-axis/index.html b/docs/gallery/export-preset-axis/index.html index 595f1538..a740570b 100644 --- a/docs/gallery/export-preset-axis/index.html +++ b/docs/gallery/export-preset-axis/index.html @@ -45,7 +45,7 @@

Export Preset Axis

A radio mast exported under Unity and Godot glTF presets and re-imported, proving the two files have different vertex orientation

- + @@ -1635,7 +1635,7 @@

glTF Skin Roundtrip - +

diff --git a/docs/gallery/gltf-export-roundtrip/index.html b/docs/gallery/gltf-export-roundtrip/index.html index e6e0a8e9..126cc03a 100644 --- a/docs/gallery/gltf-export-roundtrip/index.html +++ b/docs/gallery/gltf-export-roundtrip/index.html @@ -45,7 +45,7 @@

glTF Export Roundtrip

A sci-fi supply crate exported to glTF and re-imported, verifying the round-trip against the depsgraph-evaluated mesh within float tolerances. Positions, loop normals, box-mapped UVs, and per-triangle material bindings must all survive; the on-disk JSON proves the +Y-up conversion and the V-flipped UV layout.

- + @@ -1602,7 +1602,7 @@

Export Preset Axis

- +
diff --git a/docs/gallery/gltf-skin-roundtrip/index.html b/docs/gallery/gltf-skin-roundtrip/index.html index ed1c7e1d..e605d06f 100644 --- a/docs/gallery/gltf-skin-roundtrip/index.html +++ b/docs/gallery/gltf-skin-roundtrip/index.html @@ -45,7 +45,7 @@

glTF Skin Roundtrip

A rigged mech scorpion exported to glTF with skins and re-imported, verifying the skinning contract the geometry round-trip left uncovered. Skeleton, weights, and deformation must all survive the format.

- + @@ -2071,7 +2071,7 @@

glTF Export Roundtrip - +

diff --git a/docs/gallery/gn-instance-grid/index.html b/docs/gallery/gn-instance-grid/index.html index d86d53a5..0c997956 100644 --- a/docs/gallery/gn-instance-grid/index.html +++ b/docs/gallery/gn-instance-grid/index.html @@ -45,7 +45,7 @@

GN Instance Grid

A generative Geometry Nodes tree — Mesh Grid → Instance on Points (a modeled keycap via Object Info) → Realize Instances → Set Shade Smooth → Set Material — attached as a NODES modifier with no Group Input geometry.

- + @@ -1450,7 +1450,7 @@

Modular Kit Snap

- +
diff --git a/docs/gallery/gn-modifier-inputs/index.html b/docs/gallery/gn-modifier-inputs/index.html index 856fcecb..7b92c356 100644 --- a/docs/gallery/gn-modifier-inputs/index.html +++ b/docs/gallery/gn-modifier-inputs/index.html @@ -45,7 +45,7 @@

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.

- + @@ -1329,7 +1329,7 @@

GP Lineart Contour

- +
diff --git a/docs/gallery/gn-sdf-remesh/index.html b/docs/gallery/gn-sdf-remesh/index.html index 72060819..62e38586 100644 --- a/docs/gallery/gn-sdf-remesh/index.html +++ b/docs/gallery/gn-sdf-remesh/index.html @@ -45,7 +45,7 @@

GN SDF Remesh

A Geometry Nodes SDF remesh (MeshToSDFGrid → GridToMesh at the SDF zero-level), with a Set Material node carrying the material through the remesh.

- + @@ -716,7 +716,7 @@

GN Instance Grid

- +
diff --git a/docs/gallery/gn-sim-fountain/index.html b/docs/gallery/gn-sim-fountain/index.html index b0a560f0..a3a3f64a 100644 --- a/docs/gallery/gn-sim-fountain/index.html +++ b/docs/gallery/gn-sim-fountain/index.html @@ -45,7 +45,7 @@

GN Sim Fountain

A Simulation Zone only advances one step per consecutive frame_set. A direct jump to frame N runs a single step, and only a bake gives random access to the stepped state.

- + @@ -1437,7 +1437,7 @@

GN Modifier Inputs

- +
diff --git a/docs/gallery/gn-socket-rename/index.html b/docs/gallery/gn-socket-rename/index.html index 237494d7..5bb60233 100644 --- a/docs/gallery/gn-socket-rename/index.html +++ b/docs/gallery/gn-socket-rename/index.html @@ -45,7 +45,7 @@

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

- + @@ -1487,7 +1487,7 @@

GN Modifier Inputs

- +
diff --git a/docs/gallery/gn-zone-iterate/index.html b/docs/gallery/gn-zone-iterate/index.html index f2593237..0c1a34b5 100644 --- a/docs/gallery/gn-zone-iterate/index.html +++ b/docs/gallery/gn-zone-iterate/index.html @@ -45,7 +45,7 @@

GN Zone Iterate

Repeat Zone and For Each Element only iterate after pair_with_output. Evaluated cube counts follow 8 times (1+N) and 8 times P, not tree structure.

- + @@ -1184,7 +1184,7 @@

GN Modifier Inputs

- +
diff --git a/docs/gallery/gp-lineart-contour/index.html b/docs/gallery/gp-lineart-contour/index.html index 9a784e56..96cb86e8 100644 --- a/docs/gallery/gp-lineart-contour/index.html +++ b/docs/gallery/gp-lineart-contour/index.html @@ -45,7 +45,7 @@

GP Lineart Contour

Grease Pencil LINEART modifier ink via the depsgraph on a cel-shaded lighthouse diorama. source_object is load-bearing (clear yields 0 strokes); every edge type off yields 0; the drawing is 255 strokes / 1393 points on 4.5.11, 5.1.2 and 5.2.1, gated above the count left when any one of contour, crease, material-border or intersection edges is dropped. Stroke width: thickness exists on 4.5, AttributeError on 5.1 — portable path is radius.

- + @@ -1576,7 +1576,7 @@

Car Mirror Symmetry - +

diff --git a/docs/gallery/grease-pencil-rosette/index.html b/docs/gallery/grease-pencil-rosette/index.html index 98899a1f..cff343c8 100644 --- a/docs/gallery/grease-pencil-rosette/index.html +++ b/docs/gallery/grease-pencil-rosette/index.html @@ -45,7 +45,7 @@

Grease Pencil Rosette

Grease Pencil v3's attribute-based API — layer → frames.new(1).drawing → add_strokes → per-point position/radius/opacity/vertex_color — drawing five nested neon rose curves.

- + @@ -933,7 +933,7 @@

GP Lineart Contour

- +
diff --git a/docs/gallery/image-pixels-testcard/index.html b/docs/gallery/image-pixels-testcard/index.html index 8d6d8c34..bd7fc27e 100644 --- a/docs/gallery/image-pixels-testcard/index.html +++ b/docs/gallery/image-pixels-testcard/index.html @@ -45,7 +45,7 @@

Image Pixels Testcard

The Image pixel-buffer contract — a procedural broadcast test card written into bpy.data.images.new() with one pixels.foreach_set (589,824 floats), byte vs float_buffer storage, scale() reallocation, and the save() vs save_render() lifecycle.

- + @@ -1041,7 +1041,7 @@

Wave Displace

- +
diff --git a/docs/gallery/index.html b/docs/gallery/index.html index a2b356eb..f306eff7 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -67,7 +67,7 @@

Examples and Showcase

autocomplete="off" spellcheck="false" aria-label="Search examples and showcase pieces" /> - 52 examples, 72 showcase pieces + 54 examples, 72 showcase pieces
@@ -766,6 +766,30 @@

GN Socket Rename

+
+
+ A teal-checkered block turned and stretched on a walnut plinth, five orange rays from brass balls ending in orange rings on three faces, and one red ray striking it from elsewhere. +
+
+

Ray Cast Space

+

ray-cast-space

+

Object.ray_cast is object-local while Scene.ray_cast is world space: rays mapped through matrix_world.inverted() (directions by its 3x3 only) hit the same points both ways, and raw world coords handed to Object.ray_cast miss.

+

witnesses Five closed-form rays on a translated, Z-rotated, non-uniformly scaled block: Scene.ray_cast hits each world point and normal exactly; Object.ray_cast fed matrix_world.inverted() coordinates hits the same polygons, and mapped back lands within 5e-7 m; world coords fed to Object.ray_cast land 0.287 m or more off. --world-to-object exits 4 (1.125 m error).

+ +
+
+
+
+ Two checker-glazed columns on a walnut plinth, each in a steel-rod cage: the left upright in its rest cage, the right sheared and tapered where two orange cage corners have been pulled out. +
+
+

Lattice Deform

+

lattice-deform

+

A 2x2x2 Lattice modifier set to KEY_LINEAR on all three axes, with two top control points moved through LatticePoint.co_deform, read back through the depsgraph (evaluated_get, to_mesh, to_mesh_clear).

+

witnesses Linear lattice deform is exact trilinear interpolation: all 602 evaluated verts land on the closed-form blend of the eight co_deform corners (max error 5.6e-7 through a translated, non-uniformly scaled lattice), the undeformed lattice is the identity, and --bspline (the default interpolation) misses by 0.2145 and exits 4.

+ +
+
A grained wooden shipping crate in three-quarter view, PORT ROYAL and NO 17 stencilled along its side slats, with nailed iron corner straps and a bail handle on one end. @@ -1688,7 +1712,7 @@

Sundial

var filtersToggle = document.getElementById('filtersToggle'); var toTop = document.getElementById('toTop'); var total = cards.length; - var COUNT_LABEL = '52 examples, 72 showcase pieces'; + var COUNT_LABEL = '54 examples, 72 showcase pieces'; var LS_KEY = 'bdt-gallery-density'; // Read by detail pages: the Gallery crumb returns here, and the pager // walks the reader's filtered order instead of the full gallery. diff --git a/docs/gallery/lattice-deform/index.html b/docs/gallery/lattice-deform/index.html new file mode 100644 index 00000000..035712aa --- /dev/null +++ b/docs/gallery/lattice-deform/index.html @@ -0,0 +1,1229 @@ + + + + + + Lattice Deform (lattice-deform) — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +

Lattice Deform

+

examples/lattice-deform/

+

A 2x2x2 Lattice modifier set to KEY_LINEAR on all three axes, with two top control points moved through LatticePoint.co_deform, read back through the depsgraph (evaluated_get, to_mesh, to_mesh_clear).

+
+
+ + +

Rendered headless by the example itself. Select it to enlarge.

+
witnesses Linear lattice deform is exact trilinear interpolation: all 602 evaluated verts land on the closed-form blend of the eight co_deform corners (max error 5.6e-7 through a translated, non-uniformly scaled lattice), the undeformed lattice is the identity, and --bspline (the default interpolation) misses by 0.2145 and exits 4.
+

tags mesh modifiers depsgraph

+
+
blender --background --python examples/lattice-deform/lattice_deform.py --
+ +
+
+

A runnable example that deforms a subdivided column with a 2×2×2 lattice set to 'KEY_LINEAR' interpolation on all three axes, then proves, vertex by vertex, that the Lattice modifier's output is exactly the trilinear interpolation of the eight deformed control points. The deformed mesh is read through the depsgraph lifetime contract from depsgraph-and-evaluated-data (evaluated_get → to_mesh → to_mesh_clear), and the lattice points are edited the way mesh-editing-and-bmesh recommends for data-level work: through bpy.data, never bpy.ops.

+

What it witnesses: LatticePoint.co is the read-only rest position and LatticePoint.co_deform is the one you move. With linear interpolation a vertex at normalized rest coordinates (u, v, w) inside the lattice lands on Σ wᵢ · co_deformᵢ with wᵢ = (u or 1−u)(v or 1−v)(w or 1−w), mapped through the lattice object's matrix. The check computes that closed form independently for all 602 vertices, through a lattice object that is translated and non-uniformly scaled (1.7 × 1.7 × 2.4), and requires agreement to 1e-5 in world space (measured: 5.6e-7). It also asserts the undeformed lattice is the identity and that the deformation is not trivially small (max displacement 0.53).

+

The trap it exposes: a new lattice defaults to 'KEY_BSPLINE'. B-spline weights smooth over the control points instead of interpolating them, so a script that moves a corner and expects the mesh to follow it linearly gets a softer, smaller pull. On a 2×2×2 lattice the rest state is still the identity, so nothing looks wrong until a point moves. --bspline keeps the default and fails check 4 with the measured error.

+

The still shows the rule as a before-and-after on a walnut plinth: on the left a render-only twin of the column, undeformed, inside its rest cage; on the right the checked column inside the deformed cage, with the two moved corners in selection orange and a short orange rod from each back to its rest position. The column carries a glazed two-tone checker in Generated (rest) coordinates, so the squares are square before the lattice acts, and their shear and taper in the still are the deformation. Lattices do not render, so both cages are drawn as thin steel rods built from the corner positions after the check has run.

+

Run#

+
# Cheap correctness check (no render) — the CI check:
+blender --background --python lattice_deform.py --
+
+# Falsifier: keep the default KEY_BSPLINE interpolation. Must exit 4.
+blender --background --python lattice_deform.py -- --bspline
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python lattice_deform.py -- --output lattice.png --engine cycles
+

Version notes#

+

The Lattice datablock API (points_u/v/w, interpolation_type_u/v/w, points[i].co / co_deform, point order u fastest, then v, then w) and the 'LATTICE' modifier are the same on 4.5 LTS, 5.1 and 5.2 LTS. Measured identically on 4.5.11, 5.1.2 and 5.2.1: 602 vertices, trilinear max error 5.6e-7, max displacement 0.5311; --bspline max error 0.2145.

+

Exit codes#

+
CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Undeformed lattice moved the mesh (identity check)
4A vertex is off the trilinear closed form, or the vertex count changed (--bspline lands here)
5Max displacement below the floor (the witness would pass vacuously)
6--output produced no file
10--output framing violation (Layer 1 fill / margin gate, gallery_framing)
+

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

+
+
+

Source

+
+ examples/lattice-deform/lattice_deform.py + 460 lines · View on GitHub → +
+
+
"""Lattice deform with linear interpolation is exact trilinear interpolation — a runnable example.
+
+Witnesses the Lattice modifier contract on a 2x2x2 lattice (``points_u``,
+``points_v`` and ``points_w`` all 2). With ``interpolation_type_u/v/w`` set
+to ``'KEY_LINEAR'`` a mesh vertex inside the lattice moves to the trilinear
+interpolation of the eight *deformed* control points (``LatticePoint.co_deform``)
+at the vertex's normalized rest coordinates (u, v, w) in lattice space.
+The check computes that closed form independently in Python for every
+evaluated vertex (``evaluated_get`` + ``to_mesh`` / ``to_mesh_clear``) and
+requires agreement to 1e-5 in world space, through a lattice object that is
+itself translated and non-uniformly scaled.
+
+Three checks, in run order:
+
+- 3: with no control point moved, the modifier is the identity;
+- 4: with two top corners moved, every vertex lands on the trilinear point;
+- 5: the deformation is not trivially small (the witness cannot pass vacuously).
+
+``--bspline`` leaves the lattice on its default ``'KEY_BSPLINE'``
+interpolation, which smooths over the control points instead of
+interpolating them, so the evaluated mesh no longer matches the trilinear
+closed form and check 4 fails with the measured error. That is the
+falsifier — and the trap: a script that sets ``co_deform`` and expects the
+corner to "pull" the mesh linearly gets a softer, smaller motion.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+    blender --background --python lattice_deform.py --                 # check only
+    blender --background --python lattice_deform.py -- --bspline       # must fail
+    blender --background --python lattice_deform.py -- --output l.png  # + render
+"""
+import bpy, bmesh, sys, os, math, argparse
+from mathutils import Vector
+
+# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
+sys.dont_write_bytecode = True  # keep examples/__pycache__ out of the repo tree
+import gallery_framing
+
+# The lattice: a box in world space, as a translated, non-uniformly scaled
+# 2x2x2 lattice object (its points sit at +-0.5 in lattice-local space).
+LAT_LOC = Vector((0.0, 0.0, 1.30))
+LAT_SCALE = Vector((1.70, 1.70, 2.40))
+# The deformed column, inside the lattice on every axis (lattice-local units);
+# its base lies on the lattice's bottom face (w = 0), so it sits on the plinth
+COLUMN_HALF = (0.40, 0.40, 0.50)
+COLUMN_CUTS = 9          # subdivisions per cube edge
+# Two top corners moved (lattice-local offsets): (u, v, w) index -> offset
+MOVES = {
+    (1, 1, 1): Vector((0.30, 0.18, 0.12)),
+    (0, 0, 1): Vector((-0.10, -0.26, -0.08)),
+}
+TOL = 1e-5
+MIN_DISPLACEMENT = 0.05  # world units; check 5's floor
+TWIN_GAP = 2.9           # render only: the undeformed twin's offset to the left
+
+
+def corner_index(u, v, w, n=2):
+    """LatticePoint order: u fastest, then v, then w."""
+    return u + n * (v + n * w)
+
+
+def build_scene(bspline=False):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    scene = bpy.context.scene
+
+    lat = bpy.data.lattices.new("Cage")
+    lat.points_u = lat.points_v = lat.points_w = 2
+    if not bspline:
+        lat.interpolation_type_u = 'KEY_LINEAR'
+        lat.interpolation_type_v = 'KEY_LINEAR'
+        lat.interpolation_type_w = 'KEY_LINEAR'
+    lat_obj = bpy.data.objects.new("Cage", lat)
+    lat_obj.location = LAT_LOC
+    lat_obj.scale = LAT_SCALE
+    scene.collection.objects.link(lat_obj)
+
+    me = bpy.data.meshes.new("Column")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_cube(bm, size=1.0)
+        bmesh.ops.subdivide_edges(bm, edges=bm.edges[:], cuts=COLUMN_CUTS, use_grid_fill=True)
+        # cube [-0.5, 0.5]^3 -> the column's box in lattice-local units -> world
+        for vert in bm.verts:
+            local = Vector(tuple(vert.co[k] * 2.0 * COLUMN_HALF[k] for k in range(3)))
+            vert.co = LAT_LOC + Vector(tuple(local[k] * LAT_SCALE[k] for k in range(3)))
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    obj = bpy.data.objects.new("Column", me)
+    scene.collection.objects.link(obj)
+    mod = obj.modifiers.new("Lattice", 'LATTICE')
+    mod.object = lat_obj
+    return obj, lat_obj
+
+
+def evaluated_world_coords(obj):
+    deps = bpy.context.evaluated_depsgraph_get()
+    ev = obj.evaluated_get(deps)
+    me = ev.to_mesh()
+    try:
+        mw = ev.matrix_world
+        return [mw @ v.co for v in me.vertices]
+    finally:
+        ev.to_mesh_clear()
+
+
+def trilinear(lat_obj, corners, world_rest):
+    """Closed form, independent of Blender's deform code: normalized rest
+    coordinates in lattice space, then the trilinear blend of the deformed
+    corners, mapped back to world."""
+    local = lat_obj.matrix_world.inverted() @ world_rest
+    u, v, w = (local[k] + 0.5 for k in range(3))
+    out = Vector((0.0, 0.0, 0.0))
+    for (i, j, k), co in corners.items():
+        weight = (u if i else 1 - u) * (v if j else 1 - v) * (w if k else 1 - w)
+        out += weight * co
+    return lat_obj.matrix_world @ out
+
+
+def check(obj, lat_obj):
+    bpy.context.view_layer.update()
+    rest = [obj.matrix_world @ v.co for v in obj.data.vertices]
+
+    # 3: an undeformed lattice is the identity
+    ident = evaluated_world_coords(obj)
+    err0 = max((a - b).length for a, b in zip(ident, rest))
+    if err0 > TOL:
+        print(f"ERROR: undeformed lattice moved the mesh (max {err0:.3e} > {TOL:.0e})",
+              file=sys.stderr)
+        return 3
+
+    lat = lat_obj.data
+    for (i, j, k), off in MOVES.items():
+        pt = lat.points[corner_index(i, j, k)]
+        pt.co_deform = Vector(pt.co) + off
+    corners = {(i, j, k): Vector(lat.points[corner_index(i, j, k)].co_deform)
+               for i in (0, 1) for j in (0, 1) for k in (0, 1)}
+    lat.update_tag()
+    obj.update_tag()
+    bpy.context.view_layer.update()
+
+    # 4: every vertex on the trilinear closed form
+    got = evaluated_world_coords(obj)
+    if len(got) != len(rest):
+        print(f"ERROR: vertex count changed {len(rest)} -> {len(got)}", file=sys.stderr)
+        return 4
+    errs = [(g - trilinear(lat_obj, corners, r)).length for g, r in zip(got, rest)]
+    worst = max(range(len(errs)), key=errs.__getitem__)
+    interp = (lat.interpolation_type_u, lat.interpolation_type_v, lat.interpolation_type_w)
+    if errs[worst] > TOL:
+        print(f"ERROR: {sum(e > TOL for e in errs)}/{len(errs)} verts off the trilinear "
+              f"closed form; max {errs[worst]:.4f} at vert {worst} (interpolation {interp})",
+              file=sys.stderr)
+        return 4
+
+    # 5: the witness moved the mesh by a real amount
+    disp = max((g - r).length for g, r in zip(got, rest))
+    if disp < MIN_DISPLACEMENT:
+        print(f"ERROR: max displacement {disp:.4f} < {MIN_DISPLACEMENT}", file=sys.stderr)
+        return 5
+
+    print(f"lattice 2x2x2 {interp}: identity max {err0:.2e}; {len(errs)} verts on the "
+          f"trilinear closed form, max err {errs[worst]:.2e}; max displacement {disp:.4f}")
+    return 0
+
+
+def eevee_engine_id():
+    return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
+
+
+# ---------------------------------------------------------------------------
+# Render staging only (runs after the check; never part of it)
+# ---------------------------------------------------------------------------
+
+def principled(name, base, rough, metal=0.0, noise=None, coat=0.0, emit=None):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    nt = mat.node_tree
+    b = nt.nodes["Principled BSDF"]
+    b.inputs["Base Color"].default_value = (*base, 1.0)
+    b.inputs["Roughness"].default_value = rough
+    b.inputs["Metallic"].default_value = metal
+    if coat:
+        b.inputs["Coat Weight"].default_value = coat
+    if emit:
+        b.inputs["Emission Color"].default_value = (*emit[0], 1.0)
+        b.inputs["Emission Strength"].default_value = emit[1]
+    if noise:
+        tex = nt.nodes.new("ShaderNodeTexNoise")
+        tex.inputs["Scale"].default_value = noise
+        tex.inputs["Detail"].default_value = 8.0
+        mr = nt.nodes.new("ShaderNodeMapRange")
+        mr.inputs["To Min"].default_value = max(rough - 0.08, 0.0)
+        mr.inputs["To Max"].default_value = rough + 0.14
+        nt.links.new(tex.outputs["Fac"], mr.inputs["Value"])
+        nt.links.new(mr.outputs["Result"], b.inputs["Roughness"])
+    return mat
+
+
+def checker_ceramic():
+    """A glazed two-tone checker on the column, in Generated (rest) coordinates:
+    the squares are square before the lattice acts, so their shear and
+    stretch in the still is the deformation itself."""
+    mat = bpy.data.materials.new("CheckerGlaze")
+    mat.use_nodes = True
+    nt = mat.node_tree
+    b = nt.nodes["Principled BSDF"]
+    b.inputs["Roughness"].default_value = 0.32
+    b.inputs["Coat Weight"].default_value = 0.5
+    tc = nt.nodes.new("ShaderNodeTexCoord")
+    chk = nt.nodes.new("ShaderNodeTexChecker")
+    chk.inputs["Scale"].default_value = float(COLUMN_CUTS + 1)
+    chk.inputs["Color1"].default_value = (0.60, 0.56, 0.49, 1.0)
+    chk.inputs["Color2"].default_value = (0.10, 0.22, 0.30, 1.0)
+    nt.links.new(tc.outputs["Generated"], chk.inputs["Vector"])
+    nt.links.new(chk.outputs["Color"], b.inputs["Base Color"])
+    return mat
+
+
+def tube_mesh(name, segments, radius, sides=10):
+    """Render-only rods along line segments (lattices do not render)."""
+    me = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        for a, b in segments:
+            axis = b - a
+            res = bmesh.ops.create_cone(bm, cap_ends=True, segments=sides,
+                                        radius1=radius, radius2=radius, depth=axis.length)
+            rot = Vector((0, 0, 1)).rotation_difference(axis.normalized()).to_matrix()
+            mid = (a + b) / 2
+            for vert in res["verts"]:
+                vert.co = rot @ vert.co + mid
+        bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    for p in me.polygons:
+        p.use_smooth = True
+    return me
+
+
+def sphere_mesh(name, centers, radius):
+    me = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        for c in centers:
+            res = bmesh.ops.create_uvsphere(bm, u_segments=24, v_segments=14, radius=radius)
+            for vert in res["verts"]:
+                vert.co += c
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    for p in me.polygons:
+        p.use_smooth = True
+    return me
+
+
+def box_mesh(name, lo, hi):
+    me = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        res = bmesh.ops.create_cube(bm, size=1.0)
+        for vert in res["verts"]:
+            vert.co = Vector(tuple(lo[k] + (vert.co[k] + 0.5) * (hi[k] - lo[k]) for k in range(3)))
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    return me
+
+
+def cage_edges(points):
+    """The 12 edges of a 2x2x2 cage given corner positions keyed (i, j, k)."""
+    edges = []
+    for i in (0, 1):
+        for j in (0, 1):
+            edges.append((points[(i, j, 0)], points[(i, j, 1)]))
+            edges.append((points[(i, 0, j)], points[(i, 1, j)]))
+            edges.append((points[(0, i, j)], points[(1, i, j)]))
+    return edges
+
+
+def render_still(obj, lat_obj, path, engine):
+    scene = bpy.context.scene
+    mw = lat_obj.matrix_world
+    lat = lat_obj.data
+    rest = {(i, j, k): mw @ Vector(lat.points[corner_index(i, j, k)].co)
+            for i in (0, 1) for j in (0, 1) for k in (0, 1)}
+    moved = {(i, j, k): mw @ Vector(lat.points[corner_index(i, j, k)].co_deform)
+             for i in (0, 1) for j in (0, 1) for k in (0, 1)}
+
+    obj.data.materials.append(checker_ceramic())
+    for p in obj.data.polygons:
+        p.use_smooth = False
+
+    steel = principled("CageSteel", (0.58, 0.60, 0.64), 0.22, metal=1.0)
+    ghost = principled("RestCage", (0.16, 0.17, 0.19), 0.6, metal=0.3)
+    orange = principled("MovedCorner", (1.0, 0.42, 0.04), 0.3,
+                        emit=((1.0, 0.45, 0.06), 1.5))
+    joint = principled("CageJoint", (0.30, 0.31, 0.34), 0.3, metal=1.0)
+    walnut = principled("Walnut", (0.13, 0.055, 0.025), 0.45, noise=40.0, coat=0.4)
+
+    parts = []
+
+    def add(name, me, mat):
+        me.materials.append(mat)
+        ob = bpy.data.objects.new(name, me)
+        scene.collection.objects.link(ob)
+        parts.append(ob)
+        return ob
+
+    add("DeformedCage", tube_mesh("DeformedCage", cage_edges(moved), 0.022), steel)
+    # the rest cage, thin and dark: what the two corners were moved away from
+    rest_only = [(a, b) for a, b in cage_edges(rest)]
+    add("RestCage", tube_mesh("RestCage", rest_only, 0.009), ghost)
+    still = [moved[key] for key in moved if key not in MOVES]
+    add("CageJoints", sphere_mesh("CageJoints", still, 0.05), joint)
+    add("MovedCorners", sphere_mesh("MovedCorners", [moved[key] for key in MOVES], 0.075),
+        orange)
+    # a travel rod from each moved corner back to its rest position
+    add("Travel", tube_mesh("Travel", [(rest[key], moved[key]) for key in MOVES], 0.012),
+        orange)
+
+    # Render-only "before" twin: the same column, unmodified, in its rest cage,
+    # one cage-width to the left, so the still reads rest -> deformed.
+    shift = Vector((-TWIN_GAP, 0.0, 0.0))
+    twin_me = obj.data.copy()
+    twin = bpy.data.objects.new("RestColumn", twin_me)
+    twin.location = shift
+    scene.collection.objects.link(twin)
+    parts.append(twin)
+    rest_twin = {key: co + shift for key, co in rest.items()}
+    add("TwinCage", tube_mesh("TwinCage", cage_edges(rest_twin), 0.022), steel)
+    add("TwinJoints", sphere_mesh("TwinJoints", list(rest_twin.values()), 0.05), joint)
+
+    lo_z = LAT_LOC.z - LAT_SCALE.z / 2
+    half_x = LAT_SCALE.x / 2 + 0.45
+    plinth = add("Plinth", box_mesh("Plinth", (-TWIN_GAP - half_x, -1.3, 0.0),
+                                    (half_x, 1.3, lo_z - 0.002)), walnut)
+    bev = plinth.modifiers.new("Chamfer", 'BEVEL')
+    bev.width = 0.03
+    bev.segments = 2
+
+    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, 7.5, 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
+
+    centre = Vector((-TWIN_GAP / 2, 0.0, LAT_LOC.z))
+
+    def light(name, loc, energy, size, col, aim):
+        ld = bpy.data.lights.new(name, 'AREA')
+        ld.energy = energy; ld.size = size; ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = (Vector(aim) - Vector(loc)).to_track_quat('-Z', 'Y').to_euler()
+        scene.collection.objects.link(ob)
+
+    light("Key", (-4.5, -5.0, 6.5), 520.0, 4.0, (1.0, 0.96, 0.9), centre)
+    light("Fill", (5.5, -4.5, 2.5), 110.0, 7.0, (0.75, 0.85, 1.0), centre)
+    light("Rim", (1.5, 4.0, 6.0), 380.0, 3.0, (0.6, 0.78, 1.0), centre)
+    light("Wedge", (2.5, 4.5, 3.0), 420.0, 5.0, (1.0, 0.76, 0.5), (4.5, 7.5, 1.0))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (centre.x + 3.6, -9.6, 3.9)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = centre + Vector((0.05, 0.0, -0.15))
+    scene.collection.objects.link(aim)
+    tr = cam.constraints.new('TRACK_TO')
+    tr.target = aim
+    tr.track_axis = 'TRACK_NEGATIVE_Z'
+    tr.up_axis = 'UP_Y'
+    scene.camera = cam
+
+    scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id()
+    if engine == 'cycles':
+        scene.cycles.samples = 48
+    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
+    # AgX would wash the glaze and the orange accent toward pastel (docs/VISUAL-STYLE.md)
+    scene.view_settings.view_transform = 'Standard'
+    bpy.context.view_layer.update()
+    # Layer 1 framing gate (silhouette matte) — exit 10 on violation, before
+    # the beauty render so a defective composition ships no artifact
+    fcode = gallery_framing.check_framing(
+        scene, cam,
+        hero=[obj] + parts,
+        elements=[obj] + parts,
+        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 6
+    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("--bspline", action="store_true",
+                   help="keep the default KEY_BSPLINE interpolation (must fail)")
+    args = p.parse_args(argv)
+
+    obj, lat_obj = build_scene(bspline=args.bspline)
+    code = check(obj, lat_obj)
+    if code:
+        return code
+
+    if args.output:
+        rcode = render_still(obj, lat_obj, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+
+    print("lattice-deform 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)
+
+
+
+ + +
+ + + Two checker-glazed columns on a walnut plinth, each in a steel-rod cage: the left upright in its rest cage, the right sheared and tapered where two orange cage corners have been pulled out. + + +

+ + + diff --git a/docs/gallery/light-link-studio/index.html b/docs/gallery/light-link-studio/index.html index 7d24c34f..4ece87b2 100644 --- a/docs/gallery/light-link-studio/index.html +++ b/docs/gallery/light-link-studio/index.html @@ -45,7 +45,7 @@

Light Link Studio

One key, one hero: a light linked to a receiver collection lights only the hero, proven by two pixel renders in one pass. Linked: 3.6x luminance ratio; unlinked in the same check: the decoy rises 233% while the hero holds at 0.3% drift.

- + @@ -1170,7 +1170,7 @@

Parent Inverse Orrery

- + diff --git a/docs/gallery/lightmap-uv-channel/index.html b/docs/gallery/lightmap-uv-channel/index.html index 0d096e76..c566d8a2 100644 --- a/docs/gallery/lightmap-uv-channel/index.html +++ b/docs/gallery/lightmap-uv-channel/index.html @@ -45,7 +45,7 @@

Lightmap UV Channel

A market cart carrying the two-channel UV contract for baked lighting: UV0 untouched, UVLight packed with no overlaps and a respected margin.

- + @@ -2154,7 +2154,7 @@

Compositor Glare

- + diff --git a/docs/gallery/lod-decimate-chain/index.html b/docs/gallery/lod-decimate-chain/index.html index f61afe37..6c984dd3 100644 --- a/docs/gallery/lod-decimate-chain/index.html +++ b/docs/gallery/lod-decimate-chain/index.html @@ -45,7 +45,7 @@

LOD Decimate Chain

A retro rocket at LOD0/1/2 via the Decimate modifier evaluated through the depsgraph, with a wireframe of each evaluated mesh. The check proves the reduction is non-destructive, the triangle count hits ratio x base within bounds, and silhouette-critical dimensions survive.

- + @@ -1364,34 +1364,34 @@ - +
diff --git a/docs/gallery/mesh-hygiene-audit/index.html b/docs/gallery/mesh-hygiene-audit/index.html index 70119bce..d1cce2ef 100644 --- a/docs/gallery/mesh-hygiene-audit/index.html +++ b/docs/gallery/mesh-hygiene-audit/index.html @@ -45,7 +45,7 @@

Mesh Hygiene Audit

Engine-ingest mesh hygiene on every part of a flanged street valve: no ngons, no loose verts, manifold edges, no zero-area faces, contiguous and outward winding, Euler V-E+F==2 on the body casting. A dirty copy carries a hole, a flipped patch, an ngon and loose verts, each marked from live audit incidence; the paint glows red wherever the renderer sees a back face.

- + @@ -1584,7 +1584,7 @@

BMesh Gear

- +
diff --git a/docs/gallery/modular-kit-snap/index.html b/docs/gallery/modular-kit-snap/index.html index 8cbd0bff..4c22e1ec 100644 --- a/docs/gallery/modular-kit-snap/index.html +++ b/docs/gallery/modular-kit-snap/index.html @@ -45,7 +45,7 @@

Modular Kit Snap

A tiling corridor kit whose open-end boundary verts snap to the tile grid, so instances at 4 m multiples join with zero gap or overlap.

- + @@ -1675,7 +1675,7 @@

Socket Attach Points< - +

diff --git a/docs/gallery/parent-inverse-orrery/index.html b/docs/gallery/parent-inverse-orrery/index.html index 8a958bc4..d86178e7 100644 --- a/docs/gallery/parent-inverse-orrery/index.html +++ b/docs/gallery/parent-inverse-orrery/index.html @@ -45,7 +45,7 @@

Parent Inverse Orrery

Data-API parenting for a brass orrery — the keep-world idiom (child.parent = pivot; child.matrix_parent_inverse = pivot.matrix_world.inverted()) carrying arms, planets, and a two-level moon through spinning pivots.

- + @@ -1006,34 +1006,34 @@ - +
diff --git a/docs/gallery/png-exr-alpha/index.html b/docs/gallery/png-exr-alpha/index.html index 08b38b43..3a1e0ec7 100644 --- a/docs/gallery/png-exr-alpha/index.html +++ b/docs/gallery/png-exr-alpha/index.html @@ -45,7 +45,7 @@

PNG EXR Alpha

Float-image PNG save trap — float_buffer=True Image.save() writes RGBA16 and unpremultiplies as if associated-alpha, clamping straight-authored dark values at low alpha to white (closed-form err 0.98 at RGB 0.02 / a=1/255).

- + @@ -1579,7 +1579,7 @@

Compositor Glare

- +
diff --git a/docs/gallery/prop-origin-transform/index.html b/docs/gallery/prop-origin-transform/index.html index 183911f0..a863af4b 100644 --- a/docs/gallery/prop-origin-transform/index.html +++ b/docs/gallery/prop-origin-transform/index.html @@ -45,7 +45,7 @@

Prop Origin Transform

Street pedestal origin-to-base-center + data-API scale apply + matrix_parent_inverse for a flanged conduit elbow. After bake: scale (1,1,1), local min.z==0, world AABB unchanged. Bare parent throws the elbow off its mount; MPI keeps it seated.

- + @@ -1715,7 +1715,7 @@

Mesh Hygiene Audit

- +
diff --git a/docs/gallery/ray-cast-space/index.html b/docs/gallery/ray-cast-space/index.html new file mode 100644 index 00000000..9cc5238b --- /dev/null +++ b/docs/gallery/ray-cast-space/index.html @@ -0,0 +1,1262 @@ + + + + + + Ray Cast Space (ray-cast-space) — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +

Ray Cast Space

+

examples/ray-cast-space/

+

Object.ray_cast is object-local while Scene.ray_cast is world space: rays mapped through matrix_world.inverted() (directions by its 3x3 only) hit the same points both ways, and raw world coords handed to Object.ray_cast miss.

+
+
+ + +

Rendered headless by the example itself. Select it to enlarge.

+
witnesses Five closed-form rays on a translated, Z-rotated, non-uniformly scaled block: Scene.ray_cast hits each world point and normal exactly; Object.ray_cast fed matrix_world.inverted() coordinates hits the same polygons, and mapped back lands within 5e-7 m; world coords fed to Object.ray_cast land 0.287 m or more off. --world-to-object exits 4 (1.125 m error).
+

tags objects transforms depsgraph

+
+
blender --background --python examples/ray-cast-space/ray_cast_space.py --
+ +
+
+

A runnable example of the coordinate-space contract behind Blender's two ray casts, the one AI-written code gets wrong most often. Scene.ray_cast(depsgraph, origin, direction) takes and returns world space. Object.ray_cast(origin, direction) takes and returns the object's local space. Feed world coordinates to Object.ray_cast and nothing raises: it quietly misses, or hits the wrong spot.

+

What it witnesses: a target block carries a transform with every ingredient that separates the two spaces: a translation, a 32° turn about Z, and non-uniform scale (1.55 × 0.62 × 0.62). The example casts five rays at closed-form points on three of its faces, arriving obliquely, and checks each one three ways:

+
  1. Scene.ray_cast hits the closed-form world point (to 1e-4 m). Its normal matches the face's world normal, which comes from the inverse-transpose of matrix_world.
  2. Object.ray_cast is fed the origin through matrix_world.inverted() and the direction through that matrix's 3×3 part only, because directions do not translate. It must hit the same polygon. Its local hit point, mapped back through matrix_world (and its normal through the inverse-transpose), must land on the same world point and normal.
  3. The trap must bite on this setup. The same world origin and direction handed straight to Object.ray_cast either misses or lands at least 0.25 m from the true hit. Without this, the check could pass by coincidence.
+

Both 5.2 LTS and 4.5 LTS give identical results: every hit exact to under 1e-6 m, and the world-coordinate trap's closest landing 0.287 m away.

+

The still shows the block glazed in a teal checker laid out in object coordinates. The cells are square in local space, so the non-uniform scale stretches them into the block's own grid. The block stands on a walnut plinth. Five orange rays run from brass emitter balls to orange hit rings that sit exactly on three faces: the correct casts. One red ray is the first front-face ray's world origin and direction read by Object.ray_cast as if local. In the world that is the ray matrix_world @ origin along matrix_world.to_3x3() @ direction. It starts somewhere else entirely and strikes the far side of the block. A render-only Bevel modifier rounds the block's edges after the check has run.

+

Run#

+
# Cheap correctness check (no render) — the CI check:
+blender --background --python ray_cast_space.py --
+
+# Falsifier: feed WORLD coords to Object.ray_cast in the checked path. Must exit 4.
+blender --background --python ray_cast_space.py -- --world-to-object
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python ray_cast_space.py -- --output ray.png
+blender --background --python ray_cast_space.py -- --output ray.png --engine cycles
+

--world-to-object measured on 5.2.1 and 4.5.11: Object.ray_cast ray 0 mapped back to (1.5668, 1.22769, 1.38161), error 1.1253 m (normal error 1.0000, polygon 2 vs Scene 3). The cast hit a different face of the block, a metre from the true point.

+

Exit codes#

+
CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Scene.ray_cast missed, hit another object, or hit off the closed-form world point or normal
4Object.ray_cast missed, or its local hit mapped back by matrix_world disagrees with the world point, normal or polygon (--world-to-object lands here)
5The world-coordinate trap did not bite: world coords fed to Object.ray_cast landed within TRAP_MIN of a true hit
6--output produced no file
10--output framing violation (Layer 1 fill / margin gate, gallery_framing)
+

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 --world-to-object.

+
+
+

Source

+
+ examples/ray-cast-space/ray_cast_space.py + 476 lines · View on GitHub → +
+
+
"""Object.ray_cast is object-local, Scene.ray_cast is world space — a runnable example.
+
+Witnesses the coordinate-space contract of the two ray casts, the one AI code
+gets wrong most often: ``Scene.ray_cast(depsgraph, origin, direction)`` takes
+and returns WORLD space, while ``Object.ray_cast(origin, direction)`` takes
+and returns the object's LOCAL space. A target block carries a non-trivial
+transform (translation, a turn about Z, non-uniform scale). Five rays aimed at
+closed-form points on three of its faces are cast both ways:
+
+- ``Scene.ray_cast`` must hit each closed-form world point, with the face's
+  world normal (inverse-transpose of the object matrix) and the target object;
+- ``Object.ray_cast``, fed the origin through ``matrix_world.inverted()`` and
+  the direction through its 3x3 part only (directions do not translate), must
+  hit the same polygon, and its local hit point mapped back by
+  ``matrix_world`` (normal by the inverse-transpose) must land on the same
+  world point and normal;
+- the trap: the same WORLD origin/direction handed straight to
+  ``Object.ray_cast`` must NOT reproduce the hit — every such cast misses or
+  lands at least ``TRAP_MIN`` away, so the check proves the transform matters
+  on this setup rather than passing by coincidence.
+
+``--world-to-object`` feeds world coordinates to ``Object.ray_cast`` in the
+checked path (the bug); the local-hit check catches it (exit 4) and prints
+the measured error.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+    blender --background --python ray_cast_space.py --                       # check only
+    blender --background --python ray_cast_space.py -- --world-to-object     # must fail
+    blender --background --python ray_cast_space.py -- --output r.png        # + render
+"""
+import bpy, bmesh, sys, os, math, argparse
+from mathutils import Vector, Matrix
+
+# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
+sys.dont_write_bytecode = True  # keep examples/__pycache__ out of the repo tree
+import gallery_framing
+
+# The target: a unit-half-size cube (local coords in [-1, 1]^3) under a
+# transform with every ingredient that separates local from world space.
+TARGET_LOC = (0.35, 0.25, 0.80)
+TARGET_ROT_Z = math.radians(32.0)
+TARGET_SCALE = (1.55, 0.62, 0.62)
+
+# Closed-form hit points, authored in LOCAL face coordinates, plus a tangent
+# tilt so the rays arrive obliquely rather than straight down each normal.
+# (face axis, face sign, local point on that face, tilt in local tangent space)
+RAYS = (
+    ("y", -1, (0.55, -1.0, 0.35), (0.35, 0.0, 0.25)),
+    ("y", -1, (-0.45, -1.0, -0.30), (-0.30, 0.0, 0.20)),
+    ("z", +1, (0.40, 0.30, 1.0), (0.25, -0.35, 0.0)),
+    ("z", +1, (-0.60, -0.40, 1.0), (-0.20, -0.30, 0.0)),
+    ("x", -1, (-1.0, -0.35, 0.30), (0.0, -0.30, 0.25)),
+)
+RAY_LEN = 1.7        # world distance from each ray origin to its hit
+POS_TOL = 1e-4       # world-space hit tolerance
+NRM_TOL = 1e-4       # 1 - dot(normal, expected)
+TRAP_MIN = 0.25      # a world-coord Object.ray_cast must miss or land this far off
+
+
+def build_target():
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    me = bpy.data.meshes.new("TargetBlock")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_cube(bm, size=2.0)
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    obj = bpy.data.objects.new("TargetBlock", me)
+    bpy.context.collection.objects.link(obj)
+    obj.location = TARGET_LOC
+    obj.rotation_euler = (0.0, 0.0, TARGET_ROT_Z)
+    obj.scale = TARGET_SCALE
+    bpy.context.view_layer.update()
+    return obj
+
+
+def world_rays(obj):
+    """Closed-form world rays: (origin, direction, hit point, face normal).
+
+    The hit point is the local face point through matrix_world; the world
+    normal is the local face normal through the inverse-transpose 3x3. The
+    origin sits RAY_LEN back along the (tilted) incoming direction, in the
+    face's outer half-space, so on this convex block the first surface the
+    ray meets is that face at that point.
+    """
+    mw = obj.matrix_world
+    n_mat = mw.to_3x3().inverted().transposed()
+    out = []
+    for axis, sign, p_local, tilt in RAYS:
+        n_local = Vector((0.0, 0.0, 0.0))
+        n_local["xyz".index(axis)] = sign
+        p = mw @ Vector(p_local)
+        n = (n_mat @ n_local).normalized()
+        incoming = (n_local + Vector(tilt))       # outward-ish, in local space
+        d = -(mw.to_3x3() @ incoming).normalized()
+        if d.dot(n) >= 0.0:
+            raise ValueError(f"ray at {p_local} does not approach its face from outside")
+        out.append((p - RAY_LEN * d, d, p, n))
+    return out
+
+
+def object_space(obj, origin, direction):
+    """World ray -> the object's local space: the origin through the full
+    inverse matrix, the direction through its 3x3 only (no translation)."""
+    inv = obj.matrix_world.inverted()
+    return inv @ origin, (inv.to_3x3() @ direction).normalized()
+
+
+def check(obj, world_to_object=False):
+    dg = bpy.context.evaluated_depsgraph_get()
+    scene = bpy.context.scene
+    mw = obj.matrix_world
+    n_mat = mw.to_3x3().inverted().transposed()
+    rays = world_rays(obj)
+
+    # 1. Scene.ray_cast: world in, world out
+    scene_hits = []
+    for k, (o, d, p, n) in enumerate(rays):
+        ok, loc, nrm, idx, hit_ob, _m = scene.ray_cast(dg, o, d)
+        if not ok or hit_ob is None or hit_ob.name != obj.name:
+            print(f"ERROR: Scene.ray_cast ray {k} missed the target", file=sys.stderr)
+            return 3
+        err, nerr = (loc - p).length, 1.0 - nrm.normalized().dot(n)
+        if err > POS_TOL or nerr > NRM_TOL:
+            print(f"ERROR: Scene.ray_cast ray {k} hit {tuple(round(c, 5) for c in loc)} "
+                  f"(error {err:.6f} m, normal error {nerr:.6f}) vs closed form "
+                  f"{tuple(round(c, 5) for c in p)}", file=sys.stderr)
+            return 3
+        scene_hits.append(idx)
+
+    # 2. Object.ray_cast: local in, local out — map both ways
+    worst = 0.0
+    for k, (o, d, p, n) in enumerate(rays):
+        if world_to_object:
+            lo, ld = o, d                      # the bug: world coords as if local
+        else:
+            lo, ld = object_space(obj, o, d)
+        ok, loc, nrm, idx = obj.ray_cast(lo, ld, depsgraph=dg)
+        if not ok:
+            print(f"ERROR: Object.ray_cast ray {k} missed (origin/direction not in "
+                  f"object space?)", file=sys.stderr)
+            return 4
+        p_back = mw @ loc
+        n_back = (n_mat @ nrm).normalized()
+        err, nerr = (p_back - p).length, 1.0 - n_back.dot(n)
+        worst = max(worst, err)
+        if err > POS_TOL or nerr > NRM_TOL or idx != scene_hits[k]:
+            print(f"ERROR: Object.ray_cast ray {k} mapped back to "
+                  f"{tuple(round(c, 5) for c in p_back)}, error {err:.4f} m "
+                  f"(normal error {nerr:.4f}, polygon {idx} vs Scene {scene_hits[k]})",
+                  file=sys.stderr)
+            return 4
+
+    # 3. The trap must actually bite on this setup
+    trap_min = math.inf
+    for k, (o, d, p, n) in enumerate(rays):
+        ok, loc, _nrm, _idx = obj.ray_cast(o, d, depsgraph=dg)
+        dev = (mw @ loc - p).length if ok else math.inf
+        trap_min = min(trap_min, dev)
+    if trap_min < TRAP_MIN:
+        print(f"ERROR: world coords fed to Object.ray_cast landed only {trap_min:.4f} m "
+              f"from a true hit (< {TRAP_MIN}); the transform does not separate the "
+              f"spaces", file=sys.stderr)
+        return 5
+
+    trap_txt = "all miss" if trap_min == math.inf else f"closest {trap_min:.3f} m off"
+    print(f"rays={len(rays)} scene_hits=exact object_local_hits=exact "
+          f"max_mapped_error={worst:.2e} m world_coords_trap={trap_txt}")
+    return 0
+
+
+def eevee_engine_id():
+    return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
+
+
+# --------------------------------------------------------------------------
+# Render staging only (not part of the check)
+# --------------------------------------------------------------------------
+
+def principled(name, base, rough, metal=0.0, emit=None, strength=0.0, alpha=1.0):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    b = mat.node_tree.nodes["Principled BSDF"]
+    b.inputs["Base Color"].default_value = (*base, 1.0)
+    b.inputs["Roughness"].default_value = rough
+    b.inputs["Metallic"].default_value = metal
+    if emit is not None:
+        b.inputs["Emission Color"].default_value = (*emit, 1.0)
+        b.inputs["Emission Strength"].default_value = strength
+    if alpha < 1.0:
+        b.inputs["Alpha"].default_value = alpha
+        try:
+            mat.surface_render_method = 'BLENDED'
+        except AttributeError:
+            pass
+    return mat
+
+
+def block_material():
+    """Glazed teal ceramic with an OBJECT-space checker: the cells are square
+    in local space, so the non-uniform scale stretches them into the
+    rectangles the eye reads as 'this object has its own coordinates'. Teal
+    sits opposite the orange rays, so every hit marker reads at thumbnail size."""
+    mat = bpy.data.materials.new("LocalSpaceCeramic")
+    mat.use_nodes = True
+    nt = mat.node_tree
+    b = nt.nodes["Principled BSDF"]
+    b.inputs["Roughness"].default_value = 0.32
+    b.inputs["Coat Weight"].default_value = 0.35
+    coords = nt.nodes.new("ShaderNodeTexCoord")
+    chk = nt.nodes.new("ShaderNodeTexChecker")
+    chk.inputs["Scale"].default_value = 4.0
+    chk.inputs["Color1"].default_value = (0.09, 0.36, 0.40, 1.0)
+    chk.inputs["Color2"].default_value = (0.035, 0.15, 0.18, 1.0)
+    # Object coords span -1..1; shift to 0..2 so cells line up with face edges
+    add = nt.nodes.new("ShaderNodeVectorMath")
+    add.operation = 'ADD'
+    add.inputs[1].default_value = (1.0, 1.0, 1.0)
+    nt.links.new(coords.outputs["Object"], add.inputs[0])
+    nt.links.new(add.outputs["Vector"], chk.inputs["Vector"])
+    nt.links.new(chk.outputs["Color"], b.inputs["Base Color"])
+    return mat
+
+
+def rod(name, a, b, radius, mat, segs=16):
+    """A thin cylinder from a to b (render-only ray segment)."""
+    me = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        length = (b - a).length
+        bmesh.ops.create_cone(bm, cap_ends=True, segments=segs, radius1=radius,
+                              radius2=radius, depth=length)
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    me.materials.append(mat)
+    for poly in me.polygons:
+        poly.use_smooth = True
+    ob = bpy.data.objects.new(name, me)
+    bpy.context.scene.collection.objects.link(ob)
+    ob.location = (a + b) / 2
+    ob.rotation_euler = (b - a).to_track_quat('Z', 'Y').to_euler()
+    return ob
+
+
+def sphere(name, at, radius, mat):
+    me = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_uvsphere(bm, u_segments=24, v_segments=12, radius=radius)
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    me.materials.append(mat)
+    for poly in me.polygons:
+        poly.use_smooth = True
+    ob = bpy.data.objects.new(name, me)
+    bpy.context.scene.collection.objects.link(ob)
+    ob.location = at
+    return ob
+
+
+def ring(name, at, normal, r_out, r_in, mat):
+    """A flat hit-marker annulus lying on the surface, facing out along normal."""
+    me = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        segs = 32
+        outer = [bm.verts.new((r_out * math.cos(2 * math.pi * i / segs),
+                               r_out * math.sin(2 * math.pi * i / segs), 0.0))
+                 for i in range(segs)]
+        inner = [bm.verts.new((r_in * math.cos(2 * math.pi * i / segs),
+                               r_in * math.sin(2 * math.pi * i / segs), 0.0))
+                 for i in range(segs)]
+        for i in range(segs):
+            j = (i + 1) % segs
+            bm.faces.new((outer[i], outer[j], inner[j], inner[i]))
+        ext = bmesh.ops.extrude_face_region(bm, geom=list(bm.faces))
+        top = [e for e in ext["geom"] if isinstance(e, bmesh.types.BMVert)]
+        bmesh.ops.translate(bm, verts=top, vec=(0.0, 0.0, 0.006))
+        bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    me.materials.append(mat)
+    ob = bpy.data.objects.new(name, me)
+    bpy.context.scene.collection.objects.link(ob)
+    ob.location = at + normal * 0.002
+    ob.rotation_euler = normal.to_track_quat('Z', 'Y').to_euler()
+    return ob
+
+
+def render_still(obj, path, engine):
+    scene = bpy.context.scene
+    dg = bpy.context.evaluated_depsgraph_get()
+    rays = world_rays(obj)
+    # one ghosted trap ray: the first front-face ray's WORLD origin/direction
+    # read by Object.ray_cast as if local. In the world that is the ray
+    # matrix_world @ o along matrix_world.to_3x3() @ d; cast it before any
+    # render-only modifier changes the evaluated mesh.
+    mw = obj.matrix_world
+    o, d, p, n = rays[0]
+    t_o = mw @ o
+    t_d = (mw.to_3x3() @ d).normalized()
+    t_ok, t_loc, _n, _i = obj.ray_cast(o, d, depsgraph=dg)
+    t_end = (mw @ t_loc) if t_ok else (t_o + 3.0 * t_d)
+    obj.data.materials.append(block_material())
+    bev = obj.modifiers.new("Chamfer", 'BEVEL')   # render-only, after the check
+    bev.width = 0.025
+    bev.segments = 3
+    bev.affect = 'EDGES'
+
+    # emission kept under clipping in Standard view, so the beams stay orange
+    beam = principled("RayBeam", (1.0, 0.26, 0.02), 0.3, emit=(1.0, 0.26, 0.02), strength=1.4)
+    hit_mat = principled("HitMarker", (1.0, 0.36, 0.04), 0.25, emit=(1.0, 0.36, 0.04),
+                         strength=1.8)
+    emitter = principled("RayEmitter", (0.85, 0.58, 0.24), 0.38, metal=1.0)
+    trap_beam = principled("TrapBeam", (0.9, 0.12, 0.18), 0.4, emit=(1.0, 0.1, 0.16),
+                           strength=1.2)
+    trap_end = principled("TrapEnd", (0.9, 0.12, 0.18), 0.35, emit=(1.0, 0.1, 0.16),
+                          strength=1.5)
+    plinth_mat = principled("PlinthWalnut", (0.13, 0.055, 0.025), 0.45)
+
+    shown = [obj]
+    for k, (o, d, p, n) in enumerate(rays):
+        shown.append(rod(f"Ray{k}", o, p, 0.022, beam))
+        shown.append(sphere(f"Emitter{k}", o, 0.07, emitter))
+        shown.append(ring(f"Hit{k}", p, n, 0.10, 0.045, hit_mat))
+
+    trap = [rod("TrapRay", t_o, t_end, 0.016, trap_beam),
+            sphere("TrapEmitter", t_o, 0.06, trap_end)]
+    if not t_ok:
+        trap.append(sphere("TrapMissEnd", t_end, 0.03, trap_end))
+
+    # a low walnut plinth the block stands on (block bottom at z = loc - scale)
+    base_z = TARGET_LOC[2] - TARGET_SCALE[2]
+    pme = bpy.data.meshes.new("Plinth")
+    bm = bmesh.new()
+    try:
+        res = bmesh.ops.create_cone(bm, cap_ends=True, segments=64, radius1=1.9,
+                                    radius2=1.9, depth=base_z)
+        for v in res["verts"]:
+            v.co.z += base_z / 2
+        bm.to_mesh(pme)
+    finally:
+        bm.free()
+    pme.materials.append(plinth_mat)
+    for poly in pme.polygons:
+        poly.use_smooth = True
+    plinth = bpy.data.objects.new("Plinth", pme)
+    scene.collection.objects.link(plinth)
+    plinth.location = (TARGET_LOC[0], TARGET_LOC[1], 0.0)
+    pb = plinth.modifiers.new("Chamfer", 'BEVEL')
+    pb.width = 0.02
+    pb.segments = 3
+    pb.limit_method = 'ANGLE'
+
+    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, 7.5, 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
+
+    centre = Vector((TARGET_LOC[0], TARGET_LOC[1], 1.05))
+
+    def light(name, loc, energy, size, col, aim):
+        ld = bpy.data.lights.new(name, 'AREA')
+        ld.energy = energy; ld.size = size; ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = (Vector(aim) - Vector(loc)).to_track_quat('-Z', 'Y').to_euler()
+        scene.collection.objects.link(ob)
+
+    light("Key", (-4.0, -5.0, 6.0), 520.0, 4.0, (1.0, 0.96, 0.9), centre)
+    light("Fill", (5.5, -4.0, 2.5), 110.0, 6.0, (0.75, 0.85, 1.0), centre)
+    light("Rim", (-1.0, 5.0, 6.0), 320.0, 3.0, (0.6, 0.78, 1.0), centre)
+    light("Wedge", (2.5, 4.0, 3.0), 420.0, 5.0, (1.0, 0.76, 0.5), (4.0, 7.5, 1.0))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = centre + 1.15 * Vector((-5.2, -8.6, 4.4))
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = centre
+    scene.collection.objects.link(aim)
+    tr = cam.constraints.new('TRACK_TO')
+    tr.target = aim
+    tr.track_axis = 'TRACK_NEGATIVE_Z'
+    tr.up_axis = 'UP_Y'
+    scene.camera = cam
+
+    scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id()
+    if engine == 'cycles':
+        scene.cycles.samples = 48
+    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
+    # AgX would wash the emissive orange rays toward pastel (docs/VISUAL-STYLE.md)
+    scene.view_settings.view_transform = 'Standard'
+    bpy.context.view_layer.update()
+    # Layer 1 framing gate (silhouette matte) — exit 10 on violation
+    fcode = gallery_framing.check_framing(
+        scene, cam,
+        hero=shown + [plinth],
+        elements=shown + trap + [plinth],
+        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 6
+    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("--world-to-object", action="store_true",
+                   help="feed world coords to Object.ray_cast in the checked path (must fail)")
+    args = p.parse_args(argv)
+
+    obj = build_target()
+    code = check(obj, world_to_object=args.world_to_object)
+    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("ray-cast-space 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)
+
+
+
+ + +
+ + + A teal-checkered block turned and stretched on a walnut plinth, five orange rays from brass balls ending in orange rings on three faces, and one red ray striking it from elsewhere. + + +

+ + + diff --git a/docs/gallery/shader-node-group/index.html b/docs/gallery/shader-node-group/index.html index b8cc14fe..22af579a 100644 --- a/docs/gallery/shader-node-group/index.html +++ b/docs/gallery/shader-node-group/index.html @@ -45,7 +45,7 @@

Shader Node Group

One reusable shader group declared via tree.interface.new_socket, instanced in five materials with different Tint values — one dipped-glaze group, five stoneware mugs, five colors.

- + @@ -946,7 +946,7 @@

Color Attribute Wheel - +

diff --git a/docs/gallery/shape-key-blend/index.html b/docs/gallery/shape-key-blend/index.html index 86b11db3..94bd28ab 100644 --- a/docs/gallery/shape-key-blend/index.html +++ b/docs/gallery/shape-key-blend/index.html @@ -45,7 +45,7 @@

Shape Key Blend

A relative Tall shape key that turns a squat ceramic jar into a trumpet vase — lifting and flaring the rim — authored via shape_key_add / key_blocks / .value and read back from the depsgraph-evaluated mesh.

- + @@ -1057,7 +1057,7 @@

Color Attribute Wheel - +

diff --git a/docs/gallery/sky-texture-sun-elevation/index.html b/docs/gallery/sky-texture-sun-elevation/index.html index 4f92c0ef..c28379e6 100644 --- a/docs/gallery/sky-texture-sun-elevation/index.html +++ b/docs/gallery/sky-texture-sun-elevation/index.html @@ -45,7 +45,7 @@

Sky Texture Sun Elevation

World ShaderNodeTexSky driving Background Color — the sky contract across 4.5 LTS and 5.1. sky_type is NISHITA on 4.5 and MULTIPLE_SCATTERING on 5.1 (NISHITA gone); dust_density exists only on 4.5 (aerosol_density on 5.1). Two tiny Cycles OPEN_EXR zenith probes prove sun_elevation 8 deg to 55 deg brightens zenith (rise 2.25x on 5.1.2, 1.50x on 4.5.11, gate >= 1.25).

- + @@ -1700,7 +1700,7 @@

Text Version Stamp

- +
diff --git a/docs/gallery/soccer-ball-goldberg/index.html b/docs/gallery/soccer-ball-goldberg/index.html index fb16a7ac..f7b83348 100644 --- a/docs/gallery/soccer-ball-goldberg/index.html +++ b/docs/gallery/soccer-ball-goldberg/index.html @@ -45,7 +45,7 @@

Soccer Ball Goldberg

A soccer ball as a Goldberg polyhedron: a bmesh icosphere truncated at 1/3 per edge, faces ordered by link-topology walks, panels bound by face vertex count.

- + @@ -1347,7 +1347,7 @@

Collision Hull Proxy< - +

diff --git a/docs/gallery/socket-attach-points/index.html b/docs/gallery/socket-attach-points/index.html index 0165142c..87de4649 100644 --- a/docs/gallery/socket-attach-points/index.html +++ b/docs/gallery/socket-attach-points/index.html @@ -45,7 +45,7 @@

Socket Attach Points

A survey drone whose named SKT_ empties are the spawn contract: modules parented with an identity local transform seat exactly on their mount pads.

- + @@ -2477,7 +2477,7 @@

Prop Origin Transform - +

diff --git a/docs/gallery/swatch-grid/index.html b/docs/gallery/swatch-grid/index.html index 84c21029..e66fec56 100644 --- a/docs/gallery/swatch-grid/index.html +++ b/docs/gallery/swatch-grid/index.html @@ -45,7 +45,7 @@

Swatch Grid

Procedural Principled materials — metal and dielectric, the emission pattern, and the cross-version set_specular shim.

- + @@ -1229,7 +1229,7 @@

Compositor Glare

- +
diff --git a/docs/gallery/temp-override-join/index.html b/docs/gallery/temp-override-join/index.html index 08c9a8a0..ce0c1849 100644 --- a/docs/gallery/temp-override-join/index.html +++ b/docs/gallery/temp-override-join/index.html @@ -45,7 +45,7 @@

Temp Override Join

Join seven lantern parts into one object under bpy.context.temp_override — the supported replacement for the removed context.copy() dict-pass form.

- + @@ -1247,7 +1247,7 @@

Cross Version Pr - +

diff --git a/docs/gallery/text-version-stamp/index.html b/docs/gallery/text-version-stamp/index.html index c2f30edf..6ec9ad32 100644 --- a/docs/gallery/text-version-stamp/index.html +++ b/docs/gallery/text-version-stamp/index.html @@ -45,7 +45,7 @@

Text Version Stamp

The TextCurve data API — curves.new(type='FONT'), live body text from bpy.app.version_string, extrude and bevel_depth solids, and evaluated-mesh conversion — so every render self-documents which Blender produced it.

- + @@ -934,7 +934,7 @@

Curve Bevel Arc

- +
diff --git a/docs/gallery/triangulate-tangents/index.html b/docs/gallery/triangulate-tangents/index.html index da49650d..99dac989 100644 --- a/docs/gallery/triangulate-tangents/index.html +++ b/docs/gallery/triangulate-tangents/index.html @@ -45,7 +45,7 @@

Triangulate Tangents

A machined buckler verifying the tangent-space contract a game engine's normal mapping depends on. Deterministic triangulation, unit orthogonal tangent frames, and the edge/UV-delta formula matching mikktspace within welding tolerance.

- + @@ -1376,7 +1376,7 @@

BMesh Gear

- +
diff --git a/docs/gallery/turntable/index.html b/docs/gallery/turntable/index.html index 57f4ee93..8984040a 100644 --- a/docs/gallery/turntable/index.html +++ b/docs/gallery/turntable/index.html @@ -45,7 +45,7 @@

Turntable

A slotted-actions Z-rotation turntable keyed through the cross-version channelbag path (get_channelbag_for_slot).

- + @@ -844,7 +844,7 @@

Damped Track Aim

- +
diff --git a/docs/gallery/usd-export-evaluation-mode/index.html b/docs/gallery/usd-export-evaluation-mode/index.html index 6dd772c8..ff5318ad 100644 --- a/docs/gallery/usd-export-evaluation-mode/index.html +++ b/docs/gallery/usd-export-evaluation-mode/index.html @@ -45,7 +45,7 @@

USD Export Evaluation Mode

The USD exporter evaluation_mode chooses viewport versus render modifier quality. TESSELLATE makes the split observable; BEST_MATCH writes the cage and the mode is silent.

- + @@ -1185,7 +1185,7 @@

Text Version Stamp

- +
diff --git a/docs/gallery/uv-layer-grid/index.html b/docs/gallery/uv-layer-grid/index.html index 3055c4e6..2266ee7f 100644 --- a/docs/gallery/uv-layer-grid/index.html +++ b/docs/gallery/uv-layer-grid/index.html @@ -45,7 +45,7 @@

UV Layer Grid

The UV-layer authoring hazard — bmesh.ops.create_grid(..., calc_uvs=True) is a silent no-op unless a UV layer already exists; without one an Image Texture samples texel (0,0) everywhere.

- + @@ -1305,7 +1305,7 @@

Triangulate Tangents< - +

diff --git a/docs/gallery/vertex-color-ao/index.html b/docs/gallery/vertex-color-ao/index.html index 4ca1dcfa..b2cc1d3c 100644 --- a/docs/gallery/vertex-color-ao/index.html +++ b/docs/gallery/vertex-color-ao/index.html @@ -45,7 +45,7 @@

Vertex Color AO

A stone well carrying baked ambient occlusion in a colour attribute, with the bake held to the closed-form hemisphere integral rather than to a captured value.

- + @@ -2235,7 +2235,7 @@

Custom Normals Shade< - +

diff --git a/docs/gallery/vertex-weight-limit/index.html b/docs/gallery/vertex-weight-limit/index.html index fa710c97..36e64d9f 100644 --- a/docs/gallery/vertex-weight-limit/index.html +++ b/docs/gallery/vertex-weight-limit/index.html @@ -45,7 +45,7 @@

Vertex Weight Limit

A rigged industrial robot arm pruned to the game-engine cap of four bone influences per vertex, through the data API. The check proves no vertex exceeds the cap, weights still sum to one, the pose survives pruning, and the modifier is still exact linear blend skinning.

- + @@ -1674,7 +1674,7 @@

Shape Key Blend

- +
diff --git a/docs/gallery/vse-cut-list/index.html b/docs/gallery/vse-cut-list/index.html index 915be219..ee720ee1 100644 --- a/docs/gallery/vse-cut-list/index.html +++ b/docs/gallery/vse-cut-list/index.html @@ -45,7 +45,7 @@

VSE Cut List

The sequencer API rename from 4.5 LTS to 5.x — strips (never .sequences), new_effect ending in length= vs frame_end=, and left_handle/right_handle/duration replacing the deprecated frame_final_*. A deterministic cut list — color programs, a clamped GAMMA_CROSS, a scene strip, a text strip — asserted before and after save/reload.

- + @@ -1833,7 +1833,7 @@

Compositor Glare

- +
diff --git a/docs/gallery/vse-gamma-cross/index.html b/docs/gallery/vse-gamma-cross/index.html index 09023063..dc936ed7 100644 --- a/docs/gallery/vse-gamma-cross/index.html +++ b/docs/gallery/vse-gamma-cross/index.html @@ -45,7 +45,7 @@

VSE Gamma Cross

The GAMMA_CROSS fade is not the naive linear mix: it blends in a gamma-0.5 space, so the mid-cross dips below the sRGB lerp. Tiny per-frame renders are asserted against the closed form per frame.

- + @@ -1420,7 +1420,7 @@

Compositor Glare

- +
diff --git a/docs/gallery/wave-displace/index.html b/docs/gallery/wave-displace/index.html index 41ca9adf..c69c3aa4 100644 --- a/docs/gallery/wave-displace/index.html +++ b/docs/gallery/wave-displace/index.html @@ -45,7 +45,7 @@

Wave Displace

Bulk vertex IO at real scale — 9,409 vertices displaced into a standing wave with one foreach_get and one foreach_set, no per-vertex access.

- + @@ -808,7 +808,7 @@

Color Attribute Wheel - +

diff --git a/docs/new-example-prompt.md b/docs/new-example-prompt.md index 08e80f04..b048786f 100644 --- a/docs/new-example-prompt.md +++ b/docs/new-example-prompt.md @@ -59,7 +59,7 @@ metadata — so the render would be identical whether the API held or broke. The "redesign the scene until failure would be visible" instruction above is the test: attempt it first, and only when it cannot succeed in principle does the example become check-only. The exception is for contracts that are invisible, not for -renders that are hard. Eight of the 60 examples currently qualify, and `CLAUDE.md` +renders that are hard. Eight of the 62 examples currently qualify, and `CLAUDE.md` carries the same rule. A check-only example is otherwise a full example: it still asserts a real contract, still exits non-zero on failure, still carries a falsifier, and still takes a `tests/smoke/catalog.json` row so it runs on every PR. diff --git a/examples/gallery.json b/examples/gallery.json index 2989cdaa..a482e23e 100644 --- a/examples/gallery.json +++ b/examples/gallery.json @@ -701,6 +701,34 @@ "tags": [ "geometry-nodes" ] + }, + { + "name": "ray-cast-space", + "dir": "examples/ray-cast-space", + "teaches": "Object.ray_cast is object-local while Scene.ray_cast is world space: rays mapped through matrix_world.inverted() (directions by its 3x3 only) hit the same points both ways, and raw world coords handed to Object.ray_cast miss.", + "alt": "A teal-checkered block turned and stretched on a walnut plinth, five orange rays from brass balls ending in orange rings on three faces, and one red ray striking it from elsewhere.", + "witnessesFix": "Five closed-form rays on a translated, Z-rotated, non-uniformly scaled block: Scene.ray_cast hits each world point and normal exactly; Object.ray_cast fed matrix_world.inverted() coordinates hits the same polygons, and mapped back lands within 5e-7 m; world coords fed to Object.ray_cast land 0.287 m or more off. --world-to-object exits 4 (1.125 m error).", + "hero": "docs/gallery/assets/ray-cast-space-hero.webp", + "preview": "examples/ray-cast-space/preview.webp", + "tags": [ + "objects", + "transforms", + "depsgraph" + ] + }, + { + "name": "lattice-deform", + "dir": "examples/lattice-deform", + "teaches": "A 2x2x2 Lattice modifier set to KEY_LINEAR on all three axes, with two top control points moved through LatticePoint.co_deform, read back through the depsgraph (evaluated_get, to_mesh, to_mesh_clear).", + "alt": "Two checker-glazed columns on a walnut plinth, each in a steel-rod cage: the left upright in its rest cage, the right sheared and tapered where two orange cage corners have been pulled out.", + "witnessesFix": "Linear lattice deform is exact trilinear interpolation: all 602 evaluated verts land on the closed-form blend of the eight co_deform corners (max error 5.6e-7 through a translated, non-uniformly scaled lattice), the undeformed lattice is the identity, and --bspline (the default interpolation) misses by 0.2145 and exits 4.", + "hero": "docs/gallery/assets/lattice-deform-hero.webp", + "preview": "examples/lattice-deform/preview.webp", + "tags": [ + "mesh", + "modifiers", + "depsgraph" + ] } ] } diff --git a/examples/lattice-deform/README.md b/examples/lattice-deform/README.md new file mode 100644 index 00000000..572c3c19 --- /dev/null +++ b/examples/lattice-deform/README.md @@ -0,0 +1,73 @@ +# Lattice Deform + +A runnable example that deforms a subdivided column with a **2×2×2 lattice** set to +`'KEY_LINEAR'` interpolation on all three axes, then proves, vertex by vertex, that the +Lattice modifier's output is exactly the **trilinear interpolation** of the eight deformed +control points. The deformed mesh is read through the depsgraph lifetime contract from +[`depsgraph-and-evaluated-data`](../../skills/depsgraph-and-evaluated-data/SKILL.md) +(`evaluated_get` → `to_mesh` → `to_mesh_clear`), and the lattice points are edited the way +[`mesh-editing-and-bmesh`](../../skills/mesh-editing-and-bmesh/SKILL.md) recommends for +data-level work: through `bpy.data`, never `bpy.ops`. + +**What it witnesses:** `LatticePoint.co` is the read-only rest position and +`LatticePoint.co_deform` is the one you move. With linear interpolation a vertex at +normalized rest coordinates (u, v, w) inside the lattice lands on +Σ wᵢ · `co_deform`ᵢ with wᵢ = (u or 1−u)(v or 1−v)(w or 1−w), mapped through the lattice +object's matrix. The check computes that closed form independently for all 602 vertices, +through a lattice object that is translated and non-uniformly scaled (1.7 × 1.7 × 2.4), +and requires agreement to 1e-5 in world space (measured: 5.6e-7). It also asserts the +undeformed lattice is the identity and that the deformation is not trivially small +(max displacement 0.53). + +**The trap it exposes:** a new lattice defaults to `'KEY_BSPLINE'`. B-spline weights +smooth over the control points instead of interpolating them, so a script that moves a +corner and expects the mesh to follow it linearly gets a softer, smaller pull. On a +2×2×2 lattice the rest state is still the identity, so nothing looks wrong until a +point moves. `--bspline` keeps the default and fails check 4 with the measured error. + +The still shows the rule as a before-and-after on a walnut plinth: on the left a +render-only twin of the column, undeformed, inside its rest cage; on the right the +checked column inside the deformed cage, with the two moved corners in selection orange +and a short orange rod from each back to its rest position. The column carries a glazed +two-tone checker in Generated (rest) coordinates, so the squares are square before the +lattice acts, and their shear and taper in the still are the deformation. Lattices do +not render, so both cages are drawn as thin steel rods built from the corner positions +after the check has run. + +## Run + +```bash +# Cheap correctness check (no render) — the CI check: +blender --background --python lattice_deform.py -- + +# Falsifier: keep the default KEY_BSPLINE interpolation. Must exit 4. +blender --background --python lattice_deform.py -- --bspline + +# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): +blender --background --python lattice_deform.py -- --output lattice.png --engine cycles +``` + +## Version notes + +The Lattice datablock API (`points_u/v/w`, `interpolation_type_u/v/w`, `points[i].co` / +`co_deform`, point order u fastest, then v, then w) and the `'LATTICE'` modifier are the +same on 4.5 LTS, 5.1 and 5.2 LTS. Measured identically on 4.5.11, 5.1.2 and 5.2.1: +602 vertices, trilinear max error 5.6e-7, max displacement 0.5311; `--bspline` max error +0.2145. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Undeformed lattice moved the mesh (identity check) | +| 4 | A vertex is off the trilinear closed form, or the vertex count changed (`--bspline` lands here) | +| 5 | Max displacement below the floor (the witness would pass vacuously) | +| 6 | `--output` produced no file | +| 10 | `--output` framing violation (Layer 1 fill / margin gate, `gallery_framing`) | + +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 `--bspline`. diff --git a/examples/lattice-deform/lattice_deform.py b/examples/lattice-deform/lattice_deform.py new file mode 100644 index 00000000..b33a7ddd --- /dev/null +++ b/examples/lattice-deform/lattice_deform.py @@ -0,0 +1,460 @@ +"""Lattice deform with linear interpolation is exact trilinear interpolation — a runnable example. + +Witnesses the Lattice modifier contract on a 2x2x2 lattice (``points_u``, +``points_v`` and ``points_w`` all 2). With ``interpolation_type_u/v/w`` set +to ``'KEY_LINEAR'`` a mesh vertex inside the lattice moves to the trilinear +interpolation of the eight *deformed* control points (``LatticePoint.co_deform``) +at the vertex's normalized rest coordinates (u, v, w) in lattice space. +The check computes that closed form independently in Python for every +evaluated vertex (``evaluated_get`` + ``to_mesh`` / ``to_mesh_clear``) and +requires agreement to 1e-5 in world space, through a lattice object that is +itself translated and non-uniformly scaled. + +Three checks, in run order: + +- 3: with no control point moved, the modifier is the identity; +- 4: with two top corners moved, every vertex lands on the trilinear point; +- 5: the deformation is not trivially small (the witness cannot pass vacuously). + +``--bspline`` leaves the lattice on its default ``'KEY_BSPLINE'`` +interpolation, which smooths over the control points instead of +interpolating them, so the evaluated mesh no longer matches the trilinear +closed form and check 4 fails with the measured error. That is the +falsifier — and the trap: a script that sets ``co_deform`` and expects the +corner to "pull" the mesh linearly gets a softer, smaller motion. + +By default it runs only the correctness check (no render) — the CI smoke +check. Pass --output to also render a still: + + blender --background --python lattice_deform.py -- # check only + blender --background --python lattice_deform.py -- --bspline # must fail + blender --background --python lattice_deform.py -- --output l.png # + render +""" +import bpy, bmesh, sys, os, math, argparse +from mathutils import Vector + +# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)) +sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree +import gallery_framing + +# The lattice: a box in world space, as a translated, non-uniformly scaled +# 2x2x2 lattice object (its points sit at +-0.5 in lattice-local space). +LAT_LOC = Vector((0.0, 0.0, 1.30)) +LAT_SCALE = Vector((1.70, 1.70, 2.40)) +# The deformed column, inside the lattice on every axis (lattice-local units); +# its base lies on the lattice's bottom face (w = 0), so it sits on the plinth +COLUMN_HALF = (0.40, 0.40, 0.50) +COLUMN_CUTS = 9 # subdivisions per cube edge +# Two top corners moved (lattice-local offsets): (u, v, w) index -> offset +MOVES = { + (1, 1, 1): Vector((0.30, 0.18, 0.12)), + (0, 0, 1): Vector((-0.10, -0.26, -0.08)), +} +TOL = 1e-5 +MIN_DISPLACEMENT = 0.05 # world units; check 5's floor +TWIN_GAP = 2.9 # render only: the undeformed twin's offset to the left + + +def corner_index(u, v, w, n=2): + """LatticePoint order: u fastest, then v, then w.""" + return u + n * (v + n * w) + + +def build_scene(bspline=False): + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + + lat = bpy.data.lattices.new("Cage") + lat.points_u = lat.points_v = lat.points_w = 2 + if not bspline: + lat.interpolation_type_u = 'KEY_LINEAR' + lat.interpolation_type_v = 'KEY_LINEAR' + lat.interpolation_type_w = 'KEY_LINEAR' + lat_obj = bpy.data.objects.new("Cage", lat) + lat_obj.location = LAT_LOC + lat_obj.scale = LAT_SCALE + scene.collection.objects.link(lat_obj) + + me = bpy.data.meshes.new("Column") + bm = bmesh.new() + try: + bmesh.ops.create_cube(bm, size=1.0) + bmesh.ops.subdivide_edges(bm, edges=bm.edges[:], cuts=COLUMN_CUTS, use_grid_fill=True) + # cube [-0.5, 0.5]^3 -> the column's box in lattice-local units -> world + for vert in bm.verts: + local = Vector(tuple(vert.co[k] * 2.0 * COLUMN_HALF[k] for k in range(3))) + vert.co = LAT_LOC + Vector(tuple(local[k] * LAT_SCALE[k] for k in range(3))) + bm.to_mesh(me) + finally: + bm.free() + obj = bpy.data.objects.new("Column", me) + scene.collection.objects.link(obj) + mod = obj.modifiers.new("Lattice", 'LATTICE') + mod.object = lat_obj + return obj, lat_obj + + +def evaluated_world_coords(obj): + deps = bpy.context.evaluated_depsgraph_get() + ev = obj.evaluated_get(deps) + me = ev.to_mesh() + try: + mw = ev.matrix_world + return [mw @ v.co for v in me.vertices] + finally: + ev.to_mesh_clear() + + +def trilinear(lat_obj, corners, world_rest): + """Closed form, independent of Blender's deform code: normalized rest + coordinates in lattice space, then the trilinear blend of the deformed + corners, mapped back to world.""" + local = lat_obj.matrix_world.inverted() @ world_rest + u, v, w = (local[k] + 0.5 for k in range(3)) + out = Vector((0.0, 0.0, 0.0)) + for (i, j, k), co in corners.items(): + weight = (u if i else 1 - u) * (v if j else 1 - v) * (w if k else 1 - w) + out += weight * co + return lat_obj.matrix_world @ out + + +def check(obj, lat_obj): + bpy.context.view_layer.update() + rest = [obj.matrix_world @ v.co for v in obj.data.vertices] + + # 3: an undeformed lattice is the identity + ident = evaluated_world_coords(obj) + err0 = max((a - b).length for a, b in zip(ident, rest)) + if err0 > TOL: + print(f"ERROR: undeformed lattice moved the mesh (max {err0:.3e} > {TOL:.0e})", + file=sys.stderr) + return 3 + + lat = lat_obj.data + for (i, j, k), off in MOVES.items(): + pt = lat.points[corner_index(i, j, k)] + pt.co_deform = Vector(pt.co) + off + corners = {(i, j, k): Vector(lat.points[corner_index(i, j, k)].co_deform) + for i in (0, 1) for j in (0, 1) for k in (0, 1)} + lat.update_tag() + obj.update_tag() + bpy.context.view_layer.update() + + # 4: every vertex on the trilinear closed form + got = evaluated_world_coords(obj) + if len(got) != len(rest): + print(f"ERROR: vertex count changed {len(rest)} -> {len(got)}", file=sys.stderr) + return 4 + errs = [(g - trilinear(lat_obj, corners, r)).length for g, r in zip(got, rest)] + worst = max(range(len(errs)), key=errs.__getitem__) + interp = (lat.interpolation_type_u, lat.interpolation_type_v, lat.interpolation_type_w) + if errs[worst] > TOL: + print(f"ERROR: {sum(e > TOL for e in errs)}/{len(errs)} verts off the trilinear " + f"closed form; max {errs[worst]:.4f} at vert {worst} (interpolation {interp})", + file=sys.stderr) + return 4 + + # 5: the witness moved the mesh by a real amount + disp = max((g - r).length for g, r in zip(got, rest)) + if disp < MIN_DISPLACEMENT: + print(f"ERROR: max displacement {disp:.4f} < {MIN_DISPLACEMENT}", file=sys.stderr) + return 5 + + print(f"lattice 2x2x2 {interp}: identity max {err0:.2e}; {len(errs)} verts on the " + f"trilinear closed form, max err {errs[worst]:.2e}; max displacement {disp:.4f}") + return 0 + + +def eevee_engine_id(): + return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' + + +# --------------------------------------------------------------------------- +# Render staging only (runs after the check; never part of it) +# --------------------------------------------------------------------------- + +def principled(name, base, rough, metal=0.0, noise=None, coat=0.0, emit=None): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + b = nt.nodes["Principled BSDF"] + b.inputs["Base Color"].default_value = (*base, 1.0) + b.inputs["Roughness"].default_value = rough + b.inputs["Metallic"].default_value = metal + if coat: + b.inputs["Coat Weight"].default_value = coat + if emit: + b.inputs["Emission Color"].default_value = (*emit[0], 1.0) + b.inputs["Emission Strength"].default_value = emit[1] + if noise: + tex = nt.nodes.new("ShaderNodeTexNoise") + tex.inputs["Scale"].default_value = noise + tex.inputs["Detail"].default_value = 8.0 + mr = nt.nodes.new("ShaderNodeMapRange") + mr.inputs["To Min"].default_value = max(rough - 0.08, 0.0) + mr.inputs["To Max"].default_value = rough + 0.14 + nt.links.new(tex.outputs["Fac"], mr.inputs["Value"]) + nt.links.new(mr.outputs["Result"], b.inputs["Roughness"]) + return mat + + +def checker_ceramic(): + """A glazed two-tone checker on the column, in Generated (rest) coordinates: + the squares are square before the lattice acts, so their shear and + stretch in the still is the deformation itself.""" + mat = bpy.data.materials.new("CheckerGlaze") + mat.use_nodes = True + nt = mat.node_tree + b = nt.nodes["Principled BSDF"] + b.inputs["Roughness"].default_value = 0.32 + b.inputs["Coat Weight"].default_value = 0.5 + tc = nt.nodes.new("ShaderNodeTexCoord") + chk = nt.nodes.new("ShaderNodeTexChecker") + chk.inputs["Scale"].default_value = float(COLUMN_CUTS + 1) + chk.inputs["Color1"].default_value = (0.60, 0.56, 0.49, 1.0) + chk.inputs["Color2"].default_value = (0.10, 0.22, 0.30, 1.0) + nt.links.new(tc.outputs["Generated"], chk.inputs["Vector"]) + nt.links.new(chk.outputs["Color"], b.inputs["Base Color"]) + return mat + + +def tube_mesh(name, segments, radius, sides=10): + """Render-only rods along line segments (lattices do not render).""" + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + for a, b in segments: + axis = b - a + res = bmesh.ops.create_cone(bm, cap_ends=True, segments=sides, + radius1=radius, radius2=radius, depth=axis.length) + rot = Vector((0, 0, 1)).rotation_difference(axis.normalized()).to_matrix() + mid = (a + b) / 2 + for vert in res["verts"]: + vert.co = rot @ vert.co + mid + bmesh.ops.recalc_face_normals(bm, faces=bm.faces) + bm.to_mesh(me) + finally: + bm.free() + for p in me.polygons: + p.use_smooth = True + return me + + +def sphere_mesh(name, centers, radius): + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + for c in centers: + res = bmesh.ops.create_uvsphere(bm, u_segments=24, v_segments=14, radius=radius) + for vert in res["verts"]: + vert.co += c + bm.to_mesh(me) + finally: + bm.free() + for p in me.polygons: + p.use_smooth = True + return me + + +def box_mesh(name, lo, hi): + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + res = bmesh.ops.create_cube(bm, size=1.0) + for vert in res["verts"]: + vert.co = Vector(tuple(lo[k] + (vert.co[k] + 0.5) * (hi[k] - lo[k]) for k in range(3))) + bm.to_mesh(me) + finally: + bm.free() + return me + + +def cage_edges(points): + """The 12 edges of a 2x2x2 cage given corner positions keyed (i, j, k).""" + edges = [] + for i in (0, 1): + for j in (0, 1): + edges.append((points[(i, j, 0)], points[(i, j, 1)])) + edges.append((points[(i, 0, j)], points[(i, 1, j)])) + edges.append((points[(0, i, j)], points[(1, i, j)])) + return edges + + +def render_still(obj, lat_obj, path, engine): + scene = bpy.context.scene + mw = lat_obj.matrix_world + lat = lat_obj.data + rest = {(i, j, k): mw @ Vector(lat.points[corner_index(i, j, k)].co) + for i in (0, 1) for j in (0, 1) for k in (0, 1)} + moved = {(i, j, k): mw @ Vector(lat.points[corner_index(i, j, k)].co_deform) + for i in (0, 1) for j in (0, 1) for k in (0, 1)} + + obj.data.materials.append(checker_ceramic()) + for p in obj.data.polygons: + p.use_smooth = False + + steel = principled("CageSteel", (0.58, 0.60, 0.64), 0.22, metal=1.0) + ghost = principled("RestCage", (0.16, 0.17, 0.19), 0.6, metal=0.3) + orange = principled("MovedCorner", (1.0, 0.42, 0.04), 0.3, + emit=((1.0, 0.45, 0.06), 1.5)) + joint = principled("CageJoint", (0.30, 0.31, 0.34), 0.3, metal=1.0) + walnut = principled("Walnut", (0.13, 0.055, 0.025), 0.45, noise=40.0, coat=0.4) + + parts = [] + + def add(name, me, mat): + me.materials.append(mat) + ob = bpy.data.objects.new(name, me) + scene.collection.objects.link(ob) + parts.append(ob) + return ob + + add("DeformedCage", tube_mesh("DeformedCage", cage_edges(moved), 0.022), steel) + # the rest cage, thin and dark: what the two corners were moved away from + rest_only = [(a, b) for a, b in cage_edges(rest)] + add("RestCage", tube_mesh("RestCage", rest_only, 0.009), ghost) + still = [moved[key] for key in moved if key not in MOVES] + add("CageJoints", sphere_mesh("CageJoints", still, 0.05), joint) + add("MovedCorners", sphere_mesh("MovedCorners", [moved[key] for key in MOVES], 0.075), + orange) + # a travel rod from each moved corner back to its rest position + add("Travel", tube_mesh("Travel", [(rest[key], moved[key]) for key in MOVES], 0.012), + orange) + + # Render-only "before" twin: the same column, unmodified, in its rest cage, + # one cage-width to the left, so the still reads rest -> deformed. + shift = Vector((-TWIN_GAP, 0.0, 0.0)) + twin_me = obj.data.copy() + twin = bpy.data.objects.new("RestColumn", twin_me) + twin.location = shift + scene.collection.objects.link(twin) + parts.append(twin) + rest_twin = {key: co + shift for key, co in rest.items()} + add("TwinCage", tube_mesh("TwinCage", cage_edges(rest_twin), 0.022), steel) + add("TwinJoints", sphere_mesh("TwinJoints", list(rest_twin.values()), 0.05), joint) + + lo_z = LAT_LOC.z - LAT_SCALE.z / 2 + half_x = LAT_SCALE.x / 2 + 0.45 + plinth = add("Plinth", box_mesh("Plinth", (-TWIN_GAP - half_x, -1.3, 0.0), + (half_x, 1.3, lo_z - 0.002)), walnut) + bev = plinth.modifiers.new("Chamfer", 'BEVEL') + bev.width = 0.03 + bev.segments = 2 + + 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, 7.5, 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 + + centre = Vector((-TWIN_GAP / 2, 0.0, LAT_LOC.z)) + + def light(name, loc, energy, size, col, aim): + ld = bpy.data.lights.new(name, 'AREA') + ld.energy = energy; ld.size = size; ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = (Vector(aim) - Vector(loc)).to_track_quat('-Z', 'Y').to_euler() + scene.collection.objects.link(ob) + + light("Key", (-4.5, -5.0, 6.5), 520.0, 4.0, (1.0, 0.96, 0.9), centre) + light("Fill", (5.5, -4.5, 2.5), 110.0, 7.0, (0.75, 0.85, 1.0), centre) + light("Rim", (1.5, 4.0, 6.0), 380.0, 3.0, (0.6, 0.78, 1.0), centre) + light("Wedge", (2.5, 4.5, 3.0), 420.0, 5.0, (1.0, 0.76, 0.5), (4.5, 7.5, 1.0)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (centre.x + 3.6, -9.6, 3.9) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = centre + Vector((0.05, 0.0, -0.15)) + scene.collection.objects.link(aim) + tr = cam.constraints.new('TRACK_TO') + tr.target = aim + tr.track_axis = 'TRACK_NEGATIVE_Z' + tr.up_axis = 'UP_Y' + scene.camera = cam + + scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id() + if engine == 'cycles': + scene.cycles.samples = 48 + 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 + # AgX would wash the glaze and the orange accent toward pastel (docs/VISUAL-STYLE.md) + scene.view_settings.view_transform = 'Standard' + bpy.context.view_layer.update() + # Layer 1 framing gate (silhouette matte) — exit 10 on violation, before + # the beauty render so a defective composition ships no artifact + fcode = gallery_framing.check_framing( + scene, cam, + hero=[obj] + parts, + elements=[obj] + parts, + 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 6 + 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("--bspline", action="store_true", + help="keep the default KEY_BSPLINE interpolation (must fail)") + args = p.parse_args(argv) + + obj, lat_obj = build_scene(bspline=args.bspline) + code = check(obj, lat_obj) + if code: + return code + + if args.output: + rcode = render_still(obj, lat_obj, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + + print("lattice-deform 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/lattice-deform/preview.webp b/examples/lattice-deform/preview.webp new file mode 100644 index 00000000..caadf9fb Binary files /dev/null and b/examples/lattice-deform/preview.webp differ diff --git a/examples/ray-cast-space/README.md b/examples/ray-cast-space/README.md new file mode 100644 index 00000000..b34ad838 --- /dev/null +++ b/examples/ray-cast-space/README.md @@ -0,0 +1,72 @@ +# Ray Cast Space + +A runnable example of the coordinate-space contract behind Blender's two ray casts, the +one AI-written code gets wrong most often. `Scene.ray_cast(depsgraph, origin, direction)` +takes and returns **world** space. `Object.ray_cast(origin, direction)` takes and returns +the object's **local** space. Feed world coordinates to `Object.ray_cast` and nothing +raises: it quietly misses, or hits the wrong spot. + +**What it witnesses:** a target block carries a transform with every ingredient that +separates the two spaces: a translation, a 32° turn about Z, and non-uniform scale +(1.55 × 0.62 × 0.62). The example casts five rays at closed-form points on three of its +faces, arriving obliquely, and checks each one three ways: + +1. **`Scene.ray_cast`** hits the closed-form world point (to 1e-4 m). Its normal matches + the face's world normal, which comes from the inverse-transpose of `matrix_world`. +2. **`Object.ray_cast`** is fed the origin through `matrix_world.inverted()` and the + direction through that matrix's 3×3 part only, because directions do not translate. + It must hit the same polygon. Its local hit point, mapped back through `matrix_world` + (and its normal through the inverse-transpose), must land on the same world point and + normal. +3. **The trap must bite on this setup.** The same world origin and direction handed + straight to `Object.ray_cast` either misses or lands at least 0.25 m from the true + hit. Without this, the check could pass by coincidence. + +Both 5.2 LTS and 4.5 LTS give identical results: every hit exact to under 1e-6 m, and +the world-coordinate trap's closest landing 0.287 m away. + +The still shows the block glazed in a teal checker laid out in **object** coordinates. +The cells are square in local space, so the non-uniform scale stretches them into the +block's own grid. The block stands on a walnut plinth. Five orange rays run from brass +emitter balls to orange hit rings that sit exactly on three faces: the correct casts. +One red ray is the first front-face ray's world origin and direction read by +`Object.ray_cast` as if local. In the world that is the ray `matrix_world @ origin` +along `matrix_world.to_3x3() @ direction`. It starts somewhere else entirely and +strikes the far side of the block. A render-only Bevel modifier rounds the block's +edges after the check has run. + +## Run + +```bash +# Cheap correctness check (no render) — the CI check: +blender --background --python ray_cast_space.py -- + +# Falsifier: feed WORLD coords to Object.ray_cast in the checked path. Must exit 4. +blender --background --python ray_cast_space.py -- --world-to-object + +# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): +blender --background --python ray_cast_space.py -- --output ray.png +blender --background --python ray_cast_space.py -- --output ray.png --engine cycles +``` + +`--world-to-object` measured on 5.2.1 and 4.5.11: +`Object.ray_cast ray 0 mapped back to (1.5668, 1.22769, 1.38161), error 1.1253 m +(normal error 1.0000, polygon 2 vs Scene 3)`. The cast hit a different face of the +block, a metre from the true point. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | `Scene.ray_cast` missed, hit another object, or hit off the closed-form world point or normal | +| 4 | `Object.ray_cast` missed, or its local hit mapped back by `matrix_world` disagrees with the world point, normal or polygon (`--world-to-object` lands here) | +| 5 | The world-coordinate trap did not bite: world coords fed to `Object.ray_cast` landed within `TRAP_MIN` of a true hit | +| 6 | `--output` produced no file | +| 10 | `--output` framing violation (Layer 1 fill / margin gate, `gallery_framing`) | + +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 `--world-to-object`. diff --git a/examples/ray-cast-space/preview.webp b/examples/ray-cast-space/preview.webp new file mode 100644 index 00000000..007458f6 Binary files /dev/null and b/examples/ray-cast-space/preview.webp differ diff --git a/examples/ray-cast-space/ray_cast_space.py b/examples/ray-cast-space/ray_cast_space.py new file mode 100644 index 00000000..bea83d8e --- /dev/null +++ b/examples/ray-cast-space/ray_cast_space.py @@ -0,0 +1,476 @@ +"""Object.ray_cast is object-local, Scene.ray_cast is world space — a runnable example. + +Witnesses the coordinate-space contract of the two ray casts, the one AI code +gets wrong most often: ``Scene.ray_cast(depsgraph, origin, direction)`` takes +and returns WORLD space, while ``Object.ray_cast(origin, direction)`` takes +and returns the object's LOCAL space. A target block carries a non-trivial +transform (translation, a turn about Z, non-uniform scale). Five rays aimed at +closed-form points on three of its faces are cast both ways: + +- ``Scene.ray_cast`` must hit each closed-form world point, with the face's + world normal (inverse-transpose of the object matrix) and the target object; +- ``Object.ray_cast``, fed the origin through ``matrix_world.inverted()`` and + the direction through its 3x3 part only (directions do not translate), must + hit the same polygon, and its local hit point mapped back by + ``matrix_world`` (normal by the inverse-transpose) must land on the same + world point and normal; +- the trap: the same WORLD origin/direction handed straight to + ``Object.ray_cast`` must NOT reproduce the hit — every such cast misses or + lands at least ``TRAP_MIN`` away, so the check proves the transform matters + on this setup rather than passing by coincidence. + +``--world-to-object`` feeds world coordinates to ``Object.ray_cast`` in the +checked path (the bug); the local-hit check catches it (exit 4) and prints +the measured error. + +By default it runs only the correctness check (no render) — the CI smoke +check. Pass --output to also render a still: + + blender --background --python ray_cast_space.py -- # check only + blender --background --python ray_cast_space.py -- --world-to-object # must fail + blender --background --python ray_cast_space.py -- --output r.png # + render +""" +import bpy, bmesh, sys, os, math, argparse +from mathutils import Vector, Matrix + +# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)) +sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree +import gallery_framing + +# The target: a unit-half-size cube (local coords in [-1, 1]^3) under a +# transform with every ingredient that separates local from world space. +TARGET_LOC = (0.35, 0.25, 0.80) +TARGET_ROT_Z = math.radians(32.0) +TARGET_SCALE = (1.55, 0.62, 0.62) + +# Closed-form hit points, authored in LOCAL face coordinates, plus a tangent +# tilt so the rays arrive obliquely rather than straight down each normal. +# (face axis, face sign, local point on that face, tilt in local tangent space) +RAYS = ( + ("y", -1, (0.55, -1.0, 0.35), (0.35, 0.0, 0.25)), + ("y", -1, (-0.45, -1.0, -0.30), (-0.30, 0.0, 0.20)), + ("z", +1, (0.40, 0.30, 1.0), (0.25, -0.35, 0.0)), + ("z", +1, (-0.60, -0.40, 1.0), (-0.20, -0.30, 0.0)), + ("x", -1, (-1.0, -0.35, 0.30), (0.0, -0.30, 0.25)), +) +RAY_LEN = 1.7 # world distance from each ray origin to its hit +POS_TOL = 1e-4 # world-space hit tolerance +NRM_TOL = 1e-4 # 1 - dot(normal, expected) +TRAP_MIN = 0.25 # a world-coord Object.ray_cast must miss or land this far off + + +def build_target(): + bpy.ops.wm.read_factory_settings(use_empty=True) + me = bpy.data.meshes.new("TargetBlock") + bm = bmesh.new() + try: + bmesh.ops.create_cube(bm, size=2.0) + bm.to_mesh(me) + finally: + bm.free() + obj = bpy.data.objects.new("TargetBlock", me) + bpy.context.collection.objects.link(obj) + obj.location = TARGET_LOC + obj.rotation_euler = (0.0, 0.0, TARGET_ROT_Z) + obj.scale = TARGET_SCALE + bpy.context.view_layer.update() + return obj + + +def world_rays(obj): + """Closed-form world rays: (origin, direction, hit point, face normal). + + The hit point is the local face point through matrix_world; the world + normal is the local face normal through the inverse-transpose 3x3. The + origin sits RAY_LEN back along the (tilted) incoming direction, in the + face's outer half-space, so on this convex block the first surface the + ray meets is that face at that point. + """ + mw = obj.matrix_world + n_mat = mw.to_3x3().inverted().transposed() + out = [] + for axis, sign, p_local, tilt in RAYS: + n_local = Vector((0.0, 0.0, 0.0)) + n_local["xyz".index(axis)] = sign + p = mw @ Vector(p_local) + n = (n_mat @ n_local).normalized() + incoming = (n_local + Vector(tilt)) # outward-ish, in local space + d = -(mw.to_3x3() @ incoming).normalized() + if d.dot(n) >= 0.0: + raise ValueError(f"ray at {p_local} does not approach its face from outside") + out.append((p - RAY_LEN * d, d, p, n)) + return out + + +def object_space(obj, origin, direction): + """World ray -> the object's local space: the origin through the full + inverse matrix, the direction through its 3x3 only (no translation).""" + inv = obj.matrix_world.inverted() + return inv @ origin, (inv.to_3x3() @ direction).normalized() + + +def check(obj, world_to_object=False): + dg = bpy.context.evaluated_depsgraph_get() + scene = bpy.context.scene + mw = obj.matrix_world + n_mat = mw.to_3x3().inverted().transposed() + rays = world_rays(obj) + + # 1. Scene.ray_cast: world in, world out + scene_hits = [] + for k, (o, d, p, n) in enumerate(rays): + ok, loc, nrm, idx, hit_ob, _m = scene.ray_cast(dg, o, d) + if not ok or hit_ob is None or hit_ob.name != obj.name: + print(f"ERROR: Scene.ray_cast ray {k} missed the target", file=sys.stderr) + return 3 + err, nerr = (loc - p).length, 1.0 - nrm.normalized().dot(n) + if err > POS_TOL or nerr > NRM_TOL: + print(f"ERROR: Scene.ray_cast ray {k} hit {tuple(round(c, 5) for c in loc)} " + f"(error {err:.6f} m, normal error {nerr:.6f}) vs closed form " + f"{tuple(round(c, 5) for c in p)}", file=sys.stderr) + return 3 + scene_hits.append(idx) + + # 2. Object.ray_cast: local in, local out — map both ways + worst = 0.0 + for k, (o, d, p, n) in enumerate(rays): + if world_to_object: + lo, ld = o, d # the bug: world coords as if local + else: + lo, ld = object_space(obj, o, d) + ok, loc, nrm, idx = obj.ray_cast(lo, ld, depsgraph=dg) + if not ok: + print(f"ERROR: Object.ray_cast ray {k} missed (origin/direction not in " + f"object space?)", file=sys.stderr) + return 4 + p_back = mw @ loc + n_back = (n_mat @ nrm).normalized() + err, nerr = (p_back - p).length, 1.0 - n_back.dot(n) + worst = max(worst, err) + if err > POS_TOL or nerr > NRM_TOL or idx != scene_hits[k]: + print(f"ERROR: Object.ray_cast ray {k} mapped back to " + f"{tuple(round(c, 5) for c in p_back)}, error {err:.4f} m " + f"(normal error {nerr:.4f}, polygon {idx} vs Scene {scene_hits[k]})", + file=sys.stderr) + return 4 + + # 3. The trap must actually bite on this setup + trap_min = math.inf + for k, (o, d, p, n) in enumerate(rays): + ok, loc, _nrm, _idx = obj.ray_cast(o, d, depsgraph=dg) + dev = (mw @ loc - p).length if ok else math.inf + trap_min = min(trap_min, dev) + if trap_min < TRAP_MIN: + print(f"ERROR: world coords fed to Object.ray_cast landed only {trap_min:.4f} m " + f"from a true hit (< {TRAP_MIN}); the transform does not separate the " + f"spaces", file=sys.stderr) + return 5 + + trap_txt = "all miss" if trap_min == math.inf else f"closest {trap_min:.3f} m off" + print(f"rays={len(rays)} scene_hits=exact object_local_hits=exact " + f"max_mapped_error={worst:.2e} m world_coords_trap={trap_txt}") + return 0 + + +def eevee_engine_id(): + return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' + + +# -------------------------------------------------------------------------- +# Render staging only (not part of the check) +# -------------------------------------------------------------------------- + +def principled(name, base, rough, metal=0.0, emit=None, strength=0.0, alpha=1.0): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + b = mat.node_tree.nodes["Principled BSDF"] + b.inputs["Base Color"].default_value = (*base, 1.0) + b.inputs["Roughness"].default_value = rough + b.inputs["Metallic"].default_value = metal + if emit is not None: + b.inputs["Emission Color"].default_value = (*emit, 1.0) + b.inputs["Emission Strength"].default_value = strength + if alpha < 1.0: + b.inputs["Alpha"].default_value = alpha + try: + mat.surface_render_method = 'BLENDED' + except AttributeError: + pass + return mat + + +def block_material(): + """Glazed teal ceramic with an OBJECT-space checker: the cells are square + in local space, so the non-uniform scale stretches them into the + rectangles the eye reads as 'this object has its own coordinates'. Teal + sits opposite the orange rays, so every hit marker reads at thumbnail size.""" + mat = bpy.data.materials.new("LocalSpaceCeramic") + mat.use_nodes = True + nt = mat.node_tree + b = nt.nodes["Principled BSDF"] + b.inputs["Roughness"].default_value = 0.32 + b.inputs["Coat Weight"].default_value = 0.35 + coords = nt.nodes.new("ShaderNodeTexCoord") + chk = nt.nodes.new("ShaderNodeTexChecker") + chk.inputs["Scale"].default_value = 4.0 + chk.inputs["Color1"].default_value = (0.09, 0.36, 0.40, 1.0) + chk.inputs["Color2"].default_value = (0.035, 0.15, 0.18, 1.0) + # Object coords span -1..1; shift to 0..2 so cells line up with face edges + add = nt.nodes.new("ShaderNodeVectorMath") + add.operation = 'ADD' + add.inputs[1].default_value = (1.0, 1.0, 1.0) + nt.links.new(coords.outputs["Object"], add.inputs[0]) + nt.links.new(add.outputs["Vector"], chk.inputs["Vector"]) + nt.links.new(chk.outputs["Color"], b.inputs["Base Color"]) + return mat + + +def rod(name, a, b, radius, mat, segs=16): + """A thin cylinder from a to b (render-only ray segment).""" + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + length = (b - a).length + bmesh.ops.create_cone(bm, cap_ends=True, segments=segs, radius1=radius, + radius2=radius, depth=length) + bm.to_mesh(me) + finally: + bm.free() + me.materials.append(mat) + for poly in me.polygons: + poly.use_smooth = True + ob = bpy.data.objects.new(name, me) + bpy.context.scene.collection.objects.link(ob) + ob.location = (a + b) / 2 + ob.rotation_euler = (b - a).to_track_quat('Z', 'Y').to_euler() + return ob + + +def sphere(name, at, radius, mat): + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bmesh.ops.create_uvsphere(bm, u_segments=24, v_segments=12, radius=radius) + bm.to_mesh(me) + finally: + bm.free() + me.materials.append(mat) + for poly in me.polygons: + poly.use_smooth = True + ob = bpy.data.objects.new(name, me) + bpy.context.scene.collection.objects.link(ob) + ob.location = at + return ob + + +def ring(name, at, normal, r_out, r_in, mat): + """A flat hit-marker annulus lying on the surface, facing out along normal.""" + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + segs = 32 + outer = [bm.verts.new((r_out * math.cos(2 * math.pi * i / segs), + r_out * math.sin(2 * math.pi * i / segs), 0.0)) + for i in range(segs)] + inner = [bm.verts.new((r_in * math.cos(2 * math.pi * i / segs), + r_in * math.sin(2 * math.pi * i / segs), 0.0)) + for i in range(segs)] + for i in range(segs): + j = (i + 1) % segs + bm.faces.new((outer[i], outer[j], inner[j], inner[i])) + ext = bmesh.ops.extrude_face_region(bm, geom=list(bm.faces)) + top = [e for e in ext["geom"] if isinstance(e, bmesh.types.BMVert)] + bmesh.ops.translate(bm, verts=top, vec=(0.0, 0.0, 0.006)) + bmesh.ops.recalc_face_normals(bm, faces=bm.faces) + bm.to_mesh(me) + finally: + bm.free() + me.materials.append(mat) + ob = bpy.data.objects.new(name, me) + bpy.context.scene.collection.objects.link(ob) + ob.location = at + normal * 0.002 + ob.rotation_euler = normal.to_track_quat('Z', 'Y').to_euler() + return ob + + +def render_still(obj, path, engine): + scene = bpy.context.scene + dg = bpy.context.evaluated_depsgraph_get() + rays = world_rays(obj) + # one ghosted trap ray: the first front-face ray's WORLD origin/direction + # read by Object.ray_cast as if local. In the world that is the ray + # matrix_world @ o along matrix_world.to_3x3() @ d; cast it before any + # render-only modifier changes the evaluated mesh. + mw = obj.matrix_world + o, d, p, n = rays[0] + t_o = mw @ o + t_d = (mw.to_3x3() @ d).normalized() + t_ok, t_loc, _n, _i = obj.ray_cast(o, d, depsgraph=dg) + t_end = (mw @ t_loc) if t_ok else (t_o + 3.0 * t_d) + obj.data.materials.append(block_material()) + bev = obj.modifiers.new("Chamfer", 'BEVEL') # render-only, after the check + bev.width = 0.025 + bev.segments = 3 + bev.affect = 'EDGES' + + # emission kept under clipping in Standard view, so the beams stay orange + beam = principled("RayBeam", (1.0, 0.26, 0.02), 0.3, emit=(1.0, 0.26, 0.02), strength=1.4) + hit_mat = principled("HitMarker", (1.0, 0.36, 0.04), 0.25, emit=(1.0, 0.36, 0.04), + strength=1.8) + emitter = principled("RayEmitter", (0.85, 0.58, 0.24), 0.38, metal=1.0) + trap_beam = principled("TrapBeam", (0.9, 0.12, 0.18), 0.4, emit=(1.0, 0.1, 0.16), + strength=1.2) + trap_end = principled("TrapEnd", (0.9, 0.12, 0.18), 0.35, emit=(1.0, 0.1, 0.16), + strength=1.5) + plinth_mat = principled("PlinthWalnut", (0.13, 0.055, 0.025), 0.45) + + shown = [obj] + for k, (o, d, p, n) in enumerate(rays): + shown.append(rod(f"Ray{k}", o, p, 0.022, beam)) + shown.append(sphere(f"Emitter{k}", o, 0.07, emitter)) + shown.append(ring(f"Hit{k}", p, n, 0.10, 0.045, hit_mat)) + + trap = [rod("TrapRay", t_o, t_end, 0.016, trap_beam), + sphere("TrapEmitter", t_o, 0.06, trap_end)] + if not t_ok: + trap.append(sphere("TrapMissEnd", t_end, 0.03, trap_end)) + + # a low walnut plinth the block stands on (block bottom at z = loc - scale) + base_z = TARGET_LOC[2] - TARGET_SCALE[2] + pme = bpy.data.meshes.new("Plinth") + bm = bmesh.new() + try: + res = bmesh.ops.create_cone(bm, cap_ends=True, segments=64, radius1=1.9, + radius2=1.9, depth=base_z) + for v in res["verts"]: + v.co.z += base_z / 2 + bm.to_mesh(pme) + finally: + bm.free() + pme.materials.append(plinth_mat) + for poly in pme.polygons: + poly.use_smooth = True + plinth = bpy.data.objects.new("Plinth", pme) + scene.collection.objects.link(plinth) + plinth.location = (TARGET_LOC[0], TARGET_LOC[1], 0.0) + pb = plinth.modifiers.new("Chamfer", 'BEVEL') + pb.width = 0.02 + pb.segments = 3 + pb.limit_method = 'ANGLE' + + 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, 7.5, 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 + + centre = Vector((TARGET_LOC[0], TARGET_LOC[1], 1.05)) + + def light(name, loc, energy, size, col, aim): + ld = bpy.data.lights.new(name, 'AREA') + ld.energy = energy; ld.size = size; ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = (Vector(aim) - Vector(loc)).to_track_quat('-Z', 'Y').to_euler() + scene.collection.objects.link(ob) + + light("Key", (-4.0, -5.0, 6.0), 520.0, 4.0, (1.0, 0.96, 0.9), centre) + light("Fill", (5.5, -4.0, 2.5), 110.0, 6.0, (0.75, 0.85, 1.0), centre) + light("Rim", (-1.0, 5.0, 6.0), 320.0, 3.0, (0.6, 0.78, 1.0), centre) + light("Wedge", (2.5, 4.0, 3.0), 420.0, 5.0, (1.0, 0.76, 0.5), (4.0, 7.5, 1.0)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = centre + 1.15 * Vector((-5.2, -8.6, 4.4)) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = centre + scene.collection.objects.link(aim) + tr = cam.constraints.new('TRACK_TO') + tr.target = aim + tr.track_axis = 'TRACK_NEGATIVE_Z' + tr.up_axis = 'UP_Y' + scene.camera = cam + + scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id() + if engine == 'cycles': + scene.cycles.samples = 48 + 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 + # AgX would wash the emissive orange rays toward pastel (docs/VISUAL-STYLE.md) + scene.view_settings.view_transform = 'Standard' + bpy.context.view_layer.update() + # Layer 1 framing gate (silhouette matte) — exit 10 on violation + fcode = gallery_framing.check_framing( + scene, cam, + hero=shown + [plinth], + elements=shown + trap + [plinth], + 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 6 + 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("--world-to-object", action="store_true", + help="feed world coords to Object.ray_cast in the checked path (must fail)") + args = p.parse_args(argv) + + obj = build_target() + code = check(obj, world_to_object=args.world_to_object) + 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("ray-cast-space 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 4feccabd..48545a62 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -75,6 +75,8 @@ {"name": "gn-socket-rename", "script": "examples/gn-socket-rename/gn_socket_rename.py"}, {"name": "eval-mesh-datablock-name", "script": "examples/eval-mesh-datablock-name/eval_mesh_datablock_name.py"}, {"name": "mesh-automasking-settings", "script": "examples/mesh-automasking-settings/mesh_automasking_settings.py"}, + {"name": "ray-cast-space", "script": "examples/ray-cast-space/ray_cast_space.py"}, + {"name": "lattice-deform", "script": "examples/lattice-deform/lattice_deform.py"}, {"name": "shipping-crate", "script": "showcase/shipping-crate/shipping_crate.py"}, {"name": "stone-well", "script": "showcase/stone-well/stone_well.py"}, {"name": "wooden-barrel", "script": "showcase/wooden-barrel/wooden_barrel.py"},