shipping-crate
A procedural shipping crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
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.
category Village
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.
| 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) |
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 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.
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.
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.apothecary-shelf.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.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.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.--deep-lean exited 0. The contact is now measured against the plane of the board's outer face.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 |
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 |
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) |
# 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.
| 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.
"""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)