From 4a4085c4332f294d39de4e491ba3d28beda424cb Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:11:12 -0400 Subject: [PATCH] feat(showcase): add butter-churn The butter churn was the first unclaimed standalone showcase candidate. What an envelope check cannot see on a churn is whether the dasher can plunge: the body narrows toward the top, so a plunger that clears the staves at rest can jam a hand's width up the stroke while it still sits inside the body untouched. The piece reads the inner wall off the staves and the plunger's reach off the dasher and asserts a 400 mm stroke floor; --wide-dasher exits 20 at 140 mm while every other budget and the envelope still pass. Also asserts every stave on the floor, a three-board bottom, the handle biting the slats, hoops seated vertex-for-vertex on the staves, the lid on the stave tops and the handle clearing its hole, each with a falsifier proven on 4.5.11, 5.1.2 and 5.2.1. The ox-cart tongue entry moves behind the bookshelf: it was a restock placed ahead of older candidates, and it needs the yoke as its hitch partner. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> --- .cursor-plugin/plugin.json | 3 +- CLAUDE.md | 2 +- README.md | 9 +- ROADMAP.md | 4 +- docs/gallery/asset-sheets/butter-churn.webp | Bin 0 -> 28888 bytes docs/gallery/assets/butter-churn-hero.webp | Bin 0 -> 15044 bytes docs/gallery/butter-churn/index.html | 1644 +++++++++++++++++ .../butter-churn-contact-sheet.webp | Bin 0 -> 51388 bytes docs/gallery/index.html | 15 +- showcase/butter-churn/README.md | 238 +++ showcase/butter-churn/butter_churn.py | 1272 +++++++++++++ showcase/butter-churn/preview.webp | Bin 0 -> 14128 bytes showcase/gallery.json | 13 + tests/smoke/catalog.json | 3 +- 14 files changed, 3193 insertions(+), 10 deletions(-) create mode 100644 docs/gallery/asset-sheets/butter-churn.webp create mode 100644 docs/gallery/assets/butter-churn-hero.webp create mode 100644 docs/gallery/butter-churn/index.html create mode 100644 docs/gallery/contact-sheets/butter-churn-contact-sheet.webp create mode 100644 showcase/butter-churn/README.md create mode 100644 showcase/butter-churn/butter_churn.py create mode 100644 showcase/butter-churn/preview.webp diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index aad3f755..c1992361 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -170,6 +170,7 @@ "showcase/grain-sacks", "showcase/brazier", "showcase/apothecary-shelf", - "showcase/wooden-yoke" + "showcase/wooden-yoke", + "showcase/butter-churn" ] } diff --git a/CLAUDE.md b/CLAUDE.md index eae428f2..bf3ae90e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ rules/.mdc - Anti-pattern rules, 9 total templates// - Starter projects, 3 total snippets/.py - Standalone code patterns, 27 total examples// - Runnable smoke-gated examples, 59 total (+ gallery.json) -showcase// - Budget-conformance props, 33 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 34 pieces (sibling of examples/; see showcase/README.md) scripts/build_gallery.py - Regenerates docs/gallery/ from examples/gallery.json + showcase/gallery.json scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders diff --git a/README.md b/README.md index 96789be0..d6c08cfa 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  33 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  34 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 33 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, 59 examples, and 34 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. @@ -106,7 +106,7 @@ per-script exit-code model, are in Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](showcase/README.md).

-33 showcase pieces — click to expand the preview grid +34 showcase pieces — click to expand the preview grid

Shipping crate: a wooden slat crate with iron corner brackets on a dark studio floor, warm wedge on the back wall @@ -142,6 +142,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Brazier: an iron fire bowl with a rolled rim on three S-curved forged legs, holding ash and a heap of glowing charcoal, on a dark studio floor Apothecary shelf: a stained wooden shelf unit with an arched crest, gallery rails and three drawers, stocked with labelled glass bottles, flasks and glazed jars, on a dark studio floor Wooden yoke: a carved oak ox yoke with upturned ends standing on two bent hickory oxbows pinned above the beam, with an iron ring hung under its centre, on a dark studio floor + Butter churn: a tall staved oak churn narrowing toward the top, bound by three iron hoops, with a two-board lid and a maple dasher handle rising through it, on a dark studio floor

