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 00000000..f6a82ff1 Binary files /dev/null and b/docs/gallery/asset-sheets/apothecary-shelf.webp differ diff --git a/docs/gallery/assets/apothecary-shelf-hero.webp b/docs/gallery/assets/apothecary-shelf-hero.webp new file mode 100644 index 00000000..d02c7f22 Binary files /dev/null and b/docs/gallery/assets/apothecary-shelf-hero.webp differ 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 00000000..9f7d7c5f Binary files /dev/null and b/docs/gallery/contact-sheets/apothecary-shelf-contact-sheet.webp differ 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
@@ -1243,6 +1243,17 @@

brazier

View showcase piece brazier
+
+ +
+

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 00000000..ba5bcdde Binary files /dev/null and b/showcase/apothecary-shelf/preview.webp differ 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"} ]