Rendered headless by the showcase piece itself — click to zoom.
+blender --background --python showcase/apothecary-shelf/apothecary_shelf.py --+ +
An apothecary shelf: a stained carcass with three shelves housed in its sides, a gallery rail along each shelf, lapped back boards, a bank of three drawers with brass knobs and an arched crest, stocked with fifteen vessels in five forms (bottle, flask, jar, albarello, vial). Every vessel is corked or lidded and carries a paper label. A showcase piece, not an example — it witnesses no API contract. It asserts that generated geometry meets declared asset budgets, recomputed from the finished mesh.
+What it composes
+| Shipped content | Used for |
|---|---|
skills/mesh-editing-and-bmesh | chamfered boards, arched crest, lathed vessels, closures and knobs, labels on the bodies' rings, all in one bmesh |
skills/custom-properties | face attributes (PlankTone, GrainDir, Tint) read by the shaders |
skills/procedural-materials-and-shaders | grained stain, tinted glass, glaze, cork, inked paper, aged brass |
skills/bake-high-to-low | Cycles tangent-space normal bake, high onto low |
skills/engine-export-presets | Unity glTF (export_yup=True) |
skills/depsgraph-and-evaluated-data | evaluated triangle counts for the LOD ratios |
snippets/decimate_to_budget.py | LOD1 / LOD2 COLLAPSE chain |
snippets/convex_hull_collider.py | one hull over the carcass and knobs |
snippets/lod_chain.py | LOD naming and ratio pattern |
examples/mesh-hygiene-audit | hygiene combinatorics (copied, not imported) |
The budget that matters
+A stocked shelf fails invisibly when a vessel does not fit between its shelf and the one above. A flask a few centimetres too tall runs its neck up into the next shelf, and nothing else notices:
+- the flask still stands on its shelf, so the seat budget passes;
- the shelf above is still in its dado, so the joint budget passes;
- the neck stops inside the shelf, so the bounding box does not move;
- the triangle count is the same, because only the height changed.
The piece reads each vessel's top (body or closure, whichever is higher) and the underside of the lowest horizontal member above it off the finished mesh, and asserts a headroom floor of 20 mm. The tightest vessel clears by 24.05 mm.
+--tall-flask is the falsifier built for exactly this. It stretches the flask on the middle shelf by 1.20. It then runs 5.5 mm into the shelf above and exits 20, while every seat, every joint and the envelope still pass.
Budgets
+Declared in the script as named constants, recomputed from the generated mesh. Measured values are from Blender 5.2.1; every one is byte-identical on 4.5.11 and 5.1.2 (only the glTF file size differs, by up to 8 bytes, which is exporter metadata and not a budget).
+| Budget | Band | Measured | ||
|---|---|---|---|---|
| Base triangles | 9800–11100 | 10456 | ||
| LOD1 ratio | 0.32–0.62 | 0.5000 | ||
| LOD2 ratio | 0.10–0.35 | 0.2200 | ||
| Material slots | exactly 6, distinct | 6 | ||
| Wood / glass / glaze faces | ≥ 450 / 1800 / 1300 | 525 / 2080 / 1520 | ||
| Cork / paper / brass faces | ≥ 850 / 380 / 300 | 960 / 420 / 336 | ||
| UV bounds | inside 0..1 | (0.0009, 0.0010)–(0.9991, 0.9990) | ||
| UV AABB overlap | ≤ 1e-5 | 0.000000 | ||
| Outer AABB | 0.876 × 0.274 × 1.076 m ± 0.020 | 0.8760 × 0.2740 × 1.0760 | ||
| Collider triangles | ≤ 200 | 138 | ||
| Normal bake | {'FINISHED'} with image data | {'FINISHED'}, has_data=True | ||
| glTF export | file written, non-empty | ~449 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 | 7 cross members, each ≥ 5 mm into both sides and ≥ 6 mm clear of the far face | 8.00 mm in, 14.00 mm clear | ||
| Closure bite | every cork and lid 2–10 mm into its body | 5.00 mm | ||
| Knob bite | 3 knobs, each 2–10 mm into its drawer front | 4.00 mm | ||
| Vessel seat | 15 vessels, each 0.5–3.0 mm into its shelf | 1.00–1.80 mm | ||
| Drawer seat | 3 drawer fronts, each 1–4 mm into the base | 2.00 mm | ||
| Label seat | inner face 0.1–0.8 mm inside the body, outer face ≥ 0.4 mm proud | 0.30 mm in, 0.80 mm proud | ||
| Headroom | every vessel ≥ 20 mm under the member above | 24.05 mm | ||
| Vessel overlaps | 0, vessel against vessel and against the carcass | 0 | ||
| Form spread | widest / narrowest body ≥ 2.0; tallest / shortest ≥ 1.5 | 3.146 / 2.202 | ||
| Edge treatment | right-angle wood edges | 0 |
Real-world size: 0.88 m wide, 1.08 m to the top of the crest and 0.27 m deep. It is a wall shelf for a shop counter or a study, and each compartment is 260 mm clear.
+Construction
+- Carcass. Two full-height sides carry everything and are the only named supports. The base, the three shelves and the three gallery rails are each housed
DADO(8 mm) into both sides and stop 14 mm short of the outer faces. The top board is seated over the sides and the back boards (TOP_SEAT). The crest is an extruded outline whose top is a shallow arc, seatedCREST_BITEinto the top board. Every board is chamfered at 2.5 mm withmaterial=pinned to wood, edges sorted by index. The crest's caps are single n-gons, chamfered and then triangulated. - Back boards. Four vertical boards, lapped 2 mm into one another rather than butted. An open seam showed daylight through the back in the first close-up. A lap on one plane is a coplanar pair, so every other board steps forward 1.5 mm. The shelves and base bite into them.
- Drawers. Three fronts stand
DRAWER_BITEinto the base, with a 4 mm reveal all round. Each has a turned brass knob lathed along −Y, its shankKNOB_BITEinto the front. - Vessels. Each vessel is a closed lathe of exactly twelve rings between two poles, so every form costs the same triangles. Every foot is chamfered. The five forms:
- bottle: shoulder, neck and lip
- flask: a bulb on a small flat base, with a long neck
- jar: squat and wide-mouthed
- albarello: waisted
- vial: small
Sizes vary by a closed-form ±5 % per vessel. Each shelf shares its clear width out evenly. Vessels step front and back alternately inside the depth left between the rail and the back boards.
+- Closures. A cork is narrower than the mouth and flares above it. Its flare stops short of the neck's own radius, because a cork wall on the neck's cylinder is a coplanar pair with it. A lid is wider than the rim: a skirt, a dome and a knob. Both sit
CLOSURE_BITEinto the body. - Labels. Each label is built on the host's arc with the host's segment count. Every label vertex sits at a body vertex's own angle and ring height, so the paper follows a waist or a bulb exactly. The inner face is 0.3 mm inside the body and the outer face 0.8 mm proud. The label is centred on the front and turned with the vessel's yaw.
- Seats vary by vessel. Each vessel on a shelf sinks by a different step (1.0–1.8 mm). Sunk to one depth, two bottom caps on a shelf lie in one plane, a coplanar cross-shell pair.
Findings the budgets forced
+- Coplanar corks. The first corks flared to 1.10 × their radius, which put a cork wall 0.1 mm off the neck cylinder of the same bottle: 32 coplanar pairs on two bottles. The flare is now 1.20 × and the count is 0.
- Matching parts to vessels. Closures and labels were first matched to the body with the nearest axis in plan. The three shelves stack vessels in near-identical plan positions, so 12 of 15 labels went to a vessel on another shelf, and the label seat read −23 mm. They are now matched only among bodies at the part's own height.
- Glass. EEVEE transmission rendered every bottle as grainy speckle. It stayed with and without raytracing, and at 256 samples. The glass now carries its depth in a facing ramp and a clear coat: dark through the middle, lighter at the silhouette.
- Daylight through the back. A 2 mm butt seam between back boards showed the background as a bright line down the middle of the upper compartment. The boards are now lapped with an alternating step.
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; seats, lap steps and cork flares chosen to keep it at 0 |
| Named supports | yes | the two sides (--float-side) |
| Joint-fit budgets | yes | dado bite and far-face clearance per cross member (--short-shelves) |
| A member is tenoned into its seat | yes | sides into the top, crest into the top, back boards into base and top |
| A platform bears on something | yes | every shelf housed in both sides and biting the back boards |
| Carried parts bite their bearers | yes | vessels in shelves (--float-jars), drawer fronts in the base (--lift-drawers) |
| A joint bites; touching is not joining | yes | closures (--pop-corks) and knobs (--pull-knobs) as banded bites |
| Bands on a curved host are built on the host's arc | yes | labels on the body's own rings and angles (--float-labels) |
| Scattered parts do not interpenetrate | yes | exit 21 (--crowd-jars) |
| Scatter varies in size | yes | form spread, exit 22 (--uniform-vessels) |
| Edge treatment: no right angles | yes | exit 23 (--sharp-rail), wood only; lathed feet are chamfered by construction |
| Aim an edge falsifier at one member | yes | one rail left square: 12 edges, −32 triangles |
| Chamfer n-gon caps, then triangulate | yes | crest caps |
| Sort bmesh operator inputs | yes | bevel edges sorted by index |
| A plank wall is boards | yes | four lapped back boards |
| Identical boards read as CG | yes | per-board PlankTone and GrainDir |
| Shading is part of the model | yes | vessels, closures and knobs smooth (blown, turned); every edge over 35° hard, so boards read flat with crisp chamfers |
| One substance, one slot | yes | wood, glass, glaze, cork, paper, brass; glass and glaze colour per vessel from Tint |
| Iron is not chrome | n/a | brass knobs: metallic 0.9, roughness 0.42, darkened by noise |
| Bake texels per UV cell | measured, not a budget | 512 px over a 32-cell grid, about 15 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 except --pull-knobs (+6 mm in Y, inside BBOX_TOL) |
| Mirror symmetry, plumb, rope, masonry, roofs, rings, scatter on terrain, vessels with liquid | no | the piece has none of these |
Falsifiers
+Each breaks one pipeline stage so a named budget fails. All fourteen were run on 4.5.11, 5.1.2 and 5.2.1 and exited the same declared code on all three.
+| 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 three shelves 4 mm short of the sides; −4.0 mm | 17 |
--pop-corks | closure bite | lifts every cork and lid 10 mm; −5.0 mm | 17 |
--pull-knobs | knob bite | pulls every knob 8 mm out of its front; −4.0 mm | 17 |
--float-jars | vessel seat | lifts every vessel 6 mm off its shelf; −5.0 to −4.2 mm | 18 |
--lift-drawers | drawer seat | lifts the drawer fronts 3 mm; −1.0 mm | 18 |
--float-labels | label seat | builds every label 1.1 mm off its body; −1.1 mm | 18 |
--tall-flask | headroom | stretches the middle-shelf flask 1.20 into the shelf above; −5.5 mm | 20 |
--crowd-jars | vessel overlaps | draws the bottom shelf's jars to 0.55 of their spacing; 3 overlaps | 21 |
--uniform-vessels | form spread | one radius and height for every vessel; 1.000 / 1.000 | 22 |
--sharp-rail | edge treatment | leaves the top rail unchamfered; 12 right-angle edges | 23 |
Exit codes
+File-local and sequential. 9 is a valid check code. 1 is the FATAL wrapper — a crash, never a named check. 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, closure or knob bite (--short-shelves, --pop-corks, --pull-knobs) |
| 18 | Vessel, drawer or label seat (--float-jars, --lift-drawers, --float-labels) |
| 20 | Headroom under the member above (--tall-flask) |
| 21 | Vessels overlap each other or the carcass (--crowd-jars) |
| 22 | Form spread below floor (--uniform-vessels) |
| 23 | Right-angle wood edges (--sharp-rail) |
Run it
+# Budget check, no render. ~4.5 s on 5.1 and 5.2.
+blender --background --python apothecary_shelf.py --
+
+# Falsifier: the middle-shelf flask runs into the shelf above. Must exit 20.
+blender --background --python apothecary_shelf.py -- --tall-flask
+
+# Falsifier: the shelves stop short of their dados. Must exit 17.
+blender --background --python apothecary_shelf.py -- --short-shelves
+
+# Render the gallery still (EEVEE; --engine cycles on a GPU-less host).
+blender --background --python apothecary_shelf.py -- --output apothecary_shelf.webp
+Smoke runs the check-only path. It does not pass --output or any falsifier.
Cross-version measurements
+| Value | 4.5.11 | 5.1.2 | 5.2.1 |
|---|---|---|---|
| Base triangles | 10456 | 10456 | 10456 |
| LOD1 / LOD2 tris | 5228 / 2300 | same | same |
| Face counts (wood / glass / glaze / cork / paper / brass) | 525 / 2080 / 1520 / 960 / 420 / 336 | same | same |
| Outer AABB | 0.8760 × 0.2740 × 1.0760 | same | same |
| Collider tris | 138 | 138 | 138 |
| Headroom / form spread | 24.05 mm / 3.146, 2.202 | same | same |
| glTF bytes | 448604 | 448600 | 448596 |
Source
+ +"""Game-ready apothecary shelf — a showcase piece, not an example. + +Asserts budget conformance of a procedural apothecary shelf: a stained +carcass with housed shelves, gallery rails, a bank of three drawers and a +crest board, stocked with fifteen turned and blown vessels in five forms +(bottle, flask, jar, albarello, vial), each closed by a cork or a lid and +carrying a paper label. Carried through UVs, six materials (wood, glass, +glaze, cork, paper, brass), a high-to-low normal bake, an LOD chain, a +convex collider, and a Unity glTF export. + +The budget that matters here is the one a stocked shelf fails invisibly: +every vessel has to fit between its shelf and the one above it. A flask a +few centimetres too tall runs its neck up into the next shelf, and the +bounding box, the triangle count and every seat budget still pass, +because the flask stands where it should and the shelf above it is still +in its dado. The piece reads each vessel's top and the underside of the +member above it off the finished mesh and asserts a headroom floor. + +Budgets are declared below and recomputed from the generated result. +They are not API-contract witnesses. Each falsifier violates one named +budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh +hygiene, ``--lift-z`` grounded zmin, ``--float-side`` the named sides, +``--short-shelves`` the dado bite, ``--pop-corks`` the closure bite, +``--pull-knobs`` the knob bite, ``--float-jars`` the vessel seat, +``--lift-drawers`` the drawer seat, ``--float-labels`` the label seat, +``--tall-flask`` the headroom, ``--crowd-jars`` vessel interpenetration, +``--uniform-vessels`` the size spread, ``--sharp-rail`` edge treatment. + +No randomness: every vessel's form, size, yaw, tint and seat is a +closed-form term of its index. DECIMATE COLLAPSE triangle counts are not +byte-identical across Blender versions — the LOD gate is a ratio band. + + blender --background --python apothecary_shelf.py -- + blender --background --python apothecary_shelf.py -- --tall-flask + blender --background --python apothecary_shelf.py -- --output apothecary_shelf.png +""" +import argparse +import math +import os +import sys +import tempfile +import traceback + +import bmesh +import bpy +from mathutils import Matrix, Vector +from mathutils.bvhtree import BVHTree +from mathutils.kdtree import KDTree + +_REPO = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) +) +sys.path.insert(0, os.path.join(_REPO, "examples")) +sys.dont_write_bytecode = True +import gallery_framing # noqa: E402 + +# The carcass. X across, Y front (-) to back (+), Z up. Two full-height +# sides carry everything; every cross member is housed into both of them. +W_OUT = 0.840 +SIDE_T = 0.022 +DEPTH = 0.250 +SIDE_H = 1.000 +DADO = 0.008 +TOP_T = 0.024 +TOP_SEAT = 0.006 +TOP_OVER = 0.018 +TOP_BACK_OVER = 0.006 +CREST_H = 0.058 +CREST_SHOULDER = 0.028 +CREST_ARC = 12 +CREST_T = 0.018 +CREST_BITE = 0.004 +BACK_T = 0.012 +BACK_INSET = 0.010 +BACK_BITE = 0.005 +N_BACK = 4 +BACK_LAP = 0.002 +BACK_STEP = 0.0015 +SHELF_T = 0.020 +SHELF_SETBACK = 0.004 +SHELF_TOPS = (0.175, 0.455, 0.735) +RAIL_H = 0.028 +RAIL_T = 0.010 +RAIL_INSET = 0.010 +RAIL_BITE = 0.002 +BASE_Z = 0.030 +BASE_T = 0.018 +BASE_SETBACK = 0.012 +N_DRAWERS = 3 +DRAWER_T = 0.018 +DRAWER_SETBACK = 0.002 +DRAWER_GAP = 0.004 +DRAWER_BITE = 0.002 +KNOB_BITE = 0.004 +KNOB_LEN = 0.022 +CHAMFER = 0.0025 + +# Vessels: a closed lathe body (twelve rings between two poles), a closure +# (cork or lid, five rings), and a paper label wrapped on the body's own +# rings. Every form has the same ring counts, so a vessel costs the same +# triangles whatever its form, and swapping forms moves no triangle budget. +SEG = 16 +LABEL_COLS = 4 +LABEL_BITE = 0.0003 +LABEL_PROUD = 0.0008 +FLOAT_LABEL = 0.0011 +CLOSURE_BITE = 0.005 +POP_CORK = 0.010 +SEAT_BASE = 0.0010 +SEAT_STEP = 0.0002 +FLOAT_JARS = 0.006 +LIFT_DRAWERS = 0.003 +PULL_KNOBS = 0.008 +FLOAT_SIDE = 0.005 +TALL_FLASK = 1.20 +CROWD = 0.55 +VESSEL_CLEAR = 0.006 +UNIFORM_R = 0.030 +UNIFORM_H = 0.150 + +# (form, glass or glaze) per shelf, left to right. +LAYOUT = ( + ("jar", "albarello", "flask", "albarello", "jar"), + ("bottle", "albarello", "vial", "bottle", "flask"), + ("vial", "bottle", "vial", "flask", "bottle"), +) +FORMS = { + # radius, body height, material, closure + "bottle": (0.034, 0.200, "glass", "cork"), + "flask": (0.050, 0.210, "glass", "cork"), + "jar": (0.055, 0.130, "glaze", "lid"), + "albarello": (0.048, 0.170, "glaze", "lid"), + "vial": (0.018, 0.100, "glass", "cork"), +} +GLASS_TINTS = ((0.42, 0.17, 0.03), (0.14, 0.30, 0.11), (0.08, 0.14, 0.36), (0.22, 0.09, 0.26)) +GLAZE_TINTS = ((0.74, 0.68, 0.54), (0.34, 0.44, 0.60), (0.36, 0.20, 0.10)) + +BBOX_TOL = 0.020 +OUTER_SIZE = (0.876, 0.274, 1.076) + +BASE_TRIS_MIN = 9800 +BASE_TRIS_MAX = 11100 +LOD1_RATIO_MIN = 0.32 +LOD1_RATIO_MAX = 0.62 +LOD2_RATIO_MIN = 0.10 +LOD2_RATIO_MAX = 0.35 +LOD1_TARGET = 0.50 +LOD2_TARGET = 0.22 +MATERIAL_COUNT = 6 +FACE_FLOORS = {0: 450, 1: 1800, 2: 1300, 3: 850, 4: 380, 5: 300} +UV_EPS = 1e-4 +UV_OVERLAP_MAX = 1e-5 +COLLIDER_TRIS_MAX = 200 +BAKE_RES = 512 +CAGE_EXTRUSION = 0.01 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +COPLANAR_NORMAL_EPS = 1e-4 +COPLANAR_PLANE_EPS = 1e-4 +COPLANAR_CENTRE_MAX = 0.05 +LIFT_Z = 0.05 +SIDE_Z_MAX = 1e-4 +DADO_MIN = 0.005 +DADO_FAR_MIN = 0.006 +CLOSURE_BITE_MIN = 0.002 +CLOSURE_BITE_MAX = 0.010 +KNOB_BITE_MIN = 0.002 +KNOB_BITE_MAX = 0.010 +SEAT_MIN = 0.0005 +SEAT_MAX = 0.0030 +DRAWER_SEAT_MIN = 0.001 +DRAWER_SEAT_MAX = 0.004 +LABEL_BITE_MIN = 0.0001 +LABEL_BITE_MAX = 0.0008 +LABEL_PROUD_MIN = 0.0004 +HEADROOM_MIN = 0.020 +DIAM_SPREAD_MIN = 2.0 +HEIGHT_SPREAD_MIN = 1.5 +RIGHT_ANGLE_TOL = math.radians(5.0) + +WOOD_IDX = 0 +GLASS_IDX = 1 +GLAZE_IDX = 2 +CORK_IDX = 3 +PAPER_IDX = 4 +BRASS_IDX = 5 +N_CROSS = len(SHELF_TOPS) * 2 + 1 + + +def eevee_engine_id(): + """EEVEE id: 'BLENDER_EEVEE' on 5.0+, 'BLENDER_EEVEE_NEXT' on 4.2-4.5.""" + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"FAIL[{code}]: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + deps = bpy.context.evaluated_depsgraph_get() + ev = obj.evaluated_get(deps) + mesh = ev.to_mesh() + try: + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + finally: + ev.to_mesh_clear() + + +def frac(x): + return x - math.floor(x) + + +# --- profiles --------------------------------------------------------------- + + +def body_profile(form, r, h): + """(r, z) from the bottom pole to the top pole: exactly twelve rings. + + Every foot is chamfered, so no body meets the shelf on a right angle. + Returns the profile, the label's ring range and the mouth radius. + """ + c = 0.003 + if form == "bottle": + n = 0.012 + rings = [(r - c, 0.0), (r, c), (r, 0.25 * h), (r, 0.45 * h), (r, 0.65 * h), + (r * 0.96, 0.72 * h), (r * 0.78, 0.80 * h), (r * 0.45, 0.86 * h), + (n, 0.90 * h), (n, 0.96 * h), (n + 0.0035, 0.965 * h), (n + 0.0035, h)] + label, mouth = (3, 5), n + elif form == "flask": + n = 0.011 + rings = [(r * 0.55, 0.0), (r * 0.62, 0.004), (r * 0.86, 0.06 * h), (r * 0.98, 0.14 * h), + (r, 0.24 * h), (r * 0.95, 0.34 * h), (r * 0.75, 0.44 * h), (r * 0.40, 0.51 * h), + (n, 0.56 * h), (n, 0.93 * h), (n + 0.003, 0.94 * h), (n + 0.003, h)] + label, mouth = (4, 6), n + elif form == "jar": + rings = [(r - c, 0.0), (r, c), (r, 0.20 * h), (r, 0.45 * h), (r, 0.70 * h), + (r * 0.99, 0.80 * h), (r * 0.93, 0.86 * h), (r * 0.86, 0.90 * h), + (r * 0.84, 0.94 * h), (r * 0.88, 0.955 * h), (r * 0.88, h - 0.002), + (r * 0.86, h)] + label, mouth = (3, 5), r * 0.88 + elif form == "albarello": + rings = [(r - c, 0.0), (r, c), (r, 0.10 * h), (r * 0.90, 0.22 * h), (r * 0.86, 0.45 * h), + (r * 0.90, 0.68 * h), (r, 0.80 * h), (r * 0.96, 0.87 * h), (r * 0.80, 0.91 * h), + (r * 0.76, 0.95 * h), (r * 0.80, 0.96 * h), (r * 0.80, h)] + label, mouth = (4, 6), r * 0.80 + else: # vial + n = 0.009 + c = 0.0018 + rings = [(r - c, 0.0), (r, c), (r, 0.20 * h), (r, 0.45 * h), (r, 0.70 * h), + (r * 0.96, 0.80 * h), (r * 0.80, 0.85 * h), (r * 0.62, 0.88 * h), + (n, 0.90 * h), (n, 0.96 * h), (n + 0.002, 0.965 * h), (n + 0.002, h)] + label, mouth = (3, 5), n + prof = [Vector((0.0, 0.0))] + [Vector(p) for p in rings] + [Vector((0.0, h))] + return prof, label, mouth + + +def closure_profile(kind, mouth): + """(r, z) from the closure's own bottom: five rings between two poles. + + A cork is narrower than the mouth and flares above it; a lid is wider + than the rim, a skirt, a dome and a knob. + """ + if kind == "cork": + rc, ln = mouth - 0.001, 0.022 if mouth > 0.010 else 0.016 + # The flare stops short of the neck's own radius: a cork wall on the + # neck's cylinder is a coplanar pair with it. + rings = [(rc, 0.0), (rc * 1.06, 0.5 * ln), (rc * 1.20, 0.8 * ln), + (rc * 1.20, ln - 0.002), (rc * 1.08, ln)] + top = ln + else: + rl = mouth + 0.004 + rings = [(rl, 0.0), (rl, 0.010), (rl * 0.80, 0.019), (0.012, 0.024), (0.014, 0.034)] + top = 0.036 + return [Vector((0.0, 0.0))] + [Vector(p) for p in rings] + [Vector((0.0, top))] + + +def knob_profile(): + """(r, t) along the knob axis, from the end buried in the drawer front.""" + rings = [(0.005, 0.0), (0.005, 0.009), (0.004, 0.011), (0.010, 0.016), (0.011, 0.019), + (0.009, 0.0215)] + return [Vector((0.0, 0.0))] + [Vector(p) for p in rings] + [Vector((0.0, KNOB_LEN))] + + +# --- construction ----------------------------------------------------------- + + +def new_island(ctx): + ctx["next"] += 1 + return ctx["next"] + + +def stamp(ctx, face, island, uvmap): + face[ctx["isl"]] = island + for loop in face.loops: + loop[ctx["uv"]].uv = uvmap[loop.vert] + + +def lathe(bm, profile, n, mat_idx, ctx, xf, phase=0.0, piece=0): + """Revolve an (r, z) profile about local Z, placed by ``xf``. + + r == 0 at either end makes a pole. One strip island. Returns the + rings (a vertex or a list of n vertices per profile point). + """ + island = new_island(ctx) + s = [0.0] + for a, b in zip(profile, profile[1:]): + s.append(s[-1] + (b - a).length) + rings = [] + for p in profile: + if p.x <= 0.0: + rings.append(bm.verts.new(xf @ Vector((0.0, 0.0, p.y)))) + continue + rings.append([bm.verts.new(xf @ Vector((p.x * math.cos(phase + 2 * math.pi * k / n), + p.x * math.sin(phase + 2 * math.pi * k / n), + p.y))) + for k in range(n)]) + for k in range(len(rings) - 1): + a, b = rings[k], rings[k + 1] + for i in range(n): + j = (i + 1) % n + if isinstance(a, list) and isinstance(b, list): + vs = (a[i], a[j], b[j], b[i]) + uv = {a[i]: (s[k], i / n), a[j]: (s[k], (i + 1) / n), + b[j]: (s[k + 1], (i + 1) / n), b[i]: (s[k + 1], i / n)} + elif isinstance(b, list): + vs = (a, b[j], b[i]) + uv = {a: (s[k], (i + 0.5) / n), b[j]: (s[k + 1], (i + 1) / n), + b[i]: (s[k + 1], i / n)} + else: + vs = (a[i], a[j], b) + uv = {a[i]: (s[k], i / n), a[j]: (s[k], (i + 1) / n), + b: (s[k + 1], (i + 0.5) / n)} + f = bm.faces.new(vs) + f.material_index = mat_idx + f[ctx["piece"]] = piece + stamp(ctx, f, island, uv) + return rings + + +def add_label(bm, profile, rings, n, phase, xf, ctx, piece, float_label=False): + """A paper label on the body's own rings ``rings[0]..rings[1]``. + + Built on the host's arc with the host's segment count: every label + vertex sits at a body vertex's angle and height, so the paper follows + the body exactly, including a waist or a bulb. Inner face LABEL_BITE + inside the body, outer face LABEL_PROUD outside it. + """ + r0, r1 = rings + inner_off = FLOAT_LABEL if float_label else -LABEL_BITE + outer_off = inner_off + LABEL_BITE + LABEL_PROUD + grid = {} + for j in range(r0, r1 + 1): + p = profile[j] + for i in range(LABEL_COLS + 1): + a = phase + 2 * math.pi * i / n + for side, off in (("in", inner_off), ("out", outer_off)): + rr = p.x + off + grid[(j, i, side)] = bm.verts.new( + xf @ Vector((rr * math.cos(a), rr * math.sin(a), p.y))) + quads = [] + for j in range(r0, r1): + for i in range(LABEL_COLS): + quads.append((grid[(j, i, "out")], grid[(j, i + 1, "out")], + grid[(j + 1, i + 1, "out")], grid[(j + 1, i, "out")])) + quads.append((grid[(j + 1, i, "in")], grid[(j + 1, i + 1, "in")], + grid[(j, i + 1, "in")], grid[(j, i, "in")])) + for i in range(LABEL_COLS): + quads.append((grid[(r0, i, "in")], grid[(r0, i + 1, "in")], + grid[(r0, i + 1, "out")], grid[(r0, i, "out")])) + quads.append((grid[(r1, i, "out")], grid[(r1, i + 1, "out")], + grid[(r1, i + 1, "in")], grid[(r1, i, "in")])) + for j in range(r0, r1): + quads.append((grid[(j, 0, "in")], grid[(j, 0, "out")], + grid[(j + 1, 0, "out")], grid[(j + 1, 0, "in")])) + e = LABEL_COLS + quads.append((grid[(j + 1, e, "in")], grid[(j + 1, e, "out")], + grid[(j, e, "out")], grid[(j, e, "in")])) + for vs in quads: + f = bm.faces.new(vs) + f.material_index = PAPER_IDX + f[ctx["piece"]] = piece + + +def add_board(bm, lo, hi, boards, sharp=False): + """Axis-aligned board from corner ``lo`` to ``hi``; its verts go in ``boards``.""" + geo = bmesh.ops.create_cube(bm, size=1.0) + c = [(lo[k] + hi[k]) * 0.5 for k in range(3)] + s = [hi[k] - lo[k] for k in range(3)] + for v in geo["verts"]: + v.co = Vector((c[0] + v.co.x * s[0], c[1] + v.co.y * s[1], c[2] + v.co.z * s[2])) + for f in {f for v in geo["verts"] for f in v.link_faces}: + f.material_index = WOOD_IDX + if not sharp: + boards.extend(geo["verts"]) + return geo["verts"] + + +def add_crest(bm, a, y0, y1, z0, z_side, z_top, boards): + """The crest: a board whose top is a shallow arc, extruded through Y. + + Both caps are single n-gons, chamfered with the rest and triangulated + afterwards, so the shipped mesh has no n-gon. + """ + sag = z_top - z_side + rad = (a * a + sag * sag) / (2.0 * sag) + zc = z_top - rad + phi0 = math.asin(a / rad) + outline = [(-a, z0), (a, z0)] + for k in range(CREST_ARC + 1): + phi = phi0 - 2.0 * phi0 * k / CREST_ARC + outline.append((rad * math.sin(phi), zc + rad * math.cos(phi))) + front = [bm.verts.new((x, y0, z)) for x, z in outline] + back = [bm.verts.new((x, y1, z)) for x, z in outline] + faces = [bm.faces.new(front), bm.faces.new(list(reversed(back)))] + n = len(outline) + for i in range(n): + j = (i + 1) % n + faces.append(bm.faces.new((front[j], front[i], back[i], back[j]))) + for f in faces: + f.material_index = WOOD_IDX + boards.extend(front + back) + + +def vessel_plan(uniform=False, crowd=False): + """Every vessel: shelf, form, radius, height, centre, yaw and seat. + + Closed-form throughout. Each shelf's vessels share out the clear width + between the sides evenly, and step front and back alternately inside + the depth left between the rail and the back boards. + """ + x_in = W_OUT / 2.0 - SIDE_T + y_front = -DEPTH / 2.0 + RAIL_INSET + RAIL_T + y_back = DEPTH / 2.0 - BACK_INSET - BACK_T - BACK_STEP + out = [] + idx = 0 + for s, row in enumerate(LAYOUT): + sizes = [] + for k, form in enumerate(row): + r, h, _m, _c = FORMS[form] + jr = 1.0 + 0.10 * (frac((idx + k) * 0.6180339887 + 0.21) - 0.5) + jh = 1.0 + 0.10 * (frac((idx + k) * 0.4142135624 + 0.57) - 0.5) + if uniform: + r, h, jr, jh = UNIFORM_R, UNIFORM_H, 1.0, 1.0 + sizes.append((form, r * jr, h * jh)) + free = 2.0 * x_in - sum(2.0 * sz[1] for sz in sizes) + gap = free / (len(sizes) + 1) + x = -x_in + gap + for k, (form, r, h) in enumerate(sizes): + cx = x + r + x += 2.0 * r + gap + if crowd and s == 0: + cx *= CROWD + lo_y, hi_y = y_front + r + VESSEL_CLEAR, y_back - r - VESSEL_CLEAR + mid, half = (lo_y + hi_y) * 0.5, (hi_y - lo_y) * 0.5 + cy = mid + half * (0.55 if k % 2 else -0.45) + yaw = math.radians(18.0) * (frac(idx * 0.7548776662 + 0.1) - 0.5) + seat = SEAT_BASE + SEAT_STEP * k + out.append({"i": idx, "shelf": s, "form": form, "r": r, "h": h, + "c": (cx, cy), "yaw": yaw, "seat": seat}) + idx += 1 + return out + + +def build_shelf_mesh( + name, + stray_vert=False, + float_side=False, + short_shelves=False, + pop_corks=False, + pull_knobs=False, + float_jars=False, + lift_drawers=False, + float_labels=False, + tall_flask=False, + crowd_jars=False, + uniform_vessels=False, + sharp_rail=False, +): + x_out = W_OUT / 2.0 + x_in = x_out - SIDE_T + y0, y1 = -DEPTH / 2.0, DEPTH / 2.0 + top_z0 = SIDE_H - TOP_SEAT + top_z1 = top_z0 + TOP_T + back_y1 = y1 - BACK_INSET + back_y0 = back_y1 - BACK_T + bm = bmesh.new() + try: + ctx = {"uv": bm.loops.layers.uv.new("UVMap"), + "isl": bm.faces.layers.int.new("UVIsland"), + "piece": bm.faces.layers.int.new("Piece"), "next": 0} + boards = [] + # Sides: the two named supports. + for sx in (-1.0, 1.0): + lift = FLOAT_SIDE if (float_side and sx < 0) else 0.0 + add_board(bm, (min(sx * x_out, sx * x_in), y0, lift), + (max(sx * x_out, sx * x_in), y1, SIDE_H + lift), boards) + add_board(bm, (-x_out - TOP_OVER, y0 - TOP_OVER, top_z0), + (x_out + TOP_OVER, y1 + TOP_BACK_OVER, top_z1), boards) + add_crest(bm, x_in, back_y0 - CREST_T - 0.004, back_y0 - 0.004, + top_z1 - CREST_BITE, top_z1 + CREST_SHOULDER, top_z1 + CREST_H, boards) + # Back boards, housed into the sides and the top, standing on the + # base. Lapped, not butted: a seam left open shows daylight through + # the back, and a lap on one plane is a coplanar pair, so every other + # board steps forward by BACK_STEP. + span0, span1 = -x_in - BACK_BITE, x_in + BACK_BITE + bw = (span1 - span0 + BACK_LAP * (N_BACK - 1)) / N_BACK + for k in range(N_BACK): + bx = span0 + k * (bw - BACK_LAP) + step = BACK_STEP if k % 2 else 0.0 + add_board(bm, (bx, back_y0 - step, BASE_Z + BASE_T - BACK_BITE), + (bx + bw, back_y1 - step, top_z0 + BACK_BITE), boards) + # Cross members: base, shelves and rails, each in a dado in both sides. + reach = x_in - 0.004 if short_shelves else x_in + DADO + add_board(bm, (-(x_in + DADO), y0 + BASE_SETBACK, BASE_Z), + (x_in + DADO, back_y0 + BACK_BITE, BASE_Z + BASE_T), boards) + for s, zt in enumerate(SHELF_TOPS): + add_board(bm, (-reach, y0 + SHELF_SETBACK, zt - SHELF_T), + (reach, back_y0 + BACK_BITE, zt), boards) + add_board(bm, (-(x_in + DADO), y0 + RAIL_INSET, zt - RAIL_BITE), + (x_in + DADO, y0 + RAIL_INSET + RAIL_T, zt + RAIL_H - RAIL_BITE), + boards, sharp=(sharp_rail and s == len(SHELF_TOPS) - 1)) + # Drawer bank: three fronts standing in the base, a knob in each. + dz0 = BASE_Z + BASE_T - DRAWER_BITE + (LIFT_DRAWERS if lift_drawers else 0.0) + dz1 = SHELF_TOPS[0] - SHELF_T - DRAWER_GAP + (LIFT_DRAWERS if lift_drawers else 0.0) + dw = (2.0 * x_in - DRAWER_GAP * (N_DRAWERS + 1)) / N_DRAWERS + dy0 = y0 + DRAWER_SETBACK + knobs = [] + for k in range(N_DRAWERS): + dx = -x_in + DRAWER_GAP + k * (dw + DRAWER_GAP) + add_board(bm, (dx, dy0, dz0), (dx + dw, dy0 + DRAWER_T, dz1), boards) + knobs.append((dx + dw * 0.5, (dz0 + dz1) * 0.5)) + # A set of BMEdges iterates in address order: sort by index so the + # bevel emits its faces in the same order on every run. + bm.edges.index_update() + # The crest's arc strips meet at a few degrees; only real edges chamfer. + edges = sorted({e for v in boards for e in v.link_edges + if e.calc_face_angle(0.0) > math.radians(20.0)}, key=lambda e: e.index) + bmesh.ops.bevel(bm, geom=edges, offset=CHAMFER, segments=1, profile=0.5, + affect="EDGES", clamp_overlap=True, material=WOOD_IDX) + # Chamfer n-gon caps, then triangulate. + bmesh.ops.triangulate(bm, faces=[f for f in bm.faces if len(f.verts) > 4]) + for kx, kz in knobs: + ky = dy0 + KNOB_BITE - (PULL_KNOBS if pull_knobs else 0.0) + xf = Matrix.Translation((kx, ky, kz)) @ Matrix.Rotation(math.pi / 2.0, 4, "X") + lathe(bm, knob_profile(), SEG, BRASS_IDX, ctx, xf) + for v in vessel_plan(uniform=uniform_vessels, crowd=crowd_jars): + form = v["form"] + _r, _h, mat, kind = FORMS[form] + prof, label, mouth = body_profile(form, v["r"], v["h"]) + z0 = SHELF_TOPS[v["shelf"]] - v["seat"] + (FLOAT_JARS if float_jars else 0.0) + stretch = TALL_FLASK if (tall_flask and v["shelf"] == 1 and form == "flask") else 1.0 + xf = (Matrix.Translation((v["c"][0], v["c"][1], z0)) + @ Matrix.Diagonal((1.0, 1.0, stretch, 1.0))) + # Label centred on the front, turned by the vessel's yaw. + phase = -math.pi / 2.0 + v["yaw"] - (LABEL_COLS / 2) * 2.0 * math.pi / SEG + piece = v["i"] + 1 + midx = GLASS_IDX if mat == "glass" else GLAZE_IDX + lathe(bm, prof, SEG, midx, ctx, xf, phase=phase, piece=piece) + add_label(bm, prof, label, SEG, phase, xf, ctx, piece, float_label=float_labels) + cz = prof[-1].y - CLOSURE_BITE + (POP_CORK if pop_corks else 0.0) + cxf = xf @ Matrix.Translation((0.0, 0.0, cz)) + cmat = CORK_IDX if kind == "cork" else GLAZE_IDX + lathe(bm, closure_profile(kind, mouth), SEG, cmat, ctx, cxf, phase=phase, piece=piece) + if stray_vert: + bm.verts.new((0.0, 0.0, 0.5)) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for f in bm.faces: + f.smooth = True + for e in bm.edges: + if len(e.link_faces) == 2 and e.calc_face_angle(0.0) > math.radians(35.0): + e.smooth = False + pack_uvs(bm, ctx) + bm.faces.layers.int.remove(ctx["isl"]) + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + me.update() + finally: + bm.free() + paint_pieces(me) + obj = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(obj) + return obj + + +def pack_uvs(bm, ctx, margin=0.06): + """One grid cell per UV island: strip islands by their layer tag, else a face each.""" + uv, isl = ctx["uv"], ctx["isl"] + bm.faces.index_update() + islands, order = {}, [] + for face in bm.faces: + key = ("s", face[isl]) if face[isl] else ("f", face.index) + if key not in islands: + islands[key] = [] + order.append(key) + islands[key].append(face) + cols = max(1, math.ceil(math.sqrt(len(order)))) + rows = max(1, math.ceil(len(order) / cols)) + cw, ch = 1.0 / cols, 1.0 / rows + pu, pv = margin * cw * 0.5, margin * ch * 0.5 + for idx, key in enumerate(order): + faces = islands[key] + coords = {} + for face in faces: + if face[isl]: + coords[face.index] = [tuple(loop[uv].uv) for loop in face.loops] + continue + nrm = face.normal + ax, ay, az = abs(nrm.x), abs(nrm.y), abs(nrm.z) + pts = [] + for loop in face.loops: + co = loop.vert.co + if az >= ax and az >= ay: + pts.append((co.x, co.y)) + elif ax >= ay: + pts.append((co.y, co.z)) + else: + pts.append((co.x, co.z)) + coords[face.index] = pts + allc = [c for cs in coords.values() for c in cs] + minx, maxx = min(c[0] for c in allc), max(c[0] for c in allc) + miny, maxy = min(c[1] for c in allc), max(c[1] for c in allc) + dx, dy = max(maxx - minx, 1e-8), max(maxy - miny, 1e-8) + ou, ov = (idx % cols) * cw + pu, (idx // cols) * ch + pv + for face in faces: + for loop, (x, y) in zip(face.loops, coords[face.index]): + loop[uv].uv = (ou + (x - minx) / dx * (cw - 2 * pu), + ov + (y - miny) / dy * (ch - 2 * pv)) + + +def paint_pieces(me): + """Face attributes the shaders read: per-board tone and grain, per-vessel tint. + + Every board gets a closed-form tone and the direction it runs in, its + longest extent, so the grain follows the board. Every vessel gets a + glass tint or a glaze from its index; the ``Piece`` tag is then dropped. + """ + npoly = len(me.polygons) + piece = [0] * npoly + me.attributes["Piece"].data.foreach_get("value", piece) + mats = [0] * npoly + me.polygons.foreach_get("material_index", mats) + tone = [0.5] * npoly + grain = [(1.0, 0.0, 0.0)] * npoly + tint = [(0.5, 0.5, 0.5, 1.0)] * npoly + vf = [[] for _ in range(len(me.vertices))] + for p in me.polygons: + for i in p.vertices: + vf[i].append(p.index) + for k, g in enumerate(shells(me)): + faces = {fi for i in g for fi in vf[i]} + if not faces or mats[next(iter(faces))] != WOOD_IDX: + continue + pts = [me.vertices[i].co for i in g] + ext = [max(p[a] for p in pts) - min(p[a] for p in pts) for a in range(3)] + axis = ext.index(max(ext)) + d = tuple(1.0 if a == axis else 0.0 for a in range(3)) + t = 0.5 + 0.30 * (frac(k * 0.6180339887 + 0.3) - 0.5) + for fi in faces: + tone[fi] = t + grain[fi] = d + for fi in range(npoly): + v = piece[fi] - 1 + if v < 0: + continue + if mats[fi] == GLASS_IDX: + c = GLASS_TINTS[v % len(GLASS_TINTS)] + else: + c = GLAZE_TINTS[v % len(GLAZE_TINTS)] + tint[fi] = (c[0], c[1], c[2], 1.0) + tone[fi] = 0.5 + 0.3 * (frac(v * 0.4142135624) - 0.5) + me.attributes.remove(me.attributes["Piece"]) + a = me.attributes.new("PlankTone", "FLOAT", "FACE") + a.data.foreach_set("value", tone) + b = me.attributes.new("GrainDir", "FLOAT_VECTOR", "FACE") + b.data.foreach_set("vector", [c for v in grain for c in v]) + c = me.attributes.new("Tint", "FLOAT_COLOR", "FACE") + c.data.foreach_set("color", [x for v in tint for x in v]) + + +# --- surface ---------------------------------------------------------------- + + +def _sock(sockets, identifier): + return next(sk for sk in sockets if sk.identifier == identifier) + + +def _input(bsdf, *names): + for n in names: + if n in bsdf.inputs: + return bsdf.inputs[n] + return None + + +def wood_material(name): + """Stained timber: grain along each board, tone per board (copied from crate-stack).""" + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + coord = nt.nodes.new("ShaderNodeTexCoord") + gdir = nt.nodes.new("ShaderNodeAttribute") + gdir.attribute_name = "GrainDir" + tone = nt.nodes.new("ShaderNodeAttribute") + tone.attribute_name = "PlankTone" + dot = nt.nodes.new("ShaderNodeVectorMath") + dot.operation = "DOT_PRODUCT" + nt.links.new(coord.outputs["Object"], dot.inputs[0]) + nt.links.new(gdir.outputs["Vector"], dot.inputs[1]) + squash = nt.nodes.new("ShaderNodeMath") + squash.operation = "MULTIPLY" + squash.inputs[1].default_value = 0.94 + nt.links.new(dot.outputs["Value"], squash.inputs[0]) + along = nt.nodes.new("ShaderNodeVectorMath") + along.operation = "SCALE" + nt.links.new(gdir.outputs["Vector"], along.inputs[0]) + nt.links.new(squash.outputs["Value"], along.inputs["Scale"]) + grain_co = nt.nodes.new("ShaderNodeVectorMath") + grain_co.operation = "SUBTRACT" + nt.links.new(coord.outputs["Object"], grain_co.inputs[0]) + nt.links.new(along.outputs["Vector"], grain_co.inputs[1]) + shift = nt.nodes.new("ShaderNodeVectorMath") + shift.operation = "ADD" + nt.links.new(grain_co.outputs["Vector"], shift.inputs[0]) + nt.links.new(tone.outputs["Fac"], shift.inputs[1]) + noise = nt.nodes.new("ShaderNodeTexNoise") + noise.inputs["Scale"].default_value = 34.0 + noise.inputs["Detail"].default_value = 6.0 + noise.inputs["Roughness"].default_value = 0.62 + nt.links.new(shift.outputs["Vector"], noise.inputs["Vector"]) + ramp = nt.nodes.new("ShaderNodeValToRGB") + ramp.color_ramp.elements[0].position = 0.30 + ramp.color_ramp.elements[0].color = (0.085, 0.036, 0.014, 1.0) + ramp.color_ramp.elements[1].position = 0.72 + ramp.color_ramp.elements[1].color = (0.27, 0.125, 0.048, 1.0) + nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) + gain = nt.nodes.new("ShaderNodeMath") + gain.operation = "MULTIPLY_ADD" + gain.inputs[1].default_value = 1.2 + gain.inputs[2].default_value = 0.40 + nt.links.new(tone.outputs["Fac"], gain.inputs[0]) + mix = nt.nodes.new("ShaderNodeMix") + mix.data_type = "RGBA" + mix.blend_type = "MULTIPLY" + _sock(mix.inputs, "Factor_Float").default_value = 1.0 + nt.links.new(ramp.outputs["Color"], _sock(mix.inputs, "A_Color")) + nt.links.new(gain.outputs["Value"], _sock(mix.inputs, "B_Color")) + nt.links.new(_sock(mix.outputs, "Result_Color"), bsdf.inputs["Base Color"]) + rough = nt.nodes.new("ShaderNodeMapRange") + rough.inputs["To Min"].default_value = 0.66 + rough.inputs["To Max"].default_value = 0.48 + nt.links.new(noise.outputs["Fac"], rough.inputs["Value"]) + nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"]) + return mat + + +def glaze_material(name): + """Glazed stoneware: the per-vessel ``Tint``, glossy, with a faint mottle.""" + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + attr = nt.nodes.new("ShaderNodeAttribute") + attr.attribute_name = "Tint" + tc = nt.nodes.new("ShaderNodeTexCoord") + noise = nt.nodes.new("ShaderNodeTexNoise") + noise.inputs["Scale"].default_value = 45.0 + noise.inputs["Detail"].default_value = 4.0 + nt.links.new(tc.outputs["Object"], noise.inputs["Vector"]) + shade = nt.nodes.new("ShaderNodeMapRange") + shade.inputs["To Min"].default_value = 0.86 + shade.inputs["To Max"].default_value = 1.0 + nt.links.new(noise.outputs["Fac"], shade.inputs["Value"]) + mul = nt.nodes.new("ShaderNodeVectorMath") + mul.operation = "SCALE" + nt.links.new(attr.outputs["Color"], mul.inputs[0]) + nt.links.new(shade.outputs["Result"], mul.inputs["Scale"]) + nt.links.new(mul.outputs["Vector"], bsdf.inputs["Base Color"]) + bsdf.inputs["Roughness"].default_value = 0.28 + return mat + + +def glass_material(name): + """Tinted glass, filled: dark through the middle, lighter at the silhouette. + + No transmission. EEVEE's transmission rendered every bottle as a grainy + speckle that 256 samples did not clear, with or without raytracing, so + the depth of the glass is carried by a facing ramp and a clear coat. + """ + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + attr = nt.nodes.new("ShaderNodeAttribute") + attr.attribute_name = "Tint" + lw = nt.nodes.new("ShaderNodeLayerWeight") + lw.inputs["Blend"].default_value = 0.45 + depth = nt.nodes.new("ShaderNodeMapRange") + depth.inputs["To Min"].default_value = 0.30 + depth.inputs["To Max"].default_value = 0.95 + nt.links.new(lw.outputs["Facing"], depth.inputs["Value"]) + mul = nt.nodes.new("ShaderNodeVectorMath") + mul.operation = "SCALE" + nt.links.new(attr.outputs["Color"], mul.inputs[0]) + nt.links.new(depth.outputs["Result"], mul.inputs["Scale"]) + nt.links.new(mul.outputs["Vector"], bsdf.inputs["Base Color"]) + bsdf.inputs["Roughness"].default_value = 0.06 + coat = _input(bsdf, "Coat Weight", "Clearcoat") + if coat is not None: + coat.default_value = 1.0 + coat_r = _input(bsdf, "Coat Roughness", "Clearcoat Roughness") + if coat_r is not None: + coat_r.default_value = 0.03 + return mat + + +def paper_material(name): + """Aged paper with ruled lines of ink broken into words by noise.""" + mat = speckled(name, (0.56, 0.49, 0.36), (0.78, 0.72, 0.58), 25.0, 0.9) + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + paper = bsdf.inputs["Base Color"].links[0].from_socket + tc = nt.nodes.new("ShaderNodeTexCoord") + sep = nt.nodes.new("ShaderNodeSeparateXYZ") + nt.links.new(tc.outputs["Object"], sep.inputs["Vector"]) + lines = nt.nodes.new("ShaderNodeMath") + lines.operation = "PINGPONG" + lines.inputs[1].default_value = 0.0045 + nt.links.new(sep.outputs["Z"], lines.inputs[0]) + rule = nt.nodes.new("ShaderNodeMath") + rule.operation = "LESS_THAN" + rule.inputs[1].default_value = 0.0005 + nt.links.new(lines.outputs["Value"], rule.inputs[0]) + words = nt.nodes.new("ShaderNodeTexNoise") + words.inputs["Scale"].default_value = 220.0 + words.inputs["Detail"].default_value = 1.0 + nt.links.new(tc.outputs["Object"], words.inputs["Vector"]) + gate = nt.nodes.new("ShaderNodeMath") + gate.operation = "GREATER_THAN" + gate.inputs[1].default_value = 0.50 + nt.links.new(words.outputs["Fac"], gate.inputs[0]) + ink = nt.nodes.new("ShaderNodeMath") + ink.operation = "MULTIPLY" + nt.links.new(rule.outputs["Value"], ink.inputs[0]) + nt.links.new(gate.outputs["Value"], ink.inputs[1]) + mix = nt.nodes.new("ShaderNodeMix") + mix.data_type = "RGBA" + nt.links.new(ink.outputs["Value"], _sock(mix.inputs, "Factor_Float")) + nt.links.new(paper, _sock(mix.inputs, "A_Color")) + _sock(mix.inputs, "B_Color").default_value = (0.26, 0.18, 0.12, 1.0) + nt.links.new(_sock(mix.outputs, "Result_Color"), bsdf.inputs["Base Color"]) + return mat + + +def speckled(name, color_a, color_b, scale, roughness): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + tc = nt.nodes.new("ShaderNodeTexCoord") + noise = nt.nodes.new("ShaderNodeTexNoise") + noise.inputs["Scale"].default_value = scale + noise.inputs["Detail"].default_value = 8.0 + nt.links.new(tc.outputs["Object"], noise.inputs["Vector"]) + ramp = nt.nodes.new("ShaderNodeValToRGB") + ramp.color_ramp.elements[0].position = 0.35 + ramp.color_ramp.elements[0].color = (*color_a, 1.0) + ramp.color_ramp.elements[1].position = 0.70 + ramp.color_ramp.elements[1].color = (*color_b, 1.0) + nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) + nt.links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"]) + bsdf.inputs["Roughness"].default_value = roughness + return mat + + +def brass_material(name): + """Aged brass: warm, a little dull, darker in the recesses of the noise.""" + mat = speckled(name, (0.30, 0.19, 0.07), (0.55, 0.39, 0.16), 60.0, 0.42) + mat.node_tree.nodes["Principled BSDF"].inputs["Metallic"].default_value = 0.9 + return mat + + +def shelf_materials(): + return ( + wood_material("ShelfWood"), + glass_material("ShelfGlass"), + glaze_material("ShelfGlaze"), + speckled("ShelfCork", (0.30, 0.19, 0.10), (0.52, 0.37, 0.21), 90.0, 0.85), + paper_material("ShelfPaper"), + brass_material("ShelfBrass"), + ) + + +def assign_slots(obj, mats): + slots = obj.data.materials + for i, mat in enumerate(mats): + if i < len(slots): + slots[i] = mat + else: + slots.append(mat) + + +# --- measurement ------------------------------------------------------------ + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs, ys, zs = [c.x for c in corners], [c.y for c in corners], [c.z for c in corners] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def uv_stats(mesh): + uv = mesh.uv_layers.active + if uv is None: + return 0.0, 0.0, 1.0, 1.0, 0.0, 0 + data = uv.data + us = [loop.uv[0] for loop in data] + vs = [loop.uv[1] for loop in data] + aabbs = [] + for poly in mesh.polygons: + pu = [data[i].uv[0] for i in poly.loop_indices] + pv = [data[i].uv[1] for i in poly.loop_indices] + aabbs.append((min(pu), min(pv), max(pu), max(pv))) + span = max(1e-6, max(a[2] - a[0] for a in aabbs), max(a[3] - a[1] for a in aabbs)) + buckets = {} + for i, a in enumerate(aabbs): + for c in range(int(a[0] // span), int(a[2] // span) + 1): + for r in range(int(a[1] // span), int(a[3] // span) + 1): + buckets.setdefault((c, r), []).append(i) + overlap = 0.0 + seen = set() + for members in buckets.values(): + for ii in range(len(members)): + for jj in range(ii + 1, len(members)): + i, j = members[ii], members[jj] + key = (i, j) if i < j else (j, i) + if key in seen: + continue + seen.add(key) + a, b = aabbs[i], aabbs[j] + overlap += max(0.0, min(a[2], b[2]) - max(a[0], b[0])) * max( + 0.0, min(a[3], b[3]) - max(a[1], b[1])) + return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) + + +def face_area(me, poly): + idxs = poly.vertices + v0 = me.vertices[idxs[0]].co + area = 0.0 + for i in range(1, len(idxs) - 1): + area += (me.vertices[idxs[i]].co - v0).cross(me.vertices[idxs[i + 1]].co - v0).length * 0.5 + return area + + +def hygiene_audit(me): + ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) + zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS) + bm = bmesh.new() + try: + bm.from_mesh(me) + loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0) + loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0) + nonman = sum(1 for e in bm.edges if not e.is_manifold) + ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS) + doubles = len(ret.get("targetmap") or {}) + finally: + bm.free() + return {"ngons": ngons, "loose_v": loose_v, "loose_e": loose_e, + "nonman": nonman, "zero_area": zero_area, "doubles": doubles} + + +def shells(me): + neighbors = [[] for _ in range(len(me.vertices))] + for edge in me.edges: + a, b = edge.vertices + neighbors[a].append(b) + neighbors[b].append(a) + seen = [False] * len(me.vertices) + groups = [] + for start in range(len(me.vertices)): + if seen[start]: + continue + seen[start] = True + stack = [start] + group = [] + while stack: + cur = stack.pop() + group.append(cur) + for nxt in neighbors[cur]: + if not seen[nxt]: + seen[nxt] = True + stack.append(nxt) + groups.append(group) + return groups + + +def zfight_pairs(me): + """Coplanar face pairs from *different shells* (copied from showcase/grindstone).""" + owner = {} + for si, g in enumerate(shells(me)): + for vi in g: + owner[vi] = si + faces = [(p.normal.copy(), p.center.copy(), owner.get(p.vertices[0], -1)) + for p in me.polygons] + kd = KDTree(len(faces)) + for i, (_n, c, _s) in enumerate(faces): + kd.insert(c, i) + kd.balance() + hits = 0 + for i, (ni, ci, si) in enumerate(faces): + for _co, j, _d in kd.find_range(ci, COPLANAR_CENTRE_MAX): + if j <= i: + continue + nj, cj, sj = faces[j] + if si == sj: + continue + if abs(abs(ni.dot(nj)) - 1.0) > COPLANAR_NORMAL_EPS: + continue + if abs(ni.dot(cj - ci)) > COPLANAR_PLANE_EPS: + continue + hits += 1 + return hits + + +def right_angle_edges(me): + """Manifold edges between two wood faces that meet within 5 degrees of 90.""" + count = 0 + bm = bmesh.new() + try: + bm.from_mesh(me) + for e in bm.edges: + if len(e.link_faces) != 2: + continue + if any(f.material_index != WOOD_IDX for f in e.link_faces): + continue + if abs(e.calc_face_angle(0.0) - math.pi / 2.0) <= RIGHT_ANGLE_TOL: + count += 1 + finally: + bm.free() + return count + + +def shell_tree(me, groups): + member = set(i for g in groups for i in g) + order = sorted(member) + remap = {v: k for k, v in enumerate(order)} + polys = [[remap[i] for i in p.vertices] for p in me.polygons if p.vertices[0] in member] + return BVHTree.FromPolygons([me.vertices[i].co.copy() for i in order], polys) + + +def classify(me): + """Split the finished mesh into named parts by material and shape.""" + mats = {} + for p in me.polygons: + for i in p.vertices: + mats.setdefault(i, p.material_index) + out = {k: [] for k in ("side", "top", "crest", "back", "cross", "drawer", "knob", + "body", "closure", "label", "other")} + for g in shells(me): + pts = [me.vertices[i].co.copy() for i in g] + lo = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts))) + hi = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts))) + ext = hi - lo + rec = {"g": g, "pts": pts, "lo": lo, "hi": hi, "ext": ext, + "c": sum(pts, Vector()) / len(pts)} + m = mats.get(g[0], -1) + if m == WOOD_IDX: + if ext.x < 0.05 and ext.z > 0.9: + out["side"].append(rec) + elif ext.y < 0.03 and ext.z > 0.9: + out["back"].append(rec) + elif lo.z > SIDE_H: + out["crest"].append(rec) + elif hi.z > SIDE_H: + out["top"].append(rec) + elif ext.x > 0.7: + out["cross"].append(rec) + else: + out["drawer"].append(rec) + elif m == BRASS_IDX: + out["knob"].append(rec) + elif m == PAPER_IDX: + out["label"].append(rec) + elif m == CORK_IDX: + out["closure"].append(rec) + elif m in (GLASS_IDX, GLAZE_IDX): + out["body" if ext.z > 0.06 else "closure"].append(rec) + else: + out["other"].append(rec) + return out + + +def axis_of(rec): + """A lathe's axis in plan: the mean of its vertices, exact for a full revolution.""" + return Vector((rec["c"].x, rec["c"].y)) + + +def shelf_audit(me): + parts = classify(me) + out = {k: len(v) for k, v in parts.items()} + sides = sorted(parts["side"], key=lambda r: r["c"].x) + out["side_z"] = max((r["lo"].z for r in sides), default=99.0) + + # Every cross member is housed in both sides: bite past the inner face, + # and clear of the far face. + bite, far = 99.0, 99.0 + if len(sides) == 2: + left_in, left_out = sides[0]["hi"].x, sides[0]["lo"].x + right_in, right_out = sides[1]["lo"].x, sides[1]["hi"].x + for r in parts["cross"]: + bite = min(bite, left_in - r["lo"].x, r["hi"].x - right_in) + far = min(far, r["lo"].x - left_out, right_out - r["hi"].x) + out["dado"], out["dado_far"] = bite, far + + # Vessels: each body with its closure and label, by nearest axis. + bodies = parts["body"] + units = [{"body": b, "closure": [], "label": []} for b in bodies] + for kind in ("closure", "label"): + for r in parts[kind]: + if not units: + break + # Nearest axis in plan among the bodies at this part's height: + # the shelves stack vessels in the same plan positions. + p = Vector((r["c"].x, r["c"].y)) + level = [u for u in units + if u["body"]["lo"].z <= r["c"].z <= u["body"]["hi"].z + 0.06] + if level: + u = min(level, key=lambda u: (axis_of(u["body"]) - p).length) + u[kind].append(r) + out["units"] = len(units) + out["unmatched"] = sum(1 for u in units if len(u["closure"]) != 1 or len(u["label"]) != 1) + + shelves = [r for r in parts["cross"] if r["ext"].y > 0.15] + horizontals = shelves + parts["top"] + closure_bites, seats, heads = [], [], [] + for u in units: + b = u["body"] + for c in u["closure"]: + closure_bites.append(b["hi"].z - c["lo"].z) + under = [s for s in shelves if s["hi"].z <= b["lo"].z + 0.01] + if under: + s = max(under, key=lambda s: s["hi"].z) + u["shelf"] = s + seats.append(s["hi"].z - b["lo"].z) + top = max([b["hi"].z] + [c["hi"].z for c in u["closure"]]) + above = [h for h in horizontals if h["lo"].z > b["lo"].z + 0.05] + if above: + heads.append(min(h["lo"].z for h in above) - top) + out["closure_bite"] = (min(closure_bites, default=-99.0), max(closure_bites, default=99.0)) + out["seat"] = (min(seats, default=-99.0), max(seats, default=99.0)) + out["headroom"] = min(heads, default=-99.0) + out["n_seated"] = len(seats) + + # Labels on their bodies: each label vertex against the body vertex it + # was built on, measured radially from the body's own axis. + lab_in, lab_out = [], [] + for u in units: + b = u["body"] + kd = KDTree(len(b["pts"])) + for k, p in enumerate(b["pts"]): + kd.insert(p, k) + kd.balance() + ax = axis_of(b) + for lab in u["label"]: + # Positive is inside the body. Half the label's vertices are its + # inner face and half its outer; sorting splits them without a + # sign test, so a floated label's inner face still reads as inner. + ds = [] + for p in lab["pts"]: + q = kd.find(p)[0] + ds.append((Vector((q.x, q.y)) - ax).length - (Vector((p.x, p.y)) - ax).length) + ds.sort() + half = len(ds) // 2 + lab_in.append(min(ds[half:])) + lab_out.append(-max(ds[:half])) + out["label_bite"] = (min(lab_in, default=-99.0), max(lab_in, default=99.0)) + out["label_proud"] = min(lab_out, default=-99.0) + + # Knobs bite their drawer fronts; drawer fronts stand in the base. + base = min(parts["cross"], key=lambda r: r["lo"].z) if parts["cross"] else None + knob_bites, drawer_seats = [], [] + for d in parts["drawer"]: + if base is not None: + drawer_seats.append(base["hi"].z - d["lo"].z) + near = [k for k in parts["knob"] if d["lo"].x < k["c"].x < d["hi"].x] + for k in near: + knob_bites.append(k["hi"].y - d["lo"].y) + out["knob_bite"] = (min(knob_bites, default=-99.0), max(knob_bites, default=99.0)) + out["n_knob_bites"] = len(knob_bites) + out["drawer_seat"] = (min(drawer_seats, default=-99.0), max(drawer_seats, default=99.0)) + + # Vessels do not run into one another, nor into the carcass beyond the + # shelf each one stands in. + trees = [shell_tree(me, [u["body"]["g"]] + [r["g"] for r in u["closure"] + u["label"]]) + for u in units] + carcass = [r for k in ("side", "back", "cross", "top") for r in parts[k]] + ctrees = [shell_tree(me, [r["g"]]) for r in carcass] + hits = 0 + for i in range(len(trees)): + for j in range(i + 1, len(trees)): + if trees[i].overlap(trees[j]): + hits += 1 + for r, ct in zip(carcass, ctrees): + if r is units[i].get("shelf"): + continue + if trees[i].overlap(ct): + hits += 1 + out["vessel_hits"] = hits + + # Forms vary: widest body over narrowest, tallest over shortest. + # Diameter from the axis, not the AABB: a yawed 16-gon's box width + # moves with the yaw. + diams = [2.0 * max((Vector((p.x, p.y)) - axis_of(b)).length for p in b["pts"]) + for b in bodies] + hts = [b["ext"].z for b in bodies] + out["diam_spread"] = max(diams) / min(diams) if diams else 0.0 + out["height_spread"] = max(hts) / min(hts) if hts else 0.0 + return out + + +def make_lod(obj, name, ratio, skip_decimate): + mesh = obj.data.copy() + lod = bpy.data.objects.new(name, mesh) + lod.matrix_world = obj.matrix_world.copy() + bpy.context.scene.collection.objects.link(lod) + if not skip_decimate and 0.0 < ratio < 1.0: + mod = lod.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return lod + + +def hull_collider(obj, name): + """One convex hull over the carcass and the knobs; the vessels sit inside it.""" + me = obj.data + mats = {} + for p in me.polygons: + for i in p.vertices: + mats.setdefault(i, p.material_index) + pts = [me.vertices[i].co.copy() for i in range(len(me.vertices)) + if mats.get(i) in (WOOD_IDX, BRASS_IDX)] + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + vs = [bm.verts.new(p) for p in pts] + ret = bmesh.ops.convex_hull(bm, input=vs) + drop = [] + for v in ret["geom_interior"] + ret["geom_unused"]: + if isinstance(v, bmesh.types.BMVert) and v not in drop: + drop.append(v) + bmesh.ops.delete(bm, geom=drop, context="VERTS") + bmesh.ops.dissolve_limit(bm, angle_limit=math.radians(1.0), + verts=list(bm.verts), edges=list(bm.edges)) + bmesh.ops.triangulate(bm, faces=list(bm.faces)) + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + col = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(col) + return col + + +def setup_bake_image(obj, target_mat, size): + img = bpy.data.images.new("ShelfNrm", size, size, alpha=True, float_buffer=False) + img.colorspace_settings.name = "Non-Color" + nodes = target_mat.node_tree.nodes + tex = nodes.new("ShaderNodeTexImage") + tex.image = img + nodes.active = tex + tex.select = True + obj.active_material_index = WOOD_IDX + return img, tex + + +def bake_normal(high, low): + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = 1 + scene.cycles.use_denoising = False + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + high.select_set(True) + low.select_set(True) + bpy.context.view_layer.objects.active = low + return bpy.ops.object.bake( + type="NORMAL", use_selected_to_active=True, cage_extrusion=CAGE_EXTRUSION, + use_cage=False, normal_space="TANGENT", margin=4, + margin_type="ADJACENT_FACES", use_clear=True, target="IMAGE_TEXTURES", + ) + + +def export_unity(path, objects): + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + for ob in objects: + ob.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + bpy.ops.export_scene.gltf( + filepath=path, use_selection=True, export_yup=True, export_apply=True, + export_draco_mesh_compression_enable=False, export_animations=False, + ) + + +def check(skip_decimate, lift_z=False, **flags): + bpy.ops.wm.read_factory_settings(use_empty=True) + nothing = (None,) * 5 + low = build_shelf_mesh("ShelfLow", **flags) + hi_flags = {k: v for k, v in flags.items() if k != "stray_vert"} + high = build_shelf_mesh("ShelfHigh", **hi_flags) + mats = shelf_materials() + assign_slots(low, mats) + assign_slots(high, mats) + if lift_z: + for v in low.data.vertices: + v.co.z += LIFT_Z + low.data.update() + bpy.context.view_layer.update() + if len(low.data.polygons) < 6 or not low.data.uv_layers: + return (fail("shelf mesh did not build, or has no UV layer", 3),) + nothing + + base_tris = triangle_count(low.data) + slots = [s for s in low.data.materials if s is not None] + nmat, distinct = len(slots), len({id(s) for s in slots}) + idx_counts = {} + for poly in low.data.polygons: + idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1 + u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data) + bb = world_bbox(low) + size_x, size_y, size_z = bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2] + + img, tex = setup_bake_image(low, mats[WOOD_IDX], BAKE_RES) + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "ShelfLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "ShelfLOD2", LOD2_TARGET, skip_decimate) + bpy.context.view_layer.update() + lod1_tris = evaluated_triangle_count(lod1) + lod2_tris = evaluated_triangle_count(lod2) + r1 = lod1_tris / base_tris if base_tris else 0.0 + r2 = lod2_tris / base_tris if base_tris else 0.0 + + collider = hull_collider(high, "ShelfCollider") + col_tris = triangle_count(collider.data) + + export_path = os.path.join(tempfile.gettempdir(), f"bdt_apothecary_{os.getpid()}.glb") + if os.path.exists(export_path): + os.remove(export_path) + export_unity(export_path, [low, collider]) + export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + # Blender points TMPDIR at its own temp preference, which on a portable + # build is the working directory, so the export must not outlive this. + if os.path.isfile(export_path): + os.remove(export_path) + + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + sa = shelf_audit(low.data) + right = right_angle_edges(low.data) + + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") + print(f"measured mat_index_counts={dict(sorted(idx_counts.items()))}") + print(f"measured base_tris={base_tris} lod1_tris={lod1_tris} " + f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}") + print(f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) " + f"overlap={overlap:.6f} nfaces={nfaces}") + print(f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"outer={OUTER_SIZE} zmin={bb[2]:.5f}") + print(f"measured collider_tris={col_tris} bake={bake_result} " + f"bake_has_data={img.has_data} export_bytes={export_size}") + print(f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}") + print(f"measured parts sides={sa['side']} top={sa['top']} crest={sa['crest']} " + f"back={sa['back']} cross={sa['cross']} drawers={sa['drawer']} knobs={sa['knob']} " + f"bodies={sa['body']} closures={sa['closure']} labels={sa['label']} " + f"other={sa['other']} unmatched={sa['unmatched']} side_z={sa['side_z']:.5f}") + print(f"measured joints dado={sa['dado']:.5f} dado_far={sa['dado_far']:.5f} " + f"closure_bite=({sa['closure_bite'][0]:.5f},{sa['closure_bite'][1]:.5f}) " + f"knob_bite=({sa['knob_bite'][0]:.5f},{sa['knob_bite'][1]:.5f})") + print(f"measured seats vessel=({sa['seat'][0]:.5f},{sa['seat'][1]:.5f}) " + f"n={sa['n_seated']} drawer=({sa['drawer_seat'][0]:.5f},{sa['drawer_seat'][1]:.5f}) " + f"label_bite=({sa['label_bite'][0]:.5f},{sa['label_bite'][1]:.5f}) " + f"label_proud={sa['label_proud']:.5f}") + print(f"measured headroom={sa['headroom']:.5f} vessel_hits={sa['vessel_hits']} " + f"diam_spread={sa['diam_spread']:.3f} height_spread={sa['height_spread']:.3f} " + f"right_angle_wood={right}") + + n_vessels = sum(len(row) for row in LAYOUT) + if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): + return (fail(f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", 4),) + nothing + if nmat != MATERIAL_COUNT or distinct != MATERIAL_COUNT: + return (fail(f"material slots {nmat} distinct {distinct} != {MATERIAL_COUNT}", 5),) + nothing + for idx, floor in FACE_FLOORS.items(): + if idx_counts.get(idx, 0) < floor: + return (fail(f"material {idx} faces {idx_counts.get(idx, 0)} < {floor}", 5),) + nothing + if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: + return (fail(f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", 6),) + nothing + if overlap > UV_OVERLAP_MAX: + return (fail(f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7),) + nothing + if (abs(size_x - OUTER_SIZE[0]) > BBOX_TOL or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL + or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL): + return (fail(f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) off outer {OUTER_SIZE}", 8),) + nothing + if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): + return (fail(f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " + "(--skip-decimate is the designed fail)", 9),) + nothing + if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): + return (fail(f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", 9),) + nothing + if col_tris > COLLIDER_TRIS_MAX: + return (fail(f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", 11),) + nothing + if bake_result != {"FINISHED"} or not img.has_data: + return (fail(f"bake failed result={bake_result} has_data={img.has_data}", 12),) + nothing + if export_size <= 0: + return (fail("export file missing or empty", 13),) + nothing + if (hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"] + or hyg["doubles"] or hyg["ngons"] or zf): + return (fail(f"hygiene {hyg} zfight={zf} (--stray-vert is the designed fail)", 15),) + nothing + if abs(bb[2]) > ZMIN_EPS: + return (fail(f"zmin {bb[2]:.6f} not within {ZMIN_EPS} of 0 " + "(--lift-z is the designed fail)", 16),) + nothing + if sa["side"] != 2 or sa["side_z"] > SIDE_Z_MAX: + return (fail(f"sides: {sa['side']} of 2, worst side z={sa['side_z']:.5f} > {SIDE_Z_MAX} " + "(--float-side is the designed fail)", 16),) + nothing + if sa["cross"] != N_CROSS or sa["dado"] < DADO_MIN or sa["dado_far"] < DADO_FAR_MIN: + return (fail(f"{sa['cross']} of {N_CROSS} cross members, dado bite {sa['dado']:.5f} " + f"(min {DADO_MIN}), far face {sa['dado_far']:.5f} (min {DADO_FAR_MIN}) " + "(--short-shelves is the designed fail)", 17),) + nothing + if (sa["units"] != n_vessels or sa["unmatched"] + or not (CLOSURE_BITE_MIN <= sa["closure_bite"][0]) + or sa["closure_bite"][1] > CLOSURE_BITE_MAX): + return (fail(f"{sa['units']} of {n_vessels} vessels, {sa['unmatched']} without one " + f"closure and one label, closure bite {sa['closure_bite']} outside " + f"[{CLOSURE_BITE_MIN}, {CLOSURE_BITE_MAX}] (--pop-corks is the designed fail)", + 17),) + nothing + if (sa["n_knob_bites"] != N_DRAWERS or sa["knob_bite"][0] < KNOB_BITE_MIN + or sa["knob_bite"][1] > KNOB_BITE_MAX): + return (fail(f"{sa['n_knob_bites']} of {N_DRAWERS} knobs, bite {sa['knob_bite']} outside " + f"[{KNOB_BITE_MIN}, {KNOB_BITE_MAX}] (--pull-knobs is the designed fail)", + 17),) + nothing + if sa["n_seated"] != n_vessels or sa["seat"][0] < SEAT_MIN or sa["seat"][1] > SEAT_MAX: + return (fail(f"{sa['n_seated']} of {n_vessels} vessels on a shelf, seat {sa['seat']} " + f"outside [{SEAT_MIN}, {SEAT_MAX}] (--float-jars is the designed fail)", + 18),) + nothing + if (sa["drawer"] != N_DRAWERS or sa["drawer_seat"][0] < DRAWER_SEAT_MIN + or sa["drawer_seat"][1] > DRAWER_SEAT_MAX): + return (fail(f"{sa['drawer']} of {N_DRAWERS} drawers, seat {sa['drawer_seat']} outside " + f"[{DRAWER_SEAT_MIN}, {DRAWER_SEAT_MAX}] (--lift-drawers is the designed fail)", + 18),) + nothing + if (sa["label_bite"][0] < LABEL_BITE_MIN or sa["label_bite"][1] > LABEL_BITE_MAX + or sa["label_proud"] < LABEL_PROUD_MIN): + return (fail(f"label bite {sa['label_bite']} outside [{LABEL_BITE_MIN}, {LABEL_BITE_MAX}] " + f"or proud {sa['label_proud']:.5f} < {LABEL_PROUD_MIN} " + "(--float-labels is the designed fail)", 18),) + nothing + if sa["headroom"] < HEADROOM_MIN: + return (fail(f"headroom {sa['headroom']:.5f} < {HEADROOM_MIN} " + "(--tall-flask is the designed fail)", 20),) + nothing + if sa["vessel_hits"]: + return (fail(f"{sa['vessel_hits']} vessel overlaps, need 0 " + "(--crowd-jars is the designed fail)", 21),) + nothing + if sa["diam_spread"] < DIAM_SPREAD_MIN or sa["height_spread"] < HEIGHT_SPREAD_MIN: + return (fail(f"vessel spread diameter {sa['diam_spread']:.3f} (min {DIAM_SPREAD_MIN}), " + f"height {sa['height_spread']:.3f} (min {HEIGHT_SPREAD_MIN}) " + "(--uniform-vessels is the designed fail)", 22),) + nothing + if right: + return (fail(f"{right} right-angle wood edges, need 0 " + "(--sharp-rail is the designed fail)", 23),) + nothing + return 0, low, high, mats, tex, collider + + +def wire_normal(mat, tex): + nt = mat.node_tree + nrm = nt.nodes.new("ShaderNodeNormalMap") + nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) + nt.links.new(nrm.outputs["Normal"], nt.nodes["Principled BSDF"].inputs["Normal"]) + + +def render_still(low, mats, tex, path, engine): + scene = bpy.context.scene + wire_normal(mats[WOOD_IDX], tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + # Level on the floor: turned about Z only. + low.rotation_euler.z = math.radians(-14.0) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=60.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Floor") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 8.5, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0) + scene.world = world + + def light(name, kind, loc, energy, size, col, rot=(0, 0, 0)): + ld = bpy.data.lights.new(name, kind) + ld.energy = energy + if kind == "AREA": + ld.size = size + else: + ld.shadow_soft_size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", "AREA", (-2.4, -3.4, 3.2), 420.0, 3.0, (1.0, 0.95, 0.88), (50, 0, -35)) + light("Fill", "AREA", (3.2, -2.8, 1.6), 70.0, 5.0, (0.74, 0.84, 1.0), (68, 0, 48)) + light("Rim", "AREA", (-1.8, 2.6, 2.6), 220.0, 3.0, (0.62, 0.78, 1.0), (-55, 0, 200)) + ld = bpy.data.lights.new("Wedge", "SPOT") + ld.energy, ld.color = 260.0, (1.0, 0.66, 0.34) + ld.spot_size, ld.spot_blend, ld.shadow_soft_size = math.radians(50.0), 1.0, 0.3 + wedge = bpy.data.objects.new("Wedge", ld) + wedge.location = (0.6, 1.8, 2.2) + wedge.rotation_euler = (Vector((0.2, 0.6, 0.0)) - wedge.location).to_track_quat( + "-Z", "Y").to_euler() + scene.collection.objects.link(wedge) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (1.05, -3.05, 1.05) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.54) + scene.collection.objects.link(aim) + con = cam.constraints.new("TRACK_TO") + con.target = aim + con.track_axis = "TRACK_NEGATIVE_Z" + con.up_axis = "UP_Y" + scene.camera = cam + + scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + scene.cycles.samples = 32 + scene.cycles.device = "CPU" + else: + try: + scene.eevee.taa_render_samples = 64 + except AttributeError: + pass + scene.render.resolution_x = 1280 + scene.render.resolution_y = 720 + scene.render.image_settings.file_format = "WEBP" if path.lower().endswith(".webp") else "PNG" + if path.lower().endswith(".webp"): + scene.render.image_settings.quality = 90 + scene.render.filepath = path + scene.view_settings.view_transform = "Standard" + + fcode = gallery_framing.check_framing(scene, cam, hero=[low], elements=[low], stage=[floor, wall]) + if fcode: + return fcode + bpy.ops.render.render(write_still=True) + if not (os.path.exists(path) and os.path.getsize(path) > 0): + return fail("render produced no file", 14) + return 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None) + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) + p.add_argument("--skip-decimate", action="store_true") + p.add_argument("--stray-vert", action="store_true") + p.add_argument("--lift-z", action="store_true") + p.add_argument("--float-side", action="store_true") + p.add_argument("--short-shelves", action="store_true") + p.add_argument("--pop-corks", action="store_true") + p.add_argument("--pull-knobs", action="store_true") + p.add_argument("--float-jars", action="store_true") + p.add_argument("--lift-drawers", action="store_true") + p.add_argument("--float-labels", action="store_true") + p.add_argument("--tall-flask", action="store_true") + p.add_argument("--crowd-jars", action="store_true") + p.add_argument("--uniform-vessels", action="store_true") + p.add_argument("--sharp-rail", action="store_true") + args = p.parse_args(argv) + + code, low, _high, mats, tex, _col = check( + args.skip_decimate, + lift_z=args.lift_z, + stray_vert=args.stray_vert, + float_side=args.float_side, + short_shelves=args.short_shelves, + pop_corks=args.pop_corks, + pull_knobs=args.pull_knobs, + float_jars=args.float_jars, + lift_drawers=args.lift_drawers, + float_labels=args.float_labels, + tall_flask=args.tall_flask, + crowd_jars=args.crowd_jars, + uniform_vessels=args.uniform_vessels, + sharp_rail=args.sharp_rail, + ) + if code: + return code + if args.output: + rcode = render_still(low, mats, tex, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + print("apothecary shelf OK") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + traceback.print_exc() + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(1) ++




