shipping-crate
A procedural shipping crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
A procedural apothecary shelf — housed shelves, gallery rails, a drawer bank and an arched crest, stocked with fifteen lathed bottles, flasks and glazed jars, each corked or lidded and labelled — 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/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.
| 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) |
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 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.
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.
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, seated CREST_BITE into the top board. Every board is chamfered at 2.5 mm with material= pinned to wood, edges sorted by index. The crest's caps are single n-gons, chamfered and then triangulated.DRAWER_BITE into the base, with a 4 mm reveal all round. Each has a turned brass knob lathed along −Y, its shank KNOB_BITE into the front.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.
CLOSURE_BITE into the body.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 |
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 |
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) |
# 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.
| 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 |
"""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)