diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 7c598548..2bfee558 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -172,6 +172,7 @@ "showcase/apothecary-shelf", "showcase/wooden-yoke", "showcase/butter-churn", - "showcase/bookshelf" + "showcase/bookshelf", + "showcase/book-trolley" ] } diff --git a/CLAUDE.md b/CLAUDE.md index 5d61cd8a..e8d59bab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ rules/.mdc - Anti-pattern rules, 9 total templates// - Starter projects, 3 total snippets/.py - Standalone code patterns, 27 total examples// - Runnable smoke-gated examples, 59 total (+ gallery.json) -showcase// - Budget-conformance props, 35 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 36 pieces (sibling of examples/; see showcase/README.md) scripts/build_gallery.py - Regenerates docs/gallery/ from examples/gallery.json + showcase/gallery.json scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders diff --git a/README.md b/README.md index 493155f9..8768cd0d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  35 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  36 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 35 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. +This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 36 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. The content is consumed by AI coding agents reading these files directly from a checkout — **there is no MCP server in this repository, and none is required**. Cursor applies `rules/*.mdc` automatically wherever their scope globs match and takes skills by name in chat; Claude Code reads `skills/` and `rules/` from the project workspace, or from this repo kept as a referenced checkout. Any agent that can read files in a workspace can use it the same way. There is no build step for the content — edit the Markdown and Python files directly. @@ -106,7 +106,7 @@ per-script exit-code model, are in Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](showcase/README.md).

-35 showcase pieces — click to expand the preview grid +36 showcase pieces — click to expand the preview grid

Shipping crate: a wooden slat crate with iron corner brackets on a dark studio floor, warm wedge on the back wall @@ -144,6 +144,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Wooden yoke: a carved oak ox yoke with upturned ends standing on two bent hickory oxbows pinned above the beam, with an iron ring hung under its centre, on a dark studio floor Butter churn: a tall staved oak churn narrowing toward the top, bound by three iron hoops, with a two-board lid and a maple dasher handle rising through it, on a dark studio floor Bookshelf: a stained wooden bookcase of cloth and leather hardbacks, some standing, some stacked flat and two leaning on their neighbours, on a dark studio floor + Book trolley: a double-sided wooden library book truck on four casters, two sloped troughs of hardbacks leaning back spines out and loose books stacked on its deck, on a dark studio floor

