Examples and Showcase
GitHub

book-trolley

A procedural library book truck — arched end panels standing in skids on four swivel casters, a flat deck, and on each side two sloped troughs, a tilted shelf and a backrest square to it, filled with forty-seven rounded-spine hardbacks spines out and two loose stacks — 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: 17704 tris, seven materials with 568 wood, 2788 cloth, 1476 leather, 312 paper, 2704 gilt, 932 iron and 656 rubber faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.768×0.530×1.000 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, four wheels on the floor, ten cross members 8 mm into both end panels, four caster plates 1.0 mm into their skids, carcass and casters mirror-exact in X and Y, 52 books each with one page block 0.6 mm into its boards and two gilt bands 0.3 mm into the spine and 0.8 mm proud, every book seated 1.0–3.2 mm, four troughs each with one backrest and all 47 trough books 0.8 mm into it, thickness and height spread 2.75 and 1.64, zero unplanned book overlaps, zero right-angle wood edges, convex collider 308 tris. --off-back and --deep-back exit 21 on the backrest contact while every seat, the envelope and the overlap budget still pass; --crowd-books exits 22; --skew-caster 19; --drop-plates 18.

category Village

tags mesh export showcase

blender --background --python showcase/book-trolley/book_trolley.py --

A library book truck: two arched end panels standing in skids on four swivel casters, a flat bottom deck, and on each side two sloped troughs under a turned push rail. Each trough is a shelf tilted 14° back toward the centre and a backrest square to it, both housed in the end panels. Forty-seven hardbacks stand in the troughs spines out, leaning back into the corner; five more lie in two loose stacks on the deck. Every book is a rounded-spine cover in cloth or leather, a page block glued into it, and two gilt bands tooled round the spine. 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 in trough frames, n-gon-capped prisms (arched end panels, caster forks, wheels), U-section covers, all in bmesh
skills/custom-propertiesface attributes (PlankTone, GrainDir, Tint) read by the shaders
skills/procedural-materials-and-shadersgrained stain, cloth, leather, ruled page edges, gilt, caster iron, rubber
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 casters
snippets/lod_chain.pyLOD naming and ratio pattern
examples/mesh-hygiene-audithygiene combinatorics (copied, not imported)
showcase/bookshelfthe book builder, the seat, band and overlap audits (copied, not imported)

The budget that matters#

A book on a flat shelf stands on one support. A book in a sloped trough is carried at two: its tail on the shelf and its fore-edge against the backrest. Seated on the shelf and standing off the backrest, it would topple forward on a real trolley, and nothing else sees it:

The piece pairs each tilted shelf with the one backrest whose shell overlaps it, finds each standing book's shelf by the top plane its lowest vertex sits nearest (read off the mesh, on the book's own side of the trolley), and takes the plane of that backrest's face that looks at the book. It asserts how far the book's deepest cover vertex sits behind that plane, as a band of 0.3–2.0 mm, next to the shelf seat's own band. All forty-seven sit at 0.80 mm.

A plane, not the backrest solid, for the same reason as bookshelf's lean contact: a fore-edge driven through the 18 mm board would come out behind it and read as no contact.

--off-back and --deep-back are the falsifiers built for exactly this. One stands every trough book 3 mm off its backrest, the other drives it 4.5 mm in, both along the slope so the shelf seat is untouched. Both exit 21 while every seat, the envelope and the book-overlap budget still pass.

Budgets#

Declared in the script as named constants, recomputed from the generated mesh. Measured values are from Blender 5.2.1; see the cross-version table below.

BudgetBandMeasured
Base triangles16800–1860017704
LOD1 ratio0.32–0.620.5000
LOD2 ratio0.10–0.350.2200
Material slotsexactly 7, distinct7
Wood / cloth / leather faces≥ 510 / 2500 / 1320568 / 2788 / 1476
Paper / gilt faces≥ 280 / 2430312 / 2704
Iron / rubber faces≥ 840 / 590932 / 656
UV boundsinside 0..1(0.0003, 0.0003)–(0.9997, 0.9997)
UV AABB overlap≤ 1e-50.000000
Outer AABB0.768 × 0.530 × 1.000 m ± 0.0200.7680 × 0.5300 × 1.0000
Collider triangles≤ 360308
Normal bake{'FINISHED'} with image data{'FINISHED'}, has_data=True
glTF exportfile written, non-empty~1.30 MB
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 wheels4 wheels, each zmin ≤ 1e-44 at 0.00000
Dado2 end panels; 10 cross members (deck, 4 shelves, 4 backrests, rail), each ≥ 5 mm into both panels and ≥ 6 mm clear of the far face8.00 mm in, 10.00 mm clear
Page block52 books, one block and two bands each; every block corner 0.3–1.2 mm inside its boards0.60 mm
Book seat52 books, each 0.5–3.6 mm into its shelf (along the shelf normal), the deck or the book below1.00–3.16 mm
Band seatinner face 0.1–0.8 mm inside the spine, outer face ≥ 0.4 mm proud0.29 mm in, 0.80 mm proud
Caster plates4 plates, each top 0.5–2.0 mm into the skid above1.00 mm
Mirror symmetrycarcass and casters, every vertex's X and Y mirror image on the mesh within 1e-4 m0.0000000
Size spreadthickest / thinnest ≥ 2.5; tallest / shortest ≥ 1.52.750 / 1.640
Backrest contact47 trough and 5 lying books; 4 troughs each with one backrest; every trough book 0.3–2.0 mm into its backrest0.80 mm
Book overlaps0, book against book and against the carcass, designed contacts exempt0
Edge treatmentright-angle wood edges0

Real-world size: 0.77 m long over the skids, 0.53 m deep and 1.00 m tall over the arched ends, on 70 mm casters. Each trough is 170 mm deep and its backrest 170 mm tall; the books run from a 150 mm pocket book to a 245 mm octavo.

Construction#

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; stepped seats, the yaw cycle and the shelf tail keep it at 0
Named supportsyesthe four wheels (--float-caster); the plates, forks and skids stop above the floor
Joint-fit budgetsyesdado bite and far-face clearance per cross member (--short-shelves)
A member is tenoned into its seatyespanels in skids, backrests in shelves, rail blind in the panels, each caster part into the one above
A platform bears on somethingyesdeck and every shelf housed in both end panels
Carried parts bite their bearersyesbooks into shelves, the deck and the book below (--float-books); caster plates into the skids (--drop-plates)
A joint bites; touching is not joiningyespage blocks into their boards (--loose-pages); the backrest contact as a band
Bands on a curved host are built on the host's arcyesgilt bands on the spine's own chain (--float-bands)
A leaning prop rests on somethingyesevery trough book against its backrest, exit 21 (--off-back, --deep-back)
Mirrored assembliesyescarcass and casters mirror in X and Y, exit 19 (--skew-caster, 6 mm inside the 20 mm AABB tolerance)
Scattered parts do not interpenetrateyesexit 22 (--crowd-books), designed contacts exempt
Scatter varies in sizeyesthickness and height spread, exit 20 (--uniform-books)
Edge treatment: no right anglesyesexit 23 (--sharp-deck), wood only; iron chamfered 0.8 mm, rubber rounded
Aim an edge falsifier at one memberyesthe deck left square: 12 edges
Chamfer n-gon caps, then triangulateyesend panels, forks, races, axles, wheels and the rail are n-gon-capped prisms, bevelled then triangulated
Sort bmesh operator inputsyesbevel edges sorted by index, carcass, iron, rubber and covers
Identical boards read as CGyesper-board PlankTone and GrainDir; per-book Tint
Variation goes into surface, never into functionyessizes, tints, gaps and yaw vary; tier pitch, slope and seats are budgets
Shading is part of the modelyesspines, rail and wheels smooth (round); every edge over 35° hard, so boards and chamfers read crisp
One substance, one slotyeswood, cloth, leather, paper, gilt, iron, rubber
Iron is not chromeyesnear-black, roughness 0.52–0.78
Bake texels per UV cellmeasured, not a budget1024 px over a 98-cell grid, about 10 px per cell
The bake cage is narrower than the nearest neighbouryesCAGE_EXTRUSION 3 mm, and the bake source leaves the books out, since they sit inside any cage of the deck and shelves
Level on the stage; stage 60 myesturned about Z only; 60 m floor and wall
Keep a falsifier's envelope stillyesevery falsifier except --lift-z keeps the AABB within tolerance
Plumb, rope, masonry, roofs, rings, terrain, vesselsnothe piece has none of these

Falsifiers#

