From d9b24082297a1c5ec93f64f9515701c00cf6bafb Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:48:15 -0400 Subject: [PATCH] feat(showcase): add apothecary-shelf The apothecary shelf was the first unclaimed showcase candidate. What an envelope check cannot see on a stocked shelf is whether each vessel fits under the shelf above: a flask too tall still stands on its shelf, the shelf above is still in its dado, and the bounding box does not move. The piece reads every vessel's top and the underside of the member above it off the mesh and asserts a 20 mm headroom floor; --tall-flask exits 20 at -5.5 mm while every seat, joint and the envelope still pass. Also asserts dado bites into both sides, cork, lid and knob bites, vessel, drawer and label seats, zero vessel overlaps, form spread and chamfered wood, each with a falsifier proven on 4.5.11, 5.1.2 and 5.2.1. 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 | 3 +- docs/gallery/apothecary-shelf/index.html | 2008 +++++++++++++++++ .../asset-sheets/apothecary-shelf.webp | Bin 0 -> 38092 bytes .../gallery/assets/apothecary-shelf-hero.webp | Bin 0 -> 38106 bytes .../apothecary-shelf-contact-sheet.webp | Bin 0 -> 60064 bytes docs/gallery/index.html | 15 +- showcase/apothecary-shelf/README.md | 263 +++ showcase/apothecary-shelf/apothecary_shelf.py | 1639 ++++++++++++++ showcase/apothecary-shelf/preview.webp | Bin 0 -> 34212 bytes showcase/gallery.json | 13 + tests/smoke/catalog.json | 3 +- 14 files changed, 3949 insertions(+), 9 deletions(-) create mode 100644 docs/gallery/apothecary-shelf/index.html create mode 100644 docs/gallery/asset-sheets/apothecary-shelf.webp create mode 100644 docs/gallery/assets/apothecary-shelf-hero.webp create mode 100644 docs/gallery/contact-sheets/apothecary-shelf-contact-sheet.webp create mode 100644 showcase/apothecary-shelf/README.md create mode 100644 showcase/apothecary-shelf/apothecary_shelf.py create mode 100644 showcase/apothecary-shelf/preview.webp diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 3087f6ad..6aae6707 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -168,6 +168,7 @@ "showcase/stone-archway", "showcase/rope-bridge", "showcase/grain-sacks", - "showcase/brazier" + "showcase/brazier", + "showcase/apothecary-shelf" ] } diff --git a/CLAUDE.md b/CLAUDE.md index c01d2e38..6ff1afe5 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, 31 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 32 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 df201fc4..8750f270 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  31 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  32 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 31 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 32 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).

-31 showcase pieces — click to expand the preview grid +32 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 @@ -140,6 +140,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Rope bridge: a sagging plank deck on foot ropes between two pairs of log posts, with lashed hemp hand ropes, suspenders, and ropes staked to the ground, on a dark studio floor Grain sacks: three burlap sacks gathered and tied with twine, two standing and one lying slumped in front, one blue-striped and one red-striped, on a dark studio floor 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

