diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 03c74ace..d0d240fe 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -171,6 +171,7 @@ "showcase/brazier", "showcase/apothecary-shelf", "showcase/wooden-yoke", - "showcase/butter-churn" + "showcase/butter-churn", + "showcase/bookshelf" ] } diff --git a/CLAUDE.md b/CLAUDE.md index 1327268b..e54101fa 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, 34 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 35 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 d0f5ba30..493155f9 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

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

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

-34 showcase pieces — click to expand the preview grid +35 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 @@ -143,6 +143,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Apothecary shelf: a stained wooden shelf unit with an arched crest, gallery rails and three drawers, stocked with labelled glass bottles, flasks and glazed jars, on a dark studio floor 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

[`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9. @@ -209,6 +210,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`butter-churn`](showcase/butter-churn/) — procedural staved plunge churn whose dasher stroke is recomputed from the inner wall read off the staves and the plunger's reach, through the same pipeline. Falsifiers `--wide-dasher` exits 20, `--tight-hole` exits 18. +[`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. +
## Examples diff --git a/ROADMAP.md b/ROADMAP.md index bc6fb112..3f846024 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -139,7 +139,8 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Apothecary shelf (shelf unit, bottles and jars of varied form) as a game-prop showcase piece~~ **SHIPPED** as `showcase/apothecary-shelf/` — housed shelves, gallery rails, lapped back boards, a drawer bank and an arched crest, stocked with fifteen lathed vessels in five forms, each corked or lidded and labelled on its own rings; per-vessel headroom under the member above is read off the mesh, `--tall-flask` exits 20 on it while every seat and the envelope still pass - ~~Wooden yoke (oxbow, iron rings) as a game-prop showcase piece~~ **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 — the lean needs a contact budget the apothecary shelf's upright vessels never exercised: each leaning book rests on its neighbour's spine edge and on the shelf, not in either +- ~~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 - 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/bookshelf.webp b/docs/gallery/asset-sheets/bookshelf.webp new file mode 100644 index 00000000..779bdb1b Binary files /dev/null and b/docs/gallery/asset-sheets/bookshelf.webp differ diff --git a/docs/gallery/assets/bookshelf-hero.webp b/docs/gallery/assets/bookshelf-hero.webp new file mode 100644 index 00000000..63ec40a5 Binary files /dev/null and b/docs/gallery/assets/bookshelf-hero.webp differ diff --git a/docs/gallery/bookshelf/index.html b/docs/gallery/bookshelf/index.html new file mode 100644 index 00000000..20e28589 --- /dev/null +++ b/docs/gallery/bookshelf/index.html @@ -0,0 +1,2070 @@ + + + + + + bookshelf — Showcase — Blender Developer Tools + + + + + + + + + + + + + + + + + + + + + + + + +
+

bookshelf

+

A procedural bookcase — two housed shelves, a base on a recessed plinth, lapped back boards and a stepped cornice, filled with thirty-eight rounded-spine hardbacks standing, stacked and leaning — 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: 10908 tris, five materials with 338 wood, 2050 cloth, 1066 leather, 228 paper and 1976 gilt faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.972×0.332×1.176 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, both sides on the floor, four cross members 8 mm into both sides, 38 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, 44 mm headroom, thickness and height spread 2.90 and 1.65, both leaning books 0.8 mm onto their neighbour's head edge, zero unplanned book overlaps, zero right-angle wood edges, convex collider 68 tris. --air-lean and --deep-lean exit 22 on the lean contact while every seat, the envelope and the overlap budget still pass; --crowd-books exits 23; --uniform-books 21; --tall-book 20.
+
+
blender --background --python showcase/bookshelf/bookshelf.py --
+ +
+
+

A bookcase: a stained carcass with two shelves housed in its sides, a base on a recessed plinth, lapped back boards and a stepped cornice, filled with thirty-eight hardbacks. Twenty-nine stand upright, seven lie in three stacks, and two lean on a neighbour. 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, U-section covers with rounded spines, page blocks and bands built on the spine's own chain, 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
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
snippets/lod_chain.pyLOD naming and ratio pattern
examples/mesh-hygiene-audithygiene combinatorics (copied, not imported)
+

The budget that matters

+

A leaning book has to rest on its neighbour's head edge and on the shelf, not in either. Tip it a few degrees too far and its board cuts through the neighbour's corner; not far enough and it stands in the air against nothing. Neither is visible to any other budget:

+
  • the leaning book still stands on its 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 this very contact, so it cannot see how deep the contact goes.
+

The piece finds the leaning books on the finished mesh (a book whose largest face, a board, is tilted between 3° and 80°) and the upright neighbour on the side its top leans toward, on the same shelf. It takes the plane of the leaning board's outer face off the mesh and measures how far the neighbour's deepest vertex sits behind it, as a band of 0.3–2.0 mm. Both lean at 0.80 mm.

+

A plane, not the cover solid: the board is 2.8 mm thick, and a head edge driven right through it lies in the hollow of the book, outside the cover again, so a solid test reads a deep cut as no contact at all. That is how the first --deep-lean passed.

+

--air-lean and --deep-lean are the falsifiers built for exactly this. One stops each leaning book 3 mm short of its neighbour, the other drives it 4.5 mm in. Both exit 22 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 triangles10400–1140010908
LOD1 ratio0.32–0.620.5000
LOD2 ratio0.10–0.350.2189 (0.2198 on 4.5 and 5.1)
Material slotsexactly 5, distinct5
Wood / cloth / leather faces≥ 300 / 1850 / 960338 / 2050 / 1066
Paper / gilt faces≥ 200 / 1780228 / 1976
UV boundsinside 0..1(0.0004, 0.0004)–(0.9996, 0.9996)
UV AABB overlap≤ 1e-50.000000
Outer AABB0.972 × 0.332 × 1.176 m ± 0.0200.9720 × 0.3320 × 1.1760
Collider triangles≤ 20068
Normal bake{'FINISHED'} with image data{'FINISHED'}, has_data=True
glTF exportfile written, non-empty~786 kB
Hygieneall zeroloose 0/0, non-manifold 0, zero-area 0, doubles 0, n-gons 0, coplanar cross-shell pairs 0
Grounded AABB\zmin\≤ 1e-40.00000
Named sides2 sides, each zmin ≤ 1e-42 at 0.00000
Dado4 cross members, each ≥ 5 mm into both sides and ≥ 6 mm clear of the far face8.00 mm in, 14.00 mm clear
Page block38 books, one block and two bands each; every block corner 0.3–1.2 mm inside its boards0.60 mm
Book seat38 books, each 0.5–3.6 mm into the shelf or the book it stands on1.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
Headroomevery book ≥ 20 mm under the member above44.36 mm
Size spreadthickest / thinnest ≥ 2.5; tallest / shortest ≥ 1.52.900 / 1.645
Lean contact2 leaning and 7 lying books; each neighbour's head edge 0.3–2.0 mm behind the leaning board0.80 mm
Book overlaps0, book against book and against the carcass, designed contacts exempt0
Edge treatmentright-angle wood edges0
+

Real-world size: 0.97 m wide over the cornice, 1.18 m tall and 0.33 m deep. Each compartment is 318–348 mm clear, and the books run from a 186 mm octavo to a 305 mm folio.

+

Construction

+
  • Carcass. Two full-height sides carry everything and are the only named supports. The plinth, the base and the two shelves are each housed DADO (8 mm) into both sides and stop 14 mm short of the outer faces. The plinth stands 18 mm back from the base's front and 4 mm off the floor, so only the sides ground. The top is seated over the sides and the back boards; the cornice steps 10 mm out past it and is seated CORN_BITE into it, its back held 4 mm short of the top's back so the two do not share a plane. Every board is chamfered at 2.5 mm with material= pinned to wood, edges sorted by index.
  • Back boards. Five vertical boards, lapped 2 mm into one another and every other one stepped forward 1.5 mm, as on apothecary-shelf.
  • Covers. One shell per book: a U of front board, half-round spine and back board, extruded up the height (SPINE_ARC = 6 segments). The boards are tangent to the spine, so it reads round and the boards flat. Every edge over 40° is chamfered 0.8 mm; the spine facets (30°) are not, and shade smooth.
  • Page blocks. A block inside the U, SQUARE (4 mm) short of the cover at head, tail and fore-edge, biting PAGE_BITE (0.6 mm) into both boards. Its back stops 1.5 mm short of the line where boards meet spine, which is where the band ends lie.
  • Gilt bands. Two per book, built on the cover's own outer chain round the spine: every band vertex sits on a cover vertex's normal, inner face 0.3 mm inside the spine and outer face 0.8 mm proud.
  • Standing books. Set along each shelf with a 2.5–4.5 mm gap, spines a stepped 6–14 mm back from the shelf front, each seated a stepped 1.0–3.3 mm into the shelf. Each stands a degree or two off square, in a four-step cycle (−2.4°, −0.8°, 0.8°, 2.4°).
  • Stacks. Books laid on a board, spine out, yawed ±3° alternately and shifted a few millimetres. The bottom one seats into the shelf; each one above takes a 1.0–1.4 mm seat into the board below it.
  • Leaning books. Turned about Y by 18° (bottom shelf, onto the book on its left) and 22° (middle shelf, onto the book on its right), seated on the shelf by the lowest vertex of the chamfered cover. Then the leaning book (or, leaning right, its neighbour) is slid along the shelf until the neighbour's deepest vertex is LEAN_BITE (0.8 mm) behind the leaning board's outer face. Each leaning book is taller than its neighbour's head divided by the cosine of its lean, so the head edge meets the board and not its top corner.
+

Findings the budgets forced

+
  • Coplanar page blocks and shelves. The first page blocks sat 3 mm short of the cover's tail, and one book seated 2.98 mm into its shelf put its page block's underside 0.02 mm off the shelf top: a coplanar pair. The square is now 4 mm, clear of every seat.
  • Every spine has the same facet angles. A half-round spine of six facets has the same six facet normals on every book. Square to the shelf, two neighbours share a facet plane whenever their offsets happen to line up, and the uniform and crowded falsifiers did so (five and twelve coplanar pairs). Each upright now stands a degree or two off square in a four-step cycle. A half-degree cycle was not enough: on a 45° chamfer the normals differ by half the yaw, inside the 1e-4 parallel tolerance.
  • Planes facing the front. Spine lines, band ends and fore-edges face the shelf front, and two neighbours landed band ends on one plane. Each book is stepped back 0.9 mm at a time until none of its front-facing planes is within 0.4 mm of a neighbour's.
  • A stacked book on a page block's plane. A book stacked on another with the running seat (up to 3.3 mm) could land its underside on the plane of the lower book's page block, 2.2 mm inside its board. A stacked book now takes its own 1.0–1.4 mm seat.
  • Matching parts to books. Page blocks were first matched to the cover nearest their centroid. In a stack, the centroid of a thick book's page block is nearer the board of the book above than its own. Parts are now matched on the mean distance of all their vertices.
  • Deep lean through the board. Measured against the cover solid, a head edge driven 4.5 mm into a 2.8 mm board came out in the hollow of the leaning book and read as no contact, so --deep-lean exited 0. The contact is now measured against the plane of the board's outer face.
  • Black gilt. Fully metallic bands facing the camera mirrored the dark stage and read black except at their edges. The gilt is half metallic.
+

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; square, yaw cycle, front-plane step and stack seat chosen to keep it at 0
Named supportsyesthe two sides (--float-side); the plinth stops 4 mm off the floor
Joint-fit budgetsyesdado bite and far-face clearance per cross member (--short-shelves)
A member is tenoned into its seatyestop over sides and back boards, cornice into the top, back boards into the base
A platform bears on somethingyesevery shelf housed in both sides and biting the back boards
Carried parts bite their bearersyesbooks into shelves and into the book below (--float-books)
A joint bites; touching is not joiningyespage blocks into their boards (--loose-pages); the lean 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 somethingyesthe lean contact, exit 22 (--air-lean, --deep-lean)
Scattered parts do not interpenetrateyesexit 23 (--crowd-books), designed contacts exempt and counted
Scatter varies in sizeyesthickness and height spread, exit 21 (--uniform-books)
The subject hangs where it can be seen / headroomyesevery book under the member above, exit 20 (--tall-book)
Edge treatment: no right anglesyesexit 24 (--sharp-shelf), wood only; covers chamfered 0.8 mm by construction
Aim an edge falsifier at one memberyesone shelf left square: 12 edges
Sort bmesh operator inputsyesbevel edges sorted by index, carcass and covers
A plank wall is boardsyesfive lapped back boards
Identical boards read as CGyesper-board PlankTone and GrainDir; per-book Tint
Variation goes into surface, never into functionyessizes, tints, gaps and yaw vary; shelf pitch and seats are budgets
Shading is part of the modelyesspines smooth (round); every edge over 35° hard, so boards and chamfers read crisp
One substance, one slotyeswood, cloth, leather, paper, gilt
Iron is not chromen/ano iron; gilt is half metallic, roughness 0.45
Bake texels per UV cellmeasured, not a budget1024 px over a 76-cell grid, about 13 px per cell
The bake cage is narrower than the nearest neighbouryesCAGE_EXTRUSION 0.01 m
Level on the stage; stage 60 myesturned about Z only; 60 m floor and wall
Keep a falsifier's envelope stillyesevery falsifier leaves the AABB unchanged
Mirror symmetry, 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 carcass15
--lift-zgrounded zminlifts the whole mesh 50 mm16
--float-sidenamed sideslifts the left side 5 mm; the right side still grounds the AABB16
--short-shelvesdado bitestops the two shelves 4 mm short of the sides; −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; −5.0 mm18
--float-bandsband seatbuilds every band 1.1 mm off its spine; −1.1 mm18
--tall-bookheadroomstretches one middle-shelf folio 1.20 into the shelf above; −3.9 mm20
--uniform-bookssize spreadone height and depth, thickness within ±1.5 %; 1.030 / 1.00021
--air-leanlean contactstops each leaning book 3 mm short of its neighbour; −3.0 mm22
--deep-leanlean contactdrives each leaning book 4.5 mm into its neighbour; 4.5 mm22
--crowd-booksbook overlapscloses the top shelf's gaps to −4.1 mm; 11 overlaps23
--sharp-shelfedge treatmentleaves the top shelf unchamfered; 12 right-angle edges24
+

Exit codes

+

File-local and sequential. 9 is a valid check code. 1 is the FATAL wrapper — a crash, never a named check. 19 (plumb and real-world size) is reserved across pieces and unused here.

+
CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Mesh did not build, or has no UV layer
4Base triangle count outside band
5Material slots, or a material's face floor
6UVs outside 0..1
7UV AABB overlap above tolerance
8Outer AABB off declared size
9LOD1 or LOD2 ratio outside band (--skip-decimate)
10Framing gate (examples/gallery_framing.py, render path only)
11Collider triangles above ceiling
12Normal bake failed or produced no image data
13glTF export missing or empty
14--output produced no file
15Mesh hygiene (--stray-vert)
16Grounded zmin, or a side floating (--lift-z, --float-side)
17Dado or page-block bite, or a book without one block and two bands (--short-shelves, --loose-pages)
18Book or band seat (--float-books, --float-bands)
20Headroom under the member above (--tall-book)
21Size spread below floor (--uniform-books)
22Lean contact, or leaning / lying count (--air-lean, --deep-lean)
23Books overlap each other or the carcass (--crowd-books)
24Right-angle wood edges (--sharp-shelf)
+

Run it

+
# Budget check, no render.
+blender --background --python bookshelf.py --
+
+# Falsifier: each leaning book stops short of its neighbour. Must exit 22.
+blender --background --python bookshelf.py -- --air-lean
+
+# Falsifier: each leaning book cuts into its neighbour. Must exit 22.
+blender --background --python bookshelf.py -- --deep-lean
+
+# Render the gallery still (EEVEE; --engine cycles on a GPU-less host).
+blender --background --python bookshelf.py -- --output bookshelf.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 triangles109081090810908
LOD1 / LOD2 tris5454 / 23985454 / 23985454 / 2388
Face counts (wood / cloth / leather / paper / gilt)338 / 2050 / 1066 / 228 / 1976samesame
Outer AABB0.9720 × 0.3320 × 1.1760samesame
Collider tris686868
Headroom / spread / lean44.36 mm / 2.900, 1.645 / 0.80 mmsamesame
glTF bytes785836785836785828
+

LOD2 differs by ten triangles on 5.2 (DECIMATE COLLAPSE, the known divergence; the gate is a ratio band) and the glTF size by 8 bytes (exporter metadata, not a budget). Every other value is identical.

+
+
+

Source

+
+ showcase/bookshelf/bookshelf.py + View on GitHub → +
+
"""Game-ready bookshelf with leaning books — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural bookcase: a stained carcass
+with two housed shelves, a base on a recessed plinth, lapped back boards
+and a stepped cornice, filled with thirty-eight hardback books. Most
+stand upright; seven lie in three stacks; two lean on a neighbour. 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. Carried through UVs,
+five materials (wood, cloth, leather, paper, gilt), 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 leaning book fails invisibly: it has
+to rest on its neighbour's head edge and on the shelf, not in either. Tip
+it a few degrees too far and its board cuts through the neighbour's
+corner; not far enough and it stands in the air against nothing. Every
+seat, the bounding box, the triangle count and the book-overlap budget
+(which must exempt the designed contact) still pass either way. The piece
+finds each leaning book and its neighbour on the finished mesh and asserts
+how deep the neighbour's head edge sits in the leaning board, as a band.
+
+Budgets are declared below and recomputed from the generated result.
+They are not API-contract witnesses. Each falsifier violates one named
+budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh
+hygiene, ``--lift-z`` grounded zmin, ``--float-side`` the named sides,
+``--short-shelves`` the dado bite, ``--loose-pages`` the page-block bite,
+``--float-books`` the book seat, ``--float-bands`` the band seat,
+``--tall-book`` the headroom, ``--uniform-books`` the size spread,
+``--air-lean`` and ``--deep-lean`` the lean contact, ``--crowd-books``
+book interpenetration, ``--sharp-shelf`` edge treatment.
+
+No randomness: every book's size, tint, gap, setback 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 bookshelf.py --
+    blender --background --python bookshelf.py -- --air-lean
+    blender --background --python bookshelf.py -- --output bookshelf.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Matrix, Vector
+from mathutils.bvhtree import BVHTree
+from mathutils.kdtree import KDTree
+
+_REPO = os.path.abspath(
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing  # noqa: E402
+
+# The carcass. X across, Y front (-) to back (+), Z up. Two full-height
+# sides carry everything; every cross member is housed into both of them.
+W_OUT = 0.920
+SIDE_T = 0.022
+DEPTH = 0.300
+SIDE_H = 1.140
+DADO = 0.008
+TOP_T = 0.026
+TOP_SEAT = 0.006
+TOP_OVER = 0.016
+TOP_BACK_OVER = 0.006
+CORN_T = 0.020
+CORN_OVER = 0.010
+CORN_BITE = 0.004
+CORN_BACK_IN = 0.004
+BACK_T = 0.012
+BACK_INSET = 0.010
+BACK_BITE = 0.005
+N_BACK = 5
+BACK_LAP = 0.002
+BACK_STEP = 0.0015
+SHELF_T = 0.022
+SHELF_SETBACK = 0.004
+SHELF_TOPS = (0.460, 0.800)
+BASE_Z = 0.070
+BASE_T = 0.020
+BASE_SETBACK = 0.004
+PLINTH_Z0 = 0.004
+PLINTH_SET = 0.018
+PLINTH_T = 0.018
+PLINTH_BITE = 0.004
+CHAMFER = 0.0025
+
+# 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. The cover is
+# one shell: a U of board, rounded spine and board, extruded up the
+# height. The page block sits inside it, SQUARE short of the cover at the
+# head, tail and fore-edge, and bites PAGE_BITE into both boards.
+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
+FLOAT_SIDE = 0.005
+START_GAP = 0.006
+GAP_BASE = 0.0025
+GAP_SPAN = 0.0020
+CROWD_GAP = -0.0041
+SET_BASE = 0.006
+SET_STEP = 0.0013
+SET_CLEAR = 0.0004
+SET_NUDGE = 0.0009
+STACK_YAW = math.radians(3.0)
+STACK_SHIFT = 0.006
+LEAN_BITE = 0.0008
+AIR_LEAN = -0.003
+DEEP_LEAN = 0.0045
+TALL_BOOK = 1.20
+UNIFORM_BOOK = (0.034, 0.230, 0.170)
+UNIFORM_JIT = 0.03
+UPRIGHT_YAW = (-2.4, -0.8, 0.8, 2.4)
+
+# Per shelf, left to right. ("up", T, H, D, kind), ("lean", T, H, D, kind,
+# direction, degrees), ("stack", [(T, H, D, kind), ...] bottom first),
+# ("gap", width). kind: "c" cloth, "l" leather. A leaning book leans onto
+# the upright beside it: -1 onto the book on its left, +1 onto the right.
+LAYOUT = (
+    (
+        ("stack", [(0.046, 0.300, 0.228, "l"), (0.036, 0.285, 0.214, "c")]),
+        ("gap", 0.014),
+        ("up", 0.052, 0.305, 0.226, "l"),
+        ("up", 0.034, 0.282, 0.205, "c"),
+        ("up", 0.041, 0.291, 0.214, "c"),
+        ("up", 0.058, 0.268, 0.198, "l"),
+        ("up", 0.030, 0.296, 0.221, "c"),
+        ("up", 0.044, 0.262, 0.192, "c"),
+        ("up", 0.037, 0.276, 0.207, "l"),
+        ("up", 0.049, 0.255, 0.188, "c"),
+        ("lean", 0.036, 0.300, 0.218, "c", -1, 18.0),
+    ),
+    (
+        ("lean", 0.032, 0.268, 0.196, "l", 1, 22.0),
+        ("up", 0.040, 0.228, 0.170, "c"),
+        ("up", 0.026, 0.251, 0.183, "c"),
+        ("up", 0.047, 0.262, 0.194, "l"),
+        ("up", 0.022, 0.214, 0.158, "c"),
+        ("up", 0.035, 0.240, 0.176, "c"),
+        ("up", 0.055, 0.270, 0.201, "l"),
+        ("up", 0.029, 0.233, 0.172, "c"),
+        ("up", 0.038, 0.246, 0.180, "c"),
+        ("up", 0.024, 0.221, 0.163, "l"),
+        ("gap", 0.050),
+        ("stack", [(0.034, 0.232, 0.170, "c"), (0.028, 0.214, 0.160, "l"),
+                   (0.022, 0.196, 0.148, "c")]),
+    ),
+    (
+        ("up", 0.031, 0.236, 0.172, "c"),
+        ("up", 0.024, 0.212, 0.156, "c"),
+        ("up", 0.043, 0.254, 0.187, "l"),
+        ("up", 0.020, 0.198, 0.146, "c"),
+        ("up", 0.036, 0.226, 0.166, "c"),
+        ("up", 0.028, 0.243, 0.178, "l"),
+        ("up", 0.045, 0.265, 0.195, "c"),
+        ("up", 0.022, 0.205, 0.151, "c"),
+        ("up", 0.033, 0.219, 0.161, "l"),
+        ("up", 0.039, 0.248, 0.182, "c"),
+        ("up", 0.026, 0.186, 0.140, "c"),
+        ("up", 0.030, 0.231, 0.170, "c"),
+        ("gap", 0.080),
+        ("stack", [(0.030, 0.226, 0.166, "l"), (0.024, 0.200, 0.150, "c")]),
+    ),
+)
+TALL_TARGET = (1, 6)  # the 0.270 m leather volume on the middle shelf
+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.972, 0.332, 1.176)
+
+BASE_TRIS_MIN = 10400
+BASE_TRIS_MAX = 11400
+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 = 5
+FACE_FLOORS = {0: 300, 1: 1850, 2: 960, 3: 200, 4: 1780}
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 200
+BAKE_RES = 1024
+CAGE_EXTRUSION = 0.01
+ZMIN_EPS = 1e-4
+DOUBLES_EPS = 1e-5
+AREA_EPS = 1e-10
+COPLANAR_NORMAL_EPS = 1e-4
+COPLANAR_PLANE_EPS = 1e-4
+COPLANAR_CENTRE_MAX = 0.05
+LIFT_Z = 0.05
+SIDE_Z_MAX = 1e-4
+DADO_MIN = 0.005
+DADO_FAR_MIN = 0.006
+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
+HEADROOM_MIN = 0.020
+THICK_SPREAD_MIN = 2.5
+HEIGHT_SPREAD_MIN = 1.5
+UPRIGHT_TILT_MAX = math.radians(3.0)
+LYING_TILT_MIN = math.radians(80.0)
+LEAN_BITE_MIN = 0.0003
+LEAN_BITE_MAX = 0.0020
+LEAN_SEARCH = 0.03
+RIGHT_ANGLE_TOL = math.radians(5.0)
+
+WOOD_IDX = 0
+CLOTH_IDX = 1
+LEATHER_IDX = 2
+PAPER_IDX = 3
+GILT_IDX = 4
+N_CROSS = len(SHELF_TOPS) + 2
+N_BOOKS = sum(len(it[1]) if it[0] == "stack" else (0 if it[0] == "gap" else 1)
+              for row in LAYOUT for it in row)
+N_LEAN = sum(1 for row in LAYOUT for it in row if it[0] == "lean")
+N_LYING = sum(len(it[1]) for row in LAYOUT for it in row if it[0] == "stack")
+
+
+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 ---------------------------------------------
+
+
+def spine_chain(t, d):
+    """Centre line of the cover's U and its outward normal at each point.
+
+    Front board from the fore-edge to the spine, a half-round spine of
+    SPINE_ARC segments, back board to the fore-edge. The boards are tangent
+    to the spine, so the spine reads round and the boards flat.
+    """
+    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. The cover is chamfered here, in its own
+    bmesh, so the placement code sees the finished corners it will rest on.
+    """
+    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]))
+        # The bevel picks edges by face angle, which needs consistent normals.
+        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])
+
+        # Page block: inside the U, biting both boards, square short of the
+        # cover at head, tail and fore-edge. Its back stops PAGE_BACK short of
+        # the line where the boards meet the spine, where the band ends lie.
+        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
+
+        # Gilt bands round the spine, built on the cover's own outer chain:
+        # every band vertex sits on a cover vertex's normal, so the band
+        # follows the round of the spine exactly.
+        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):
+    t, h, d, kind = 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 lean_face(book, side):
+    """Point and outward normal of a leaning book's board face on ``side``."""
+    rot = book["M"].to_3x3()
+    n = (rot @ Vector((float(side), 0.0, 0.0))).normalized()
+    return book["M"] @ Vector((side * book["t"] / 2.0, 0.0, 0.0)), n
+
+
+def penetration(pts, p0, n):
+    """How far the deepest of ``pts`` sits behind the plane (p0, n)."""
+    return max(-(p - p0).dot(n) for p in pts)
+
+
+def plan_books(loose_pages=False, float_books=False, float_bands=False, tall_book=False,
+               uniform_books=False, crowd_books=False, air_lean=False, deep_lean=False):
+    """Place every book. Closed-form: sizes from LAYOUT, the rest from the index.
+
+    Each book is built, turned, then set down: its lowest cover vertex a
+    stepped seat into whatever it stands on, its spine a stepped setback
+    from the shelf front, and along the shelf at the running cursor. A
+    leaning book is then slid (or its neighbour is) until the neighbour's
+    head edge sits the lean bite into the leaning board.
+    """
+    x_in = W_OUT / 2.0 - SIDE_T
+    y_front = -DEPTH / 2.0 + SHELF_SETBACK
+    tops = (BASE_Z + BASE_T,) + SHELF_TOPS
+    bite = LEAN_BITE + (AIR_LEAN - LEAN_BITE if air_lean else 0.0) + (
+        DEEP_LEAN - LEAN_BITE if deep_lean else 0.0)
+    lift = FLOAT_BOOKS if float_books else 0.0
+    books = []
+    for s, row in enumerate(LAYOUT):
+        cursor = -x_in + START_GAP
+        k = 0
+        placed = []
+        pending = None
+
+        def sized(dims, s=s, ii=None, j=0):
+            t, h, d, kind = dims
+            if uniform_books:
+                # One height and depth; thickness within +-1.5 %, so no two
+                # spines are the same solid set a hair apart.
+                n = s * 100 + (ii or 0) * 5 + j
+                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]
+            if tall_book and (s, ii) == TALL_TARGET:
+                h *= TALL_BOOK
+            return (t, h, d, kind)
+
+        def gap_of(k, s=s):
+            if crowd_books and s == 2:
+                return CROWD_GAP
+            return GAP_BASE + GAP_SPAN * frac(k * 0.6180339887 + 0.3 + s * 0.17)
+
+        def y_keys(book):
+            lo, hi = bounds(cover_points(book))
+            yc = lo.y + book["t"] / 2.0
+            return (lo.x + hi.x) / 2.0, (lo.y, yc, yc + PAGE_BACK, hi.y - SQUARE, hi.y)
+
+        def set_down(book, k, x_left, support_top, seat_k=None):
+            lo, hi = bounds(cover_points(book))
+            seat = SEAT_BASE + SEAT_STEP * (k if seat_k is None else seat_k)
+            move(book, Vector((x_left - lo.x,
+                               y_front + SET_BASE + SET_STEP * (k % 7) - lo.y,
+                               support_top - seat - lo.z)))
+            # Spine fronts, spine lines and fore-edges are planes facing Y.
+            # Two neighbours landing one on the same plane z-fight, so step
+            # this book back until none of its planes meets a neighbour's.
+            for _ in range(8):
+                cx, keys = y_keys(book)
+                near = [y_keys(o)[1] for o in placed if abs(y_keys(o)[0] - cx) < 0.12]
+                if not any(abs(a - b) < SET_CLEAR for ks in near for a in keys for b in ks):
+                    break
+                move(book, Vector((0.0, SET_NUDGE, 0.0)))
+
+        for ii, item in enumerate(row):
+            tag = item[0]
+            if tag == "gap":
+                cursor += item[1]
+                continue
+            if tag == "up":
+                # Upright books stand a degree or so off square, in a
+                # four-step cycle. Every spine has the same facet angles, so
+                # square neighbours share planes by accident whenever their
+                # offsets line up; a degree apart, no two faces are parallel.
+                yaw = math.radians(UPRIGHT_YAW[k % len(UPRIGHT_YAW)])
+                b = make_book(sized(item[1:5], ii=ii), Matrix.Rotation(yaw, 4, "Z"),
+                              loose_pages, float_bands)
+                set_down(b, k, cursor + gap_of(k), tops[s] + lift)
+                if pending is not None:
+                    p0, n = lean_face(pending, 1)
+                    pen = penetration(cover_points(b), p0, n)
+                    move(b, Vector(((pen - bite) / n.x, 0.0, 0.0)))
+                    b["lean_on"] = pending
+                    pending = None
+            elif tag == "lean":
+                side, deg = item[5], item[6]
+                rot = Matrix.Rotation(side * math.radians(deg), 4, "Y")
+                b = make_book(sized(item[1:5], ii=ii), rot, loose_pages, float_bands)
+                set_down(b, k, cursor + gap_of(k), tops[s] + lift)
+                if side < 0:
+                    nb = placed[-1]
+                    p0, n = lean_face(b, -1)
+                    pen = penetration(cover_points(nb), p0, n)
+                    move(b, Vector(((bite - pen) / n.x, 0.0, 0.0)))
+                    b["lean_on"] = nb
+                else:
+                    pending = b
+            else:
+                stack_top = tops[s] + lift
+                centre = None
+                stack = []
+                for j, dims in enumerate(item[1]):
+                    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, ii=ii, j=j), rot, loose_pages, float_bands)
+                    lo, hi = bounds(cover_points(b))
+                    if centre is None:
+                        centre = cursor + gap_of(k) + (hi.x - lo.x) / 2.0
+                    shift = STACK_SHIFT * (0.5 - frac(j * 0.7548776662 + 0.2)) * 2.0
+                    # A book stacked 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.
+                    set_down(b, k, centre + shift - (hi.x - lo.x) / 2.0, stack_top,
+                             seat_k=(j if j else None))
+                    stack_top = bounds(cover_points(b))[1].z
+                    b.update({"shelf": s, "i": len(books)})
+                    books.append(b)
+                    stack.append(b)
+                    placed.append(b)
+                    k += 1
+                cursor = max(bounds(cover_points(x))[1].x for x in stack)
+                continue
+            b.update({"shelf": s, "i": len(books)})
+            books.append(b)
+            placed.append(b)
+            cursor = bounds(cover_points(b))[1].x
+            k += 1
+    return books
+
+
+# --- construction -----------------------------------------------------------
+
+
+def add_board(bm, lo, hi, boards, sharp=False):
+    """Axis-aligned board from corner ``lo`` to ``hi``; its verts go in ``boards``."""
+    geo = bmesh.ops.create_cube(bm, size=1.0)
+    c = [(lo[k] + hi[k]) * 0.5 for k in range(3)]
+    s = [hi[k] - lo[k] for k in range(3)]
+    for v in geo["verts"]:
+        v.co = Vector((c[0] + v.co.x * s[0], c[1] + v.co.y * s[1], c[2] + v.co.z * s[2]))
+    for f in {f for v in geo["verts"] for f in v.link_faces}:
+        f.material_index = WOOD_IDX
+    if not sharp:
+        boards.extend(geo["verts"])
+    return geo["verts"]
+
+
+def build_bookshelf_mesh(
+    name,
+    stray_vert=False,
+    float_side=False,
+    short_shelves=False,
+    sharp_shelf=False,
+    **book_flags,
+):
+    x_out = W_OUT / 2.0
+    x_in = x_out - SIDE_T
+    y0, y1 = -DEPTH / 2.0, DEPTH / 2.0
+    top_z0 = SIDE_H - TOP_SEAT
+    top_z1 = top_z0 + TOP_T
+    back_y1 = y1 - BACK_INSET
+    back_y0 = back_y1 - BACK_T
+    bm = bmesh.new()
+    try:
+        uv = bm.loops.layers.uv.new("UVMap")
+        piece = bm.faces.layers.int.new("Piece")
+        boards = []
+        # Sides: the two named supports.
+        for sx in (-1.0, 1.0):
+            lift = FLOAT_SIDE if (float_side and sx < 0) else 0.0
+            add_board(bm, (min(sx * x_out, sx * x_in), y0, lift),
+                      (max(sx * x_out, sx * x_in), y1, SIDE_H + lift), boards)
+        add_board(bm, (-x_out - TOP_OVER, y0 - TOP_OVER, top_z0),
+                  (x_out + TOP_OVER, y1 + TOP_BACK_OVER, top_z1), boards)
+        # Cornice: a wider board seated on the top, stepping out past it.
+        # Its back stops short of the top's back, or the two share a plane.
+        add_board(bm, (-x_out - TOP_OVER - CORN_OVER, y0 - TOP_OVER - CORN_OVER,
+                       top_z1 - CORN_BITE),
+                  (x_out + TOP_OVER + CORN_OVER, y1 + TOP_BACK_OVER - CORN_BACK_IN,
+                   top_z1 - CORN_BITE + CORN_T), boards)
+        # Back boards, housed into the sides and the top, standing in the
+        # base; lapped, every other one stepped forward so no lap is a plane.
+        span0, span1 = -x_in - BACK_BITE, x_in + BACK_BITE
+        bw = (span1 - span0 + BACK_LAP * (N_BACK - 1)) / N_BACK
+        for k in range(N_BACK):
+            bx = span0 + k * (bw - BACK_LAP)
+            step = BACK_STEP if k % 2 else 0.0
+            add_board(bm, (bx, back_y0 - step, BASE_Z + BASE_T - BACK_BITE),
+                      (bx + bw, back_y1 - step, top_z0 + BACK_BITE), boards)
+        # Cross members: plinth, base and shelves, each in a dado in both sides.
+        add_board(bm, (-(x_in + DADO), y0 + PLINTH_SET, PLINTH_Z0),
+                  (x_in + DADO, y0 + PLINTH_SET + PLINTH_T, BASE_Z + PLINTH_BITE), boards)
+        add_board(bm, (-(x_in + DADO), y0 + BASE_SETBACK, BASE_Z),
+                  (x_in + DADO, back_y0 + BACK_BITE, BASE_Z + BASE_T), boards)
+        reach = x_in - 0.004 if short_shelves else x_in + DADO
+        for s, zt in enumerate(SHELF_TOPS):
+            add_board(bm, (-reach, y0 + SHELF_SETBACK, zt - SHELF_T),
+                      (reach, back_y0 + BACK_BITE, zt), boards,
+                      sharp=(sharp_shelf and s == len(SHELF_TOPS) - 1))
+        # 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()
+        edges = sorted({e for v in boards for e in v.link_edges
+                        if e.calc_face_angle(0.0) > math.radians(20.0)}, key=lambda e: e.index)
+        bmesh.ops.bevel(bm, geom=edges, offset=CHAMFER, segments=1, profile=0.5,
+                        affect="EDGES", clamp_overlap=True, material=WOOD_IDX)
+        bmesh.ops.triangulate(bm, faces=[f for f in bm.faces if len(f.verts) > 4])
+
+        books = plan_books(**book_flags)
+        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, 0.62))
+        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.
+
+    Every carcass board gets a closed-form tone and its long axis as grain.
+    Every book gets a cloth or leather tint from its index, and its page
+    block the book's own thickness axis, so the page lines run parallel to
+    the boards however the book is turned. The ``Piece`` tag is then dropped.
+    """
+    npoly = len(me.polygons)
+    piece = [0] * npoly
+    me.attributes["Piece"].data.foreach_get("value", piece)
+    mats = [0] * npoly
+    me.polygons.foreach_get("material_index", mats)
+    tone = [0.5] * npoly
+    grain = [(1.0, 0.0, 0.0)] * npoly
+    tint = [(0.5, 0.5, 0.5, 1.0)] * npoly
+    vf = [[] for _ in range(len(me.vertices))]
+    for p in me.polygons:
+        for i in p.vertices:
+            vf[i].append(p.index)
+    for k, g in enumerate(shells(me)):
+        faces = {fi for i in g for fi in vf[i]}
+        if not faces or mats[next(iter(faces))] != WOOD_IDX:
+            continue
+        pts = [me.vertices[i].co for i in g]
+        ext = [max(p[a] for p in pts) - min(p[a] for p in pts) for a in range(3)]
+        axis = ext.index(max(ext))
+        d = tuple(1.0 if a == axis else 0.0 for a in range(3))
+        t = 0.5 + 0.30 * (frac(k * 0.6180339887 + 0.3) - 0.5)
+        for fi in faces:
+            tone[fi] = t
+            grain[fi] = d
+    for fi in range(npoly):
+        v = piece[fi] - 1
+        if v < 0:
+            continue
+        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 timber: grain along each board, tone per board (copied from crate-stack)."""
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    coord = nt.nodes.new("ShaderNodeTexCoord")
+    gdir = nt.nodes.new("ShaderNodeAttribute")
+    gdir.attribute_name = "GrainDir"
+    tone = nt.nodes.new("ShaderNodeAttribute")
+    tone.attribute_name = "PlankTone"
+    dot = nt.nodes.new("ShaderNodeVectorMath")
+    dot.operation = "DOT_PRODUCT"
+    nt.links.new(coord.outputs["Object"], dot.inputs[0])
+    nt.links.new(gdir.outputs["Vector"], dot.inputs[1])
+    squash = nt.nodes.new("ShaderNodeMath")
+    squash.operation = "MULTIPLY"
+    squash.inputs[1].default_value = 0.94
+    nt.links.new(dot.outputs["Value"], squash.inputs[0])
+    along = nt.nodes.new("ShaderNodeVectorMath")
+    along.operation = "SCALE"
+    nt.links.new(gdir.outputs["Vector"], along.inputs[0])
+    nt.links.new(squash.outputs["Value"], along.inputs["Scale"])
+    grain_co = nt.nodes.new("ShaderNodeVectorMath")
+    grain_co.operation = "SUBTRACT"
+    nt.links.new(coord.outputs["Object"], grain_co.inputs[0])
+    nt.links.new(along.outputs["Vector"], grain_co.inputs[1])
+    shift = nt.nodes.new("ShaderNodeVectorMath")
+    shift.operation = "ADD"
+    nt.links.new(grain_co.outputs["Vector"], shift.inputs[0])
+    nt.links.new(tone.outputs["Fac"], shift.inputs[1])
+    noise = nt.nodes.new("ShaderNodeTexNoise")
+    noise.inputs["Scale"].default_value = 34.0
+    noise.inputs["Detail"].default_value = 6.0
+    noise.inputs["Roughness"].default_value = 0.62
+    nt.links.new(shift.outputs["Vector"], noise.inputs["Vector"])
+    ramp = nt.nodes.new("ShaderNodeValToRGB")
+    ramp.color_ramp.elements[0].position = 0.30
+    ramp.color_ramp.elements[0].color = (0.070, 0.031, 0.013, 1.0)
+    ramp.color_ramp.elements[1].position = 0.72
+    ramp.color_ramp.elements[1].color = (0.21, 0.10, 0.042, 1.0)
+    nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
+    gain = nt.nodes.new("ShaderNodeMath")
+    gain.operation = "MULTIPLY_ADD"
+    gain.inputs[1].default_value = 1.2
+    gain.inputs[2].default_value = 0.40
+    nt.links.new(tone.outputs["Fac"], gain.inputs[0])
+    mix = nt.nodes.new("ShaderNodeMix")
+    mix.data_type = "RGBA"
+    mix.blend_type = "MULTIPLY"
+    _sock(mix.inputs, "Factor_Float").default_value = 1.0
+    nt.links.new(ramp.outputs["Color"], _sock(mix.inputs, "A_Color"))
+    nt.links.new(gain.outputs["Value"], _sock(mix.inputs, "B_Color"))
+    nt.links.new(_sock(mix.outputs, "Result_Color"), bsdf.inputs["Base Color"])
+    rough = nt.nodes.new("ShaderNodeMapRange")
+    rough.inputs["To Min"].default_value = 0.66
+    rough.inputs["To Max"].default_value = 0.48
+    nt.links.new(noise.outputs["Fac"], rough.inputs["Value"])
+    nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"])
+    return mat
+
+
+def 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, 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"])
+    # Half metallic and fairly rough: a fully metallic band on a spine facing
+    # the camera mirrors the dark stage and reads black.
+    bsdf.inputs["Metallic"].default_value = 0.55
+    bsdf.inputs["Roughness"].default_value = 0.45
+    return mat
+
+
+def bookshelf_materials():
+    return (
+        wood_material("BookshelfWood"),
+        cover_material("BookCloth", 420.0, 0.78, 0.86),
+        cover_material("BookLeather", 70.0, 0.62, 0.48),
+        paper_material("BookPages"),
+        gilt_material("BookGilt"),
+    )
+
+
+def assign_slots(obj, mats):
+    slots = obj.data.materials
+    for i, mat in enumerate(mats):
+        if i < len(slots):
+            slots[i] = mat
+        else:
+            slots.append(mat)
+
+
+# --- measurement ------------------------------------------------------------
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs, ys, zs = [c.x for c in corners], [c.y for c in corners], [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0.0, 0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    span = max(1e-6, max(a[2] - a[0] for a in aabbs), max(a[3] - a[1] for a in aabbs))
+    buckets = {}
+    for i, a in enumerate(aabbs):
+        for c in range(int(a[0] // span), int(a[2] // span) + 1):
+            for r in range(int(a[1] // span), int(a[3] // span) + 1):
+                buckets.setdefault((c, r), []).append(i)
+    overlap = 0.0
+    seen = set()
+    for members in buckets.values():
+        for ii in range(len(members)):
+            for jj in range(ii + 1, len(members)):
+                i, j = members[ii], members[jj]
+                key = (i, j) if i < j else (j, i)
+                if key in seen:
+                    continue
+                seen.add(key)
+                a, b = aabbs[i], aabbs[j]
+                overlap += max(0.0, min(a[2], b[2]) - max(a[0], b[0])) * max(
+                    0.0, min(a[3], b[3]) - max(a[1], b[1]))
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def face_area(me, poly):
+    idxs = poly.vertices
+    v0 = me.vertices[idxs[0]].co
+    area = 0.0
+    for i in range(1, len(idxs) - 1):
+        area += (me.vertices[idxs[i]].co - v0).cross(me.vertices[idxs[i + 1]].co - v0).length * 0.5
+    return area
+
+
+def hygiene_audit(me):
+    ngons = sum(1 for p in me.polygons if len(p.vertices) > 4)
+    zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(me)
+        loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0)
+        loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0)
+        nonman = sum(1 for e in bm.edges if not e.is_manifold)
+        ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS)
+        doubles = len(ret.get("targetmap") or {})
+    finally:
+        bm.free()
+    return {"ngons": ngons, "loose_v": loose_v, "loose_e": loose_e,
+            "nonman": nonman, "zero_area": zero_area, "doubles": doubles}
+
+
+def shells(me):
+    neighbors = [[] for _ in range(len(me.vertices))]
+    for edge in me.edges:
+        a, b = edge.vertices
+        neighbors[a].append(b)
+        neighbors[b].append(a)
+    seen = [False] * len(me.vertices)
+    groups = []
+    for start in range(len(me.vertices)):
+        if seen[start]:
+            continue
+        seen[start] = True
+        stack = [start]
+        group = []
+        while stack:
+            cur = stack.pop()
+            group.append(cur)
+            for nxt in neighbors[cur]:
+                if not seen[nxt]:
+                    seen[nxt] = True
+                    stack.append(nxt)
+        groups.append(group)
+    return groups
+
+
+def zfight_pairs(me):
+    """Coplanar face pairs from *different shells* (copied from showcase/grindstone)."""
+    owner = {}
+    for si, g in enumerate(shells(me)):
+        for vi in g:
+            owner[vi] = si
+    faces = [(p.normal.copy(), p.center.copy(), owner.get(p.vertices[0], -1))
+             for p in me.polygons]
+    kd = KDTree(len(faces))
+    for i, (_n, c, _s) in enumerate(faces):
+        kd.insert(c, i)
+    kd.balance()
+    hits = 0
+    for i, (ni, ci, si) in enumerate(faces):
+        for _co, j, _d in kd.find_range(ci, COPLANAR_CENTRE_MAX):
+            if j <= i:
+                continue
+            nj, cj, sj = faces[j]
+            if si == sj:
+                continue
+            if abs(abs(ni.dot(nj)) - 1.0) > COPLANAR_NORMAL_EPS:
+                continue
+            if abs(ni.dot(cj - ci)) > COPLANAR_PLANE_EPS:
+                continue
+            hits += 1
+    return hits
+
+
+def right_angle_edges(me):
+    """Manifold edges between two wood faces that meet within 5 degrees of 90."""
+    count = 0
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(me)
+        for e in bm.edges:
+            if len(e.link_faces) != 2:
+                continue
+            if any(f.material_index != WOOD_IDX for f in e.link_faces):
+                continue
+            if abs(e.calc_face_angle(0.0) - math.pi / 2.0) <= RIGHT_ANGLE_TOL:
+                count += 1
+    finally:
+        bm.free()
+    return count
+
+
+def shell_tree(me, groups):
+    member = set(i for g in groups for i in g)
+    order = sorted(member)
+    remap = {v: k for k, v in enumerate(order)}
+    polys = [[remap[i] for i in p.vertices] for p in me.polygons if p.vertices[0] in member]
+    return BVHTree.FromPolygons([me.vertices[i].co.copy() for i in order], polys)
+
+
+def 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
+    big, polys = {}, {}
+    for p in me.polygons:
+        si = owner[p.vertices[0]]
+        polys.setdefault(si, []).append(p)
+        if si not in big or p.area > big[si].area:
+            big[si] = p
+    out = {k: [] for k in ("side", "top", "cornice", "back", "cross", "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
+        rec = {"g": g, "pts": pts, "lo": lo, "hi": hi, "ext": ext,
+               "c": sum(pts, Vector()) / len(pts)}
+        m = mats.get(g[0], -1)
+        if m == WOOD_IDX:
+            if ext.x < 0.05 and ext.z > 0.9:
+                out["side"].append(rec)
+            elif ext.y < 0.03 and ext.z > 0.9:
+                out["back"].append(rec)
+            elif lo.z > SIDE_H:
+                out["cornice"].append(rec)
+            elif hi.z > SIDE_H:
+                out["top"].append(rec)
+            elif ext.x > 0.7:
+                out["cross"].append(rec)
+            else:
+                out["other"].append(rec)
+        elif m in (CLOTH_IDX, LEATHER_IDX):
+            face = big[si]
+            rec["n"] = face.normal.copy()
+            # The two boards' outer faces: the largest faces, one per side.
+            ranked = sorted(polys[si], key=lambda p: -p.area)[:4]
+            rec["boards"] = [(p.center.copy(), p.normal.copy()) for p in ranked
+                             if abs(abs(p.normal.dot(face.normal)) - 1.0) < 1e-3]
+            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 bookshelf_audit(me):
+    parts = classify(me)
+    out = {k: len(v) for k, v in parts.items()}
+    sides = sorted(parts["side"], key=lambda r: r["c"].x)
+    out["side_z"] = max((r["lo"].z for r in sides), default=99.0)
+
+    # Every cross member is housed in both sides: bite past the inner face,
+    # and clear of the far face.
+    bite, far = 99.0, 99.0
+    if len(sides) == 2:
+        left_in, left_out = sides[0]["hi"].x, sides[0]["lo"].x
+        right_in, right_out = sides[1]["lo"].x, sides[1]["hi"].x
+        for r in parts["cross"]:
+            bite = min(bite, left_in - r["lo"].x, r["hi"].x - right_in)
+            far = min(far, r["lo"].x - left_out, right_out - r["hi"].x)
+    out["dado"], out["dado_far"] = bite, far
+
+    # 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]:
+            # The cover every vertex of the part sits closest to, on average:
+            # a page block's corners are in its own boards, a band on its own
+            # spine. A centroid alone is nearer a neighbour's board in a stack.
+            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)
+
+    # Pages glued in: every page-block corner inside the cover's boards.
+    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))
+
+    # Bands on the spine: inner face inside the cover, outer face proud.
+    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: upright, lying or leaning.
+    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 "lean")
+        c["tilt"] = tilt
+        ds = [p.dot(n) for p in c["pts"]]
+        c["thick"] = max(ds) - min(ds)
+    out["n_lean"] = sum(1 for u in units if u["cover"]["pose"] == "lean")
+    out["n_lying"] = sum(1 for u in units if u["cover"]["pose"] == "lying")
+
+    # Seat: what each book stands on is the highest shelf or cover under its
+    # footprint whose top is within reach of its lowest point.
+    shelves = [r for r in parts["cross"] if r["ext"].y > 0.15]
+    horizontals = shelves + parts["top"]
+    seats = []
+    for u in units:
+        c = u["cover"]
+        cands = [r for r in shelves + covers if r is not c and overlaps_xy(r, c)
+                 and c["lo"].z - 0.02 <= 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)
+
+    # Headroom: the top of each book (bands included) under the member above.
+    heads = []
+    for u in units:
+        c = u["cover"]
+        top = max([c["hi"].z] + [r["hi"].z for r in u["band"]])
+        above = [h for h in horizontals if h["lo"].z > c["lo"].z + 0.05]
+        if above:
+            heads.append(min(h["lo"].z for h in above) - top)
+    out["headroom"] = min(heads, default=-99.0)
+
+    # 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
+
+    # Lean contact: the neighbour's head edge in the leaning board, as a band.
+    leans = []
+    for u in units:
+        c = u["cover"]
+        if c["pose"] != "lean":
+            continue
+        n = c["n"] if c["n"].x > 0 else -c["n"]
+        up_x = -n.z  # the board's up direction, in the XZ plane, has this x
+        side = 1.0 if up_x > 0 else -1.0
+        sup = u.get("support")
+        mates = [v for v in units if v["cover"]["pose"] == "up" and v.get("support") is sup
+                 and (v["cover"]["c"].x - c["c"].x) * side > 0]
+        if not mates:
+            leans.append(-99.0)
+            continue
+        mate = min(mates, key=lambda v: abs(v["cover"]["c"].x - c["c"].x))
+        u["lean_on"] = mate
+        # Depth past the plane of the leaning board's outer face, read off the
+        # mesh, over the neighbour's vertices near that board. A plane, not
+        # the cover solid: the board is 2.8 mm thick, and a head edge driven
+        # right through it is in the hollow, outside the cover again.
+        to_mate = mate["cover"]["c"] - c["c"]
+        facing = [b for b in c["boards"] if b[1].dot(to_mate) > 0.0]
+        if not facing:
+            leans.append(-99.0)
+            continue
+        p0, n = max(facing, key=lambda b: b[0].dot(b[1]))
+        ds = [-(p - p0).dot(n) for p in mate["cover"]["pts"]
+              if u["tree"].find_nearest(p, LEAN_SEARCH)[0] is not None]
+        leans.append(max(ds, default=-99.0))
+    out["lean"] = (min(leans, default=-99.0), max(leans, default=99.0))
+
+    # 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 leaning book
+    # on its neighbour, a book in the shelf it stands on.
+    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 ("side", "back", "cross", "top", "cornice") 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"] or u.get("lean_on") is v:
+                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"):
+                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; 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) == WOOD_IDX]
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        vs = [bm.verts.new(p) for p in pts]
+        ret = bmesh.ops.convex_hull(bm, input=vs)
+        drop = []
+        for v in ret["geom_interior"] + ret["geom_unused"]:
+            if isinstance(v, bmesh.types.BMVert) and v not in drop:
+                drop.append(v)
+        bmesh.ops.delete(bm, geom=drop, context="VERTS")
+        bmesh.ops.dissolve_limit(bm, angle_limit=math.radians(1.0),
+                                 verts=list(bm.verts), edges=list(bm.edges))
+        bmesh.ops.triangulate(bm, faces=list(bm.faces))
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    col = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(col)
+    return col
+
+
+def setup_bake_image(obj, target_mat, size):
+    img = bpy.data.images.new("BookshelfNrm", 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_bookshelf_mesh("BookshelfLow", **flags)
+    hi_flags = {k: v for k, v in flags.items() if k != "stray_vert"}
+    high = build_bookshelf_mesh("BookshelfHigh", **hi_flags)
+    mats = bookshelf_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("bookshelf 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, "BookshelfLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "BookshelfLOD2", 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, "BookshelfCollider")
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(tempfile.gettempdir(), f"bdt_bookshelf_{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 = bookshelf_audit(low.data)
+    right = right_angle_edges(low.data)
+
+    print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
+    print(f"measured mat_index_counts={dict(sorted(idx_counts.items()))}")
+    print(f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+          f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}")
+    print(f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+          f"overlap={overlap:.6f} nfaces={nfaces}")
+    print(f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+          f"outer={OUTER_SIZE} zmin={bb[2]:.5f}")
+    print(f"measured collider_tris={col_tris} bake={bake_result} "
+          f"bake_has_data={img.has_data} export_bytes={export_size}")
+    print(f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} "
+          f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} "
+          f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}")
+    print(f"measured parts sides={sa['side']} top={sa['top']} cornice={sa['cornice']} "
+          f"back={sa['back']} cross={sa['cross']} covers={sa['cover']} pages={sa['pages']} "
+          f"bands={sa['band']} other={sa['other']} unmatched={sa['unmatched']} "
+          f"lean={sa['n_lean']} lying={sa['n_lying']} side_z={sa['side_z']:.5f}")
+    print(f"measured joints dado={sa['dado']:.5f} 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}")
+    print(f"measured headroom={sa['headroom']:.5f} thick_spread={sa['thick_spread']:.3f} "
+          f"height_spread={sa['height_spread']:.3f} "
+          f"lean=({sa['lean'][0]:.5f},{sa['lean'][1]:.5f}) book_hits={sa['book_hits']} "
+          f"right_angle_wood={right}")
+
+    if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+        return (fail(f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", 4),) + nothing
+    if nmat != MATERIAL_COUNT or distinct != MATERIAL_COUNT:
+        return (fail(f"material slots {nmat} distinct {distinct} != {MATERIAL_COUNT}", 5),) + nothing
+    for idx, floor in FACE_FLOORS.items():
+        if idx_counts.get(idx, 0) < floor:
+            return (fail(f"material {idx} faces {idx_counts.get(idx, 0)} < {floor}", 5),) + nothing
+    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+        return (fail(f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", 6),) + nothing
+    if overlap > UV_OVERLAP_MAX:
+        return (fail(f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7),) + nothing
+    if (abs(size_x - OUTER_SIZE[0]) > BBOX_TOL or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+            or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL):
+        return (fail(f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) off outer {OUTER_SIZE}", 8),) + nothing
+    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+        return (fail(f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+                     "(--skip-decimate is the designed fail)", 9),) + nothing
+    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+        return (fail(f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", 9),) + nothing
+    if col_tris > COLLIDER_TRIS_MAX:
+        return (fail(f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", 11),) + nothing
+    if bake_result != {"FINISHED"} or not img.has_data:
+        return (fail(f"bake failed result={bake_result} has_data={img.has_data}", 12),) + nothing
+    if export_size <= 0:
+        return (fail("export file missing or empty", 13),) + nothing
+    if (hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"]
+            or hyg["doubles"] or hyg["ngons"] or zf):
+        return (fail(f"hygiene {hyg} zfight={zf} (--stray-vert is the designed fail)", 15),) + nothing
+    if abs(bb[2]) > ZMIN_EPS:
+        return (fail(f"zmin {bb[2]:.6f} not within {ZMIN_EPS} of 0 "
+                     "(--lift-z is the designed fail)", 16),) + nothing
+    if sa["side"] != 2 or sa["side_z"] > SIDE_Z_MAX:
+        return (fail(f"sides: {sa['side']} of 2, worst side z={sa['side_z']:.5f} > {SIDE_Z_MAX} "
+                     "(--float-side is the designed fail)", 16),) + nothing
+    if sa["cross"] != N_CROSS or sa["dado"] < DADO_MIN or sa["dado_far"] < DADO_FAR_MIN:
+        return (fail(f"{sa['cross']} of {N_CROSS} cross members, dado bite {sa['dado']:.5f} "
+                     f"(min {DADO_MIN}), far face {sa['dado_far']:.5f} (min {DADO_FAR_MIN}) "
+                     "(--short-shelves is the designed fail)", 17),) + nothing
+    if (sa["units"] != N_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["headroom"] < HEADROOM_MIN:
+        return (fail(f"headroom {sa['headroom']:.5f} < {HEADROOM_MIN} "
+                     "(--tall-book is the designed fail)", 20),) + 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)", 21),) + nothing
+    if (sa["n_lean"] != N_LEAN or sa["n_lying"] != N_LYING
+            or sa["lean"][0] < LEAN_BITE_MIN or sa["lean"][1] > LEAN_BITE_MAX):
+        return (fail(f"{sa['n_lean']} of {N_LEAN} leaning and {sa['n_lying']} of {N_LYING} lying "
+                     f"books, lean contact {sa['lean']} outside [{LEAN_BITE_MIN}, {LEAN_BITE_MAX}] "
+                     "(--air-lean and --deep-lean are the designed fails)", 22),) + nothing
+    if sa["book_hits"]:
+        return (fail(f"{sa['book_hits']} book overlaps, need 0 "
+                     "(--crowd-books is the designed fail)", 23),) + nothing
+    if right:
+        return (fail(f"{right} right-angle wood edges, need 0 "
+                     "(--sharp-shelf is the designed fail)", 24),) + nothing
+    return 0, low, high, mats, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], nt.nodes["Principled BSDF"].inputs["Normal"])
+
+
+def render_still(low, mats, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(mats[WOOD_IDX], tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+    # Level on the floor: turned about Z only.
+    low.rotation_euler.z = math.radians(-14.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=60.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Floor")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 8.5, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0)
+    scene.world = world
+
+    def light(name, kind, loc, energy, size, col, rot=(0, 0, 0)):
+        ld = bpy.data.lights.new(name, kind)
+        ld.energy = energy
+        if kind == "AREA":
+            ld.size = size
+        else:
+            ld.shadow_soft_size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", "AREA", (-2.4, -3.4, 3.2), 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.27, -3.74, 1.175)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.565)
+    scene.collection.objects.link(aim)
+    con = cam.constraints.new("TRACK_TO")
+    con.target = aim
+    con.track_axis = "TRACK_NEGATIVE_Z"
+    con.up_axis = "UP_Y"
+    scene.camera = cam
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 32
+        scene.cycles.device = "CPU"
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = "WEBP" if path.lower().endswith(".webp") else "PNG"
+    if path.lower().endswith(".webp"):
+        scene.render.image_settings.quality = 90
+    scene.render.filepath = path
+    scene.view_settings.view_transform = "Standard"
+
+    fcode = gallery_framing.check_framing(scene, cam, hero=[low], elements=[low], stage=[floor, wall])
+    if fcode:
+        return fcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        return fail("render produced no file", 14)
+    return 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None)
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+    p.add_argument("--skip-decimate", action="store_true")
+    p.add_argument("--stray-vert", action="store_true")
+    p.add_argument("--lift-z", action="store_true")
+    p.add_argument("--float-side", action="store_true")
+    p.add_argument("--short-shelves", action="store_true")
+    p.add_argument("--loose-pages", action="store_true")
+    p.add_argument("--float-books", action="store_true")
+    p.add_argument("--float-bands", action="store_true")
+    p.add_argument("--tall-book", action="store_true")
+    p.add_argument("--uniform-books", action="store_true")
+    p.add_argument("--air-lean", action="store_true")
+    p.add_argument("--deep-lean", action="store_true")
+    p.add_argument("--crowd-books", action="store_true")
+    p.add_argument("--sharp-shelf", action="store_true")
+    args = p.parse_args(argv)
+
+    code, low, _high, mats, tex, _col = check(
+        args.skip_decimate,
+        lift_z=args.lift_z,
+        stray_vert=args.stray_vert,
+        float_side=args.float_side,
+        short_shelves=args.short_shelves,
+        sharp_shelf=args.sharp_shelf,
+        loose_pages=args.loose_pages,
+        float_books=args.float_books,
+        float_bands=args.float_bands,
+        tall_book=args.tall_book,
+        uniform_books=args.uniform_books,
+        crowd_books=args.crowd_books,
+        air_lean=args.air_lean,
+        deep_lean=args.deep_lean,
+    )
+    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("bookshelf OK")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as e:
+        traceback.print_exc()
+        print(f"FATAL: {e}", file=sys.stderr)
+        sys.exit(1)
+
+
+
+ + + A stained wooden bookcase with three shelves of cloth and leather hardbacks with gilt spine bands, some standing, some stacked flat, and two leaning against their neighbours. + +
+
+ generated from showcase/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/docs/gallery/contact-sheets/bookshelf-contact-sheet.webp b/docs/gallery/contact-sheets/bookshelf-contact-sheet.webp new file mode 100644 index 00000000..8a927c27 Binary files /dev/null and b/docs/gallery/contact-sheets/bookshelf-contact-sheet.webp differ diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 3ea245f0..4dc3a9c0 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, 34 showcase pieces + 51 examples, 35 showcase pieces
@@ -1276,6 +1276,17 @@

butter-churn

View showcase piece butter-churn
+
+ +
+

bookshelf

+

A procedural bookcase — two housed shelves, a base on a recessed plinth, lapped back boards and a stepped cornice, filled with thirty-eight rounded-spine hardbacks standing, stacked and leaning — carried through UVs, bake, LOD, convex collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 10908 tris, five materials with 338 wood, 2050 cloth, 1066 leather, 228 paper and 1976 gilt faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.972×0.332×1.176 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, both sides on the floor, four cross members 8 mm into both sides, 38 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, 44 mm headroom, thickness and height spread 2.90 and 1.65, both leaning books 0.8 mm onto their neighbour's head edge, zero unplanned book overlaps, zero right-angle wood edges, convex collider 68 tris. --air-lean and --deep-lean exit 22 on the lean contact while every seat, the envelope and the overlap budget still pass; --crowd-books exits 23; --uniform-books 21; --tall-book 20.

+ View showcase piece bookshelf +
+
@@ -1305,7 +1316,7 @@

butter-churn

var tagsToggle = document.getElementById('tagsToggle'); var toTop = document.getElementById('toTop'); var total = cards.length; - var COUNT_LABEL = '51 examples, 34 showcase pieces'; + var COUNT_LABEL = '51 examples, 35 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 265a9d43..18e10f67 100644 --- a/scripts/asset_sheet.py +++ b/scripts/asset_sheet.py @@ -61,6 +61,7 @@ "vertex-color-ao": (r"^Well\.Stone\.", None), "wooden-yoke": (r"^YokeLow$", None), "apothecary-shelf": (r"^ShelfLow$", None), + "bookshelf": (r"^BookshelfLow$", None), "brazier": (r"^BrazierLow$", None), "grain-sacks": (r"^SacksLow$", None), "rope-bridge": (r"^BridgeLow$", None), diff --git a/showcase/bookshelf/README.md b/showcase/bookshelf/README.md new file mode 100644 index 00000000..6492f443 --- /dev/null +++ b/showcase/bookshelf/README.md @@ -0,0 +1,293 @@ +# bookshelf + +![A stained wooden bookcase with three shelves of cloth and leather hardbacks with gilt spine bands, some standing, some stacked flat, and two leaning against their neighbours](preview.webp) + +A bookcase: a stained carcass with two shelves housed in its sides, a base +on a recessed plinth, lapped back boards and a stepped cornice, filled +with thirty-eight hardbacks. Twenty-nine stand upright, seven lie in +three stacks, and two lean on a neighbour. 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, U-section covers with rounded spines, page blocks and bands built on the spine's own chain, 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 | +| `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 | +| `snippets/lod_chain.py` | LOD naming and ratio pattern | +| `examples/mesh-hygiene-audit` | hygiene combinatorics (copied, not imported) | + +## The budget that matters + +A leaning book has to rest on its neighbour's head edge and on the shelf, +not in either. Tip it a few degrees too far and its board cuts through the +neighbour's corner; not far enough and it stands in the air against +nothing. Neither is visible to any other budget: + +- the leaning book still stands on its 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 this very contact, so it cannot + see how deep the contact goes. + +The piece finds the leaning books on the finished mesh (a book whose +largest face, a board, is tilted between 3° and 80°) and the upright +neighbour on the side its top leans toward, on the same shelf. It takes +the plane of the leaning board's outer face off the mesh and measures how +far the neighbour's deepest vertex sits behind it, as a band of +0.3–2.0 mm. Both lean at 0.80 mm. + +A plane, not the cover solid: the board is 2.8 mm thick, and a head edge +driven right through it lies in the hollow of the book, outside the cover +again, so a solid test reads a deep cut as no contact at all. That is how +the first `--deep-lean` passed. + +`--air-lean` and `--deep-lean` are the falsifiers built for exactly this. +One stops each leaning book 3 mm short of its neighbour, the other drives +it 4.5 mm in. Both exit 22 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 | 10400–11400 | 10908 | +| LOD1 ratio | 0.32–0.62 | 0.5000 | +| LOD2 ratio | 0.10–0.35 | 0.2189 (0.2198 on 4.5 and 5.1) | +| Material slots | exactly 5, distinct | 5 | +| Wood / cloth / leather faces | ≥ 300 / 1850 / 960 | 338 / 2050 / 1066 | +| Paper / gilt faces | ≥ 200 / 1780 | 228 / 1976 | +| UV bounds | inside 0..1 | (0.0004, 0.0004)–(0.9996, 0.9996) | +| UV AABB overlap | ≤ 1e-5 | 0.000000 | +| Outer AABB | 0.972 × 0.332 × 1.176 m ± 0.020 | 0.9720 × 0.3320 × 1.1760 | +| Collider triangles | ≤ 200 | 68 | +| Normal bake | `{'FINISHED'}` with image data | `{'FINISHED'}`, `has_data=True` | +| glTF export | file written, non-empty | ~786 kB | +| Hygiene | all zero | loose 0/0, non-manifold 0, zero-area 0, doubles 0, n-gons 0, coplanar cross-shell pairs 0 | +| Grounded AABB | \|zmin\| ≤ 1e-4 | 0.00000 | +| Named sides | 2 sides, each zmin ≤ 1e-4 | 2 at 0.00000 | +| Dado | 4 cross members, each ≥ 5 mm into both sides and ≥ 6 mm clear of the far face | 8.00 mm in, 14.00 mm clear | +| Page block | 38 books, one block and two bands each; every block corner 0.3–1.2 mm inside its boards | 0.60 mm | +| Book seat | 38 books, each 0.5–3.6 mm into the shelf or the book it stands on | 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 | +| Headroom | every book ≥ 20 mm under the member above | 44.36 mm | +| Size spread | thickest / thinnest ≥ 2.5; tallest / shortest ≥ 1.5 | 2.900 / 1.645 | +| Lean contact | 2 leaning and 7 lying books; each neighbour's head edge 0.3–2.0 mm behind the leaning board | 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.97 m wide over the cornice, 1.18 m tall and 0.33 m +deep. Each compartment is 318–348 mm clear, and the books run from a +186 mm octavo to a 305 mm folio. + +## Construction + +- **Carcass.** Two full-height sides carry everything and are the only + named supports. The plinth, the base and the two shelves are each housed + `DADO` (8 mm) into both sides and stop 14 mm short of the outer faces. + The plinth stands 18 mm back from the base's front and 4 mm off the + floor, so only the sides ground. The top is seated over the sides and + the back boards; the cornice steps 10 mm out past it and is seated + `CORN_BITE` into it, its back held 4 mm short of the top's back so the + two do not share a plane. Every board is chamfered at 2.5 mm with + `material=` pinned to wood, edges sorted by index. +- **Back boards.** Five vertical boards, lapped 2 mm into one another and + every other one stepped forward 1.5 mm, as on `apothecary-shelf`. +- **Covers.** One shell per book: a U of front board, half-round spine and + back board, extruded up the height (`SPINE_ARC` = 6 segments). The + boards are tangent to the spine, so it reads round and the boards flat. + Every edge over 40° is chamfered 0.8 mm; the spine facets (30°) are not, + and shade smooth. +- **Page blocks.** A block inside the U, `SQUARE` (4 mm) short of the + cover at head, tail and fore-edge, biting `PAGE_BITE` (0.6 mm) into both + boards. Its back stops 1.5 mm short of the line where boards meet spine, + which is where the band ends lie. +- **Gilt bands.** Two per book, built on the cover's own outer chain round + the spine: every band vertex sits on a cover vertex's normal, inner face + 0.3 mm inside the spine and outer face 0.8 mm proud. +- **Standing books.** Set along each shelf with a 2.5–4.5 mm gap, spines a + stepped 6–14 mm back from the shelf front, each seated a stepped + 1.0–3.3 mm into the shelf. Each stands a degree or two off square, in a + four-step cycle (−2.4°, −0.8°, 0.8°, 2.4°). +- **Stacks.** Books laid on a board, spine out, yawed ±3° alternately and + shifted a few millimetres. The bottom one seats into the shelf; each one + above takes a 1.0–1.4 mm seat into the board below it. +- **Leaning books.** Turned about Y by 18° (bottom shelf, onto the book on + its left) and 22° (middle shelf, onto the book on its right), seated on + the shelf by the lowest vertex of the chamfered cover. Then the leaning + book (or, leaning right, its neighbour) is slid along the shelf until + the neighbour's deepest vertex is `LEAN_BITE` (0.8 mm) behind the + leaning board's outer face. Each leaning book is taller than its + neighbour's head divided by the cosine of its lean, so the head edge + meets the board and not its top corner. + +## Findings the budgets forced + +- **Coplanar page blocks and shelves.** The first page blocks sat 3 mm + short of the cover's tail, and one book seated 2.98 mm into its shelf put + its page block's underside 0.02 mm off the shelf top: a coplanar pair. + The square is now 4 mm, clear of every seat. +- **Every spine has the same facet angles.** A half-round spine of six + facets has the same six facet normals on every book. Square to the + shelf, two neighbours share a facet plane whenever their offsets happen + to line up, and the uniform and crowded falsifiers did so (five and + twelve coplanar pairs). Each upright now stands a degree or two off + square in a four-step cycle. A half-degree cycle was not enough: on a + 45° chamfer the normals differ by half the yaw, inside the 1e-4 + parallel tolerance. +- **Planes facing the front.** Spine lines, band ends and fore-edges face + the shelf front, and two neighbours landed band ends on one plane. Each + book is stepped back 0.9 mm at a time until none of its front-facing + planes is within 0.4 mm of a neighbour's. +- **A stacked book on a page block's plane.** A book stacked on another + with the running seat (up to 3.3 mm) could land its underside on the + plane of the lower book's page block, 2.2 mm inside its board. A stacked + book now takes its own 1.0–1.4 mm seat. +- **Matching parts to books.** Page blocks were first matched to the cover + nearest their centroid. In a stack, the centroid of a thick book's page + block is nearer the board of the book above than its own. Parts are now + matched on the mean distance of all their vertices. +- **Deep lean through the board.** Measured against the cover solid, a + head edge driven 4.5 mm into a 2.8 mm board came out in the hollow of + the leaning book and read as no contact, so `--deep-lean` exited 0. The + contact is now measured against the plane of the board's outer face. +- **Black gilt.** Fully metallic bands facing the camera mirrored the dark + stage and read black except at their edges. The gilt is half metallic. + +## 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; square, yaw cycle, front-plane step and stack seat chosen to keep it at 0 | +| Named supports | yes | the two sides (`--float-side`); the plinth stops 4 mm off 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 | top over sides and back boards, cornice into the top, back boards into the base | +| A platform bears on something | yes | every shelf housed in both sides and biting the back boards | +| Carried parts bite their bearers | yes | books into shelves and into the book below (`--float-books`) | +| A joint bites; touching is not joining | yes | page blocks into their boards (`--loose-pages`); the lean 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 | the lean contact, exit 22 (`--air-lean`, `--deep-lean`) | +| Scattered parts do not interpenetrate | yes | exit 23 (`--crowd-books`), designed contacts exempt and counted | +| Scatter varies in size | yes | thickness and height spread, exit 21 (`--uniform-books`) | +| The subject hangs where it can be seen / headroom | yes | every book under the member above, exit 20 (`--tall-book`) | +| Edge treatment: no right angles | yes | exit 24 (`--sharp-shelf`), wood only; covers chamfered 0.8 mm by construction | +| Aim an edge falsifier at one member | yes | one shelf left square: 12 edges | +| Sort bmesh operator inputs | yes | bevel edges sorted by index, carcass and covers | +| A plank wall is boards | yes | five lapped back boards | +| 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; shelf pitch and seats are budgets | +| Shading is part of the model | yes | spines smooth (round); every edge over 35° hard, so boards and chamfers read crisp | +| One substance, one slot | yes | wood, cloth, leather, paper, gilt | +| Iron is not chrome | n/a | no iron; gilt is half metallic, roughness 0.45 | +| Bake texels per UV cell | measured, not a budget | 1024 px over a 76-cell grid, about 13 px per cell | +| The bake cage is narrower than the nearest neighbour | yes | `CAGE_EXTRUSION` 0.01 m | +| Level on the stage; stage 60 m | yes | turned about Z only; 60 m floor and wall | +| Keep a falsifier's envelope still | yes | every falsifier leaves the AABB unchanged | +| Mirror symmetry, 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 carcass | 15 | +| `--lift-z` | grounded zmin | lifts the whole mesh 50 mm | 16 | +| `--float-side` | named sides | lifts the left side 5 mm; the right side still grounds the AABB | 16 | +| `--short-shelves` | dado bite | stops the two shelves 4 mm short of the sides; −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; −5.0 mm | 18 | +| `--float-bands` | band seat | builds every band 1.1 mm off its spine; −1.1 mm | 18 | +| `--tall-book` | headroom | stretches one middle-shelf folio 1.20 into the shelf above; −3.9 mm | 20 | +| `--uniform-books` | size spread | one height and depth, thickness within ±1.5 %; 1.030 / 1.000 | 21 | +| `--air-lean` | lean contact | stops each leaning book 3 mm short of its neighbour; −3.0 mm | 22 | +| `--deep-lean` | lean contact | drives each leaning book 4.5 mm into its neighbour; 4.5 mm | 22 | +| `--crowd-books` | book overlaps | closes the top shelf's gaps to −4.1 mm; 11 overlaps | 23 | +| `--sharp-shelf` | edge treatment | leaves the top shelf unchamfered; 12 right-angle edges | 24 | + +## Exit codes + +File-local and sequential. `9` is a valid check code. `1` is the FATAL +wrapper — a crash, never a named check. `19` (plumb and real-world size) +is reserved across pieces and unused here. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Mesh did not build, or has no UV layer | +| 4 | Base triangle count outside band | +| 5 | Material slots, or a material's face floor | +| 6 | UVs outside 0..1 | +| 7 | UV AABB overlap above tolerance | +| 8 | Outer AABB off declared size | +| 9 | LOD1 or LOD2 ratio outside band (`--skip-decimate`) | +| 10 | Framing gate (`examples/gallery_framing.py`, render path only) | +| 11 | Collider triangles above ceiling | +| 12 | Normal bake failed or produced no image data | +| 13 | glTF export missing or empty | +| 14 | `--output` produced no file | +| 15 | Mesh hygiene (`--stray-vert`) | +| 16 | Grounded zmin, or a side floating (`--lift-z`, `--float-side`) | +| 17 | Dado or page-block bite, or a book without one block and two bands (`--short-shelves`, `--loose-pages`) | +| 18 | Book or band seat (`--float-books`, `--float-bands`) | +| 20 | Headroom under the member above (`--tall-book`) | +| 21 | Size spread below floor (`--uniform-books`) | +| 22 | Lean contact, or leaning / lying count (`--air-lean`, `--deep-lean`) | +| 23 | Books overlap each other or the carcass (`--crowd-books`) | +| 24 | Right-angle wood edges (`--sharp-shelf`) | + +## Run it + +```bash +# Budget check, no render. +blender --background --python bookshelf.py -- + +# Falsifier: each leaning book stops short of its neighbour. Must exit 22. +blender --background --python bookshelf.py -- --air-lean + +# Falsifier: each leaning book cuts into its neighbour. Must exit 22. +blender --background --python bookshelf.py -- --deep-lean + +# Render the gallery still (EEVEE; --engine cycles on a GPU-less host). +blender --background --python bookshelf.py -- --output bookshelf.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 | 10908 | 10908 | 10908 | +| LOD1 / LOD2 tris | 5454 / 2398 | 5454 / 2398 | 5454 / 2388 | +| Face counts (wood / cloth / leather / paper / gilt) | 338 / 2050 / 1066 / 228 / 1976 | same | same | +| Outer AABB | 0.9720 × 0.3320 × 1.1760 | same | same | +| Collider tris | 68 | 68 | 68 | +| Headroom / spread / lean | 44.36 mm / 2.900, 1.645 / 0.80 mm | same | same | +| glTF bytes | 785836 | 785836 | 785828 | + +LOD2 differs by ten triangles on 5.2 (DECIMATE COLLAPSE, the known +divergence; the gate is a ratio band) and the glTF size by 8 bytes +(exporter metadata, not a budget). Every other value is identical. diff --git a/showcase/bookshelf/bookshelf.py b/showcase/bookshelf/bookshelf.py new file mode 100644 index 00000000..9d212312 --- /dev/null +++ b/showcase/bookshelf/bookshelf.py @@ -0,0 +1,1701 @@ +"""Game-ready bookshelf with leaning books — a showcase piece, not an example. + +Asserts budget conformance of a procedural bookcase: a stained carcass +with two housed shelves, a base on a recessed plinth, lapped back boards +and a stepped cornice, filled with thirty-eight hardback books. Most +stand upright; seven lie in three stacks; two lean on a neighbour. 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. Carried through UVs, +five materials (wood, cloth, leather, paper, gilt), 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 leaning book fails invisibly: it has +to rest on its neighbour's head edge and on the shelf, not in either. Tip +it a few degrees too far and its board cuts through the neighbour's +corner; not far enough and it stands in the air against nothing. Every +seat, the bounding box, the triangle count and the book-overlap budget +(which must exempt the designed contact) still pass either way. The piece +finds each leaning book and its neighbour on the finished mesh and asserts +how deep the neighbour's head edge sits in the leaning board, as a band. + +Budgets are declared below and recomputed from the generated result. +They are not API-contract witnesses. Each falsifier violates one named +budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh +hygiene, ``--lift-z`` grounded zmin, ``--float-side`` the named sides, +``--short-shelves`` the dado bite, ``--loose-pages`` the page-block bite, +``--float-books`` the book seat, ``--float-bands`` the band seat, +``--tall-book`` the headroom, ``--uniform-books`` the size spread, +``--air-lean`` and ``--deep-lean`` the lean contact, ``--crowd-books`` +book interpenetration, ``--sharp-shelf`` edge treatment. + +No randomness: every book's size, tint, gap, setback 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 bookshelf.py -- + blender --background --python bookshelf.py -- --air-lean + blender --background --python bookshelf.py -- --output bookshelf.png +""" +import argparse +import math +import os +import sys +import tempfile +import traceback + +import bmesh +import bpy +from mathutils import Matrix, Vector +from mathutils.bvhtree import BVHTree +from mathutils.kdtree import KDTree + +_REPO = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) +) +sys.path.insert(0, os.path.join(_REPO, "examples")) +sys.dont_write_bytecode = True +import gallery_framing # noqa: E402 + +# The carcass. X across, Y front (-) to back (+), Z up. Two full-height +# sides carry everything; every cross member is housed into both of them. +W_OUT = 0.920 +SIDE_T = 0.022 +DEPTH = 0.300 +SIDE_H = 1.140 +DADO = 0.008 +TOP_T = 0.026 +TOP_SEAT = 0.006 +TOP_OVER = 0.016 +TOP_BACK_OVER = 0.006 +CORN_T = 0.020 +CORN_OVER = 0.010 +CORN_BITE = 0.004 +CORN_BACK_IN = 0.004 +BACK_T = 0.012 +BACK_INSET = 0.010 +BACK_BITE = 0.005 +N_BACK = 5 +BACK_LAP = 0.002 +BACK_STEP = 0.0015 +SHELF_T = 0.022 +SHELF_SETBACK = 0.004 +SHELF_TOPS = (0.460, 0.800) +BASE_Z = 0.070 +BASE_T = 0.020 +BASE_SETBACK = 0.004 +PLINTH_Z0 = 0.004 +PLINTH_SET = 0.018 +PLINTH_T = 0.018 +PLINTH_BITE = 0.004 +CHAMFER = 0.0025 + +# 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. The cover is +# one shell: a U of board, rounded spine and board, extruded up the +# height. The page block sits inside it, SQUARE short of the cover at the +# head, tail and fore-edge, and bites PAGE_BITE into both boards. +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 +FLOAT_SIDE = 0.005 +START_GAP = 0.006 +GAP_BASE = 0.0025 +GAP_SPAN = 0.0020 +CROWD_GAP = -0.0041 +SET_BASE = 0.006 +SET_STEP = 0.0013 +SET_CLEAR = 0.0004 +SET_NUDGE = 0.0009 +STACK_YAW = math.radians(3.0) +STACK_SHIFT = 0.006 +LEAN_BITE = 0.0008 +AIR_LEAN = -0.003 +DEEP_LEAN = 0.0045 +TALL_BOOK = 1.20 +UNIFORM_BOOK = (0.034, 0.230, 0.170) +UNIFORM_JIT = 0.03 +UPRIGHT_YAW = (-2.4, -0.8, 0.8, 2.4) + +# Per shelf, left to right. ("up", T, H, D, kind), ("lean", T, H, D, kind, +# direction, degrees), ("stack", [(T, H, D, kind), ...] bottom first), +# ("gap", width). kind: "c" cloth, "l" leather. A leaning book leans onto +# the upright beside it: -1 onto the book on its left, +1 onto the right. +LAYOUT = ( + ( + ("stack", [(0.046, 0.300, 0.228, "l"), (0.036, 0.285, 0.214, "c")]), + ("gap", 0.014), + ("up", 0.052, 0.305, 0.226, "l"), + ("up", 0.034, 0.282, 0.205, "c"), + ("up", 0.041, 0.291, 0.214, "c"), + ("up", 0.058, 0.268, 0.198, "l"), + ("up", 0.030, 0.296, 0.221, "c"), + ("up", 0.044, 0.262, 0.192, "c"), + ("up", 0.037, 0.276, 0.207, "l"), + ("up", 0.049, 0.255, 0.188, "c"), + ("lean", 0.036, 0.300, 0.218, "c", -1, 18.0), + ), + ( + ("lean", 0.032, 0.268, 0.196, "l", 1, 22.0), + ("up", 0.040, 0.228, 0.170, "c"), + ("up", 0.026, 0.251, 0.183, "c"), + ("up", 0.047, 0.262, 0.194, "l"), + ("up", 0.022, 0.214, 0.158, "c"), + ("up", 0.035, 0.240, 0.176, "c"), + ("up", 0.055, 0.270, 0.201, "l"), + ("up", 0.029, 0.233, 0.172, "c"), + ("up", 0.038, 0.246, 0.180, "c"), + ("up", 0.024, 0.221, 0.163, "l"), + ("gap", 0.050), + ("stack", [(0.034, 0.232, 0.170, "c"), (0.028, 0.214, 0.160, "l"), + (0.022, 0.196, 0.148, "c")]), + ), + ( + ("up", 0.031, 0.236, 0.172, "c"), + ("up", 0.024, 0.212, 0.156, "c"), + ("up", 0.043, 0.254, 0.187, "l"), + ("up", 0.020, 0.198, 0.146, "c"), + ("up", 0.036, 0.226, 0.166, "c"), + ("up", 0.028, 0.243, 0.178, "l"), + ("up", 0.045, 0.265, 0.195, "c"), + ("up", 0.022, 0.205, 0.151, "c"), + ("up", 0.033, 0.219, 0.161, "l"), + ("up", 0.039, 0.248, 0.182, "c"), + ("up", 0.026, 0.186, 0.140, "c"), + ("up", 0.030, 0.231, 0.170, "c"), + ("gap", 0.080), + ("stack", [(0.030, 0.226, 0.166, "l"), (0.024, 0.200, 0.150, "c")]), + ), +) +TALL_TARGET = (1, 6) # the 0.270 m leather volume on the middle shelf +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.972, 0.332, 1.176) + +BASE_TRIS_MIN = 10400 +BASE_TRIS_MAX = 11400 +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 = 5 +FACE_FLOORS = {0: 300, 1: 1850, 2: 960, 3: 200, 4: 1780} +UV_EPS = 1e-4 +UV_OVERLAP_MAX = 1e-5 +COLLIDER_TRIS_MAX = 200 +BAKE_RES = 1024 +CAGE_EXTRUSION = 0.01 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +COPLANAR_NORMAL_EPS = 1e-4 +COPLANAR_PLANE_EPS = 1e-4 +COPLANAR_CENTRE_MAX = 0.05 +LIFT_Z = 0.05 +SIDE_Z_MAX = 1e-4 +DADO_MIN = 0.005 +DADO_FAR_MIN = 0.006 +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 +HEADROOM_MIN = 0.020 +THICK_SPREAD_MIN = 2.5 +HEIGHT_SPREAD_MIN = 1.5 +UPRIGHT_TILT_MAX = math.radians(3.0) +LYING_TILT_MIN = math.radians(80.0) +LEAN_BITE_MIN = 0.0003 +LEAN_BITE_MAX = 0.0020 +LEAN_SEARCH = 0.03 +RIGHT_ANGLE_TOL = math.radians(5.0) + +WOOD_IDX = 0 +CLOTH_IDX = 1 +LEATHER_IDX = 2 +PAPER_IDX = 3 +GILT_IDX = 4 +N_CROSS = len(SHELF_TOPS) + 2 +N_BOOKS = sum(len(it[1]) if it[0] == "stack" else (0 if it[0] == "gap" else 1) + for row in LAYOUT for it in row) +N_LEAN = sum(1 for row in LAYOUT for it in row if it[0] == "lean") +N_LYING = sum(len(it[1]) for row in LAYOUT for it in row if it[0] == "stack") + + +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 --------------------------------------------- + + +def spine_chain(t, d): + """Centre line of the cover's U and its outward normal at each point. + + Front board from the fore-edge to the spine, a half-round spine of + SPINE_ARC segments, back board to the fore-edge. The boards are tangent + to the spine, so the spine reads round and the boards flat. + """ + 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. The cover is chamfered here, in its own + bmesh, so the placement code sees the finished corners it will rest on. + """ + 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])) + # The bevel picks edges by face angle, which needs consistent normals. + 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]) + + # Page block: inside the U, biting both boards, square short of the + # cover at head, tail and fore-edge. Its back stops PAGE_BACK short of + # the line where the boards meet the spine, where the band ends lie. + 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 + + # Gilt bands round the spine, built on the cover's own outer chain: + # every band vertex sits on a cover vertex's normal, so the band + # follows the round of the spine exactly. + 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): + t, h, d, kind = 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 lean_face(book, side): + """Point and outward normal of a leaning book's board face on ``side``.""" + rot = book["M"].to_3x3() + n = (rot @ Vector((float(side), 0.0, 0.0))).normalized() + return book["M"] @ Vector((side * book["t"] / 2.0, 0.0, 0.0)), n + + +def penetration(pts, p0, n): + """How far the deepest of ``pts`` sits behind the plane (p0, n).""" + return max(-(p - p0).dot(n) for p in pts) + + +def plan_books(loose_pages=False, float_books=False, float_bands=False, tall_book=False, + uniform_books=False, crowd_books=False, air_lean=False, deep_lean=False): + """Place every book. Closed-form: sizes from LAYOUT, the rest from the index. + + Each book is built, turned, then set down: its lowest cover vertex a + stepped seat into whatever it stands on, its spine a stepped setback + from the shelf front, and along the shelf at the running cursor. A + leaning book is then slid (or its neighbour is) until the neighbour's + head edge sits the lean bite into the leaning board. + """ + x_in = W_OUT / 2.0 - SIDE_T + y_front = -DEPTH / 2.0 + SHELF_SETBACK + tops = (BASE_Z + BASE_T,) + SHELF_TOPS + bite = LEAN_BITE + (AIR_LEAN - LEAN_BITE if air_lean else 0.0) + ( + DEEP_LEAN - LEAN_BITE if deep_lean else 0.0) + lift = FLOAT_BOOKS if float_books else 0.0 + books = [] + for s, row in enumerate(LAYOUT): + cursor = -x_in + START_GAP + k = 0 + placed = [] + pending = None + + def sized(dims, s=s, ii=None, j=0): + t, h, d, kind = dims + if uniform_books: + # One height and depth; thickness within +-1.5 %, so no two + # spines are the same solid set a hair apart. + n = s * 100 + (ii or 0) * 5 + j + 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] + if tall_book and (s, ii) == TALL_TARGET: + h *= TALL_BOOK + return (t, h, d, kind) + + def gap_of(k, s=s): + if crowd_books and s == 2: + return CROWD_GAP + return GAP_BASE + GAP_SPAN * frac(k * 0.6180339887 + 0.3 + s * 0.17) + + def y_keys(book): + lo, hi = bounds(cover_points(book)) + yc = lo.y + book["t"] / 2.0 + return (lo.x + hi.x) / 2.0, (lo.y, yc, yc + PAGE_BACK, hi.y - SQUARE, hi.y) + + def set_down(book, k, x_left, support_top, seat_k=None): + lo, hi = bounds(cover_points(book)) + seat = SEAT_BASE + SEAT_STEP * (k if seat_k is None else seat_k) + move(book, Vector((x_left - lo.x, + y_front + SET_BASE + SET_STEP * (k % 7) - lo.y, + support_top - seat - lo.z))) + # Spine fronts, spine lines and fore-edges are planes facing Y. + # Two neighbours landing one on the same plane z-fight, so step + # this book back until none of its planes meets a neighbour's. + for _ in range(8): + cx, keys = y_keys(book) + near = [y_keys(o)[1] for o in placed if abs(y_keys(o)[0] - cx) < 0.12] + if not any(abs(a - b) < SET_CLEAR for ks in near for a in keys for b in ks): + break + move(book, Vector((0.0, SET_NUDGE, 0.0))) + + for ii, item in enumerate(row): + tag = item[0] + if tag == "gap": + cursor += item[1] + continue + if tag == "up": + # Upright books stand a degree or so off square, in a + # four-step cycle. Every spine has the same facet angles, so + # square neighbours share planes by accident whenever their + # offsets line up; a degree apart, no two faces are parallel. + yaw = math.radians(UPRIGHT_YAW[k % len(UPRIGHT_YAW)]) + b = make_book(sized(item[1:5], ii=ii), Matrix.Rotation(yaw, 4, "Z"), + loose_pages, float_bands) + set_down(b, k, cursor + gap_of(k), tops[s] + lift) + if pending is not None: + p0, n = lean_face(pending, 1) + pen = penetration(cover_points(b), p0, n) + move(b, Vector(((pen - bite) / n.x, 0.0, 0.0))) + b["lean_on"] = pending + pending = None + elif tag == "lean": + side, deg = item[5], item[6] + rot = Matrix.Rotation(side * math.radians(deg), 4, "Y") + b = make_book(sized(item[1:5], ii=ii), rot, loose_pages, float_bands) + set_down(b, k, cursor + gap_of(k), tops[s] + lift) + if side < 0: + nb = placed[-1] + p0, n = lean_face(b, -1) + pen = penetration(cover_points(nb), p0, n) + move(b, Vector(((bite - pen) / n.x, 0.0, 0.0))) + b["lean_on"] = nb + else: + pending = b + else: + stack_top = tops[s] + lift + centre = None + stack = [] + for j, dims in enumerate(item[1]): + 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, ii=ii, j=j), rot, loose_pages, float_bands) + lo, hi = bounds(cover_points(b)) + if centre is None: + centre = cursor + gap_of(k) + (hi.x - lo.x) / 2.0 + shift = STACK_SHIFT * (0.5 - frac(j * 0.7548776662 + 0.2)) * 2.0 + # A book stacked 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. + set_down(b, k, centre + shift - (hi.x - lo.x) / 2.0, stack_top, + seat_k=(j if j else None)) + stack_top = bounds(cover_points(b))[1].z + b.update({"shelf": s, "i": len(books)}) + books.append(b) + stack.append(b) + placed.append(b) + k += 1 + cursor = max(bounds(cover_points(x))[1].x for x in stack) + continue + b.update({"shelf": s, "i": len(books)}) + books.append(b) + placed.append(b) + cursor = bounds(cover_points(b))[1].x + k += 1 + return books + + +# --- construction ----------------------------------------------------------- + + +def add_board(bm, lo, hi, boards, sharp=False): + """Axis-aligned board from corner ``lo`` to ``hi``; its verts go in ``boards``.""" + geo = bmesh.ops.create_cube(bm, size=1.0) + c = [(lo[k] + hi[k]) * 0.5 for k in range(3)] + s = [hi[k] - lo[k] for k in range(3)] + for v in geo["verts"]: + v.co = Vector((c[0] + v.co.x * s[0], c[1] + v.co.y * s[1], c[2] + v.co.z * s[2])) + for f in {f for v in geo["verts"] for f in v.link_faces}: + f.material_index = WOOD_IDX + if not sharp: + boards.extend(geo["verts"]) + return geo["verts"] + + +def build_bookshelf_mesh( + name, + stray_vert=False, + float_side=False, + short_shelves=False, + sharp_shelf=False, + **book_flags, +): + x_out = W_OUT / 2.0 + x_in = x_out - SIDE_T + y0, y1 = -DEPTH / 2.0, DEPTH / 2.0 + top_z0 = SIDE_H - TOP_SEAT + top_z1 = top_z0 + TOP_T + back_y1 = y1 - BACK_INSET + back_y0 = back_y1 - BACK_T + bm = bmesh.new() + try: + uv = bm.loops.layers.uv.new("UVMap") + piece = bm.faces.layers.int.new("Piece") + boards = [] + # Sides: the two named supports. + for sx in (-1.0, 1.0): + lift = FLOAT_SIDE if (float_side and sx < 0) else 0.0 + add_board(bm, (min(sx * x_out, sx * x_in), y0, lift), + (max(sx * x_out, sx * x_in), y1, SIDE_H + lift), boards) + add_board(bm, (-x_out - TOP_OVER, y0 - TOP_OVER, top_z0), + (x_out + TOP_OVER, y1 + TOP_BACK_OVER, top_z1), boards) + # Cornice: a wider board seated on the top, stepping out past it. + # Its back stops short of the top's back, or the two share a plane. + add_board(bm, (-x_out - TOP_OVER - CORN_OVER, y0 - TOP_OVER - CORN_OVER, + top_z1 - CORN_BITE), + (x_out + TOP_OVER + CORN_OVER, y1 + TOP_BACK_OVER - CORN_BACK_IN, + top_z1 - CORN_BITE + CORN_T), boards) + # Back boards, housed into the sides and the top, standing in the + # base; lapped, every other one stepped forward so no lap is a plane. + span0, span1 = -x_in - BACK_BITE, x_in + BACK_BITE + bw = (span1 - span0 + BACK_LAP * (N_BACK - 1)) / N_BACK + for k in range(N_BACK): + bx = span0 + k * (bw - BACK_LAP) + step = BACK_STEP if k % 2 else 0.0 + add_board(bm, (bx, back_y0 - step, BASE_Z + BASE_T - BACK_BITE), + (bx + bw, back_y1 - step, top_z0 + BACK_BITE), boards) + # Cross members: plinth, base and shelves, each in a dado in both sides. + add_board(bm, (-(x_in + DADO), y0 + PLINTH_SET, PLINTH_Z0), + (x_in + DADO, y0 + PLINTH_SET + PLINTH_T, BASE_Z + PLINTH_BITE), boards) + add_board(bm, (-(x_in + DADO), y0 + BASE_SETBACK, BASE_Z), + (x_in + DADO, back_y0 + BACK_BITE, BASE_Z + BASE_T), boards) + reach = x_in - 0.004 if short_shelves else x_in + DADO + for s, zt in enumerate(SHELF_TOPS): + add_board(bm, (-reach, y0 + SHELF_SETBACK, zt - SHELF_T), + (reach, back_y0 + BACK_BITE, zt), boards, + sharp=(sharp_shelf and s == len(SHELF_TOPS) - 1)) + # 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() + edges = sorted({e for v in boards for e in v.link_edges + if e.calc_face_angle(0.0) > math.radians(20.0)}, key=lambda e: e.index) + bmesh.ops.bevel(bm, geom=edges, offset=CHAMFER, segments=1, profile=0.5, + affect="EDGES", clamp_overlap=True, material=WOOD_IDX) + bmesh.ops.triangulate(bm, faces=[f for f in bm.faces if len(f.verts) > 4]) + + books = plan_books(**book_flags) + 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, 0.62)) + 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. + + Every carcass board gets a closed-form tone and its long axis as grain. + Every book gets a cloth or leather tint from its index, and its page + block the book's own thickness axis, so the page lines run parallel to + the boards however the book is turned. The ``Piece`` tag is then dropped. + """ + npoly = len(me.polygons) + piece = [0] * npoly + me.attributes["Piece"].data.foreach_get("value", piece) + mats = [0] * npoly + me.polygons.foreach_get("material_index", mats) + tone = [0.5] * npoly + grain = [(1.0, 0.0, 0.0)] * npoly + tint = [(0.5, 0.5, 0.5, 1.0)] * npoly + vf = [[] for _ in range(len(me.vertices))] + for p in me.polygons: + for i in p.vertices: + vf[i].append(p.index) + for k, g in enumerate(shells(me)): + faces = {fi for i in g for fi in vf[i]} + if not faces or mats[next(iter(faces))] != WOOD_IDX: + continue + pts = [me.vertices[i].co for i in g] + ext = [max(p[a] for p in pts) - min(p[a] for p in pts) for a in range(3)] + axis = ext.index(max(ext)) + d = tuple(1.0 if a == axis else 0.0 for a in range(3)) + t = 0.5 + 0.30 * (frac(k * 0.6180339887 + 0.3) - 0.5) + for fi in faces: + tone[fi] = t + grain[fi] = d + for fi in range(npoly): + v = piece[fi] - 1 + if v < 0: + continue + 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 timber: grain along each board, tone per board (copied from crate-stack).""" + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + coord = nt.nodes.new("ShaderNodeTexCoord") + gdir = nt.nodes.new("ShaderNodeAttribute") + gdir.attribute_name = "GrainDir" + tone = nt.nodes.new("ShaderNodeAttribute") + tone.attribute_name = "PlankTone" + dot = nt.nodes.new("ShaderNodeVectorMath") + dot.operation = "DOT_PRODUCT" + nt.links.new(coord.outputs["Object"], dot.inputs[0]) + nt.links.new(gdir.outputs["Vector"], dot.inputs[1]) + squash = nt.nodes.new("ShaderNodeMath") + squash.operation = "MULTIPLY" + squash.inputs[1].default_value = 0.94 + nt.links.new(dot.outputs["Value"], squash.inputs[0]) + along = nt.nodes.new("ShaderNodeVectorMath") + along.operation = "SCALE" + nt.links.new(gdir.outputs["Vector"], along.inputs[0]) + nt.links.new(squash.outputs["Value"], along.inputs["Scale"]) + grain_co = nt.nodes.new("ShaderNodeVectorMath") + grain_co.operation = "SUBTRACT" + nt.links.new(coord.outputs["Object"], grain_co.inputs[0]) + nt.links.new(along.outputs["Vector"], grain_co.inputs[1]) + shift = nt.nodes.new("ShaderNodeVectorMath") + shift.operation = "ADD" + nt.links.new(grain_co.outputs["Vector"], shift.inputs[0]) + nt.links.new(tone.outputs["Fac"], shift.inputs[1]) + noise = nt.nodes.new("ShaderNodeTexNoise") + noise.inputs["Scale"].default_value = 34.0 + noise.inputs["Detail"].default_value = 6.0 + noise.inputs["Roughness"].default_value = 0.62 + nt.links.new(shift.outputs["Vector"], noise.inputs["Vector"]) + ramp = nt.nodes.new("ShaderNodeValToRGB") + ramp.color_ramp.elements[0].position = 0.30 + ramp.color_ramp.elements[0].color = (0.070, 0.031, 0.013, 1.0) + ramp.color_ramp.elements[1].position = 0.72 + ramp.color_ramp.elements[1].color = (0.21, 0.10, 0.042, 1.0) + nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) + gain = nt.nodes.new("ShaderNodeMath") + gain.operation = "MULTIPLY_ADD" + gain.inputs[1].default_value = 1.2 + gain.inputs[2].default_value = 0.40 + nt.links.new(tone.outputs["Fac"], gain.inputs[0]) + mix = nt.nodes.new("ShaderNodeMix") + mix.data_type = "RGBA" + mix.blend_type = "MULTIPLY" + _sock(mix.inputs, "Factor_Float").default_value = 1.0 + nt.links.new(ramp.outputs["Color"], _sock(mix.inputs, "A_Color")) + nt.links.new(gain.outputs["Value"], _sock(mix.inputs, "B_Color")) + nt.links.new(_sock(mix.outputs, "Result_Color"), bsdf.inputs["Base Color"]) + rough = nt.nodes.new("ShaderNodeMapRange") + rough.inputs["To Min"].default_value = 0.66 + rough.inputs["To Max"].default_value = 0.48 + nt.links.new(noise.outputs["Fac"], rough.inputs["Value"]) + nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"]) + return mat + + +def 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, 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"]) + # Half metallic and fairly rough: a fully metallic band on a spine facing + # the camera mirrors the dark stage and reads black. + bsdf.inputs["Metallic"].default_value = 0.55 + bsdf.inputs["Roughness"].default_value = 0.45 + return mat + + +def bookshelf_materials(): + return ( + wood_material("BookshelfWood"), + cover_material("BookCloth", 420.0, 0.78, 0.86), + cover_material("BookLeather", 70.0, 0.62, 0.48), + paper_material("BookPages"), + gilt_material("BookGilt"), + ) + + +def assign_slots(obj, mats): + slots = obj.data.materials + for i, mat in enumerate(mats): + if i < len(slots): + slots[i] = mat + else: + slots.append(mat) + + +# --- measurement ------------------------------------------------------------ + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs, ys, zs = [c.x for c in corners], [c.y for c in corners], [c.z for c in corners] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def uv_stats(mesh): + uv = mesh.uv_layers.active + if uv is None: + return 0.0, 0.0, 1.0, 1.0, 0.0, 0 + data = uv.data + us = [loop.uv[0] for loop in data] + vs = [loop.uv[1] for loop in data] + aabbs = [] + for poly in mesh.polygons: + pu = [data[i].uv[0] for i in poly.loop_indices] + pv = [data[i].uv[1] for i in poly.loop_indices] + aabbs.append((min(pu), min(pv), max(pu), max(pv))) + span = max(1e-6, max(a[2] - a[0] for a in aabbs), max(a[3] - a[1] for a in aabbs)) + buckets = {} + for i, a in enumerate(aabbs): + for c in range(int(a[0] // span), int(a[2] // span) + 1): + for r in range(int(a[1] // span), int(a[3] // span) + 1): + buckets.setdefault((c, r), []).append(i) + overlap = 0.0 + seen = set() + for members in buckets.values(): + for ii in range(len(members)): + for jj in range(ii + 1, len(members)): + i, j = members[ii], members[jj] + key = (i, j) if i < j else (j, i) + if key in seen: + continue + seen.add(key) + a, b = aabbs[i], aabbs[j] + overlap += max(0.0, min(a[2], b[2]) - max(a[0], b[0])) * max( + 0.0, min(a[3], b[3]) - max(a[1], b[1])) + return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) + + +def face_area(me, poly): + idxs = poly.vertices + v0 = me.vertices[idxs[0]].co + area = 0.0 + for i in range(1, len(idxs) - 1): + area += (me.vertices[idxs[i]].co - v0).cross(me.vertices[idxs[i + 1]].co - v0).length * 0.5 + return area + + +def hygiene_audit(me): + ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) + zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS) + bm = bmesh.new() + try: + bm.from_mesh(me) + loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0) + loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0) + nonman = sum(1 for e in bm.edges if not e.is_manifold) + ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS) + doubles = len(ret.get("targetmap") or {}) + finally: + bm.free() + return {"ngons": ngons, "loose_v": loose_v, "loose_e": loose_e, + "nonman": nonman, "zero_area": zero_area, "doubles": doubles} + + +def shells(me): + neighbors = [[] for _ in range(len(me.vertices))] + for edge in me.edges: + a, b = edge.vertices + neighbors[a].append(b) + neighbors[b].append(a) + seen = [False] * len(me.vertices) + groups = [] + for start in range(len(me.vertices)): + if seen[start]: + continue + seen[start] = True + stack = [start] + group = [] + while stack: + cur = stack.pop() + group.append(cur) + for nxt in neighbors[cur]: + if not seen[nxt]: + seen[nxt] = True + stack.append(nxt) + groups.append(group) + return groups + + +def zfight_pairs(me): + """Coplanar face pairs from *different shells* (copied from showcase/grindstone).""" + owner = {} + for si, g in enumerate(shells(me)): + for vi in g: + owner[vi] = si + faces = [(p.normal.copy(), p.center.copy(), owner.get(p.vertices[0], -1)) + for p in me.polygons] + kd = KDTree(len(faces)) + for i, (_n, c, _s) in enumerate(faces): + kd.insert(c, i) + kd.balance() + hits = 0 + for i, (ni, ci, si) in enumerate(faces): + for _co, j, _d in kd.find_range(ci, COPLANAR_CENTRE_MAX): + if j <= i: + continue + nj, cj, sj = faces[j] + if si == sj: + continue + if abs(abs(ni.dot(nj)) - 1.0) > COPLANAR_NORMAL_EPS: + continue + if abs(ni.dot(cj - ci)) > COPLANAR_PLANE_EPS: + continue + hits += 1 + return hits + + +def right_angle_edges(me): + """Manifold edges between two wood faces that meet within 5 degrees of 90.""" + count = 0 + bm = bmesh.new() + try: + bm.from_mesh(me) + for e in bm.edges: + if len(e.link_faces) != 2: + continue + if any(f.material_index != WOOD_IDX for f in e.link_faces): + continue + if abs(e.calc_face_angle(0.0) - math.pi / 2.0) <= RIGHT_ANGLE_TOL: + count += 1 + finally: + bm.free() + return count + + +def shell_tree(me, groups): + member = set(i for g in groups for i in g) + order = sorted(member) + remap = {v: k for k, v in enumerate(order)} + polys = [[remap[i] for i in p.vertices] for p in me.polygons if p.vertices[0] in member] + return BVHTree.FromPolygons([me.vertices[i].co.copy() for i in order], polys) + + +def 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 + big, polys = {}, {} + for p in me.polygons: + si = owner[p.vertices[0]] + polys.setdefault(si, []).append(p) + if si not in big or p.area > big[si].area: + big[si] = p + out = {k: [] for k in ("side", "top", "cornice", "back", "cross", "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 + rec = {"g": g, "pts": pts, "lo": lo, "hi": hi, "ext": ext, + "c": sum(pts, Vector()) / len(pts)} + m = mats.get(g[0], -1) + if m == WOOD_IDX: + if ext.x < 0.05 and ext.z > 0.9: + out["side"].append(rec) + elif ext.y < 0.03 and ext.z > 0.9: + out["back"].append(rec) + elif lo.z > SIDE_H: + out["cornice"].append(rec) + elif hi.z > SIDE_H: + out["top"].append(rec) + elif ext.x > 0.7: + out["cross"].append(rec) + else: + out["other"].append(rec) + elif m in (CLOTH_IDX, LEATHER_IDX): + face = big[si] + rec["n"] = face.normal.copy() + # The two boards' outer faces: the largest faces, one per side. + ranked = sorted(polys[si], key=lambda p: -p.area)[:4] + rec["boards"] = [(p.center.copy(), p.normal.copy()) for p in ranked + if abs(abs(p.normal.dot(face.normal)) - 1.0) < 1e-3] + 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 bookshelf_audit(me): + parts = classify(me) + out = {k: len(v) for k, v in parts.items()} + sides = sorted(parts["side"], key=lambda r: r["c"].x) + out["side_z"] = max((r["lo"].z for r in sides), default=99.0) + + # Every cross member is housed in both sides: bite past the inner face, + # and clear of the far face. + bite, far = 99.0, 99.0 + if len(sides) == 2: + left_in, left_out = sides[0]["hi"].x, sides[0]["lo"].x + right_in, right_out = sides[1]["lo"].x, sides[1]["hi"].x + for r in parts["cross"]: + bite = min(bite, left_in - r["lo"].x, r["hi"].x - right_in) + far = min(far, r["lo"].x - left_out, right_out - r["hi"].x) + out["dado"], out["dado_far"] = bite, far + + # 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]: + # The cover every vertex of the part sits closest to, on average: + # a page block's corners are in its own boards, a band on its own + # spine. A centroid alone is nearer a neighbour's board in a stack. + 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) + + # Pages glued in: every page-block corner inside the cover's boards. + 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)) + + # Bands on the spine: inner face inside the cover, outer face proud. + 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: upright, lying or leaning. + 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 "lean") + c["tilt"] = tilt + ds = [p.dot(n) for p in c["pts"]] + c["thick"] = max(ds) - min(ds) + out["n_lean"] = sum(1 for u in units if u["cover"]["pose"] == "lean") + out["n_lying"] = sum(1 for u in units if u["cover"]["pose"] == "lying") + + # Seat: what each book stands on is the highest shelf or cover under its + # footprint whose top is within reach of its lowest point. + shelves = [r for r in parts["cross"] if r["ext"].y > 0.15] + horizontals = shelves + parts["top"] + seats = [] + for u in units: + c = u["cover"] + cands = [r for r in shelves + covers if r is not c and overlaps_xy(r, c) + and c["lo"].z - 0.02 <= 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) + + # Headroom: the top of each book (bands included) under the member above. + heads = [] + for u in units: + c = u["cover"] + top = max([c["hi"].z] + [r["hi"].z for r in u["band"]]) + above = [h for h in horizontals if h["lo"].z > c["lo"].z + 0.05] + if above: + heads.append(min(h["lo"].z for h in above) - top) + out["headroom"] = min(heads, default=-99.0) + + # 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 + + # Lean contact: the neighbour's head edge in the leaning board, as a band. + leans = [] + for u in units: + c = u["cover"] + if c["pose"] != "lean": + continue + n = c["n"] if c["n"].x > 0 else -c["n"] + up_x = -n.z # the board's up direction, in the XZ plane, has this x + side = 1.0 if up_x > 0 else -1.0 + sup = u.get("support") + mates = [v for v in units if v["cover"]["pose"] == "up" and v.get("support") is sup + and (v["cover"]["c"].x - c["c"].x) * side > 0] + if not mates: + leans.append(-99.0) + continue + mate = min(mates, key=lambda v: abs(v["cover"]["c"].x - c["c"].x)) + u["lean_on"] = mate + # Depth past the plane of the leaning board's outer face, read off the + # mesh, over the neighbour's vertices near that board. A plane, not + # the cover solid: the board is 2.8 mm thick, and a head edge driven + # right through it is in the hollow, outside the cover again. + to_mate = mate["cover"]["c"] - c["c"] + facing = [b for b in c["boards"] if b[1].dot(to_mate) > 0.0] + if not facing: + leans.append(-99.0) + continue + p0, n = max(facing, key=lambda b: b[0].dot(b[1])) + ds = [-(p - p0).dot(n) for p in mate["cover"]["pts"] + if u["tree"].find_nearest(p, LEAN_SEARCH)[0] is not None] + leans.append(max(ds, default=-99.0)) + out["lean"] = (min(leans, default=-99.0), max(leans, default=99.0)) + + # 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 leaning book + # on its neighbour, a book in the shelf it stands on. + 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 ("side", "back", "cross", "top", "cornice") 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"] or u.get("lean_on") is v: + 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"): + 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; 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) == WOOD_IDX] + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + vs = [bm.verts.new(p) for p in pts] + ret = bmesh.ops.convex_hull(bm, input=vs) + drop = [] + for v in ret["geom_interior"] + ret["geom_unused"]: + if isinstance(v, bmesh.types.BMVert) and v not in drop: + drop.append(v) + bmesh.ops.delete(bm, geom=drop, context="VERTS") + bmesh.ops.dissolve_limit(bm, angle_limit=math.radians(1.0), + verts=list(bm.verts), edges=list(bm.edges)) + bmesh.ops.triangulate(bm, faces=list(bm.faces)) + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + col = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(col) + return col + + +def setup_bake_image(obj, target_mat, size): + img = bpy.data.images.new("BookshelfNrm", 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_bookshelf_mesh("BookshelfLow", **flags) + hi_flags = {k: v for k, v in flags.items() if k != "stray_vert"} + high = build_bookshelf_mesh("BookshelfHigh", **hi_flags) + mats = bookshelf_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("bookshelf 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, "BookshelfLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "BookshelfLOD2", 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, "BookshelfCollider") + col_tris = triangle_count(collider.data) + + export_path = os.path.join(tempfile.gettempdir(), f"bdt_bookshelf_{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 = bookshelf_audit(low.data) + right = right_angle_edges(low.data) + + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") + print(f"measured mat_index_counts={dict(sorted(idx_counts.items()))}") + print(f"measured base_tris={base_tris} lod1_tris={lod1_tris} " + f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}") + print(f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) " + f"overlap={overlap:.6f} nfaces={nfaces}") + print(f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"outer={OUTER_SIZE} zmin={bb[2]:.5f}") + print(f"measured collider_tris={col_tris} bake={bake_result} " + f"bake_has_data={img.has_data} export_bytes={export_size}") + print(f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}") + print(f"measured parts sides={sa['side']} top={sa['top']} cornice={sa['cornice']} " + f"back={sa['back']} cross={sa['cross']} covers={sa['cover']} pages={sa['pages']} " + f"bands={sa['band']} other={sa['other']} unmatched={sa['unmatched']} " + f"lean={sa['n_lean']} lying={sa['n_lying']} side_z={sa['side_z']:.5f}") + print(f"measured joints dado={sa['dado']:.5f} 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}") + print(f"measured headroom={sa['headroom']:.5f} thick_spread={sa['thick_spread']:.3f} " + f"height_spread={sa['height_spread']:.3f} " + f"lean=({sa['lean'][0]:.5f},{sa['lean'][1]:.5f}) book_hits={sa['book_hits']} " + f"right_angle_wood={right}") + + if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): + return (fail(f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", 4),) + nothing + if nmat != MATERIAL_COUNT or distinct != MATERIAL_COUNT: + return (fail(f"material slots {nmat} distinct {distinct} != {MATERIAL_COUNT}", 5),) + nothing + for idx, floor in FACE_FLOORS.items(): + if idx_counts.get(idx, 0) < floor: + return (fail(f"material {idx} faces {idx_counts.get(idx, 0)} < {floor}", 5),) + nothing + if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: + return (fail(f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", 6),) + nothing + if overlap > UV_OVERLAP_MAX: + return (fail(f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7),) + nothing + if (abs(size_x - OUTER_SIZE[0]) > BBOX_TOL or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL + or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL): + return (fail(f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) off outer {OUTER_SIZE}", 8),) + nothing + if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): + return (fail(f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " + "(--skip-decimate is the designed fail)", 9),) + nothing + if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): + return (fail(f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", 9),) + nothing + if col_tris > COLLIDER_TRIS_MAX: + return (fail(f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", 11),) + nothing + if bake_result != {"FINISHED"} or not img.has_data: + return (fail(f"bake failed result={bake_result} has_data={img.has_data}", 12),) + nothing + if export_size <= 0: + return (fail("export file missing or empty", 13),) + nothing + if (hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"] + or hyg["doubles"] or hyg["ngons"] or zf): + return (fail(f"hygiene {hyg} zfight={zf} (--stray-vert is the designed fail)", 15),) + nothing + if abs(bb[2]) > ZMIN_EPS: + return (fail(f"zmin {bb[2]:.6f} not within {ZMIN_EPS} of 0 " + "(--lift-z is the designed fail)", 16),) + nothing + if sa["side"] != 2 or sa["side_z"] > SIDE_Z_MAX: + return (fail(f"sides: {sa['side']} of 2, worst side z={sa['side_z']:.5f} > {SIDE_Z_MAX} " + "(--float-side is the designed fail)", 16),) + nothing + if sa["cross"] != N_CROSS or sa["dado"] < DADO_MIN or sa["dado_far"] < DADO_FAR_MIN: + return (fail(f"{sa['cross']} of {N_CROSS} cross members, dado bite {sa['dado']:.5f} " + f"(min {DADO_MIN}), far face {sa['dado_far']:.5f} (min {DADO_FAR_MIN}) " + "(--short-shelves is the designed fail)", 17),) + nothing + if (sa["units"] != N_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["headroom"] < HEADROOM_MIN: + return (fail(f"headroom {sa['headroom']:.5f} < {HEADROOM_MIN} " + "(--tall-book is the designed fail)", 20),) + 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)", 21),) + nothing + if (sa["n_lean"] != N_LEAN or sa["n_lying"] != N_LYING + or sa["lean"][0] < LEAN_BITE_MIN or sa["lean"][1] > LEAN_BITE_MAX): + return (fail(f"{sa['n_lean']} of {N_LEAN} leaning and {sa['n_lying']} of {N_LYING} lying " + f"books, lean contact {sa['lean']} outside [{LEAN_BITE_MIN}, {LEAN_BITE_MAX}] " + "(--air-lean and --deep-lean are the designed fails)", 22),) + nothing + if sa["book_hits"]: + return (fail(f"{sa['book_hits']} book overlaps, need 0 " + "(--crowd-books is the designed fail)", 23),) + nothing + if right: + return (fail(f"{right} right-angle wood edges, need 0 " + "(--sharp-shelf is the designed fail)", 24),) + nothing + return 0, low, high, mats, tex, collider + + +def wire_normal(mat, tex): + nt = mat.node_tree + nrm = nt.nodes.new("ShaderNodeNormalMap") + nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) + nt.links.new(nrm.outputs["Normal"], nt.nodes["Principled BSDF"].inputs["Normal"]) + + +def render_still(low, mats, tex, path, engine): + scene = bpy.context.scene + wire_normal(mats[WOOD_IDX], tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + # Level on the floor: turned about Z only. + low.rotation_euler.z = math.radians(-14.0) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=60.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Floor") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 8.5, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0) + scene.world = world + + def light(name, kind, loc, energy, size, col, rot=(0, 0, 0)): + ld = bpy.data.lights.new(name, kind) + ld.energy = energy + if kind == "AREA": + ld.size = size + else: + ld.shadow_soft_size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", "AREA", (-2.4, -3.4, 3.2), 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.27, -3.74, 1.175) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.565) + scene.collection.objects.link(aim) + con = cam.constraints.new("TRACK_TO") + con.target = aim + con.track_axis = "TRACK_NEGATIVE_Z" + con.up_axis = "UP_Y" + scene.camera = cam + + scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + scene.cycles.samples = 32 + scene.cycles.device = "CPU" + else: + try: + scene.eevee.taa_render_samples = 64 + except AttributeError: + pass + scene.render.resolution_x = 1280 + scene.render.resolution_y = 720 + scene.render.image_settings.file_format = "WEBP" if path.lower().endswith(".webp") else "PNG" + if path.lower().endswith(".webp"): + scene.render.image_settings.quality = 90 + scene.render.filepath = path + scene.view_settings.view_transform = "Standard" + + fcode = gallery_framing.check_framing(scene, cam, hero=[low], elements=[low], stage=[floor, wall]) + if fcode: + return fcode + bpy.ops.render.render(write_still=True) + if not (os.path.exists(path) and os.path.getsize(path) > 0): + return fail("render produced no file", 14) + return 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None) + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) + p.add_argument("--skip-decimate", action="store_true") + p.add_argument("--stray-vert", action="store_true") + p.add_argument("--lift-z", action="store_true") + p.add_argument("--float-side", action="store_true") + p.add_argument("--short-shelves", action="store_true") + p.add_argument("--loose-pages", action="store_true") + p.add_argument("--float-books", action="store_true") + p.add_argument("--float-bands", action="store_true") + p.add_argument("--tall-book", action="store_true") + p.add_argument("--uniform-books", action="store_true") + p.add_argument("--air-lean", action="store_true") + p.add_argument("--deep-lean", action="store_true") + p.add_argument("--crowd-books", action="store_true") + p.add_argument("--sharp-shelf", action="store_true") + args = p.parse_args(argv) + + code, low, _high, mats, tex, _col = check( + args.skip_decimate, + lift_z=args.lift_z, + stray_vert=args.stray_vert, + float_side=args.float_side, + short_shelves=args.short_shelves, + sharp_shelf=args.sharp_shelf, + loose_pages=args.loose_pages, + float_books=args.float_books, + float_bands=args.float_bands, + tall_book=args.tall_book, + uniform_books=args.uniform_books, + crowd_books=args.crowd_books, + air_lean=args.air_lean, + deep_lean=args.deep_lean, + ) + 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("bookshelf 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/bookshelf/preview.webp b/showcase/bookshelf/preview.webp new file mode 100644 index 00000000..e12eb7ca Binary files /dev/null and b/showcase/bookshelf/preview.webp differ diff --git a/showcase/gallery.json b/showcase/gallery.json index c31232bb..766b4e44 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -452,6 +452,19 @@ "mesh", "export" ] + }, + { + "name": "bookshelf", + "dir": "showcase/bookshelf", + "teaches": "A procedural bookcase — two housed shelves, a base on a recessed plinth, lapped back boards and a stepped cornice, filled with thirty-eight rounded-spine hardbacks standing, stacked and leaning — carried through UVs, bake, LOD, convex collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "alt": "A stained wooden bookcase with three shelves of cloth and leather hardbacks with gilt spine bands, some standing, some stacked flat, and two leaning against their neighbours.", + "witnessesFix": "Recomputed: 10908 tris, five materials with 338 wood, 2050 cloth, 1066 leather, 228 paper and 1976 gilt faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.972×0.332×1.176 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, both sides on the floor, four cross members 8 mm into both sides, 38 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, 44 mm headroom, thickness and height spread 2.90 and 1.65, both leaning books 0.8 mm onto their neighbour's head edge, zero unplanned book overlaps, zero right-angle wood edges, convex collider 68 tris. --air-lean and --deep-lean exit 22 on the lean contact while every seat, the envelope and the overlap budget still pass; --crowd-books exits 23; --uniform-books 21; --tall-book 20.", + "hero": "docs/gallery/assets/bookshelf-hero.webp", + "preview": "showcase/bookshelf/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index de48bb2d..393f8f01 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -107,5 +107,6 @@ {"name": "brazier", "script": "showcase/brazier/brazier.py"}, {"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": "butter-churn", "script": "showcase/butter-churn/butter_churn.py"}, + {"name": "bookshelf", "script": "showcase/bookshelf/bookshelf.py"} ]