Each breaks one pipeline stage so a named budget fails. All fifteen 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 deck15
--lift-zgrounded zminlifts the whole mesh 50 mm16
--float-casternamed wheelslifts one caster 5 mm; the other three still ground the AABB16
--short-shelvesdado bitestops the four shelves 4 mm short of the ends; −4.0 mm17
--loose-pagespage-block bitebuilds every page block 0.9 mm clear of its boards; −0.9 mm17
--float-booksbook seatlifts every book 6 mm off its shelf or the deck; −5.0 mm18
--float-bandsband seatbuilds every band 1.1 mm off its spine; −1.1 mm18
--drop-platescaster plate seatdrops every caster plate 2.5 mm, clear of its skid; −1.5 mm18
--skew-castermirror symmetrymoves one caster 6 mm along the trolley; 6.0 mm19
--uniform-bookssize spreadone height and depth, thickness within ±1.5 %; 1.030 / 1.00020
--off-backbackrest contactstands every trough book 3 mm off its backrest; −3.0 mm21
--deep-backbackrest contactdrives every trough book 4.5 mm into its backrest; 4.5 mm21
--crowd-booksbook overlapscloses the front upper trough's gaps to −6 mm; 13 overlaps22
--sharp-deckedge treatmentleaves the deck 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.

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 wheel floating (--lift-z, --float-caster)
17Dado or page-block bite, or a book without one block and two bands (--short-shelves, --loose-pages)
18Book, band or caster-plate seat (--float-books, --float-bands, --drop-plates)
19Carcass and casters off mirror symmetry (--skew-caster)
20Size spread below floor (--uniform-books)
21Backrest contact, trough / lying count, or a trough without one backrest (--off-back, --deep-back)
22Books overlap each other or the carcass (--crowd-books)
23Right-angle wood edges (--sharp-deck)

Run it#

# Budget check, no render.
blender --background --python book_trolley.py --

# Falsifier: every trough book stands off its backrest. Must exit 21.
blender --background --python book_trolley.py -- --off-back

# Falsifier: every trough book cuts into its backrest. Must exit 21.
blender --background --python book_trolley.py -- --deep-back

# Render the gallery still (EEVEE; --engine cycles on a GPU-less host).
blender --background --python book_trolley.py -- --output book-trolley.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 triangles177041770417704
LOD1 / LOD2 tris8852 / 38948852 / 38948852 / 3894
Face counts (wood / cloth / leather / paper / gilt / iron / rubber)568 / 2788 / 1476 / 312 / 2704 / 932 / 656samesame
Outer AABB0.7680 × 0.5300 × 1.0000samesame
Collider tris308308308
Seat / backrest contact / mirror1.00–3.16 mm / 0.80 mm / 0.0samesame
glTF bytes130331613033161303308

The glTF size differs by 8 bytes on 5.2 (exporter metadata, not a budget). Every other value, including the DECIMATE COLLAPSE LOD counts, is identical on all three.

Source

showcase/book-trolley/book_trolley.py 1820 lines · View on GitHub →
"""Game-ready library book trolley — a showcase piece, not an example.

Asserts budget conformance of a procedural double-sided library book
truck: two arched end panels standing in skids on four swivel casters, a
flat bottom deck, and on each side two sloped troughs — a shelf tilted
back and a backrest square to it, both housed in the end panels — under a
turned push rail. The troughs are filled with rounded-spine hardbacks,
spines out; loose books lie in two stacks on the deck. Every book is a
cloth or leather cover, a page block glued into it and two gilt bands
round the spine. Carried through UVs, seven materials (wood, cloth,
leather, paper, gilt, iron, rubber), a high-to-low normal bake, an LOD
chain, a convex collider, and a Unity glTF export.

The budget that matters is the one a sloped shelf adds: a book in a trough
is carried at two places, its tail on the shelf and its fore-edge against
the backrest. A book seated on the shelf and standing off the backrest
passes every seat, the bounding box, the triangle count and the overlap
budget (which must exempt both designed contacts) — and on a real trolley
it would topple forward. The piece reads each backrest's face plane off
the finished mesh and asserts how deep every trough book's fore-edge sits
in it, as a band, beside the shelf seat.

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-caster`` the named wheels,
``--short-shelves`` the dado bite, ``--loose-pages`` the page-block bite,
``--float-books`` the book seat, ``--float-bands`` the band seat,
``--drop-plates`` the caster plate seat, ``--skew-caster`` mirror
symmetry, ``--uniform-books`` the size spread, ``--off-back`` and
``--deep-back`` the backrest contact, ``--crowd-books`` book
interpenetration, ``--sharp-deck`` edge treatment.

No randomness: every book's size, tint, gap, yaw and seat is a table
entry or 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 book_trolley.py --
    blender --background --python book_trolley.py -- --off-back
    blender --background --python book_trolley.py -- --output book-trolley.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 along the trolley, Y front (-) to back (+), Z up. Two end
# panels carry everything; every cross member is housed into both of them.
L_OUT = 0.720
END_T = 0.022
END_HALF = 0.250
END_Z0 = 0.115
END_SHOULDER = 0.930
END_TOP = 1.000
END_ARC = 16
SKID_HALF = 0.265
SKID_W = 0.070
SKID_Z0 = 0.092
SKID_Z1 = 0.127
DADO = 0.008
DECK_Z = 0.175
DECK_T = 0.020
DECK_HALF = 0.235
CHAMFER = 0.0025

# The troughs. Each is a shelf tilted TILT back toward the centre and a
# backrest square to it, meeting at a corner CORNER_Y off the centre line.
# In a trough's own frame s runs downhill into the backrest, t up the
# shelf normal; the corner is s = t = 0, the backrest's face s = 0 and the
# shelf's top t = 0.
TILT = math.radians(14.0)
CORNER_Y = 0.068
TIER_Z = (0.360, 0.680)
SHELF_T = 0.018
SHELF_D = 0.170
SHELF_TAIL = 0.006
BACK_T = 0.018
BACK_H = 0.170
BACK_SEAT = 0.006
RAIL_R = 0.016
RAIL_Z = 0.962
RAIL_BITE = 0.012
RAIL_SEG = 16

# Swivel casters, one under each end of each skid, rolling along X.
CASTER_Y = 0.225
WHEEL_R = 0.035
WHEEL_W = 0.024
WHEEL_SEG = 24
WHEEL_ROUND = 0.004
AXLE_R = 0.005
AXLE_HALF = 0.022
FORK_WO = 0.019
FORK_WI = 0.015
FORK_ZB = 0.024
FORK_ZI = 0.074
FORK_ZT = 0.082
FORK_D = 0.030
RACE_R = 0.021
RACE_Z0 = 0.081
RACE_Z1 = 0.090
RACE_SEG = 16
PLATE = 0.056
PLATE_T = 0.004
PLATE_BITE = 0.001
DROP_PLATES = 0.0025
IRON_CHAMFER = 0.0008
FLOAT_CASTER = 0.005
SKEW_CASTER = 0.006

# Books. Local frame: X across the thickness, Y from the spine (-) to the
# fore-edge (+), Z up the height, bottom of the cover at 0 (copied from
# showcase/bookshelf).
COVER_B = 0.0028
SQUARE = 0.004
PAGE_BACK = 0.0015
PAGE_BITE = 0.0006
LOOSE_PAGES = 0.0009
SPINE_ARC = 6
COVER_CHAMFER = 0.0008
BAND_OFF = 0.018
BAND_H = 0.006
BAND_BITE = 0.0003
BAND_PROUD = 0.0008
FLOAT_BAND = 0.0011
SEAT_BASE = 0.0010
SEAT_STEP = 0.00018
FLOAT_BOOKS = 0.006
START_GAP = 0.006
GAP_BASE = 0.0025
GAP_SPAN = 0.0020
CROWD_GAP = -0.0060
BACK_BITE = 0.0008
OFF_BACK = -0.0030
DEEP_BACK = 0.0045
FIT_CLEAR = 0.004
STACK_YAW = math.radians(3.0)
STACK_SHIFT = 0.006
STACK_Y = -0.080
STACK_STEP_Y = 0.004
UNIFORM_BOOK = (0.034, 0.215, 0.158)
UNIFORM_JIT = 0.03
UPRIGHT_YAW = (-2.4, -0.8, 0.8, 2.4)

# Per trough, left to right: (kind, T, H, D) or ("gap", width). Troughs
# are (side, tier): side 0 front, 1 back; tier 0 lower, 1 upper.
TROUGHS = (
    ((0, 1), (
        ("c", 0.032, 0.228, 0.165), ("l", 0.045, 0.242, 0.178), ("c", 0.026, 0.205, 0.150),
        ("c", 0.038, 0.236, 0.172), ("l", 0.052, 0.245, 0.184), ("c", 0.029, 0.198, 0.146),
        ("c", 0.041, 0.221, 0.163), ("l", 0.035, 0.232, 0.170), ("c", 0.048, 0.240, 0.176),
        ("c", 0.024, 0.186, 0.137), ("l", 0.037, 0.214, 0.158), ("c", 0.043, 0.226, 0.166),
        ("c", 0.030, 0.203, 0.149),
    )),
    ((0, 0), (
        ("l", 0.050, 0.244, 0.186), ("c", 0.034, 0.225, 0.166), ("c", 0.027, 0.210, 0.154),
        ("l", 0.044, 0.238, 0.176), ("c", 0.036, 0.218, 0.160), ("c", 0.020, 0.192, 0.141),
        ("l", 0.055, 0.243, 0.188), ("gap", 0.030), ("c", 0.031, 0.207, 0.152),
        ("c", 0.040, 0.231, 0.170), ("l", 0.028, 0.199, 0.146), ("c", 0.046, 0.236, 0.174),
        ("c", 0.033, 0.212, 0.156), ("l", 0.039, 0.228, 0.168),
    )),
    ((1, 1), (
        ("c", 0.036, 0.230, 0.168), ("l", 0.042, 0.240, 0.176), ("c", 0.028, 0.206, 0.151),
        ("c", 0.047, 0.235, 0.172), ("l", 0.031, 0.215, 0.158), ("c", 0.039, 0.224, 0.165),
        ("gap", 0.060), ("c", 0.025, 0.195, 0.143), ("l", 0.049, 0.244, 0.182),
        ("c", 0.034, 0.219, 0.161), ("c", 0.040, 0.227, 0.167),
    )),
    ((1, 0), (
        ("l", 0.046, 0.241, 0.180), ("c", 0.030, 0.214, 0.157), ("c", 0.037, 0.226, 0.166),
        ("l", 0.026, 0.201, 0.148), ("c", 0.051, 0.239, 0.178), ("c", 0.033, 0.217, 0.159),
        ("l", 0.040, 0.231, 0.170), ("c", 0.023, 0.188, 0.139), ("c", 0.044, 0.233, 0.172),
        ("gap", 0.040), ("l", 0.035, 0.222, 0.163), ("c", 0.029, 0.209, 0.153),
    )),
)
CROWD_TROUGH = (0, 1)
# Loose books on the deck: (x centre, [(kind, T, H, D), ...] bottom first).
STACKS = (
    (-0.160, (("l", 0.042, 0.232, 0.172), ("c", 0.034, 0.214, 0.158),
              ("c", 0.026, 0.180, 0.132))),
    (0.160, (("c", 0.048, 0.238, 0.176), ("l", 0.030, 0.150, 0.112))),
)
CLOTH_TINTS = ((0.22, 0.035, 0.030), (0.045, 0.095, 0.055), (0.035, 0.050, 0.115),
               (0.33, 0.21, 0.075), (0.10, 0.105, 0.11), (0.12, 0.045, 0.085),
               (0.30, 0.21, 0.13), (0.045, 0.095, 0.095))
LEATHER_TINTS = ((0.17, 0.070, 0.030), (0.26, 0.12, 0.050), (0.085, 0.040, 0.020),
                 (0.21, 0.055, 0.030))

BBOX_TOL = 0.020
OUTER_SIZE = (0.768, 0.530, 1.000)

BASE_TRIS_MIN = 16800
BASE_TRIS_MAX = 18600
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 = 7
FACE_FLOORS = {0: 510, 1: 2500, 2: 1320, 3: 280, 4: 2430, 5: 840, 6: 590}
UV_EPS = 1e-4
UV_OVERLAP_MAX = 1e-5
COLLIDER_TRIS_MAX = 360
BAKE_RES = 1024
CAGE_EXTRUSION = 0.003
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
WHEEL_Z_MAX = 1e-4
DADO_MIN = 0.005
DADO_FAR_MIN = 0.006
PAGE_BITE_MIN = 0.0003
PAGE_BITE_MAX = 0.0012
SEAT_MIN = 0.0005
SEAT_MAX = 0.0036
BAND_BITE_MIN = 0.0001
BAND_BITE_MAX = 0.0008
BAND_PROUD_MIN = 0.0004
PLATE_SEAT_MIN = 0.0005
PLATE_SEAT_MAX = 0.0020
MIRROR_EPS = 1e-4
THICK_SPREAD_MIN = 2.5
HEIGHT_SPREAD_MIN = 1.5
UPRIGHT_TILT_MAX = math.radians(3.0)
LYING_TILT_MIN = math.radians(80.0)
BACK_MIN = 0.0003
BACK_MAX = 0.0020
SUPPORT_REACH = 0.02
RIGHT_ANGLE_TOL = math.radians(5.0)

WOOD_IDX = 0
CLOTH_IDX = 1
LEATHER_IDX = 2
PAPER_IDX = 3
GILT_IDX = 4
IRON_IDX = 5
RUBBER_IDX = 6
BOOK_MATS = (CLOTH_IDX, LEATHER_IDX, PAPER_IDX, GILT_IDX)
N_TROUGH = len(TROUGHS)
N_CROSS = 1 + 2 * N_TROUGH + 1
N_CASTERS = 4
N_UP = sum(1 for _k, row in TROUGHS for it in row if it[0] != "gap")
N_LYING = sum(len(books) for _x, books in STACKS)
N_BOOKS = N_UP + N_LYING


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)


# --- one book, in its own frame (copied from showcase/bookshelf) -------------


def spine_chain(t, d):
    """Centre line of the cover's U and its outward normal at each point."""
    rc = t / 2.0 - COVER_B / 2.0
    yc = -d / 2.0 + t / 2.0
    pts = [(Vector((-rc, d / 2.0)), Vector((-1.0, 0.0)))]
    for k in range(SPINE_ARC + 1):
        phi = math.pi + math.pi * k / SPINE_ARC
        n = Vector((math.cos(phi), math.sin(phi)))
        pts.append((Vector((0.0, yc)) + n * rc, n))
    pts.append((Vector((rc, d / 2.0)), Vector((1.0, 0.0))))
    return pts, yc