[`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9. @@ -200,6 +201,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`brazier`](showcase/brazier/) — procedural iron brazier with ash and glowing charcoal whose tip angle is recomputed from a density-weighted mass centre and the feet on the floor, through the same pipeline. Falsifiers `--tuck-legs` exits 19, `--overfill` exits 18. +[`apothecary-shelf`](showcase/apothecary-shelf/) — procedural apothecary shelf stocked with fifteen labelled bottles, flasks and jars, whose headroom under the shelf above is recomputed per vessel, through the same pipeline. Falsifiers `--tall-flask` exits 20, `--short-shelves` exits 17. +
## Examples diff --git a/ROADMAP.md b/ROADMAP.md index 18f806ef..9342f0d1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -136,9 +136,10 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Rope bridge (planks, rope rails, sag between anchors) as a game-prop showcase piece~~ **SHIPPED** as `showcase/rope-bridge/` — log posts, through-tenoned sills, twenty planks seated on laid foot ropes, lashed hand ropes, suspenders, staked tie-offs; a least-squares parabola is fitted to the plank tops and `--vee-deck` exits 19 on it with the sag still in band - ~~Grain sacks (cloth sacks, some slumped, tied necks) as a game-prop showcase piece~~ **SHIPPED** as `showcase/grain-sacks/` — three settled, pleated burlap sacks, one lying, twine ties seated in the neck waist, sacks pressed into one another; flat contact patch summed from the mesh, `--round-bottom` exits 19 on it while zmin still passes - ~~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 +- ~~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 - Butter churn (staved tub, lid, dasher) as a game-prop showcase piece +- 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 - ~~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/apothecary-shelf/index.html b/docs/gallery/apothecary-shelf/index.html new file mode 100644 index 00000000..1c59d7f1 --- /dev/null +++ b/docs/gallery/apothecary-shelf/index.html @@ -0,0 +1,2008 @@ + + + + + + apothecary-shelf — Showcase — Blender Developer Tools + + + + + + + + + + + + + + + + + + + + + + + + +
+

apothecary-shelf

+

A procedural apothecary shelf — housed shelves, gallery rails, a drawer bank and an arched crest, stocked with fifteen lathed bottles, flasks and glazed jars, each corked or lidded and labelled — carried through UVs, bake, LOD, convex collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+
+
+ +

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

+
witnesses Recomputed: 10456 tris, six materials with a face floor each, UVs in 0..1 with zero AABB overlap, outer AABB 0.876×0.274×1.076 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, both sides grounded, seven cross members housed 8 mm into each side and 14 mm clear of its far face, fifteen vessels seated 1.0–1.8 mm into their shelves, every cork and lid biting 5 mm, every label 0.3 mm into its body and 0.8 mm proud, no vessel touching another or the carcass, and at least 24 mm of headroom between every vessel and the member above it. --tall-flask exits 20 at −5.5 mm while every seat and the envelope still pass; --short-shelves exits 17 on the dado bite.
+
+
blender --background --python showcase/apothecary-shelf/apothecary_shelf.py --
+ +
+
+

An apothecary shelf: a stained carcass with three shelves housed in its sides, a gallery rail along each shelf, lapped back boards, a bank of three drawers with brass knobs and an arched crest, stocked with fifteen vessels in five forms (bottle, flask, jar, albarello, vial). Every vessel is corked or lidded and carries a paper label. 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-bmeshchamfered boards, arched crest, lathed vessels, closures and knobs, labels on the bodies' rings, all in one bmesh
skills/custom-propertiesface attributes (PlankTone, GrainDir, Tint) read by the shaders
skills/procedural-materials-and-shadersgrained stain, tinted glass, glaze, cork, inked paper, aged brass
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 over the carcass and knobs
snippets/lod_chain.pyLOD naming and ratio pattern
examples/mesh-hygiene-audithygiene combinatorics (copied, not imported)
+

The budget that matters

+

A stocked shelf fails invisibly when a vessel does not fit between its shelf and the one above. A flask a few centimetres too tall runs its neck up into the next shelf, and nothing else notices:

+
  • the flask still stands on its shelf, so the seat budget passes;
  • the shelf above is still in its dado, so the joint budget passes;
  • the neck stops inside the shelf, so the bounding box does not move;
  • the triangle count is the same, because only the height changed.
+

The piece reads each vessel's top (body or closure, whichever is higher) and the underside of the lowest horizontal member above it off the finished mesh, and asserts a headroom floor of 20 mm. The tightest vessel clears by 24.05 mm.

+

--tall-flask is the falsifier built for exactly this. It stretches the flask on the middle shelf by 1.20. It then runs 5.5 mm into the shelf above and exits 20, while every seat, every joint and the envelope still pass.

+

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 up to 8 bytes, which is exporter metadata and not a budget).

+
BudgetBandMeasured
Base triangles9800–1110010456
LOD1 ratio0.32–0.620.5000
LOD2 ratio0.10–0.350.2200
Material slotsexactly 6, distinct6
Wood / glass / glaze faces≥ 450 / 1800 / 1300525 / 2080 / 1520
Cork / paper / brass faces≥ 850 / 380 / 300960 / 420 / 336
UV boundsinside 0..1(0.0009, 0.0010)–(0.9991, 0.9990)
UV AABB overlap≤ 1e-50.000000
Outer AABB0.876 × 0.274 × 1.076 m ± 0.0200.8760 × 0.2740 × 1.0760
Collider triangles≤ 200138
Normal bake{'FINISHED'} with image data{'FINISHED'}, has_data=True
glTF exportfile written, non-empty~449 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 sides2 sides, each zmin ≤ 1e-42 at 0.00000
Dado7 cross members, each ≥ 5 mm into both sides and ≥ 6 mm clear of the far face8.00 mm in, 14.00 mm clear
Closure biteevery cork and lid 2–10 mm into its body5.00 mm
Knob bite3 knobs, each 2–10 mm into its drawer front4.00 mm
Vessel seat15 vessels, each 0.5–3.0 mm into its shelf1.00–1.80 mm
Drawer seat3 drawer fronts, each 1–4 mm into the base2.00 mm
Label seatinner face 0.1–0.8 mm inside the body, outer face ≥ 0.4 mm proud0.30 mm in, 0.80 mm proud
Headroomevery vessel ≥ 20 mm under the member above24.05 mm
Vessel overlaps0, vessel against vessel and against the carcass0
Form spreadwidest / narrowest body ≥ 2.0; tallest / shortest ≥ 1.53.146 / 2.202
Edge treatmentright-angle wood edges0
+

Real-world size: 0.88 m wide, 1.08 m to the top of the crest and 0.27 m deep. It is a wall shelf for a shop counter or a study, and each compartment is 260 mm clear.

+

Construction

+
  • Carcass. Two full-height sides carry everything and are the only named supports. The base, the three shelves and the three gallery rails are each housed DADO (8 mm) into both sides and stop 14 mm short of the outer faces. The top board is seated over the sides and the back boards (TOP_SEAT). The crest is an extruded outline whose top is a shallow arc, seated CREST_BITE into the top board. Every board is chamfered at 2.5 mm with material= pinned to wood, edges sorted by index. The crest's caps are single n-gons, chamfered and then triangulated.
  • Back boards. Four vertical boards, lapped 2 mm into one another rather than butted. An open seam showed daylight through the back in the first close-up. A lap on one plane is a coplanar pair, so every other board steps forward 1.5 mm. The shelves and base bite into them.
  • Drawers. Three fronts stand DRAWER_BITE into the base, with a 4 mm reveal all round. Each has a turned brass knob lathed along −Y, its shank KNOB_BITE into the front.
  • Vessels. Each vessel is a closed lathe of exactly twelve rings between two poles, so every form costs the same triangles. Every foot is chamfered. The five forms:
  • bottle: shoulder, neck and lip
  • flask: a bulb on a small flat base, with a long neck
  • jar: squat and wide-mouthed
  • albarello: waisted
  • vial: small
+

Sizes vary by a closed-form ±5 % per vessel. Each shelf shares its clear width out evenly. Vessels step front and back alternately inside the depth left between the rail and the back boards.

+
  • Closures. A cork is narrower than the mouth and flares above it. Its flare stops short of the neck's own radius, because a cork wall on the neck's cylinder is a coplanar pair with it. A lid is wider than the rim: a skirt, a dome and a knob. Both sit CLOSURE_BITE into the body.
  • Labels. Each label is built on the host's arc with the host's segment count. Every label vertex sits at a body vertex's own angle and ring height, so the paper follows a waist or a bulb exactly. The inner face is 0.3 mm inside the body and the outer face 0.8 mm proud. The label is centred on the front and turned with the vessel's yaw.
  • Seats vary by vessel. Each vessel on a shelf sinks by a different step (1.0–1.8 mm). Sunk to one depth, two bottom caps on a shelf lie in one plane, a coplanar cross-shell pair.
+

Findings the budgets forced

+
  • Coplanar corks. The first corks flared to 1.10 × their radius, which put a cork wall 0.1 mm off the neck cylinder of the same bottle: 32 coplanar pairs on two bottles. The flare is now 1.20 × and the count is 0.
  • Matching parts to vessels. Closures and labels were first matched to the body with the nearest axis in plan. The three shelves stack vessels in near-identical plan positions, so 12 of 15 labels went to a vessel on another shelf, and the label seat read −23 mm. They are now matched only among bodies at the part's own height.
  • Glass. EEVEE transmission rendered every bottle as grainy speckle. It stayed with and without raytracing, and at 256 samples. The glass now carries its depth in a facing ramp and a clear coat: dark through the middle, lighter at the silhouette.
  • Daylight through the back. A 2 mm butt seam between back boards showed the background as a bright line down the middle of the upper compartment. The boards are now lapped with an alternating step.
+

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; seats, lap steps and cork flares chosen to keep it at 0
Named supportsyesthe two sides (--float-side)
Joint-fit budgetsyesdado bite and far-face clearance per cross member (--short-shelves)
A member is tenoned into its seatyessides into the top, crest into the top, back boards into base and top
A platform bears on somethingyesevery shelf housed in both sides and biting the back boards
Carried parts bite their bearersyesvessels in shelves (--float-jars), drawer fronts in the base (--lift-drawers)
A joint bites; touching is not joiningyesclosures (--pop-corks) and knobs (--pull-knobs) as banded bites
Bands on a curved host are built on the host's arcyeslabels on the body's own rings and angles (--float-labels)
Scattered parts do not interpenetrateyesexit 21 (--crowd-jars)
Scatter varies in sizeyesform spread, exit 22 (--uniform-vessels)
Edge treatment: no right anglesyesexit 23 (--sharp-rail), wood only; lathed feet are chamfered by construction
Aim an edge falsifier at one memberyesone rail left square: 12 edges, −32 triangles
Chamfer n-gon caps, then triangulateyescrest caps
Sort bmesh operator inputsyesbevel edges sorted by index
A plank wall is boardsyesfour lapped back boards
Identical boards read as CGyesper-board PlankTone and GrainDir
Shading is part of the modelyesvessels, closures and knobs smooth (blown, turned); every edge over 35° hard, so boards read flat with crisp chamfers
One substance, one slotyeswood, glass, glaze, cork, paper, brass; glass and glaze colour per vessel from Tint
Iron is not chromen/abrass knobs: metallic 0.9, roughness 0.42, darkened by noise
Bake texels per UV cellmeasured, not a budget512 px over a 32-cell grid, about 15 px per cell
The bake cage is narrower than the nearest neighbouryesCAGE_EXTRUSION 0.01 m
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 except --pull-knobs (+6 mm in Y, inside BBOX_TOL)
Mirror symmetry, plumb, rope, masonry, roofs, rings, scatter on terrain, vessels with liquidnothe piece has none of these
+

Falsifiers

+

Each breaks one pipeline stage so a named budget fails. All fourteen 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 carcass15
--lift-zgrounded zminlifts the whole mesh 50 mm16
--float-sidenamed sideslifts the left side 5 mm; the right side still grounds the AABB16
--short-shelvesdado bitestops the three shelves 4 mm short of the sides; −4.0 mm17
--pop-corksclosure bitelifts every cork and lid 10 mm; −5.0 mm17
--pull-knobsknob bitepulls every knob 8 mm out of its front; −4.0 mm17
--float-jarsvessel seatlifts every vessel 6 mm off its shelf; −5.0 to −4.2 mm18
--lift-drawersdrawer seatlifts the drawer fronts 3 mm; −1.0 mm18
--float-labelslabel seatbuilds every label 1.1 mm off its body; −1.1 mm18
--tall-flaskheadroomstretches the middle-shelf flask 1.20 into the shelf above; −5.5 mm20
--crowd-jarsvessel overlapsdraws the bottom shelf's jars to 0.55 of their spacing; 3 overlaps21
--uniform-vesselsform spreadone radius and height for every vessel; 1.000 / 1.00022
--sharp-railedge treatmentleaves the top rail unchamfered; 12 right-angle edges23
+

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 side floating (--lift-z, --float-side)
17Dado, closure or knob bite (--short-shelves, --pop-corks, --pull-knobs)
18Vessel, drawer or label seat (--float-jars, --lift-drawers, --float-labels)
20Headroom under the member above (--tall-flask)
21Vessels overlap each other or the carcass (--crowd-jars)
22Form spread below floor (--uniform-vessels)
23Right-angle wood edges (--sharp-rail)
+

Run it

+
# Budget check, no render. ~4.5 s on 5.1 and 5.2.
+blender --background --python apothecary_shelf.py --
+
+# Falsifier: the middle-shelf flask runs into the shelf above. Must exit 20.
+blender --background --python apothecary_shelf.py -- --tall-flask
+
+# Falsifier: the shelves stop short of their dados. Must exit 17.
+blender --background --python apothecary_shelf.py -- --short-shelves
+
+# Render the gallery still (EEVEE; --engine cycles on a GPU-less host).
+blender --background --python apothecary_shelf.py -- --output apothecary_shelf.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 triangles104561045610456
LOD1 / LOD2 tris5228 / 2300samesame
Face counts (wood / glass / glaze / cork / paper / brass)525 / 2080 / 1520 / 960 / 420 / 336samesame
Outer AABB0.8760 × 0.2740 × 1.0760samesame
Collider tris138138138
Headroom / form spread24.05 mm / 3.146, 2.202samesame
glTF bytes448604448600448596
+
+
+

Source

+
+ showcase/apothecary-shelf/apothecary_shelf.py + View on GitHub → +
+
"""Game-ready apothecary shelf — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural apothecary shelf: a stained
+carcass with housed shelves, gallery rails, a bank of three drawers and a
+crest board, stocked with fifteen turned and blown vessels in five forms
+(bottle, flask, jar, albarello, vial), each closed by a cork or a lid and
+carrying a paper label. Carried through UVs, six materials (wood, glass,
+glaze, cork, paper, brass), a high-to-low normal bake, an LOD chain, a
+convex collider, and a Unity glTF export.
+
+The budget that matters here is the one a stocked shelf fails invisibly:
+every vessel has to fit between its shelf and the one above it. A flask a
+few centimetres too tall runs its neck up into the next shelf, and the
+bounding box, the triangle count and every seat budget still pass,
+because the flask stands where it should and the shelf above it is still
+in its dado. The piece reads each vessel's top and the underside of the
+member above it off the finished mesh and asserts a headroom floor.
+
+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, ``--float-side`` the named sides,
+``--short-shelves`` the dado bite, ``--pop-corks`` the closure bite,
+``--pull-knobs`` the knob bite, ``--float-jars`` the vessel seat,
+``--lift-drawers`` the drawer seat, ``--float-labels`` the label seat,
+``--tall-flask`` the headroom, ``--crowd-jars`` vessel interpenetration,
+``--uniform-vessels`` the size spread, ``--sharp-rail`` edge treatment.
+
+No randomness: every vessel's form, size, yaw, tint and seat 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 apothecary_shelf.py --
+    blender --background --python apothecary_shelf.py -- --tall-flask
+    blender --background --python apothecary_shelf.py -- --output apothecary_shelf.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Matrix, Vector
+from mathutils.bvhtree import BVHTree
+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 carcass. X across, Y front (-) to back (+), Z up. Two full-height
+# sides carry everything; every cross member is housed into both of them.
+W_OUT = 0.840
+SIDE_T = 0.022
+DEPTH = 0.250
+SIDE_H = 1.000
+DADO = 0.008
+TOP_T = 0.024
+TOP_SEAT = 0.006
+TOP_OVER = 0.018
+TOP_BACK_OVER = 0.006
+CREST_H = 0.058
+CREST_SHOULDER = 0.028
+CREST_ARC = 12
+CREST_T = 0.018
+CREST_BITE = 0.004
+BACK_T = 0.012
+BACK_INSET = 0.010
+BACK_BITE = 0.005
+N_BACK = 4
+BACK_LAP = 0.002
+BACK_STEP = 0.0015
+SHELF_T = 0.020
+SHELF_SETBACK = 0.004
+SHELF_TOPS = (0.175, 0.455, 0.735)
+RAIL_H = 0.028
+RAIL_T = 0.010
+RAIL_INSET = 0.010
+RAIL_BITE = 0.002
+BASE_Z = 0.030
+BASE_T = 0.018
+BASE_SETBACK = 0.012
+N_DRAWERS = 3
+DRAWER_T = 0.018
+DRAWER_SETBACK = 0.002
+DRAWER_GAP = 0.004
+DRAWER_BITE = 0.002
+KNOB_BITE = 0.004
+KNOB_LEN = 0.022
+CHAMFER = 0.0025
+
+# Vessels: a closed lathe body (twelve rings between two poles), a closure
+# (cork or lid, five rings), and a paper label wrapped on the body's own
+# rings. Every form has the same ring counts, so a vessel costs the same
+# triangles whatever its form, and swapping forms moves no triangle budget.
+SEG = 16
+LABEL_COLS = 4
+LABEL_BITE = 0.0003
+LABEL_PROUD = 0.0008
+FLOAT_LABEL = 0.0011
+CLOSURE_BITE = 0.005
+POP_CORK = 0.010
+SEAT_BASE = 0.0010
+SEAT_STEP = 0.0002
+FLOAT_JARS = 0.006
+LIFT_DRAWERS = 0.003
+PULL_KNOBS = 0.008
+FLOAT_SIDE = 0.005
+TALL_FLASK = 1.20
+CROWD = 0.55
+VESSEL_CLEAR = 0.006
+UNIFORM_R = 0.030
+UNIFORM_H = 0.150
+
+# (form, glass or glaze) per shelf, left to right.
+LAYOUT = (
+    ("jar", "albarello", "flask", "albarello", "jar"),
+    ("bottle", "albarello", "vial", "bottle", "flask"),
+    ("vial", "bottle", "vial", "flask", "bottle"),
+)
+FORMS = {
+    # radius, body height, material, closure
+    "bottle": (0.034, 0.200, "glass", "cork"),
+    "flask": (0.050, 0.210, "glass", "cork"),
+    "jar": (0.055, 0.130, "glaze", "lid"),
+    "albarello": (0.048, 0.170, "glaze", "lid"),
+    "vial": (0.018, 0.100, "glass", "cork"),
+}
+GLASS_TINTS = ((0.42, 0.17, 0.03), (0.14, 0.30, 0.11), (0.08, 0.14, 0.36), (0.22, 0.09, 0.26))
+GLAZE_TINTS = ((0.74, 0.68, 0.54), (0.34, 0.44, 0.60), (0.36, 0.20, 0.10))
+
+BBOX_TOL = 0.020
+OUTER_SIZE = (0.876, 0.274, 1.076)
+
+BASE_TRIS_MIN = 9800
+BASE_TRIS_MAX = 11100
+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 = 6
+FACE_FLOORS = {0: 450, 1: 1800, 2: 1300, 3: 850, 4: 380, 5: 300}
+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
+SIDE_Z_MAX = 1e-4
+DADO_MIN = 0.005
+DADO_FAR_MIN = 0.006
+CLOSURE_BITE_MIN = 0.002
+CLOSURE_BITE_MAX = 0.010
+KNOB_BITE_MIN = 0.002
+KNOB_BITE_MAX = 0.010
+SEAT_MIN = 0.0005
+SEAT_MAX = 0.0030
+DRAWER_SEAT_MIN = 0.001
+DRAWER_SEAT_MAX = 0.004
+LABEL_BITE_MIN = 0.0001
+LABEL_BITE_MAX = 0.0008
+LABEL_PROUD_MIN = 0.0004
+HEADROOM_MIN = 0.020
+DIAM_SPREAD_MIN = 2.0
+HEIGHT_SPREAD_MIN = 1.5
+RIGHT_ANGLE_TOL = math.radians(5.0)
+
+WOOD_IDX = 0
+GLASS_IDX = 1
+GLAZE_IDX = 2
+CORK_IDX = 3
+PAPER_IDX = 4
+BRASS_IDX = 5
+N_CROSS = len(SHELF_TOPS) * 2 + 1
+
+
+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 frac(x):
+    return x - math.floor(x)
+
+
+# --- profiles ---------------------------------------------------------------
+
+
+def body_profile(form, r, h):
+    """(r, z) from the bottom pole to the top pole: exactly twelve rings.
+
+    Every foot is chamfered, so no body meets the shelf on a right angle.
+    Returns the profile, the label's ring range and the mouth radius.
+    """
+    c = 0.003
+    if form == "bottle":
+        n = 0.012
+        rings = [(r - c, 0.0), (r, c), (r, 0.25 * h), (r, 0.45 * h), (r, 0.65 * h),
+                 (r * 0.96, 0.72 * h), (r * 0.78, 0.80 * h), (r * 0.45, 0.86 * h),
+                 (n, 0.90 * h), (n, 0.96 * h), (n + 0.0035, 0.965 * h), (n + 0.0035, h)]
+        label, mouth = (3, 5), n
+    elif form == "flask":
+        n = 0.011
+        rings = [(r * 0.55, 0.0), (r * 0.62, 0.004), (r * 0.86, 0.06 * h), (r * 0.98, 0.14 * h),
+                 (r, 0.24 * h), (r * 0.95, 0.34 * h), (r * 0.75, 0.44 * h), (r * 0.40, 0.51 * h),
+                 (n, 0.56 * h), (n, 0.93 * h), (n + 0.003, 0.94 * h), (n + 0.003, h)]
+        label, mouth = (4, 6), n
+    elif form == "jar":
+        rings = [(r - c, 0.0), (r, c), (r, 0.20 * h), (r, 0.45 * h), (r, 0.70 * h),
+                 (r * 0.99, 0.80 * h), (r * 0.93, 0.86 * h), (r * 0.86, 0.90 * h),
+                 (r * 0.84, 0.94 * h), (r * 0.88, 0.955 * h), (r * 0.88, h - 0.002),
+                 (r * 0.86, h)]
+        label, mouth = (3, 5), r * 0.88
+    elif form == "albarello":
+        rings = [(r - c, 0.0), (r, c), (r, 0.10 * h), (r * 0.90, 0.22 * h), (r * 0.86, 0.45 * h),
+                 (r * 0.90, 0.68 * h), (r, 0.80 * h), (r * 0.96, 0.87 * h), (r * 0.80, 0.91 * h),
+                 (r * 0.76, 0.95 * h), (r * 0.80, 0.96 * h), (r * 0.80, h)]
+        label, mouth = (4, 6), r * 0.80
+    else:  # vial
+        n = 0.009
+        c = 0.0018
+        rings = [(r - c, 0.0), (r, c), (r, 0.20 * h), (r, 0.45 * h), (r, 0.70 * h),
+                 (r * 0.96, 0.80 * h), (r * 0.80, 0.85 * h), (r * 0.62, 0.88 * h),
+                 (n, 0.90 * h), (n, 0.96 * h), (n + 0.002, 0.965 * h), (n + 0.002, h)]
+        label, mouth = (3, 5), n
+    prof = [Vector((0.0, 0.0))] + [Vector(p) for p in rings] + [Vector((0.0, h))]
+    return prof, label, mouth
+
+
+def closure_profile(kind, mouth):
+    """(r, z) from the closure's own bottom: five rings between two poles.
+
+    A cork is narrower than the mouth and flares above it; a lid is wider
+    than the rim, a skirt, a dome and a knob.
+    """
+    if kind == "cork":
+        rc, ln = mouth - 0.001, 0.022 if mouth > 0.010 else 0.016
+        # The flare stops short of the neck's own radius: a cork wall on the
+        # neck's cylinder is a coplanar pair with it.
+        rings = [(rc, 0.0), (rc * 1.06, 0.5 * ln), (rc * 1.20, 0.8 * ln),
+                 (rc * 1.20, ln - 0.002), (rc * 1.08, ln)]
+        top = ln
+    else:
+        rl = mouth + 0.004
+        rings = [(rl, 0.0), (rl, 0.010), (rl * 0.80, 0.019), (0.012, 0.024), (0.014, 0.034)]
+        top = 0.036
+    return [Vector((0.0, 0.0))] + [Vector(p) for p in rings] + [Vector((0.0, top))]
+
+
+def knob_profile():
+    """(r, t) along the knob axis, from the end buried in the drawer front."""
+    rings = [(0.005, 0.0), (0.005, 0.009), (0.004, 0.011), (0.010, 0.016), (0.011, 0.019),
+             (0.009, 0.0215)]
+    return [Vector((0.0, 0.0))] + [Vector(p) for p in rings] + [Vector((0.0, KNOB_LEN))]
+
+
+# --- 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 lathe(bm, profile, n, mat_idx, ctx, xf, phase=0.0, piece=0):
+    """Revolve an (r, z) profile about local Z, placed by ``xf``.
+
+    r == 0 at either end makes a pole. One strip island. Returns the
+    rings (a vertex or a list of n vertices per profile point).
+    """
+    island = new_island(ctx)
+    s = [0.0]
+    for a, b in zip(profile, profile[1:]):
+        s.append(s[-1] + (b - a).length)
+    rings = []
+    for p in profile:
+        if p.x <= 0.0:
+            rings.append(bm.verts.new(xf @ Vector((0.0, 0.0, p.y))))
+            continue
+        rings.append([bm.verts.new(xf @ Vector((p.x * math.cos(phase + 2 * math.pi * k / n),
+                                                 p.x * math.sin(phase + 2 * math.pi * k / n),
+                                                 p.y)))
+                      for k in range(n)])
+    for k in range(len(rings) - 1):
+        a, b = rings[k], rings[k + 1]
+        for i in range(n):
+            j = (i + 1) % n
+            if isinstance(a, list) and isinstance(b, list):
+                vs = (a[i], a[j], b[j], b[i])
+                uv = {a[i]: (s[k], i / n), a[j]: (s[k], (i + 1) / n),
+                      b[j]: (s[k + 1], (i + 1) / n), b[i]: (s[k + 1], i / n)}
+            elif isinstance(b, list):
+                vs = (a, b[j], b[i])
+                uv = {a: (s[k], (i + 0.5) / n), b[j]: (s[k + 1], (i + 1) / n),
+                      b[i]: (s[k + 1], i / n)}
+            else:
+                vs = (a[i], a[j], b)
+                uv = {a[i]: (s[k], i / n), a[j]: (s[k], (i + 1) / n),
+                      b: (s[k + 1], (i + 0.5) / n)}
+            f = bm.faces.new(vs)
+            f.material_index = mat_idx
+            f[ctx["piece"]] = piece
+            stamp(ctx, f, island, uv)
+    return rings
+
+
+def add_label(bm, profile, rings, n, phase, xf, ctx, piece, float_label=False):
+    """A paper label on the body's own rings ``rings[0]..rings[1]``.
+
+    Built on the host's arc with the host's segment count: every label
+    vertex sits at a body vertex's angle and height, so the paper follows
+    the body exactly, including a waist or a bulb. Inner face LABEL_BITE
+    inside the body, outer face LABEL_PROUD outside it.
+    """
+    r0, r1 = rings
+    inner_off = FLOAT_LABEL if float_label else -LABEL_BITE
+    outer_off = inner_off + LABEL_BITE + LABEL_PROUD
+    grid = {}
+    for j in range(r0, r1 + 1):
+        p = profile[j]
+        for i in range(LABEL_COLS + 1):
+            a = phase + 2 * math.pi * i / n
+            for side, off in (("in", inner_off), ("out", outer_off)):
+                rr = p.x + off
+                grid[(j, i, side)] = bm.verts.new(
+                    xf @ Vector((rr * math.cos(a), rr * math.sin(a), p.y)))
+    quads = []
+    for j in range(r0, r1):
+        for i in range(LABEL_COLS):
+            quads.append((grid[(j, i, "out")], grid[(j, i + 1, "out")],
+                          grid[(j + 1, i + 1, "out")], grid[(j + 1, i, "out")]))
+            quads.append((grid[(j + 1, i, "in")], grid[(j + 1, i + 1, "in")],
+                          grid[(j, i + 1, "in")], grid[(j, i, "in")]))
+    for i in range(LABEL_COLS):
+        quads.append((grid[(r0, i, "in")], grid[(r0, i + 1, "in")],
+                      grid[(r0, i + 1, "out")], grid[(r0, i, "out")]))
+        quads.append((grid[(r1, i, "out")], grid[(r1, i + 1, "out")],
+                      grid[(r1, i + 1, "in")], grid[(r1, i, "in")]))
+    for j in range(r0, r1):
+        quads.append((grid[(j, 0, "in")], grid[(j, 0, "out")],
+                      grid[(j + 1, 0, "out")], grid[(j + 1, 0, "in")]))
+        e = LABEL_COLS
+        quads.append((grid[(j + 1, e, "in")], grid[(j + 1, e, "out")],
+                      grid[(j, e, "out")], grid[(j, e, "in")]))
+    for vs in quads:
+        f = bm.faces.new(vs)
+        f.material_index = PAPER_IDX
+        f[ctx["piece"]] = piece
+
+
+def add_board(bm, lo, hi, boards, sharp=False):
+    """Axis-aligned board from corner ``lo`` to ``hi``; its verts go in ``boards``."""
+    geo = bmesh.ops.create_cube(bm, size=1.0)
+    c = [(lo[k] + hi[k]) * 0.5 for k in range(3)]
+    s = [hi[k] - lo[k] for k in range(3)]
+    for v in geo["verts"]:
+        v.co = Vector((c[0] + v.co.x * s[0], c[1] + v.co.y * s[1], c[2] + v.co.z * s[2]))
+    for f in {f for v in geo["verts"] for f in v.link_faces}:
+        f.material_index = WOOD_IDX
+    if not sharp:
+        boards.extend(geo["verts"])
+    return geo["verts"]
+
+
+def add_crest(bm, a, y0, y1, z0, z_side, z_top, boards):
+    """The crest: a board whose top is a shallow arc, extruded through Y.
+
+    Both caps are single n-gons, chamfered with the rest and triangulated
+    afterwards, so the shipped mesh has no n-gon.
+    """
+    sag = z_top - z_side
+    rad = (a * a + sag * sag) / (2.0 * sag)
+    zc = z_top - rad
+    phi0 = math.asin(a / rad)
+    outline = [(-a, z0), (a, z0)]
+    for k in range(CREST_ARC + 1):
+        phi = phi0 - 2.0 * phi0 * k / CREST_ARC
+        outline.append((rad * math.sin(phi), zc + rad * math.cos(phi)))
+    front = [bm.verts.new((x, y0, z)) for x, z in outline]
+    back = [bm.verts.new((x, y1, z)) for x, z in outline]
+    faces = [bm.faces.new(front), bm.faces.new(list(reversed(back)))]
+    n = len(outline)
+    for i in range(n):
+        j = (i + 1) % n
+        faces.append(bm.faces.new((front[j], front[i], back[i], back[j])))
+    for f in faces:
+        f.material_index = WOOD_IDX
+    boards.extend(front + back)
+
+
+def vessel_plan(uniform=False, crowd=False):
+    """Every vessel: shelf, form, radius, height, centre, yaw and seat.
+
+    Closed-form throughout. Each shelf's vessels share out the clear width
+    between the sides evenly, and step front and back alternately inside
+    the depth left between the rail and the back boards.
+    """
+    x_in = W_OUT / 2.0 - SIDE_T
+    y_front = -DEPTH / 2.0 + RAIL_INSET + RAIL_T
+    y_back = DEPTH / 2.0 - BACK_INSET - BACK_T - BACK_STEP
+    out = []
+    idx = 0
+    for s, row in enumerate(LAYOUT):
+        sizes = []
+        for k, form in enumerate(row):
+            r, h, _m, _c = FORMS[form]
+            jr = 1.0 + 0.10 * (frac((idx + k) * 0.6180339887 + 0.21) - 0.5)
+            jh = 1.0 + 0.10 * (frac((idx + k) * 0.4142135624 + 0.57) - 0.5)
+            if uniform:
+                r, h, jr, jh = UNIFORM_R, UNIFORM_H, 1.0, 1.0
+            sizes.append((form, r * jr, h * jh))
+        free = 2.0 * x_in - sum(2.0 * sz[1] for sz in sizes)
+        gap = free / (len(sizes) + 1)
+        x = -x_in + gap
+        for k, (form, r, h) in enumerate(sizes):
+            cx = x + r
+            x += 2.0 * r + gap
+            if crowd and s == 0:
+                cx *= CROWD
+            lo_y, hi_y = y_front + r + VESSEL_CLEAR, y_back - r - VESSEL_CLEAR
+            mid, half = (lo_y + hi_y) * 0.5, (hi_y - lo_y) * 0.5
+            cy = mid + half * (0.55 if k % 2 else -0.45)
+            yaw = math.radians(18.0) * (frac(idx * 0.7548776662 + 0.1) - 0.5)
+            seat = SEAT_BASE + SEAT_STEP * k
+            out.append({"i": idx, "shelf": s, "form": form, "r": r, "h": h,
+                        "c": (cx, cy), "yaw": yaw, "seat": seat})
+            idx += 1
+    return out
+
+
+def build_shelf_mesh(
+    name,
+    stray_vert=False,
+    float_side=False,
+    short_shelves=False,
+    pop_corks=False,
+    pull_knobs=False,
+    float_jars=False,
+    lift_drawers=False,
+    float_labels=False,
+    tall_flask=False,
+    crowd_jars=False,
+    uniform_vessels=False,
+    sharp_rail=False,
+):
+    x_out = W_OUT / 2.0
+    x_in = x_out - SIDE_T
+    y0, y1 = -DEPTH / 2.0, DEPTH / 2.0
+    top_z0 = SIDE_H - TOP_SEAT
+    top_z1 = top_z0 + TOP_T
+    back_y1 = y1 - BACK_INSET
+    back_y0 = back_y1 - BACK_T
+    bm = bmesh.new()
+    try:
+        ctx = {"uv": bm.loops.layers.uv.new("UVMap"),
+               "isl": bm.faces.layers.int.new("UVIsland"),
+               "piece": bm.faces.layers.int.new("Piece"), "next": 0}
+        boards = []
+        # Sides: the two named supports.
+        for sx in (-1.0, 1.0):
+            lift = FLOAT_SIDE if (float_side and sx < 0) else 0.0
+            add_board(bm, (min(sx * x_out, sx * x_in), y0, lift),
+                      (max(sx * x_out, sx * x_in), y1, SIDE_H + lift), boards)
+        add_board(bm, (-x_out - TOP_OVER, y0 - TOP_OVER, top_z0),
+                  (x_out + TOP_OVER, y1 + TOP_BACK_OVER, top_z1), boards)
+        add_crest(bm, x_in, back_y0 - CREST_T - 0.004, back_y0 - 0.004,
+                  top_z1 - CREST_BITE, top_z1 + CREST_SHOULDER, top_z1 + CREST_H, boards)
+        # Back boards, housed into the sides and the top, standing on the
+        # base. Lapped, not butted: a seam left open shows daylight through
+        # the back, and a lap on one plane is a coplanar pair, so every other
+        # board steps forward by BACK_STEP.
+        span0, span1 = -x_in - BACK_BITE, x_in + BACK_BITE
+        bw = (span1 - span0 + BACK_LAP * (N_BACK - 1)) / N_BACK
+        for k in range(N_BACK):
+            bx = span0 + k * (bw - BACK_LAP)
+            step = BACK_STEP if k % 2 else 0.0
+            add_board(bm, (bx, back_y0 - step, BASE_Z + BASE_T - BACK_BITE),
+                      (bx + bw, back_y1 - step, top_z0 + BACK_BITE), boards)
+        # Cross members: base, shelves and rails, each in a dado in both sides.
+        reach = x_in - 0.004 if short_shelves else x_in + DADO
+        add_board(bm, (-(x_in + DADO), y0 + BASE_SETBACK, BASE_Z),
+                  (x_in + DADO, back_y0 + BACK_BITE, BASE_Z + BASE_T), boards)
+        for s, zt in enumerate(SHELF_TOPS):
+            add_board(bm, (-reach, y0 + SHELF_SETBACK, zt - SHELF_T),
+                      (reach, back_y0 + BACK_BITE, zt), boards)
+            add_board(bm, (-(x_in + DADO), y0 + RAIL_INSET, zt - RAIL_BITE),
+                      (x_in + DADO, y0 + RAIL_INSET + RAIL_T, zt + RAIL_H - RAIL_BITE),
+                      boards, sharp=(sharp_rail and s == len(SHELF_TOPS) - 1))
+        # Drawer bank: three fronts standing in the base, a knob in each.
+        dz0 = BASE_Z + BASE_T - DRAWER_BITE + (LIFT_DRAWERS if lift_drawers else 0.0)
+        dz1 = SHELF_TOPS[0] - SHELF_T - DRAWER_GAP + (LIFT_DRAWERS if lift_drawers else 0.0)
+        dw = (2.0 * x_in - DRAWER_GAP * (N_DRAWERS + 1)) / N_DRAWERS
+        dy0 = y0 + DRAWER_SETBACK
+        knobs = []
+        for k in range(N_DRAWERS):
+            dx = -x_in + DRAWER_GAP + k * (dw + DRAWER_GAP)
+            add_board(bm, (dx, dy0, dz0), (dx + dw, dy0 + DRAWER_T, dz1), boards)
+            knobs.append((dx + dw * 0.5, (dz0 + dz1) * 0.5))
+        # A set of BMEdges iterates in address order: sort by index so the
+        # bevel emits its faces in the same order on every run.
+        bm.edges.index_update()
+        # The crest's arc strips meet at a few degrees; only real edges chamfer.
+        edges = sorted({e for v in boards 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=WOOD_IDX)
+        # Chamfer n-gon caps, then triangulate.
+        bmesh.ops.triangulate(bm, faces=[f for f in bm.faces if len(f.verts) > 4])
+        for kx, kz in knobs:
+            ky = dy0 + KNOB_BITE - (PULL_KNOBS if pull_knobs else 0.0)
+            xf = Matrix.Translation((kx, ky, kz)) @ Matrix.Rotation(math.pi / 2.0, 4, "X")
+            lathe(bm, knob_profile(), SEG, BRASS_IDX, ctx, xf)
+        for v in vessel_plan(uniform=uniform_vessels, crowd=crowd_jars):
+            form = v["form"]
+            _r, _h, mat, kind = FORMS[form]
+            prof, label, mouth = body_profile(form, v["r"], v["h"])
+            z0 = SHELF_TOPS[v["shelf"]] - v["seat"] + (FLOAT_JARS if float_jars else 0.0)
+            stretch = TALL_FLASK if (tall_flask and v["shelf"] == 1 and form == "flask") else 1.0
+            xf = (Matrix.Translation((v["c"][0], v["c"][1], z0))
+                  @ Matrix.Diagonal((1.0, 1.0, stretch, 1.0)))
+            # Label centred on the front, turned by the vessel's yaw.
+            phase = -math.pi / 2.0 + v["yaw"] - (LABEL_COLS / 2) * 2.0 * math.pi / SEG
+            piece = v["i"] + 1
+            midx = GLASS_IDX if mat == "glass" else GLAZE_IDX
+            lathe(bm, prof, SEG, midx, ctx, xf, phase=phase, piece=piece)
+            add_label(bm, prof, label, SEG, phase, xf, ctx, piece, float_label=float_labels)
+            cz = prof[-1].y - CLOSURE_BITE + (POP_CORK if pop_corks else 0.0)
+            cxf = xf @ Matrix.Translation((0.0, 0.0, cz))
+            cmat = CORK_IDX if kind == "cork" else GLAZE_IDX
+            lathe(bm, closure_profile(kind, mouth), SEG, cmat, ctx, cxf, phase=phase, piece=piece)
+        if stray_vert:
+            bm.verts.new((0.0, 0.0, 0.5))
+        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):
+    """Face attributes the shaders read: per-board tone and grain, per-vessel tint.
+
+    Every board gets a closed-form tone and the direction it runs in, its
+    longest extent, so the grain follows the board. Every vessel gets a
+    glass tint or a glaze from its index; the ``Piece`` tag is then dropped.
+    """
+    npoly = len(me.polygons)
+    piece = [0] * npoly
+    me.attributes["Piece"].data.foreach_get("value", piece)
+    mats = [0] * npoly
+    me.polygons.foreach_get("material_index", mats)
+    tone = [0.5] * npoly
+    grain = [(1.0, 0.0, 0.0)] * npoly
+    tint = [(0.5, 0.5, 0.5, 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)):
+        faces = {fi for i in g for fi in vf[i]}
+        if not faces or mats[next(iter(faces))] != WOOD_IDX:
+            continue
+        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.30 * (frac(k * 0.6180339887 + 0.3) - 0.5)
+        for fi in faces:
+            tone[fi] = t
+            grain[fi] = d
+    for fi in range(npoly):
+        v = piece[fi] - 1
+        if v < 0:
+            continue
+        if mats[fi] == GLASS_IDX:
+            c = GLASS_TINTS[v % len(GLASS_TINTS)]
+        else:
+            c = GLAZE_TINTS[v % len(GLAZE_TINTS)]
+        tint[fi] = (c[0], c[1], c[2], 1.0)
+        tone[fi] = 0.5 + 0.3 * (frac(v * 0.4142135624) - 0.5)
+    me.attributes.remove(me.attributes["Piece"])
+    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])
+    c = me.attributes.new("Tint", "FLOAT_COLOR", "FACE")
+    c.data.foreach_set("color", [x for v in tint for x in v])
+
+
+# --- surface ----------------------------------------------------------------
+
+
+def _sock(sockets, identifier):
+    return next(sk for sk in sockets if sk.identifier == identifier)
+
+
+def _input(bsdf, *names):
+    for n in names:
+        if n in bsdf.inputs:
+            return bsdf.inputs[n]
+    return None
+
+
+def wood_material(name):
+    """Stained timber: grain along each board, tone per board (copied from crate-stack)."""
+    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 = (0.085, 0.036, 0.014, 1.0)
+    ramp.color_ramp.elements[1].position = 0.72
+    ramp.color_ramp.elements[1].color = (0.27, 0.125, 0.048, 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"])
+    rough = nt.nodes.new("ShaderNodeMapRange")
+    rough.inputs["To Min"].default_value = 0.66
+    rough.inputs["To Max"].default_value = 0.48
+    nt.links.new(noise.outputs["Fac"], rough.inputs["Value"])
+    nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"])
+    return mat
+
+
+def glaze_material(name):
+    """Glazed stoneware: the per-vessel ``Tint``, glossy, with a faint mottle."""
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    attr = nt.nodes.new("ShaderNodeAttribute")
+    attr.attribute_name = "Tint"
+    tc = nt.nodes.new("ShaderNodeTexCoord")
+    noise = nt.nodes.new("ShaderNodeTexNoise")
+    noise.inputs["Scale"].default_value = 45.0
+    noise.inputs["Detail"].default_value = 4.0
+    nt.links.new(tc.outputs["Object"], noise.inputs["Vector"])
+    shade = nt.nodes.new("ShaderNodeMapRange")
+    shade.inputs["To Min"].default_value = 0.86
+    shade.inputs["To Max"].default_value = 1.0
+    nt.links.new(noise.outputs["Fac"], shade.inputs["Value"])
+    mul = nt.nodes.new("ShaderNodeVectorMath")
+    mul.operation = "SCALE"
+    nt.links.new(attr.outputs["Color"], mul.inputs[0])
+    nt.links.new(shade.outputs["Result"], mul.inputs["Scale"])
+    nt.links.new(mul.outputs["Vector"], bsdf.inputs["Base Color"])
+    bsdf.inputs["Roughness"].default_value = 0.28
+    return mat
+
+
+def glass_material(name):
+    """Tinted glass, filled: dark through the middle, lighter at the silhouette.
+
+    No transmission. EEVEE's transmission rendered every bottle as a grainy
+    speckle that 256 samples did not clear, with or without raytracing, so
+    the depth of the glass is carried by a facing ramp and a clear coat.
+    """
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    attr = nt.nodes.new("ShaderNodeAttribute")
+    attr.attribute_name = "Tint"
+    lw = nt.nodes.new("ShaderNodeLayerWeight")
+    lw.inputs["Blend"].default_value = 0.45
+    depth = nt.nodes.new("ShaderNodeMapRange")
+    depth.inputs["To Min"].default_value = 0.30
+    depth.inputs["To Max"].default_value = 0.95
+    nt.links.new(lw.outputs["Facing"], depth.inputs["Value"])
+    mul = nt.nodes.new("ShaderNodeVectorMath")
+    mul.operation = "SCALE"
+    nt.links.new(attr.outputs["Color"], mul.inputs[0])
+    nt.links.new(depth.outputs["Result"], mul.inputs["Scale"])
+    nt.links.new(mul.outputs["Vector"], bsdf.inputs["Base Color"])
+    bsdf.inputs["Roughness"].default_value = 0.06
+    coat = _input(bsdf, "Coat Weight", "Clearcoat")
+    if coat is not None:
+        coat.default_value = 1.0
+    coat_r = _input(bsdf, "Coat Roughness", "Clearcoat Roughness")
+    if coat_r is not None:
+        coat_r.default_value = 0.03
+    return mat
+
+
+def paper_material(name):
+    """Aged paper with ruled lines of ink broken into words by noise."""
+    mat = speckled(name, (0.56, 0.49, 0.36), (0.78, 0.72, 0.58), 25.0, 0.9)
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    paper = bsdf.inputs["Base Color"].links[0].from_socket
+    tc = nt.nodes.new("ShaderNodeTexCoord")
+    sep = nt.nodes.new("ShaderNodeSeparateXYZ")
+    nt.links.new(tc.outputs["Object"], sep.inputs["Vector"])
+    lines = nt.nodes.new("ShaderNodeMath")
+    lines.operation = "PINGPONG"
+    lines.inputs[1].default_value = 0.0045
+    nt.links.new(sep.outputs["Z"], lines.inputs[0])
+    rule = nt.nodes.new("ShaderNodeMath")
+    rule.operation = "LESS_THAN"
+    rule.inputs[1].default_value = 0.0005
+    nt.links.new(lines.outputs["Value"], rule.inputs[0])
+    words = nt.nodes.new("ShaderNodeTexNoise")
+    words.inputs["Scale"].default_value = 220.0
+    words.inputs["Detail"].default_value = 1.0
+    nt.links.new(tc.outputs["Object"], words.inputs["Vector"])
+    gate = nt.nodes.new("ShaderNodeMath")
+    gate.operation = "GREATER_THAN"
+    gate.inputs[1].default_value = 0.50
+    nt.links.new(words.outputs["Fac"], gate.inputs[0])
+    ink = nt.nodes.new("ShaderNodeMath")
+    ink.operation = "MULTIPLY"
+    nt.links.new(rule.outputs["Value"], ink.inputs[0])
+    nt.links.new(gate.outputs["Value"], ink.inputs[1])
+    mix = nt.nodes.new("ShaderNodeMix")
+    mix.data_type = "RGBA"
+    nt.links.new(ink.outputs["Value"], _sock(mix.inputs, "Factor_Float"))
+    nt.links.new(paper, _sock(mix.inputs, "A_Color"))
+    _sock(mix.inputs, "B_Color").default_value = (0.26, 0.18, 0.12, 1.0)
+    nt.links.new(_sock(mix.outputs, "Result_Color"), bsdf.inputs["Base Color"])
+    return mat
+
+
+def speckled(name, color_a, color_b, scale, roughness):
+    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 = scale
+    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.35
+    ramp.color_ramp.elements[0].color = (*color_a, 1.0)
+    ramp.color_ramp.elements[1].position = 0.70
+    ramp.color_ramp.elements[1].color = (*color_b, 1.0)
+    nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
+    nt.links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"])
+    bsdf.inputs["Roughness"].default_value = roughness
+    return mat
+
+
+def brass_material(name):
+    """Aged brass: warm, a little dull, darker in the recesses of the noise."""
+    mat = speckled(name, (0.30, 0.19, 0.07), (0.55, 0.39, 0.16), 60.0, 0.42)
+    mat.node_tree.nodes["Principled BSDF"].inputs["Metallic"].default_value = 0.9
+    return mat
+
+
+def shelf_materials():
+    return (
+        wood_material("ShelfWood"),
+        glass_material("ShelfGlass"),
+        glaze_material("ShelfGlaze"),
+        speckled("ShelfCork", (0.30, 0.19, 0.10), (0.52, 0.37, 0.21), 90.0, 0.85),
+        paper_material("ShelfPaper"),
+        brass_material("ShelfBrass"),
+    )
+
+
+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 right_angle_edges(me):
+    """Manifold edges between two wood faces that meet within 5 degrees of 90."""
+    count = 0
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(me)
+        for e in bm.edges:
+            if len(e.link_faces) != 2:
+                continue
+            if any(f.material_index != WOOD_IDX for f in e.link_faces):
+                continue
+            if abs(e.calc_face_angle(0.0) - math.pi / 2.0) <= RIGHT_ANGLE_TOL:
+                count += 1
+    finally:
+        bm.free()
+    return count
+
+
+def shell_tree(me, groups):
+    member = set(i for g in groups for i in g)
+    order = sorted(member)
+    remap = {v: k for k, v in enumerate(order)}
+    polys = [[remap[i] for i in p.vertices] for p in me.polygons if p.vertices[0] in member]
+    return BVHTree.FromPolygons([me.vertices[i].co.copy() for i in order], polys)
+
+
+def classify(me):
+    """Split the finished mesh into named parts by material and shape."""
+    mats = {}
+    for p in me.polygons:
+        for i in p.vertices:
+            mats.setdefault(i, p.material_index)
+    out = {k: [] for k in ("side", "top", "crest", "back", "cross", "drawer", "knob",
+                           "body", "closure", "label", "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)))
+        ext = hi - lo
+        rec = {"g": g, "pts": pts, "lo": lo, "hi": hi, "ext": ext,
+               "c": sum(pts, Vector()) / len(pts)}
+        m = mats.get(g[0], -1)
+        if m == WOOD_IDX:
+            if ext.x < 0.05 and ext.z > 0.9:
+                out["side"].append(rec)
+            elif ext.y < 0.03 and ext.z > 0.9:
+                out["back"].append(rec)
+            elif lo.z > SIDE_H:
+                out["crest"].append(rec)
+            elif hi.z > SIDE_H:
+                out["top"].append(rec)
+            elif ext.x > 0.7:
+                out["cross"].append(rec)
+            else:
+                out["drawer"].append(rec)
+        elif m == BRASS_IDX:
+            out["knob"].append(rec)
+        elif m == PAPER_IDX:
+            out["label"].append(rec)
+        elif m == CORK_IDX:
+            out["closure"].append(rec)
+        elif m in (GLASS_IDX, GLAZE_IDX):
+            out["body" if ext.z > 0.06 else "closure"].append(rec)
+        else:
+            out["other"].append(rec)
+    return out
+
+
+def axis_of(rec):
+    """A lathe's axis in plan: the mean of its vertices, exact for a full revolution."""
+    return Vector((rec["c"].x, rec["c"].y))
+
+
+def shelf_audit(me):
+    parts = classify(me)
+    out = {k: len(v) for k, v in parts.items()}
+    sides = sorted(parts["side"], key=lambda r: r["c"].x)
+    out["side_z"] = max((r["lo"].z for r in sides), default=99.0)
+
+    # Every cross member is housed in both sides: bite past the inner face,
+    # and clear of the far face.
+    bite, far = 99.0, 99.0
+    if len(sides) == 2:
+        left_in, left_out = sides[0]["hi"].x, sides[0]["lo"].x
+        right_in, right_out = sides[1]["lo"].x, sides[1]["hi"].x
+        for r in parts["cross"]:
+            bite = min(bite, left_in - r["lo"].x, r["hi"].x - right_in)
+            far = min(far, r["lo"].x - left_out, right_out - r["hi"].x)
+    out["dado"], out["dado_far"] = bite, far
+
+    # Vessels: each body with its closure and label, by nearest axis.
+    bodies = parts["body"]
+    units = [{"body": b, "closure": [], "label": []} for b in bodies]
+    for kind in ("closure", "label"):
+        for r in parts[kind]:
+            if not units:
+                break
+            # Nearest axis in plan among the bodies at this part's height:
+            # the shelves stack vessels in the same plan positions.
+            p = Vector((r["c"].x, r["c"].y))
+            level = [u for u in units
+                     if u["body"]["lo"].z <= r["c"].z <= u["body"]["hi"].z + 0.06]
+            if level:
+                u = min(level, key=lambda u: (axis_of(u["body"]) - p).length)
+                u[kind].append(r)
+    out["units"] = len(units)
+    out["unmatched"] = sum(1 for u in units if len(u["closure"]) != 1 or len(u["label"]) != 1)
+
+    shelves = [r for r in parts["cross"] if r["ext"].y > 0.15]
+    horizontals = shelves + parts["top"]
+    closure_bites, seats, heads = [], [], []
+    for u in units:
+        b = u["body"]
+        for c in u["closure"]:
+            closure_bites.append(b["hi"].z - c["lo"].z)
+        under = [s for s in shelves if s["hi"].z <= b["lo"].z + 0.01]
+        if under:
+            s = max(under, key=lambda s: s["hi"].z)
+            u["shelf"] = s
+            seats.append(s["hi"].z - b["lo"].z)
+        top = max([b["hi"].z] + [c["hi"].z for c in u["closure"]])
+        above = [h for h in horizontals if h["lo"].z > b["lo"].z + 0.05]
+        if above:
+            heads.append(min(h["lo"].z for h in above) - top)
+    out["closure_bite"] = (min(closure_bites, default=-99.0), max(closure_bites, default=99.0))
+    out["seat"] = (min(seats, default=-99.0), max(seats, default=99.0))
+    out["headroom"] = min(heads, default=-99.0)
+    out["n_seated"] = len(seats)
+
+    # Labels on their bodies: each label vertex against the body vertex it
+    # was built on, measured radially from the body's own axis.
+    lab_in, lab_out = [], []
+    for u in units:
+        b = u["body"]
+        kd = KDTree(len(b["pts"]))
+        for k, p in enumerate(b["pts"]):
+            kd.insert(p, k)
+        kd.balance()
+        ax = axis_of(b)
+        for lab in u["label"]:
+            # Positive is inside the body. Half the label's vertices are its
+            # inner face and half its outer; sorting splits them without a
+            # sign test, so a floated label's inner face still reads as inner.
+            ds = []
+            for p in lab["pts"]:
+                q = kd.find(p)[0]
+                ds.append((Vector((q.x, q.y)) - ax).length - (Vector((p.x, p.y)) - ax).length)
+            ds.sort()
+            half = len(ds) // 2
+            lab_in.append(min(ds[half:]))
+            lab_out.append(-max(ds[:half]))
+    out["label_bite"] = (min(lab_in, default=-99.0), max(lab_in, default=99.0))
+    out["label_proud"] = min(lab_out, default=-99.0)
+
+    # Knobs bite their drawer fronts; drawer fronts stand in the base.
+    base = min(parts["cross"], key=lambda r: r["lo"].z) if parts["cross"] else None
+    knob_bites, drawer_seats = [], []
+    for d in parts["drawer"]:
+        if base is not None:
+            drawer_seats.append(base["hi"].z - d["lo"].z)
+        near = [k for k in parts["knob"] if d["lo"].x < k["c"].x < d["hi"].x]
+        for k in near:
+            knob_bites.append(k["hi"].y - d["lo"].y)
+    out["knob_bite"] = (min(knob_bites, default=-99.0), max(knob_bites, default=99.0))
+    out["n_knob_bites"] = len(knob_bites)
+    out["drawer_seat"] = (min(drawer_seats, default=-99.0), max(drawer_seats, default=99.0))
+
+    # Vessels do not run into one another, nor into the carcass beyond the
+    # shelf each one stands in.
+    trees = [shell_tree(me, [u["body"]["g"]] + [r["g"] for r in u["closure"] + u["label"]])
+             for u in units]
+    carcass = [r for k in ("side", "back", "cross", "top") for r in parts[k]]
+    ctrees = [shell_tree(me, [r["g"]]) for r in carcass]
+    hits = 0
+    for i in range(len(trees)):
+        for j in range(i + 1, len(trees)):
+            if trees[i].overlap(trees[j]):
+                hits += 1
+        for r, ct in zip(carcass, ctrees):
+            if r is units[i].get("shelf"):
+                continue
+            if trees[i].overlap(ct):
+                hits += 1
+    out["vessel_hits"] = hits
+
+    # Forms vary: widest body over narrowest, tallest over shortest.
+    # Diameter from the axis, not the AABB: a yawed 16-gon's box width
+    # moves with the yaw.
+    diams = [2.0 * max((Vector((p.x, p.y)) - axis_of(b)).length for p in b["pts"])
+             for b in bodies]
+    hts = [b["ext"].z for b in bodies]
+    out["diam_spread"] = max(diams) / min(diams) if diams else 0.0
+    out["height_spread"] = max(hts) / min(hts) if hts else 0.0
+    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):
+    """One convex hull over the carcass and the knobs; the vessels sit inside it."""
+    me = obj.data
+    mats = {}
+    for p in me.polygons:
+        for i in p.vertices:
+            mats.setdefault(i, p.material_index)
+    pts = [me.vertices[i].co.copy() for i in range(len(me.vertices))
+           if mats.get(i) in (WOOD_IDX, BRASS_IDX)]
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        vs = [bm.verts.new(p) for p in pts]
+        ret = bmesh.ops.convex_hull(bm, input=vs)
+        drop = []
+        for v in ret["geom_interior"] + ret["geom_unused"]:
+            if isinstance(v, bmesh.types.BMVert) and v not in drop:
+                drop.append(v)
+        bmesh.ops.delete(bm, geom=drop, context="VERTS")
+        bmesh.ops.dissolve_limit(bm, angle_limit=math.radians(1.0),
+                                 verts=list(bm.verts), edges=list(bm.edges))
+        bmesh.ops.triangulate(bm, faces=list(bm.faces))
+        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("ShelfNrm", 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 = WOOD_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_shelf_mesh("ShelfLow", **flags)
+    hi_flags = {k: v for k, v in flags.items() if k != "stray_vert"}
+    high = build_shelf_mesh("ShelfHigh", **hi_flags)
+    mats = shelf_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("shelf 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[WOOD_IDX], BAKE_RES)
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "ShelfLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "ShelfLOD2", 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, "ShelfCollider")
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(tempfile.gettempdir(), f"bdt_apothecary_{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)
+    sa = shelf_audit(low.data)
+    right = right_angle_edges(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 sides={sa['side']} top={sa['top']} crest={sa['crest']} "
+          f"back={sa['back']} cross={sa['cross']} drawers={sa['drawer']} knobs={sa['knob']} "
+          f"bodies={sa['body']} closures={sa['closure']} labels={sa['label']} "
+          f"other={sa['other']} unmatched={sa['unmatched']} side_z={sa['side_z']:.5f}")
+    print(f"measured joints dado={sa['dado']:.5f} dado_far={sa['dado_far']:.5f} "
+          f"closure_bite=({sa['closure_bite'][0]:.5f},{sa['closure_bite'][1]:.5f}) "
+          f"knob_bite=({sa['knob_bite'][0]:.5f},{sa['knob_bite'][1]:.5f})")
+    print(f"measured seats vessel=({sa['seat'][0]:.5f},{sa['seat'][1]:.5f}) "
+          f"n={sa['n_seated']} drawer=({sa['drawer_seat'][0]:.5f},{sa['drawer_seat'][1]:.5f}) "
+          f"label_bite=({sa['label_bite'][0]:.5f},{sa['label_bite'][1]:.5f}) "
+          f"label_proud={sa['label_proud']:.5f}")
+    print(f"measured headroom={sa['headroom']:.5f} vessel_hits={sa['vessel_hits']} "
+          f"diam_spread={sa['diam_spread']:.3f} height_spread={sa['height_spread']:.3f} "
+          f"right_angle_wood={right}")
+
+    n_vessels = sum(len(row) for row in LAYOUT)
+    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 sa["side"] != 2 or sa["side_z"] > SIDE_Z_MAX:
+        return (fail(f"sides: {sa['side']} of 2, worst side z={sa['side_z']:.5f} > {SIDE_Z_MAX} "
+                     "(--float-side is the designed fail)", 16),) + nothing
+    if sa["cross"] != N_CROSS or sa["dado"] < DADO_MIN or sa["dado_far"] < DADO_FAR_MIN:
+        return (fail(f"{sa['cross']} of {N_CROSS} cross members, dado bite {sa['dado']:.5f} "
+                     f"(min {DADO_MIN}), far face {sa['dado_far']:.5f} (min {DADO_FAR_MIN}) "
+                     "(--short-shelves is the designed fail)", 17),) + nothing
+    if (sa["units"] != n_vessels or sa["unmatched"]
+            or not (CLOSURE_BITE_MIN <= sa["closure_bite"][0])
+            or sa["closure_bite"][1] > CLOSURE_BITE_MAX):
+        return (fail(f"{sa['units']} of {n_vessels} vessels, {sa['unmatched']} without one "
+                     f"closure and one label, closure bite {sa['closure_bite']} outside "
+                     f"[{CLOSURE_BITE_MIN}, {CLOSURE_BITE_MAX}] (--pop-corks is the designed fail)",
+                     17),) + nothing
+    if (sa["n_knob_bites"] != N_DRAWERS or sa["knob_bite"][0] < KNOB_BITE_MIN
+            or sa["knob_bite"][1] > KNOB_BITE_MAX):
+        return (fail(f"{sa['n_knob_bites']} of {N_DRAWERS} knobs, bite {sa['knob_bite']} outside "
+                     f"[{KNOB_BITE_MIN}, {KNOB_BITE_MAX}] (--pull-knobs is the designed fail)",
+                     17),) + nothing
+    if sa["n_seated"] != n_vessels or sa["seat"][0] < SEAT_MIN or sa["seat"][1] > SEAT_MAX:
+        return (fail(f"{sa['n_seated']} of {n_vessels} vessels on a shelf, seat {sa['seat']} "
+                     f"outside [{SEAT_MIN}, {SEAT_MAX}] (--float-jars is the designed fail)",
+                     18),) + nothing
+    if (sa["drawer"] != N_DRAWERS or sa["drawer_seat"][0] < DRAWER_SEAT_MIN
+            or sa["drawer_seat"][1] > DRAWER_SEAT_MAX):
+        return (fail(f"{sa['drawer']} of {N_DRAWERS} drawers, seat {sa['drawer_seat']} outside "
+                     f"[{DRAWER_SEAT_MIN}, {DRAWER_SEAT_MAX}] (--lift-drawers is the designed fail)",
+                     18),) + nothing
+    if (sa["label_bite"][0] < LABEL_BITE_MIN or sa["label_bite"][1] > LABEL_BITE_MAX
+            or sa["label_proud"] < LABEL_PROUD_MIN):
+        return (fail(f"label bite {sa['label_bite']} outside [{LABEL_BITE_MIN}, {LABEL_BITE_MAX}] "
+                     f"or proud {sa['label_proud']:.5f} < {LABEL_PROUD_MIN} "
+                     "(--float-labels is the designed fail)", 18),) + nothing
+    if sa["headroom"] < HEADROOM_MIN:
+        return (fail(f"headroom {sa['headroom']:.5f} < {HEADROOM_MIN} "
+                     "(--tall-flask is the designed fail)", 20),) + nothing
+    if sa["vessel_hits"]:
+        return (fail(f"{sa['vessel_hits']} vessel overlaps, need 0 "
+                     "(--crowd-jars is the designed fail)", 21),) + nothing
+    if sa["diam_spread"] < DIAM_SPREAD_MIN or sa["height_spread"] < HEIGHT_SPREAD_MIN:
+        return (fail(f"vessel spread diameter {sa['diam_spread']:.3f} (min {DIAM_SPREAD_MIN}), "
+                     f"height {sa['height_spread']:.3f} (min {HEIGHT_SPREAD_MIN}) "
+                     "(--uniform-vessels is the designed fail)", 22),) + nothing
+    if right:
+        return (fail(f"{right} right-angle wood edges, need 0 "
+                     "(--sharp-rail is the designed fail)", 23),) + 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[WOOD_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(-14.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.4, 3.2), 420.0, 3.0, (1.0, 0.95, 0.88), (50, 0, -35))
+    light("Fill", "AREA", (3.2, -2.8, 1.6), 70.0, 5.0, (0.74, 0.84, 1.0), (68, 0, 48))
+    light("Rim", "AREA", (-1.8, 2.6, 2.6), 220.0, 3.0, (0.62, 0.78, 1.0), (-55, 0, 200))
+    ld = bpy.data.lights.new("Wedge", "SPOT")
+    ld.energy, ld.color = 260.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.6, 1.8, 2.2)
+    wedge.rotation_euler = (Vector((0.2, 0.6, 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.05, -3.05, 1.05)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.54)
+    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("--float-side", action="store_true")
+    p.add_argument("--short-shelves", action="store_true")
+    p.add_argument("--pop-corks", action="store_true")
+    p.add_argument("--pull-knobs", action="store_true")
+    p.add_argument("--float-jars", action="store_true")
+    p.add_argument("--lift-drawers", action="store_true")
+    p.add_argument("--float-labels", action="store_true")
+    p.add_argument("--tall-flask", action="store_true")
+    p.add_argument("--crowd-jars", action="store_true")
+    p.add_argument("--uniform-vessels", action="store_true")
+    p.add_argument("--sharp-rail", 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,
+        float_side=args.float_side,
+        short_shelves=args.short_shelves,
+        pop_corks=args.pop_corks,
+        pull_knobs=args.pull_knobs,
+        float_jars=args.float_jars,
+        lift_drawers=args.lift_drawers,
+        float_labels=args.float_labels,
+        tall_flask=args.tall_flask,
+        crowd_jars=args.crowd_jars,
+        uniform_vessels=args.uniform_vessels,
+        sharp_rail=args.sharp_rail,
+    )
+    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("apothecary shelf 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 stained wooden shelf with an arched crest, gallery rails and three brass-knobbed drawers, stocked with labelled glass bottles and flasks and cream and blue glazed jars. + +
+
+ generated from showcase/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/docs/gallery/asset-sheets/apothecary-shelf.webp b/docs/gallery/asset-sheets/apothecary-shelf.webp new file mode 100644 index 0000000000000000000000000000000000000000..f6a82ff1050b15ba1bf5893e4a2ccbc70cd13355 GIT binary patch literal 38092 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 Kd-LNrzyJVJ>Veh( literal 0 HcmV?d00001 diff --git a/docs/gallery/contact-sheets/apothecary-shelf-contact-sheet.webp b/docs/gallery/contact-sheets/apothecary-shelf-contact-sheet.webp new file mode 100644 index 0000000000000000000000000000000000000000..9f7d7c5fa85dd04c5591f3971deab583dffea43d GIT binary patch literal 60064 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index b4eac134..d9df27ad 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, 31 showcase pieces + 51 examples, 32 showcase pieces
+
+ +
+

apothecary-shelf

+

A procedural apothecary shelf — housed shelves, gallery rails, a drawer bank and an arched crest, stocked with fifteen lathed bottles, flasks and glazed jars, each corked or lidded and labelled — carried through UVs, bake, LOD, convex collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 10456 tris, six materials with a face floor each, UVs in 0..1 with zero AABB overlap, outer AABB 0.876×0.274×1.076 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, both sides grounded, seven cross members housed 8 mm into each side and 14 mm clear of its far face, fifteen vessels seated 1.0–1.8 mm into their shelves, every cork and lid biting 5 mm, every label 0.3 mm into its body and 0.8 mm proud, no vessel touching another or the carcass, and at least 24 mm of headroom between every vessel and the member above it. --tall-flask exits 20 at −5.5 mm while every seat and the envelope still pass; --short-shelves exits 17 on the dado bite.

+ View showcase piece apothecary-shelf +
+
@@ -1272,7 +1283,7 @@

brazier

var tagsToggle = document.getElementById('tagsToggle'); var toTop = document.getElementById('toTop'); var total = cards.length; - var COUNT_LABEL = '51 examples, 31 showcase pieces'; + var COUNT_LABEL = '51 examples, 32 showcase pieces'; var LS_KEY = 'bdt-gallery-density'; var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; diff --git a/showcase/apothecary-shelf/README.md b/showcase/apothecary-shelf/README.md new file mode 100644 index 00000000..fa8ddb10 --- /dev/null +++ b/showcase/apothecary-shelf/README.md @@ -0,0 +1,263 @@ +# apothecary-shelf + +![A stained wooden shelf unit with an arched crest, three shelves behind low gallery rails and three drawers with brass knobs, stocked with labelled glass bottles, flasks and glazed jars](preview.webp) + +An apothecary shelf: a stained carcass with three shelves housed in its +sides, a gallery rail along each shelf, lapped back boards, a bank of three +drawers with brass knobs and an arched crest, stocked with fifteen vessels +in five forms (bottle, flask, jar, albarello, vial). Every vessel is corked +or lidded and carries a paper label. **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` | chamfered boards, arched crest, lathed vessels, closures and knobs, labels on the bodies' rings, all in one `bmesh` | +| `skills/custom-properties` | face attributes (`PlankTone`, `GrainDir`, `Tint`) read by the shaders | +| `skills/procedural-materials-and-shaders` | grained stain, tinted glass, glaze, cork, inked paper, aged brass | +| `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 over the carcass and knobs | +| `snippets/lod_chain.py` | LOD naming and ratio pattern | +| `examples/mesh-hygiene-audit` | hygiene combinatorics (copied, not imported) | + +## The budget that matters + +A stocked shelf fails invisibly when a vessel does not fit between its +shelf and the one above. A flask a few centimetres too tall runs its neck +up into the next shelf, and nothing else notices: + +- the flask still stands on its shelf, so the seat budget passes; +- the shelf above is still in its dado, so the joint budget passes; +- the neck stops inside the shelf, so the bounding box does not move; +- the triangle count is the same, because only the height changed. + +The piece reads each vessel's top (body or closure, whichever is higher) +and the underside of the lowest horizontal member above it off the +finished mesh, and asserts a headroom floor of 20 mm. The tightest vessel +clears by 24.05 mm. + +`--tall-flask` is the falsifier built for exactly this. It stretches the +flask on the middle shelf by 1.20. It then runs 5.5 mm into the shelf above +and exits 20, while every seat, every joint and the envelope still pass. + +## 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 up to 8 bytes, +which is exporter metadata and not a budget). + +| Budget | Band | Measured | +| --- | --- | --- | +| Base triangles | 9800–11100 | 10456 | +| LOD1 ratio | 0.32–0.62 | 0.5000 | +| LOD2 ratio | 0.10–0.35 | 0.2200 | +| Material slots | exactly 6, distinct | 6 | +| Wood / glass / glaze faces | ≥ 450 / 1800 / 1300 | 525 / 2080 / 1520 | +| Cork / paper / brass faces | ≥ 850 / 380 / 300 | 960 / 420 / 336 | +| UV bounds | inside 0..1 | (0.0009, 0.0010)–(0.9991, 0.9990) | +| UV AABB overlap | ≤ 1e-5 | 0.000000 | +| Outer AABB | 0.876 × 0.274 × 1.076 m ± 0.020 | 0.8760 × 0.2740 × 1.0760 | +| Collider triangles | ≤ 200 | 138 | +| Normal bake | `{'FINISHED'}` with image data | `{'FINISHED'}`, `has_data=True` | +| glTF export | file written, non-empty | ~449 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 sides | 2 sides, each zmin ≤ 1e-4 | 2 at 0.00000 | +| Dado | 7 cross members, each ≥ 5 mm into both sides and ≥ 6 mm clear of the far face | 8.00 mm in, 14.00 mm clear | +| Closure bite | every cork and lid 2–10 mm into its body | 5.00 mm | +| Knob bite | 3 knobs, each 2–10 mm into its drawer front | 4.00 mm | +| Vessel seat | 15 vessels, each 0.5–3.0 mm into its shelf | 1.00–1.80 mm | +| Drawer seat | 3 drawer fronts, each 1–4 mm into the base | 2.00 mm | +| Label seat | inner face 0.1–0.8 mm inside the body, outer face ≥ 0.4 mm proud | 0.30 mm in, 0.80 mm proud | +| Headroom | every vessel ≥ 20 mm under the member above | 24.05 mm | +| Vessel overlaps | 0, vessel against vessel and against the carcass | 0 | +| Form spread | widest / narrowest body ≥ 2.0; tallest / shortest ≥ 1.5 | 3.146 / 2.202 | +| Edge treatment | right-angle wood edges | 0 | + +Real-world size: 0.88 m wide, 1.08 m to the top of the crest and 0.27 m +deep. It is a wall shelf for a shop counter or a study, and each +compartment is 260 mm clear. + +## Construction + +- **Carcass.** Two full-height sides carry everything and are the only + named supports. The base, the three shelves and the three gallery rails + are each housed `DADO` (8 mm) into both sides and stop 14 mm short of the + outer faces. The top board is seated over the sides and the back boards + (`TOP_SEAT`). The crest is an extruded outline whose top is a shallow + arc, seated `CREST_BITE` into the top board. Every board is chamfered at + 2.5 mm with `material=` pinned to wood, edges sorted by index. The + crest's caps are single n-gons, chamfered and then triangulated. +- **Back boards.** Four vertical boards, lapped 2 mm into one another + rather than butted. An open seam showed daylight through the back in the + first close-up. A lap on one plane is a coplanar pair, so every other + board steps forward 1.5 mm. The shelves and base bite into them. +- **Drawers.** Three fronts stand `DRAWER_BITE` into the base, with a + 4 mm reveal all round. Each has a turned brass knob lathed along −Y, + its shank `KNOB_BITE` into the front. +- **Vessels.** Each vessel is a closed lathe of exactly twelve rings between + two poles, so every form costs the same triangles. Every foot is + chamfered. The five forms: + - bottle: shoulder, neck and lip + - flask: a bulb on a small flat base, with a long neck + - jar: squat and wide-mouthed + - albarello: waisted + - vial: small + + Sizes vary by a closed-form ±5 % per vessel. Each shelf shares its clear + width out evenly. Vessels step front and back alternately inside the + depth left between the rail and the back boards. +- **Closures.** A cork is narrower than the mouth and flares above it. Its + flare stops short of the neck's own radius, because a cork wall on the + neck's cylinder is a coplanar pair with it. A lid is wider than the rim: + a skirt, a dome and a knob. Both sit `CLOSURE_BITE` into the body. +- **Labels.** Each label is built on the host's arc with the host's segment + count. Every label vertex sits at a body vertex's own angle and ring + height, so the paper follows a waist or a bulb exactly. The inner face + is 0.3 mm inside the body and the outer face 0.8 mm proud. The label is + centred on the front and turned with the vessel's yaw. +- **Seats vary by vessel.** Each vessel on a shelf sinks by a different + step (1.0–1.8 mm). Sunk to one depth, two bottom caps on a shelf lie in + one plane, a coplanar cross-shell pair. + +## Findings the budgets forced + +- **Coplanar corks.** The first corks flared to 1.10 × their radius, which + put a cork wall 0.1 mm off the neck cylinder of the same bottle: 32 + coplanar pairs on two bottles. The flare is now 1.20 × and the count is 0. +- **Matching parts to vessels.** Closures and labels were first matched to + the body with the nearest axis in plan. The three shelves stack vessels in + near-identical plan positions, so 12 of 15 labels went to a vessel on + another shelf, and the label seat read −23 mm. They are now matched only + among bodies at the part's own height. +- **Glass.** EEVEE transmission rendered every bottle as grainy speckle. + It stayed with and without raytracing, and at 256 samples. The glass + now carries its depth in a facing ramp and a clear coat: dark through + the middle, lighter at the silhouette. +- **Daylight through the back.** A 2 mm butt seam between back boards + showed the background as a bright line down the middle of the upper + compartment. The boards are now lapped with an alternating step. + +## 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; seats, lap steps and cork flares chosen to keep it at 0 | +| Named supports | yes | the two sides (`--float-side`) | +| Joint-fit budgets | yes | dado bite and far-face clearance per cross member (`--short-shelves`) | +| A member is tenoned into its seat | yes | sides into the top, crest into the top, back boards into base and top | +| A platform bears on something | yes | every shelf housed in both sides and biting the back boards | +| Carried parts bite their bearers | yes | vessels in shelves (`--float-jars`), drawer fronts in the base (`--lift-drawers`) | +| A joint bites; touching is not joining | yes | closures (`--pop-corks`) and knobs (`--pull-knobs`) as banded bites | +| Bands on a curved host are built on the host's arc | yes | labels on the body's own rings and angles (`--float-labels`) | +| Scattered parts do not interpenetrate | yes | exit 21 (`--crowd-jars`) | +| Scatter varies in size | yes | form spread, exit 22 (`--uniform-vessels`) | +| Edge treatment: no right angles | yes | exit 23 (`--sharp-rail`), wood only; lathed feet are chamfered by construction | +| Aim an edge falsifier at one member | yes | one rail left square: 12 edges, −32 triangles | +| Chamfer n-gon caps, then triangulate | yes | crest caps | +| Sort bmesh operator inputs | yes | bevel edges sorted by index | +| A plank wall is boards | yes | four lapped back boards | +| Identical boards read as CG | yes | per-board `PlankTone` and `GrainDir` | +| Shading is part of the model | yes | vessels, closures and knobs smooth (blown, turned); every edge over 35° hard, so boards read flat with crisp chamfers | +| One substance, one slot | yes | wood, glass, glaze, cork, paper, brass; glass and glaze colour per vessel from `Tint` | +| Iron is not chrome | n/a | brass knobs: metallic 0.9, roughness 0.42, darkened by noise | +| Bake texels per UV cell | measured, not a budget | 512 px over a 32-cell grid, about 15 px per cell | +| The bake cage is narrower than the nearest neighbour | yes | `CAGE_EXTRUSION` 0.01 m | +| 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 except `--pull-knobs` (+6 mm in Y, inside `BBOX_TOL`) | +| Mirror symmetry, plumb, rope, masonry, roofs, rings, scatter on terrain, vessels with liquid | no | the piece has none of these | + +## Falsifiers + +Each breaks one pipeline stage so a **named** budget fails. All fourteen +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 carcass | 15 | +| `--lift-z` | grounded zmin | lifts the whole mesh 50 mm | 16 | +| `--float-side` | named sides | lifts the left side 5 mm; the right side still grounds the AABB | 16 | +| `--short-shelves` | dado bite | stops the three shelves 4 mm short of the sides; −4.0 mm | 17 | +| `--pop-corks` | closure bite | lifts every cork and lid 10 mm; −5.0 mm | 17 | +| `--pull-knobs` | knob bite | pulls every knob 8 mm out of its front; −4.0 mm | 17 | +| `--float-jars` | vessel seat | lifts every vessel 6 mm off its shelf; −5.0 to −4.2 mm | 18 | +| `--lift-drawers` | drawer seat | lifts the drawer fronts 3 mm; −1.0 mm | 18 | +| `--float-labels` | label seat | builds every label 1.1 mm off its body; −1.1 mm | 18 | +| `--tall-flask` | headroom | stretches the middle-shelf flask 1.20 into the shelf above; −5.5 mm | 20 | +| `--crowd-jars` | vessel overlaps | draws the bottom shelf's jars to 0.55 of their spacing; 3 overlaps | 21 | +| `--uniform-vessels` | form spread | one radius and height for every vessel; 1.000 / 1.000 | 22 | +| `--sharp-rail` | edge treatment | leaves the top rail unchamfered; 12 right-angle edges | 23 | + +## 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 side floating (`--lift-z`, `--float-side`) | +| 17 | Dado, closure or knob bite (`--short-shelves`, `--pop-corks`, `--pull-knobs`) | +| 18 | Vessel, drawer or label seat (`--float-jars`, `--lift-drawers`, `--float-labels`) | +| 20 | Headroom under the member above (`--tall-flask`) | +| 21 | Vessels overlap each other or the carcass (`--crowd-jars`) | +| 22 | Form spread below floor (`--uniform-vessels`) | +| 23 | Right-angle wood edges (`--sharp-rail`) | + +## Run it + +```bash +# Budget check, no render. ~4.5 s on 5.1 and 5.2. +blender --background --python apothecary_shelf.py -- + +# Falsifier: the middle-shelf flask runs into the shelf above. Must exit 20. +blender --background --python apothecary_shelf.py -- --tall-flask + +# Falsifier: the shelves stop short of their dados. Must exit 17. +blender --background --python apothecary_shelf.py -- --short-shelves + +# Render the gallery still (EEVEE; --engine cycles on a GPU-less host). +blender --background --python apothecary_shelf.py -- --output apothecary_shelf.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 | 10456 | 10456 | 10456 | +| LOD1 / LOD2 tris | 5228 / 2300 | same | same | +| Face counts (wood / glass / glaze / cork / paper / brass) | 525 / 2080 / 1520 / 960 / 420 / 336 | same | same | +| Outer AABB | 0.8760 × 0.2740 × 1.0760 | same | same | +| Collider tris | 138 | 138 | 138 | +| Headroom / form spread | 24.05 mm / 3.146, 2.202 | same | same | +| glTF bytes | 448604 | 448600 | 448596 | diff --git a/showcase/apothecary-shelf/apothecary_shelf.py b/showcase/apothecary-shelf/apothecary_shelf.py new file mode 100644 index 00000000..d3955002 --- /dev/null +++ b/showcase/apothecary-shelf/apothecary_shelf.py @@ -0,0 +1,1639 @@ +"""Game-ready apothecary shelf — a showcase piece, not an example. + +Asserts budget conformance of a procedural apothecary shelf: a stained +carcass with housed shelves, gallery rails, a bank of three drawers and a +crest board, stocked with fifteen turned and blown vessels in five forms +(bottle, flask, jar, albarello, vial), each closed by a cork or a lid and +carrying a paper label. Carried through UVs, six materials (wood, glass, +glaze, cork, paper, brass), a high-to-low normal bake, an LOD chain, a +convex collider, and a Unity glTF export. + +The budget that matters here is the one a stocked shelf fails invisibly: +every vessel has to fit between its shelf and the one above it. A flask a +few centimetres too tall runs its neck up into the next shelf, and the +bounding box, the triangle count and every seat budget still pass, +because the flask stands where it should and the shelf above it is still +in its dado. The piece reads each vessel's top and the underside of the +member above it off the finished mesh and asserts a headroom floor. + +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, ``--float-side`` the named sides, +``--short-shelves`` the dado bite, ``--pop-corks`` the closure bite, +``--pull-knobs`` the knob bite, ``--float-jars`` the vessel seat, +``--lift-drawers`` the drawer seat, ``--float-labels`` the label seat, +``--tall-flask`` the headroom, ``--crowd-jars`` vessel interpenetration, +``--uniform-vessels`` the size spread, ``--sharp-rail`` edge treatment. + +No randomness: every vessel's form, size, yaw, tint and seat 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 apothecary_shelf.py -- + blender --background --python apothecary_shelf.py -- --tall-flask + blender --background --python apothecary_shelf.py -- --output apothecary_shelf.png +""" +import argparse +import math +import os +import sys +import tempfile +import traceback + +import bmesh +import bpy +from mathutils import Matrix, Vector +from mathutils.bvhtree import BVHTree +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 carcass. X across, Y front (-) to back (+), Z up. Two full-height +# sides carry everything; every cross member is housed into both of them. +W_OUT = 0.840 +SIDE_T = 0.022 +DEPTH = 0.250 +SIDE_H = 1.000 +DADO = 0.008 +TOP_T = 0.024 +TOP_SEAT = 0.006 +TOP_OVER = 0.018 +TOP_BACK_OVER = 0.006 +CREST_H = 0.058 +CREST_SHOULDER = 0.028 +CREST_ARC = 12 +CREST_T = 0.018 +CREST_BITE = 0.004 +BACK_T = 0.012 +BACK_INSET = 0.010 +BACK_BITE = 0.005 +N_BACK = 4 +BACK_LAP = 0.002 +BACK_STEP = 0.0015 +SHELF_T = 0.020 +SHELF_SETBACK = 0.004 +SHELF_TOPS = (0.175, 0.455, 0.735) +RAIL_H = 0.028 +RAIL_T = 0.010 +RAIL_INSET = 0.010 +RAIL_BITE = 0.002 +BASE_Z = 0.030 +BASE_T = 0.018 +BASE_SETBACK = 0.012 +N_DRAWERS = 3 +DRAWER_T = 0.018 +DRAWER_SETBACK = 0.002 +DRAWER_GAP = 0.004 +DRAWER_BITE = 0.002 +KNOB_BITE = 0.004 +KNOB_LEN = 0.022 +CHAMFER = 0.0025 + +# Vessels: a closed lathe body (twelve rings between two poles), a closure +# (cork or lid, five rings), and a paper label wrapped on the body's own +# rings. Every form has the same ring counts, so a vessel costs the same +# triangles whatever its form, and swapping forms moves no triangle budget. +SEG = 16 +LABEL_COLS = 4 +LABEL_BITE = 0.0003 +LABEL_PROUD = 0.0008 +FLOAT_LABEL = 0.0011 +CLOSURE_BITE = 0.005 +POP_CORK = 0.010 +SEAT_BASE = 0.0010 +SEAT_STEP = 0.0002 +FLOAT_JARS = 0.006 +LIFT_DRAWERS = 0.003 +PULL_KNOBS = 0.008 +FLOAT_SIDE = 0.005 +TALL_FLASK = 1.20 +CROWD = 0.55 +VESSEL_CLEAR = 0.006 +UNIFORM_R = 0.030 +UNIFORM_H = 0.150 + +# (form, glass or glaze) per shelf, left to right. +LAYOUT = ( + ("jar", "albarello", "flask", "albarello", "jar"), + ("bottle", "albarello", "vial", "bottle", "flask"), + ("vial", "bottle", "vial", "flask", "bottle"), +) +FORMS = { + # radius, body height, material, closure + "bottle": (0.034, 0.200, "glass", "cork"), + "flask": (0.050, 0.210, "glass", "cork"), + "jar": (0.055, 0.130, "glaze", "lid"), + "albarello": (0.048, 0.170, "glaze", "lid"), + "vial": (0.018, 0.100, "glass", "cork"), +} +GLASS_TINTS = ((0.42, 0.17, 0.03), (0.14, 0.30, 0.11), (0.08, 0.14, 0.36), (0.22, 0.09, 0.26)) +GLAZE_TINTS = ((0.74, 0.68, 0.54), (0.34, 0.44, 0.60), (0.36, 0.20, 0.10)) + +BBOX_TOL = 0.020 +OUTER_SIZE = (0.876, 0.274, 1.076) + +BASE_TRIS_MIN = 9800 +BASE_TRIS_MAX = 11100 +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 = 6 +FACE_FLOORS = {0: 450, 1: 1800, 2: 1300, 3: 850, 4: 380, 5: 300} +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 +SIDE_Z_MAX = 1e-4 +DADO_MIN = 0.005 +DADO_FAR_MIN = 0.006 +CLOSURE_BITE_MIN = 0.002 +CLOSURE_BITE_MAX = 0.010 +KNOB_BITE_MIN = 0.002 +KNOB_BITE_MAX = 0.010 +SEAT_MIN = 0.0005 +SEAT_MAX = 0.0030 +DRAWER_SEAT_MIN = 0.001 +DRAWER_SEAT_MAX = 0.004 +LABEL_BITE_MIN = 0.0001 +LABEL_BITE_MAX = 0.0008 +LABEL_PROUD_MIN = 0.0004 +HEADROOM_MIN = 0.020 +DIAM_SPREAD_MIN = 2.0 +HEIGHT_SPREAD_MIN = 1.5 +RIGHT_ANGLE_TOL = math.radians(5.0) + +WOOD_IDX = 0 +GLASS_IDX = 1 +GLAZE_IDX = 2 +CORK_IDX = 3 +PAPER_IDX = 4 +BRASS_IDX = 5 +N_CROSS = len(SHELF_TOPS) * 2 + 1 + + +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 frac(x): + return x - math.floor(x) + + +# --- profiles --------------------------------------------------------------- + + +def body_profile(form, r, h): + """(r, z) from the bottom pole to the top pole: exactly twelve rings. + + Every foot is chamfered, so no body meets the shelf on a right angle. + Returns the profile, the label's ring range and the mouth radius. + """ + c = 0.003 + if form == "bottle": + n = 0.012 + rings = [(r - c, 0.0), (r, c), (r, 0.25 * h), (r, 0.45 * h), (r, 0.65 * h), + (r * 0.96, 0.72 * h), (r * 0.78, 0.80 * h), (r * 0.45, 0.86 * h), + (n, 0.90 * h), (n, 0.96 * h), (n + 0.0035, 0.965 * h), (n + 0.0035, h)] + label, mouth = (3, 5), n + elif form == "flask": + n = 0.011 + rings = [(r * 0.55, 0.0), (r * 0.62, 0.004), (r * 0.86, 0.06 * h), (r * 0.98, 0.14 * h), + (r, 0.24 * h), (r * 0.95, 0.34 * h), (r * 0.75, 0.44 * h), (r * 0.40, 0.51 * h), + (n, 0.56 * h), (n, 0.93 * h), (n + 0.003, 0.94 * h), (n + 0.003, h)] + label, mouth = (4, 6), n + elif form == "jar": + rings = [(r - c, 0.0), (r, c), (r, 0.20 * h), (r, 0.45 * h), (r, 0.70 * h), + (r * 0.99, 0.80 * h), (r * 0.93, 0.86 * h), (r * 0.86, 0.90 * h), + (r * 0.84, 0.94 * h), (r * 0.88, 0.955 * h), (r * 0.88, h - 0.002), + (r * 0.86, h)] + label, mouth = (3, 5), r * 0.88 + elif form == "albarello": + rings = [(r - c, 0.0), (r, c), (r, 0.10 * h), (r * 0.90, 0.22 * h), (r * 0.86, 0.45 * h), + (r * 0.90, 0.68 * h), (r, 0.80 * h), (r * 0.96, 0.87 * h), (r * 0.80, 0.91 * h), + (r * 0.76, 0.95 * h), (r * 0.80, 0.96 * h), (r * 0.80, h)] + label, mouth = (4, 6), r * 0.80 + else: # vial + n = 0.009 + c = 0.0018 + rings = [(r - c, 0.0), (r, c), (r, 0.20 * h), (r, 0.45 * h), (r, 0.70 * h), + (r * 0.96, 0.80 * h), (r * 0.80, 0.85 * h), (r * 0.62, 0.88 * h), + (n, 0.90 * h), (n, 0.96 * h), (n + 0.002, 0.965 * h), (n + 0.002, h)] + label, mouth = (3, 5), n + prof = [Vector((0.0, 0.0))] + [Vector(p) for p in rings] + [Vector((0.0, h))] + return prof, label, mouth + + +def closure_profile(kind, mouth): + """(r, z) from the closure's own bottom: five rings between two poles. + + A cork is narrower than the mouth and flares above it; a lid is wider + than the rim, a skirt, a dome and a knob. + """ + if kind == "cork": + rc, ln = mouth - 0.001, 0.022 if mouth > 0.010 else 0.016 + # The flare stops short of the neck's own radius: a cork wall on the + # neck's cylinder is a coplanar pair with it. + rings = [(rc, 0.0), (rc * 1.06, 0.5 * ln), (rc * 1.20, 0.8 * ln), + (rc * 1.20, ln - 0.002), (rc * 1.08, ln)] + top = ln + else: + rl = mouth + 0.004 + rings = [(rl, 0.0), (rl, 0.010), (rl * 0.80, 0.019), (0.012, 0.024), (0.014, 0.034)] + top = 0.036 + return [Vector((0.0, 0.0))] + [Vector(p) for p in rings] + [Vector((0.0, top))] + + +def knob_profile(): + """(r, t) along the knob axis, from the end buried in the drawer front.""" + rings = [(0.005, 0.0), (0.005, 0.009), (0.004, 0.011), (0.010, 0.016), (0.011, 0.019), + (0.009, 0.0215)] + return [Vector((0.0, 0.0))] + [Vector(p) for p in rings] + [Vector((0.0, KNOB_LEN))] + + +# --- 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 lathe(bm, profile, n, mat_idx, ctx, xf, phase=0.0, piece=0): + """Revolve an (r, z) profile about local Z, placed by ``xf``. + + r == 0 at either end makes a pole. One strip island. Returns the + rings (a vertex or a list of n vertices per profile point). + """ + island = new_island(ctx) + s = [0.0] + for a, b in zip(profile, profile[1:]): + s.append(s[-1] + (b - a).length) + rings = [] + for p in profile: + if p.x <= 0.0: + rings.append(bm.verts.new(xf @ Vector((0.0, 0.0, p.y)))) + continue + rings.append([bm.verts.new(xf @ Vector((p.x * math.cos(phase + 2 * math.pi * k / n), + p.x * math.sin(phase + 2 * math.pi * k / n), + p.y))) + for k in range(n)]) + for k in range(len(rings) - 1): + a, b = rings[k], rings[k + 1] + for i in range(n): + j = (i + 1) % n + if isinstance(a, list) and isinstance(b, list): + vs = (a[i], a[j], b[j], b[i]) + uv = {a[i]: (s[k], i / n), a[j]: (s[k], (i + 1) / n), + b[j]: (s[k + 1], (i + 1) / n), b[i]: (s[k + 1], i / n)} + elif isinstance(b, list): + vs = (a, b[j], b[i]) + uv = {a: (s[k], (i + 0.5) / n), b[j]: (s[k + 1], (i + 1) / n), + b[i]: (s[k + 1], i / n)} + else: + vs = (a[i], a[j], b) + uv = {a[i]: (s[k], i / n), a[j]: (s[k], (i + 1) / n), + b: (s[k + 1], (i + 0.5) / n)} + f = bm.faces.new(vs) + f.material_index = mat_idx + f[ctx["piece"]] = piece + stamp(ctx, f, island, uv) + return rings + + +def add_label(bm, profile, rings, n, phase, xf, ctx, piece, float_label=False): + """A paper label on the body's own rings ``rings[0]..rings[1]``. + + Built on the host's arc with the host's segment count: every label + vertex sits at a body vertex's angle and height, so the paper follows + the body exactly, including a waist or a bulb. Inner face LABEL_BITE + inside the body, outer face LABEL_PROUD outside it. + """ + r0, r1 = rings + inner_off = FLOAT_LABEL if float_label else -LABEL_BITE + outer_off = inner_off + LABEL_BITE + LABEL_PROUD + grid = {} + for j in range(r0, r1 + 1): + p = profile[j] + for i in range(LABEL_COLS + 1): + a = phase + 2 * math.pi * i / n + for side, off in (("in", inner_off), ("out", outer_off)): + rr = p.x + off + grid[(j, i, side)] = bm.verts.new( + xf @ Vector((rr * math.cos(a), rr * math.sin(a), p.y))) + quads = [] + for j in range(r0, r1): + for i in range(LABEL_COLS): + quads.append((grid[(j, i, "out")], grid[(j, i + 1, "out")], + grid[(j + 1, i + 1, "out")], grid[(j + 1, i, "out")])) + quads.append((grid[(j + 1, i, "in")], grid[(j + 1, i + 1, "in")], + grid[(j, i + 1, "in")], grid[(j, i, "in")])) + for i in range(LABEL_COLS): + quads.append((grid[(r0, i, "in")], grid[(r0, i + 1, "in")], + grid[(r0, i + 1, "out")], grid[(r0, i, "out")])) + quads.append((grid[(r1, i, "out")], grid[(r1, i + 1, "out")], + grid[(r1, i + 1, "in")], grid[(r1, i, "in")])) + for j in range(r0, r1): + quads.append((grid[(j, 0, "in")], grid[(j, 0, "out")], + grid[(j + 1, 0, "out")], grid[(j + 1, 0, "in")])) + e = LABEL_COLS + quads.append((grid[(j + 1, e, "in")], grid[(j + 1, e, "out")], + grid[(j, e, "out")], grid[(j, e, "in")])) + for vs in quads: + f = bm.faces.new(vs) + f.material_index = PAPER_IDX + f[ctx["piece"]] = piece + + +def add_board(bm, lo, hi, boards, sharp=False): + """Axis-aligned board from corner ``lo`` to ``hi``; its verts go in ``boards``.""" + geo = bmesh.ops.create_cube(bm, size=1.0) + c = [(lo[k] + hi[k]) * 0.5 for k in range(3)] + s = [hi[k] - lo[k] for k in range(3)] + for v in geo["verts"]: + v.co = Vector((c[0] + v.co.x * s[0], c[1] + v.co.y * s[1], c[2] + v.co.z * s[2])) + for f in {f for v in geo["verts"] for f in v.link_faces}: + f.material_index = WOOD_IDX + if not sharp: + boards.extend(geo["verts"]) + return geo["verts"] + + +def add_crest(bm, a, y0, y1, z0, z_side, z_top, boards): + """The crest: a board whose top is a shallow arc, extruded through Y. + + Both caps are single n-gons, chamfered with the rest and triangulated + afterwards, so the shipped mesh has no n-gon. + """ + sag = z_top - z_side + rad = (a * a + sag * sag) / (2.0 * sag) + zc = z_top - rad + phi0 = math.asin(a / rad) + outline = [(-a, z0), (a, z0)] + for k in range(CREST_ARC + 1): + phi = phi0 - 2.0 * phi0 * k / CREST_ARC + outline.append((rad * math.sin(phi), zc + rad * math.cos(phi))) + front = [bm.verts.new((x, y0, z)) for x, z in outline] + back = [bm.verts.new((x, y1, z)) for x, z in outline] + faces = [bm.faces.new(front), bm.faces.new(list(reversed(back)))] + n = len(outline) + for i in range(n): + j = (i + 1) % n + faces.append(bm.faces.new((front[j], front[i], back[i], back[j]))) + for f in faces: + f.material_index = WOOD_IDX + boards.extend(front + back) + + +def vessel_plan(uniform=False, crowd=False): + """Every vessel: shelf, form, radius, height, centre, yaw and seat. + + Closed-form throughout. Each shelf's vessels share out the clear width + between the sides evenly, and step front and back alternately inside + the depth left between the rail and the back boards. + """ + x_in = W_OUT / 2.0 - SIDE_T + y_front = -DEPTH / 2.0 + RAIL_INSET + RAIL_T + y_back = DEPTH / 2.0 - BACK_INSET - BACK_T - BACK_STEP + out = [] + idx = 0 + for s, row in enumerate(LAYOUT): + sizes = [] + for k, form in enumerate(row): + r, h, _m, _c = FORMS[form] + jr = 1.0 + 0.10 * (frac((idx + k) * 0.6180339887 + 0.21) - 0.5) + jh = 1.0 + 0.10 * (frac((idx + k) * 0.4142135624 + 0.57) - 0.5) + if uniform: + r, h, jr, jh = UNIFORM_R, UNIFORM_H, 1.0, 1.0 + sizes.append((form, r * jr, h * jh)) + free = 2.0 * x_in - sum(2.0 * sz[1] for sz in sizes) + gap = free / (len(sizes) + 1) + x = -x_in + gap + for k, (form, r, h) in enumerate(sizes): + cx = x + r + x += 2.0 * r + gap + if crowd and s == 0: + cx *= CROWD + lo_y, hi_y = y_front + r + VESSEL_CLEAR, y_back - r - VESSEL_CLEAR + mid, half = (lo_y + hi_y) * 0.5, (hi_y - lo_y) * 0.5 + cy = mid + half * (0.55 if k % 2 else -0.45) + yaw = math.radians(18.0) * (frac(idx * 0.7548776662 + 0.1) - 0.5) + seat = SEAT_BASE + SEAT_STEP * k + out.append({"i": idx, "shelf": s, "form": form, "r": r, "h": h, + "c": (cx, cy), "yaw": yaw, "seat": seat}) + idx += 1 + return out + + +def build_shelf_mesh( + name, + stray_vert=False, + float_side=False, + short_shelves=False, + pop_corks=False, + pull_knobs=False, + float_jars=False, + lift_drawers=False, + float_labels=False, + tall_flask=False, + crowd_jars=False, + uniform_vessels=False, + sharp_rail=False, +): + x_out = W_OUT / 2.0 + x_in = x_out - SIDE_T + y0, y1 = -DEPTH / 2.0, DEPTH / 2.0 + top_z0 = SIDE_H - TOP_SEAT + top_z1 = top_z0 + TOP_T + back_y1 = y1 - BACK_INSET + back_y0 = back_y1 - BACK_T + bm = bmesh.new() + try: + ctx = {"uv": bm.loops.layers.uv.new("UVMap"), + "isl": bm.faces.layers.int.new("UVIsland"), + "piece": bm.faces.layers.int.new("Piece"), "next": 0} + boards = [] + # Sides: the two named supports. + for sx in (-1.0, 1.0): + lift = FLOAT_SIDE if (float_side and sx < 0) else 0.0 + add_board(bm, (min(sx * x_out, sx * x_in), y0, lift), + (max(sx * x_out, sx * x_in), y1, SIDE_H + lift), boards) + add_board(bm, (-x_out - TOP_OVER, y0 - TOP_OVER, top_z0), + (x_out + TOP_OVER, y1 + TOP_BACK_OVER, top_z1), boards) + add_crest(bm, x_in, back_y0 - CREST_T - 0.004, back_y0 - 0.004, + top_z1 - CREST_BITE, top_z1 + CREST_SHOULDER, top_z1 + CREST_H, boards) + # Back boards, housed into the sides and the top, standing on the + # base. Lapped, not butted: a seam left open shows daylight through + # the back, and a lap on one plane is a coplanar pair, so every other + # board steps forward by BACK_STEP. + span0, span1 = -x_in - BACK_BITE, x_in + BACK_BITE + bw = (span1 - span0 + BACK_LAP * (N_BACK - 1)) / N_BACK + for k in range(N_BACK): + bx = span0 + k * (bw - BACK_LAP) + step = BACK_STEP if k % 2 else 0.0 + add_board(bm, (bx, back_y0 - step, BASE_Z + BASE_T - BACK_BITE), + (bx + bw, back_y1 - step, top_z0 + BACK_BITE), boards) + # Cross members: base, shelves and rails, each in a dado in both sides. + reach = x_in - 0.004 if short_shelves else x_in + DADO + add_board(bm, (-(x_in + DADO), y0 + BASE_SETBACK, BASE_Z), + (x_in + DADO, back_y0 + BACK_BITE, BASE_Z + BASE_T), boards) + for s, zt in enumerate(SHELF_TOPS): + add_board(bm, (-reach, y0 + SHELF_SETBACK, zt - SHELF_T), + (reach, back_y0 + BACK_BITE, zt), boards) + add_board(bm, (-(x_in + DADO), y0 + RAIL_INSET, zt - RAIL_BITE), + (x_in + DADO, y0 + RAIL_INSET + RAIL_T, zt + RAIL_H - RAIL_BITE), + boards, sharp=(sharp_rail and s == len(SHELF_TOPS) - 1)) + # Drawer bank: three fronts standing in the base, a knob in each. + dz0 = BASE_Z + BASE_T - DRAWER_BITE + (LIFT_DRAWERS if lift_drawers else 0.0) + dz1 = SHELF_TOPS[0] - SHELF_T - DRAWER_GAP + (LIFT_DRAWERS if lift_drawers else 0.0) + dw = (2.0 * x_in - DRAWER_GAP * (N_DRAWERS + 1)) / N_DRAWERS + dy0 = y0 + DRAWER_SETBACK + knobs = [] + for k in range(N_DRAWERS): + dx = -x_in + DRAWER_GAP + k * (dw + DRAWER_GAP) + add_board(bm, (dx, dy0, dz0), (dx + dw, dy0 + DRAWER_T, dz1), boards) + knobs.append((dx + dw * 0.5, (dz0 + dz1) * 0.5)) + # A set of BMEdges iterates in address order: sort by index so the + # bevel emits its faces in the same order on every run. + bm.edges.index_update() + # The crest's arc strips meet at a few degrees; only real edges chamfer. + edges = sorted({e for v in boards 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=WOOD_IDX) + # Chamfer n-gon caps, then triangulate. + bmesh.ops.triangulate(bm, faces=[f for f in bm.faces if len(f.verts) > 4]) + for kx, kz in knobs: + ky = dy0 + KNOB_BITE - (PULL_KNOBS if pull_knobs else 0.0) + xf = Matrix.Translation((kx, ky, kz)) @ Matrix.Rotation(math.pi / 2.0, 4, "X") + lathe(bm, knob_profile(), SEG, BRASS_IDX, ctx, xf) + for v in vessel_plan(uniform=uniform_vessels, crowd=crowd_jars): + form = v["form"] + _r, _h, mat, kind = FORMS[form] + prof, label, mouth = body_profile(form, v["r"], v["h"]) + z0 = SHELF_TOPS[v["shelf"]] - v["seat"] + (FLOAT_JARS if float_jars else 0.0) + stretch = TALL_FLASK if (tall_flask and v["shelf"] == 1 and form == "flask") else 1.0 + xf = (Matrix.Translation((v["c"][0], v["c"][1], z0)) + @ Matrix.Diagonal((1.0, 1.0, stretch, 1.0))) + # Label centred on the front, turned by the vessel's yaw. + phase = -math.pi / 2.0 + v["yaw"] - (LABEL_COLS / 2) * 2.0 * math.pi / SEG + piece = v["i"] + 1 + midx = GLASS_IDX if mat == "glass" else GLAZE_IDX + lathe(bm, prof, SEG, midx, ctx, xf, phase=phase, piece=piece) + add_label(bm, prof, label, SEG, phase, xf, ctx, piece, float_label=float_labels) + cz = prof[-1].y - CLOSURE_BITE + (POP_CORK if pop_corks else 0.0) + cxf = xf @ Matrix.Translation((0.0, 0.0, cz)) + cmat = CORK_IDX if kind == "cork" else GLAZE_IDX + lathe(bm, closure_profile(kind, mouth), SEG, cmat, ctx, cxf, phase=phase, piece=piece) + if stray_vert: + bm.verts.new((0.0, 0.0, 0.5)) + 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): + """Face attributes the shaders read: per-board tone and grain, per-vessel tint. + + Every board gets a closed-form tone and the direction it runs in, its + longest extent, so the grain follows the board. Every vessel gets a + glass tint or a glaze from its index; the ``Piece`` tag is then dropped. + """ + npoly = len(me.polygons) + piece = [0] * npoly + me.attributes["Piece"].data.foreach_get("value", piece) + mats = [0] * npoly + me.polygons.foreach_get("material_index", mats) + tone = [0.5] * npoly + grain = [(1.0, 0.0, 0.0)] * npoly + tint = [(0.5, 0.5, 0.5, 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)): + faces = {fi for i in g for fi in vf[i]} + if not faces or mats[next(iter(faces))] != WOOD_IDX: + continue + 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.30 * (frac(k * 0.6180339887 + 0.3) - 0.5) + for fi in faces: + tone[fi] = t + grain[fi] = d + for fi in range(npoly): + v = piece[fi] - 1 + if v < 0: + continue + if mats[fi] == GLASS_IDX: + c = GLASS_TINTS[v % len(GLASS_TINTS)] + else: + c = GLAZE_TINTS[v % len(GLAZE_TINTS)] + tint[fi] = (c[0], c[1], c[2], 1.0) + tone[fi] = 0.5 + 0.3 * (frac(v * 0.4142135624) - 0.5) + me.attributes.remove(me.attributes["Piece"]) + 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]) + c = me.attributes.new("Tint", "FLOAT_COLOR", "FACE") + c.data.foreach_set("color", [x for v in tint for x in v]) + + +# --- surface ---------------------------------------------------------------- + + +def _sock(sockets, identifier): + return next(sk for sk in sockets if sk.identifier == identifier) + + +def _input(bsdf, *names): + for n in names: + if n in bsdf.inputs: + return bsdf.inputs[n] + return None + + +def wood_material(name): + """Stained timber: grain along each board, tone per board (copied from crate-stack).""" + 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 = (0.085, 0.036, 0.014, 1.0) + ramp.color_ramp.elements[1].position = 0.72 + ramp.color_ramp.elements[1].color = (0.27, 0.125, 0.048, 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"]) + rough = nt.nodes.new("ShaderNodeMapRange") + rough.inputs["To Min"].default_value = 0.66 + rough.inputs["To Max"].default_value = 0.48 + nt.links.new(noise.outputs["Fac"], rough.inputs["Value"]) + nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"]) + return mat + + +def glaze_material(name): + """Glazed stoneware: the per-vessel ``Tint``, glossy, with a faint mottle.""" + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + attr = nt.nodes.new("ShaderNodeAttribute") + attr.attribute_name = "Tint" + tc = nt.nodes.new("ShaderNodeTexCoord") + noise = nt.nodes.new("ShaderNodeTexNoise") + noise.inputs["Scale"].default_value = 45.0 + noise.inputs["Detail"].default_value = 4.0 + nt.links.new(tc.outputs["Object"], noise.inputs["Vector"]) + shade = nt.nodes.new("ShaderNodeMapRange") + shade.inputs["To Min"].default_value = 0.86 + shade.inputs["To Max"].default_value = 1.0 + nt.links.new(noise.outputs["Fac"], shade.inputs["Value"]) + mul = nt.nodes.new("ShaderNodeVectorMath") + mul.operation = "SCALE" + nt.links.new(attr.outputs["Color"], mul.inputs[0]) + nt.links.new(shade.outputs["Result"], mul.inputs["Scale"]) + nt.links.new(mul.outputs["Vector"], bsdf.inputs["Base Color"]) + bsdf.inputs["Roughness"].default_value = 0.28 + return mat + + +def glass_material(name): + """Tinted glass, filled: dark through the middle, lighter at the silhouette. + + No transmission. EEVEE's transmission rendered every bottle as a grainy + speckle that 256 samples did not clear, with or without raytracing, so + the depth of the glass is carried by a facing ramp and a clear coat. + """ + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + attr = nt.nodes.new("ShaderNodeAttribute") + attr.attribute_name = "Tint" + lw = nt.nodes.new("ShaderNodeLayerWeight") + lw.inputs["Blend"].default_value = 0.45 + depth = nt.nodes.new("ShaderNodeMapRange") + depth.inputs["To Min"].default_value = 0.30 + depth.inputs["To Max"].default_value = 0.95 + nt.links.new(lw.outputs["Facing"], depth.inputs["Value"]) + mul = nt.nodes.new("ShaderNodeVectorMath") + mul.operation = "SCALE" + nt.links.new(attr.outputs["Color"], mul.inputs[0]) + nt.links.new(depth.outputs["Result"], mul.inputs["Scale"]) + nt.links.new(mul.outputs["Vector"], bsdf.inputs["Base Color"]) + bsdf.inputs["Roughness"].default_value = 0.06 + coat = _input(bsdf, "Coat Weight", "Clearcoat") + if coat is not None: + coat.default_value = 1.0 + coat_r = _input(bsdf, "Coat Roughness", "Clearcoat Roughness") + if coat_r is not None: + coat_r.default_value = 0.03 + return mat + + +def paper_material(name): + """Aged paper with ruled lines of ink broken into words by noise.""" + mat = speckled(name, (0.56, 0.49, 0.36), (0.78, 0.72, 0.58), 25.0, 0.9) + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + paper = bsdf.inputs["Base Color"].links[0].from_socket + tc = nt.nodes.new("ShaderNodeTexCoord") + sep = nt.nodes.new("ShaderNodeSeparateXYZ") + nt.links.new(tc.outputs["Object"], sep.inputs["Vector"]) + lines = nt.nodes.new("ShaderNodeMath") + lines.operation = "PINGPONG" + lines.inputs[1].default_value = 0.0045 + nt.links.new(sep.outputs["Z"], lines.inputs[0]) + rule = nt.nodes.new("ShaderNodeMath") + rule.operation = "LESS_THAN" + rule.inputs[1].default_value = 0.0005 + nt.links.new(lines.outputs["Value"], rule.inputs[0]) + words = nt.nodes.new("ShaderNodeTexNoise") + words.inputs["Scale"].default_value = 220.0 + words.inputs["Detail"].default_value = 1.0 + nt.links.new(tc.outputs["Object"], words.inputs["Vector"]) + gate = nt.nodes.new("ShaderNodeMath") + gate.operation = "GREATER_THAN" + gate.inputs[1].default_value = 0.50 + nt.links.new(words.outputs["Fac"], gate.inputs[0]) + ink = nt.nodes.new("ShaderNodeMath") + ink.operation = "MULTIPLY" + nt.links.new(rule.outputs["Value"], ink.inputs[0]) + nt.links.new(gate.outputs["Value"], ink.inputs[1]) + mix = nt.nodes.new("ShaderNodeMix") + mix.data_type = "RGBA" + nt.links.new(ink.outputs["Value"], _sock(mix.inputs, "Factor_Float")) + nt.links.new(paper, _sock(mix.inputs, "A_Color")) + _sock(mix.inputs, "B_Color").default_value = (0.26, 0.18, 0.12, 1.0) + nt.links.new(_sock(mix.outputs, "Result_Color"), bsdf.inputs["Base Color"]) + return mat + + +def speckled(name, color_a, color_b, scale, roughness): + 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 = scale + 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.35 + ramp.color_ramp.elements[0].color = (*color_a, 1.0) + ramp.color_ramp.elements[1].position = 0.70 + ramp.color_ramp.elements[1].color = (*color_b, 1.0) + nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) + nt.links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"]) + bsdf.inputs["Roughness"].default_value = roughness + return mat + + +def brass_material(name): + """Aged brass: warm, a little dull, darker in the recesses of the noise.""" + mat = speckled(name, (0.30, 0.19, 0.07), (0.55, 0.39, 0.16), 60.0, 0.42) + mat.node_tree.nodes["Principled BSDF"].inputs["Metallic"].default_value = 0.9 + return mat + + +def shelf_materials(): + return ( + wood_material("ShelfWood"), + glass_material("ShelfGlass"), + glaze_material("ShelfGlaze"), + speckled("ShelfCork", (0.30, 0.19, 0.10), (0.52, 0.37, 0.21), 90.0, 0.85), + paper_material("ShelfPaper"), + brass_material("ShelfBrass"), + ) + + +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 right_angle_edges(me): + """Manifold edges between two wood faces that meet within 5 degrees of 90.""" + count = 0 + bm = bmesh.new() + try: + bm.from_mesh(me) + for e in bm.edges: + if len(e.link_faces) != 2: + continue + if any(f.material_index != WOOD_IDX for f in e.link_faces): + continue + if abs(e.calc_face_angle(0.0) - math.pi / 2.0) <= RIGHT_ANGLE_TOL: + count += 1 + finally: + bm.free() + return count + + +def shell_tree(me, groups): + member = set(i for g in groups for i in g) + order = sorted(member) + remap = {v: k for k, v in enumerate(order)} + polys = [[remap[i] for i in p.vertices] for p in me.polygons if p.vertices[0] in member] + return BVHTree.FromPolygons([me.vertices[i].co.copy() for i in order], polys) + + +def classify(me): + """Split the finished mesh into named parts by material and shape.""" + mats = {} + for p in me.polygons: + for i in p.vertices: + mats.setdefault(i, p.material_index) + out = {k: [] for k in ("side", "top", "crest", "back", "cross", "drawer", "knob", + "body", "closure", "label", "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))) + ext = hi - lo + rec = {"g": g, "pts": pts, "lo": lo, "hi": hi, "ext": ext, + "c": sum(pts, Vector()) / len(pts)} + m = mats.get(g[0], -1) + if m == WOOD_IDX: + if ext.x < 0.05 and ext.z > 0.9: + out["side"].append(rec) + elif ext.y < 0.03 and ext.z > 0.9: + out["back"].append(rec) + elif lo.z > SIDE_H: + out["crest"].append(rec) + elif hi.z > SIDE_H: + out["top"].append(rec) + elif ext.x > 0.7: + out["cross"].append(rec) + else: + out["drawer"].append(rec) + elif m == BRASS_IDX: + out["knob"].append(rec) + elif m == PAPER_IDX: + out["label"].append(rec) + elif m == CORK_IDX: + out["closure"].append(rec) + elif m in (GLASS_IDX, GLAZE_IDX): + out["body" if ext.z > 0.06 else "closure"].append(rec) + else: + out["other"].append(rec) + return out + + +def axis_of(rec): + """A lathe's axis in plan: the mean of its vertices, exact for a full revolution.""" + return Vector((rec["c"].x, rec["c"].y)) + + +def shelf_audit(me): + parts = classify(me) + out = {k: len(v) for k, v in parts.items()} + sides = sorted(parts["side"], key=lambda r: r["c"].x) + out["side_z"] = max((r["lo"].z for r in sides), default=99.0) + + # Every cross member is housed in both sides: bite past the inner face, + # and clear of the far face. + bite, far = 99.0, 99.0 + if len(sides) == 2: + left_in, left_out = sides[0]["hi"].x, sides[0]["lo"].x + right_in, right_out = sides[1]["lo"].x, sides[1]["hi"].x + for r in parts["cross"]: + bite = min(bite, left_in - r["lo"].x, r["hi"].x - right_in) + far = min(far, r["lo"].x - left_out, right_out - r["hi"].x) + out["dado"], out["dado_far"] = bite, far + + # Vessels: each body with its closure and label, by nearest axis. + bodies = parts["body"] + units = [{"body": b, "closure": [], "label": []} for b in bodies] + for kind in ("closure", "label"): + for r in parts[kind]: + if not units: + break + # Nearest axis in plan among the bodies at this part's height: + # the shelves stack vessels in the same plan positions. + p = Vector((r["c"].x, r["c"].y)) + level = [u for u in units + if u["body"]["lo"].z <= r["c"].z <= u["body"]["hi"].z + 0.06] + if level: + u = min(level, key=lambda u: (axis_of(u["body"]) - p).length) + u[kind].append(r) + out["units"] = len(units) + out["unmatched"] = sum(1 for u in units if len(u["closure"]) != 1 or len(u["label"]) != 1) + + shelves = [r for r in parts["cross"] if r["ext"].y > 0.15] + horizontals = shelves + parts["top"] + closure_bites, seats, heads = [], [], [] + for u in units: + b = u["body"] + for c in u["closure"]: + closure_bites.append(b["hi"].z - c["lo"].z) + under = [s for s in shelves if s["hi"].z <= b["lo"].z + 0.01] + if under: + s = max(under, key=lambda s: s["hi"].z) + u["shelf"] = s + seats.append(s["hi"].z - b["lo"].z) + top = max([b["hi"].z] + [c["hi"].z for c in u["closure"]]) + above = [h for h in horizontals if h["lo"].z > b["lo"].z + 0.05] + if above: + heads.append(min(h["lo"].z for h in above) - top) + out["closure_bite"] = (min(closure_bites, default=-99.0), max(closure_bites, default=99.0)) + out["seat"] = (min(seats, default=-99.0), max(seats, default=99.0)) + out["headroom"] = min(heads, default=-99.0) + out["n_seated"] = len(seats) + + # Labels on their bodies: each label vertex against the body vertex it + # was built on, measured radially from the body's own axis. + lab_in, lab_out = [], [] + for u in units: + b = u["body"] + kd = KDTree(len(b["pts"])) + for k, p in enumerate(b["pts"]): + kd.insert(p, k) + kd.balance() + ax = axis_of(b) + for lab in u["label"]: + # Positive is inside the body. Half the label's vertices are its + # inner face and half its outer; sorting splits them without a + # sign test, so a floated label's inner face still reads as inner. + ds = [] + for p in lab["pts"]: + q = kd.find(p)[0] + ds.append((Vector((q.x, q.y)) - ax).length - (Vector((p.x, p.y)) - ax).length) + ds.sort() + half = len(ds) // 2 + lab_in.append(min(ds[half:])) + lab_out.append(-max(ds[:half])) + out["label_bite"] = (min(lab_in, default=-99.0), max(lab_in, default=99.0)) + out["label_proud"] = min(lab_out, default=-99.0) + + # Knobs bite their drawer fronts; drawer fronts stand in the base. + base = min(parts["cross"], key=lambda r: r["lo"].z) if parts["cross"] else None + knob_bites, drawer_seats = [], [] + for d in parts["drawer"]: + if base is not None: + drawer_seats.append(base["hi"].z - d["lo"].z) + near = [k for k in parts["knob"] if d["lo"].x < k["c"].x < d["hi"].x] + for k in near: + knob_bites.append(k["hi"].y - d["lo"].y) + out["knob_bite"] = (min(knob_bites, default=-99.0), max(knob_bites, default=99.0)) + out["n_knob_bites"] = len(knob_bites) + out["drawer_seat"] = (min(drawer_seats, default=-99.0), max(drawer_seats, default=99.0)) + + # Vessels do not run into one another, nor into the carcass beyond the + # shelf each one stands in. + trees = [shell_tree(me, [u["body"]["g"]] + [r["g"] for r in u["closure"] + u["label"]]) + for u in units] + carcass = [r for k in ("side", "back", "cross", "top") for r in parts[k]] + ctrees = [shell_tree(me, [r["g"]]) for r in carcass] + hits = 0 + for i in range(len(trees)): + for j in range(i + 1, len(trees)): + if trees[i].overlap(trees[j]): + hits += 1 + for r, ct in zip(carcass, ctrees): + if r is units[i].get("shelf"): + continue + if trees[i].overlap(ct): + hits += 1 + out["vessel_hits"] = hits + + # Forms vary: widest body over narrowest, tallest over shortest. + # Diameter from the axis, not the AABB: a yawed 16-gon's box width + # moves with the yaw. + diams = [2.0 * max((Vector((p.x, p.y)) - axis_of(b)).length for p in b["pts"]) + for b in bodies] + hts = [b["ext"].z for b in bodies] + out["diam_spread"] = max(diams) / min(diams) if diams else 0.0 + out["height_spread"] = max(hts) / min(hts) if hts else 0.0 + 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): + """One convex hull over the carcass and the knobs; the vessels sit inside it.""" + me = obj.data + mats = {} + for p in me.polygons: + for i in p.vertices: + mats.setdefault(i, p.material_index) + pts = [me.vertices[i].co.copy() for i in range(len(me.vertices)) + if mats.get(i) in (WOOD_IDX, BRASS_IDX)] + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + vs = [bm.verts.new(p) for p in pts] + ret = bmesh.ops.convex_hull(bm, input=vs) + drop = [] + for v in ret["geom_interior"] + ret["geom_unused"]: + if isinstance(v, bmesh.types.BMVert) and v not in drop: + drop.append(v) + bmesh.ops.delete(bm, geom=drop, context="VERTS") + bmesh.ops.dissolve_limit(bm, angle_limit=math.radians(1.0), + verts=list(bm.verts), edges=list(bm.edges)) + bmesh.ops.triangulate(bm, faces=list(bm.faces)) + 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("ShelfNrm", 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 = WOOD_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_shelf_mesh("ShelfLow", **flags) + hi_flags = {k: v for k, v in flags.items() if k != "stray_vert"} + high = build_shelf_mesh("ShelfHigh", **hi_flags) + mats = shelf_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("shelf 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[WOOD_IDX], BAKE_RES) + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "ShelfLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "ShelfLOD2", 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, "ShelfCollider") + col_tris = triangle_count(collider.data) + + export_path = os.path.join(tempfile.gettempdir(), f"bdt_apothecary_{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) + sa = shelf_audit(low.data) + right = right_angle_edges(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 sides={sa['side']} top={sa['top']} crest={sa['crest']} " + f"back={sa['back']} cross={sa['cross']} drawers={sa['drawer']} knobs={sa['knob']} " + f"bodies={sa['body']} closures={sa['closure']} labels={sa['label']} " + f"other={sa['other']} unmatched={sa['unmatched']} side_z={sa['side_z']:.5f}") + print(f"measured joints dado={sa['dado']:.5f} dado_far={sa['dado_far']:.5f} " + f"closure_bite=({sa['closure_bite'][0]:.5f},{sa['closure_bite'][1]:.5f}) " + f"knob_bite=({sa['knob_bite'][0]:.5f},{sa['knob_bite'][1]:.5f})") + print(f"measured seats vessel=({sa['seat'][0]:.5f},{sa['seat'][1]:.5f}) " + f"n={sa['n_seated']} drawer=({sa['drawer_seat'][0]:.5f},{sa['drawer_seat'][1]:.5f}) " + f"label_bite=({sa['label_bite'][0]:.5f},{sa['label_bite'][1]:.5f}) " + f"label_proud={sa['label_proud']:.5f}") + print(f"measured headroom={sa['headroom']:.5f} vessel_hits={sa['vessel_hits']} " + f"diam_spread={sa['diam_spread']:.3f} height_spread={sa['height_spread']:.3f} " + f"right_angle_wood={right}") + + n_vessels = sum(len(row) for row in LAYOUT) + 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 sa["side"] != 2 or sa["side_z"] > SIDE_Z_MAX: + return (fail(f"sides: {sa['side']} of 2, worst side z={sa['side_z']:.5f} > {SIDE_Z_MAX} " + "(--float-side is the designed fail)", 16),) + nothing + if sa["cross"] != N_CROSS or sa["dado"] < DADO_MIN or sa["dado_far"] < DADO_FAR_MIN: + return (fail(f"{sa['cross']} of {N_CROSS} cross members, dado bite {sa['dado']:.5f} " + f"(min {DADO_MIN}), far face {sa['dado_far']:.5f} (min {DADO_FAR_MIN}) " + "(--short-shelves is the designed fail)", 17),) + nothing + if (sa["units"] != n_vessels or sa["unmatched"] + or not (CLOSURE_BITE_MIN <= sa["closure_bite"][0]) + or sa["closure_bite"][1] > CLOSURE_BITE_MAX): + return (fail(f"{sa['units']} of {n_vessels} vessels, {sa['unmatched']} without one " + f"closure and one label, closure bite {sa['closure_bite']} outside " + f"[{CLOSURE_BITE_MIN}, {CLOSURE_BITE_MAX}] (--pop-corks is the designed fail)", + 17),) + nothing + if (sa["n_knob_bites"] != N_DRAWERS or sa["knob_bite"][0] < KNOB_BITE_MIN + or sa["knob_bite"][1] > KNOB_BITE_MAX): + return (fail(f"{sa['n_knob_bites']} of {N_DRAWERS} knobs, bite {sa['knob_bite']} outside " + f"[{KNOB_BITE_MIN}, {KNOB_BITE_MAX}] (--pull-knobs is the designed fail)", + 17),) + nothing + if sa["n_seated"] != n_vessels or sa["seat"][0] < SEAT_MIN or sa["seat"][1] > SEAT_MAX: + return (fail(f"{sa['n_seated']} of {n_vessels} vessels on a shelf, seat {sa['seat']} " + f"outside [{SEAT_MIN}, {SEAT_MAX}] (--float-jars is the designed fail)", + 18),) + nothing + if (sa["drawer"] != N_DRAWERS or sa["drawer_seat"][0] < DRAWER_SEAT_MIN + or sa["drawer_seat"][1] > DRAWER_SEAT_MAX): + return (fail(f"{sa['drawer']} of {N_DRAWERS} drawers, seat {sa['drawer_seat']} outside " + f"[{DRAWER_SEAT_MIN}, {DRAWER_SEAT_MAX}] (--lift-drawers is the designed fail)", + 18),) + nothing + if (sa["label_bite"][0] < LABEL_BITE_MIN or sa["label_bite"][1] > LABEL_BITE_MAX + or sa["label_proud"] < LABEL_PROUD_MIN): + return (fail(f"label bite {sa['label_bite']} outside [{LABEL_BITE_MIN}, {LABEL_BITE_MAX}] " + f"or proud {sa['label_proud']:.5f} < {LABEL_PROUD_MIN} " + "(--float-labels is the designed fail)", 18),) + nothing + if sa["headroom"] < HEADROOM_MIN: + return (fail(f"headroom {sa['headroom']:.5f} < {HEADROOM_MIN} " + "(--tall-flask is the designed fail)", 20),) + nothing + if sa["vessel_hits"]: + return (fail(f"{sa['vessel_hits']} vessel overlaps, need 0 " + "(--crowd-jars is the designed fail)", 21),) + nothing + if sa["diam_spread"] < DIAM_SPREAD_MIN or sa["height_spread"] < HEIGHT_SPREAD_MIN: + return (fail(f"vessel spread diameter {sa['diam_spread']:.3f} (min {DIAM_SPREAD_MIN}), " + f"height {sa['height_spread']:.3f} (min {HEIGHT_SPREAD_MIN}) " + "(--uniform-vessels is the designed fail)", 22),) + nothing + if right: + return (fail(f"{right} right-angle wood edges, need 0 " + "(--sharp-rail is the designed fail)", 23),) + 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[WOOD_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(-14.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.4, 3.2), 420.0, 3.0, (1.0, 0.95, 0.88), (50, 0, -35)) + light("Fill", "AREA", (3.2, -2.8, 1.6), 70.0, 5.0, (0.74, 0.84, 1.0), (68, 0, 48)) + light("Rim", "AREA", (-1.8, 2.6, 2.6), 220.0, 3.0, (0.62, 0.78, 1.0), (-55, 0, 200)) + ld = bpy.data.lights.new("Wedge", "SPOT") + ld.energy, ld.color = 260.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.6, 1.8, 2.2) + wedge.rotation_euler = (Vector((0.2, 0.6, 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.05, -3.05, 1.05) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.54) + 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("--float-side", action="store_true") + p.add_argument("--short-shelves", action="store_true") + p.add_argument("--pop-corks", action="store_true") + p.add_argument("--pull-knobs", action="store_true") + p.add_argument("--float-jars", action="store_true") + p.add_argument("--lift-drawers", action="store_true") + p.add_argument("--float-labels", action="store_true") + p.add_argument("--tall-flask", action="store_true") + p.add_argument("--crowd-jars", action="store_true") + p.add_argument("--uniform-vessels", action="store_true") + p.add_argument("--sharp-rail", 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, + float_side=args.float_side, + short_shelves=args.short_shelves, + pop_corks=args.pop_corks, + pull_knobs=args.pull_knobs, + float_jars=args.float_jars, + lift_drawers=args.lift_drawers, + float_labels=args.float_labels, + tall_flask=args.tall_flask, + crowd_jars=args.crowd_jars, + uniform_vessels=args.uniform_vessels, + sharp_rail=args.sharp_rail, + ) + 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("apothecary shelf 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/apothecary-shelf/preview.webp b/showcase/apothecary-shelf/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..ba5bcdde9b70e189b8e818b9429e3b81b4ec899b GIT binary patch literal 34212 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 QXH#D6vRplsfq#Gi0KMHA!2kdN literal 0 HcmV?d00001 diff --git a/showcase/gallery.json b/showcase/gallery.json index 676ce283..cb08cdac 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -413,6 +413,19 @@ "mesh", "export" ] + }, + { + "name": "apothecary-shelf", + "dir": "showcase/apothecary-shelf", + "teaches": "A procedural apothecary shelf — housed shelves, gallery rails, a drawer bank and an arched crest, stocked with fifteen lathed bottles, flasks and glazed jars, each corked or lidded and labelled — carried through UVs, bake, LOD, convex collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "alt": "A stained wooden shelf with an arched crest, gallery rails and three brass-knobbed drawers, stocked with labelled glass bottles and flasks and cream and blue glazed jars.", + "witnessesFix": "Recomputed: 10456 tris, six materials with a face floor each, UVs in 0..1 with zero AABB overlap, outer AABB 0.876×0.274×1.076 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, both sides grounded, seven cross members housed 8 mm into each side and 14 mm clear of its far face, fifteen vessels seated 1.0–1.8 mm into their shelves, every cork and lid biting 5 mm, every label 0.3 mm into its body and 0.8 mm proud, no vessel touching another or the carcass, and at least 24 mm of headroom between every vessel and the member above it. --tall-flask exits 20 at −5.5 mm while every seat and the envelope still pass; --short-shelves exits 17 on the dado bite.", + "hero": "docs/gallery/assets/apothecary-shelf-hero.webp", + "preview": "showcase/apothecary-shelf/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index 7b6af26e..7ea77134 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -104,5 +104,6 @@ {"name": "stone-archway", "script": "showcase/stone-archway/stone_archway.py"}, {"name": "rope-bridge", "script": "showcase/rope-bridge/rope_bridge.py"}, {"name": "grain-sacks", "script": "showcase/grain-sacks/grain_sacks.py"}, - {"name": "brazier", "script": "showcase/brazier/brazier.py"} + {"name": "brazier", "script": "showcase/brazier/brazier.py"}, + {"name": "apothecary-shelf", "script": "showcase/apothecary-shelf/apothecary_shelf.py"} ]