[`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9. @@ -206,6 +207,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`wooden-yoke`](showcase/wooden-yoke/) — procedural double ox yoke on two bent hickory bows with a hung ring, whose neck openings are measured between the bow legs and up to the saddles, through the same pipeline. Falsifiers `--pinch-bows` exits 19, `--edge-on-ring` exits 17. +[`butter-churn`](showcase/butter-churn/) — procedural staved plunge churn whose dasher stroke is recomputed from the inner wall read off the staves and the plunger's reach, through the same pipeline. Falsifiers `--wide-dasher` exits 20, `--tight-hole` exits 18. +
## Examples diff --git a/ROADMAP.md b/ROADMAP.md index bf37329a..a84d8d5f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -138,9 +138,9 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Brazier (iron bowl on legs, coals, ash) as a game-prop showcase piece~~ **SHIPPED** as `showcase/brazier/` — spun bowl with a rolled rim, three one-sweep forged legs, ash bed biting the wall, 36 cleaved coals packed without interpenetration; tip angle from a density-weighted mass centre and the support polygon, `--tuck-legs` exits 19 on it while the feet still ground - ~~Apothecary shelf (shelf unit, bottles and jars of varied form) as a game-prop showcase piece~~ **SHIPPED** as `showcase/apothecary-shelf/` — housed shelves, gallery rails, lapped back boards, a drawer bank and an arched crest, stocked with fifteen lathed vessels in five forms, each corked or lidded and labelled on its own rings; per-vessel headroom under the member above is read off the mesh, `--tall-flask` exits 20 on it while every seat and the envelope still pass - ~~Wooden yoke (oxbow, iron rings) as a game-prop showcase piece~~ **SHIPPED** as `showcase/wooden-yoke/` — lofted oak beam with neck saddles and upturned ends, two bent-hickory bows through it and pinned above it, a staple and a threaded hung ring; each neck opening measured between the bow legs and up to the saddle, `--pinch-bows` exits 19 on it while the bows still ground and the envelope is unchanged -- Ox cart tongue or plough beam that hitches to the yoke's ring as a game-prop showcase piece — a hitch is a ring-on-hook contact the yoke's hung ring only tests against a fixed bar -- Butter churn (staved tub, lid, dasher) as a game-prop showcase piece +- ~~Butter churn (staved tub, lid, dasher) as a game-prop showcase piece~~ **SHIPPED** as `showcase/butter-churn/` — sixteen tapered staves with canted ends, three hoops seated vertex-for-vertex on them, a three-board bottom in a croze, a two-board lid and a cross dasher; the plunge stroke is recomputed from the inner wall and the plunger's reach, `--wide-dasher` exits 20 on it while the plunger still clears the staves at rest - Bookshelf with leaning books (upright and toppled volumes of varied size, one leaning on its neighbour) as a game-prop showcase piece — the lean needs a contact budget the apothecary shelf's upright vessels never exercised: each leaning book rests on its neighbour's spine edge and on the shelf, not in either +- Ox cart tongue or plough beam that hitches to the yoke's ring as a game-prop showcase piece — a hitch is a ring-on-hook contact the yoke's hung ring only tests against a fixed bar - ~~Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract)~~ **SHIPPED** as `showcase/fence-kit/` — tiling post-and-rail section (AABB X is the tile width; does not re-witness the snap contract); `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Custom ID-property delete witness~~ **SHIPPED** as `examples/cross-version-property-delete/` — IDs built via `bpy.data.objects.new` (not `active_object`); `property_unset` is TypeError and leaves the key; `del` removes it on 4.5 LTS and 5.x diff --git a/docs/gallery/asset-sheets/butter-churn.webp b/docs/gallery/asset-sheets/butter-churn.webp new file mode 100644 index 0000000000000000000000000000000000000000..d167e037193076544669eb3ebe6252df769bcd48 GIT binary patch literal 28888 zcmZs>V~}P+mo-||C&*5mu1pFNV(xKtx#BZtZfp z#WW{-Y}d}SG@RkS^zs|cmYxLo&~4z=Yd?@joh9sckNQ1-uX;`Xq%MslL&fca5P#Y| z`|bZY{4nqZa*_|y`@Rm|-&}3t%UOQEq_gw>%>0nPi9V2j{e0a#eZT%F{H(mdef{}S z{-(Z9++Tg6-kH6g{em~_UE-hd+x;Q^__^Qt(R-2K)_aA2!>`S~;uHL-`-%HmU%U7g z|52}-eUN{|fB$mlxA(>NtNiKyYJX1sIk*J=`Prm@g;48d{3!d0pQ6eio%G}U(OWTd z?d5c#|BQS0d;g}eM|_um)%)O!W`24vRq&5`>GA{7r#uR%^7gdps7d2{~n zx9I*eVWSk)NCn5#HAnwGaFM3aU=8b503$C7$3vphW_mB5Tm~1JK;B$qCMXL-f+6m@ZK1_zFVrmhPJB&s=%#2mJJ_B!7N2 zpk0xirsvaⓈ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®f^R*dw}vKR*B}3f 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 + + + + + butter-churn — Showcase — Blender Developer Tools + + + + + + + + + + + + + + + + + + + + + + + + +
+

butter-churn

+

A procedural plunge butter churn — sixteen tapered oak staves, three iron hoops, a three-board bottom, a two-board lid and a maple cross dasher — carried through UVs, bake, LOD, compound collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+
+
+ +

Rendered headless by the showcase piece itself — click to zoom.

+
witnesses Recomputed: 5684 tris, three materials with 2240 oak, 160 maple and 576 iron faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.340×0.340×1.050 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, all sixteen staves on the floor, three bottom boards with 1.2 mm seams, the handle 14 mm into the slats, three hoops 2.5 mm into the staves and 4.9 mm proud, the lid 1.4–2.0 mm onto the stave tops, 5 mm of clearance round the handle, and a 514 mm plunge stroke for a 101 mm reach. --wide-dasher exits 20 at 140 mm while the plunger still clears the staves at rest; --tight-hole exits 18.
+
+
blender --background --python showcase/butter-churn/butter_churn.py --
+ +
+
+

A plunge butter churn: a tall body of sixteen oak staves that narrows toward the top, three forged iron hoops, a bottom of three boards set in a croze, a lid of two boards with a hole for the handle, and a maple cross dasher whose handle runs up through the lid. A showcase piece, not an example — it witnesses no API contract. It asserts that generated geometry meets declared asset budgets, recomputed from the finished mesh.

+

What it composes

+
Shipped contentUsed for
skills/mesh-editing-and-bmeshlofted staves, banded hoops, clipped board polygons, lathed handle, all in one bmesh
skills/custom-propertiesface attributes (PlankTone, GrainDir) read by the wood shaders
skills/procedural-materials-and-shadersoak with grain along each stave, pale maple, rusted rough iron
skills/bake-high-to-lowCycles tangent-space normal bake, high onto low
skills/engine-export-presetsUnity glTF (export_yup=True)
skills/depsgraph-and-evaluated-dataevaluated triangle counts for the LOD ratios
snippets/decimate_to_budget.pyLOD1 / LOD2 COLLAPSE chain
snippets/convex_hull_collider.pyone hull for the body and lid, one for the handle above it
snippets/lod_chain.pyLOD naming and ratio pattern
examples/mesh-hygiene-audithygiene combinatorics (copied, not imported)
+

The budget that matters

+

A churn is for plunging. The body narrows toward the top, so a plunger that clears the staves at the bottom can jam partway up the stroke, and nothing else notices:

+
  • the plunger still sits inside the body without touching it;
  • the handle still clears the lid's hole;
  • the bounding box does not move.
+

The piece reads the inner wall off the staves ring by ring and the plunger's reach off the dasher's slats, about the handle's axis. It finds the height at which the wall comes within WALL_CLEAR (10 mm) of the plunger, stops at the lid if that comes first, and asserts the travel from the plunger's rest height as a stroke floor of 400 mm. Measured: 513.8 mm, for a 100.6 mm reach.

+

--wide-dasher is the falsifier built for exactly this. It widens the slats to 125 mm. The plunger still clears the staves at rest, but it can travel only 140.4 mm before it would meet the wall, and the run exits

+
  1. Every other budget passes and the AABB is unchanged.
+

Budgets

+

Declared in the script as named constants, recomputed from the generated mesh. Measured values are from Blender 5.2.1; every one is byte-identical on 4.5.11 and 5.1.2 (only the glTF file size differs, by 8 bytes, which is exporter metadata and not a budget).

+
BudgetBandMeasured
Base triangles5000–65005684
LOD1 ratio0.32–0.620.5000
LOD2 ratio0.10–0.350.2199
Material slotsexactly 3, distinct3
Oak / maple / iron faces≥ 2000 / 140 / 5002240 / 160 / 576
UV boundsinside 0..1(0.0013, 0.0013)–(0.9987, 0.9987)
UV AABB overlap≤ 1e-50.000000
Outer AABB0.340 × 0.340 × 1.050 m ± 0.0200.3401 × 0.3401 × 1.0500
Collider triangles≤ 200178 (two hulls)
Normal bake{'FINISHED'} with image data{'FINISHED'}, has_data=True
glTF exportfile written, non-empty~240 kB
Hygieneall zeroloose 0/0, non-manifold 0, zero-area 0, doubles 0, n-gons 0, coplanar cross-shell pairs 0
Grounded AABB\zmin\≤ 1e-40.00000
Named staves16 staves, each zmin ≤ 1e-416 at 0.00000
Bottom boards3 boards, seams 0.6–2.5 mm3, 1.20 mm
Handle bitehandle's foot 8–20 mm into the slats14.0 mm
Hoop seat3 hoops, inner face 1.0–4.5 mm into the staves, outer face ≥ 3 mm proud2.50 mm in, 4.90 mm proud
Lid seat2 boards, each 1–4 mm onto the stave tops1.40–2.00 mm
Handle clearancelid hole over handle radius, 2–10 mm5.00 mm
Plunge stroke≥ 400 mm513.8 mm
+

Real-world size: 0.34 m across the foot, 0.75 m to the rim and 1.05 m to the top of the handle. It is a farmhouse plunge churn.

+

Construction

+
  • Staves. Sixteen staves are lofted on a straight taper (R_BOT 170 mm to R_TOP 120 mm), with a gap of STAVE_GAP between neighbours.
  • Each stave's section has four vertices across its width at fractions 0, ¼, ¾ and 1, outer face over inner face, and quads close both ends.
  • Ring heights are a uniform run plus both edges of every hoop and of the croze, so a hoop's edge and the bottom's faces meet stave vertices.
  • Each stave's foot and top are cut sloping inward (END_CANT, doubled on alternate staves), so the churn stands on its outer edges.
  • Every real edge is chamfered at 1.5 mm; the few-degree edges across a stave's width are left alone.
  • Hoops. Each hoop is a banded iron section: flat inner face, chamfered outer edges. It is sampled at every stave's two interior vertex angles, so every hoop vertex lies radially over a stave vertex at the same height. The inner face is HOOP_BITE inside the stave's outer face, read from the same taper; the outer face is HOOP_PROUD outside it.
  • Bottom. A 48-gon disk sized to the inner wall plus CROZE, cut across X into three boards with a named seam (Sutherland–Hodgman clip). Caps are chamfered as n-gons and then triangulated.
  • Lid. Two half-annulus boards with the seam across the hole. The hole is HOLE_CLEAR wider than the handle. The lid rests LID_SEAT on the stave tops and overhangs by LID_OVER.
  • Dasher. Two crossed maple slats, the second a step higher. The handle's foot is driven HANDLE_BITE into them. The handle is a 12-segment lathe with rings at both faces of the lid, so its clearance is measured where it passes, and it ends in a swelled grip.
+

Findings the budgets forced

+
  • Neighbouring ends in one plane. The first build had 240 coplanar cross-shell pairs:
  • adjacent stave feet and tops;
  • the two lid boards;
  • the bottom boards' faces and a rim chord the seams split.
+

The fixes:

+
  • stave ends are canted inward, doubled on alternate staves (at one cant, neighbours 5.6° apart in bearing still matched as one plane);
  • one lid board stands 0.6 mm proud;
  • the middle bottom board steps up and in by 0.6 mm.
+

The count is 0.

+
  • A falsifier stolen by the collider. --float-hoops exited 11 on the collider ceiling (242 against 240) instead of 18. The hoops stand 5 mm proud and add nothing a character could hit, so they were taken out of the body hull rather than the ceiling raised. The collider is now 178 triangles, and the falsifier exits 18.
+

Conventions walked

+

Every convention in showcase/README.md, and whether it applies here.

+
ConventionAppliesHow
Deterministic, budgets declared, assertions recomputeyesno RNG; every value above is read off the mesh
Falsifier fails the budget it targetsyestable below, proven on all three binaries
Hygiene incl. cross-shell coplanaryesexit 15; canted stave ends and stepped boards keep it at 0
Named supportsyesall sixteen staves (--short-stave)
Seat conformance, sampled per segmentyesevery hoop vertex against the stave vertex under it (--float-hoops)
Bands on a curved host are built on the host's arcyeshoop vertices at the staves' own angles
A band wraps its host, never sunk into ityesinner face bites, outer face asserted proud
A head or bottom is boards, not a slabyesthree bottom boards with a banded seam (--one-piece-bottom); the lid is two boards
A vessel holds its contentsn/athe churn shows no contents
A joint bites; touching is not joiningyeshandle into the slats (--short-handle)
Carried parts bite their bearersyeslid on the stave tops (--float-lid)
What passes through a part must clear its mountyeshandle through the lid's hole (--tight-hole)
Identical boards read as CGyesper-stave and per-board PlankTone, grain along each piece's longest extent
Hoops are forged iron, not brightyesnear-black and rust, roughness 0.55–0.85, metallic 0.65
Shading is part of the modelyesstaves smooth across their width with every edge over 35° hard, so they read as coopered boards; handle smooth (turned)
One substance, one slotyesoak, maple, iron
Chamfer n-gon caps, then triangulateyesbottom and lid boards
Sort bmesh operator inputsyesbevel edges sorted by index
Level on the stage; stage 60 myesturned about Z only; 60 m floor and wall
Keep a falsifier's envelope stillyesevery falsifier leaves the AABB unchanged
Rope, masonry, roofs, rings, scatter, mirrored assembliesnothe piece has none of these
+

Falsifiers

+

Each breaks one pipeline stage so a named budget fails. All ten were run on 4.5.11, 5.1.2 and 5.2.1 and exited the same declared code on all three.

+
FlagTarget budgetBreaksExit
--skip-decimateLOD1 ratiodrops the DECIMATE modifiers, LOD1 ratio goes to 1.00009
--stray-vertmesh hygieneadds one loose vertex inside the body15
--lift-zgrounded zminlifts the whole mesh 50 mm16
--short-stavenamed stavesstops one stave's foot 6 mm above the floor; the rest ground the AABB16
--one-piece-bottombottom boardsone disk instead of three boards17
--short-handlehandle bitestarts the handle 5 mm above the slats; −5.0 mm17
--float-hoopshoop seatbuilds every hoop 4 mm off the staves; −3.5 mm18
--float-lidlid seatlifts the lid 5 mm; −3.6 to −3.0 mm18
--tight-holehandle clearancecuts the hole 2 mm inside the handle; −2.0 mm18
--wide-dasherplunge strokewidens the slats to 125 mm; 140.4 mm20
+

Exit codes

+

File-local and sequential. 9 is a valid check code. 1 is the FATAL wrapper — a crash, never a named check. 19 (plumb and real-world size) is reserved across pieces and unused here.

+
CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Mesh did not build, or has no UV layer
4Base triangle count outside band
5Material slots, or a material's face floor
6UVs outside 0..1
7UV AABB overlap above tolerance
8Outer AABB off declared size
9LOD1 or LOD2 ratio outside band (--skip-decimate)
10Framing gate (examples/gallery_framing.py, render path only)
11Collider triangles above ceiling
12Normal bake failed or produced no image data
13glTF export missing or empty
14--output produced no file
15Mesh hygiene (--stray-vert)
16Grounded zmin, or a stave floating (--lift-z, --short-stave)
17Bottom boards or handle bite (--one-piece-bottom, --short-handle)
18Hoop seat, lid seat or handle clearance (--float-hoops, --float-lid, --tight-hole)
20Plunge stroke below floor (--wide-dasher)
+

Run it

+
# Budget check, no render. ~1.8 s on 4.5 and 5.2.
+blender --background --python butter_churn.py --
+
+# Falsifier: the plunger jams partway up the narrowing body. Must exit 20.
+blender --background --python butter_churn.py -- --wide-dasher
+
+# Falsifier: the lid's hole is narrower than the handle. Must exit 18.
+blender --background --python butter_churn.py -- --tight-hole
+
+# Render the gallery still (EEVEE; --engine cycles on a GPU-less host).
+blender --background --python butter_churn.py -- --output butter_churn.webp
+

Smoke runs the check-only path. It does not pass --output or any falsifier.

+

Cross-version measurements

+
Value4.5.115.1.25.2.1
Base triangles568456845684
LOD1 / LOD2 tris2842 / 1250samesame
Face counts (oak / maple / iron)2240 / 160 / 576samesame
Outer AABB0.3401 × 0.3401 × 1.0500samesame
Collider tris178178178
Plunge stroke / reach513.8 mm / 100.6 mmsamesame
glTF bytes239804239804239796
+
+
+

Source

+
+ showcase/butter-churn/butter_churn.py + View on GitHub → +
+
"""Game-ready butter churn — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural plunge churn: a tall staved
+body that narrows toward the top, three iron hoops, a bottom of three
+boards set in a croze, a lid of two boards with a hole for the handle,
+and a cross dasher whose handle runs up through the lid. Carried through
+UVs, three materials (oak, maple, iron), a high-to-low normal bake, an
+LOD chain, a compound convex collider, and a Unity glTF export.
+
+The budget that matters here is the one a churn fails invisibly: the
+dasher has to plunge. The body narrows toward the top, so a plunger that
+clears the staves at rest can jam a hand's width up the stroke. It still
+sits inside the body without touching it, the handle still clears the lid
+and the bounding box does not move; only the stroke knows. The piece
+reads the inner wall off the staves and the plunger's reach off the
+dasher, and asserts how far the plunger can travel before it would meet
+the wall or the lid.
+
+Budgets are declared below and recomputed from the generated result.
+They are not API-contract witnesses. Each falsifier violates one named
+budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh
+hygiene, ``--lift-z`` grounded zmin, ``--short-stave`` the named staves,
+``--one-piece-bottom`` the bottom boards, ``--short-handle`` the handle
+bite, ``--float-hoops`` the hoop seat, ``--float-lid`` the lid seat,
+``--tight-hole`` the handle clearance, ``--wide-dasher`` the stroke.
+
+No randomness: every stave's tone is a closed-form term of its index.
+DECIMATE COLLAPSE triangle counts are not byte-identical across Blender
+versions — the LOD gate is a ratio band.
+
+    blender --background --python butter_churn.py --
+    blender --background --python butter_churn.py -- --wide-dasher
+    blender --background --python butter_churn.py -- --output butter_churn.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Matrix, Vector
+from mathutils.kdtree import KDTree
+
+_REPO = os.path.abspath(
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing  # noqa: E402
+
+# The body: sixteen staves on a straight taper, wider at the foot.
+HEIGHT = 0.750
+R_BOT = 0.170
+R_TOP = 0.120
+N_STAVES = 16
+STAVE_T = 0.018
+STAVE_GAP = 0.0008
+STAVE_FRACS = (0.0, 0.25, 0.75, 1.0)
+BODY_RINGS = 6
+CHAMFER = 0.0015
+SHORT_STAVE = 0.006
+# Each stave's foot and top are cut sloping inward, so the churn stands on
+# its outer edges. Alternate staves take a double cant: at one cant, two
+# neighbours' end faces are only 5.6 degrees apart in bearing and still
+# match as one plane.
+END_CANT = 0.0015
+
+# Hoops: banded iron sampled at each stave's interior vertices, so every
+# hoop vertex sits radially over a stave vertex at the same height.
+HOOP_ZS = (0.060, 0.360, 0.655)
+HOOP_H = 0.024
+HOOP_BITE = 0.0025
+HOOP_PROUD = 0.005
+HOOP_CH = 0.0015
+FLOAT_HOOP = 0.004
+
+# Bottom: three boards in a croze cut into the staves' inner face.
+BOTTOM_Z = 0.012
+BOTTOM_T = 0.022
+CROZE = 0.0035
+N_BOARDS = 3
+BOARD_SEAM = 0.0012
+DISK_SEG = 48
+BOARD_STEP = 0.0006
+
+# Lid: two boards resting on the stave tops, a hole for the handle.
+LID_T = 0.022
+LID_SEAT = 0.002
+LID_OVER = 0.008
+LID_SEAM = 0.0012
+HOLE_CLEAR = 0.005
+TIGHT_HOLE = -0.002
+FLOAT_LID = 0.005
+LID_ARC = 24
+LID_STEP = 0.0006
+
+# Dasher: a round maple handle through the lid, two crossed slats below.
+HANDLE_R = 0.016
+HANDLE_TOP = 1.050
+HANDLE_BITE = 0.014
+SHORT_HANDLE = -0.005
+PLUNGER_Z = BOTTOM_Z + BOTTOM_T + 0.050
+SLAT_REACH = 0.100
+WIDE_REACH = 0.125
+SLAT_W = 0.024
+SLAT_T = 0.020
+SLAT_STEP = 0.004
+WALL_CLEAR = 0.010
+
+BBOX_TOL = 0.020
+OUTER_SIZE = (0.340, 0.340, 1.050)
+
+BASE_TRIS_MIN = 5000
+BASE_TRIS_MAX = 6500
+LOD1_RATIO_MIN = 0.32
+LOD1_RATIO_MAX = 0.62
+LOD2_RATIO_MIN = 0.10
+LOD2_RATIO_MAX = 0.35
+LOD1_TARGET = 0.50
+LOD2_TARGET = 0.22
+MATERIAL_COUNT = 3
+FACE_FLOORS = {0: 2000, 1: 140, 2: 500}
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 200
+BAKE_RES = 512
+CAGE_EXTRUSION = 0.01
+ZMIN_EPS = 1e-4
+DOUBLES_EPS = 1e-5
+AREA_EPS = 1e-10
+COPLANAR_NORMAL_EPS = 1e-4
+COPLANAR_PLANE_EPS = 1e-4
+COPLANAR_CENTRE_MAX = 0.05
+LIFT_Z = 0.05
+STAVE_Z_MAX = 1e-4
+BOARD_SEAM_MIN = 0.0006
+BOARD_SEAM_MAX = 0.0025
+HANDLE_BITE_MIN = 0.008
+HANDLE_BITE_MAX = 0.020
+HOOP_BITE_MIN = 0.0010
+HOOP_BITE_MAX = 0.0045
+HOOP_PROUD_MIN = 0.0030
+LID_SEAT_MIN = 0.0010
+LID_SEAT_MAX = 0.0040
+HOLE_CLEAR_MIN = 0.002
+HOLE_CLEAR_MAX = 0.010
+STROKE_MIN = 0.400
+
+OAK_IDX = 0
+MAPLE_IDX = 1
+IRON_IDX = 2
+
+
+def eevee_engine_id():
+    """EEVEE id: 'BLENDER_EEVEE' on 5.0+, 'BLENDER_EEVEE_NEXT' on 4.2-4.5."""
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"FAIL[{code}]: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    deps = bpy.context.evaluated_depsgraph_get()
+    ev = obj.evaluated_get(deps)
+    mesh = ev.to_mesh()
+    try:
+        mesh.calc_loop_triangles()
+        return len(mesh.loop_triangles)
+    finally:
+        ev.to_mesh_clear()
+
+
+def r_out(z):
+    return R_BOT + (R_TOP - R_BOT) * z / HEIGHT
+
+
+def r_in(z):
+    return r_out(z) - STAVE_T
+
+
+def stave_angles(k):
+    """Angles of stave ``k``'s vertices across its width, gap split either side."""
+    span = 2.0 * math.pi / N_STAVES
+    gap = STAVE_GAP / R_TOP
+    a0 = k * span + gap * 0.5
+    width = span - gap
+    return [a0 + width * f for f in STAVE_FRACS]
+
+
+def ring_zs():
+    """Stave ring heights: a uniform run plus both edges of every hoop and the croze."""
+    zs = {round(HEIGHT * k / BODY_RINGS, 6) for k in range(BODY_RINGS + 1)}
+    for z in HOOP_ZS:
+        zs |= {round(z, 6), round(z + HOOP_H, 6)}
+    zs |= {round(BOTTOM_Z, 6), round(BOTTOM_Z + BOTTOM_T, 6)}
+    return sorted(zs)
+
+
+# --- construction -----------------------------------------------------------
+
+
+def new_island(ctx):
+    ctx["next"] += 1
+    return ctx["next"]
+
+
+def stamp(ctx, face, island, uvmap):
+    face[ctx["isl"]] = island
+    for loop in face.loops:
+        loop[ctx["uv"]].uv = uvmap[loop.vert]
+
+
+def loft(bm, rings, mat_idx, ctx, closed=True):
+    """Quads between consecutive rings of equal length; one strip island."""
+    island = new_island(ctx)
+    n = len(rings[0])
+    arc = [0.0]
+    for a, b in zip(rings, rings[1:]):
+        ca = sum((v.co for v in a), Vector()) / n
+        cb = sum((v.co for v in b), Vector()) / n
+        arc.append(arc[-1] + (cb - ca).length)
+    faces = []
+    for k in range(len(rings) - 1):
+        a, b = rings[k], rings[k + 1]
+        for i in range(n if closed else n - 1):
+            j = (i + 1) % n
+            f = bm.faces.new((a[i], a[j], b[j], b[i]))
+            f.material_index = mat_idx
+            stamp(ctx, f, island, {a[i]: (arc[k], i / n), a[j]: (arc[k], (i + 1) / n),
+                                   b[j]: (arc[k + 1], (i + 1) / n), b[i]: (arc[k + 1], i / n)})
+            faces.append(f)
+    return faces
+
+
+def add_stave(bm, k, ctx, z0, verts_out):
+    """One stave: an annular sector on the taper, capped by quads at both ends."""
+    angs = stave_angles(k)
+    rings = []
+    for z in ring_zs():
+        # ``z0`` lifts only the foot ring, so a short stave stops above the
+        # floor while its hoop and croze rings stay where the others are.
+        zz = z if z > 0.0 else z0
+        c = END_CANT * (1 + k % 2)
+        cant = c if z <= 0.0 else (-c if z >= HEIGHT else 0.0)
+        outer = [Vector((r_out(z) * math.cos(a), r_out(z) * math.sin(a), zz)) for a in angs]
+        inner = [Vector((r_in(z) * math.cos(a), r_in(z) * math.sin(a), zz + cant))
+                 for a in reversed(angs)]
+        rings.append([bm.verts.new(p) for p in outer + inner])
+    loft(bm, rings, OAK_IDX, ctx)
+    m = len(angs)
+    for ring, flip in ((rings[0], True), (rings[-1], False)):
+        for i in range(m - 1):
+            o0, o1 = ring[i], ring[i + 1]
+            i1, i0 = ring[2 * m - 2 - i], ring[2 * m - 1 - i]
+            vs = (o0, o1, i1, i0)
+            f = bm.faces.new(tuple(reversed(vs)) if flip else vs)
+            f.material_index = OAK_IDX
+            f[ctx["isl"]] = 0
+    for r in rings:
+        verts_out.extend(r)
+
+
+def add_hoop(bm, z, ctx, float_hoop=False):
+    """A banded hoop sampled at every stave's interior vertex angles.
+
+    Its inner face sits HOOP_BITE inside the stave's outer face at each
+    edge height, read from the same taper, so the band follows the staves.
+    """
+    angs = [a for k in range(N_STAVES) for a in stave_angles(k)[1:-1]]
+    off_in = FLOAT_HOOP if float_hoop else -HOOP_BITE
+    sec = [(off_in, 0.0), (HOOP_PROUD - HOOP_CH, 0.0), (HOOP_PROUD, HOOP_CH),
+           (HOOP_PROUD, HOOP_H - HOOP_CH), (HOOP_PROUD - HOOP_CH, HOOP_H), (off_in, HOOP_H)]
+    cols = []
+    for a in angs:
+        c, s = math.cos(a), math.sin(a)
+        cols.append([bm.verts.new(((r_out(z + dz) + dr) * c, (r_out(z + dz) + dr) * s, z + dz))
+                     for dr, dz in sec])
+    island = new_island(ctx)
+    n, m = len(cols), len(sec)
+    for i in range(n):
+        a, b = cols[i], cols[(i + 1) % n]
+        for j in range(m):
+            jj = (j + 1) % m
+            f = bm.faces.new((a[j], a[jj], b[jj], b[j]))
+            f.material_index = IRON_IDX
+            stamp(ctx, f, island, {a[j]: (i / n, j / m), a[jj]: (i / n, (j + 1) / m),
+                                   b[jj]: ((i + 1) / n, (j + 1) / m), b[j]: ((i + 1) / n, j / m)})
+
+
+def clip_x(poly, x0, x1):
+    """Clip a convex (x, y) polygon to x0 <= x <= x1 (Sutherland-Hodgman)."""
+    def cut(pts, keep, xc):
+        out = []
+        for i, p in enumerate(pts):
+            q = pts[(i + 1) % len(pts)]
+            pin, qin = keep(p[0]), keep(q[0])
+            if pin:
+                out.append(p)
+            if pin != qin:
+                t = (xc - p[0]) / (q[0] - p[0])
+                out.append((xc, p[1] + (q[1] - p[1]) * t))
+        return out
+    pts = cut(poly, lambda x: x >= x0, x0)
+    return cut(pts, lambda x: x <= x1, x1)
+
+
+def extrude_polygon(bm, poly, z0, z1, mat_idx, verts_out):
+    """A board from an (x, y) outline: two n-gon caps and quad sides."""
+    lo = [bm.verts.new((x, y, z0)) for x, y in poly]
+    hi = [bm.verts.new((x, y, z1)) for x, y in poly]
+    faces = [bm.faces.new(lo), bm.faces.new(list(reversed(hi)))]
+    n = len(poly)
+    for i in range(n):
+        j = (i + 1) % n
+        faces.append(bm.faces.new((lo[j], lo[i], hi[i], hi[j])))
+    for f in faces:
+        f.material_index = mat_idx
+    verts_out.extend(lo + hi)
+
+
+def add_bottom(bm, one_piece, verts_out):
+    zm = BOTTOM_Z + BOTTOM_T * 0.5
+    rr = r_in(zm) + CROZE
+    disk = [(rr * math.cos(2 * math.pi * i / DISK_SEG), rr * math.sin(2 * math.pi * i / DISK_SEG))
+            for i in range(DISK_SEG)]
+    if one_piece:
+        extrude_polygon(bm, disk, BOTTOM_Z, BOTTOM_Z + BOTTOM_T, OAK_IDX, verts_out)
+        return
+    w = (2.0 * rr - BOARD_SEAM * (N_BOARDS - 1)) / N_BOARDS
+    for k in range(N_BOARDS):
+        x0 = -rr + k * (w + BOARD_SEAM)
+        # Alternate boards step up and in by BOARD_STEP: level with their
+        # neighbours, their faces and the rim chord a seam splits would each
+        # be one plane shared by two boards.
+        step = BOARD_STEP if k % 2 else 0.0
+        src = [(x * (rr - step) / rr, y * (rr - step) / rr) for x, y in disk]
+        poly = clip_x(src, x0 - (1.0 if k == 0 else 0.0), x0 + w + (1.0 if k == N_BOARDS - 1 else 0.0))
+        extrude_polygon(bm, poly, BOTTOM_Z + step, BOTTOM_Z + BOTTOM_T + step, OAK_IDX, verts_out)
+
+
+def add_lid(bm, hole_r, lift, verts_out):
+    """Two half-annulus boards, seam across the hole, resting on the stave tops."""
+    ro = r_out(HEIGHT) + LID_OVER
+    z0 = HEIGHT - LID_SEAT + lift
+    s = LID_SEAM * 0.5
+    t_o = math.acos(s / ro)
+    t_i = math.acos(s / hole_r)
+    for side in (1.0, -1.0):
+        outer = [(ro * math.cos(-t_o + 2 * t_o * k / LID_ARC), ro * math.sin(-t_o + 2 * t_o * k / LID_ARC))
+                 for k in range(LID_ARC + 1)]
+        inner = [(hole_r * math.cos(t_i - 2 * t_i * k / 8), hole_r * math.sin(t_i - 2 * t_i * k / 8))
+                 for k in range(9)]
+        poly = [(side * x, y) for x, y in outer + inner]
+        if side < 0:
+            poly.reverse()
+        # One board a step proud of the other, so the two do not share a face plane.
+        step = LID_STEP if side < 0 else 0.0
+        extrude_polygon(bm, poly, z0 + step, z0 + step + LID_T, OAK_IDX, verts_out)
+
+
+def lathe_z(bm, profile, n, mat_idx, ctx, base):
+    """Revolve an (r, z) profile about the vertical axis through ``base``."""
+    rings, poles = [], []
+    for p in profile:
+        if p.x <= 0.0:
+            poles.append(bm.verts.new(base + Vector((0.0, 0.0, p.y))))
+        else:
+            rings.append([bm.verts.new(base + Vector((p.x * math.cos(2 * math.pi * k / n),
+                                                       p.x * math.sin(2 * math.pi * k / n), p.y)))
+                          for k in range(n)])
+    loft(bm, rings, mat_idx, ctx)
+    island = new_island(ctx)
+    for pole, ring, flip in ((poles[0], rings[0], True), (poles[1], rings[-1], False)):
+        for i in range(n):
+            j = (i + 1) % n
+            vs = (pole, ring[j], ring[i]) if flip else (pole, ring[i], ring[j])
+            f = bm.faces.new(vs)
+            f.material_index = mat_idx
+            stamp(ctx, f, island, {pole: (0.5 if flip else 1.5, (i + 0.5) / n),
+                                   ring[i]: (0.0 if flip else 1.0, i / n),
+                                   ring[j]: (0.0 if flip else 1.0, (i + 1) / n)})
+
+
+def handle_profile(z_start, lid_z0):
+    """(r, z) from the handle's foot, with rings at both faces of the lid."""
+    r = HANDLE_R
+    top = HANDLE_TOP - z_start
+    rings = [(r * 0.85, 0.0), (r, 0.004), (r, lid_z0 - z_start), (r, lid_z0 + LID_T - z_start),
+             (r, top - 0.060), (r * 1.25, top - 0.035), (r * 1.35, top - 0.018), (r * 1.05, top)]
+    return [Vector((0.0, 0.0))] + [Vector(p) for p in rings] + [Vector((0.0, top))]
+
+
+def build_churn_mesh(
+    name,
+    stray_vert=False,
+    short_stave=False,
+    one_piece_bottom=False,
+    short_handle=False,
+    float_hoops=False,
+    float_lid=False,
+    tight_hole=False,
+    wide_dasher=False,
+):
+    bm = bmesh.new()
+    try:
+        ctx = {"uv": bm.loops.layers.uv.new("UVMap"),
+               "isl": bm.faces.layers.int.new("UVIsland"), "next": 0}
+        wood = []
+        for k in range(N_STAVES):
+            add_stave(bm, k, ctx, SHORT_STAVE if (short_stave and k == 0) else 0.0, wood)
+        add_bottom(bm, one_piece_bottom, wood)
+        hole_r = HANDLE_R + (TIGHT_HOLE if tight_hole else HOLE_CLEAR)
+        lid_lift = FLOAT_LID if float_lid else 0.0
+        add_lid(bm, hole_r, lid_lift, wood)
+        # Dasher: two crossed slats, the second a step higher so no two of
+        # their faces share a plane, and the handle's foot biting both.
+        reach = WIDE_REACH if wide_dasher else SLAT_REACH
+        maple = []
+        extrude_polygon(bm, [(-reach, -SLAT_W / 2), (reach, -SLAT_W / 2), (reach, SLAT_W / 2), (-reach, SLAT_W / 2)],
+                PLUNGER_Z, PLUNGER_Z + SLAT_T, MAPLE_IDX, maple)
+        extrude_polygon(bm, [(-SLAT_W / 2, -reach), (SLAT_W / 2, -reach), (SLAT_W / 2, reach), (-SLAT_W / 2, reach)],
+                PLUNGER_Z + SLAT_STEP, PLUNGER_Z + SLAT_STEP + SLAT_T, MAPLE_IDX, maple)
+        # Real edges only (a stave's width and a disk's rim meet at a few
+        # degrees), sorted by index, each pass pinned to its own material.
+        for verts, mat_idx in ((wood, OAK_IDX), (maple, MAPLE_IDX)):
+            bm.edges.index_update()
+            edges = sorted({e for v in verts if v.is_valid for e in v.link_edges
+                            if e.calc_face_angle(0.0) > math.radians(20.0)},
+                           key=lambda e: e.index)
+            bmesh.ops.bevel(bm, geom=edges, offset=CHAMFER, segments=1, profile=0.5,
+                            affect="EDGES", clamp_overlap=True, material=mat_idx)
+        bmesh.ops.triangulate(bm, faces=[f for f in bm.faces if len(f.verts) > 4])
+        for z in HOOP_ZS:
+            add_hoop(bm, z, ctx, float_hoop=float_hoops)
+        z_start = PLUNGER_Z + SLAT_STEP + SLAT_T - (SHORT_HANDLE if short_handle else HANDLE_BITE)
+        lathe_z(bm, handle_profile(z_start, HEIGHT - LID_SEAT + lid_lift), 12, MAPLE_IDX, ctx,
+                Vector((0.0, 0.0, z_start)))
+        if stray_vert:
+            bm.verts.new((0.0, 0.0, 0.3))
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for f in bm.faces:
+            f.smooth = True
+        for e in bm.edges:
+            if len(e.link_faces) == 2 and e.calc_face_angle(0.0) > math.radians(35.0):
+                e.smooth = False
+        pack_uvs(bm, ctx)
+        bm.faces.layers.int.remove(ctx["isl"])
+        me = bpy.data.meshes.new(name)
+        bm.to_mesh(me)
+        me.update()
+    finally:
+        bm.free()
+    paint_pieces(me)
+    obj = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(obj)
+    return obj
+
+
+def pack_uvs(bm, ctx, margin=0.06):
+    """One grid cell per UV island: strip islands by their layer tag, else a face each."""
+    uv, isl = ctx["uv"], ctx["isl"]
+    bm.faces.index_update()
+    islands, order = {}, []
+    for face in bm.faces:
+        key = ("s", face[isl]) if face[isl] else ("f", face.index)
+        if key not in islands:
+            islands[key] = []
+            order.append(key)
+        islands[key].append(face)
+    cols = max(1, math.ceil(math.sqrt(len(order))))
+    rows = max(1, math.ceil(len(order) / cols))
+    cw, ch = 1.0 / cols, 1.0 / rows
+    pu, pv = margin * cw * 0.5, margin * ch * 0.5
+    for idx, key in enumerate(order):
+        faces = islands[key]
+        coords = {}
+        for face in faces:
+            if face[isl]:
+                coords[face.index] = [tuple(loop[uv].uv) for loop in face.loops]
+                continue
+            nrm = face.normal
+            ax, ay, az = abs(nrm.x), abs(nrm.y), abs(nrm.z)
+            pts = []
+            for loop in face.loops:
+                co = loop.vert.co
+                if az >= ax and az >= ay:
+                    pts.append((co.x, co.y))
+                elif ax >= ay:
+                    pts.append((co.y, co.z))
+                else:
+                    pts.append((co.x, co.z))
+            coords[face.index] = pts
+        allc = [c for cs in coords.values() for c in cs]
+        minx, maxx = min(c[0] for c in allc), max(c[0] for c in allc)
+        miny, maxy = min(c[1] for c in allc), max(c[1] for c in allc)
+        dx, dy = max(maxx - minx, 1e-8), max(maxy - miny, 1e-8)
+        ou, ov = (idx % cols) * cw + pu, (idx // cols) * ch + pv
+        for face in faces:
+            for loop, (x, y) in zip(face.loops, coords[face.index]):
+                loop[uv].uv = (ou + (x - minx) / dx * (cw - 2 * pu),
+                               ov + (y - miny) / dy * (ch - 2 * pv))
+
+
+def paint_pieces(me):
+    """Per-piece ``PlankTone`` and ``GrainDir`` (each shell's longest extent)."""
+    npoly = len(me.polygons)
+    tone = [0.5] * npoly
+    grain = [(0.0, 0.0, 1.0)] * npoly
+    vf = [[] for _ in range(len(me.vertices))]
+    for p in me.polygons:
+        for i in p.vertices:
+            vf[i].append(p.index)
+    for k, g in enumerate(shells(me)):
+        pts = [me.vertices[i].co for i in g]
+        ext = [max(p[a] for p in pts) - min(p[a] for p in pts) for a in range(3)]
+        axis = ext.index(max(ext))
+        d = tuple(1.0 if a == axis else 0.0 for a in range(3))
+        t = 0.5 + 0.34 * (((k * 0.6180339887 + 0.3) % 1.0) - 0.5)
+        for fi in {fi for i in g for fi in vf[i]}:
+            tone[fi] = t
+            grain[fi] = d
+    a = me.attributes.new("PlankTone", "FLOAT", "FACE")
+    a.data.foreach_set("value", tone)
+    b = me.attributes.new("GrainDir", "FLOAT_VECTOR", "FACE")
+    b.data.foreach_set("vector", [c for v in grain for c in v])
+
+
+# --- surface ----------------------------------------------------------------
+
+
+def _sock(sockets, identifier):
+    return next(sk for sk in sockets if sk.identifier == identifier)
+
+
+def wood_material(name, dark, light, rough=(0.72, 0.52)):
+    """Timber whose grain runs along ``GrainDir`` and whose tone varies by piece."""
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    coord = nt.nodes.new("ShaderNodeTexCoord")
+    gdir = nt.nodes.new("ShaderNodeAttribute")
+    gdir.attribute_name = "GrainDir"
+    tone = nt.nodes.new("ShaderNodeAttribute")
+    tone.attribute_name = "PlankTone"
+    dot = nt.nodes.new("ShaderNodeVectorMath")
+    dot.operation = "DOT_PRODUCT"
+    nt.links.new(coord.outputs["Object"], dot.inputs[0])
+    nt.links.new(gdir.outputs["Vector"], dot.inputs[1])
+    squash = nt.nodes.new("ShaderNodeMath")
+    squash.operation = "MULTIPLY"
+    squash.inputs[1].default_value = 0.94
+    nt.links.new(dot.outputs["Value"], squash.inputs[0])
+    along = nt.nodes.new("ShaderNodeVectorMath")
+    along.operation = "SCALE"
+    nt.links.new(gdir.outputs["Vector"], along.inputs[0])
+    nt.links.new(squash.outputs["Value"], along.inputs["Scale"])
+    grain_co = nt.nodes.new("ShaderNodeVectorMath")
+    grain_co.operation = "SUBTRACT"
+    nt.links.new(coord.outputs["Object"], grain_co.inputs[0])
+    nt.links.new(along.outputs["Vector"], grain_co.inputs[1])
+    shift = nt.nodes.new("ShaderNodeVectorMath")
+    shift.operation = "ADD"
+    nt.links.new(grain_co.outputs["Vector"], shift.inputs[0])
+    nt.links.new(tone.outputs["Fac"], shift.inputs[1])
+    noise = nt.nodes.new("ShaderNodeTexNoise")
+    noise.inputs["Scale"].default_value = 34.0
+    noise.inputs["Detail"].default_value = 6.0
+    noise.inputs["Roughness"].default_value = 0.62
+    nt.links.new(shift.outputs["Vector"], noise.inputs["Vector"])
+    ramp = nt.nodes.new("ShaderNodeValToRGB")
+    ramp.color_ramp.elements[0].position = 0.30
+    ramp.color_ramp.elements[0].color = (*dark, 1.0)
+    ramp.color_ramp.elements[1].position = 0.72
+    ramp.color_ramp.elements[1].color = (*light, 1.0)
+    nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
+    gain = nt.nodes.new("ShaderNodeMath")
+    gain.operation = "MULTIPLY_ADD"
+    gain.inputs[1].default_value = 1.2
+    gain.inputs[2].default_value = 0.40
+    nt.links.new(tone.outputs["Fac"], gain.inputs[0])
+    mix = nt.nodes.new("ShaderNodeMix")
+    mix.data_type = "RGBA"
+    mix.blend_type = "MULTIPLY"
+    _sock(mix.inputs, "Factor_Float").default_value = 1.0
+    nt.links.new(ramp.outputs["Color"], _sock(mix.inputs, "A_Color"))
+    nt.links.new(gain.outputs["Value"], _sock(mix.inputs, "B_Color"))
+    nt.links.new(_sock(mix.outputs, "Result_Color"), bsdf.inputs["Base Color"])
+    rmap = nt.nodes.new("ShaderNodeMapRange")
+    rmap.inputs["To Min"].default_value = rough[0]
+    rmap.inputs["To Max"].default_value = rough[1]
+    nt.links.new(noise.outputs["Fac"], rmap.inputs["Value"])
+    nt.links.new(rmap.outputs["Result"], bsdf.inputs["Roughness"])
+    return mat
+
+
+def iron_material(name):
+    """Forged iron: near-black, rough, rusted in patches. Not chrome."""
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    tc = nt.nodes.new("ShaderNodeTexCoord")
+    noise = nt.nodes.new("ShaderNodeTexNoise")
+    noise.inputs["Scale"].default_value = 40.0
+    noise.inputs["Detail"].default_value = 8.0
+    nt.links.new(tc.outputs["Object"], noise.inputs["Vector"])
+    ramp = nt.nodes.new("ShaderNodeValToRGB")
+    ramp.color_ramp.elements[0].position = 0.45
+    ramp.color_ramp.elements[0].color = (0.035, 0.033, 0.031, 1.0)
+    ramp.color_ramp.elements[1].position = 0.78
+    ramp.color_ramp.elements[1].color = (0.20, 0.085, 0.035, 1.0)
+    nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
+    nt.links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"])
+    bsdf.inputs["Metallic"].default_value = 0.65
+    rough = nt.nodes.new("ShaderNodeMapRange")
+    rough.inputs["To Min"].default_value = 0.55
+    rough.inputs["To Max"].default_value = 0.85
+    nt.links.new(noise.outputs["Fac"], rough.inputs["Value"])
+    nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"])
+    return mat
+
+
+def churn_materials():
+    return (
+        wood_material("ChurnOak", (0.085, 0.040, 0.016), (0.28, 0.14, 0.060)),
+        wood_material("ChurnMaple", (0.26, 0.17, 0.09), (0.55, 0.40, 0.24), rough=(0.62, 0.45)),
+        iron_material("ChurnIron"),
+    )
+
+
+def assign_slots(obj, mats):
+    slots = obj.data.materials
+    for i, mat in enumerate(mats):
+        if i < len(slots):
+            slots[i] = mat
+        else:
+            slots.append(mat)
+
+
+# --- measurement ------------------------------------------------------------
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs, ys, zs = [c.x for c in corners], [c.y for c in corners], [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0.0, 0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    span = max(1e-6, max(a[2] - a[0] for a in aabbs), max(a[3] - a[1] for a in aabbs))
+    buckets = {}
+    for i, a in enumerate(aabbs):
+        for c in range(int(a[0] // span), int(a[2] // span) + 1):
+            for r in range(int(a[1] // span), int(a[3] // span) + 1):
+                buckets.setdefault((c, r), []).append(i)
+    overlap = 0.0
+    seen = set()
+    for members in buckets.values():
+        for ii in range(len(members)):
+            for jj in range(ii + 1, len(members)):
+                i, j = members[ii], members[jj]
+                key = (i, j) if i < j else (j, i)
+                if key in seen:
+                    continue
+                seen.add(key)
+                a, b = aabbs[i], aabbs[j]
+                overlap += max(0.0, min(a[2], b[2]) - max(a[0], b[0])) * max(
+                    0.0, min(a[3], b[3]) - max(a[1], b[1]))
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def face_area(me, poly):
+    idxs = poly.vertices
+    v0 = me.vertices[idxs[0]].co
+    area = 0.0
+    for i in range(1, len(idxs) - 1):
+        area += (me.vertices[idxs[i]].co - v0).cross(me.vertices[idxs[i + 1]].co - v0).length * 0.5
+    return area
+
+
+def hygiene_audit(me):
+    ngons = sum(1 for p in me.polygons if len(p.vertices) > 4)
+    zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(me)
+        loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0)
+        loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0)
+        nonman = sum(1 for e in bm.edges if not e.is_manifold)
+        ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS)
+        doubles = len(ret.get("targetmap") or {})
+    finally:
+        bm.free()
+    return {"ngons": ngons, "loose_v": loose_v, "loose_e": loose_e,
+            "nonman": nonman, "zero_area": zero_area, "doubles": doubles}
+
+
+def shells(me):
+    neighbors = [[] for _ in range(len(me.vertices))]
+    for edge in me.edges:
+        a, b = edge.vertices
+        neighbors[a].append(b)
+        neighbors[b].append(a)
+    seen = [False] * len(me.vertices)
+    groups = []
+    for start in range(len(me.vertices)):
+        if seen[start]:
+            continue
+        seen[start] = True
+        stack = [start]
+        group = []
+        while stack:
+            cur = stack.pop()
+            group.append(cur)
+            for nxt in neighbors[cur]:
+                if not seen[nxt]:
+                    seen[nxt] = True
+                    stack.append(nxt)
+        groups.append(group)
+    return groups
+
+
+def zfight_pairs(me):
+    """Coplanar face pairs from *different shells* (copied from showcase/grindstone)."""
+    owner = {}
+    for si, g in enumerate(shells(me)):
+        for vi in g:
+            owner[vi] = si
+    faces = [(p.normal.copy(), p.center.copy(), owner.get(p.vertices[0], -1))
+             for p in me.polygons]
+    kd = KDTree(len(faces))
+    for i, (_n, c, _s) in enumerate(faces):
+        kd.insert(c, i)
+    kd.balance()
+    hits = 0
+    for i, (ni, ci, si) in enumerate(faces):
+        for _co, j, _d in kd.find_range(ci, COPLANAR_CENTRE_MAX):
+            if j <= i:
+                continue
+            nj, cj, sj = faces[j]
+            if si == sj:
+                continue
+            if abs(abs(ni.dot(nj)) - 1.0) > COPLANAR_NORMAL_EPS:
+                continue
+            if abs(ni.dot(cj - ci)) > COPLANAR_PLANE_EPS:
+                continue
+            hits += 1
+    return hits
+
+
+def classify(me):
+    mats = {}
+    for p in me.polygons:
+        for i in p.vertices:
+            mats.setdefault(i, p.material_index)
+    out = {"stave": [], "board": [], "lid": [], "hoop": [], "slat": [], "handle": [], "other": []}
+    for g in shells(me):
+        pts = [me.vertices[i].co.copy() for i in g]
+        lo = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts)))
+        hi = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts)))
+        rec = {"g": g, "pts": pts, "lo": lo, "hi": hi, "ext": hi - lo,
+               "c": sum(pts, Vector()) / len(pts)}
+        m = mats.get(g[0], -1)
+        e = rec["ext"]
+        if m == OAK_IDX:
+            if e.z > HEIGHT * 0.5:
+                out["stave"].append(rec)
+            elif lo.z > HEIGHT * 0.5:
+                out["lid"].append(rec)
+            else:
+                out["board"].append(rec)
+        elif m == MAPLE_IDX:
+            out["handle" if e.z > 0.3 else "slat"].append(rec)
+        elif m == IRON_IDX:
+            out["hoop"].append(rec)
+        else:
+            out["other"].append(rec)
+    return out
+
+
+def radial(p):
+    return math.hypot(p.x, p.y)
+
+
+def churn_audit(me):
+    parts = classify(me)
+    out = {k: len(v) for k, v in parts.items()}
+    staves = parts["stave"]
+    out["stave_z"] = max((r["lo"].z for r in staves), default=99.0)
+
+    # Bottom boards: count and the seams between neighbours.
+    boards = sorted(parts["board"], key=lambda r: r["c"].x)
+    seams = [b["lo"].x - a["hi"].x for a, b in zip(boards, boards[1:])]
+    out["seam"] = (min(seams, default=-99.0), max(seams, default=99.0))
+
+    # Hoops on the staves: every hoop vertex against the stave vertex it
+    # was built over, measured radially.
+    spts = [p for r in staves for p in r["pts"]]
+    kd = KDTree(len(spts))
+    for i, p in enumerate(spts):
+        kd.insert(p, i)
+    kd.balance()
+    bites, prouds = [], []
+    for h in parts["hoop"]:
+        ds = sorted(radial(kd.find(p)[0]) - radial(p) for p in h["pts"])
+        n_in = len(ds) // 3
+        bites.append(min(ds[-n_in:]))
+        prouds.append(-max(ds[:n_in]))
+    out["hoop_bite"] = (min(bites, default=-99.0), max(bites, default=99.0))
+    out["hoop_proud"] = min(prouds, default=-99.0)
+
+    # The lid rests on the stave tops; the handle clears the lid's hole.
+    top = max((r["hi"].z for r in staves), default=0.0)
+    lids = parts["lid"]
+    seats = [top - r["lo"].z for r in lids]
+    out["lid_seat"] = (min(seats, default=-99.0), max(seats, default=99.0))
+    out["hole_clear"] = -99.0
+    handle = parts["handle"][0] if parts["handle"] else None
+    if handle and lids:
+        lz0 = min(r["lo"].z for r in lids)
+        lz1 = max(r["hi"].z for r in lids)
+        hr = max((radial(p) for p in handle["pts"] if lz0 - 1e-6 <= p.z <= lz1 + 1e-6), default=99.0)
+        lr = min(radial(p) for r in lids for p in r["pts"])
+        out["hole_clear"] = lr - hr
+
+    # The handle's foot bites the slats.
+    slats = parts["slat"]
+    out["handle_bite"] = -99.0
+    if handle and slats:
+        out["handle_bite"] = max(r["hi"].z for r in slats) - handle["lo"].z
+
+    # Stroke: the inner wall read off the staves, ring by ring, against the
+    # plunger's reach about the handle's axis.
+    out["stroke"] = -99.0
+    if slats and staves:
+        reach = max(radial(p) for r in slats for p in r["pts"])
+        p_top = max(r["hi"].z for r in slats)
+        walls = {}
+        for p in spts:
+            z = round(p.z, 5)
+            walls[z] = min(walls.get(z, 99.0), radial(p))
+        table = sorted(walls.items())
+        need = reach + WALL_CLEAR
+        limit = min((r["lo"].z for r in lids), default=top)
+        z_ok = None
+        for (za, ra), (zb, rb) in zip(table, table[1:]):
+            if za < p_top - 1e-6:
+                continue
+            if rb >= need:
+                continue
+            if ra >= need:
+                z_ok = za + (zb - za) * (ra - need) / (ra - rb)
+            else:
+                z_ok = za
+            break
+        if z_ok is None:
+            z_ok = limit
+        out["reach"] = reach
+        out["stroke"] = min(z_ok, limit) - p_top
+    return out
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+    mesh = obj.data.copy()
+    lod = bpy.data.objects.new(name, mesh)
+    lod.matrix_world = obj.matrix_world.copy()
+    bpy.context.scene.collection.objects.link(lod)
+    if not skip_decimate and 0.0 < ratio < 1.0:
+        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+        mod.decimate_type = "COLLAPSE"
+        mod.ratio = ratio
+    return lod
+
+
+def hull_collider(obj, name):
+    """Compound collider: one hull for the staves and lid, one for the handle above it.
+
+    The hoops stand 5 mm proud of the staves and are left out: in the hull
+    they add triangles and nothing a character could collide with.
+    """
+    me = obj.data
+    parts = classify(me)
+    body = [p for k in ("stave", "lid") for r in parts[k] for p in r["pts"]]
+    body = [p for i, p in enumerate(body) if i % 3 == 0]
+    groups = [body]
+    if parts["handle"]:
+        top = max(p.z for r in parts["lid"] for p in r["pts"]) if parts["lid"] else HEIGHT
+        groups.append([p for p in parts["handle"][0]["pts"] if p.z >= top - 0.01])
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        for pts in groups:
+            tmp = bmesh.new()
+            try:
+                vs = [tmp.verts.new(p) for p in pts]
+                bmesh.ops.convex_hull(tmp, input=vs)
+                bmesh.ops.dissolve_limit(tmp, angle_limit=math.radians(4.0),
+                                         verts=list(tmp.verts), edges=list(tmp.edges))
+                bmesh.ops.triangulate(tmp, faces=list(tmp.faces))
+                remap = {}
+                for f in tmp.faces:
+                    for v in f.verts:
+                        if v not in remap:
+                            remap[v] = bm.verts.new(v.co)
+                    bm.faces.new([remap[v] for v in f.verts])
+            finally:
+                tmp.free()
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    col = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(col)
+    return col
+
+
+def setup_bake_image(obj, target_mat, size):
+    img = bpy.data.images.new("ChurnNrm", size, size, alpha=True, float_buffer=False)
+    img.colorspace_settings.name = "Non-Color"
+    nodes = target_mat.node_tree.nodes
+    tex = nodes.new("ShaderNodeTexImage")
+    tex.image = img
+    nodes.active = tex
+    tex.select = True
+    obj.active_material_index = OAK_IDX
+    return img, tex
+
+
+def bake_normal(high, low):
+    scene = bpy.context.scene
+    scene.render.engine = "CYCLES"
+    scene.cycles.device = "CPU"
+    scene.cycles.samples = 1
+    scene.cycles.use_denoising = False
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    high.select_set(True)
+    low.select_set(True)
+    bpy.context.view_layer.objects.active = low
+    return bpy.ops.object.bake(
+        type="NORMAL", use_selected_to_active=True, cage_extrusion=CAGE_EXTRUSION,
+        use_cage=False, normal_space="TANGENT", margin=4,
+        margin_type="ADJACENT_FACES", use_clear=True, target="IMAGE_TEXTURES",
+    )
+
+
+def export_unity(path, objects):
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    for ob in objects:
+        ob.select_set(True)
+    bpy.context.view_layer.objects.active = objects[0]
+    bpy.ops.export_scene.gltf(
+        filepath=path, use_selection=True, export_yup=True, export_apply=True,
+        export_draco_mesh_compression_enable=False, export_animations=False,
+    )
+
+
+def check(skip_decimate, lift_z=False, **flags):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    nothing = (None,) * 5
+    low = build_churn_mesh("ChurnLow", **flags)
+    hi_flags = {k: v for k, v in flags.items() if k != "stray_vert"}
+    high = build_churn_mesh("ChurnHigh", **hi_flags)
+    mats = churn_materials()
+    assign_slots(low, mats)
+    assign_slots(high, mats)
+    if lift_z:
+        for v in low.data.vertices:
+            v.co.z += LIFT_Z
+        low.data.update()
+        bpy.context.view_layer.update()
+    if len(low.data.polygons) < 6 or not low.data.uv_layers:
+        return (fail("churn mesh did not build, or has no UV layer", 3),) + nothing
+
+    base_tris = triangle_count(low.data)
+    slots = [s for s in low.data.materials if s is not None]
+    nmat, distinct = len(slots), len({id(s) for s in slots})
+    idx_counts = {}
+    for poly in low.data.polygons:
+        idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1
+    u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data)
+    bb = world_bbox(low)
+    size_x, size_y, size_z = bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2]
+
+    img, tex = setup_bake_image(low, mats[OAK_IDX], BAKE_RES)
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "ChurnLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "ChurnLOD2", LOD2_TARGET, skip_decimate)
+    bpy.context.view_layer.update()
+    lod1_tris = evaluated_triangle_count(lod1)
+    lod2_tris = evaluated_triangle_count(lod2)
+    r1 = lod1_tris / base_tris if base_tris else 0.0
+    r2 = lod2_tris / base_tris if base_tris else 0.0
+
+    collider = hull_collider(high, "ChurnCollider")
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(tempfile.gettempdir(), f"bdt_churn_{os.getpid()}.glb")
+    if os.path.exists(export_path):
+        os.remove(export_path)
+    export_unity(export_path, [low, collider])
+    export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+    # Blender points TMPDIR at its own temp preference, which on a portable
+    # build is the working directory, so the export must not outlive this.
+    if os.path.isfile(export_path):
+        os.remove(export_path)
+
+    hyg = hygiene_audit(low.data)
+    zf = zfight_pairs(low.data)
+    ca = churn_audit(low.data)
+
+    print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
+    print(f"measured mat_index_counts={dict(sorted(idx_counts.items()))}")
+    print(f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+          f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}")
+    print(f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+          f"overlap={overlap:.6f} nfaces={nfaces}")
+    print(f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+          f"outer={OUTER_SIZE} zmin={bb[2]:.5f}")
+    print(f"measured collider_tris={col_tris} bake={bake_result} "
+          f"bake_has_data={img.has_data} export_bytes={export_size}")
+    print(f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} "
+          f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} "
+          f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}")
+    print(f"measured parts staves={ca['stave']} boards={ca['board']} lid={ca['lid']} "
+          f"hoops={ca['hoop']} slats={ca['slat']} handle={ca['handle']} other={ca['other']} "
+          f"stave_z={ca['stave_z']:.5f}")
+    print(f"measured joints seam=({ca['seam'][0]:.5f},{ca['seam'][1]:.5f}) "
+          f"handle_bite={ca['handle_bite']:.5f}")
+    print(f"measured seats hoop_bite=({ca['hoop_bite'][0]:.5f},{ca['hoop_bite'][1]:.5f}) "
+          f"hoop_proud={ca['hoop_proud']:.5f} lid=({ca['lid_seat'][0]:.5f},{ca['lid_seat'][1]:.5f}) "
+          f"hole_clear={ca['hole_clear']:.5f}")
+    print(f"measured stroke={ca['stroke']:.5f} reach={ca.get('reach', 0.0):.5f}")
+
+    if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+        return (fail(f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", 4),) + nothing
+    if nmat != MATERIAL_COUNT or distinct != MATERIAL_COUNT:
+        return (fail(f"material slots {nmat} distinct {distinct} != {MATERIAL_COUNT}", 5),) + nothing
+    for idx, floor in FACE_FLOORS.items():
+        if idx_counts.get(idx, 0) < floor:
+            return (fail(f"material {idx} faces {idx_counts.get(idx, 0)} < {floor}", 5),) + nothing
+    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+        return (fail(f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", 6),) + nothing
+    if overlap > UV_OVERLAP_MAX:
+        return (fail(f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7),) + nothing
+    if (abs(size_x - OUTER_SIZE[0]) > BBOX_TOL or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+            or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL):
+        return (fail(f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) off outer {OUTER_SIZE}", 8),) + nothing
+    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+        return (fail(f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+                     "(--skip-decimate is the designed fail)", 9),) + nothing
+    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+        return (fail(f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", 9),) + nothing
+    if col_tris > COLLIDER_TRIS_MAX:
+        return (fail(f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", 11),) + nothing
+    if bake_result != {"FINISHED"} or not img.has_data:
+        return (fail(f"bake failed result={bake_result} has_data={img.has_data}", 12),) + nothing
+    if export_size <= 0:
+        return (fail("export file missing or empty", 13),) + nothing
+    if (hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"]
+            or hyg["doubles"] or hyg["ngons"] or zf):
+        return (fail(f"hygiene {hyg} zfight={zf} (--stray-vert is the designed fail)", 15),) + nothing
+    if abs(bb[2]) > ZMIN_EPS:
+        return (fail(f"zmin {bb[2]:.6f} not within {ZMIN_EPS} of 0 "
+                     "(--lift-z is the designed fail)", 16),) + nothing
+    if ca["stave"] != N_STAVES or ca["stave_z"] > STAVE_Z_MAX:
+        return (fail(f"staves: {ca['stave']} of {N_STAVES}, worst stave z={ca['stave_z']:.5f} "
+                     f"> {STAVE_Z_MAX} (--short-stave is the designed fail)", 16),) + nothing
+    if (ca["board"] != N_BOARDS or ca["seam"][0] < BOARD_SEAM_MIN
+            or ca["seam"][1] > BOARD_SEAM_MAX):
+        return (fail(f"{ca['board']} of {N_BOARDS} bottom boards, seams {ca['seam']} outside "
+                     f"[{BOARD_SEAM_MIN}, {BOARD_SEAM_MAX}] (--one-piece-bottom is the designed fail)",
+                     17),) + nothing
+    if not (HANDLE_BITE_MIN <= ca["handle_bite"] <= HANDLE_BITE_MAX):
+        return (fail(f"handle bite {ca['handle_bite']:.5f} outside [{HANDLE_BITE_MIN}, "
+                     f"{HANDLE_BITE_MAX}] (--short-handle is the designed fail)", 17),) + nothing
+    if (ca["hoop"] != len(HOOP_ZS) or ca["hoop_bite"][0] < HOOP_BITE_MIN
+            or ca["hoop_bite"][1] > HOOP_BITE_MAX or ca["hoop_proud"] < HOOP_PROUD_MIN):
+        return (fail(f"{ca['hoop']} of {len(HOOP_ZS)} hoops, bite {ca['hoop_bite']} outside "
+                     f"[{HOOP_BITE_MIN}, {HOOP_BITE_MAX}] or proud {ca['hoop_proud']:.5f} < "
+                     f"{HOOP_PROUD_MIN} (--float-hoops is the designed fail)", 18),) + nothing
+    if (ca["lid"] != 2 or ca["lid_seat"][0] < LID_SEAT_MIN or ca["lid_seat"][1] > LID_SEAT_MAX):
+        return (fail(f"{ca['lid']} of 2 lid boards, seat {ca['lid_seat']} outside "
+                     f"[{LID_SEAT_MIN}, {LID_SEAT_MAX}] (--float-lid is the designed fail)", 18),) + nothing
+    if not (HOLE_CLEAR_MIN <= ca["hole_clear"] <= HOLE_CLEAR_MAX):
+        return (fail(f"handle clears the lid hole by {ca['hole_clear']:.5f}, outside "
+                     f"[{HOLE_CLEAR_MIN}, {HOLE_CLEAR_MAX}] (--tight-hole is the designed fail)",
+                     18),) + nothing
+    if ca["stroke"] < STROKE_MIN:
+        return (fail(f"plunge stroke {ca['stroke']:.5f} < {STROKE_MIN} "
+                     "(--wide-dasher is the designed fail)", 20),) + nothing
+    return 0, low, high, mats, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], nt.nodes["Principled BSDF"].inputs["Normal"])
+
+
+def render_still(low, mats, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(mats[OAK_IDX], tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+    # Level on the floor: turned about Z only.
+    low.rotation_euler.z = math.radians(10.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=60.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Floor")
+    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, 8.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
+
+    def light(name, kind, loc, energy, size, col, rot=(0, 0, 0)):
+        ld = bpy.data.lights.new(name, kind)
+        ld.energy = energy
+        if kind == "AREA":
+            ld.size = size
+        else:
+            ld.shadow_soft_size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", "AREA", (-2.4, -3.2, 3.2), 360.0, 3.0, (1.0, 0.95, 0.88), (50, 0, -35))
+    light("Fill", "AREA", (3.2, -2.6, 1.6), 60.0, 5.0, (0.74, 0.84, 1.0), (68, 0, 50))
+    light("Rim", "AREA", (-1.6, 2.6, 2.4), 220.0, 3.0, (0.62, 0.78, 1.0), (-55, 0, 200))
+    ld = bpy.data.lights.new("Wedge", "SPOT")
+    ld.energy, ld.color = 220.0, (1.0, 0.66, 0.34)
+    ld.spot_size, ld.spot_blend, ld.shadow_soft_size = math.radians(50.0), 1.0, 0.3
+    wedge = bpy.data.objects.new("Wedge", ld)
+    wedge.location = (0.5, 1.6, 2.0)
+    wedge.rotation_euler = (Vector((0.25, 0.5, 0.0)) - wedge.location).to_track_quat(
+        "-Z", "Y").to_euler()
+    scene.collection.objects.link(wedge)
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (1.23, -2.86, 1.28)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.52)
+    scene.collection.objects.link(aim)
+    con = cam.constraints.new("TRACK_TO")
+    con.target = aim
+    con.track_axis = "TRACK_NEGATIVE_Z"
+    con.up_axis = "UP_Y"
+    scene.camera = cam
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 32
+        scene.cycles.device = "CPU"
+    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 = "WEBP" if path.lower().endswith(".webp") else "PNG"
+    if path.lower().endswith(".webp"):
+        scene.render.image_settings.quality = 90
+    scene.render.filepath = path
+    scene.view_settings.view_transform = "Standard"
+
+    fcode = gallery_framing.check_framing(scene, cam, hero=[low], elements=[low], 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):
+        return fail("render produced no file", 14)
+    return 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None)
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+    p.add_argument("--skip-decimate", action="store_true")
+    p.add_argument("--stray-vert", action="store_true")
+    p.add_argument("--lift-z", action="store_true")
+    p.add_argument("--short-stave", action="store_true")
+    p.add_argument("--one-piece-bottom", action="store_true")
+    p.add_argument("--short-handle", action="store_true")
+    p.add_argument("--float-hoops", action="store_true")
+    p.add_argument("--float-lid", action="store_true")
+    p.add_argument("--tight-hole", action="store_true")
+    p.add_argument("--wide-dasher", action="store_true")
+    args = p.parse_args(argv)
+
+    code, low, _high, mats, tex, _col = check(
+        args.skip_decimate,
+        lift_z=args.lift_z,
+        stray_vert=args.stray_vert,
+        short_stave=args.short_stave,
+        one_piece_bottom=args.one_piece_bottom,
+        short_handle=args.short_handle,
+        float_hoops=args.float_hoops,
+        float_lid=args.float_lid,
+        tight_hole=args.tight_hole,
+        wide_dasher=args.wide_dasher,
+    )
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(low, mats, tex, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    print("butter churn OK")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as e:
+        traceback.print_exc()
+        print(f"FATAL: {e}", file=sys.stderr)
+        sys.exit(1)
+
+
+
+ + + A tall staved oak butter churn narrowing toward the top, bound by three dark iron hoops, with a two-board lid and a pale maple dasher handle rising through it. + +
+
+ generated from showcase/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/docs/gallery/contact-sheets/butter-churn-contact-sheet.webp b/docs/gallery/contact-sheets/butter-churn-contact-sheet.webp new file mode 100644 index 0000000000000000000000000000000000000000..e82bb8a7b908bc5cf8dac2eb8fa2df179d93af31 GIT binary patch literal 51388 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%0DV5=E1dQpC|z%U!Hy!+s#$^UykmmI zUFfJ1!df8>fo!anwEPm#(cFF%qf6e)RPVwTSA2>2ci&V^zJU69zYr4{x7CbJmP?x2 z{kAtYbHzMD)rB-(9d(O)p*V!G2W)QXJ3|Xgb2g*d==!~xfts6&W|$>4E~mOG0>AGKyAK_l8N>$_4~!pK#e=Xow`?w4Gi9#DI%uijpoS?`C9^=O z3utgny&P2nyRy5&;jz1(uO`PAhbxE@$>f#3uf9QvHtbM*!?7hx zXZ`db&*Gf<+lK-^#dO|Xo6qC+!{OgaHSn>Mi3@kN^gd@qTYZk}QOgZcJOas+S`$;2Rm4;HeM|h_) ze^9QZp(3B*;F8?)swaYgt!bb=+pk(B@#7yOGE~rBw=@c+Im^V$LPk{sgD8Tq6e}JE zVCTj4G1Um~j~Sd?D%28l$>zY1$tZga_8T~gx}S1)h}7n)aDmZBbUvJ7Qfw3WaPI}? zPe+sc(wexO>MwuVI1S=AR|*N-zFUSb-d7&;aS`SKB+1^vaiSL@0~=MzZ2prS3Oyv# z2T*KU&G#^8ty_aK1PaR-bFDBa;V!E4GKSG2hL#00tjC~s61;#W79UM2(Z^XHkzoUq2@6 z`hzx@!kW~>T!W5S^V4h|;ru=ZyEZAEPtZKLiMZHr&)>hG7dWb>(2`3`2)h8O#8U&D zy_?2u4CrPd>fp&vdDZ5mrYb56K#+u06WnA3iM1ltZVt8PPssq|DROx$@k0gs zb%I~2?SuE*CvF#+DmMX)%sabn)C~&mKYCLh)eHv(=P2(A^x^h30SSt!o^x@VI@c~O zA0nfBP_G%Sb*Ppw+%axw&e4eEbpzd+hK^0%$v)Nf`drrX&S@Laq>#L`OEStr08Pq6 z*VH;$z>zf$;Bo*lO{!dbn&5=5SJ9>rljd^$eDICoB)0YUbQkc5lB)2$d=dN{*Z36o zTNfk0Og5g&d&{Pl;tXB2+)*RTU!-=BiUd+|g zIY##PJ#1XY6XMA%A8z0)VmhLFx#uLpZhrNB25-a^nRPBewH+ zjaDpqa_fQeHefR~XWYdE?#Q(p@vJ4KMDuXlT#vQeTIc0Ri52N0}7OkG{`^$CmFzYX6n_0Y=f=T32x8EsG2< z{0a!QW6oahD3bmpxy8A_KN`O29i<``kU%5H-s3-`^=@DvFMo@bG^?x{x3<9^{v(O6 z6Lf8CEx3n9P^`Z3!CZ<&XDRbH&Pjl`$heP=`I&dN34FFrlQRRDAo!8Z`o0|<$77D+ zn&*vXBnmU!*PO3$bu(IktFfTC@TvX}!uGN@uRaJ`W9wLSNru|PuVrk#`)HieUJdJ+ zG0ZCyZ-*gs0w2u-m4{mxNjn35XnyxXOlJ?5y)tl#ydzCK-@)eav(ztFpi-!`W3a~# z=y|1}x$ewv1-C!Ra{Q~Zv48x2=y@D$K;!fw9k|aS@~n4{RskX<=hfz#fRe0+bs~&& zL!9rW>NHB}0dqo_Hn8JC-T?g70u5Jqq%pok7La0esv`3e8OL)G(7;?5kWhv%aAD@? zhJUd$koBH>gEYE#i!$uHcYi_e)8!z#Fggn+t3xmKmr5}dV${cK|H}6)^X9j~b2nDk_Jl$ZNwuX<25m<-b-2Gs1)WHQc|xYe8hQ9c3%kB|*BqAq+nlifGsYM22N_YNIPXNoboyx+c+EdA;SpVn@#M-4ih ztL;tR%$7~re~C*$Czbn6L<0L9vH2ZZxmwn9$7>vr1Z7Qg)J38tnD*#asp; z@EkT|p3pA7A{D&9#YmLnFm=hgWO8oB^2!vww}1!yGC;i9DopeM`s1^{S&IMV7h18I z0ZVoQrT|kq<+-3N#Qq|!JkuKmM7`RW4yN}15x_# zt%(`4{mvs{KmX>W%o!I~Q98_}ZE8B?5l1Tqnu%YqjYm8LAqLwEp%#$p#&xkWK604R zt+6_1uqPUCTBz-2xBT<0i|y3NknHhb(AY0R*V?cg(9S)pL2YJN+k>7GRp}&5;-WJX z9cdZ?zsW!v_XA$8`HKnm+PS2$V^oo{5Xpqx<*L%w zvA;5FM4g*m>N}n^h!`{a#WbA#c>_JLEPY;90NNHj~W&Qcnp{NLQw582y_LkK;ok zPN+kIFyU`jFX5TCPOfw$L}4CNBUZ1Qn1`J-<$V>_jDEl`F%s&2K2Zp?IqFhiAjaUU zUCVU@zY)0X80{8|`s5ocUT{l&?ebi-4)HjWG9i^&(;B6_AP?)OAl4l`yCPmL;KT_?0Hg zJ5}E_rX#rn2BAGroqYauC6WK;heqxhzD%d+#Rg8ic!A}VZ=TXMn_kjBi7gzNU^>oq z!5E5fLfwL>xe9t33(g}s93$-e^MriydHFkkp%E=$^VC*i{UwvW?yRmsW3cI6X{Cz> zLFxf_2k|{TY9l-FCajHo@lrQiqo8X-n>BIedT33Y(eJM?4`$5iRQXJen}hs$LEd_I zJLmEiYnPY}oit@52Htr~*)&WinCQYe)cZMkw zX66t5&jmp!3p1)v?d1m(|65L)w)F~A6@yL`xBR?wqndn21XI~PWKhjPCuTFW-6Z4L z-~zH_HL<49KRcN9U`i2Y^r?cQLth=g<2`3Aobajks^t%q@!y&UUc{?v?q8vH5FAd# zP^wAO2T8Bb`YV(cRoaR zcD&`vF+ulOJBHKnEw2~>A}OO_i+|E3g`>*7e#i0U72{C`ikQWmTbOkxj^~bvpt`u z(=&N9^TtSFSGB8+h_)>Q<;CY4Yz1<=6g9juk-X77)&^+oz|nC8V5RrjdYs5$NbYw| z@AxDZKe9K!Pya0}32XuE8(urq2*3mPliZ;Ax2O7`=l@lAKdDouIVEg0bdZpm!4kMf z_T<*~pGeh)Zaz+i7?iUWZrGAuHIQD0RB%wdQ1S{PTB4KN<7ov_0l-<|;tCsNz=&G? zW$abgsZnb&{RYtzKi}pwn*@A-mtc}8=YL#Qr;k^5XhV6)X6&?DIq(1OVD|fXF*eG_ zj-FXZJJ3gb8U(v?rPP%8C~YKf&25om`bOye_65!N z-m-9n?D_xr7`Q;A(hF06k((yDduPfp75%y;l`G@e0>MV#kc?{y$QTefzq|TODw9n8 zz;3QRI`WxV`<1YI?u7vaaLJw0@Rj(N31BI{eOH~Y7X&(CLr*f=)B}V?puvD zcpo4!)U?a7$qFp-#$mqs_(XEJeCgk-c`{5;i*a@0)N#D}p8$Ab@WB&Q6GXr#L?iuE zit(N?R?xQs1E)V0XDhfKJzL!hNXo0JMh>=Tt-6+Mm5uTf#%@{_?Jgf}mAWcAk+Urm z6jOR|^O~soHy82}z&p^Wzq=7AOroTm4`u(KWsF!l`{>YrsUf5Fh2G%cu{Js=(UoG+`r`Wz&wY`g4 z3k=;dmy5hcJ&F+^OZ-k!EyUdq0QlMclusU+es&ps@<`M&eVX3-=A737J}S?y8MB#x zz9Ra`9Ia2Z7XKw7B|L_PNkSUc55M(iy($SflV7hDSb^O+Se zs>7uLN6r=%)eR0gPg?-vV`I{l6dC8jBl?V6S6Zvc&!vNIg3I8vb`2yH?vJmEBQRNP z<2IdlXm$gu18RHL&_p8E`%)jFSNnn_v-+c6d0=c}HGq^)JlbJcHnY&3Ql-85%aV8# zz`mTy01>W$@lVx9w8DgBC9L#6bYCx$7lxmqOxIzsgoPBGIlquu$UXwVC}~rvYqLmP z2rvsG>+HpdiC=(z(-KNm#}SP91fe@ctAfKDj_=vg%Ld2!bHWLWB;)iFv;LsO14lE? zJt`hE4T0&K&yA0|I%P7L%?nTZ@DE49Ul(_QWlVs`%goi;*`MmW$uF{uy!#CcZ3M!8Zr-YSe>T_gn}wQ zh(-PaA{e`j{E`u+iu6E^JvDDoZclIgVnr9&KxikyzGVcMhis%oDc+YD2+NEn2R5PQ z4;$^zAv;Nm=wE`etY|TB>L7QI&1F zoPK!XA@ExM5|bI%Nu?0h%ij!XFIXODe0)jzcJxVOsd<)!-+8dO?;oi(h!)fRX)*4SptoB>l|EZjvKX`o#;h zPxuZ-L5MP=UBTCC?m8EEBtj3n&o>lkPgNvHf#f3-zbZqlyV$5uM9KnoWeNm$ZhDVc zuO6@O*&xT%n=h*p;pz>!4;PDfo^&>GoSqNY;N-gk#@AN-mYypS%i8w17fxm6$8(=d zP3GSRHG#4L!_d?NGWNKSs9G*iG%@A5oFbF>@eB;g(!{!1VqeJzLzyb+lD`jncv|Uty2{9D@AB5!XoNIx3~~eEM9D0G z1BOzU&I)_oC=!-{RoD@85a1ODrqm#(x`oMVCK$;z9vD88*Ic7_z2Mq^!}M0BMUW0| z2tw6U8C?SC;$Rt}&U9dSwl9^lAgF(9MRvb3vd^ zMN(d^VglKgQdc~Udesw=Hj9i%+5ibXPK9{$ir+#gVXn99lALO5FUx(~4N8LLDJrM$ zSB4Sw{fkKd01ZcyH3<0|DQN~esq)Syx-)C~=~Wy-OJo!#0CeE1wr}7~ElU(I^Y81Z zjiGaJG12G!U>yFO z;dH%9bs)+(s!aCItjZhj*VGT6G2BZuILjThd4XR;Ry2~9FtP9V@VU3I!uABUh0pOW zog@|=5xov|ahFJ>mud#G`S%^*E&=(MBVl8Aly2{W@sU^37%|+gKlGG#mH+A+U^?;P zkhL2eJ3=~cqgh8+hziqzCO6Ts&}Q&DL=5;8_@UMLjk~!j=nIW7;lOOM0VQ_or`8z3 zP+jL71+MP(qIJCDSlo|FdRxOjpP-Isj>Fi0Eu#UHBc0ijQCGCS!wUyj4EIgbaqAO` zea<5FF#=;O3!@6;!<^RJU|sv@+!Joeu~ASZK<%(?;NS$AQ}wA-s_D1)ap{B-XvNP% z>JAFCrUd9V&4ln2pk~tgJDd)LopdXqp(fMFFLmF7dY0jGSmGFct#FrDCvE^lW#W*8 zj9KA=$g;y#M7pG!9v!abal7Tvo!UALyMv7w=#I$0Ai;STtkM9!xW47%1YsvUlHJkh=Bk@ORa05{D|sL z1R3QhasfyUj!1;N=5TSgIop7=0KVGjez=i5$Rypxf|JVH_dPof zs-&BLtsk4lEh<|hSlP?5vEky^m;-R>26OKV^MVj>JT`@{IX{Pt=*)7K?^LbjRVZdAWKhuhq^f}7C$Eimo` zkqq~bXn=4Vu)e%QL9z39{&MYgqg>qKG)hfN6qPXhXB0cBdnt(7eWg{hVhW^(5ez_> zI1kJ$o)@??M>(|%ezDDp!;9WTe>X?Nj2LSf+L?M9b=vq0A1?8t&08?$=2YCt2`R}3 zR+>`n_e*VfB|mx|r()s!g{2XPssv59*~6{+wJ;DzO#wCFHA5oFmm3CClQs}iGZ|Ny z#qR-v3;2Fz!6R&fGFXIs!n*y=R~c~+u~@8Vtg|Pw@1wUm^XS65aDnq#p;~~OVaO_= z^g|jL3HrFwMg8o2t2 zPFJN!jn`m(iHIl#Kj$1%79{kmrEVt@k*VqEe66smAJ`5ioDPkUnN2es4Luu%R`&$` z!}RsK#(J-!=PGlVK;SXRdba@C6#dvz$C(Dp>O19AoQ1Tfd24a!U3BR&a-0nDaBy&R zqDYCOW4g4d?-D7MB4mY(63#G3DjVIqEnviGcFr>1JpDeVO@+b%>6kmjR$4;hYF)UDZY#;DT~aK<63Y9 z!Z}dTW9iJLIMwrfqgMaa?0l-#3a1;CqE^vXNztBRr&V9NE z$B^vMmSsE7C;_Us2@-Je=Z&TRZw>XJ#XfT@r3b0oMx+PN64pP>Ex6YZPk_C+!})aS zaaO;Ck1%*#1ia3!8F|i+D{jj$MD{+or$%6_mp%Mox`}`Q5m)Fpx=d>;nLzPG$UPh4 zbo}(J4LQJiybR|oP$IS$Vl#ecN6zVJ0vBPgL4B@_rTC5&4LD2hlynn`0Ij!E`7G7A zP>~>s(6}L7=}$h%-pYzH;6#5yek*=mfc4gl;&wL4s5D4yOVd3(xgIve`pzT zV|x0C8w-*6Ns~6Ly*ZnrYi~v}N{D5Fd1+cQgWk$K;1qc(8)8bv1R`Npil&wK54U$P zsQn+WJ>atBY7?h(YIsN-=|g8v0C2ABn|r_WZQk*%B7n~X5V(NI+Q_(aOb=0|J~n66 zyE^%C7(y>z1%4xZ^Au5^l1SJF!dg>BGo=yjot!gHqe}LP1mO{S+d=tg+uY^ktI>m` zn1l*VjFJ?4@lT2gce3QX>tp0Z3>5tpWCFpQmWLPio}&rr#m8MF9$Zn#qv_Jn_L?K$ zS5@Ayu~YliAu-4X3P0YoYt=Ze(IjTXdhOdqXq(w*2BFx0)K zl(l&Y=IdAV7HUcrkhQ!E zc5ouI+mlsKryi3F9mE(}kM6D+KlD|ryLkSkYkw^*!S|ShnX2f9epMR034K6J?~zJH zc@q_rJXO^SKYz$&kwsgHWv8d_kFtctH6P=?&RT5-hCF~nH!m$|>s$g`bvx8Ic-8>Q z0+3bNsqm*(A`u^&W;l|L1!0 zD*pdVpEBp(-?T&#jb3bQkQ`B8`7QjcGyM}fV9z?J$^6hlrPxIa6s*9(6;O}V)*{Zg zvE~7qe?q>I81A%;u1$abc`AbV+>HbvXB7jz3wIj3TV-a22@U{-_l*-TGlFB|lYmhl zU{D|v0Mvcxc12qlfz)wxK`PEO!SGzRy+)IfpQ-u)_`Rht)B$u*Nl__@UD5Ju9oDxK zR1co7W>^Y`iG>dXyIFiKJ+g&?Rrczh#J+NcX4Qe(@#TnE%s&^5~!tmqqfs& zf&2hJB9>^db0uAmL!uM^-}u%Yl)_tWf17V&)fv7=WV@)_SEO%6P1TRqv)dyk4Y~sm zuCdKfR{|a-Ed3gzfr1}YQkj{(cz#Y-~OYk(xAZv}#6#tw75b}8M~giJyq68D(y z6W%Z)oc5Mbd!r&~;wKT02$b?X2N|`a(#J6!!Eg3mx5sabs8~m;d;M;wd^>5a1+fZA z@K~LejtBPX5*rKsHRG>&xMo5W+m+5lOumVGYEhwQ9`yzBY{@t$g&Xch*x&G|&is zGjF|h%)Yc*3u8!Z#k62Jvq(@#k<<(Zt`uXRie-xyBcR(Hgfgs`$LT6Yl=$n?5ap7p zr?$&z4$6;^eZH}2y^Yl(;cSj0ZMU>qZ_Fx}-9Nk+%}PU1g{6HHEeAikWN1L6vnLJT zADH_^Ot>e{Ww$}w)Zfm2)aB3U%uXk;_UY#%Q`SLMM{RqJu6(7{|G_mrgldEaFz?Rn zw7VH_b_SEBC8#r}9g_w+pW{WJ%y!~A$kZ>QX6VEsFmTKA z(;RR3jpSs^Ri5=FTKGX&+?m7+0F}3TEk*I8mgWs84y!7_c`+E=LyBuv#A^2l>rmjk zHN$yFZZDahOHh3MwcGKeM~T0~I`E(h9(|s%fB0u!6ahodxoY<7DXDK6aum+)?RsIJ zUQ@V>zO^p@>T!RQyBgm;SapmChWQup3qZ*w;CCpCGCKh&29UQ&wXdNPVsIJt$Y0^x zBUtD5qW{QABHjKL3VS0_eW2jv$jMxW>sD0s6%T1k#mg?9zgo{|dmCSCeww>owk3Ql zZ{d&aCd(YYsDsY4wHLlBED|n9Dt(ntBy8DDKuuM73+-Rldq@?2)Dh_pdbB7YaebV} zVK=1HIrhGy4F_hCU~gZiF8?ut0rdw8_!m)kIOGA7^8-o{rpJV;0(H(|FOR?oiS>un zEsTjh8QB~z4kvZAoM^kjc)~tZXr%1#9xnzOk4V8uQ(5JhC*CUhepSx{fY3pUoDZ^8 z{ici-cmMzZ00000L4Q^iJ*GxkNniGd-qF%VAfh2tIEotq`=6tH(`|}{`DixdMbmJ` z26=2H%lvj>LNw4f~AN>QNg#I z#!D|^(5uv5>~JoC42O|S7F{@V@LKYkI|4fvf0)3g*p+`)^~-~GHvw$qvD!Q7Sh++9 zpp?Wb=S;O5)_@3qrNZm9uTg$k8V|YzXJlZ*MO{=b4u<&`I{ty#aUvD~4~Sw=gFf`>T_uE3qREND z>fcWm9`8g@4G+LxcX#yKwN`Bw2YPHwDBE#wy#zdb4gWK$d|M3E)Vd0TI1XL<+ z35@{zlAX$GtR+Id!80#WCG|4@`rUm1P(pMrK^j;s0|2N$mSOaRwRvNso~_gGLF}KC z+f`nC8I@Tcj=k0H#>()LTUwYv00000000BWpi(|Lyfsh3i+Ym7EkO3m+7|2I*9150 z$)UXtt6tu{1tUXmR3}?@LZpP13|gH&-SNs$+gtS^Xjbv(yqP~^gi4AVT-12)${4M| zRHq<*hEazx#RK-eqT_z+W|FrYYq6}i-qpv7Scm=;`wzT5I}B#Vi5N0ZrE9bENV+6S zjKCf>{JB|omjSfhdo9!6%JJA4^_mTk%V1=xZvJl4YfJDI8W;RmV=}S(st+A^2BU#D zm7HmYbG~vub$P z+~FriLUO&}r?Sq64STcv8L}=$Hr0FDa3K4}x?M^`0p;HK|}2 z;nW*(PO->J#r&l)MX0QEVXXf3*I=ONvigBuq1OZSIu+OVADiam|B=maU~3>m+(YMn zHwa*^$gPg{7`tod;!adMe7z47i^0ko5OW@>j1y36`vEuQ1L=PiQ;f;sEO(uAOQ`ER zMn6pAYwiZHsu=GXK7uTQ%?vYkmobi5P%0MH$ihZ_D)<$%2GhqeRg*;E2LAPw%Y^!= z(xB&rpi`jry5q{eO|oZ(#bosHs(7^5Pld>$S~L z1-HKG$k1wfEKl#Ga+e=>KaY2#9NT<3@I*j|Go%krCS_*?3c6jVJ~xUB{ro~>9vYno zfO6i1(y0Hl(?|W=_e0#=nt(nk5b8%}V%o|KUV6{ttCH9ME0_N@ z1a9z}-)$9w;1-~B$L4SGjrX#{3v?D`@?R8S%NffC6$mA>*iTyu3|U;b=wzt$nZahb z0000000000q&Ta)y?m%l_{#P_<^zMvq)ayZaOD`cNFyIWZuBx^PKLSG4~;+v1M^f1dRd)LRWTY9!Ela5F_}1&}I5uV{Obhp#MPRrEt-o(t z8_U^koN_uOT~?alrEaRw^Q>`B>d5gT>M@W5MV`iIBhRq~^5ybh4^2$#)WXlF|W4iCH0*DKlcC&`R6jLOq35QmhFVE_*_cf==fh zYcGCS967sL?&ap&zl%V_6_D9f4zSbxFs_l;S?tpBx0ov5CbUxD6x@IS0000000000 l0000000000000000000000000000000000000000001_&EJ^?X literal 0 HcmV?d00001 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index a7cb210c..48ed8007 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -291,7 +291,7 @@

Examples and Showcase

autocomplete="off" spellcheck="false" aria-label="Search examples and showcase pieces" /> - 51 examples, 33 showcase pieces + 51 examples, 34 showcase pieces
@@ -1265,6 +1265,17 @@

wooden-yoke

View showcase piece wooden-yoke
+
+ +
+

butter-churn

+

A procedural plunge butter churn — sixteen tapered oak staves, three iron hoops, a three-board bottom, a two-board lid and a maple cross dasher — carried through UVs, bake, LOD, compound collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 5684 tris, three materials with 2240 oak, 160 maple and 576 iron faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.340×0.340×1.050 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, all sixteen staves on the floor, three bottom boards with 1.2 mm seams, the handle 14 mm into the slats, three hoops 2.5 mm into the staves and 4.9 mm proud, the lid 1.4–2.0 mm onto the stave tops, 5 mm of clearance round the handle, and a 514 mm plunge stroke for a 101 mm reach. --wide-dasher exits 20 at 140 mm while the plunger still clears the staves at rest; --tight-hole exits 18.

+ View showcase piece butter-churn +
+
@@ -1294,7 +1305,7 @@

wooden-yoke

var tagsToggle = document.getElementById('tagsToggle'); var toTop = document.getElementById('toTop'); var total = cards.length; - var COUNT_LABEL = '51 examples, 33 showcase pieces'; + var COUNT_LABEL = '51 examples, 34 showcase pieces'; var LS_KEY = 'bdt-gallery-density'; var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; diff --git a/showcase/butter-churn/README.md b/showcase/butter-churn/README.md new file mode 100644 index 00000000..4d3ba826 --- /dev/null +++ b/showcase/butter-churn/README.md @@ -0,0 +1,238 @@ +# butter-churn + +![A tall staved oak butter churn narrowing toward the top, bound by three dark iron hoops, with a two-board lid and a pale maple dasher handle rising through it](preview.webp) + +A plunge butter churn: a tall body of sixteen oak staves that narrows +toward the top, three forged iron hoops, a bottom of three boards set in +a croze, a lid of two boards with a hole for the handle, and a maple +cross dasher whose handle runs up through the lid. **A showcase piece, +not an example** — it witnesses no API contract. It asserts that +generated geometry meets declared asset budgets, recomputed from the +finished mesh. + +## What it composes + +| Shipped content | Used for | +| --- | --- | +| `skills/mesh-editing-and-bmesh` | lofted staves, banded hoops, clipped board polygons, lathed handle, all in one `bmesh` | +| `skills/custom-properties` | face attributes (`PlankTone`, `GrainDir`) read by the wood shaders | +| `skills/procedural-materials-and-shaders` | oak with grain along each stave, pale maple, rusted rough iron | +| `skills/bake-high-to-low` | Cycles tangent-space normal bake, high onto low | +| `skills/engine-export-presets` | Unity glTF (`export_yup=True`) | +| `skills/depsgraph-and-evaluated-data` | evaluated triangle counts for the LOD ratios | +| `snippets/decimate_to_budget.py` | LOD1 / LOD2 COLLAPSE chain | +| `snippets/convex_hull_collider.py` | one hull for the body and lid, one for the handle above it | +| `snippets/lod_chain.py` | LOD naming and ratio pattern | +| `examples/mesh-hygiene-audit` | hygiene combinatorics (copied, not imported) | + +## The budget that matters + +A churn is for plunging. The body narrows toward the top, so a plunger +that clears the staves at the bottom can jam partway up the stroke, and +nothing else notices: + +- the plunger still sits inside the body without touching it; +- the handle still clears the lid's hole; +- the bounding box does not move. + +The piece reads the inner wall off the staves ring by ring and the +plunger's reach off the dasher's slats, about the handle's axis. It +finds the height at which the wall comes within `WALL_CLEAR` (10 mm) of +the plunger, stops at the lid if that comes first, and asserts the +travel from the plunger's rest height as a stroke floor of 400 mm. +Measured: 513.8 mm, for a 100.6 mm reach. + +`--wide-dasher` is the falsifier built for exactly this. It widens the +slats to 125 mm. The plunger still clears the staves at rest, but it can +travel only 140.4 mm before it would meet the wall, and the run exits +20. Every other budget passes and the AABB is unchanged. + +## Budgets + +Declared in the script as named constants, recomputed from the generated +mesh. Measured values are from Blender 5.2.1; every one is byte-identical +on 4.5.11 and 5.1.2 (only the glTF file size differs, by 8 bytes, which is +exporter metadata and not a budget). + +| Budget | Band | Measured | +| --- | --- | --- | +| Base triangles | 5000–6500 | 5684 | +| LOD1 ratio | 0.32–0.62 | 0.5000 | +| LOD2 ratio | 0.10–0.35 | 0.2199 | +| Material slots | exactly 3, distinct | 3 | +| Oak / maple / iron faces | ≥ 2000 / 140 / 500 | 2240 / 160 / 576 | +| UV bounds | inside 0..1 | (0.0013, 0.0013)–(0.9987, 0.9987) | +| UV AABB overlap | ≤ 1e-5 | 0.000000 | +| Outer AABB | 0.340 × 0.340 × 1.050 m ± 0.020 | 0.3401 × 0.3401 × 1.0500 | +| Collider triangles | ≤ 200 | 178 (two hulls) | +| Normal bake | `{'FINISHED'}` with image data | `{'FINISHED'}`, `has_data=True` | +| glTF export | file written, non-empty | ~240 kB | +| Hygiene | all zero | loose 0/0, non-manifold 0, zero-area 0, doubles 0, n-gons 0, coplanar cross-shell pairs 0 | +| Grounded AABB | \|zmin\| ≤ 1e-4 | 0.00000 | +| Named staves | 16 staves, each zmin ≤ 1e-4 | 16 at 0.00000 | +| Bottom boards | 3 boards, seams 0.6–2.5 mm | 3, 1.20 mm | +| Handle bite | handle's foot 8–20 mm into the slats | 14.0 mm | +| Hoop seat | 3 hoops, inner face 1.0–4.5 mm into the staves, outer face ≥ 3 mm proud | 2.50 mm in, 4.90 mm proud | +| Lid seat | 2 boards, each 1–4 mm onto the stave tops | 1.40–2.00 mm | +| Handle clearance | lid hole over handle radius, 2–10 mm | 5.00 mm | +| Plunge stroke | ≥ 400 mm | 513.8 mm | + +Real-world size: 0.34 m across the foot, 0.75 m to the rim and 1.05 m to +the top of the handle. It is a farmhouse plunge churn. + +## Construction + +- **Staves.** Sixteen staves are lofted on a straight taper (`R_BOT` 170 mm + to `R_TOP` 120 mm), with a gap of `STAVE_GAP` between neighbours. + - Each stave's section has four vertices across its width at fractions + 0, ¼, ¾ and 1, outer face over inner face, and quads close both ends. + - Ring heights are a uniform run plus both edges of every hoop and of + the croze, so a hoop's edge and the bottom's faces meet stave + vertices. + - Each stave's foot and top are cut sloping inward (`END_CANT`, doubled + on alternate staves), so the churn stands on its outer edges. + - Every real edge is chamfered at 1.5 mm; the few-degree edges across a + stave's width are left alone. +- **Hoops.** Each hoop is a banded iron section: flat inner face, + chamfered outer edges. It is sampled at every stave's two interior + vertex angles, so every hoop vertex lies radially over a stave vertex at + the same height. The inner face is `HOOP_BITE` inside the stave's outer + face, read from the same taper; the outer face is `HOOP_PROUD` outside + it. +- **Bottom.** A 48-gon disk sized to the inner wall plus `CROZE`, cut + across X into three boards with a named seam (Sutherland–Hodgman clip). + Caps are chamfered as n-gons and then triangulated. +- **Lid.** Two half-annulus boards with the seam across the hole. The + hole is `HOLE_CLEAR` wider than the handle. The lid rests `LID_SEAT` on + the stave tops and overhangs by `LID_OVER`. +- **Dasher.** Two crossed maple slats, the second a step higher. The + handle's foot is driven `HANDLE_BITE` into them. The handle is a + 12-segment lathe with rings at both faces of the lid, so its clearance + is measured where it passes, and it ends in a swelled grip. + +## Findings the budgets forced + +- **Neighbouring ends in one plane.** The first build had 240 coplanar + cross-shell pairs: + - adjacent stave feet and tops; + - the two lid boards; + - the bottom boards' faces and a rim chord the seams split. + + The fixes: + - stave ends are canted inward, doubled on alternate staves (at one + cant, neighbours 5.6° apart in bearing still matched as one plane); + - one lid board stands 0.6 mm proud; + - the middle bottom board steps up and in by 0.6 mm. + + The count is 0. +- **A falsifier stolen by the collider.** `--float-hoops` exited 11 on + the collider ceiling (242 against 240) instead of 18. The hoops stand + 5 mm proud and add nothing a character could hit, so they were taken + out of the body hull rather than the ceiling raised. The collider is + now 178 triangles, and the falsifier exits 18. + +## Conventions walked + +Every convention in `showcase/README.md`, and whether it applies here. + +| Convention | Applies | How | +| --- | --- | --- | +| Deterministic, budgets declared, assertions recompute | yes | no RNG; every value above is read off the mesh | +| Falsifier fails the budget it targets | yes | table below, proven on all three binaries | +| Hygiene incl. cross-shell coplanar | yes | exit 15; canted stave ends and stepped boards keep it at 0 | +| Named supports | yes | all sixteen staves (`--short-stave`) | +| Seat conformance, sampled per segment | yes | every hoop vertex against the stave vertex under it (`--float-hoops`) | +| Bands on a curved host are built on the host's arc | yes | hoop vertices at the staves' own angles | +| A band wraps its host, never sunk into it | yes | inner face bites, outer face asserted proud | +| A head or bottom is boards, not a slab | yes | three bottom boards with a banded seam (`--one-piece-bottom`); the lid is two boards | +| A vessel holds its contents | n/a | the churn shows no contents | +| A joint bites; touching is not joining | yes | handle into the slats (`--short-handle`) | +| Carried parts bite their bearers | yes | lid on the stave tops (`--float-lid`) | +| What passes through a part must clear its mount | yes | handle through the lid's hole (`--tight-hole`) | +| Identical boards read as CG | yes | per-stave and per-board `PlankTone`, grain along each piece's longest extent | +| Hoops are forged iron, not bright | yes | near-black and rust, roughness 0.55–0.85, metallic 0.65 | +| Shading is part of the model | yes | staves smooth across their width with every edge over 35° hard, so they read as coopered boards; handle smooth (turned) | +| One substance, one slot | yes | oak, maple, iron | +| Chamfer n-gon caps, then triangulate | yes | bottom and lid boards | +| Sort bmesh operator inputs | yes | bevel edges sorted by index | +| Level on the stage; stage 60 m | yes | turned about Z only; 60 m floor and wall | +| Keep a falsifier's envelope still | yes | every falsifier leaves the AABB unchanged | +| Rope, masonry, roofs, rings, scatter, mirrored assemblies | no | the piece has none of these | + +## Falsifiers + +Each breaks one pipeline stage so a **named** budget fails. All ten were +run on 4.5.11, 5.1.2 and 5.2.1 and exited the same declared code on all +three. + +| Flag | Target budget | Breaks | Exit | +| --- | --- | --- | --- | +| `--skip-decimate` | LOD1 ratio | drops the DECIMATE modifiers, LOD1 ratio goes to 1.0000 | 9 | +| `--stray-vert` | mesh hygiene | adds one loose vertex inside the body | 15 | +| `--lift-z` | grounded zmin | lifts the whole mesh 50 mm | 16 | +| `--short-stave` | named staves | stops one stave's foot 6 mm above the floor; the rest ground the AABB | 16 | +| `--one-piece-bottom` | bottom boards | one disk instead of three boards | 17 | +| `--short-handle` | handle bite | starts the handle 5 mm above the slats; −5.0 mm | 17 | +| `--float-hoops` | hoop seat | builds every hoop 4 mm off the staves; −3.5 mm | 18 | +| `--float-lid` | lid seat | lifts the lid 5 mm; −3.6 to −3.0 mm | 18 | +| `--tight-hole` | handle clearance | cuts the hole 2 mm inside the handle; −2.0 mm | 18 | +| `--wide-dasher` | plunge stroke | widens the slats to 125 mm; 140.4 mm | 20 | + +## Exit codes + +File-local and sequential. `9` is a valid check code. `1` is the FATAL +wrapper — a crash, never a named check. `19` (plumb and real-world size) +is reserved across pieces and unused here. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Mesh did not build, or has no UV layer | +| 4 | Base triangle count outside band | +| 5 | Material slots, or a material's face floor | +| 6 | UVs outside 0..1 | +| 7 | UV AABB overlap above tolerance | +| 8 | Outer AABB off declared size | +| 9 | LOD1 or LOD2 ratio outside band (`--skip-decimate`) | +| 10 | Framing gate (`examples/gallery_framing.py`, render path only) | +| 11 | Collider triangles above ceiling | +| 12 | Normal bake failed or produced no image data | +| 13 | glTF export missing or empty | +| 14 | `--output` produced no file | +| 15 | Mesh hygiene (`--stray-vert`) | +| 16 | Grounded zmin, or a stave floating (`--lift-z`, `--short-stave`) | +| 17 | Bottom boards or handle bite (`--one-piece-bottom`, `--short-handle`) | +| 18 | Hoop seat, lid seat or handle clearance (`--float-hoops`, `--float-lid`, `--tight-hole`) | +| 20 | Plunge stroke below floor (`--wide-dasher`) | + +## Run it + +```bash +# Budget check, no render. ~1.8 s on 4.5 and 5.2. +blender --background --python butter_churn.py -- + +# Falsifier: the plunger jams partway up the narrowing body. Must exit 20. +blender --background --python butter_churn.py -- --wide-dasher + +# Falsifier: the lid's hole is narrower than the handle. Must exit 18. +blender --background --python butter_churn.py -- --tight-hole + +# Render the gallery still (EEVEE; --engine cycles on a GPU-less host). +blender --background --python butter_churn.py -- --output butter_churn.webp +``` + +Smoke runs the check-only path. It does not pass `--output` or any falsifier. + +## Cross-version measurements + +| Value | 4.5.11 | 5.1.2 | 5.2.1 | +| --- | --- | --- | --- | +| Base triangles | 5684 | 5684 | 5684 | +| LOD1 / LOD2 tris | 2842 / 1250 | same | same | +| Face counts (oak / maple / iron) | 2240 / 160 / 576 | same | same | +| Outer AABB | 0.3401 × 0.3401 × 1.0500 | same | same | +| Collider tris | 178 | 178 | 178 | +| Plunge stroke / reach | 513.8 mm / 100.6 mm | same | same | +| glTF bytes | 239804 | 239804 | 239796 | diff --git a/showcase/butter-churn/butter_churn.py b/showcase/butter-churn/butter_churn.py new file mode 100644 index 00000000..c2ac7e07 --- /dev/null +++ b/showcase/butter-churn/butter_churn.py @@ -0,0 +1,1272 @@ +"""Game-ready butter churn — a showcase piece, not an example. + +Asserts budget conformance of a procedural plunge churn: a tall staved +body that narrows toward the top, three iron hoops, a bottom of three +boards set in a croze, a lid of two boards with a hole for the handle, +and a cross dasher whose handle runs up through the lid. Carried through +UVs, three materials (oak, maple, iron), a high-to-low normal bake, an +LOD chain, a compound convex collider, and a Unity glTF export. + +The budget that matters here is the one a churn fails invisibly: the +dasher has to plunge. The body narrows toward the top, so a plunger that +clears the staves at rest can jam a hand's width up the stroke. It still +sits inside the body without touching it, the handle still clears the lid +and the bounding box does not move; only the stroke knows. The piece +reads the inner wall off the staves and the plunger's reach off the +dasher, and asserts how far the plunger can travel before it would meet +the wall or the lid. + +Budgets are declared below and recomputed from the generated result. +They are not API-contract witnesses. Each falsifier violates one named +budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh +hygiene, ``--lift-z`` grounded zmin, ``--short-stave`` the named staves, +``--one-piece-bottom`` the bottom boards, ``--short-handle`` the handle +bite, ``--float-hoops`` the hoop seat, ``--float-lid`` the lid seat, +``--tight-hole`` the handle clearance, ``--wide-dasher`` the stroke. + +No randomness: every stave's tone is a closed-form term of its index. +DECIMATE COLLAPSE triangle counts are not byte-identical across Blender +versions — the LOD gate is a ratio band. + + blender --background --python butter_churn.py -- + blender --background --python butter_churn.py -- --wide-dasher + blender --background --python butter_churn.py -- --output butter_churn.png +""" +import argparse +import math +import os +import sys +import tempfile +import traceback + +import bmesh +import bpy +from mathutils import Matrix, Vector +from mathutils.kdtree import KDTree + +_REPO = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) +) +sys.path.insert(0, os.path.join(_REPO, "examples")) +sys.dont_write_bytecode = True +import gallery_framing # noqa: E402 + +# The body: sixteen staves on a straight taper, wider at the foot. +HEIGHT = 0.750 +R_BOT = 0.170 +R_TOP = 0.120 +N_STAVES = 16 +STAVE_T = 0.018 +STAVE_GAP = 0.0008 +STAVE_FRACS = (0.0, 0.25, 0.75, 1.0) +BODY_RINGS = 6 +CHAMFER = 0.0015 +SHORT_STAVE = 0.006 +# Each stave's foot and top are cut sloping inward, so the churn stands on +# its outer edges. Alternate staves take a double cant: at one cant, two +# neighbours' end faces are only 5.6 degrees apart in bearing and still +# match as one plane. +END_CANT = 0.0015 + +# Hoops: banded iron sampled at each stave's interior vertices, so every +# hoop vertex sits radially over a stave vertex at the same height. +HOOP_ZS = (0.060, 0.360, 0.655) +HOOP_H = 0.024 +HOOP_BITE = 0.0025 +HOOP_PROUD = 0.005 +HOOP_CH = 0.0015 +FLOAT_HOOP = 0.004 + +# Bottom: three boards in a croze cut into the staves' inner face. +BOTTOM_Z = 0.012 +BOTTOM_T = 0.022 +CROZE = 0.0035 +N_BOARDS = 3 +BOARD_SEAM = 0.0012 +DISK_SEG = 48 +BOARD_STEP = 0.0006 + +# Lid: two boards resting on the stave tops, a hole for the handle. +LID_T = 0.022 +LID_SEAT = 0.002 +LID_OVER = 0.008 +LID_SEAM = 0.0012 +HOLE_CLEAR = 0.005 +TIGHT_HOLE = -0.002 +FLOAT_LID = 0.005 +LID_ARC = 24 +LID_STEP = 0.0006 + +# Dasher: a round maple handle through the lid, two crossed slats below. +HANDLE_R = 0.016 +HANDLE_TOP = 1.050 +HANDLE_BITE = 0.014 +SHORT_HANDLE = -0.005 +PLUNGER_Z = BOTTOM_Z + BOTTOM_T + 0.050 +SLAT_REACH = 0.100 +WIDE_REACH = 0.125 +SLAT_W = 0.024 +SLAT_T = 0.020 +SLAT_STEP = 0.004 +WALL_CLEAR = 0.010 + +BBOX_TOL = 0.020 +OUTER_SIZE = (0.340, 0.340, 1.050) + +BASE_TRIS_MIN = 5000 +BASE_TRIS_MAX = 6500 +LOD1_RATIO_MIN = 0.32 +LOD1_RATIO_MAX = 0.62 +LOD2_RATIO_MIN = 0.10 +LOD2_RATIO_MAX = 0.35 +LOD1_TARGET = 0.50 +LOD2_TARGET = 0.22 +MATERIAL_COUNT = 3 +FACE_FLOORS = {0: 2000, 1: 140, 2: 500} +UV_EPS = 1e-4 +UV_OVERLAP_MAX = 1e-5 +COLLIDER_TRIS_MAX = 200 +BAKE_RES = 512 +CAGE_EXTRUSION = 0.01 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +COPLANAR_NORMAL_EPS = 1e-4 +COPLANAR_PLANE_EPS = 1e-4 +COPLANAR_CENTRE_MAX = 0.05 +LIFT_Z = 0.05 +STAVE_Z_MAX = 1e-4 +BOARD_SEAM_MIN = 0.0006 +BOARD_SEAM_MAX = 0.0025 +HANDLE_BITE_MIN = 0.008 +HANDLE_BITE_MAX = 0.020 +HOOP_BITE_MIN = 0.0010 +HOOP_BITE_MAX = 0.0045 +HOOP_PROUD_MIN = 0.0030 +LID_SEAT_MIN = 0.0010 +LID_SEAT_MAX = 0.0040 +HOLE_CLEAR_MIN = 0.002 +HOLE_CLEAR_MAX = 0.010 +STROKE_MIN = 0.400 + +OAK_IDX = 0 +MAPLE_IDX = 1 +IRON_IDX = 2 + + +def eevee_engine_id(): + """EEVEE id: 'BLENDER_EEVEE' on 5.0+, 'BLENDER_EEVEE_NEXT' on 4.2-4.5.""" + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"FAIL[{code}]: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + deps = bpy.context.evaluated_depsgraph_get() + ev = obj.evaluated_get(deps) + mesh = ev.to_mesh() + try: + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + finally: + ev.to_mesh_clear() + + +def r_out(z): + return R_BOT + (R_TOP - R_BOT) * z / HEIGHT + + +def r_in(z): + return r_out(z) - STAVE_T + + +def stave_angles(k): + """Angles of stave ``k``'s vertices across its width, gap split either side.""" + span = 2.0 * math.pi / N_STAVES + gap = STAVE_GAP / R_TOP + a0 = k * span + gap * 0.5 + width = span - gap + return [a0 + width * f for f in STAVE_FRACS] + + +def ring_zs(): + """Stave ring heights: a uniform run plus both edges of every hoop and the croze.""" + zs = {round(HEIGHT * k / BODY_RINGS, 6) for k in range(BODY_RINGS + 1)} + for z in HOOP_ZS: + zs |= {round(z, 6), round(z + HOOP_H, 6)} + zs |= {round(BOTTOM_Z, 6), round(BOTTOM_Z + BOTTOM_T, 6)} + return sorted(zs) + + +# --- construction ----------------------------------------------------------- + + +def new_island(ctx): + ctx["next"] += 1 + return ctx["next"] + + +def stamp(ctx, face, island, uvmap): + face[ctx["isl"]] = island + for loop in face.loops: + loop[ctx["uv"]].uv = uvmap[loop.vert] + + +def loft(bm, rings, mat_idx, ctx, closed=True): + """Quads between consecutive rings of equal length; one strip island.""" + island = new_island(ctx) + n = len(rings[0]) + arc = [0.0] + for a, b in zip(rings, rings[1:]): + ca = sum((v.co for v in a), Vector()) / n + cb = sum((v.co for v in b), Vector()) / n + arc.append(arc[-1] + (cb - ca).length) + faces = [] + for k in range(len(rings) - 1): + a, b = rings[k], rings[k + 1] + for i in range(n if closed else n - 1): + j = (i + 1) % n + f = bm.faces.new((a[i], a[j], b[j], b[i])) + f.material_index = mat_idx + stamp(ctx, f, island, {a[i]: (arc[k], i / n), a[j]: (arc[k], (i + 1) / n), + b[j]: (arc[k + 1], (i + 1) / n), b[i]: (arc[k + 1], i / n)}) + faces.append(f) + return faces + + +def add_stave(bm, k, ctx, z0, verts_out): + """One stave: an annular sector on the taper, capped by quads at both ends.""" + angs = stave_angles(k) + rings = [] + for z in ring_zs(): + # ``z0`` lifts only the foot ring, so a short stave stops above the + # floor while its hoop and croze rings stay where the others are. + zz = z if z > 0.0 else z0 + c = END_CANT * (1 + k % 2) + cant = c if z <= 0.0 else (-c if z >= HEIGHT else 0.0) + outer = [Vector((r_out(z) * math.cos(a), r_out(z) * math.sin(a), zz)) for a in angs] + inner = [Vector((r_in(z) * math.cos(a), r_in(z) * math.sin(a), zz + cant)) + for a in reversed(angs)] + rings.append([bm.verts.new(p) for p in outer + inner]) + loft(bm, rings, OAK_IDX, ctx) + m = len(angs) + for ring, flip in ((rings[0], True), (rings[-1], False)): + for i in range(m - 1): + o0, o1 = ring[i], ring[i + 1] + i1, i0 = ring[2 * m - 2 - i], ring[2 * m - 1 - i] + vs = (o0, o1, i1, i0) + f = bm.faces.new(tuple(reversed(vs)) if flip else vs) + f.material_index = OAK_IDX + f[ctx["isl"]] = 0 + for r in rings: + verts_out.extend(r) + + +def add_hoop(bm, z, ctx, float_hoop=False): + """A banded hoop sampled at every stave's interior vertex angles. + + Its inner face sits HOOP_BITE inside the stave's outer face at each + edge height, read from the same taper, so the band follows the staves. + """ + angs = [a for k in range(N_STAVES) for a in stave_angles(k)[1:-1]] + off_in = FLOAT_HOOP if float_hoop else -HOOP_BITE + sec = [(off_in, 0.0), (HOOP_PROUD - HOOP_CH, 0.0), (HOOP_PROUD, HOOP_CH), + (HOOP_PROUD, HOOP_H - HOOP_CH), (HOOP_PROUD - HOOP_CH, HOOP_H), (off_in, HOOP_H)] + cols = [] + for a in angs: + c, s = math.cos(a), math.sin(a) + cols.append([bm.verts.new(((r_out(z + dz) + dr) * c, (r_out(z + dz) + dr) * s, z + dz)) + for dr, dz in sec]) + island = new_island(ctx) + n, m = len(cols), len(sec) + for i in range(n): + a, b = cols[i], cols[(i + 1) % n] + for j in range(m): + jj = (j + 1) % m + f = bm.faces.new((a[j], a[jj], b[jj], b[j])) + f.material_index = IRON_IDX + stamp(ctx, f, island, {a[j]: (i / n, j / m), a[jj]: (i / n, (j + 1) / m), + b[jj]: ((i + 1) / n, (j + 1) / m), b[j]: ((i + 1) / n, j / m)}) + + +def clip_x(poly, x0, x1): + """Clip a convex (x, y) polygon to x0 <= x <= x1 (Sutherland-Hodgman).""" + def cut(pts, keep, xc): + out = [] + for i, p in enumerate(pts): + q = pts[(i + 1) % len(pts)] + pin, qin = keep(p[0]), keep(q[0]) + if pin: + out.append(p) + if pin != qin: + t = (xc - p[0]) / (q[0] - p[0]) + out.append((xc, p[1] + (q[1] - p[1]) * t)) + return out + pts = cut(poly, lambda x: x >= x0, x0) + return cut(pts, lambda x: x <= x1, x1) + + +def extrude_polygon(bm, poly, z0, z1, mat_idx, verts_out): + """A board from an (x, y) outline: two n-gon caps and quad sides.""" + lo = [bm.verts.new((x, y, z0)) for x, y in poly] + hi = [bm.verts.new((x, y, z1)) for x, y in poly] + faces = [bm.faces.new(lo), bm.faces.new(list(reversed(hi)))] + n = len(poly) + for i in range(n): + j = (i + 1) % n + faces.append(bm.faces.new((lo[j], lo[i], hi[i], hi[j]))) + for f in faces: + f.material_index = mat_idx + verts_out.extend(lo + hi) + + +def add_bottom(bm, one_piece, verts_out): + zm = BOTTOM_Z + BOTTOM_T * 0.5 + rr = r_in(zm) + CROZE + disk = [(rr * math.cos(2 * math.pi * i / DISK_SEG), rr * math.sin(2 * math.pi * i / DISK_SEG)) + for i in range(DISK_SEG)] + if one_piece: + extrude_polygon(bm, disk, BOTTOM_Z, BOTTOM_Z + BOTTOM_T, OAK_IDX, verts_out) + return + w = (2.0 * rr - BOARD_SEAM * (N_BOARDS - 1)) / N_BOARDS + for k in range(N_BOARDS): + x0 = -rr + k * (w + BOARD_SEAM) + # Alternate boards step up and in by BOARD_STEP: level with their + # neighbours, their faces and the rim chord a seam splits would each + # be one plane shared by two boards. + step = BOARD_STEP if k % 2 else 0.0 + src = [(x * (rr - step) / rr, y * (rr - step) / rr) for x, y in disk] + poly = clip_x(src, x0 - (1.0 if k == 0 else 0.0), x0 + w + (1.0 if k == N_BOARDS - 1 else 0.0)) + extrude_polygon(bm, poly, BOTTOM_Z + step, BOTTOM_Z + BOTTOM_T + step, OAK_IDX, verts_out) + + +def add_lid(bm, hole_r, lift, verts_out): + """Two half-annulus boards, seam across the hole, resting on the stave tops.""" + ro = r_out(HEIGHT) + LID_OVER + z0 = HEIGHT - LID_SEAT + lift + s = LID_SEAM * 0.5 + t_o = math.acos(s / ro) + t_i = math.acos(s / hole_r) + for side in (1.0, -1.0): + outer = [(ro * math.cos(-t_o + 2 * t_o * k / LID_ARC), ro * math.sin(-t_o + 2 * t_o * k / LID_ARC)) + for k in range(LID_ARC + 1)] + inner = [(hole_r * math.cos(t_i - 2 * t_i * k / 8), hole_r * math.sin(t_i - 2 * t_i * k / 8)) + for k in range(9)] + poly = [(side * x, y) for x, y in outer + inner] + if side < 0: + poly.reverse() + # One board a step proud of the other, so the two do not share a face plane. + step = LID_STEP if side < 0 else 0.0 + extrude_polygon(bm, poly, z0 + step, z0 + step + LID_T, OAK_IDX, verts_out) + + +def lathe_z(bm, profile, n, mat_idx, ctx, base): + """Revolve an (r, z) profile about the vertical axis through ``base``.""" + rings, poles = [], [] + for p in profile: + if p.x <= 0.0: + poles.append(bm.verts.new(base + Vector((0.0, 0.0, p.y)))) + else: + rings.append([bm.verts.new(base + Vector((p.x * math.cos(2 * math.pi * k / n), + p.x * math.sin(2 * math.pi * k / n), p.y))) + for k in range(n)]) + loft(bm, rings, mat_idx, ctx) + island = new_island(ctx) + for pole, ring, flip in ((poles[0], rings[0], True), (poles[1], rings[-1], False)): + for i in range(n): + j = (i + 1) % n + vs = (pole, ring[j], ring[i]) if flip else (pole, ring[i], ring[j]) + f = bm.faces.new(vs) + f.material_index = mat_idx + stamp(ctx, f, island, {pole: (0.5 if flip else 1.5, (i + 0.5) / n), + ring[i]: (0.0 if flip else 1.0, i / n), + ring[j]: (0.0 if flip else 1.0, (i + 1) / n)}) + + +def handle_profile(z_start, lid_z0): + """(r, z) from the handle's foot, with rings at both faces of the lid.""" + r = HANDLE_R + top = HANDLE_TOP - z_start + rings = [(r * 0.85, 0.0), (r, 0.004), (r, lid_z0 - z_start), (r, lid_z0 + LID_T - z_start), + (r, top - 0.060), (r * 1.25, top - 0.035), (r * 1.35, top - 0.018), (r * 1.05, top)] + return [Vector((0.0, 0.0))] + [Vector(p) for p in rings] + [Vector((0.0, top))] + + +def build_churn_mesh( + name, + stray_vert=False, + short_stave=False, + one_piece_bottom=False, + short_handle=False, + float_hoops=False, + float_lid=False, + tight_hole=False, + wide_dasher=False, +): + bm = bmesh.new() + try: + ctx = {"uv": bm.loops.layers.uv.new("UVMap"), + "isl": bm.faces.layers.int.new("UVIsland"), "next": 0} + wood = [] + for k in range(N_STAVES): + add_stave(bm, k, ctx, SHORT_STAVE if (short_stave and k == 0) else 0.0, wood) + add_bottom(bm, one_piece_bottom, wood) + hole_r = HANDLE_R + (TIGHT_HOLE if tight_hole else HOLE_CLEAR) + lid_lift = FLOAT_LID if float_lid else 0.0 + add_lid(bm, hole_r, lid_lift, wood) + # Dasher: two crossed slats, the second a step higher so no two of + # their faces share a plane, and the handle's foot biting both. + reach = WIDE_REACH if wide_dasher else SLAT_REACH + maple = [] + extrude_polygon(bm, [(-reach, -SLAT_W / 2), (reach, -SLAT_W / 2), (reach, SLAT_W / 2), (-reach, SLAT_W / 2)], + PLUNGER_Z, PLUNGER_Z + SLAT_T, MAPLE_IDX, maple) + extrude_polygon(bm, [(-SLAT_W / 2, -reach), (SLAT_W / 2, -reach), (SLAT_W / 2, reach), (-SLAT_W / 2, reach)], + PLUNGER_Z + SLAT_STEP, PLUNGER_Z + SLAT_STEP + SLAT_T, MAPLE_IDX, maple) + # Real edges only (a stave's width and a disk's rim meet at a few + # degrees), sorted by index, each pass pinned to its own material. + for verts, mat_idx in ((wood, OAK_IDX), (maple, MAPLE_IDX)): + bm.edges.index_update() + edges = sorted({e for v in verts if v.is_valid for e in v.link_edges + if e.calc_face_angle(0.0) > math.radians(20.0)}, + key=lambda e: e.index) + bmesh.ops.bevel(bm, geom=edges, offset=CHAMFER, segments=1, profile=0.5, + affect="EDGES", clamp_overlap=True, material=mat_idx) + bmesh.ops.triangulate(bm, faces=[f for f in bm.faces if len(f.verts) > 4]) + for z in HOOP_ZS: + add_hoop(bm, z, ctx, float_hoop=float_hoops) + z_start = PLUNGER_Z + SLAT_STEP + SLAT_T - (SHORT_HANDLE if short_handle else HANDLE_BITE) + lathe_z(bm, handle_profile(z_start, HEIGHT - LID_SEAT + lid_lift), 12, MAPLE_IDX, ctx, + Vector((0.0, 0.0, z_start))) + if stray_vert: + bm.verts.new((0.0, 0.0, 0.3)) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for f in bm.faces: + f.smooth = True + for e in bm.edges: + if len(e.link_faces) == 2 and e.calc_face_angle(0.0) > math.radians(35.0): + e.smooth = False + pack_uvs(bm, ctx) + bm.faces.layers.int.remove(ctx["isl"]) + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + me.update() + finally: + bm.free() + paint_pieces(me) + obj = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(obj) + return obj + + +def pack_uvs(bm, ctx, margin=0.06): + """One grid cell per UV island: strip islands by their layer tag, else a face each.""" + uv, isl = ctx["uv"], ctx["isl"] + bm.faces.index_update() + islands, order = {}, [] + for face in bm.faces: + key = ("s", face[isl]) if face[isl] else ("f", face.index) + if key not in islands: + islands[key] = [] + order.append(key) + islands[key].append(face) + cols = max(1, math.ceil(math.sqrt(len(order)))) + rows = max(1, math.ceil(len(order) / cols)) + cw, ch = 1.0 / cols, 1.0 / rows + pu, pv = margin * cw * 0.5, margin * ch * 0.5 + for idx, key in enumerate(order): + faces = islands[key] + coords = {} + for face in faces: + if face[isl]: + coords[face.index] = [tuple(loop[uv].uv) for loop in face.loops] + continue + nrm = face.normal + ax, ay, az = abs(nrm.x), abs(nrm.y), abs(nrm.z) + pts = [] + for loop in face.loops: + co = loop.vert.co + if az >= ax and az >= ay: + pts.append((co.x, co.y)) + elif ax >= ay: + pts.append((co.y, co.z)) + else: + pts.append((co.x, co.z)) + coords[face.index] = pts + allc = [c for cs in coords.values() for c in cs] + minx, maxx = min(c[0] for c in allc), max(c[0] for c in allc) + miny, maxy = min(c[1] for c in allc), max(c[1] for c in allc) + dx, dy = max(maxx - minx, 1e-8), max(maxy - miny, 1e-8) + ou, ov = (idx % cols) * cw + pu, (idx // cols) * ch + pv + for face in faces: + for loop, (x, y) in zip(face.loops, coords[face.index]): + loop[uv].uv = (ou + (x - minx) / dx * (cw - 2 * pu), + ov + (y - miny) / dy * (ch - 2 * pv)) + + +def paint_pieces(me): + """Per-piece ``PlankTone`` and ``GrainDir`` (each shell's longest extent).""" + npoly = len(me.polygons) + tone = [0.5] * npoly + grain = [(0.0, 0.0, 1.0)] * npoly + vf = [[] for _ in range(len(me.vertices))] + for p in me.polygons: + for i in p.vertices: + vf[i].append(p.index) + for k, g in enumerate(shells(me)): + pts = [me.vertices[i].co for i in g] + ext = [max(p[a] for p in pts) - min(p[a] for p in pts) for a in range(3)] + axis = ext.index(max(ext)) + d = tuple(1.0 if a == axis else 0.0 for a in range(3)) + t = 0.5 + 0.34 * (((k * 0.6180339887 + 0.3) % 1.0) - 0.5) + for fi in {fi for i in g for fi in vf[i]}: + tone[fi] = t + grain[fi] = d + a = me.attributes.new("PlankTone", "FLOAT", "FACE") + a.data.foreach_set("value", tone) + b = me.attributes.new("GrainDir", "FLOAT_VECTOR", "FACE") + b.data.foreach_set("vector", [c for v in grain for c in v]) + + +# --- surface ---------------------------------------------------------------- + + +def _sock(sockets, identifier): + return next(sk for sk in sockets if sk.identifier == identifier) + + +def wood_material(name, dark, light, rough=(0.72, 0.52)): + """Timber whose grain runs along ``GrainDir`` and whose tone varies by piece.""" + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + coord = nt.nodes.new("ShaderNodeTexCoord") + gdir = nt.nodes.new("ShaderNodeAttribute") + gdir.attribute_name = "GrainDir" + tone = nt.nodes.new("ShaderNodeAttribute") + tone.attribute_name = "PlankTone" + dot = nt.nodes.new("ShaderNodeVectorMath") + dot.operation = "DOT_PRODUCT" + nt.links.new(coord.outputs["Object"], dot.inputs[0]) + nt.links.new(gdir.outputs["Vector"], dot.inputs[1]) + squash = nt.nodes.new("ShaderNodeMath") + squash.operation = "MULTIPLY" + squash.inputs[1].default_value = 0.94 + nt.links.new(dot.outputs["Value"], squash.inputs[0]) + along = nt.nodes.new("ShaderNodeVectorMath") + along.operation = "SCALE" + nt.links.new(gdir.outputs["Vector"], along.inputs[0]) + nt.links.new(squash.outputs["Value"], along.inputs["Scale"]) + grain_co = nt.nodes.new("ShaderNodeVectorMath") + grain_co.operation = "SUBTRACT" + nt.links.new(coord.outputs["Object"], grain_co.inputs[0]) + nt.links.new(along.outputs["Vector"], grain_co.inputs[1]) + shift = nt.nodes.new("ShaderNodeVectorMath") + shift.operation = "ADD" + nt.links.new(grain_co.outputs["Vector"], shift.inputs[0]) + nt.links.new(tone.outputs["Fac"], shift.inputs[1]) + noise = nt.nodes.new("ShaderNodeTexNoise") + noise.inputs["Scale"].default_value = 34.0 + noise.inputs["Detail"].default_value = 6.0 + noise.inputs["Roughness"].default_value = 0.62 + nt.links.new(shift.outputs["Vector"], noise.inputs["Vector"]) + ramp = nt.nodes.new("ShaderNodeValToRGB") + ramp.color_ramp.elements[0].position = 0.30 + ramp.color_ramp.elements[0].color = (*dark, 1.0) + ramp.color_ramp.elements[1].position = 0.72 + ramp.color_ramp.elements[1].color = (*light, 1.0) + nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) + gain = nt.nodes.new("ShaderNodeMath") + gain.operation = "MULTIPLY_ADD" + gain.inputs[1].default_value = 1.2 + gain.inputs[2].default_value = 0.40 + nt.links.new(tone.outputs["Fac"], gain.inputs[0]) + mix = nt.nodes.new("ShaderNodeMix") + mix.data_type = "RGBA" + mix.blend_type = "MULTIPLY" + _sock(mix.inputs, "Factor_Float").default_value = 1.0 + nt.links.new(ramp.outputs["Color"], _sock(mix.inputs, "A_Color")) + nt.links.new(gain.outputs["Value"], _sock(mix.inputs, "B_Color")) + nt.links.new(_sock(mix.outputs, "Result_Color"), bsdf.inputs["Base Color"]) + rmap = nt.nodes.new("ShaderNodeMapRange") + rmap.inputs["To Min"].default_value = rough[0] + rmap.inputs["To Max"].default_value = rough[1] + nt.links.new(noise.outputs["Fac"], rmap.inputs["Value"]) + nt.links.new(rmap.outputs["Result"], bsdf.inputs["Roughness"]) + return mat + + +def iron_material(name): + """Forged iron: near-black, rough, rusted in patches. Not chrome.""" + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + tc = nt.nodes.new("ShaderNodeTexCoord") + noise = nt.nodes.new("ShaderNodeTexNoise") + noise.inputs["Scale"].default_value = 40.0 + noise.inputs["Detail"].default_value = 8.0 + nt.links.new(tc.outputs["Object"], noise.inputs["Vector"]) + ramp = nt.nodes.new("ShaderNodeValToRGB") + ramp.color_ramp.elements[0].position = 0.45 + ramp.color_ramp.elements[0].color = (0.035, 0.033, 0.031, 1.0) + ramp.color_ramp.elements[1].position = 0.78 + ramp.color_ramp.elements[1].color = (0.20, 0.085, 0.035, 1.0) + nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) + nt.links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"]) + bsdf.inputs["Metallic"].default_value = 0.65 + rough = nt.nodes.new("ShaderNodeMapRange") + rough.inputs["To Min"].default_value = 0.55 + rough.inputs["To Max"].default_value = 0.85 + nt.links.new(noise.outputs["Fac"], rough.inputs["Value"]) + nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"]) + return mat + + +def churn_materials(): + return ( + wood_material("ChurnOak", (0.085, 0.040, 0.016), (0.28, 0.14, 0.060)), + wood_material("ChurnMaple", (0.26, 0.17, 0.09), (0.55, 0.40, 0.24), rough=(0.62, 0.45)), + iron_material("ChurnIron"), + ) + + +def assign_slots(obj, mats): + slots = obj.data.materials + for i, mat in enumerate(mats): + if i < len(slots): + slots[i] = mat + else: + slots.append(mat) + + +# --- measurement ------------------------------------------------------------ + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs, ys, zs = [c.x for c in corners], [c.y for c in corners], [c.z for c in corners] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def uv_stats(mesh): + uv = mesh.uv_layers.active + if uv is None: + return 0.0, 0.0, 1.0, 1.0, 0.0, 0 + data = uv.data + us = [loop.uv[0] for loop in data] + vs = [loop.uv[1] for loop in data] + aabbs = [] + for poly in mesh.polygons: + pu = [data[i].uv[0] for i in poly.loop_indices] + pv = [data[i].uv[1] for i in poly.loop_indices] + aabbs.append((min(pu), min(pv), max(pu), max(pv))) + span = max(1e-6, max(a[2] - a[0] for a in aabbs), max(a[3] - a[1] for a in aabbs)) + buckets = {} + for i, a in enumerate(aabbs): + for c in range(int(a[0] // span), int(a[2] // span) + 1): + for r in range(int(a[1] // span), int(a[3] // span) + 1): + buckets.setdefault((c, r), []).append(i) + overlap = 0.0 + seen = set() + for members in buckets.values(): + for ii in range(len(members)): + for jj in range(ii + 1, len(members)): + i, j = members[ii], members[jj] + key = (i, j) if i < j else (j, i) + if key in seen: + continue + seen.add(key) + a, b = aabbs[i], aabbs[j] + overlap += max(0.0, min(a[2], b[2]) - max(a[0], b[0])) * max( + 0.0, min(a[3], b[3]) - max(a[1], b[1])) + return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) + + +def face_area(me, poly): + idxs = poly.vertices + v0 = me.vertices[idxs[0]].co + area = 0.0 + for i in range(1, len(idxs) - 1): + area += (me.vertices[idxs[i]].co - v0).cross(me.vertices[idxs[i + 1]].co - v0).length * 0.5 + return area + + +def hygiene_audit(me): + ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) + zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS) + bm = bmesh.new() + try: + bm.from_mesh(me) + loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0) + loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0) + nonman = sum(1 for e in bm.edges if not e.is_manifold) + ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS) + doubles = len(ret.get("targetmap") or {}) + finally: + bm.free() + return {"ngons": ngons, "loose_v": loose_v, "loose_e": loose_e, + "nonman": nonman, "zero_area": zero_area, "doubles": doubles} + + +def shells(me): + neighbors = [[] for _ in range(len(me.vertices))] + for edge in me.edges: + a, b = edge.vertices + neighbors[a].append(b) + neighbors[b].append(a) + seen = [False] * len(me.vertices) + groups = [] + for start in range(len(me.vertices)): + if seen[start]: + continue + seen[start] = True + stack = [start] + group = [] + while stack: + cur = stack.pop() + group.append(cur) + for nxt in neighbors[cur]: + if not seen[nxt]: + seen[nxt] = True + stack.append(nxt) + groups.append(group) + return groups + + +def zfight_pairs(me): + """Coplanar face pairs from *different shells* (copied from showcase/grindstone).""" + owner = {} + for si, g in enumerate(shells(me)): + for vi in g: + owner[vi] = si + faces = [(p.normal.copy(), p.center.copy(), owner.get(p.vertices[0], -1)) + for p in me.polygons] + kd = KDTree(len(faces)) + for i, (_n, c, _s) in enumerate(faces): + kd.insert(c, i) + kd.balance() + hits = 0 + for i, (ni, ci, si) in enumerate(faces): + for _co, j, _d in kd.find_range(ci, COPLANAR_CENTRE_MAX): + if j <= i: + continue + nj, cj, sj = faces[j] + if si == sj: + continue + if abs(abs(ni.dot(nj)) - 1.0) > COPLANAR_NORMAL_EPS: + continue + if abs(ni.dot(cj - ci)) > COPLANAR_PLANE_EPS: + continue + hits += 1 + return hits + + +def classify(me): + mats = {} + for p in me.polygons: + for i in p.vertices: + mats.setdefault(i, p.material_index) + out = {"stave": [], "board": [], "lid": [], "hoop": [], "slat": [], "handle": [], "other": []} + for g in shells(me): + pts = [me.vertices[i].co.copy() for i in g] + lo = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts))) + hi = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts))) + rec = {"g": g, "pts": pts, "lo": lo, "hi": hi, "ext": hi - lo, + "c": sum(pts, Vector()) / len(pts)} + m = mats.get(g[0], -1) + e = rec["ext"] + if m == OAK_IDX: + if e.z > HEIGHT * 0.5: + out["stave"].append(rec) + elif lo.z > HEIGHT * 0.5: + out["lid"].append(rec) + else: + out["board"].append(rec) + elif m == MAPLE_IDX: + out["handle" if e.z > 0.3 else "slat"].append(rec) + elif m == IRON_IDX: + out["hoop"].append(rec) + else: + out["other"].append(rec) + return out + + +def radial(p): + return math.hypot(p.x, p.y) + + +def churn_audit(me): + parts = classify(me) + out = {k: len(v) for k, v in parts.items()} + staves = parts["stave"] + out["stave_z"] = max((r["lo"].z for r in staves), default=99.0) + + # Bottom boards: count and the seams between neighbours. + boards = sorted(parts["board"], key=lambda r: r["c"].x) + seams = [b["lo"].x - a["hi"].x for a, b in zip(boards, boards[1:])] + out["seam"] = (min(seams, default=-99.0), max(seams, default=99.0)) + + # Hoops on the staves: every hoop vertex against the stave vertex it + # was built over, measured radially. + spts = [p for r in staves for p in r["pts"]] + kd = KDTree(len(spts)) + for i, p in enumerate(spts): + kd.insert(p, i) + kd.balance() + bites, prouds = [], [] + for h in parts["hoop"]: + ds = sorted(radial(kd.find(p)[0]) - radial(p) for p in h["pts"]) + n_in = len(ds) // 3 + bites.append(min(ds[-n_in:])) + prouds.append(-max(ds[:n_in])) + out["hoop_bite"] = (min(bites, default=-99.0), max(bites, default=99.0)) + out["hoop_proud"] = min(prouds, default=-99.0) + + # The lid rests on the stave tops; the handle clears the lid's hole. + top = max((r["hi"].z for r in staves), default=0.0) + lids = parts["lid"] + seats = [top - r["lo"].z for r in lids] + out["lid_seat"] = (min(seats, default=-99.0), max(seats, default=99.0)) + out["hole_clear"] = -99.0 + handle = parts["handle"][0] if parts["handle"] else None + if handle and lids: + lz0 = min(r["lo"].z for r in lids) + lz1 = max(r["hi"].z for r in lids) + hr = max((radial(p) for p in handle["pts"] if lz0 - 1e-6 <= p.z <= lz1 + 1e-6), default=99.0) + lr = min(radial(p) for r in lids for p in r["pts"]) + out["hole_clear"] = lr - hr + + # The handle's foot bites the slats. + slats = parts["slat"] + out["handle_bite"] = -99.0 + if handle and slats: + out["handle_bite"] = max(r["hi"].z for r in slats) - handle["lo"].z + + # Stroke: the inner wall read off the staves, ring by ring, against the + # plunger's reach about the handle's axis. + out["stroke"] = -99.0 + if slats and staves: + reach = max(radial(p) for r in slats for p in r["pts"]) + p_top = max(r["hi"].z for r in slats) + walls = {} + for p in spts: + z = round(p.z, 5) + walls[z] = min(walls.get(z, 99.0), radial(p)) + table = sorted(walls.items()) + need = reach + WALL_CLEAR + limit = min((r["lo"].z for r in lids), default=top) + z_ok = None + for (za, ra), (zb, rb) in zip(table, table[1:]): + if za < p_top - 1e-6: + continue + if rb >= need: + continue + if ra >= need: + z_ok = za + (zb - za) * (ra - need) / (ra - rb) + else: + z_ok = za + break + if z_ok is None: + z_ok = limit + out["reach"] = reach + out["stroke"] = min(z_ok, limit) - p_top + return out + + +def make_lod(obj, name, ratio, skip_decimate): + mesh = obj.data.copy() + lod = bpy.data.objects.new(name, mesh) + lod.matrix_world = obj.matrix_world.copy() + bpy.context.scene.collection.objects.link(lod) + if not skip_decimate and 0.0 < ratio < 1.0: + mod = lod.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return lod + + +def hull_collider(obj, name): + """Compound collider: one hull for the staves and lid, one for the handle above it. + + The hoops stand 5 mm proud of the staves and are left out: in the hull + they add triangles and nothing a character could collide with. + """ + me = obj.data + parts = classify(me) + body = [p for k in ("stave", "lid") for r in parts[k] for p in r["pts"]] + body = [p for i, p in enumerate(body) if i % 3 == 0] + groups = [body] + if parts["handle"]: + top = max(p.z for r in parts["lid"] for p in r["pts"]) if parts["lid"] else HEIGHT + groups.append([p for p in parts["handle"][0]["pts"] if p.z >= top - 0.01]) + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + for pts in groups: + tmp = bmesh.new() + try: + vs = [tmp.verts.new(p) for p in pts] + bmesh.ops.convex_hull(tmp, input=vs) + bmesh.ops.dissolve_limit(tmp, angle_limit=math.radians(4.0), + verts=list(tmp.verts), edges=list(tmp.edges)) + bmesh.ops.triangulate(tmp, faces=list(tmp.faces)) + remap = {} + for f in tmp.faces: + for v in f.verts: + if v not in remap: + remap[v] = bm.verts.new(v.co) + bm.faces.new([remap[v] for v in f.verts]) + finally: + tmp.free() + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + col = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(col) + return col + + +def setup_bake_image(obj, target_mat, size): + img = bpy.data.images.new("ChurnNrm", size, size, alpha=True, float_buffer=False) + img.colorspace_settings.name = "Non-Color" + nodes = target_mat.node_tree.nodes + tex = nodes.new("ShaderNodeTexImage") + tex.image = img + nodes.active = tex + tex.select = True + obj.active_material_index = OAK_IDX + return img, tex + + +def bake_normal(high, low): + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = 1 + scene.cycles.use_denoising = False + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + high.select_set(True) + low.select_set(True) + bpy.context.view_layer.objects.active = low + return bpy.ops.object.bake( + type="NORMAL", use_selected_to_active=True, cage_extrusion=CAGE_EXTRUSION, + use_cage=False, normal_space="TANGENT", margin=4, + margin_type="ADJACENT_FACES", use_clear=True, target="IMAGE_TEXTURES", + ) + + +def export_unity(path, objects): + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + for ob in objects: + ob.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + bpy.ops.export_scene.gltf( + filepath=path, use_selection=True, export_yup=True, export_apply=True, + export_draco_mesh_compression_enable=False, export_animations=False, + ) + + +def check(skip_decimate, lift_z=False, **flags): + bpy.ops.wm.read_factory_settings(use_empty=True) + nothing = (None,) * 5 + low = build_churn_mesh("ChurnLow", **flags) + hi_flags = {k: v for k, v in flags.items() if k != "stray_vert"} + high = build_churn_mesh("ChurnHigh", **hi_flags) + mats = churn_materials() + assign_slots(low, mats) + assign_slots(high, mats) + if lift_z: + for v in low.data.vertices: + v.co.z += LIFT_Z + low.data.update() + bpy.context.view_layer.update() + if len(low.data.polygons) < 6 or not low.data.uv_layers: + return (fail("churn mesh did not build, or has no UV layer", 3),) + nothing + + base_tris = triangle_count(low.data) + slots = [s for s in low.data.materials if s is not None] + nmat, distinct = len(slots), len({id(s) for s in slots}) + idx_counts = {} + for poly in low.data.polygons: + idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1 + u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data) + bb = world_bbox(low) + size_x, size_y, size_z = bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2] + + img, tex = setup_bake_image(low, mats[OAK_IDX], BAKE_RES) + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "ChurnLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "ChurnLOD2", LOD2_TARGET, skip_decimate) + bpy.context.view_layer.update() + lod1_tris = evaluated_triangle_count(lod1) + lod2_tris = evaluated_triangle_count(lod2) + r1 = lod1_tris / base_tris if base_tris else 0.0 + r2 = lod2_tris / base_tris if base_tris else 0.0 + + collider = hull_collider(high, "ChurnCollider") + col_tris = triangle_count(collider.data) + + export_path = os.path.join(tempfile.gettempdir(), f"bdt_churn_{os.getpid()}.glb") + if os.path.exists(export_path): + os.remove(export_path) + export_unity(export_path, [low, collider]) + export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + # Blender points TMPDIR at its own temp preference, which on a portable + # build is the working directory, so the export must not outlive this. + if os.path.isfile(export_path): + os.remove(export_path) + + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + ca = churn_audit(low.data) + + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") + print(f"measured mat_index_counts={dict(sorted(idx_counts.items()))}") + print(f"measured base_tris={base_tris} lod1_tris={lod1_tris} " + f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}") + print(f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) " + f"overlap={overlap:.6f} nfaces={nfaces}") + print(f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"outer={OUTER_SIZE} zmin={bb[2]:.5f}") + print(f"measured collider_tris={col_tris} bake={bake_result} " + f"bake_has_data={img.has_data} export_bytes={export_size}") + print(f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}") + print(f"measured parts staves={ca['stave']} boards={ca['board']} lid={ca['lid']} " + f"hoops={ca['hoop']} slats={ca['slat']} handle={ca['handle']} other={ca['other']} " + f"stave_z={ca['stave_z']:.5f}") + print(f"measured joints seam=({ca['seam'][0]:.5f},{ca['seam'][1]:.5f}) " + f"handle_bite={ca['handle_bite']:.5f}") + print(f"measured seats hoop_bite=({ca['hoop_bite'][0]:.5f},{ca['hoop_bite'][1]:.5f}) " + f"hoop_proud={ca['hoop_proud']:.5f} lid=({ca['lid_seat'][0]:.5f},{ca['lid_seat'][1]:.5f}) " + f"hole_clear={ca['hole_clear']:.5f}") + print(f"measured stroke={ca['stroke']:.5f} reach={ca.get('reach', 0.0):.5f}") + + if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): + return (fail(f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", 4),) + nothing + if nmat != MATERIAL_COUNT or distinct != MATERIAL_COUNT: + return (fail(f"material slots {nmat} distinct {distinct} != {MATERIAL_COUNT}", 5),) + nothing + for idx, floor in FACE_FLOORS.items(): + if idx_counts.get(idx, 0) < floor: + return (fail(f"material {idx} faces {idx_counts.get(idx, 0)} < {floor}", 5),) + nothing + if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: + return (fail(f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", 6),) + nothing + if overlap > UV_OVERLAP_MAX: + return (fail(f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7),) + nothing + if (abs(size_x - OUTER_SIZE[0]) > BBOX_TOL or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL + or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL): + return (fail(f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) off outer {OUTER_SIZE}", 8),) + nothing + if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): + return (fail(f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " + "(--skip-decimate is the designed fail)", 9),) + nothing + if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): + return (fail(f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", 9),) + nothing + if col_tris > COLLIDER_TRIS_MAX: + return (fail(f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", 11),) + nothing + if bake_result != {"FINISHED"} or not img.has_data: + return (fail(f"bake failed result={bake_result} has_data={img.has_data}", 12),) + nothing + if export_size <= 0: + return (fail("export file missing or empty", 13),) + nothing + if (hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"] + or hyg["doubles"] or hyg["ngons"] or zf): + return (fail(f"hygiene {hyg} zfight={zf} (--stray-vert is the designed fail)", 15),) + nothing + if abs(bb[2]) > ZMIN_EPS: + return (fail(f"zmin {bb[2]:.6f} not within {ZMIN_EPS} of 0 " + "(--lift-z is the designed fail)", 16),) + nothing + if ca["stave"] != N_STAVES or ca["stave_z"] > STAVE_Z_MAX: + return (fail(f"staves: {ca['stave']} of {N_STAVES}, worst stave z={ca['stave_z']:.5f} " + f"> {STAVE_Z_MAX} (--short-stave is the designed fail)", 16),) + nothing + if (ca["board"] != N_BOARDS or ca["seam"][0] < BOARD_SEAM_MIN + or ca["seam"][1] > BOARD_SEAM_MAX): + return (fail(f"{ca['board']} of {N_BOARDS} bottom boards, seams {ca['seam']} outside " + f"[{BOARD_SEAM_MIN}, {BOARD_SEAM_MAX}] (--one-piece-bottom is the designed fail)", + 17),) + nothing + if not (HANDLE_BITE_MIN <= ca["handle_bite"] <= HANDLE_BITE_MAX): + return (fail(f"handle bite {ca['handle_bite']:.5f} outside [{HANDLE_BITE_MIN}, " + f"{HANDLE_BITE_MAX}] (--short-handle is the designed fail)", 17),) + nothing + if (ca["hoop"] != len(HOOP_ZS) or ca["hoop_bite"][0] < HOOP_BITE_MIN + or ca["hoop_bite"][1] > HOOP_BITE_MAX or ca["hoop_proud"] < HOOP_PROUD_MIN): + return (fail(f"{ca['hoop']} of {len(HOOP_ZS)} hoops, bite {ca['hoop_bite']} outside " + f"[{HOOP_BITE_MIN}, {HOOP_BITE_MAX}] or proud {ca['hoop_proud']:.5f} < " + f"{HOOP_PROUD_MIN} (--float-hoops is the designed fail)", 18),) + nothing + if (ca["lid"] != 2 or ca["lid_seat"][0] < LID_SEAT_MIN or ca["lid_seat"][1] > LID_SEAT_MAX): + return (fail(f"{ca['lid']} of 2 lid boards, seat {ca['lid_seat']} outside " + f"[{LID_SEAT_MIN}, {LID_SEAT_MAX}] (--float-lid is the designed fail)", 18),) + nothing + if not (HOLE_CLEAR_MIN <= ca["hole_clear"] <= HOLE_CLEAR_MAX): + return (fail(f"handle clears the lid hole by {ca['hole_clear']:.5f}, outside " + f"[{HOLE_CLEAR_MIN}, {HOLE_CLEAR_MAX}] (--tight-hole is the designed fail)", + 18),) + nothing + if ca["stroke"] < STROKE_MIN: + return (fail(f"plunge stroke {ca['stroke']:.5f} < {STROKE_MIN} " + "(--wide-dasher is the designed fail)", 20),) + nothing + return 0, low, high, mats, tex, collider + + +def wire_normal(mat, tex): + nt = mat.node_tree + nrm = nt.nodes.new("ShaderNodeNormalMap") + nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) + nt.links.new(nrm.outputs["Normal"], nt.nodes["Principled BSDF"].inputs["Normal"]) + + +def render_still(low, mats, tex, path, engine): + scene = bpy.context.scene + wire_normal(mats[OAK_IDX], tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + # Level on the floor: turned about Z only. + low.rotation_euler.z = math.radians(10.0) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=60.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Floor") + 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, 8.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 + + def light(name, kind, loc, energy, size, col, rot=(0, 0, 0)): + ld = bpy.data.lights.new(name, kind) + ld.energy = energy + if kind == "AREA": + ld.size = size + else: + ld.shadow_soft_size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", "AREA", (-2.4, -3.2, 3.2), 360.0, 3.0, (1.0, 0.95, 0.88), (50, 0, -35)) + light("Fill", "AREA", (3.2, -2.6, 1.6), 60.0, 5.0, (0.74, 0.84, 1.0), (68, 0, 50)) + light("Rim", "AREA", (-1.6, 2.6, 2.4), 220.0, 3.0, (0.62, 0.78, 1.0), (-55, 0, 200)) + ld = bpy.data.lights.new("Wedge", "SPOT") + ld.energy, ld.color = 220.0, (1.0, 0.66, 0.34) + ld.spot_size, ld.spot_blend, ld.shadow_soft_size = math.radians(50.0), 1.0, 0.3 + wedge = bpy.data.objects.new("Wedge", ld) + wedge.location = (0.5, 1.6, 2.0) + wedge.rotation_euler = (Vector((0.25, 0.5, 0.0)) - wedge.location).to_track_quat( + "-Z", "Y").to_euler() + scene.collection.objects.link(wedge) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (1.23, -2.86, 1.28) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.52) + scene.collection.objects.link(aim) + con = cam.constraints.new("TRACK_TO") + con.target = aim + con.track_axis = "TRACK_NEGATIVE_Z" + con.up_axis = "UP_Y" + scene.camera = cam + + scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + scene.cycles.samples = 32 + scene.cycles.device = "CPU" + 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 = "WEBP" if path.lower().endswith(".webp") else "PNG" + if path.lower().endswith(".webp"): + scene.render.image_settings.quality = 90 + scene.render.filepath = path + scene.view_settings.view_transform = "Standard" + + fcode = gallery_framing.check_framing(scene, cam, hero=[low], elements=[low], 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): + return fail("render produced no file", 14) + return 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None) + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) + p.add_argument("--skip-decimate", action="store_true") + p.add_argument("--stray-vert", action="store_true") + p.add_argument("--lift-z", action="store_true") + p.add_argument("--short-stave", action="store_true") + p.add_argument("--one-piece-bottom", action="store_true") + p.add_argument("--short-handle", action="store_true") + p.add_argument("--float-hoops", action="store_true") + p.add_argument("--float-lid", action="store_true") + p.add_argument("--tight-hole", action="store_true") + p.add_argument("--wide-dasher", action="store_true") + args = p.parse_args(argv) + + code, low, _high, mats, tex, _col = check( + args.skip_decimate, + lift_z=args.lift_z, + stray_vert=args.stray_vert, + short_stave=args.short_stave, + one_piece_bottom=args.one_piece_bottom, + short_handle=args.short_handle, + float_hoops=args.float_hoops, + float_lid=args.float_lid, + tight_hole=args.tight_hole, + wide_dasher=args.wide_dasher, + ) + if code: + return code + if args.output: + rcode = render_still(low, mats, tex, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + print("butter churn OK") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + traceback.print_exc() + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(1) diff --git a/showcase/butter-churn/preview.webp b/showcase/butter-churn/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..5c137449a9ddb8b26c80a1051ff37a37e1dbca59 GIT binary patch literal 14128 zcmV-0H_ymYNk&E}Hvj-vMM6+kP&gnQHvj-|>;attDzF5j0zNSqibNtIp&=mhn3zBY z32AQhs80Xo|LG2-L3{g4{xfh}037=KuKD%_$psGM6@33cM5j|;U3h+6N43x`6FGqj z^UL@AUZ1nEru%q5s?8R)0=E<@#m) zXaAOZng0|2PwRQ>RsGW@uirkA{+s^J9h3mN&T*Ll9t_>ay%wH2L3euCq~}rWWz)^& zV337{V2rZ4CTt)DxmSsf?j}ZEJl<9a%cq;FEumYFQF}a0A^nw5oPWO;N6ZdA%ZJK zE~meA`$rGZ5W;3R)@D`vI_ZmS|Ak`wy%?f{5e~z+=z*&!pFq?M9!>ScDG^ENcR(!! zT8WGdiKVo<<9)^`|ITTPyAAQi(hy3k%0UaKCQz&ZMgb-$W{VMn>cnWb3`pDLG`ms` zUwAtbI%;62V=Cz`F+@e_aPHughr9jlaTUjzGWFTqaazYBdKqiZvoZ)}{13za{_$FC zrj7S@2ulBH)B>aMmh_YcPhSqr(_39M;zG?Hyi7H%tcqp6{S2w;yWWQ zkXZJpX5}Q_D|}UqdJqI>tV~K$+tte#Cw=MxZ|6m>7;)l~N2^f4!N=2DS=;YjN+KHIF0;pHi+hID6P!T8sYUwCYK#)XS&NvsO*mlEXy!S1}$`z zIjGwt-Z1jY1y{hOjkOwnNUHF&Uo+WbemPi4nkLHuH!6CXGRfVa4R8l;u@T!~jh`^~ zDkn8{=RSMpqqnfkrR5HUgbMC`dP1qf4%@HjcS0JQDo?^w<*Hd2?nE*9 zKww)@wJn>1NZ)0L$5j=3<$7XAu?-)aL{J(BNo7P9j9^o}|CR?<=`P_!&2D#R>MJmr zP+Pl%ghcv}w4vU)Dc94dsh=ZF&cimcIN;u;_=GUIHTRhoM?C8xf4<)$w_O(l!C7+6 zDEZ6;&5Y)sn=xfI!Bb`VBu?~Fv9P+?O!D$ufD206^pU^DNR z!6o)iGR8Dg@6W%yp(@rjZMv80m?Gm~Zch^Rx_pQE!qr5}f2GX3qY~AapU4j6bdr*S z2LyYp%H*T?JQ)#$gzL zV*t7Pv7y^$SxbSQB}W+k_w&%{T^)`74MF6FIm|WZ>X|mWqJJS6P`tkJ{BaeuDt78c zS?nL7!hcr&UWJ9e*u{)Tf72FITkf;z3}gfy8pa1z+%qE(A@}Y>#0snKdl#RQkd(Vl z9t{uA+ujK0yfAVu3V;&dMGcY8UM3x?rc+|oxDzMn7@}=tta+r}1(6#hDkXomZz)uk z?`EMQ){*lZ&|k_=>%)z+)XH#?cbfEymF@k6Gd?hCEuuq;*B>&w$07e&Xo6?3zaCh~ zQbS{Ebqc;DT0gM5$oRDw2z5%R5=yYqz2OB24ykeh!o+wf+dP|_snyZ)y zA|$o16yoH48{Q%Z?cfZ(#x}ADY{@}VCZIR~976v&Sb~Tlq~S?XIZ%fXK@Z?5C*9wS z2)l(onFn9CG*R`|o(C_Mcyn9hp(Bsg)qt36VRMLSs*qhVcrPD+(FOu7d%kg26paVu znb~L=+e94MtQvUan3f}G9Qg7-`-p5@veiX~y)HIA@`De_{`)FQnVgSr-Y7^F{We8s zp*p9k@SasCQ+miG(*ji@*MadMt*VG=f` zofghXA4c{hAs-WQ!FWqNzRZxu=L*e-46ld{U1Lg6o(q!1sz}_G_aKG?rHZYXlb++(NBHo0@ry#(WdMU{kodn?=gB_Hx6cd_m z%CDIuuDt;{f||c1+n_GB5$fkOjS?l^m{nBL!I*Cb4z|YLPrI1t%Eoe>4~CflT6Xg5 z$@D9Y3F8mMX_*Zl<8iq*XwRCCO)060

XbhlF0x42C@$xY;}SSfSwe}z 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 literal 0 HcmV?d00001 diff --git a/showcase/gallery.json b/showcase/gallery.json index 6ebeb97c..c31232bb 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -439,6 +439,19 @@ "mesh", "export" ] + }, + { + "name": "butter-churn", + "dir": "showcase/butter-churn", + "teaches": "A procedural plunge butter churn — sixteen tapered oak staves, three iron hoops, a three-board bottom, a two-board lid and a maple cross dasher — carried through UVs, bake, LOD, compound collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "alt": "A tall staved oak butter churn narrowing toward the top, bound by three dark iron hoops, with a two-board lid and a pale maple dasher handle rising through it.", + "witnessesFix": "Recomputed: 5684 tris, three materials with 2240 oak, 160 maple and 576 iron faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.340×0.340×1.050 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, all sixteen staves on the floor, three bottom boards with 1.2 mm seams, the handle 14 mm into the slats, three hoops 2.5 mm into the staves and 4.9 mm proud, the lid 1.4–2.0 mm onto the stave tops, 5 mm of clearance round the handle, and a 514 mm plunge stroke for a 101 mm reach. --wide-dasher exits 20 at 140 mm while the plunger still clears the staves at rest; --tight-hole exits 18.", + "hero": "docs/gallery/assets/butter-churn-hero.webp", + "preview": "showcase/butter-churn/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index 96d07ada..de48bb2d 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -106,5 +106,6 @@ {"name": "grain-sacks", "script": "showcase/grain-sacks/grain_sacks.py"}, {"name": "brazier", "script": "showcase/brazier/brazier.py"}, {"name": "apothecary-shelf", "script": "showcase/apothecary-shelf/apothecary_shelf.py"}, - {"name": "wooden-yoke", "script": "showcase/wooden-yoke/wooden_yoke.py"} + {"name": "wooden-yoke", "script": "showcase/wooden-yoke/wooden_yoke.py"}, + {"name": "butter-churn", "script": "showcase/butter-churn/butter_churn.py"} ]