def book_local(t, h, d, loose_pages=False, float_bands=False):
    """A hardback in its own frame: cover, page block and two gilt bands.

    Returns (verts, faces) with each face as (vertex indices, kind), kind
    0 cover, 1 pages, 2 band.
    """
    chain, yc = spine_chain(t, d)
    bm = bmesh.new()
    try:
        kind = bm.faces.layers.int.new("kind")
        outer = [p + n * (COVER_B / 2.0) for p, n in chain]
        inner = [p - n * (COVER_B / 2.0) for p, n in chain]

        def ring(pts2, z):
            return [bm.verts.new((p.x, p.y, z)) for p in pts2]

        ob, ot = ring(outer, 0.0), ring(outer, h)
        ib, it = ring(inner, 0.0), ring(inner, h)
        m = len(chain)
        for i in range(m - 1):
            j = i + 1
            bm.faces.new((ob[i], ob[j], ot[j], ot[i]))
            bm.faces.new((ib[j], ib[i], it[i], it[j]))
            bm.faces.new((ot[i], ot[j], it[j], it[i]))
            bm.faces.new((ob[j], ob[i], ib[i], ib[j]))
        bm.faces.new((ob[0], ot[0], it[0], ib[0]))
        e = m - 1
        bm.faces.new((ib[e], it[e], ot[e], ob[e]))
        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
        bm.edges.index_update()
        edges = sorted((ed for ed in bm.edges if ed.calc_face_angle(0.0) > math.radians(40.0)),
                       key=lambda ed: ed.index)
        bmesh.ops.bevel(bm, geom=edges, offset=COVER_CHAMFER, segments=1, profile=0.5,
                        affect="EDGES", clamp_overlap=True)
        bmesh.ops.triangulate(bm, faces=[f for f in bm.faces if len(f.verts) > 4])

        px = t / 2.0 - COVER_B + (-LOOSE_PAGES if loose_pages else PAGE_BITE)
        lo = Vector((-px, yc + PAGE_BACK, SQUARE))
        hi = Vector((px, d / 2.0 - SQUARE, h - SQUARE))
        corner = {}
        for a in (0, 1):
            for b in (0, 1):
                for c in (0, 1):
                    corner[(a, b, c)] = bm.verts.new((hi.x if a else lo.x, hi.y if b else lo.y,
                                                      hi.z if c else lo.z))
        box = (((0, 0, 0), (0, 1, 0), (1, 1, 0), (1, 0, 0)),
               ((0, 0, 1), (1, 0, 1), (1, 1, 1), (0, 1, 1)),
               ((0, 0, 0), (1, 0, 0), (1, 0, 1), (0, 0, 1)),
               ((0, 1, 0), (0, 1, 1), (1, 1, 1), (1, 1, 0)),
               ((0, 0, 0), (0, 0, 1), (0, 1, 1), (0, 1, 0)),
               ((1, 0, 0), (1, 1, 0), (1, 1, 1), (1, 0, 1)))
        for quad in box:
            f = bm.faces.new([corner[q] for q in quad])
            f[kind] = 1

        span = range(1, SPINE_ARC + 2)
        inner_off = FLOAT_BAND if float_bands else -BAND_BITE
        outer_off = inner_off + BAND_BITE + BAND_PROUD
        for z0 in (BAND_OFF, h - BAND_OFF - BAND_H):
            g = {}
            for j in span:
                p, n = chain[j]
                for side, off in (("in", inner_off), ("out", outer_off)):
                    q = p + n * (COVER_B / 2.0 + off)
                    for zz, z in (("b", z0), ("t", z0 + BAND_H)):
                        g[(j, side, zz)] = bm.verts.new((q.x, q.y, z))
            quads = []
            for j in list(span)[:-1]:
                k = j + 1
                quads.append((g[(j, "out", "b")], g[(k, "out", "b")], g[(k, "out", "t")],
                              g[(j, "out", "t")]))
                quads.append((g[(k, "in", "b")], g[(j, "in", "b")], g[(j, "in", "t")],
                              g[(k, "in", "t")]))
                quads.append((g[(j, "out", "t")], g[(k, "out", "t")], g[(k, "in", "t")],
                              g[(j, "in", "t")]))
                quads.append((g[(k, "out", "b")], g[(j, "out", "b")], g[(j, "in", "b")],
                              g[(k, "in", "b")]))
            a, z = span[0], span[-1]
            quads.append((g[(a, "in", "b")], g[(a, "out", "b")], g[(a, "out", "t")],
                          g[(a, "in", "t")]))
            quads.append((g[(z, "out", "b")], g[(z, "in", "b")], g[(z, "in", "t")],
                          g[(z, "out", "t")]))
            for vs in quads:
                f = bm.faces.new(vs)
                f[kind] = 2
        bm.verts.index_update()
        verts = [v.co.copy() for v in bm.verts]
        faces = [(tuple(v.index for v in f.verts), f[kind]) for f in bm.faces]
    finally:
        bm.free()
    return verts, faces


