Examples and Showcase
GitHub

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.

category Village

tags mesh export showcase

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 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#

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.

Findings the budgets forced#

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 1639 lines · 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.