diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index c0064dfa..3bc8cc69 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -138,7 +138,9 @@ "examples/vse-linear-modifiers", "examples/wave-displace", "examples/ray-cast-space", - "examples/lattice-deform" + "examples/lattice-deform", + "examples/solidify-even-thickness", + "examples/boolean-exact-volume" ], "showcase": [ "showcase/shipping-crate", diff --git a/AGENTS.md b/AGENTS.md index 35a69d2b..9fdfca6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ a `.cursor-plugin/plugin.json` manifest so the ecosystem drift checker classifies it as a `cursor-plugin`. This is content the AI loads when the user asks Blender questions or works on Blender add-ons in Cursor or Claude Code. -The content base is 16 skills, 9 rules, 3 templates, 27 snippets, 62 +The content base is 16 skills, 9 rules, 3 templates, 27 snippets, 64 examples, and 74 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/`; @@ -36,7 +36,7 @@ Blender-Developer-Tools/ rules/.mdc # 9 rule files templates// # 3 starter templates snippets/.py # 27 standalone Python snippets - examples// # 62 runnable smoke-gated examples (+ gallery.json) + examples// # 64 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 635383ac..289aab19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ skills//SKILL.md - AI workflow definitions, 16 total rules/.mdc - Anti-pattern rules, 9 total templates// - Starter projects, 3 total snippets/.py - Standalone code patterns, 27 total -examples// - Runnable smoke-gated examples, 62 total (+ gallery.json) +examples// - Runnable smoke-gated examples, 64 total (+ gallery.json) showcase// - Budget-conformance props, 74 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 @@ -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 (62) +## Examples (64) 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); -**54 of the 62 ship a render in the site gallery** at `docs/gallery/`. The other +**56 of the 64 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 4ab6cd05..a0bfae10 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  62 examples  •  74 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  64 examples  •  74 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 62 examples, and 74 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, 64 examples, and 74 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 62 examples carries a **falsifier**: a flag that changes the +Every one of the 64 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 @@ -458,7 +458,7 @@ Gallery still is a sky-lit obelisk diptych (8° dusk | 55° midday) so the contr

-Mesh, curves & text — 15 examples +Mesh, curves & text — 17 examples @@ -686,6 +686,36 @@ A 2×2×2 Lattice modifier set to `'KEY_LINEAR'`, with two corners moved through blend of the eight deformed corners, and that the default `'KEY_BSPLINE'` misses it (`--bspline` exits 4). + + + + + + + + +
+Solidify even thickness: two folded zigzag shells on a walnut plinth, their cut ends in orange, the left band pinching thin at every fold and the right staying one width, above brass plaques reading use_even_offset = False and use_even_offset = True + + +### [solidify-even-thickness](examples/solidify-even-thickness/) + +Solidify on a strip folded at 60°, 90° and 120°. Without `use_even_offset`, each fold vertex +moves *t* along the bisector and the wall thins to *t*·cos(φ/2), half at 120°. With it, the +vertex moves *t*/cos(φ/2) and the wall stays exactly *t*. Both closed forms are measured off +the evaluated mesh to 1e-5 (`--no-even` exits 4). + +
+Boolean exact volume: three results on a walnut plinth, each inside thin steel and glowing orange outlines of its two operands - a teal union with a slab grown out of a cube, a brass cube with a notch cut down through its top, and the orange one-metre overlap cube, on a dark studio floor + + +### [boolean-exact-volume](examples/boolean-exact-volume/) + +The Boolean modifier on `solver='EXACT'`, cutting a cube with a slab whose top face is +coplanar with the cube's. Union, difference and intersection measure their closed-form +9, 7 and 1 m³ by the divergence theorem and are closed 2-manifolds. The floating-point +solver, renamed `'FAST'` → `'FLOAT'` in 5.0, gets the union wrong (`--float-solver` exits 3). +
diff --git a/ROADMAP.md b/ROADMAP.md index 63a6227e..6db9755c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -167,8 +167,9 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - `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 +- ~~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~~ **SHIPPED** as `examples/solidify-even-thickness/` — Simple-mode Solidify (offset −1) on a strip folded at 60°/90°/120°: the even shell is exactly t thick (copies t/cos(φ/2) along the bisector), the plain shell t·cos(φ/2) = t·sin(θ/2) (0.866t, 0.707t, 0.5t), both to 3.7e-8; the candidate's "thins by 1/cos(θ/2)" was imprecise, since the factor is cos(φ/2) on the bend angle; `--no-even` exits 4 +- ~~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~~ **SHIPPED** as `examples/boolean-exact-volume/` — union/difference/intersection of a cube and a top-coplanar slab through translated, Z-turned operands: 9 / 7 / 1 m³ by the divergence theorem (1e-6 rel), closed 2-manifolds; the floating-point solver (`'FAST'` on 4.5, renamed `'FLOAT'` in 5.0) gives a 7.708 m³ union and `--float-solver` exits 3 +- MANIFOLD Boolean solver witness — the 4.5+ `'MANIFOLD'` solver against EXACT on the same closed-form operands: identical volumes with fewer faces (10 vs 14 on the fully coplanar union), plus its documented refusal of non-manifold input operands (result empty or unchanged) as the falsifier - ~~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 diff --git a/docs/gallery/armature-bend/index.html b/docs/gallery/armature-bend/index.html index 6166426e..c8bb1571 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/boolean-exact-volume-hero-640.webp b/docs/gallery/assets/boolean-exact-volume-hero-640.webp new file mode 100644 index 00000000..40c0618d Binary files /dev/null and b/docs/gallery/assets/boolean-exact-volume-hero-640.webp differ diff --git a/docs/gallery/assets/boolean-exact-volume-hero.webp b/docs/gallery/assets/boolean-exact-volume-hero.webp new file mode 100644 index 00000000..becf6c40 Binary files /dev/null and b/docs/gallery/assets/boolean-exact-volume-hero.webp differ diff --git a/docs/gallery/assets/solidify-even-thickness-hero-640.webp b/docs/gallery/assets/solidify-even-thickness-hero-640.webp new file mode 100644 index 00000000..7fbfce03 Binary files /dev/null and b/docs/gallery/assets/solidify-even-thickness-hero-640.webp differ diff --git a/docs/gallery/assets/solidify-even-thickness-hero.webp b/docs/gallery/assets/solidify-even-thickness-hero.webp new file mode 100644 index 00000000..0cecca1a Binary files /dev/null and b/docs/gallery/assets/solidify-even-thickness-hero.webp differ diff --git a/docs/gallery/attribute-domain-shear/index.html b/docs/gallery/attribute-domain-shear/index.html index 196440db..c4de4250 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 e199dcd5..1c475160 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 d756b011..0e6ddf90 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/boolean-exact-volume/index.html b/docs/gallery/boolean-exact-volume/index.html new file mode 100644 index 00000000..a39db438 --- /dev/null +++ b/docs/gallery/boolean-exact-volume/index.html @@ -0,0 +1,1134 @@ + + + + + + Boolean Exact Volume (boolean-exact-volume) — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +

Boolean Exact Volume

+

examples/boolean-exact-volume/

+

The Boolean modifier on the EXACT solver: union, difference and intersection of a cube and a slab whose top face is coplanar with the cube's, read back through the depsgraph and measured by the divergence theorem.

+
+
+ + +

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

+
witnesses EXACT booleans are closed-form even on coplanar faces: through translated, 18°-turned operands the union, difference and intersection measure 9, 7 and 1 m³ (8.999999 / 6.999999 / 1.000000, float32) and are closed 2-manifolds with no loose geometry; the floating-point solver ('FAST' on 4.5, 'FLOAT' on 5.x) gives a 7.708 m³ union and --float-solver exits 3.
+

tags mesh modifiers depsgraph

+
+
blender --background --python examples/boolean-exact-volume/boolean_exact_volume.py --
+ +
+
+

A runnable example that cuts a 2 m cube with a 2 × 1 × 1 m slab using the Boolean modifier on the EXACT solver, in all three operations (UNION, DIFFERENCE, INTERSECT), then proves that each result has the closed-form volume and is a closed 2-manifold. The hard part is deliberate: the slab's top face lies in the cube's top plane, the coplanar case where floating-point booleans go wrong. The results are read through the depsgraph lifetime contract from depsgraph-and-evaluated-data (evaluated_get → to_mesh → to_mesh_clear), and the operands are built with bmesh and freed in try/finally as mesh-editing-and-bmesh requires.

+

What it witnesses: with A = [0,2]³ and B = [1,3] × [0.5,1.5] × [1,2], the overlap is A ∩ B = [1,2] × [0.5,1.5] × [1,2], so the closed forms are V(A ∪ B) = 8 + 2 − 1 = 9, V(A − B) = 8 − 1 = 7 and V(A ∩ B) = 1 cubic metre. Each operand pair sits in an object matrix that is translated and turned 18° about Z, so the modifier has to carry B into A's space. For every result the check:

+
  • sums the signed volume over the evaluated mesh's own triangles (divergence theorem, world space) and requires the closed form to 1e-6 relative (measured 8.999999, 6.999999 and 1.000000 — float32 coordinates through a rotation). A positive volume also means the normals point outward;
  • requires a closed 2-manifold: every edge borders exactly two faces, with no loose verts or edges;
  • requires that the result's volume differs from V(A) by at least 0.5 m³, so the modifier really acted and the witness cannot pass vacuously.
+

The trap it exposes: the floating-point solver is faster and is right in general position, but it mishandles coplanar faces. --float-solver runs the same three operations on it and gets a union of 7.708 m³ against the closed form 9, so check 3 fails with the measured error. That solver was 'FAST' through 4.5 LTS and is 'FLOAT' from 5.0, so a script that hard-codes either name breaks on the other series (fast_solver_id() branches on bpy.app.version). 'EXACT' is the same identifier on 4.5, 5.1 and 5.2.

+

The still shows the three operations side by side on a walnut plinth, each with its two operands drawn as thin outlines (the cube in steel, the coplanar slab in glowing orange): on the left the union in teal glaze with the slab's free end grown out of the cube; in the middle the difference in brass with the slab's notch cut down through the top face; on the right the intersection, the 1 m³ overlap cube, in orange. The outlines are render-only rods built after the check has run, and the cutter objects themselves never render.

+

Run#

+
# Cheap correctness check (no render) — the CI check:
+blender --background --python boolean_exact_volume.py --
+
+# Falsifier: the floating-point solver (FLOAT / FAST) on the coplanar case. Must exit 3.
+blender --background --python boolean_exact_volume.py -- --float-solver
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python boolean_exact_volume.py -- --output boolean.png --engine cycles
+

Version notes#

+

BooleanModifier.solver lists 'FAST', 'EXACT', 'MANIFOLD' on 4.5 LTS and 'FLOAT', 'EXACT', 'MANIFOLD' on 5.1 and 5.2 LTS: the floating-point solver was renamed in 5.0. Measured identically on 4.5.11, 5.1.2 and 5.2.1: EXACT union 8.999999 (12 faces), difference 6.999999 (10 faces), intersection 1.000000 (6 faces), all closed 2-manifolds; --float-solver union 7.708379.

+

Exit codes#

+
CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3A result's volume is off its closed form (--float-solver lands here)
4A result is not a closed 2-manifold (an edge without exactly two faces, or loose geometry)
5A result's volume is within 0.5 m³ of operand A's (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 --float-solver.

+
+
+

Source

+
+ examples/boolean-exact-volume/boolean_exact_volume.py + 412 lines · View on GitHub → +
+
+
"""EXACT Boolean volumes are closed-form and manifold, even on coplanar faces — a runnable example.
+
+Witnesses the Boolean modifier contract with ``solver = 'EXACT'``. Operand A
+is a 2 m cube and operand B a 2 x 1 x 1 m slab that overlaps A by a 1 m cube
+and whose top face lies in A's top plane: the coplanar case. Both operands
+sit in object matrices that are translated and turned about Z, so the
+modifier has to bring B into A's space. For each of UNION, DIFFERENCE and
+INTERSECT the evaluated mesh (``evaluated_get`` + ``to_mesh`` /
+``to_mesh_clear``) is measured in world space:
+
+- 3: its signed volume, by the divergence theorem over its own triangles,
+  matches the closed form (V(A) + V(B) - V(A n B) = 9, V(A) - V(A n B) = 7,
+  V(A n B) = 1 cubic metres) to 1e-6 relative; positive volume also means
+  the normals point outward;
+- 4: it is a closed 2-manifold: every edge borders exactly two faces, and
+  there are no loose verts or edges;
+- 5: it is not vacuous: each result's volume differs from V(A) by at least
+  0.5 m^3, so the modifier really acted.
+
+``--float-solver`` runs the same three operations on the floating-point
+solver (``'FLOAT'`` on Blender 5.x, ``'FAST'`` on 4.5 LTS, the same solver
+under its older name). On the coplanar top face it returns a wrong union
+(7.708 m^3 against 9, measured identically on 4.5.11, 5.1.2 and 5.2.1), so
+check 3 fails with the measured error. That is the falsifier, and the documented failure case
+the EXACT solver exists to handle.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+    blender --background --python boolean_exact_volume.py --                     # check only
+    blender --background --python boolean_exact_volume.py -- --float-solver      # must fail
+    blender --background --python boolean_exact_volume.py -- --output b.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
+
+# Operands in their shared local frame (metres). B's top face (z = 2) is
+# coplanar with A's top face; the overlap A n B is [1,2] x [0.5,1.5] x [1,2].
+A_LO, A_HI = (0.0, 0.0, 0.0), (2.0, 2.0, 2.0)
+B_LO, B_HI = (1.0, 0.5, 1.0), (3.0, 1.5, 2.0)
+OPS = ('UNION', 'DIFFERENCE', 'INTERSECT')
+SPACING = 4.6           # world X between the three operation sets
+TURN = math.radians(-18.0)  # every set's frame turns about Z
+
+
+def box_volume(lo, hi):
+    return (hi[0] - lo[0]) * (hi[1] - lo[1]) * (hi[2] - lo[2])
+
+
+def overlap(lo1, hi1, lo2, hi2):
+    lo = tuple(max(lo1[k], lo2[k]) for k in range(3))
+    hi = tuple(min(hi1[k], hi2[k]) for k in range(3))
+    return lo, hi
+
+
+V_A = box_volume(A_LO, A_HI)
+V_B = box_volume(B_LO, B_HI)
+V_AB = box_volume(*overlap(A_LO, A_HI, B_LO, B_HI))
+EXPECTED = {'UNION': V_A + V_B - V_AB, 'DIFFERENCE': V_A - V_AB, 'INTERSECT': V_AB}
+REL_TOL = 1e-6
+MIN_CHANGE = 0.5        # m^3; check 5's floor against V(A)
+
+
+def fast_solver_id():
+    """The floating-point solver: 'FAST' through 4.5 LTS, renamed 'FLOAT' in 5.0."""
+    return 'FLOAT' if bpy.app.version >= (5, 0, 0) else 'FAST'
+
+
+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 frame(i):
+    """World matrix of operation set i: translated along X, turned about Z,
+    with the operands' local frame centred on A's footprint."""
+    centre = Matrix.Translation((-(A_HI[0] - A_LO[0]) / 2 - 0.5, -(A_HI[1] - A_LO[1]) / 2, 0.0))
+    return (Matrix.Translation(((i - 1) * SPACING, 0.0, 0.0))
+            @ Matrix.Rotation(TURN, 4, 'Z') @ centre)
+
+
+def build_scene(float_solver=False):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    scene = bpy.context.scene
+    solver = fast_solver_id() if float_solver else 'EXACT'
+    results = {}
+    cutters = {}
+    for i, op in enumerate(OPS):
+        a = bpy.data.objects.new(f"{op.title()}Result", box_mesh(f"{op.title()}Result", A_LO, A_HI))
+        b = bpy.data.objects.new(f"{op.title()}Cutter", box_mesh(f"{op.title()}Cutter", B_LO, B_HI))
+        a.matrix_world = frame(i)
+        b.matrix_world = frame(i)
+        b.hide_render = True
+        b.display_type = 'WIRE'
+        scene.collection.objects.link(a)
+        scene.collection.objects.link(b)
+        mod = a.modifiers.new("Boolean", 'BOOLEAN')
+        mod.operation = op
+        mod.solver = solver
+        mod.object = b
+        results[op] = a
+        cutters[op] = b
+    return results, cutters, solver
+
+
+def measure(obj):
+    """World-space signed volume (divergence theorem over fan triangles of
+    each polygon) plus manifold counts, from the evaluated mesh."""
+    deps = bpy.context.evaluated_depsgraph_get()
+    ev = obj.evaluated_get(deps)
+    me = ev.to_mesh()
+    try:
+        mw = ev.matrix_world
+        co = [mw @ v.co for v in me.vertices]
+        vol = 0.0
+        for p in me.polygons:
+            idx = list(p.vertices)
+            v0 = co[idx[0]]
+            for j in range(1, len(idx) - 1):
+                vol += v0.dot(co[idx[j]].cross(co[idx[j + 1]])) / 6.0
+        bm = bmesh.new()
+        try:
+            bm.from_mesh(me)
+            bad_edges = sum(1 for e in bm.edges if len(e.link_faces) != 2)
+            loose_verts = sum(1 for v in bm.verts if not v.link_faces)
+            loose_edges = sum(1 for e in bm.edges if not e.link_faces)
+        finally:
+            bm.free()
+        return vol, bad_edges, loose_verts, loose_edges, len(me.polygons)
+    finally:
+        ev.to_mesh_clear()
+
+
+def check(results, solver):
+    bpy.context.view_layer.update()
+    m = {op: measure(results[op]) for op in OPS}
+
+    # 3: volumes on the closed forms (positive => outward normals)
+    for op in OPS:
+        vol, exp = m[op][0], EXPECTED[op]
+        if abs(vol - exp) > REL_TOL * exp:
+            print(f"ERROR: {op} on solver {solver!r}: volume {vol:.6f} m^3, closed form "
+                  f"{exp:.6f} (error {vol - exp:+.6f})", file=sys.stderr)
+            return 3
+
+    # 4: closed 2-manifold, no loose geometry
+    for op in OPS:
+        _, bad, lv, le, _ = m[op]
+        if bad or lv or le:
+            print(f"ERROR: {op} on solver {solver!r} is not a closed manifold: {bad} edges "
+                  f"without exactly two faces, {lv} loose verts, {le} loose edges",
+                  file=sys.stderr)
+            return 4
+
+    # 5: every result really differs from operand A
+    for op in OPS:
+        if abs(m[op][0] - V_A) < MIN_CHANGE:
+            print(f"ERROR: {op} volume {m[op][0]:.4f} within {MIN_CHANGE} of V(A) = {V_A}",
+                  file=sys.stderr)
+            return 5
+
+    parts = ", ".join(f"{op} {m[op][0]:.6f} (closed form {EXPECTED[op]:g}, {m[op][4]} faces)"
+                      for op in OPS)
+    print(f"solver {solver!r}, coplanar top face: {parts}; all closed 2-manifold, "
+          f"0 loose verts/edges")
+    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 tube_mesh(name, segments, radius, sides=10):
+    """Render-only rods along line segments (the operand outlines)."""
+    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 box_edges(lo, hi, mw):
+    c = {(i, j, k): mw @ Vector((hi[0] if i else lo[0], hi[1] if j else lo[1], hi[2] if k else lo[2]))
+         for i in (0, 1) for j in (0, 1) for k in (0, 1)}
+    edges = []
+    for i in (0, 1):
+        for j in (0, 1):
+            edges.append((c[(i, j, 0)], c[(i, j, 1)]))
+            edges.append((c[(i, 0, j)], c[(i, 1, j)]))
+            edges.append((c[(0, i, j)], c[(1, i, j)]))
+    return edges
+
+
+def render_still(results, cutters, path, engine):
+    scene = bpy.context.scene
+    glazes = {
+        'UNION': principled("UnionGlaze", (0.07, 0.30, 0.34), 0.28, coat=0.5),
+        'DIFFERENCE': principled("DifferenceBrass", (0.86, 0.56, 0.26), 0.30, metal=1.0, noise=60.0),
+        'INTERSECT': principled("IntersectOrange", (0.95, 0.36, 0.05), 0.32, coat=0.4),
+    }
+    steel = principled("OperandA", (0.62, 0.64, 0.68), 0.25, metal=1.0)
+    cutter_mat = principled("OperandB", (1.0, 0.45, 0.06), 0.35,
+                            emit=((1.0, 0.45, 0.06), 1.2))
+    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
+
+    for i, op in enumerate(OPS):
+        res = results[op]
+        res.data.materials.append(glazes[op])
+        mw = frame(i)
+        # thin outlines of both operands: A in steel, the coplanar cutter B in orange
+        add(f"{op.title()}OutlineA", tube_mesh(f"{op.title()}OutlineA",
+                                               box_edges(A_LO, A_HI, mw), 0.016), steel)
+        add(f"{op.title()}OutlineB", tube_mesh(f"{op.title()}OutlineB",
+                                               box_edges(B_LO, B_HI, mw), 0.020), cutter_mat)
+
+    half_x = SPACING + 2.2
+    plinth = add("Plinth", bpy.data.meshes.new("Plinth"), walnut)
+    bm = bmesh.new()
+    try:
+        res = bmesh.ops.create_cube(bm, size=1.0)
+        lo, hi = (-half_x, -2.3, -0.16), (half_x, 2.3, -0.004)
+        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(plinth.data)
+    finally:
+        bm.free()
+    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)
+    floor.location = (0.0, 0.0, -0.16)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 8.0, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0)
+    scene.world = world
+
+    centre = Vector((0.0, 0.0, 1.0))
+
+    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", (-5.0, -6.0, 7.5), 700.0, 5.0, (1.0, 0.96, 0.9), centre)
+    light("Fill", (7.0, -5.5, 3.0), 140.0, 8.0, (0.75, 0.85, 1.0), centre)
+    light("Rim", (2.0, 5.0, 7.0), 420.0, 4.0, (0.6, 0.78, 1.0), centre)
+    light("Wedge", (3.0, 5.0, 3.0), 480.0, 6.0, (1.0, 0.76, 0.5), (5.0, 8.0, 1.0))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (3.2, -20.9, 11.0)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = centre + Vector((0.0, 0.0, -0.1))
+    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 glazes and the orange cutter 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
+    shown = list(results.values()) + parts
+    fcode = gallery_framing.check_framing(
+        scene, cam,
+        hero=shown,
+        elements=shown,
+        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("--float-solver", action="store_true",
+                   help="use the floating-point solver (FLOAT / FAST) on the coplanar case (must fail)")
+    args = p.parse_args(argv)
+
+    results, cutters, solver = build_scene(float_solver=args.float_solver)
+    code = check(results, solver)
+    if code:
+        return code
+
+    if args.output:
+        rcode = render_still(results, cutters, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+
+    print("boolean-exact-volume 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)
+
+
+
+ + +
+ + + Three results on a walnut plinth, each inside steel and orange operand outlines: a teal union with a slab grown out of a cube, a brass cube notched through its top, and an orange overlap cube. + + +

+ + + diff --git a/docs/gallery/car-mirror-symmetry/index.html b/docs/gallery/car-mirror-symmetry/index.html index 3084e15b..abc4fb02 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.

- + @@ -1892,25 +1892,25 @@

Lattice Deform

- A game controller's sparse control cage in orange wire and vertex beads beside the smooth subdivided cobalt controller with sticks and colored buttons, on a dark studio floor. + Two folded zigzag shells on a walnut plinth with orange cut ends: the left band pinching thin at each fold, the right one even width, above brass plaques reading use_even_offset False and True.
-

Depsgraph Export

-

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

+

Solidify Even Thickness

+

Solidify's use_even_offset on a folded strip: off, each fold vertex moves t along the bisector of its face normals and the shell thins to t*cos(phi/2); on, it moves t/cos(phi/2) and stays exactly t thick at 60, 90 and 120 degree folds.

- Three spiral staircases of one design, with oak treads, teal posts and brass rails, rising left to right from seven steps to seventeen to twenty-seven. + Three results on a walnut plinth, each inside steel and orange operand outlines: a teal union with a slab grown out of a cube, a brass cube notched through its top, and an orange overlap cube.
-

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.

+

Boolean Exact Volume

+

The Boolean modifier on the EXACT solver: union, difference and intersection of a cube and a slab whose top face is coplanar with the cube's, read back through the depsgraph and measured by the divergence theorem.

- +
diff --git a/docs/gallery/collision-hull-proxy/index.html b/docs/gallery/collision-hull-proxy/index.html index 45db1ffd..cd109236 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 14b5bb2d..298893db 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 73ad9cf9..f87d61cc 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/boolean-exact-volume-contact-sheet.webp b/docs/gallery/contact-sheets/boolean-exact-volume-contact-sheet.webp new file mode 100644 index 00000000..b7a6ccd7 Binary files /dev/null and b/docs/gallery/contact-sheets/boolean-exact-volume-contact-sheet.webp differ diff --git a/docs/gallery/contact-sheets/solidify-even-thickness-contact-sheet.webp b/docs/gallery/contact-sheets/solidify-even-thickness-contact-sheet.webp new file mode 100644 index 00000000..9d3a00ee Binary files /dev/null and b/docs/gallery/contact-sheets/solidify-even-thickness-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 bba5b652..3967282c 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 54eaee86..f89fbe1c 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 f77388c3..ff4d7269 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 0e2f2624..710a1dd7 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 a1e89370..c1625982 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 e9f64cb5..10c2d448 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 c39c17a5..b4a85599 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 a740570b..9b1c0054 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 126cc03a..1bfa58bf 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 e605d06f..478a6731 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 0c997956..25a0575f 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 7b92c356..7beabc83 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 62e38586..09d9fd54 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 a3a3f64a..4bf2981f 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 5bb60233..891d119b 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 0c1a34b5..b1e0629b 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 96cb86e8..cbca8065 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 cff343c8..b4a02d3c 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 bd7fc27e..5d7441a3 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 e1a1d16b..1998542d 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" /> - 54 examples, 74 showcase pieces + 56 examples, 74 showcase pieces
@@ -790,6 +790,30 @@

Lattice Deform

+
+
+ Two folded zigzag shells on a walnut plinth with orange cut ends: the left band pinching thin at each fold, the right one even width, above brass plaques reading use_even_offset False and True. +
+
+

Solidify Even Thickness

+

solidify-even-thickness

+

Solidify's use_even_offset on a folded strip: off, each fold vertex moves t along the bisector of its face normals and the shell thins to t*cos(phi/2); on, it moves t/cos(phi/2) and stays exactly t thick at 60, 90 and 120 degree folds.

+

witnesses Simple-mode Solidify (offset -1, t = 0.16) on a strip folded at 60, 90 and 120 degrees: the even shell is exactly t thick at every fold, its copies t/cos(phi/2) along the bisector; the plain shell is t*cos(phi/2) thick: 0.1386, 0.1131, 0.0800 (max error 3.7e-8). --no-even exits 4 at 0.0800 vs 0.1600.

+ +
+
+
+
+ Three results on a walnut plinth, each inside steel and orange operand outlines: a teal union with a slab grown out of a cube, a brass cube notched through its top, and an orange overlap cube. +
+
+

Boolean Exact Volume

+

boolean-exact-volume

+

The Boolean modifier on the EXACT solver: union, difference and intersection of a cube and a slab whose top face is coplanar with the cube's, read back through the depsgraph and measured by the divergence theorem.

+

witnesses EXACT booleans are closed-form even on coplanar faces: through translated, 18°-turned operands the union, difference and intersection measure 9, 7 and 1 m³ (8.999999 / 6.999999 / 1.000000, float32) and are closed 2-manifolds with no loose geometry; the floating-point solver ('FAST' on 4.5, 'FLOAT' on 5.x) gives a 7.708 m³ union and --float-solver exits 3.

+ +
+
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. @@ -1736,7 +1760,7 @@

Garden Gate

var filtersToggle = document.getElementById('filtersToggle'); var toTop = document.getElementById('toTop'); var total = cards.length; - var COUNT_LABEL = '54 examples, 74 showcase pieces'; + var COUNT_LABEL = '56 examples, 74 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 index 035712aa..5b00a425 100644 --- a/docs/gallery/lattice-deform/index.html +++ b/docs/gallery/lattice-deform/index.html @@ -45,7 +45,7 @@

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

- + @@ -1010,34 +1010,34 @@ - +
diff --git a/docs/gallery/light-link-studio/index.html b/docs/gallery/light-link-studio/index.html index 4ece87b2..522739fe 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 c566d8a2..cccf6a32 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 6c984dd3..4b5fa7b1 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.

- + @@ -1373,25 +1373,25 @@

Lattice Deform

- A game controller's sparse control cage in orange wire and vertex beads beside the smooth subdivided cobalt controller with sticks and colored buttons, on a dark studio floor. + Two folded zigzag shells on a walnut plinth with orange cut ends: the left band pinching thin at each fold, the right one even width, above brass plaques reading use_even_offset False and True.
-

Depsgraph Export

-

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

+

Solidify Even Thickness

+

Solidify's use_even_offset on a folded strip: off, each fold vertex moves t along the bisector of its face normals and the shell thins to t*cos(phi/2); on, it moves t/cos(phi/2) and stays exactly t thick at 60, 90 and 120 degree folds.

- A cast bronze tile in a walnut frame whose top is a grid displaced into rolling sine-wave crests, polished on the peaks with green verdigris pooled in the troughs. + Three results on a walnut plinth, each inside steel and orange operand outlines: a teal union with a slab grown out of a cube, a brass cube notched through its top, and an orange overlap cube.
-

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.

+

Boolean Exact Volume

+

The Boolean modifier on the EXACT solver: union, difference and intersection of a cube and a slab whose top face is coplanar with the cube's, read back through the depsgraph and measured by the divergence theorem.

- +
diff --git a/docs/gallery/mesh-hygiene-audit/index.html b/docs/gallery/mesh-hygiene-audit/index.html index d1cce2ef..0c4b7645 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 4c22e1ec..7a9ed429 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 d86178e7..4006c521 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.

- + @@ -1033,7 +1033,7 @@

Prop Origin Transform - +

diff --git a/docs/gallery/png-exr-alpha/index.html b/docs/gallery/png-exr-alpha/index.html index 3a1e0ec7..f971c572 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 a863af4b..ad784709 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 index 9cc5238b..b2f648cc 100644 --- a/docs/gallery/ray-cast-space/index.html +++ b/docs/gallery/ray-cast-space/index.html @@ -45,7 +45,7 @@

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.

- + @@ -1070,7 +1070,7 @@

Armature Bend

- +
diff --git a/docs/gallery/shader-node-group/index.html b/docs/gallery/shader-node-group/index.html index 22af579a..e8c28f51 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 94bd28ab..63de2feb 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 c28379e6..8d21cfbf 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 f7b83348..572b0596 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 87de4649..a96f4a55 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/solidify-even-thickness/index.html b/docs/gallery/solidify-even-thickness/index.html new file mode 100644 index 00000000..814bf27a --- /dev/null +++ b/docs/gallery/solidify-even-thickness/index.html @@ -0,0 +1,1191 @@ + + + + + + Solidify Even Thickness (solidify-even-thickness) — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +

Solidify Even Thickness

+

examples/solidify-even-thickness/

+

Solidify's use_even_offset on a folded strip: off, each fold vertex moves t along the bisector of its face normals and the shell thins to t*cos(phi/2); on, it moves t/cos(phi/2) and stays exactly t thick at 60, 90 and 120 degree folds.

+
+
+ + +

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

+
witnesses Simple-mode Solidify (offset -1, t = 0.16) on a strip folded at 60, 90 and 120 degrees: the even shell is exactly t thick at every fold, its copies t/cos(phi/2) along the bisector; the plain shell is t*cos(phi/2) thick: 0.1386, 0.1131, 0.0800 (max error 3.7e-8). --no-even exits 4 at 0.0800 vs 0.1600.
+

tags mesh modifiers depsgraph

+
+
blender --background --python examples/solidify-even-thickness/solidify_even_thickness.py --
+ +
+
+

A runnable example that solidifies an open, folded strip with the Solidify modifier and proves, fold by fold, what use_even_offset does to the shell's thickness. The strip is a zigzag profile extruded along Y, folded at bend angles of 60°, 90° and 120°. The evaluated shells are read through the depsgraph lifetime contract from depsgraph-and-evaluated-data (evaluated_get → to_mesh → to_mesh_clear), and the strip is built with bmesh in a try/finally, as mesh-editing-and-bmesh requires.

+

What it witnesses: in the default Simple mode (solidify_mode = 'EXTRUDE') with offset = -1, the original surface stays put and every vertex gets a copy pushed thickness along its vertex normal. At a fold the vertex normal is the bisector of the two face normals, which meet at the bend angle φ. So:

+
  • without use_even_offset the copy moves t along the bisector, and the shell's perpendicular thickness at the fold is t · cos(φ/2), which is t · sin(θ/2) for the interior angle θ = 180° − φ. With t = 0.16 that is 0.1386 at 60°, 0.1131 at 90° and 0.0800 at 120°: half the thickness you asked for;
  • with use_even_offset the copy moves t / cos(φ/2) along the bisector, and the thickness is exactly t at every fold.
+

The check builds both closed forms from the profile itself, taking the face planes from the segment directions and never from Blender's normals. It measures each shell's perpendicular thickness at every fold and free edge and requires agreement to 1e-5 (measured: 3.7e-8). It also asserts the topology the measurement relies on: 2N evaluated vertices, the first N being the untouched original surface, and copy i at vertex N + i. Finally it checks that the thinning is real, so the witness cannot pass vacuously.

+

The trap it exposes: use_even_offset is off by default. A script that solidifies a bent panel, a folded bracket or a box with thickness = t gets a wall that is noticeably thinner at every corner, down to half at a 120° bend, and nothing errors. --no-even leaves the flag off on the shell that the check expects to be even, and check 4 fails with the measured thickness.

+

The still shows both shells side by side on a walnut plinth, viewed end-on. The cut section, Solidify's rim, is drawn in selection orange through material_offset_rim, so the band's width is the shell's thickness. On the left, with use_even_offset = False, the band pinches at every fold, and to half at the sharp V. On the right, with use_even_offset = True, it stays one width all the way round. Brass plaques name each setting, and brass dowels hold the raised folds off the plinth.

+

Run#

+
# Cheap correctness check (no render) — the CI check:
+blender --background --python solidify_even_thickness.py --
+
+# Falsifier: leave use_even_offset off on the shell checked as even. Must exit 4.
+blender --background --python solidify_even_thickness.py -- --no-even
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python solidify_even_thickness.py -- --output solidify.png --engine cycles
+

Version notes#

+

The Solidify modifier properties used here (solidify_mode, thickness, offset, use_even_offset, use_rim, material_offset_rim) and the output vertex order (originals first, then copies) are the same on 4.5 LTS, 5.1 and 5.2 LTS. The defaults are also the same: 'EXTRUDE', offset −1, even off, rim on, quality normals off. All values were measured identically on 4.5.11, 5.1.2 and 5.2.1.

+

Exit codes#

+
CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Evaluated topology is not the original surface plus one copy per vertex (2N verts, originals untouched)
4The even shell is not t thick at every fold, or its copies are off t / cos(φ/2) (--no-even lands here)
5The plain shell is off the t · cos(φ/2) closed form at a fold
6The plain shell thins by less than the floor (the witness would pass vacuously)
7--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 --no-even.

+
+
+

Source

+
+ examples/solidify-even-thickness/solidify_even_thickness.py + 440 lines · View on GitHub → +
+
+
"""Solidify Even Thickness keeps a folded shell's thickness constant — a runnable example.
+
+Witnesses the Solidify modifier's ``use_even_offset`` contract on an open,
+folded strip (a zigzag profile extruded along Y, folded at bend angles of
+60, 90 and 120 degrees). In the default Simple (``'EXTRUDE'``) mode with
+``offset = -1`` the original surface stays put and each vertex gets a copy
+pushed ``thickness`` along its vertex normal. At a fold the vertex normal is
+the bisector of the two face normals, which meet at the bend angle phi, so:
+
+- without even offset the copy moves t along the bisector and the shell's
+  perpendicular thickness at the fold is t * cos(phi / 2) — 0.866 t, 0.707 t
+  and 0.5 t at the three folds;
+- with ``use_even_offset`` the copy moves t / cos(phi / 2) along the bisector
+  and the thickness is exactly t at every fold.
+
+The check computes both closed forms from the profile itself (face planes
+from the segment directions, never from Blender's normals), reads the
+evaluated shells through the depsgraph (``evaluated_get`` + ``to_mesh`` /
+``to_mesh_clear``) and requires agreement to 1e-5. Four checks, in run order:
+
+- 3: topology — 2N evaluated verts, the first N are the untouched original
+  surface (offset -1), copy i is vert N + i;
+- 4: the even shell is exactly t thick at every fold and free edge;
+- 5: the plain shell is t * cos(phi / 2) thick at every fold;
+- 6: the thinning is real (the witness cannot pass vacuously).
+
+``--no-even`` leaves ``use_even_offset`` off on the shell the check expects
+to be even, so check 4 fails with the measured thickness. That is the
+falsifier — and the trap: a script that solidifies a bent panel by
+``thickness`` gets half that thickness at a 120-degree fold.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+    blender --background --python solidify_even_thickness.py --                 # check only
+    blender --background --python solidify_even_thickness.py -- --no-even       # must fail
+    blender --background --python solidify_even_thickness.py -- --output s.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
+
+THICKNESS = 0.16
+# Profile segment headings in the XZ plane (degrees from +X): the bends
+# between consecutive segments are 60, 90 and 120 degrees.
+HEADINGS = (-30.0, 30.0, -60.0, 60.0)
+SEG_LEN = 0.7
+DEPTH = 1.1            # extrusion along Y
+Y_CUTS = 3             # rows of quads along Y
+TOL = 1e-5
+MIN_THINNING = 0.02    # check 6's floor on t - min(plain fold thickness)
+SHELL_GAP = 2.45       # render only: the plain shell's offset to the left
+Y_AXIS = Vector((0.0, 1.0, 0.0))
+
+
+def profile_points():
+    pts = [Vector((0.0, 0.0, 0.0))]
+    for h in HEADINGS:
+        a = math.radians(h)
+        pts.append(pts[-1] + SEG_LEN * Vector((math.cos(a), 0.0, math.sin(a))))
+    return pts
+
+
+def segment_normals(pts):
+    """Unit face normals of each strip segment, from the profile alone."""
+    return [(pts[k + 1] - pts[k]).cross(Y_AXIS).normalized() for k in range(len(pts) - 1)]
+
+
+def build_shell(name, even):
+    pts = profile_points()
+    me = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        rows = []
+        for j in range(Y_CUTS + 1):
+            y = -DEPTH / 2 + DEPTH * j / Y_CUTS
+            rows.append([bm.verts.new((p.x, y, p.z)) for p in pts])
+        for j in range(Y_CUTS):
+            for i in range(len(pts) - 1):
+                bm.faces.new((rows[j][i], rows[j][i + 1], rows[j + 1][i + 1], rows[j + 1][i]))
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    obj = bpy.data.objects.new(name, me)
+    bpy.context.scene.collection.objects.link(obj)
+    mod = obj.modifiers.new("Solidify", 'SOLIDIFY')
+    mod.solidify_mode = 'EXTRUDE'
+    mod.thickness = THICKNESS
+    mod.offset = -1.0
+    mod.use_even_offset = even
+    return obj
+
+
+def build_scene(no_even=False):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    even = build_shell("EvenShell", even=not no_even)
+    plain = build_shell("PlainShell", even=False)
+    return even, plain
+
+
+def evaluated_local_coords(obj):
+    deps = bpy.context.evaluated_depsgraph_get()
+    ev = obj.evaluated_get(deps)
+    me = ev.to_mesh()
+    try:
+        return [v.co.copy() for v in me.vertices]
+    finally:
+        ev.to_mesh_clear()
+
+
+def fold_thickness(obj):
+    """[(profile index, bend angle phi, measured thickness, copy displacement)]
+    for every profile vertex of every row; free-edge verts report phi = 0."""
+    pts = profile_points()
+    normals = segment_normals(pts)
+    n = len(obj.data.vertices)
+    got = evaluated_local_coords(obj)
+    rows = []
+    for i in range(n):
+        p = obj.data.vertices[i].co
+        q = got[n + i]
+        k = i % len(pts)
+        adj = [normals[s] for s in (k - 1, k) if 0 <= s < len(normals)]
+        phi = adj[0].angle(adj[1]) if len(adj) == 2 else 0.0
+        thick = min(abs((q - p).dot(nrm)) for nrm in adj)
+        rows.append((k, phi, thick, (q - p).length))
+    return got, rows
+
+
+def check(even_obj, plain_obj):
+    bpy.context.view_layer.update()
+    t = THICKNESS
+
+    # 3: topology — original surface kept, copy i at N + i
+    for obj in (even_obj, plain_obj):
+        n = len(obj.data.vertices)
+        got = evaluated_local_coords(obj)
+        if len(got) != 2 * n:
+            print(f"ERROR: {obj.name}: {len(got)} evaluated verts, expected 2N = {2 * n}",
+                  file=sys.stderr)
+            return 3
+        drift = max((got[i] - obj.data.vertices[i].co).length for i in range(n))
+        if drift > TOL:
+            print(f"ERROR: {obj.name}: original surface moved by {drift:.3e} (offset -1 keeps it)",
+                  file=sys.stderr)
+            return 3
+
+    # 4: even shell — thickness t everywhere, copy at t / cos(phi/2) along the bisector
+    _, rows = fold_thickness(even_obj)
+    worst = max(rows, key=lambda r: abs(r[2] - t))
+    if abs(worst[2] - t) > TOL:
+        print(f"ERROR: even shell is {worst[2]:.4f} thick at the {math.degrees(worst[1]):.0f}-degree "
+              f"fold, expected {t:.4f} (use_even_offset={even_obj.modifiers[0].use_even_offset})",
+              file=sys.stderr)
+        return 4
+    dworst = max(abs(d - t / math.cos(phi / 2)) for _, phi, _, d in rows)
+    if dworst > TOL:
+        print(f"ERROR: even-shell copy displacement off t / cos(phi/2) by {dworst:.3e}",
+              file=sys.stderr)
+        return 4
+
+    # 5: plain shell — t * cos(phi/2) at every fold, t on the free edges
+    _, prow = fold_thickness(plain_obj)
+    perr = max(abs(th - t * math.cos(phi / 2)) for _, phi, th, _ in prow)
+    if perr > TOL:
+        print(f"ERROR: plain shell off the t*cos(phi/2) closed form by {perr:.3e}", file=sys.stderr)
+        return 5
+
+    # 6: the thinning the witness rests on is real
+    thinnest = min(th for _, _, th, _ in prow)
+    if t - thinnest < MIN_THINNING:
+        print(f"ERROR: plain shell thins by only {t - thinnest:.4f} < {MIN_THINNING}",
+              file=sys.stderr)
+        return 6
+
+    folds = sorted({(round(math.degrees(phi)), th) for _, phi, th, _ in prow if phi > 0})
+    plain = ", ".join(f"{deg} deg {th:.4f}" for deg, th in folds)
+    print(f"solidify t={t}: even shell {t:.4f} at every fold (max err {abs(worst[2] - t):.1e}); "
+          f"plain shell {plain} = t*cos(phi/2) (max err {perr:.1e})")
+    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 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 render_still(even_obj, plain_obj, path, engine):
+    scene = bpy.context.scene
+
+    # Sheet faces in a pale enamel, the cut section (Solidify's rim) in
+    # selection orange via material_offset_rim, so the end of each shell
+    # reads as a band whose width is the thickness.
+    sheet = principled("SheetEnamel", (0.50, 0.56, 0.62), 0.38, metal=0.15, coat=0.3)
+    section = principled("CutSection", (1.0, 0.42, 0.04), 0.35,
+                         emit=((1.0, 0.45, 0.06), 0.6))
+    for obj in (even_obj, plain_obj):
+        obj.data.materials.append(sheet)
+        obj.data.materials.append(section)
+        mod = obj.modifiers[0]
+        mod.use_rim = True
+        mod.material_offset_rim = 1
+
+    # Stand each shell on the plinth: lowest evaluated point at the plinth top
+    plinth_top = 0.24
+    plain_obj.location = (-SHELL_GAP, 0.0, 0.0)
+    bpy.context.view_layer.update()
+    for obj in (even_obj, plain_obj):
+        lo = min((obj.matrix_world @ c).z for c in evaluated_local_coords(obj))
+        obj.location.z += plinth_top - lo
+    bpy.context.view_layer.update()
+
+    walnut = principled("Walnut", (0.13, 0.055, 0.025), 0.45, noise=40.0, coat=0.4)
+    brass = principled("Brass", (0.80, 0.58, 0.26), 0.3, metal=1.0)
+    ink = principled("Engraving", (0.012, 0.010, 0.008), 0.7)
+    parts = []
+    pts = profile_points()
+
+    # Brass display dowels under each free end and raised valley, so each
+    # shell stands on the plinth instead of floating off its lowest fold.
+    for obj in (even_obj, plain_obj):
+        n = len(obj.data.vertices)
+        got = evaluated_local_coords(obj)
+        mw = obj.matrix_world
+        for k in range(len(pts)):
+            lows = []
+            for i in range(k, n, len(pts)):
+                a, b = mw @ obj.data.vertices[i].co, mw @ got[n + i]
+                lows.append(a if a.z < b.z else b)
+            under = min(lows, key=lambda v: v.z)
+            prev = pts[k - 1].z if k > 0 else 1e9
+            nxt = pts[k + 1].z if k + 1 < len(pts) else 1e9
+            if not (pts[k].z <= prev and pts[k].z <= nxt) or under.z - plinth_top < 0.03:
+                continue
+            for y in (-DEPTH * 0.3, DEPTH * 0.3):
+                rod = bpy.data.meshes.new("Dowel")
+                bm = bmesh.new()
+                try:
+                    h = under.z - plinth_top
+                    res = bmesh.ops.create_cone(bm, cap_ends=True, segments=16,
+                                                radius1=0.022, radius2=0.022, depth=h)
+                    for vert in res["verts"]:
+                        vert.co += Vector((under.x, y, plinth_top + h / 2))
+                    bm.to_mesh(rod)
+                finally:
+                    bm.free()
+                for poly in rod.polygons:
+                    poly.use_smooth = True
+                rod.materials.append(brass)
+                ob = bpy.data.objects.new("Dowel", rod)
+                scene.collection.objects.link(ob)
+                parts.append(ob)
+    span = max(p.x for p in pts) - min(p.x for p in pts)
+    plinth = bpy.data.objects.new("Plinth", box_mesh(
+        "Plinth", (-SHELL_GAP - 0.35, -DEPTH / 2 - 0.35, 0.0), (span + 0.35, DEPTH / 2 + 0.35, plinth_top)))
+    plinth.data.materials.append(walnut)
+    scene.collection.objects.link(plinth)
+    bev = plinth.modifiers.new("Chamfer", 'BEVEL')
+    bev.width = 0.03
+    bev.segments = 2
+    parts.append(plinth)
+
+    # Brass plaques on the plinth's front edge naming each shell's setting
+    front = -DEPTH / 2 - 0.35
+    for obj, label in ((plain_obj, "use_even_offset = False"), (even_obj, "use_even_offset = True")):
+        cx = obj.location.x + span / 2
+        plate = bpy.data.objects.new("Plaque", box_mesh(
+            "Plaque", (cx - 0.95, front - 0.012, 0.03), (cx + 0.95, front + 0.01, plinth_top - 0.03)))
+        plate.data.materials.append(brass)
+        scene.collection.objects.link(plate)
+        parts.append(plate)
+        cu = bpy.data.curves.new("PlaqueText", 'FONT')
+        cu.body = label
+        cu.size = 0.13
+        cu.extrude = 0.004
+        cu.align_x = 'CENTER'
+        cu.align_y = 'CENTER'
+        cu.materials.append(ink)
+        txt = bpy.data.objects.new("PlaqueText", cu)
+        txt.location = (cx, front - 0.014, plinth_top / 2)
+        txt.rotation_euler = (math.radians(90), 0.0, 0.0)
+        scene.collection.objects.link(txt)
+
+    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(((span - SHELL_GAP) / 2, 0.0, 0.55))
+
+    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), 400.0, 4.0, (1.0, 0.96, 0.9), centre)
+    light("Fill", (5.5, -4.5, 2.5), 80.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 - 1.3, -8.5, 1.75)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = centre + Vector((0.0, 0.0, -0.2))
+    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 orange section 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
+    hero = [even_obj, plain_obj] + parts
+    fcode = gallery_framing.check_framing(scene, cam, hero=hero, elements=hero, 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 7
+    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("--no-even", action="store_true",
+                   help="leave use_even_offset off on the shell checked as even (must fail)")
+    args = p.parse_args(argv)
+
+    even_obj, plain_obj = build_scene(no_even=args.no_even)
+    code = check(even_obj, plain_obj)
+    if code:
+        return code
+
+    if args.output:
+        rcode = render_still(even_obj, plain_obj, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+
+    print("solidify-even-thickness 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 folded zigzag shells on a walnut plinth with orange cut ends: the left band pinching thin at each fold, the right one even width, above brass plaques reading use_even_offset False and True. + + +

+ + + diff --git a/docs/gallery/swatch-grid/index.html b/docs/gallery/swatch-grid/index.html index e66fec56..df1119ec 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 ce0c1849..f00b8837 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 6ec9ad32..35e12bb6 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 99dac989..f4e731c5 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 8984040a..fc72a88d 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 ff5318ad..d6542a77 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 2266ee7f..c58391c6 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 b2cc1d3c..e9f09939 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 36e64d9f..3aa09a1e 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 ee720ee1..594745c9 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 dc936ed7..5baa2074 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 c69c3aa4..f0b1a16b 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 b048786f..ef061360 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 62 examples currently qualify, and `CLAUDE.md` +renders that are hard. Eight of the 64 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/boolean-exact-volume/README.md b/examples/boolean-exact-volume/README.md new file mode 100644 index 00000000..6b9fdfe5 --- /dev/null +++ b/examples/boolean-exact-volume/README.md @@ -0,0 +1,81 @@ +# Boolean Exact Volume + +A runnable example that cuts a 2 m cube with a 2 × 1 × 1 m slab using the **Boolean +modifier on the EXACT solver**, in all three operations (UNION, DIFFERENCE, INTERSECT), +then proves that each result has the **closed-form volume** and is a **closed +2-manifold**. The hard part is deliberate: the slab's top face lies in the cube's top +plane, the coplanar case where floating-point booleans go wrong. The results are 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 operands are built with bmesh +and freed in `try`/`finally` as +[`mesh-editing-and-bmesh`](../../skills/mesh-editing-and-bmesh/SKILL.md) requires. + +**What it witnesses:** with A = [0,2]³ and B = [1,3] × [0.5,1.5] × [1,2], the overlap is +A ∩ B = [1,2] × [0.5,1.5] × [1,2], so the closed forms are V(A ∪ B) = 8 + 2 − 1 = 9, +V(A − B) = 8 − 1 = 7 and V(A ∩ B) = 1 cubic metre. Each operand pair sits in an object +matrix that is translated and turned 18° about Z, so the modifier has to carry B into A's +space. For every result the check: + +- sums the signed volume over the evaluated mesh's own triangles (divergence theorem, + world space) and requires the closed form to 1e-6 relative (measured 8.999999, + 6.999999 and 1.000000 — float32 coordinates through a rotation). A positive volume + also means the normals point outward; +- requires a closed 2-manifold: every edge borders exactly two faces, with no loose verts + or edges; +- requires that the result's volume differs from V(A) by at least 0.5 m³, so the + modifier really acted and the witness cannot pass vacuously. + +**The trap it exposes:** the floating-point solver is faster and is right in general +position, but it mishandles coplanar faces. `--float-solver` runs the same three +operations on it and gets a union of 7.708 m³ against the closed form 9, so check 3 +fails with the measured error. That solver was `'FAST'` through 4.5 LTS and is `'FLOAT'` +from 5.0, so a script that hard-codes either name breaks on the other series +(`fast_solver_id()` branches on `bpy.app.version`). `'EXACT'` is the same identifier on +4.5, 5.1 and 5.2. + +The still shows the three operations side by side on a walnut plinth, each with its +two operands drawn as thin outlines (the cube in steel, the coplanar slab in glowing +orange): on the left the union in teal glaze with the slab's free end grown out of the +cube; in the middle the difference in brass with the slab's notch cut down through the +top face; on the right the intersection, the 1 m³ overlap cube, in orange. The outlines +are render-only rods built after the check has run, and the cutter objects themselves +never render. + +## Run + +```bash +# Cheap correctness check (no render) — the CI check: +blender --background --python boolean_exact_volume.py -- + +# Falsifier: the floating-point solver (FLOAT / FAST) on the coplanar case. Must exit 3. +blender --background --python boolean_exact_volume.py -- --float-solver + +# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): +blender --background --python boolean_exact_volume.py -- --output boolean.png --engine cycles +``` + +## Version notes + +`BooleanModifier.solver` lists `'FAST'`, `'EXACT'`, `'MANIFOLD'` on 4.5 LTS and +`'FLOAT'`, `'EXACT'`, `'MANIFOLD'` on 5.1 and 5.2 LTS: the floating-point solver was +renamed in 5.0. Measured identically on 4.5.11, 5.1.2 and 5.2.1: EXACT union 8.999999 +(12 faces), difference 6.999999 (10 faces), intersection 1.000000 (6 faces), all closed +2-manifolds; `--float-solver` union 7.708379. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | A result's volume is off its closed form (`--float-solver` lands here) | +| 4 | A result is not a closed 2-manifold (an edge without exactly two faces, or loose geometry) | +| 5 | A result's volume is within 0.5 m³ of operand A's (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 `--float-solver`. diff --git a/examples/boolean-exact-volume/boolean_exact_volume.py b/examples/boolean-exact-volume/boolean_exact_volume.py new file mode 100644 index 00000000..05fa26a6 --- /dev/null +++ b/examples/boolean-exact-volume/boolean_exact_volume.py @@ -0,0 +1,412 @@ +"""EXACT Boolean volumes are closed-form and manifold, even on coplanar faces — a runnable example. + +Witnesses the Boolean modifier contract with ``solver = 'EXACT'``. Operand A +is a 2 m cube and operand B a 2 x 1 x 1 m slab that overlaps A by a 1 m cube +and whose top face lies in A's top plane: the coplanar case. Both operands +sit in object matrices that are translated and turned about Z, so the +modifier has to bring B into A's space. For each of UNION, DIFFERENCE and +INTERSECT the evaluated mesh (``evaluated_get`` + ``to_mesh`` / +``to_mesh_clear``) is measured in world space: + +- 3: its signed volume, by the divergence theorem over its own triangles, + matches the closed form (V(A) + V(B) - V(A n B) = 9, V(A) - V(A n B) = 7, + V(A n B) = 1 cubic metres) to 1e-6 relative; positive volume also means + the normals point outward; +- 4: it is a closed 2-manifold: every edge borders exactly two faces, and + there are no loose verts or edges; +- 5: it is not vacuous: each result's volume differs from V(A) by at least + 0.5 m^3, so the modifier really acted. + +``--float-solver`` runs the same three operations on the floating-point +solver (``'FLOAT'`` on Blender 5.x, ``'FAST'`` on 4.5 LTS, the same solver +under its older name). On the coplanar top face it returns a wrong union +(7.708 m^3 against 9, measured identically on 4.5.11, 5.1.2 and 5.2.1), so +check 3 fails with the measured error. That is the falsifier, and the documented failure case +the EXACT solver exists to handle. + +By default it runs only the correctness check (no render) — the CI smoke +check. Pass --output to also render a still: + + blender --background --python boolean_exact_volume.py -- # check only + blender --background --python boolean_exact_volume.py -- --float-solver # must fail + blender --background --python boolean_exact_volume.py -- --output b.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 + +# Operands in their shared local frame (metres). B's top face (z = 2) is +# coplanar with A's top face; the overlap A n B is [1,2] x [0.5,1.5] x [1,2]. +A_LO, A_HI = (0.0, 0.0, 0.0), (2.0, 2.0, 2.0) +B_LO, B_HI = (1.0, 0.5, 1.0), (3.0, 1.5, 2.0) +OPS = ('UNION', 'DIFFERENCE', 'INTERSECT') +SPACING = 4.6 # world X between the three operation sets +TURN = math.radians(-18.0) # every set's frame turns about Z + + +def box_volume(lo, hi): + return (hi[0] - lo[0]) * (hi[1] - lo[1]) * (hi[2] - lo[2]) + + +def overlap(lo1, hi1, lo2, hi2): + lo = tuple(max(lo1[k], lo2[k]) for k in range(3)) + hi = tuple(min(hi1[k], hi2[k]) for k in range(3)) + return lo, hi + + +V_A = box_volume(A_LO, A_HI) +V_B = box_volume(B_LO, B_HI) +V_AB = box_volume(*overlap(A_LO, A_HI, B_LO, B_HI)) +EXPECTED = {'UNION': V_A + V_B - V_AB, 'DIFFERENCE': V_A - V_AB, 'INTERSECT': V_AB} +REL_TOL = 1e-6 +MIN_CHANGE = 0.5 # m^3; check 5's floor against V(A) + + +def fast_solver_id(): + """The floating-point solver: 'FAST' through 4.5 LTS, renamed 'FLOAT' in 5.0.""" + return 'FLOAT' if bpy.app.version >= (5, 0, 0) else 'FAST' + + +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 frame(i): + """World matrix of operation set i: translated along X, turned about Z, + with the operands' local frame centred on A's footprint.""" + centre = Matrix.Translation((-(A_HI[0] - A_LO[0]) / 2 - 0.5, -(A_HI[1] - A_LO[1]) / 2, 0.0)) + return (Matrix.Translation(((i - 1) * SPACING, 0.0, 0.0)) + @ Matrix.Rotation(TURN, 4, 'Z') @ centre) + + +def build_scene(float_solver=False): + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + solver = fast_solver_id() if float_solver else 'EXACT' + results = {} + cutters = {} + for i, op in enumerate(OPS): + a = bpy.data.objects.new(f"{op.title()}Result", box_mesh(f"{op.title()}Result", A_LO, A_HI)) + b = bpy.data.objects.new(f"{op.title()}Cutter", box_mesh(f"{op.title()}Cutter", B_LO, B_HI)) + a.matrix_world = frame(i) + b.matrix_world = frame(i) + b.hide_render = True + b.display_type = 'WIRE' + scene.collection.objects.link(a) + scene.collection.objects.link(b) + mod = a.modifiers.new("Boolean", 'BOOLEAN') + mod.operation = op + mod.solver = solver + mod.object = b + results[op] = a + cutters[op] = b + return results, cutters, solver + + +def measure(obj): + """World-space signed volume (divergence theorem over fan triangles of + each polygon) plus manifold counts, from the evaluated mesh.""" + deps = bpy.context.evaluated_depsgraph_get() + ev = obj.evaluated_get(deps) + me = ev.to_mesh() + try: + mw = ev.matrix_world + co = [mw @ v.co for v in me.vertices] + vol = 0.0 + for p in me.polygons: + idx = list(p.vertices) + v0 = co[idx[0]] + for j in range(1, len(idx) - 1): + vol += v0.dot(co[idx[j]].cross(co[idx[j + 1]])) / 6.0 + bm = bmesh.new() + try: + bm.from_mesh(me) + bad_edges = sum(1 for e in bm.edges if len(e.link_faces) != 2) + loose_verts = sum(1 for v in bm.verts if not v.link_faces) + loose_edges = sum(1 for e in bm.edges if not e.link_faces) + finally: + bm.free() + return vol, bad_edges, loose_verts, loose_edges, len(me.polygons) + finally: + ev.to_mesh_clear() + + +def check(results, solver): + bpy.context.view_layer.update() + m = {op: measure(results[op]) for op in OPS} + + # 3: volumes on the closed forms (positive => outward normals) + for op in OPS: + vol, exp = m[op][0], EXPECTED[op] + if abs(vol - exp) > REL_TOL * exp: + print(f"ERROR: {op} on solver {solver!r}: volume {vol:.6f} m^3, closed form " + f"{exp:.6f} (error {vol - exp:+.6f})", file=sys.stderr) + return 3 + + # 4: closed 2-manifold, no loose geometry + for op in OPS: + _, bad, lv, le, _ = m[op] + if bad or lv or le: + print(f"ERROR: {op} on solver {solver!r} is not a closed manifold: {bad} edges " + f"without exactly two faces, {lv} loose verts, {le} loose edges", + file=sys.stderr) + return 4 + + # 5: every result really differs from operand A + for op in OPS: + if abs(m[op][0] - V_A) < MIN_CHANGE: + print(f"ERROR: {op} volume {m[op][0]:.4f} within {MIN_CHANGE} of V(A) = {V_A}", + file=sys.stderr) + return 5 + + parts = ", ".join(f"{op} {m[op][0]:.6f} (closed form {EXPECTED[op]:g}, {m[op][4]} faces)" + for op in OPS) + print(f"solver {solver!r}, coplanar top face: {parts}; all closed 2-manifold, " + f"0 loose verts/edges") + 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 tube_mesh(name, segments, radius, sides=10): + """Render-only rods along line segments (the operand outlines).""" + 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 box_edges(lo, hi, mw): + c = {(i, j, k): mw @ Vector((hi[0] if i else lo[0], hi[1] if j else lo[1], hi[2] if k else lo[2])) + for i in (0, 1) for j in (0, 1) for k in (0, 1)} + edges = [] + for i in (0, 1): + for j in (0, 1): + edges.append((c[(i, j, 0)], c[(i, j, 1)])) + edges.append((c[(i, 0, j)], c[(i, 1, j)])) + edges.append((c[(0, i, j)], c[(1, i, j)])) + return edges + + +def render_still(results, cutters, path, engine): + scene = bpy.context.scene + glazes = { + 'UNION': principled("UnionGlaze", (0.07, 0.30, 0.34), 0.28, coat=0.5), + 'DIFFERENCE': principled("DifferenceBrass", (0.86, 0.56, 0.26), 0.30, metal=1.0, noise=60.0), + 'INTERSECT': principled("IntersectOrange", (0.95, 0.36, 0.05), 0.32, coat=0.4), + } + steel = principled("OperandA", (0.62, 0.64, 0.68), 0.25, metal=1.0) + cutter_mat = principled("OperandB", (1.0, 0.45, 0.06), 0.35, + emit=((1.0, 0.45, 0.06), 1.2)) + 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 + + for i, op in enumerate(OPS): + res = results[op] + res.data.materials.append(glazes[op]) + mw = frame(i) + # thin outlines of both operands: A in steel, the coplanar cutter B in orange + add(f"{op.title()}OutlineA", tube_mesh(f"{op.title()}OutlineA", + box_edges(A_LO, A_HI, mw), 0.016), steel) + add(f"{op.title()}OutlineB", tube_mesh(f"{op.title()}OutlineB", + box_edges(B_LO, B_HI, mw), 0.020), cutter_mat) + + half_x = SPACING + 2.2 + plinth = add("Plinth", bpy.data.meshes.new("Plinth"), walnut) + bm = bmesh.new() + try: + res = bmesh.ops.create_cube(bm, size=1.0) + lo, hi = (-half_x, -2.3, -0.16), (half_x, 2.3, -0.004) + 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(plinth.data) + finally: + bm.free() + 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) + floor.location = (0.0, 0.0, -0.16) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 8.0, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0) + scene.world = world + + centre = Vector((0.0, 0.0, 1.0)) + + 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", (-5.0, -6.0, 7.5), 700.0, 5.0, (1.0, 0.96, 0.9), centre) + light("Fill", (7.0, -5.5, 3.0), 140.0, 8.0, (0.75, 0.85, 1.0), centre) + light("Rim", (2.0, 5.0, 7.0), 420.0, 4.0, (0.6, 0.78, 1.0), centre) + light("Wedge", (3.0, 5.0, 3.0), 480.0, 6.0, (1.0, 0.76, 0.5), (5.0, 8.0, 1.0)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (3.2, -20.9, 11.0) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = centre + Vector((0.0, 0.0, -0.1)) + 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 glazes and the orange cutter 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 + shown = list(results.values()) + parts + fcode = gallery_framing.check_framing( + scene, cam, + hero=shown, + elements=shown, + 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("--float-solver", action="store_true", + help="use the floating-point solver (FLOAT / FAST) on the coplanar case (must fail)") + args = p.parse_args(argv) + + results, cutters, solver = build_scene(float_solver=args.float_solver) + code = check(results, solver) + if code: + return code + + if args.output: + rcode = render_still(results, cutters, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + + print("boolean-exact-volume 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/boolean-exact-volume/preview.webp b/examples/boolean-exact-volume/preview.webp new file mode 100644 index 00000000..7f05573e Binary files /dev/null and b/examples/boolean-exact-volume/preview.webp differ diff --git a/examples/gallery.json b/examples/gallery.json index a482e23e..9b1a5ed7 100644 --- a/examples/gallery.json +++ b/examples/gallery.json @@ -729,6 +729,34 @@ "modifiers", "depsgraph" ] + }, + { + "name": "solidify-even-thickness", + "dir": "examples/solidify-even-thickness", + "teaches": "Solidify's use_even_offset on a folded strip: off, each fold vertex moves t along the bisector of its face normals and the shell thins to t*cos(phi/2); on, it moves t/cos(phi/2) and stays exactly t thick at 60, 90 and 120 degree folds.", + "alt": "Two folded zigzag shells on a walnut plinth with orange cut ends: the left band pinching thin at each fold, the right one even width, above brass plaques reading use_even_offset False and True.", + "witnessesFix": "Simple-mode Solidify (offset -1, t = 0.16) on a strip folded at 60, 90 and 120 degrees: the even shell is exactly t thick at every fold, its copies t/cos(phi/2) along the bisector; the plain shell is t*cos(phi/2) thick: 0.1386, 0.1131, 0.0800 (max error 3.7e-8). --no-even exits 4 at 0.0800 vs 0.1600.", + "hero": "docs/gallery/assets/solidify-even-thickness-hero.webp", + "preview": "examples/solidify-even-thickness/preview.webp", + "tags": [ + "mesh", + "modifiers", + "depsgraph" + ] + }, + { + "name": "boolean-exact-volume", + "dir": "examples/boolean-exact-volume", + "teaches": "The Boolean modifier on the EXACT solver: union, difference and intersection of a cube and a slab whose top face is coplanar with the cube's, read back through the depsgraph and measured by the divergence theorem.", + "alt": "Three results on a walnut plinth, each inside steel and orange operand outlines: a teal union with a slab grown out of a cube, a brass cube notched through its top, and an orange overlap cube.", + "witnessesFix": "EXACT booleans are closed-form even on coplanar faces: through translated, 18°-turned operands the union, difference and intersection measure 9, 7 and 1 m³ (8.999999 / 6.999999 / 1.000000, float32) and are closed 2-manifolds with no loose geometry; the floating-point solver ('FAST' on 4.5, 'FLOAT' on 5.x) gives a 7.708 m³ union and --float-solver exits 3.", + "hero": "docs/gallery/assets/boolean-exact-volume-hero.webp", + "preview": "examples/boolean-exact-volume/preview.webp", + "tags": [ + "mesh", + "modifiers", + "depsgraph" + ] } ] } diff --git a/examples/solidify-even-thickness/README.md b/examples/solidify-even-thickness/README.md new file mode 100644 index 00000000..c8102f35 --- /dev/null +++ b/examples/solidify-even-thickness/README.md @@ -0,0 +1,81 @@ +# Solidify Even Thickness + +A runnable example that solidifies an open, folded strip with the **Solidify** modifier and +proves, fold by fold, what `use_even_offset` does to the shell's thickness. The strip is a +zigzag profile extruded along Y, folded at bend angles of **60°, 90° and 120°**. The +evaluated shells are 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 strip is built with bmesh in a +`try`/`finally`, as [`mesh-editing-and-bmesh`](../../skills/mesh-editing-and-bmesh/SKILL.md) +requires. + +**What it witnesses:** in the default Simple mode (`solidify_mode = 'EXTRUDE'`) with +`offset = -1`, the original surface stays put and every vertex gets a copy pushed +`thickness` along its vertex normal. At a fold the vertex normal is the bisector of the +two face normals, which meet at the bend angle φ. So: + +- **without** `use_even_offset` the copy moves *t* along the bisector, and the shell's + perpendicular thickness at the fold is **t · cos(φ/2)**, which is t · sin(θ/2) for the + interior angle θ = 180° − φ. With t = 0.16 that is 0.1386 at 60°, 0.1131 at 90° and + 0.0800 at 120°: half the thickness you asked for; +- **with** `use_even_offset` the copy moves **t / cos(φ/2)** along the bisector, and the + thickness is exactly *t* at every fold. + +The check builds both closed forms from the profile itself, taking the face planes from +the segment directions and never from Blender's normals. It measures each shell's +perpendicular thickness at every fold and free edge and requires agreement to 1e-5 +(measured: 3.7e-8). It also asserts the topology the measurement relies on: 2N evaluated +vertices, the first N being the untouched original surface, and copy *i* at vertex N + *i*. +Finally it checks that the thinning is real, so the witness cannot pass vacuously. + +**The trap it exposes:** `use_even_offset` is off by default. A script that solidifies a +bent panel, a folded bracket or a box with `thickness = t` gets a wall that is noticeably +thinner at every corner, down to half at a 120° bend, and nothing errors. `--no-even` +leaves the flag off on the shell that the check expects to be even, and check 4 fails +with the measured thickness. + +The still shows both shells side by side on a walnut plinth, viewed end-on. The cut +section, Solidify's rim, is drawn in selection orange through `material_offset_rim`, so +the band's width is the shell's thickness. On the left, with `use_even_offset = False`, +the band pinches at every fold, and to half at the sharp V. On the right, with +`use_even_offset = True`, it stays one width all the way round. Brass plaques name each +setting, and brass dowels hold the raised folds off the plinth. + +## Run + +```bash +# Cheap correctness check (no render) — the CI check: +blender --background --python solidify_even_thickness.py -- + +# Falsifier: leave use_even_offset off on the shell checked as even. Must exit 4. +blender --background --python solidify_even_thickness.py -- --no-even + +# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): +blender --background --python solidify_even_thickness.py -- --output solidify.png --engine cycles +``` + +## Version notes + +The Solidify modifier properties used here (`solidify_mode`, `thickness`, `offset`, +`use_even_offset`, `use_rim`, `material_offset_rim`) and the output vertex order +(originals first, then copies) are the same on 4.5 LTS, 5.1 and 5.2 LTS. The defaults +are also the same: `'EXTRUDE'`, offset −1, even off, rim on, quality normals off. All +values were measured identically on 4.5.11, 5.1.2 and 5.2.1. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Evaluated topology is not the original surface plus one copy per vertex (2N verts, originals untouched) | +| 4 | The even shell is not *t* thick at every fold, or its copies are off t / cos(φ/2) (`--no-even` lands here) | +| 5 | The plain shell is off the t · cos(φ/2) closed form at a fold | +| 6 | The plain shell thins by less than the floor (the witness would pass vacuously) | +| 7 | `--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 `--no-even`. diff --git a/examples/solidify-even-thickness/preview.webp b/examples/solidify-even-thickness/preview.webp new file mode 100644 index 00000000..231cd430 Binary files /dev/null and b/examples/solidify-even-thickness/preview.webp differ diff --git a/examples/solidify-even-thickness/solidify_even_thickness.py b/examples/solidify-even-thickness/solidify_even_thickness.py new file mode 100644 index 00000000..c21fe7bb --- /dev/null +++ b/examples/solidify-even-thickness/solidify_even_thickness.py @@ -0,0 +1,440 @@ +"""Solidify Even Thickness keeps a folded shell's thickness constant — a runnable example. + +Witnesses the Solidify modifier's ``use_even_offset`` contract on an open, +folded strip (a zigzag profile extruded along Y, folded at bend angles of +60, 90 and 120 degrees). In the default Simple (``'EXTRUDE'``) mode with +``offset = -1`` the original surface stays put and each vertex gets a copy +pushed ``thickness`` along its vertex normal. At a fold the vertex normal is +the bisector of the two face normals, which meet at the bend angle phi, so: + +- without even offset the copy moves t along the bisector and the shell's + perpendicular thickness at the fold is t * cos(phi / 2) — 0.866 t, 0.707 t + and 0.5 t at the three folds; +- with ``use_even_offset`` the copy moves t / cos(phi / 2) along the bisector + and the thickness is exactly t at every fold. + +The check computes both closed forms from the profile itself (face planes +from the segment directions, never from Blender's normals), reads the +evaluated shells through the depsgraph (``evaluated_get`` + ``to_mesh`` / +``to_mesh_clear``) and requires agreement to 1e-5. Four checks, in run order: + +- 3: topology — 2N evaluated verts, the first N are the untouched original + surface (offset -1), copy i is vert N + i; +- 4: the even shell is exactly t thick at every fold and free edge; +- 5: the plain shell is t * cos(phi / 2) thick at every fold; +- 6: the thinning is real (the witness cannot pass vacuously). + +``--no-even`` leaves ``use_even_offset`` off on the shell the check expects +to be even, so check 4 fails with the measured thickness. That is the +falsifier — and the trap: a script that solidifies a bent panel by +``thickness`` gets half that thickness at a 120-degree fold. + +By default it runs only the correctness check (no render) — the CI smoke +check. Pass --output to also render a still: + + blender --background --python solidify_even_thickness.py -- # check only + blender --background --python solidify_even_thickness.py -- --no-even # must fail + blender --background --python solidify_even_thickness.py -- --output s.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 + +THICKNESS = 0.16 +# Profile segment headings in the XZ plane (degrees from +X): the bends +# between consecutive segments are 60, 90 and 120 degrees. +HEADINGS = (-30.0, 30.0, -60.0, 60.0) +SEG_LEN = 0.7 +DEPTH = 1.1 # extrusion along Y +Y_CUTS = 3 # rows of quads along Y +TOL = 1e-5 +MIN_THINNING = 0.02 # check 6's floor on t - min(plain fold thickness) +SHELL_GAP = 2.45 # render only: the plain shell's offset to the left +Y_AXIS = Vector((0.0, 1.0, 0.0)) + + +def profile_points(): + pts = [Vector((0.0, 0.0, 0.0))] + for h in HEADINGS: + a = math.radians(h) + pts.append(pts[-1] + SEG_LEN * Vector((math.cos(a), 0.0, math.sin(a)))) + return pts + + +def segment_normals(pts): + """Unit face normals of each strip segment, from the profile alone.""" + return [(pts[k + 1] - pts[k]).cross(Y_AXIS).normalized() for k in range(len(pts) - 1)] + + +def build_shell(name, even): + pts = profile_points() + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + rows = [] + for j in range(Y_CUTS + 1): + y = -DEPTH / 2 + DEPTH * j / Y_CUTS + rows.append([bm.verts.new((p.x, y, p.z)) for p in pts]) + for j in range(Y_CUTS): + for i in range(len(pts) - 1): + bm.faces.new((rows[j][i], rows[j][i + 1], rows[j + 1][i + 1], rows[j + 1][i])) + bm.to_mesh(me) + finally: + bm.free() + obj = bpy.data.objects.new(name, me) + bpy.context.scene.collection.objects.link(obj) + mod = obj.modifiers.new("Solidify", 'SOLIDIFY') + mod.solidify_mode = 'EXTRUDE' + mod.thickness = THICKNESS + mod.offset = -1.0 + mod.use_even_offset = even + return obj + + +def build_scene(no_even=False): + bpy.ops.wm.read_factory_settings(use_empty=True) + even = build_shell("EvenShell", even=not no_even) + plain = build_shell("PlainShell", even=False) + return even, plain + + +def evaluated_local_coords(obj): + deps = bpy.context.evaluated_depsgraph_get() + ev = obj.evaluated_get(deps) + me = ev.to_mesh() + try: + return [v.co.copy() for v in me.vertices] + finally: + ev.to_mesh_clear() + + +def fold_thickness(obj): + """[(profile index, bend angle phi, measured thickness, copy displacement)] + for every profile vertex of every row; free-edge verts report phi = 0.""" + pts = profile_points() + normals = segment_normals(pts) + n = len(obj.data.vertices) + got = evaluated_local_coords(obj) + rows = [] + for i in range(n): + p = obj.data.vertices[i].co + q = got[n + i] + k = i % len(pts) + adj = [normals[s] for s in (k - 1, k) if 0 <= s < len(normals)] + phi = adj[0].angle(adj[1]) if len(adj) == 2 else 0.0 + thick = min(abs((q - p).dot(nrm)) for nrm in adj) + rows.append((k, phi, thick, (q - p).length)) + return got, rows + + +def check(even_obj, plain_obj): + bpy.context.view_layer.update() + t = THICKNESS + + # 3: topology — original surface kept, copy i at N + i + for obj in (even_obj, plain_obj): + n = len(obj.data.vertices) + got = evaluated_local_coords(obj) + if len(got) != 2 * n: + print(f"ERROR: {obj.name}: {len(got)} evaluated verts, expected 2N = {2 * n}", + file=sys.stderr) + return 3 + drift = max((got[i] - obj.data.vertices[i].co).length for i in range(n)) + if drift > TOL: + print(f"ERROR: {obj.name}: original surface moved by {drift:.3e} (offset -1 keeps it)", + file=sys.stderr) + return 3 + + # 4: even shell — thickness t everywhere, copy at t / cos(phi/2) along the bisector + _, rows = fold_thickness(even_obj) + worst = max(rows, key=lambda r: abs(r[2] - t)) + if abs(worst[2] - t) > TOL: + print(f"ERROR: even shell is {worst[2]:.4f} thick at the {math.degrees(worst[1]):.0f}-degree " + f"fold, expected {t:.4f} (use_even_offset={even_obj.modifiers[0].use_even_offset})", + file=sys.stderr) + return 4 + dworst = max(abs(d - t / math.cos(phi / 2)) for _, phi, _, d in rows) + if dworst > TOL: + print(f"ERROR: even-shell copy displacement off t / cos(phi/2) by {dworst:.3e}", + file=sys.stderr) + return 4 + + # 5: plain shell — t * cos(phi/2) at every fold, t on the free edges + _, prow = fold_thickness(plain_obj) + perr = max(abs(th - t * math.cos(phi / 2)) for _, phi, th, _ in prow) + if perr > TOL: + print(f"ERROR: plain shell off the t*cos(phi/2) closed form by {perr:.3e}", file=sys.stderr) + return 5 + + # 6: the thinning the witness rests on is real + thinnest = min(th for _, _, th, _ in prow) + if t - thinnest < MIN_THINNING: + print(f"ERROR: plain shell thins by only {t - thinnest:.4f} < {MIN_THINNING}", + file=sys.stderr) + return 6 + + folds = sorted({(round(math.degrees(phi)), th) for _, phi, th, _ in prow if phi > 0}) + plain = ", ".join(f"{deg} deg {th:.4f}" for deg, th in folds) + print(f"solidify t={t}: even shell {t:.4f} at every fold (max err {abs(worst[2] - t):.1e}); " + f"plain shell {plain} = t*cos(phi/2) (max err {perr:.1e})") + 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 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 render_still(even_obj, plain_obj, path, engine): + scene = bpy.context.scene + + # Sheet faces in a pale enamel, the cut section (Solidify's rim) in + # selection orange via material_offset_rim, so the end of each shell + # reads as a band whose width is the thickness. + sheet = principled("SheetEnamel", (0.50, 0.56, 0.62), 0.38, metal=0.15, coat=0.3) + section = principled("CutSection", (1.0, 0.42, 0.04), 0.35, + emit=((1.0, 0.45, 0.06), 0.6)) + for obj in (even_obj, plain_obj): + obj.data.materials.append(sheet) + obj.data.materials.append(section) + mod = obj.modifiers[0] + mod.use_rim = True + mod.material_offset_rim = 1 + + # Stand each shell on the plinth: lowest evaluated point at the plinth top + plinth_top = 0.24 + plain_obj.location = (-SHELL_GAP, 0.0, 0.0) + bpy.context.view_layer.update() + for obj in (even_obj, plain_obj): + lo = min((obj.matrix_world @ c).z for c in evaluated_local_coords(obj)) + obj.location.z += plinth_top - lo + bpy.context.view_layer.update() + + walnut = principled("Walnut", (0.13, 0.055, 0.025), 0.45, noise=40.0, coat=0.4) + brass = principled("Brass", (0.80, 0.58, 0.26), 0.3, metal=1.0) + ink = principled("Engraving", (0.012, 0.010, 0.008), 0.7) + parts = [] + pts = profile_points() + + # Brass display dowels under each free end and raised valley, so each + # shell stands on the plinth instead of floating off its lowest fold. + for obj in (even_obj, plain_obj): + n = len(obj.data.vertices) + got = evaluated_local_coords(obj) + mw = obj.matrix_world + for k in range(len(pts)): + lows = [] + for i in range(k, n, len(pts)): + a, b = mw @ obj.data.vertices[i].co, mw @ got[n + i] + lows.append(a if a.z < b.z else b) + under = min(lows, key=lambda v: v.z) + prev = pts[k - 1].z if k > 0 else 1e9 + nxt = pts[k + 1].z if k + 1 < len(pts) else 1e9 + if not (pts[k].z <= prev and pts[k].z <= nxt) or under.z - plinth_top < 0.03: + continue + for y in (-DEPTH * 0.3, DEPTH * 0.3): + rod = bpy.data.meshes.new("Dowel") + bm = bmesh.new() + try: + h = under.z - plinth_top + res = bmesh.ops.create_cone(bm, cap_ends=True, segments=16, + radius1=0.022, radius2=0.022, depth=h) + for vert in res["verts"]: + vert.co += Vector((under.x, y, plinth_top + h / 2)) + bm.to_mesh(rod) + finally: + bm.free() + for poly in rod.polygons: + poly.use_smooth = True + rod.materials.append(brass) + ob = bpy.data.objects.new("Dowel", rod) + scene.collection.objects.link(ob) + parts.append(ob) + span = max(p.x for p in pts) - min(p.x for p in pts) + plinth = bpy.data.objects.new("Plinth", box_mesh( + "Plinth", (-SHELL_GAP - 0.35, -DEPTH / 2 - 0.35, 0.0), (span + 0.35, DEPTH / 2 + 0.35, plinth_top))) + plinth.data.materials.append(walnut) + scene.collection.objects.link(plinth) + bev = plinth.modifiers.new("Chamfer", 'BEVEL') + bev.width = 0.03 + bev.segments = 2 + parts.append(plinth) + + # Brass plaques on the plinth's front edge naming each shell's setting + front = -DEPTH / 2 - 0.35 + for obj, label in ((plain_obj, "use_even_offset = False"), (even_obj, "use_even_offset = True")): + cx = obj.location.x + span / 2 + plate = bpy.data.objects.new("Plaque", box_mesh( + "Plaque", (cx - 0.95, front - 0.012, 0.03), (cx + 0.95, front + 0.01, plinth_top - 0.03))) + plate.data.materials.append(brass) + scene.collection.objects.link(plate) + parts.append(plate) + cu = bpy.data.curves.new("PlaqueText", 'FONT') + cu.body = label + cu.size = 0.13 + cu.extrude = 0.004 + cu.align_x = 'CENTER' + cu.align_y = 'CENTER' + cu.materials.append(ink) + txt = bpy.data.objects.new("PlaqueText", cu) + txt.location = (cx, front - 0.014, plinth_top / 2) + txt.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(txt) + + 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(((span - SHELL_GAP) / 2, 0.0, 0.55)) + + 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), 400.0, 4.0, (1.0, 0.96, 0.9), centre) + light("Fill", (5.5, -4.5, 2.5), 80.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 - 1.3, -8.5, 1.75) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = centre + Vector((0.0, 0.0, -0.2)) + 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 orange section 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 + hero = [even_obj, plain_obj] + parts + fcode = gallery_framing.check_framing(scene, cam, hero=hero, elements=hero, 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 7 + 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("--no-even", action="store_true", + help="leave use_even_offset off on the shell checked as even (must fail)") + args = p.parse_args(argv) + + even_obj, plain_obj = build_scene(no_even=args.no_even) + code = check(even_obj, plain_obj) + if code: + return code + + if args.output: + rcode = render_still(even_obj, plain_obj, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + + print("solidify-even-thickness 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 4626e231..04837057 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -77,6 +77,8 @@ {"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": "solidify-even-thickness", "script": "examples/solidify-even-thickness/solidify_even_thickness.py"}, + {"name": "boolean-exact-volume", "script": "examples/boolean-exact-volume/boolean_exact_volume.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"},