# --- the layout -------------------------------------------------------------


def cover_points(book):
    return [book["world"][i] for i in book["cover_ix"]]


def bounds(pts):
    return (Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts))),
            Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts))))


def move(book, delta):
    book["M"] = Matrix.Translation(delta) @ book["M"]
    book["world"] = [p + delta for p in book["world"]]


def make_book(dims, rot, loose_pages, float_bands):
    kind, t, h, d = dims
    verts, faces = book_local(t, h, d, loose_pages=loose_pages, float_bands=float_bands)
    cover_ix = sorted({i for vs, k in faces if k == 0 for i in vs})
    return {"t": t, "h": h, "d": d, "kind": kind, "verts": verts, "faces": faces,
            "cover_ix": cover_ix, "M": rot.copy(), "world": [rot @ v for v in verts]}


def trough_frame(side, tier):
    """Corner, downhill direction (into the backrest) and shelf normal of a trough."""
    sy = -1.0 if side == 0 else 1.0
    c = Vector((0.0, sy * CORNER_Y, TIER_Z[tier]))
    u = Vector((0.0, -sy * math.cos(TILT), -math.sin(TILT)))
    n = Vector((0.0, -sy * math.sin(TILT), math.cos(TILT)))
    return c, u, n


def plan_books(loose_pages=False, float_books=False, float_bands=False, uniform_books=False,
               crowd_books=False, off_back=False, deep_back=False):
    """Place every book. Closed-form: sizes from the tables, the rest from the index.

    A trough book is built in its own frame, turned into the trough (its
    height up the shelf normal, its fore-edge downhill) and yawed a degree
    or so about the shelf normal. It is then slid up the normal until its
    lowest cover vertex sits a stepped seat into the shelf, down the slope
    until its deepest vertex sits the back bite into the backrest, and
    along the trough to the running cursor.
    """
    x_in = L_OUT / 2.0 - END_T
    bite = BACK_BITE
    if off_back:
        bite = OFF_BACK
    if deep_back:
        bite = DEEP_BACK
    lift = FLOAT_BOOKS if float_books else 0.0
    books = []

    def sized(dims, n):
        kind, t, h, d = dims
        if uniform_books:
            ft = 1.0 + UNIFORM_JIT * (frac(n * 0.6180339887 + 0.1) - 0.5)
            t, h, d = UNIFORM_BOOK[0] * ft, UNIFORM_BOOK[1], UNIFORM_BOOK[2]
        return (kind, t, h, d)

    for (side, tier), row in TROUGHS:
        c, u, n = trough_frame(side, tier)
        xs = Vector((1.0, 0.0, 0.0)) if side == 0 else Vector((-1.0, 0.0, 0.0))
        basis = Matrix((xs, u, n)).transposed().to_4x4()
        cursor = -x_in + START_GAP
        k = 0
        for item in row:
            if item[0] == "gap":
                cursor += item[1]
                continue
            yaw = math.radians(UPRIGHT_YAW[k % len(UPRIGHT_YAW)])
            rot = Matrix.Rotation(yaw, 4, n) @ basis
            b = make_book(sized(item, len(books)), rot, loose_pages, float_bands)
            if crowd_books and (side, tier) == CROWD_TROUGH:
                gap = CROWD_GAP
            else:
                gap = GAP_BASE + GAP_SPAN * frac(k * 0.6180339887 + 0.3 + (2 * side + tier) * 0.17)
            pts = cover_points(b)
            t_min = min((p - c).dot(n) for p in pts)
            s_max = max((p - c).dot(u) for p in pts)
            lo_x = min(p.x for p in pts)
            seat = SEAT_BASE + SEAT_STEP * k
            move(b, n * (lift - seat - t_min) + u * (bite - s_max)
                 + Vector((cursor + gap - lo_x, 0.0, 0.0)))
            hi_x = bounds(cover_points(b))[1].x
            if hi_x > x_in - FIT_CLEAR:
                raise ValueError(f"trough {(side, tier)} overflows: book {k} ends at {hi_x:.4f}")
            b.update({"trough": (side, tier), "i": len(books)})
            books.append(b)
            cursor = hi_x
            k += 1

    deck_top = DECK_Z + DECK_T
    for si, (xc, stack) in enumerate(STACKS):
        top = deck_top + lift
        for j, dims in enumerate(stack):
            yaw = STACK_YAW * (1.0 if j % 2 else -1.0)
            rot = Matrix.Rotation(yaw, 4, "Z") @ Matrix.Rotation(-math.pi / 2.0, 4, "Y")
            b = make_book(sized(dims, len(books)), rot, loose_pages, float_bands)
            lo, hi = bounds(cover_points(b))
            shift = STACK_SHIFT * (0.5 - frac(j * 0.7548776662 + 0.2 + si * 0.31)) * 2.0
            # A book on a book takes a small seat of its own (1.0-1.4 mm), so
            # its underside never lands on the plane of the page block 2.2 mm
            # inside the board below it.
            seat = SEAT_BASE + SEAT_STEP * (j if j else 3 + 2 * si)
            move(b, Vector((xc + shift - (lo.x + hi.x) / 2.0,
                            STACK_Y + STACK_STEP_Y * j - (lo.y + hi.y) / 2.0,
                            top - seat - lo.z)))
            top = bounds(cover_points(b))[1].z
            b.update({"trough": None, "i": len(books)})
            books.append(b)
    return books


# --- construction -----------------------------------------------------------

BOX_FACES = ((0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3))


def add_frame_box(bm, origin, axes, lo, hi, mat):
    """Box spanning ``lo``..``hi`` in the frame (origin, axes); returns its verts."""
    vs = []
    for a in (0, 1):
        for b in (0, 1):
            for c in (0, 1):
                p = (origin + axes[0] * (hi[0] if a else lo[0]) + axes[1] * (hi[1] if b else lo[1])
                     + axes[2] * (hi[2] if c else lo[2]))
                vs.append(bm.verts.new(p))
    faces = [bm.faces.new([vs[i] for i in q]) for q in BOX_FACES]
    for f in faces:
        f.material_index = mat
    bmesh.ops.recalc_face_normals(bm, faces=faces)
    return vs


def add_box(bm, lo, hi, mat):
    return add_frame_box(bm, Vector(), (Vector((1, 0, 0)), Vector((0, 1, 0)), Vector((0, 0, 1))),
                         lo, hi, mat)