[`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9. @@ -212,6 +213,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`bookshelf`](showcase/bookshelf/) — procedural bookcase of thirty-eight rounded-spine hardbacks, standing, stacked and leaning, whose leaning books' contact with their neighbour's head edge is recomputed as a band, through the same pipeline. Falsifiers `--air-lean` and `--deep-lean` exit 22, `--crowd-books` exits 23. +[`book-trolley`](showcase/book-trolley/) — procedural double-sided library book truck on four casters, forty-seven hardbacks in four sloped troughs, whose every book's fore-edge depth in its backrest is recomputed as a band beside its shelf seat, through the same pipeline. Falsifiers `--off-back` and `--deep-back` exit 21, `--skew-caster` exits 19. +
## Examples diff --git a/ROADMAP.md b/ROADMAP.md index 0fd9562f..5cafe40e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,7 +140,8 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Wooden yoke (oxbow, iron rings) as a game-prop showcase piece~~ **SHIPPED** as `showcase/wooden-yoke/` — lofted oak beam with neck saddles and upturned ends, two bent-hickory bows through it and pinned above it, a staple and a threaded hung ring; each neck opening measured between the bow legs and up to the saddle, `--pinch-bows` exits 19 on it while the bows still ground and the envelope is unchanged - ~~Butter churn (staved tub, lid, dasher) as a game-prop showcase piece~~ **SHIPPED** as `showcase/butter-churn/` — sixteen tapered staves with canted ends, three hoops seated vertex-for-vertex on them, a three-board bottom in a croze, a two-board lid and a cross dasher; the plunge stroke is recomputed from the inner wall and the plunger's reach, `--wide-dasher` exits 20 on it while the plunger still clears the staves at rest - ~~Bookshelf with leaning books (upright and toppled volumes of varied size, one leaning on its neighbour) as a game-prop showcase piece~~ **SHIPPED** as `showcase/bookshelf/` — housed shelves on a recessed plinth, lapped back boards and a stepped cornice, thirty-eight rounded-spine hardbacks with glued page blocks and gilt bands, standing, stacked and leaning; each leaning board's contact with its neighbour's head edge is read off the mesh as a plane depth, `--air-lean` and `--deep-lean` exit 22 on it while every seat and the envelope still pass -- Library ladder or book trolley with loose books as a game-prop showcase piece — books resting across two rails (a ledge, a trolley shelf) exercise a two-point support the bookshelf's flat shelves never do +- ~~Library ladder or book trolley with loose books as a game-prop showcase piece~~ **SHIPPED** (the trolley half) as `showcase/book-trolley/` — a double-sided library book truck on four swivel casters: arched end panels standing in skids, four sloped troughs each a tilted shelf and a backrest square to it, forty-seven spines-out hardbacks and two loose stacks on the deck; each trough book's fore-edge depth in its backrest is read off the mesh beside its shelf seat, `--off-back` and `--deep-back` exit 21 on it while every seat and the envelope still pass +- Library rolling ladder (hooked top rollers riding a round rail, raked stiles, treads, floor wheels) as a game-prop showcase piece — the unbuilt half of the item above: a hook riding a round rail is a curved-on-curved contact that neither the trolley's flat backrests nor the yoke's hung ring exercise - Ox cart tongue or plough beam that hitches to the yoke's ring as a game-prop showcase piece — a hitch is a ring-on-hook contact the yoke's hung ring only tests against a fixed bar - ~~Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract)~~ **SHIPPED** as `showcase/fence-kit/` — tiling post-and-rail section (AABB X is the tile width; does not re-witness the snap contract); `--skip-decimate` exits 9 on the LOD1 ratio band diff --git a/docs/gallery/asset-sheets/book-trolley.webp b/docs/gallery/asset-sheets/book-trolley.webp new file mode 100644 index 00000000..4d301990 Binary files /dev/null and b/docs/gallery/asset-sheets/book-trolley.webp differ diff --git a/docs/gallery/assets/book-trolley-hero.webp b/docs/gallery/assets/book-trolley-hero.webp new file mode 100644 index 00000000..8639f2c4 Binary files /dev/null and b/docs/gallery/assets/book-trolley-hero.webp differ diff --git a/docs/gallery/book-trolley/index.html b/docs/gallery/book-trolley/index.html new file mode 100644 index 00000000..f28d5ae8 --- /dev/null +++ b/docs/gallery/book-trolley/index.html @@ -0,0 +1,2189 @@ + + + + + + book-trolley — Showcase — Blender Developer Tools + + + + + + + + + + + + + + + + + + + + + + + + +
+

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.
+
+
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 book still sits its seat into the shelf, so the seat budget passes;
  • it stays inside the carcass, so the bounding box does not move;
  • the triangle count is the same, because only a position changed;
  • the book-overlap budget has to exempt both designed contacts, the shelf and the backrest, so it cannot see how deep either goes.
+

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

+
  • End panels and skids. Each end is one prism: a flat-sided outline whose top is a 16-segment arc, extruded 22 mm and chamfered 2.5 mm, standing 12 mm deep in a 70 × 35 mm skid that runs 15 mm past it front and back. The panels are the carcass's only bearers; the casters are the named supports.
  • Cross members. The deck and the four tilted shelves and backrests are each housed DADO (8 mm) into both ends and stop 14 mm short of the outer faces. The push rail is a 16-sided dowel whose ends sit 12 mm blind in both panels, 10 mm short of the outer faces.
  • Troughs. Built in a trough frame: s runs down the slope into the backrest, t up the shelf normal, the corner at the origin. The shelf runs from 170 mm in front of the corner to 6 mm past the backrest's back face, so the two backs never share a plane; the backrest stands 6 mm into the shelf. The two sides are mirror images, and the two tiers of a side stand 320 mm apart, so each tier's books clear the shelf above.
  • Casters. Stacked by a named bite at each joint: a 56 mm plate 1 mm into the skid, a swivel race 1 mm into the plate, a fork (one U-section prism, chamfered 0.8 mm) 1 mm into the race, an axle through both fork legs and a 70 mm rubber wheel whose lowest vertex is the floor. The wheel's rim is rounded with a two-segment bevel and shaded smooth; the iron is near-black with a little rust in the noise, not chrome.
  • Books. Copied from bookshelf: one U-section cover per book with a rounded spine, a page block 0.6 mm into both boards, two gilt bands on the spine's own chain.
  • Trough books. Built in their own frame, turned so the height runs up the shelf normal and the fore-edge down the slope, and yawed about the shelf normal in a four-step cycle (−2.4°, −0.8°, 0.8°, 2.4°) so no two neighbours share a plane. Each is then slid up the normal to a stepped 1.0–3.2 mm seat into the shelf, down the slope until its deepest vertex is BACK_BITE (0.8 mm) into the backrest, and along the trough with a 2.5–4.5 mm gap. A trough that overflows its span is a build error.
  • Loose stacks. Books laid flat on the deck, spine out, yawed ±3° alternately and shifted a few millimetres; each book above the first takes its own 1.0–1.4 mm seat into the board below it.
+

Findings the budgets forced

+
  • A 45° plane shared by accident. Dropping the caster plates 2 mm for the plate-seat falsifier put each plate's lower chamfer and the fork's upper chamfer on the same 45° plane — the plate's half-width minus the fork's is 13 mm, and so was the drop between them — and the run exited 15 on eight coplanar pairs instead of 18. The drop is 2.5 mm.
  • An edge falsifier the mirror budget saw first. Leaving one front backrest square broke the carcass's front/back symmetry, so the run exited 19 before the edge budget. The falsifier now leaves the deck square: one centred member, symmetric in both axes, 12 right-angle edges.
  • The bake cage reached the books. At 10 mm the cage from each end panel's inner face reached the high-poly covers 6 mm away, and the books' edges baked onto the panel as pale streaks. Cutting the cage to 3 mm cleared the panels but not the deck: the loose stacks sit 1.0–1.4 mm into it, so rays from round each stack still found the spines and baked every footprint in as a dished groove. Only the wood takes a baked map, so the high-poly bake source is now the carcass and casters alone; the cage stays at 3 mm.
+

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 + 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. + +
+
+ generated from showcase/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/docs/gallery/contact-sheets/book-trolley-contact-sheet.webp b/docs/gallery/contact-sheets/book-trolley-contact-sheet.webp new file mode 100644 index 00000000..1f487249 Binary files /dev/null and b/docs/gallery/contact-sheets/book-trolley-contact-sheet.webp differ diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 4dc3a9c0..1000bf02 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -291,7 +291,7 @@

Examples and Showcase

autocomplete="off" spellcheck="false" aria-label="Search examples and showcase pieces" /> - 51 examples, 35 showcase pieces + 51 examples, 36 showcase pieces
@@ -1287,6 +1287,17 @@

bookshelf

View showcase piece bookshelf
+
+ +
+

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.

+

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.

+ View showcase piece book-trolley +
+
@@ -1316,7 +1327,7 @@

bookshelf

var tagsToggle = document.getElementById('tagsToggle'); var toTop = document.getElementById('toTop'); var total = cards.length; - var COUNT_LABEL = '51 examples, 35 showcase pieces'; + var COUNT_LABEL = '51 examples, 36 showcase pieces'; var LS_KEY = 'bdt-gallery-density'; var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; diff --git a/scripts/asset_sheet.py b/scripts/asset_sheet.py index 18e10f67..550ce752 100644 --- a/scripts/asset_sheet.py +++ b/scripts/asset_sheet.py @@ -62,6 +62,7 @@ "wooden-yoke": (r"^YokeLow$", None), "apothecary-shelf": (r"^ShelfLow$", None), "bookshelf": (r"^BookshelfLow$", None), + "book-trolley": (r"^TrolleyLow$", None), "brazier": (r"^BrazierLow$", None), "grain-sacks": (r"^SacksLow$", None), "rope-bridge": (r"^BridgeLow$", None), diff --git a/showcase/book-trolley/README.md b/showcase/book-trolley/README.md new file mode 100644 index 00000000..535ae854 --- /dev/null +++ b/showcase/book-trolley/README.md @@ -0,0 +1,283 @@ +# book-trolley + +![A double-sided wooden library book trolley on four small casters, with arched end panels, a turned push rail, two sloped troughs of cloth and leather hardbacks leaning back spines out, and two loose stacks of books on its bottom deck](preview.webp) + +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 content | Used for | +| --- | --- | +| `skills/mesh-editing-and-bmesh` | chamfered boards in trough frames, n-gon-capped prisms (arched end panels, caster forks, wheels), U-section covers, all in `bmesh` | +| `skills/custom-properties` | face attributes (`PlankTone`, `GrainDir`, `Tint`) read by the shaders | +| `skills/procedural-materials-and-shaders` | grained stain, cloth, leather, ruled page edges, gilt, caster iron, rubber | +| `skills/bake-high-to-low` | Cycles tangent-space normal bake, high onto low | +| `skills/engine-export-presets` | Unity glTF (`export_yup=True`) | +| `skills/depsgraph-and-evaluated-data` | evaluated triangle counts for the LOD ratios | +| `snippets/decimate_to_budget.py` | LOD1 / LOD2 COLLAPSE chain | +| `snippets/convex_hull_collider.py` | one hull over the carcass and casters | +| `snippets/lod_chain.py` | LOD naming and ratio pattern | +| `examples/mesh-hygiene-audit` | hygiene combinatorics (copied, not imported) | +| `showcase/bookshelf` | the 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 book still sits its seat into the shelf, so the seat budget passes; +- it stays inside the carcass, so the bounding box does not move; +- the triangle count is the same, because only a position changed; +- the book-overlap budget has to exempt both designed contacts, the shelf + and the backrest, so it cannot see how deep either goes. + +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. + +| Budget | Band | Measured | +| --- | --- | --- | +| Base triangles | 16800–18600 | 17704 | +| LOD1 ratio | 0.32–0.62 | 0.5000 | +| LOD2 ratio | 0.10–0.35 | 0.2200 | +| Material slots | exactly 7, distinct | 7 | +| Wood / cloth / leather faces | ≥ 510 / 2500 / 1320 | 568 / 2788 / 1476 | +| Paper / gilt faces | ≥ 280 / 2430 | 312 / 2704 | +| Iron / rubber faces | ≥ 840 / 590 | 932 / 656 | +| UV bounds | inside 0..1 | (0.0003, 0.0003)–(0.9997, 0.9997) | +| UV AABB overlap | ≤ 1e-5 | 0.000000 | +| Outer AABB | 0.768 × 0.530 × 1.000 m ± 0.020 | 0.7680 × 0.5300 × 1.0000 | +| Collider triangles | ≤ 360 | 308 | +| Normal bake | `{'FINISHED'}` with image data | `{'FINISHED'}`, `has_data=True` | +| glTF export | file written, non-empty | ~1.30 MB | +| Hygiene | all zero | loose 0/0, non-manifold 0, zero-area 0, doubles 0, n-gons 0, coplanar cross-shell pairs 0 | +| Grounded AABB | \|zmin\| ≤ 1e-4 | 0.00000 | +| Named wheels | 4 wheels, each zmin ≤ 1e-4 | 4 at 0.00000 | +| Dado | 2 end panels; 10 cross members (deck, 4 shelves, 4 backrests, rail), each ≥ 5 mm into both panels and ≥ 6 mm clear of the far face | 8.00 mm in, 10.00 mm clear | +| Page block | 52 books, one block and two bands each; every block corner 0.3–1.2 mm inside its boards | 0.60 mm | +| Book seat | 52 books, each 0.5–3.6 mm into its shelf (along the shelf normal), the deck or the book below | 1.00–3.16 mm | +| Band seat | inner face 0.1–0.8 mm inside the spine, outer face ≥ 0.4 mm proud | 0.29 mm in, 0.80 mm proud | +| Caster plates | 4 plates, each top 0.5–2.0 mm into the skid above | 1.00 mm | +| Mirror symmetry | carcass and casters, every vertex's X and Y mirror image on the mesh within 1e-4 m | 0.0000000 | +| Size spread | thickest / thinnest ≥ 2.5; tallest / shortest ≥ 1.5 | 2.750 / 1.640 | +| Backrest contact | 47 trough and 5 lying books; 4 troughs each with one backrest; every trough book 0.3–2.0 mm into its backrest | 0.80 mm | +| Book overlaps | 0, book against book and against the carcass, designed contacts exempt | 0 | +| Edge treatment | right-angle wood edges | 0 | + +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 + +- **End panels and skids.** Each end is one prism: a flat-sided outline + whose top is a 16-segment arc, extruded 22 mm and chamfered 2.5 mm, + standing 12 mm deep in a 70 × 35 mm skid that runs 15 mm past it front + and back. The panels are the carcass's only bearers; the casters are the + named supports. +- **Cross members.** The deck and the four tilted shelves and backrests + are each housed `DADO` (8 mm) into both ends and stop 14 mm short of the + outer faces. The push rail is a 16-sided dowel whose ends sit 12 mm blind + in both panels, 10 mm short of the outer faces. +- **Troughs.** Built in a trough frame: `s` runs down the slope into the + backrest, `t` up the shelf normal, the corner at the origin. The shelf + runs from 170 mm in front of the corner to 6 mm past the backrest's back + face, so the two backs never share a plane; the backrest stands 6 mm into + the shelf. The two sides are mirror images, and the two tiers of a side + stand 320 mm apart, so each tier's books clear the shelf above. +- **Casters.** Stacked by a named bite at each joint: a 56 mm plate 1 mm + into the skid, a swivel race 1 mm into the plate, a fork (one U-section + prism, chamfered 0.8 mm) 1 mm into the race, an axle through both fork + legs and a 70 mm rubber wheel whose lowest vertex is the floor. The + wheel's rim is rounded with a two-segment bevel and shaded smooth; the + iron is near-black with a little rust in the noise, not chrome. +- **Books.** Copied from `bookshelf`: one U-section cover per book with a + rounded spine, a page block 0.6 mm into both boards, two gilt bands on + the spine's own chain. +- **Trough books.** Built in their own frame, turned so the height runs up + the shelf normal and the fore-edge down the slope, and yawed about the + shelf normal in a four-step cycle (−2.4°, −0.8°, 0.8°, 2.4°) so no two + neighbours share a plane. Each is then slid up the normal to a stepped + 1.0–3.2 mm seat into the shelf, down the slope until its deepest vertex + is `BACK_BITE` (0.8 mm) into the backrest, and along the trough with a + 2.5–4.5 mm gap. A trough that overflows its span is a build error. +- **Loose stacks.** Books laid flat on the deck, spine out, yawed ±3° + alternately and shifted a few millimetres; each book above the first + takes its own 1.0–1.4 mm seat into the board below it. + +## Findings the budgets forced + +- **A 45° plane shared by accident.** Dropping the caster plates 2 mm for + the plate-seat falsifier put each plate's lower chamfer and the fork's + upper chamfer on the same 45° plane — the plate's half-width minus the + fork's is 13 mm, and so was the drop between them — and the run exited + 15 on eight coplanar pairs instead of 18. The drop is 2.5 mm. +- **An edge falsifier the mirror budget saw first.** Leaving one front + backrest square broke the carcass's front/back symmetry, so the run + exited 19 before the edge budget. The falsifier now leaves the deck + square: one centred member, symmetric in both axes, 12 right-angle + edges. +- **The bake cage reached the books.** At 10 mm the cage from each end + panel's inner face reached the high-poly covers 6 mm away, and the books' + edges baked onto the panel as pale streaks. Cutting the cage to 3 mm + cleared the panels but not the deck: the loose stacks sit 1.0–1.4 mm into + it, so rays from round each stack still found the spines and baked every + footprint in as a dished groove. Only the wood takes a baked map, so the + high-poly bake source is now the carcass and casters alone; the cage + stays at 3 mm. + +## Conventions walked + +Every convention in `showcase/README.md`, and whether it applies here. + +| Convention | Applies | How | +| --- | --- | --- | +| Deterministic, budgets declared, assertions recompute | yes | no RNG; every value above is read off the mesh | +| Falsifier fails the budget it targets | yes | table below, proven on all three binaries | +| Hygiene incl. cross-shell coplanar | yes | exit 15; stepped seats, the yaw cycle and the shelf tail keep it at 0 | +| Named supports | yes | the four wheels (`--float-caster`); the plates, forks and skids stop above the floor | +| Joint-fit budgets | yes | dado bite and far-face clearance per cross member (`--short-shelves`) | +| A member is tenoned into its seat | yes | panels in skids, backrests in shelves, rail blind in the panels, each caster part into the one above | +| A platform bears on something | yes | deck and every shelf housed in both end panels | +| Carried parts bite their bearers | yes | books into shelves, the deck and the book below (`--float-books`); caster plates into the skids (`--drop-plates`) | +| A joint bites; touching is not joining | yes | page blocks into their boards (`--loose-pages`); the backrest contact as a band | +| Bands on a curved host are built on the host's arc | yes | gilt bands on the spine's own chain (`--float-bands`) | +| A leaning prop rests on something | yes | every trough book against its backrest, exit 21 (`--off-back`, `--deep-back`) | +| Mirrored assemblies | yes | carcass and casters mirror in X and Y, exit 19 (`--skew-caster`, 6 mm inside the 20 mm AABB tolerance) | +| Scattered parts do not interpenetrate | yes | exit 22 (`--crowd-books`), designed contacts exempt | +| Scatter varies in size | yes | thickness and height spread, exit 20 (`--uniform-books`) | +| Edge treatment: no right angles | yes | exit 23 (`--sharp-deck`), wood only; iron chamfered 0.8 mm, rubber rounded | +| Aim an edge falsifier at one member | yes | the deck left square: 12 edges | +| Chamfer n-gon caps, then triangulate | yes | end panels, forks, races, axles, wheels and the rail are n-gon-capped prisms, bevelled then triangulated | +| Sort bmesh operator inputs | yes | bevel edges sorted by index, carcass, iron, rubber and covers | +| Identical boards read as CG | yes | per-board `PlankTone` and `GrainDir`; per-book `Tint` | +| Variation goes into surface, never into function | yes | sizes, tints, gaps and yaw vary; tier pitch, slope and seats are budgets | +| Shading is part of the model | yes | spines, rail and wheels smooth (round); every edge over 35° hard, so boards and chamfers read crisp | +| One substance, one slot | yes | wood, cloth, leather, paper, gilt, iron, rubber | +| Iron is not chrome | yes | near-black, roughness 0.52–0.78 | +| Bake texels per UV cell | measured, not a budget | 1024 px over a 98-cell grid, about 10 px per cell | +| The bake cage is narrower than the nearest neighbour | yes | `CAGE_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 m | yes | turned about Z only; 60 m floor and wall | +| Keep a falsifier's envelope still | yes | every falsifier except `--lift-z` keeps the AABB within tolerance | +| Plumb, rope, masonry, roofs, rings, terrain, vessels | no | the 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. + +| Flag | Target budget | Breaks | Exit | +| --- | --- | --- | --- | +| `--skip-decimate` | LOD1 ratio | drops the DECIMATE modifiers, LOD1 ratio goes to 1.0000 | 9 | +| `--stray-vert` | mesh hygiene | adds one loose vertex inside the deck | 15 | +| `--lift-z` | grounded zmin | lifts the whole mesh 50 mm | 16 | +| `--float-caster` | named wheels | lifts one caster 5 mm; the other three still ground the AABB | 16 | +| `--short-shelves` | dado bite | stops the four shelves 4 mm short of the ends; −4.0 mm | 17 | +| `--loose-pages` | page-block bite | builds every page block 0.9 mm clear of its boards; −0.9 mm | 17 | +| `--float-books` | book seat | lifts every book 6 mm off its shelf or the deck; −5.0 mm | 18 | +| `--float-bands` | band seat | builds every band 1.1 mm off its spine; −1.1 mm | 18 | +| `--drop-plates` | caster plate seat | drops every caster plate 2.5 mm, clear of its skid; −1.5 mm | 18 | +| `--skew-caster` | mirror symmetry | moves one caster 6 mm along the trolley; 6.0 mm | 19 | +| `--uniform-books` | size spread | one height and depth, thickness within ±1.5 %; 1.030 / 1.000 | 20 | +| `--off-back` | backrest contact | stands every trough book 3 mm off its backrest; −3.0 mm | 21 | +| `--deep-back` | backrest contact | drives every trough book 4.5 mm into its backrest; 4.5 mm | 21 | +| `--crowd-books` | book overlaps | closes the front upper trough's gaps to −6 mm; 13 overlaps | 22 | +| `--sharp-deck` | edge treatment | leaves the deck unchamfered; 12 right-angle edges | 23 | + +## Exit codes + +File-local and sequential. `9` is a valid check code. `1` is the FATAL +wrapper — a crash, never a named check. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Mesh did not build, or has no UV layer | +| 4 | Base triangle count outside band | +| 5 | Material slots, or a material's face floor | +| 6 | UVs outside 0..1 | +| 7 | UV AABB overlap above tolerance | +| 8 | Outer AABB off declared size | +| 9 | LOD1 or LOD2 ratio outside band (`--skip-decimate`) | +| 10 | Framing gate (`examples/gallery_framing.py`, render path only) | +| 11 | Collider triangles above ceiling | +| 12 | Normal bake failed or produced no image data | +| 13 | glTF export missing or empty | +| 14 | `--output` produced no file | +| 15 | Mesh hygiene (`--stray-vert`) | +| 16 | Grounded zmin, or a wheel floating (`--lift-z`, `--float-caster`) | +| 17 | Dado or page-block bite, or a book without one block and two bands (`--short-shelves`, `--loose-pages`) | +| 18 | Book, band or caster-plate seat (`--float-books`, `--float-bands`, `--drop-plates`) | +| 19 | Carcass and casters off mirror symmetry (`--skew-caster`) | +| 20 | Size spread below floor (`--uniform-books`) | +| 21 | Backrest contact, trough / lying count, or a trough without one backrest (`--off-back`, `--deep-back`) | +| 22 | Books overlap each other or the carcass (`--crowd-books`) | +| 23 | Right-angle wood edges (`--sharp-deck`) | + +## Run it + +```bash +# 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 + +| Value | 4.5.11 | 5.1.2 | 5.2.1 | +| --- | --- | --- | --- | +| Base triangles | 17704 | 17704 | 17704 | +| LOD1 / LOD2 tris | 8852 / 3894 | 8852 / 3894 | 8852 / 3894 | +| Face counts (wood / cloth / leather / paper / gilt / iron / rubber) | 568 / 2788 / 1476 / 312 / 2704 / 932 / 656 | same | same | +| Outer AABB | 0.7680 × 0.5300 × 1.0000 | same | same | +| Collider tris | 308 | 308 | 308 | +| Seat / backrest contact / mirror | 1.00–3.16 mm / 0.80 mm / 0.0 | same | same | +| glTF bytes | 1303316 | 1303316 | 1303308 | + +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. diff --git a/showcase/book-trolley/book_trolley.py b/showcase/book-trolley/book_trolley.py new file mode 100644 index 00000000..cff67f92 --- /dev/null +++ b/showcase/book-trolley/book_trolley.py @@ -0,0 +1,1820 @@ +"""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) diff --git a/showcase/book-trolley/preview.webp b/showcase/book-trolley/preview.webp new file mode 100644 index 00000000..d9d61fc4 Binary files /dev/null and b/showcase/book-trolley/preview.webp differ diff --git a/showcase/gallery.json b/showcase/gallery.json index 766b4e44..05c3771e 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -465,6 +465,19 @@ "mesh", "export" ] + }, + { + "name": "book-trolley", + "dir": "showcase/book-trolley", + "teaches": "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.", + "alt": "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.", + "witnessesFix": "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.", + "hero": "docs/gallery/assets/book-trolley-hero.webp", + "preview": "showcase/book-trolley/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index 393f8f01..f2aa1ed4 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -108,5 +108,6 @@ {"name": "apothecary-shelf", "script": "showcase/apothecary-shelf/apothecary_shelf.py"}, {"name": "wooden-yoke", "script": "showcase/wooden-yoke/wooden_yoke.py"}, {"name": "butter-churn", "script": "showcase/butter-churn/butter_churn.py"}, - {"name": "bookshelf", "script": "showcase/bookshelf/bookshelf.py"} + {"name": "bookshelf", "script": "showcase/bookshelf/bookshelf.py"}, + {"name": "book-trolley", "script": "showcase/book-trolley/book_trolley.py"} ]