def add_prism(bm, profile, w0, w1, to3d, mat):
    """Extrude a closed 2D profile between stations w0 and w1; n-gon caps."""
    lo = [bm.verts.new(to3d(a, b, w0)) for a, b in profile]
    hi = [bm.verts.new(to3d(a, b, w1)) for a, b in profile]
    faces = [bm.faces.new(list(reversed(lo))), bm.faces.new(hi)]
    m = len(profile)
    for i in range(m):
        j = (i + 1) % m
        faces.append(bm.faces.new((lo[i], lo[j], hi[j], hi[i])))
    for f in faces:
        f.material_index = mat
    bmesh.ops.recalc_face_normals(bm, faces=faces)
    return lo + hi


def circle(r, segs, phase=0.0):
    return [(r * math.cos(phase + 2.0 * math.pi * k / segs),
             r * math.sin(phase + 2.0 * math.pi * k / segs)) for k in range(segs)]


def end_profile():
    """End panel outline in (y, z): straight sides, an arched top."""
    a = END_TOP - END_SHOULDER
    r = (END_HALF ** 2 + a * a) / (2.0 * a)
    zc = END_TOP - r
    phi0 = math.atan2(END_SHOULDER - zc, END_HALF)
    pts = [(-END_HALF, END_Z0), (END_HALF, END_Z0)]
    for k in range(END_ARC + 1):
        phi = phi0 + (math.pi - 2.0 * phi0) * k / END_ARC
        pts.append((r * math.cos(phi), zc + r * math.sin(phi)))
    return pts


def add_caster(bm, cx, cy, dz, drop_plate, groups):
    """Plate, swivel race, fork, axle and wheel, stacked under a skid end."""
    iron, rubber = groups["iron"], groups["rubber"]
    pz0 = SKID_Z0 + PLATE_BITE - PLATE_T - (DROP_PLATES if drop_plate else 0.0)
    iron.extend(add_box(bm, (cx - PLATE / 2, cy - PLATE / 2, pz0 + dz),
                        (cx + PLATE / 2, cy + PLATE / 2, pz0 + PLATE_T + dz), IRON_IDX))
    iron.extend(add_prism(bm, circle(RACE_R, RACE_SEG), RACE_Z0 + dz, RACE_Z1 + dz,
                          lambda a, b, w: Vector((cx + a, cy + b, w)), IRON_IDX))
    fork = ((-FORK_WO, FORK_ZB), (-FORK_WI, FORK_ZB), (-FORK_WI, FORK_ZI), (FORK_WI, FORK_ZI),
            (FORK_WI, FORK_ZB), (FORK_WO, FORK_ZB), (FORK_WO, FORK_ZT), (-FORK_WO, FORK_ZT))
    iron.extend(add_prism(bm, fork, cx - FORK_D / 2, cx + FORK_D / 2,
                          lambda a, b, w: Vector((w, cy + a, b + dz)), IRON_IDX))
    iron.extend(add_prism(bm, circle(AXLE_R, 12), cy - AXLE_HALF, cy + AXLE_HALF,
                          lambda a, b, w: Vector((cx + a, w, WHEEL_R + b + dz)), IRON_IDX))
    rubber.extend(add_prism(bm, circle(WHEEL_R, WHEEL_SEG, -math.pi / 2.0),
                            cy - WHEEL_W / 2, cy + WHEEL_W / 2,
                            lambda a, b, w: Vector((cx + a, w, WHEEL_R + b + dz)), RUBBER_IDX))


def bevel_group(bm, verts, offset, segments, mat, min_angle):
    """Chamfer every edge of ``verts``' shells sharper than ``min_angle``."""
    # 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()
    live = [v for v in verts if v.is_valid]
    edges = sorted({e for v in live for e in v.link_edges
                    if e.calc_face_angle(0.0) > min_angle}, key=lambda e: e.index)
    bmesh.ops.bevel(bm, geom=edges, offset=offset, segments=segments, profile=0.5,
                    affect="EDGES", clamp_overlap=True, material=mat)


def build_trolley_mesh(
    name,
    stray_vert=False,
    float_caster=False,
    short_shelves=False,
    sharp_deck=False,
    drop_plates=False,
    skew_caster=False,
    with_books=True,
    **book_flags,
):
    x_out = L_OUT / 2.0
    x_in = x_out - END_T
    x_mid = x_out - END_T / 2.0
    X = Vector((1.0, 0.0, 0.0))
    bm = bmesh.new()
    try:
        uv = bm.loops.layers.uv.new("UVMap")
        piece = bm.faces.layers.int.new("Piece")
        wood, sharp = [], []
        groups = {"iron": [], "rubber": []}
        # End panels, each standing in its skid.
        prof = end_profile()
        for sx in (-1.0, 1.0):
            a, b = sorted((sx * x_in, sx * x_out))
            wood.extend(add_prism(bm, prof, a, b, lambda y, z, w: Vector((w, y, z)), WOOD_IDX))
            wood.extend(add_box(bm, (sx * x_mid - SKID_W / 2, -SKID_HALF, SKID_Z0),
                                (sx * x_mid + SKID_W / 2, SKID_HALF, SKID_Z1), WOOD_IDX))
        # Deck, housed in both ends.
        # --sharp-deck leaves it square: one member, centred, so the mirror
        # budget cannot see it and only the edge budget can.
        deck = add_box(bm, (-(x_in + DADO), -DECK_HALF, DECK_Z),
                       (x_in + DADO, DECK_HALF, DECK_Z + DECK_T), WOOD_IDX)
        (sharp if sharp_deck else wood).extend(deck)
        # Troughs: a tilted shelf and a backrest square to it, both housed.
        reach = x_in - 0.004 if short_shelves else x_in + DADO
        for (side, tier), _row in TROUGHS:
            c, u, n = trough_frame(side, tier)
            axes = (X, u, n)
            wood.extend(add_frame_box(bm, c, axes, (-reach, -SHELF_D, -SHELF_T),
                                      (reach, BACK_T + SHELF_TAIL, 0.0), WOOD_IDX))
            wood.extend(add_frame_box(bm, c, axes, (-(x_in + DADO), 0.0, -BACK_SEAT),
                                      (x_in + DADO, BACK_T, BACK_H), WOOD_IDX))
        # Push rail along the ridge, its ends blind in both panels.
        rail = circle(RAIL_R, RAIL_SEG)
        wood.extend(add_prism(bm, rail, -(x_in + RAIL_BITE), x_in + RAIL_BITE,
                              lambda y, z, w: Vector((w, y, RAIL_Z + z)), WOOD_IDX))
        # Casters.
        k = 0
        for sx in (-1.0, 1.0):
            for sy in (-1.0, 1.0):
                dz = FLOAT_CASTER if (float_caster and k == 0) else 0.0
                dx = SKEW_CASTER if (skew_caster and k == 3) else 0.0
                add_caster(bm, sx * x_mid + dx, sy * CASTER_Y, dz, drop_plates, groups)
                k += 1
        bevel_group(bm, wood, CHAMFER, 1, WOOD_IDX, math.radians(30.0))
        bevel_group(bm, groups["iron"], IRON_CHAMFER, 1, IRON_IDX, math.radians(30.0))
        bevel_group(bm, groups["rubber"], WHEEL_ROUND, 2, RUBBER_IDX, math.radians(30.0))
        bmesh.ops.triangulate(bm, faces=[f for f in bm.faces if len(f.verts) > 4])

        books = plan_books(**book_flags) if with_books else []
        for b in books:
            vs = [bm.verts.new(p) for p in b["world"]]
            cover = LEATHER_IDX if b["kind"] == "l" else CLOTH_IDX
            for idx, kind in b["faces"]:
                f = bm.faces.new([vs[i] for i in idx])
                f.material_index = (cover, PAPER_IDX, GILT_IDX)[kind]
                f[piece] = b["i"] + 1
        if stray_vert:
            bm.verts.new((0.0, 0.0, DECK_Z + DECK_T / 2.0))
        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, uv)
        me = bpy.data.meshes.new(name)
        bm.to_mesh(me)
        me.update()
    finally:
        bm.free()
    paint_pieces(me, books)
    obj = bpy.data.objects.new(name, me)
    bpy.context.collection.objects.link(obj)
    return obj


def pack_uvs(bm, uv, margin=0.06):
    """One grid cell per face, projected on its dominant axis."""
    faces = list(bm.faces)
    cols = max(1, math.ceil(math.sqrt(len(faces))))
    rows = max(1, math.ceil(len(faces) / cols))
    cw, ch = 1.0 / cols, 1.0 / rows
    pu, pv = margin * cw * 0.5, margin * ch * 0.5
    for idx, face in enumerate(faces):
        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))
        minx, maxx = min(c[0] for c in pts), max(c[0] for c in pts)
        miny, maxy = min(c[1] for c in pts), max(c[1] for c in pts)
        dx, dy = max(maxx - minx, 1e-8), max(maxy - miny, 1e-8)
        ou, ov = (idx % cols) * cw + pu, (idx // cols) * ch + pv
        for loop, (x, y) in zip(face.loops, pts):
            loop[uv].uv = (ou + (x - minx) / dx * (cw - 2 * pu),
                           ov + (y - miny) / dy * (ch - 2 * pv))


def paint_pieces(me, books):
    """Face attributes the shaders read: per-board tone and grain, per-book tint."""
    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))] not in (WOOD_IDX, IRON_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
        b = books[v]
        if mats[fi] == LEATHER_IDX:
            c = LEATHER_TINTS[int(frac(v * 0.6180339887 + 0.1) * len(LEATHER_TINTS))]
        else:
            c = CLOTH_TINTS[int(frac(v * 0.6180339887 + 0.4) * len(CLOTH_TINTS))]
        tint[fi] = (c[0], c[1], c[2], 1.0)
        tone[fi] = 0.5 + 0.3 * (frac(v * 0.4142135624) - 0.5)
        ax = b["M"].to_3x3() @ Vector((1.0, 0.0, 0.0))
        grain[fi] = tuple(ax.normalized())
    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 wood_material(name):
    """Stained oak: grain along each board, tone per board (copied from showcase/bookshelf)."""
    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.044, 0.019, 1.0)
    ramp.color_ramp.elements[1].position = 0.72
    ramp.color_ramp.elements[1].color = (0.25, 0.135, 0.060, 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.62
    rough.inputs["To Max"].default_value = 0.44
    nt.links.new(noise.outputs["Fac"], rough.inputs["Value"])
    nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"])
    return mat


def cover_material(name, scale, low, roughness):
    """A book cover: the per-book ``Tint``, broken up by a fine noise."""
    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 = scale
    noise.inputs["Detail"].default_value = 8.0
    nt.links.new(tc.outputs["Object"], noise.inputs["Vector"])
    shade = nt.nodes.new("ShaderNodeMapRange")
    shade.inputs["To Min"].default_value = low
    shade.inputs["To Max"].default_value = 1.1
    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"])
    rough = nt.nodes.new("ShaderNodeMapRange")
    rough.inputs["To Min"].default_value = roughness + 0.08
    rough.inputs["To Max"].default_value = roughness - 0.08
    nt.links.new(noise.outputs["Fac"], rough.inputs["Value"])
    nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"])
    return mat


def paper_material(name):
    """Page edges: warm off-white with fine lines parallel to the boards."""
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    nt = mat.node_tree
    bsdf = nt.nodes["Principled BSDF"]
    tc = nt.nodes.new("ShaderNodeTexCoord")
    gdir = nt.nodes.new("ShaderNodeAttribute")
    gdir.attribute_name = "GrainDir"
    dot = nt.nodes.new("ShaderNodeVectorMath")
    dot.operation = "DOT_PRODUCT"
    nt.links.new(tc.outputs["Object"], dot.inputs[0])
    nt.links.new(gdir.outputs["Vector"], dot.inputs[1])
    across = nt.nodes.new("ShaderNodeCombineXYZ")
    nt.links.new(dot.outputs["Value"], across.inputs["X"])
    lines = nt.nodes.new("ShaderNodeTexNoise")
    lines.inputs["Scale"].default_value = 900.0
    lines.inputs["Detail"].default_value = 2.0
    nt.links.new(across.outputs["Vector"], lines.inputs["Vector"])
    ramp = nt.nodes.new("ShaderNodeValToRGB")
    ramp.color_ramp.elements[0].position = 0.30
    ramp.color_ramp.elements[0].color = (0.40, 0.34, 0.24, 1.0)
    ramp.color_ramp.elements[1].position = 0.70
    ramp.color_ramp.elements[1].color = (0.68, 0.61, 0.47, 1.0)
    nt.links.new(lines.outputs["Fac"], ramp.inputs["Fac"])
    nt.links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"])
    bsdf.inputs["Roughness"].default_value = 0.88
    return mat


def gilt_material(name):
    """Gold leaf tooling: warm, half metallic, a little worn by the noise."""
    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 = 300.0
    noise.inputs["Detail"].default_value = 4.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 = (0.36, 0.23, 0.07, 1.0)
    ramp.color_ramp.elements[1].position = 0.65
    ramp.color_ramp.elements[1].color = (0.80, 0.56, 0.20, 1.0)
    nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
    nt.links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"])
    bsdf.inputs["Metallic"].default_value = 0.55
    bsdf.inputs["Roughness"].default_value = 0.45
    return mat


def iron_material(name):
    """Caster iron: near-black, rough, a little rust in the noise. Not chrome."""
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    nt = mat.node_tree
    bsdf = nt.nodes["Principled BSDF"]
    tc = nt.nodes.new("ShaderNodeTexCoord")
    noise = nt.nodes.new("ShaderNodeTexNoise")
    noise.inputs["Scale"].default_value = 160.0
    noise.inputs["Detail"].default_value = 6.0
    nt.links.new(tc.outputs["Object"], noise.inputs["Vector"])
    ramp = nt.nodes.new("ShaderNodeValToRGB")
    ramp.color_ramp.elements[0].position = 0.40
    ramp.color_ramp.elements[0].color = (0.035, 0.034, 0.033, 1.0)
    ramp.color_ramp.elements[1].position = 0.80
    ramp.color_ramp.elements[1].color = (0.080, 0.050, 0.034, 1.0)
    nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
    nt.links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"])
    rough = nt.nodes.new("ShaderNodeMapRange")
    rough.inputs["To Min"].default_value = 0.52
    rough.inputs["To Max"].default_value = 0.78
    nt.links.new(noise.outputs["Fac"], rough.inputs["Value"])
    nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"])
    bsdf.inputs["Metallic"].default_value = 0.7
    return mat


def rubber_material(name):
    """Solid rubber tyres: charcoal, matte."""
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    bsdf = mat.node_tree.nodes["Principled BSDF"]
    bsdf.inputs["Base Color"].default_value = (0.028, 0.027, 0.026, 1.0)
    bsdf.inputs["Roughness"].default_value = 0.78
    return mat


def trolley_materials():
    return (
        wood_material("TrolleyWood"),
        cover_material("BookCloth", 420.0, 0.78, 0.86),
        cover_material("BookLeather", 70.0, 0.62, 0.48),
        paper_material("BookPages"),
        gilt_material("BookGilt"),
        iron_material("CasterIron"),
        rubber_material("CasterRubber"),
    )


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 mirror_error(me):
    """Worst distance from a non-book vertex's X and Y mirror images to the mesh."""
    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, -1) not in BOOK_MATS]
    if not pts:
        return 99.0
    kd = KDTree(len(pts))
    for i, p in enumerate(pts):
        kd.insert(p, i)
    kd.balance()
    worst = 0.0
    for p in pts:
        for q in (Vector((-p.x, p.y, p.z)), Vector((p.x, -p.y, p.z))):
            worst = max(worst, kd.find(q)[2])
    return worst


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 depth_in(tree, p, reach=1.0):
    """Signed depth of ``p`` inside the shell ``tree``: positive inside, negative outside."""
    hit = tree.find_nearest(p, reach)
    if hit[0] is None:
        return -reach
    loc, nrm, _i, dist = hit
    return dist if (p - loc).dot(nrm) < 0.0 else -dist


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)
    groups = shells(me)
    owner = {}
    for si, g in enumerate(groups):
        for i in g:
            owner[i] = si
    polys = {}
    for p in me.polygons:
        polys.setdefault(owner[p.vertices[0]], []).append(p)
    out = {k: [] for k in ("end", "skid", "deck", "shelf", "back", "rail", "plate", "iron",
                           "wheel", "cover", "pages", "band", "other")}
    for si, g in enumerate(groups):
        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
        ranked = sorted(polys.get(si, []), key=lambda p: -p.area)
        rec = {"g": g, "pts": pts, "lo": lo, "hi": hi, "ext": ext,
               "c": sum(pts, Vector()) / len(pts),
               "big": [(p.center.copy(), p.normal.copy()) for p in ranked[:2]]}
        m = mats.get(g[0], -1)
        if m == WOOD_IDX:
            nz = abs(rec["big"][0][1].z) if rec["big"] else 0.0
            if ext.x < 0.05 and ext.z > 0.7:
                out["end"].append(rec)
            elif ext.x < 0.1 and ext.y > 0.4 and ext.z < 0.05:
                out["skid"].append(rec)
            elif ext.x > 0.6 and ext.y < 0.04 and ext.z < 0.04:
                out["rail"].append(rec)
            elif ext.x > 0.6 and nz > 0.999:
                out["deck"].append(rec)
            elif ext.x > 0.6 and nz > 0.9:
                out["shelf"].append(rec)
            elif ext.x > 0.6 and nz < 0.5:
                out["back"].append(rec)
            else:
                out["other"].append(rec)
        elif m == IRON_IDX:
            (out["plate"] if ext.z < 0.006 and ext.x > 0.04 else out["iron"]).append(rec)
        elif m == RUBBER_IDX:
            out["wheel"].append(rec)
        elif m in (CLOTH_IDX, LEATHER_IDX):
            face = ranked[0]
            rec["n"] = face.normal.copy()
            rec["face_len"] = max((me.vertices[face.vertices[k]].co
                                   - me.vertices[face.vertices[k - 1]].co).length
                                  for k in range(len(face.vertices)))
            out["cover"].append(rec)
        elif m == PAPER_IDX:
            out["pages"].append(rec)
        elif m == GILT_IDX:
            out["band"].append(rec)
        else:
            out["other"].append(rec)
    return out


def overlaps_xy(a, b, pad=0.0):
    return (a["lo"].x - pad < b["hi"].x and b["lo"].x - pad < a["hi"].x
            and a["lo"].y - pad < b["hi"].y and b["lo"].y - pad < a["hi"].y)


def trolley_audit(me):
    parts = classify(me)
    out = {k: len(v) for k, v in parts.items()}

    # Named supports: the four wheels, each on the floor.
    out["wheel_z"] = max((r["lo"].z for r in parts["wheel"]), default=99.0)

    # Every cross member is housed in both end panels: bite past the inner
    # face, and clear of the far face.
    ends = sorted(parts["end"], key=lambda r: r["c"].x)
    cross = parts["deck"] + parts["shelf"] + parts["back"] + parts["rail"]
    out["cross"] = len(cross)
    bite, far = 99.0, 99.0
    if len(ends) == 2:
        left_in, left_out = ends[0]["hi"].x, ends[0]["lo"].x
        right_in, right_out = ends[1]["lo"].x, ends[1]["hi"].x
        for r in 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

    # Caster plates: each one's top a band into the skid above it.
    pseats = []
    for r in parts["plate"]:
        above = [s for s in parts["skid"] if overlaps_xy(s, r)]
        if above:
            pseats.append(r["hi"].z - min(s["lo"].z for s in above))
    out["plate_seat"] = (min(pseats, default=-99.0), max(pseats, default=99.0))
    out["n_plate_seated"] = len(pseats)

    # Books: each cover with the page block and bands nearest it.
    covers = parts["cover"]
    trees = [shell_tree(me, [c["g"]]) for c in covers]
    units = [{"cover": c, "tree": t, "pages": [], "band": []} for c, t in zip(covers, trees)]
    for kind in ("pages", "band"):
        for r in parts[kind]:
            if units:
                u = min(units, key=lambda u: sum(u["tree"].find_nearest(p)[3]
                                                 for p in r["pts"]) / len(r["pts"]))
                u[kind].append(r)
    out["units"] = len(units)
    out["unmatched"] = sum(1 for u in units if len(u["pages"]) != 1 or len(u["band"]) != 2)

    page_d = [depth_in(u["tree"], p) for u in units for r in u["pages"] for p in r["pts"]]
    out["page_bite"] = (min(page_d, default=-99.0), max(page_d, default=99.0))

    b_in, b_out = [], []
    for u in units:
        for r in u["band"]:
            ds = sorted(depth_in(u["tree"], p) for p in r["pts"])
            half = len(ds) // 2
            b_in.extend(ds[half:])
            b_out.append(-max(ds[:half]))
    out["band_bite"] = (min(b_in, default=-99.0), max(b_in, default=99.0))
    out["band_proud"] = min(b_out, default=-99.0)

    # Pose of each book from its largest face, a board: standing or lying.
    for u in units:
        c = u["cover"]
        n = c["n"]
        tilt = math.asin(min(1.0, abs(n.z)))
        c["pose"] = ("up" if tilt <= UPRIGHT_TILT_MAX else
                     "lying" if tilt >= LYING_TILT_MIN else "other")
        ds = [p.dot(n) for p in c["pts"]]
        c["thick"] = max(ds) - min(ds)
    out["n_up"] = sum(1 for u in units if u["cover"]["pose"] == "up")
    out["n_lying"] = sum(1 for u in units if u["cover"]["pose"] == "lying")

    # Each trough is a shelf and the one backrest that bites into it.
    shelves = parts["shelf"]
    stree = [shell_tree(me, [r["g"]]) for r in shelves]
    btree = [shell_tree(me, [r["g"]]) for r in parts["back"]]
    for r, t in zip(shelves, stree):
        r["backs"] = [b for b, bt in zip(parts["back"], btree) if t.overlap(bt)]
        top = [f for f in r["big"] if f[1].z > 0.0]
        r["top"] = top[0] if top else None
    out["troughs_paired"] = sum(1 for r in shelves if len(r["backs"]) == 1 and r["top"])

    # Seats. A standing book is on the shelf of its own side whose top plane
    # (read off the mesh) is nearest its lowest point; the seat is how deep
    # that point sits under the plane. A lying book is on the deck or the
    # cover below it, measured in Z.
    seats = []
    deck = parts["deck"]
    for u in units:
        c = u["cover"]
        if c["pose"] == "up":
            best = None
            for r in shelves:
                if r["top"] is None or (r["c"].y > 0) != (c["c"].y > 0):
                    continue
                p0, n = r["top"]
                dmin = min((p - p0).dot(n) for p in c["pts"])
                if abs(dmin) <= SUPPORT_REACH and (best is None or abs(dmin) < abs(best[0])):
                    best = (dmin, r)
            if best is not None:
                u["support"] = best[1]
                seats.append(-best[0])
        elif c["pose"] == "lying":
            cands = [r for r in deck + covers if r is not c and overlaps_xy(r, c)
                     and c["lo"].z - SUPPORT_REACH <= r["hi"].z <= c["lo"].z + 0.01]
            if cands:
                u["support"] = max(cands, key=lambda r: r["hi"].z)
                seats.append(u["support"]["hi"].z - c["lo"].z)
    out["seat"] = (min(seats, default=-99.0), max(seats, default=99.0))
    out["n_seated"] = len(seats)

    # Size spread: thickness across the boards, height along the board face.
    th = [u["cover"]["thick"] for u in units]
    hs = [u["cover"]["face_len"] for u in units]
    out["thick_spread"] = max(th) / min(th) if th else 0.0
    out["height_spread"] = max(hs) / min(hs) if hs else 0.0

    # The second support: every standing book's fore-edge in its trough's
    # backrest. Depth past the plane of the backrest face that looks at the
    # book, read off the mesh, over the book's cover vertices.
    backs = []
    for u in units:
        c = u["cover"]
        if c["pose"] != "up":
            continue
        sup = u.get("support")
        if sup is None or len(sup.get("backs", ())) != 1:
            backs.append(-99.0)
            continue
        br = sup["backs"][0]
        u["back"] = br
        facing = [f for f in br["big"] if f[1].dot(c["c"] - f[0]) > 0.0]
        if not facing:
            backs.append(-99.0)
            continue
        p0, n = facing[0]
        backs.append(max(-(p - p0).dot(n) for p in c["pts"]))
    out["back_bite"] = (min(backs, default=-99.0), max(backs, default=99.0))
    out["n_backed"] = len(backs)

    # Books do not run into one another, nor into the carcass, except where
    # they are meant to: a book on the one it is stacked on, a trough book on
    # its shelf and against its backrest, a stack on the deck.
    utrees = [shell_tree(me, [u["cover"]["g"]] + [r["g"] for r in u["pages"] + u["band"]])
              for u in units]
    carcass = [r for k in ("end", "skid", "deck", "shelf", "back", "rail") for r in parts[k]]
    ctrees = [shell_tree(me, [r["g"]]) for r in carcass]
    designed = set()
    for i, u in enumerate(units):
        for j, v in enumerate(units):
            if u.get("support") is v["cover"]:
                designed.add((min(i, j), max(i, j)))
    hits, where = 0, []
    for i in range(len(units)):
        for j in range(i + 1, len(units)):
            if (i, j) in designed:
                continue
            if utrees[i].overlap(utrees[j]):
                hits += 1
                where.append((units[i]["cover"]["c"], units[j]["cover"]["c"]))
        for r, ct in zip(carcass, ctrees):
            if r is units[i].get("support") or r is units[i].get("back"):
                continue
            if utrees[i].overlap(ct):
                hits += 1
                where.append((units[i]["cover"]["c"], r["c"]))
    out["book_hits"] = hits
    out["hit_at"] = where
    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 casters; the books 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, -1) not in BOOK_MATS]
    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("TrolleyNrm", 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_trolley_mesh("TrolleyLow", **flags)
    # The bake source is the carcass and casters only. Only the wood takes a
    # baked map, and the books sit within any cage of it: seated 1-3 mm into
    # the deck and shelves, 6 mm off the end panels. Rays from those faces
    # found the covers and baked each book's footprint into the deck.
    hi_flags = {k: v for k, v in flags.items() if k != "stray_vert"}
    high = build_trolley_mesh("TrolleyHigh", with_books=False, **hi_flags)
    mats = trolley_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("trolley 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, "TrolleyLOD1", LOD1_TARGET, skip_decimate)
    lod2 = make_lod(low, "TrolleyLOD2", 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, "TrolleyCollider")
    col_tris = triangle_count(collider.data)

    export_path = os.path.join(tempfile.gettempdir(), f"bdt_book_trolley_{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 = trolley_audit(low.data)
    mirror = mirror_error(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 ends={sa['end']} skids={sa['skid']} deck={sa['deck']} "
          f"shelves={sa['shelf']} backs={sa['back']} rail={sa['rail']} plates={sa['plate']} "
          f"iron={sa['iron']} wheels={sa['wheel']} covers={sa['cover']} pages={sa['pages']} "
          f"bands={sa['band']} other={sa['other']} unmatched={sa['unmatched']} "
          f"up={sa['n_up']} lying={sa['n_lying']} wheel_z={sa['wheel_z']:.5f}")
    print(f"measured joints cross={sa['cross']} dado={sa['dado']:.5f} "
          f"dado_far={sa['dado_far']:.5f} "
          f"page_bite=({sa['page_bite'][0]:.5f},{sa['page_bite'][1]:.5f})")
    print(f"measured seats book=({sa['seat'][0]:.5f},{sa['seat'][1]:.5f}) n={sa['n_seated']} "
          f"band_bite=({sa['band_bite'][0]:.5f},{sa['band_bite'][1]:.5f}) "
          f"band_proud={sa['band_proud']:.5f} "
          f"plate_seat=({sa['plate_seat'][0]:.5f},{sa['plate_seat'][1]:.5f}) "
          f"n_plate={sa['n_plate_seated']}")
    print(f"measured mirror={mirror:.7f} thick_spread={sa['thick_spread']:.3f} "
          f"height_spread={sa['height_spread']:.3f} troughs_paired={sa['troughs_paired']} "
          f"back=({sa['back_bite'][0]:.5f},{sa['back_bite'][1]:.5f}) n_backed={sa['n_backed']} "
          f"book_hits={sa['book_hits']} right_angle_wood={right}")
    if sa["book_hits"]:
        print(f"measured hit_at={[(tuple(round(x, 3) for x in a), tuple(round(x, 3) for x in b)) for a, b in sa['hit_at'][:6]]}")

    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["wheel"] != N_CASTERS or sa["wheel_z"] > WHEEL_Z_MAX:
        return (fail(f"wheels: {sa['wheel']} of {N_CASTERS}, worst wheel z={sa['wheel_z']:.5f} "
                     f"> {WHEEL_Z_MAX} (--float-caster is the designed fail)", 16),) + nothing
    if (sa["end"] != 2 or sa["cross"] != N_CROSS or sa["dado"] < DADO_MIN
            or sa["dado_far"] < DADO_FAR_MIN):
        return (fail(f"{sa['end']} of 2 ends, {sa['cross']} of {N_CROSS} cross members, dado bite "
                     f"{sa['dado']:.5f} (min {DADO_MIN}), far face {sa['dado_far']:.5f} "
                     f"(min {DADO_FAR_MIN}) (--short-shelves is the designed fail)", 17),) + nothing
    if (sa["units"] != N_BOOKS or sa["unmatched"] or sa["page_bite"][0] < PAGE_BITE_MIN
            or sa["page_bite"][1] > PAGE_BITE_MAX):
        return (fail(f"{sa['units']} of {N_BOOKS} books, {sa['unmatched']} without one page "
                     f"block and two bands, page bite {sa['page_bite']} outside "
                     f"[{PAGE_BITE_MIN}, {PAGE_BITE_MAX}] (--loose-pages is the designed fail)",
                     17),) + nothing
    if sa["n_seated"] != N_BOOKS or sa["seat"][0] < SEAT_MIN or sa["seat"][1] > SEAT_MAX:
        return (fail(f"{sa['n_seated']} of {N_BOOKS} books seated, seat {sa['seat']} outside "
                     f"[{SEAT_MIN}, {SEAT_MAX}] (--float-books is the designed fail)", 18),) + nothing
    if (sa["band_bite"][0] < BAND_BITE_MIN or sa["band_bite"][1] > BAND_BITE_MAX
            or sa["band_proud"] < BAND_PROUD_MIN):
        return (fail(f"band bite {sa['band_bite']} outside [{BAND_BITE_MIN}, {BAND_BITE_MAX}] "
                     f"or proud {sa['band_proud']:.5f} < {BAND_PROUD_MIN} "
                     "(--float-bands is the designed fail)", 18),) + nothing
    if (sa["n_plate_seated"] != N_CASTERS or sa["plate_seat"][0] < PLATE_SEAT_MIN
            or sa["plate_seat"][1] > PLATE_SEAT_MAX):
        return (fail(f"{sa['n_plate_seated']} of {N_CASTERS} caster plates under a skid, seat "
                     f"{sa['plate_seat']} outside [{PLATE_SEAT_MIN}, {PLATE_SEAT_MAX}] "
                     "(--drop-plates is the designed fail)", 18),) + nothing
    if mirror > MIRROR_EPS:
        return (fail(f"carcass and casters off mirror symmetry by {mirror:.6f} > {MIRROR_EPS} "
                     "(--skew-caster is the designed fail)", 19),) + nothing
    if sa["thick_spread"] < THICK_SPREAD_MIN or sa["height_spread"] < HEIGHT_SPREAD_MIN:
        return (fail(f"book spread thickness {sa['thick_spread']:.3f} (min {THICK_SPREAD_MIN}), "
                     f"height {sa['height_spread']:.3f} (min {HEIGHT_SPREAD_MIN}) "
                     "(--uniform-books is the designed fail)", 20),) + nothing
    if (sa["n_up"] != N_UP or sa["n_lying"] != N_LYING or sa["troughs_paired"] != N_TROUGH
            or sa["n_backed"] != N_UP or sa["back_bite"][0] < BACK_MIN
            or sa["back_bite"][1] > BACK_MAX):
        return (fail(f"{sa['n_up']} of {N_UP} trough and {sa['n_lying']} of {N_LYING} lying books, "
                     f"{sa['troughs_paired']} of {N_TROUGH} troughs with one backrest, backrest "
                     f"contact {sa['back_bite']} outside [{BACK_MIN}, {BACK_MAX}] "
                     "(--off-back and --deep-back are the designed fails)", 21),) + nothing
    if sa["book_hits"]:
        return (fail(f"{sa['book_hits']} book overlaps, need 0 "
                     "(--crowd-books is the designed fail)", 22),) + nothing
    if right:
        return (fail(f"{right} right-angle wood edges, need 0 "
                     "(--sharp-deck 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(-16.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), 480.0, 3.0, (1.0, 0.95, 0.88), (50, 0, -35))
    light("Fill", "AREA", (3.2, -2.8, 1.6), 80.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.10, -3.10, 1.50)
    scene.collection.objects.link(cam)
    aim = bpy.data.objects.new("Aim", None)
    aim.location = (0.0, 0.0, 0.48)
    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-caster", action="store_true")
    p.add_argument("--short-shelves", action="store_true")
    p.add_argument("--loose-pages", action="store_true")
    p.add_argument("--float-books", action="store_true")
    p.add_argument("--float-bands", action="store_true")
    p.add_argument("--drop-plates", action="store_true")
    p.add_argument("--skew-caster", action="store_true")
    p.add_argument("--uniform-books", action="store_true")
    p.add_argument("--off-back", action="store_true")
    p.add_argument("--deep-back", action="store_true")
    p.add_argument("--crowd-books", action="store_true")
    p.add_argument("--sharp-deck", 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_caster=args.float_caster,
        short_shelves=args.short_shelves,
        sharp_deck=args.sharp_deck,
        drop_plates=args.drop_plates,
        skew_caster=args.skew_caster,
        loose_pages=args.loose_pages,
        float_books=args.float_books,
        float_bands=args.float_bands,
        uniform_books=args.uniform_books,
        crowd_books=args.crowd_books,
        off_back=args.off_back,
        deep_back=args.deep_back,
    )
    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("book-trolley 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 double-sided wooden library book trolley on four casters, its two sloped troughs of cloth and leather hardbacks leaning back spines out, with loose books stacked on the bottom deck.