diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 2fa631b1..762e8c90 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -167,6 +167,7 @@ "showcase/crate-stack", "showcase/stone-archway", "showcase/rope-bridge", - "showcase/grain-sacks" + "showcase/grain-sacks", + "showcase/brazier" ] } diff --git a/CLAUDE.md b/CLAUDE.md index e061df11..81befb07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ rules/.mdc - Anti-pattern rules, 9 total templates// - Starter projects, 3 total snippets/.py - Standalone code patterns, 27 total examples// - Runnable smoke-gated examples, 59 total (+ gallery.json) -showcase// - Budget-conformance props, 30 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 31 pieces (sibling of examples/; see showcase/README.md) scripts/build_gallery.py - Regenerates docs/gallery/ from examples/gallery.json + showcase/gallery.json scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders diff --git a/README.md b/README.md index 80bb0dcf..df201fc4 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  30 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  31 showcase pieces

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

-30 showcase pieces — click to expand the preview grid +31 showcase pieces — click to expand the preview grid

Shipping crate: a wooden slat crate with iron corner brackets on a dark studio floor, warm wedge on the back wall @@ -139,6 +139,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Stone archway: a semicircular masonry arch with coursed piers, projecting imposts, nine voussoirs and a proud keystone, on a dark studio floor Rope bridge: a sagging plank deck on foot ropes between two pairs of log posts, with lashed hemp hand ropes, suspenders, and ropes staked to the ground, on a dark studio floor Grain sacks: three burlap sacks gathered and tied with twine, two standing and one lying slumped in front, one blue-striped and one red-striped, on a dark studio floor + Brazier: an iron fire bowl with a rolled rim on three S-curved forged legs, holding ash and a heap of glowing charcoal, on a dark studio floor

[`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9. @@ -197,6 +198,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`grain-sacks`](showcase/grain-sacks/) — procedural pile of burlap grain sacks settled onto flat bases and tied with twine, through the same pipeline. Falsifiers `--round-bottom` exits 19, `--slip-tie` exits 17. +[`brazier`](showcase/brazier/) — procedural iron brazier with ash and glowing charcoal whose tip angle is recomputed from a density-weighted mass centre and the feet on the floor, through the same pipeline. Falsifiers `--tuck-legs` exits 19, `--overfill` exits 18. +
## Examples diff --git a/ROADMAP.md b/ROADMAP.md index 3f70c9d3..319fd2c6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,7 +135,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Keystone archway with voussoirs as a game-prop showcase piece~~ **SHIPPED** as `showcase/stone-archway/` — coursed piers, projecting imposts, nine voussoirs, proud keystone; the intrados circle fit is recomputed from vertex positions and `--off-circle` exits 19 on it while leaving every other budget passing - ~~Rope bridge (planks, rope rails, sag between anchors) as a game-prop showcase piece~~ **SHIPPED** as `showcase/rope-bridge/` — log posts, through-tenoned sills, twenty planks seated on laid foot ropes, lashed hand ropes, suspenders, staked tie-offs; a least-squares parabola is fitted to the plank tops and `--vee-deck` exits 19 on it with the sag still in band - ~~Grain sacks (cloth sacks, some slumped, tied necks) as a game-prop showcase piece~~ **SHIPPED** as `showcase/grain-sacks/` — three settled, pleated burlap sacks, one lying, twine ties seated in the neck waist, sacks pressed into one another; flat contact patch summed from the mesh, `--round-bottom` exits 19 on it while zmin still passes -- Brazier (iron bowl on legs, coals, ash) as a game-prop showcase piece +- ~~Brazier (iron bowl on legs, coals, ash) as a game-prop showcase piece~~ **SHIPPED** as `showcase/brazier/` — spun bowl with a rolled rim, three one-sweep forged legs, ash bed biting the wall, 36 cleaved coals packed without interpenetration; tip angle from a density-weighted mass centre and the support polygon, `--tuck-legs` exits 19 on it while the feet still ground - Apothecary shelf (shelf unit, bottles and jars of varied form) as a game-prop showcase piece - Wooden yoke (oxbow, iron rings) as a game-prop showcase piece - Butter churn (staved tub, lid, dasher) as a game-prop showcase piece diff --git a/docs/gallery/asset-sheets/brazier.webp b/docs/gallery/asset-sheets/brazier.webp new file mode 100644 index 00000000..bfa61b3f Binary files /dev/null and b/docs/gallery/asset-sheets/brazier.webp differ diff --git a/docs/gallery/assets/brazier-hero.webp b/docs/gallery/assets/brazier-hero.webp new file mode 100644 index 00000000..70d44c4d Binary files /dev/null and b/docs/gallery/assets/brazier-hero.webp differ diff --git a/docs/gallery/brazier/index.html b/docs/gallery/brazier/index.html new file mode 100644 index 00000000..39e13e24 --- /dev/null +++ b/docs/gallery/brazier/index.html @@ -0,0 +1,1739 @@ + + + + + + brazier — Showcase — Blender Developer Tools + + + + + + + + + + + + + + + + + + + + + + + + +
+

brazier

+

A procedural iron brazier — spun bowl with a rolled rim on three forged legs, a bed of ash and a heap of broken glowing charcoal — carried through UVs, bake, LOD, compound collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+
+
+ +

Rendered headless by the showcase piece itself — click to zoom.

+
witnesses Recomputed: 6960 tris, three materials with 1680 iron, 2880 coal and 448 ash faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.696×0.696×0.612 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, three feet grounded, every leg biting 2.6 mm into the bowl wall, 36 coals seated 6.4–15.7 mm into the ash with none interpenetrating, 54 mm of freeboard under the rim, and a 51.9 kg density-weighted mass centre 0.466 m up and 154 mm inside the support polygon — an 18.3° tip angle. --tuck-legs exits 19 at 10.2° while the feet still ground and the envelope is unchanged; --overfill exits 18 on the freeboard.
+
+
blender --background --python showcase/brazier/brazier.py --
+ +
+
+

A fire brazier: a spun-iron bowl with a rolled rim, three forged legs swept in one piece from the bowl wall to curled feet, a bed of ash in the bowl and a heap of thirty-six broken, glowing charcoal lumps on it. A showcase piece, not an example — it witnesses no API contract. It asserts that generated geometry meets declared asset budgets, recomputed from the finished mesh.

+

What it composes

+
Shipped contentUsed for
skills/mesh-editing-and-bmeshlathed bowl and ash, swept legs, cleaved coal lumps in one bmesh
skills/procedural-materials-and-shadersrusted rough iron, pale ash, charcoal with emissive cracks masked by noise
skills/bake-high-to-lowCycles tangent-space normal bake, high onto low
skills/engine-export-presetsUnity glTF (export_yup=True)
skills/depsgraph-and-evaluated-dataevaluated triangle counts for the LOD ratios
snippets/decimate_to_budget.pyLOD1 / LOD2 COLLAPSE chain
snippets/convex_hull_collider.pyone hull for the bowl, one per leg, merged into a compound
snippets/lod_chain.pyLOD naming and ratio pattern
examples/mesh-hygiene-audithygiene combinatorics (copied, not imported)
+

The budget that matters

+

A brazier holds burning coals, so the thing it must not do is tip when someone knocks it. That is invisible to every envelope check: legs tucked in under the bowl still reach the floor, still ground the piece, and still fit inside the bowl's own bounding box.

+

The piece recomputes stability from the finished mesh:

+
  • Centre of mass. Signed tetrahedra over every closed shell, each triangle weighted by its material's density (iron 7850, coal 500, ash 600 kg/m³). The bowl dominates: 51.9 kg in all, centred on the axis at 0.466 m.
  • Support polygon. The convex hull, in plan, of the leg vertices that lie on the floor.
  • Tip angle. atan(d / z), where d is the distance from the mass centre's plan position to the nearest support edge and z is its height. This is the lean the brazier survives before it goes over. Measured 18.30° against a 13° floor.
+

--tuck-legs is the falsifier built for exactly this. It brings the feet in from 0.29 m to 0.15 m of the axis. They still ground (zmin and the three-feet gate pass), the rim still sets the envelope (the AABB is unchanged), and every other budget passes. Only the tip angle sees it: 10.23°.

+

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 4 bytes, which is exporter metadata and not a budget).

+
BudgetBandMeasured
Base triangles6600–82006960
LOD1 ratio0.32–0.620.5000
LOD2 ratio0.10–0.350.2198
Material slotsexactly 3, distinct3
Iron faces≥ 14001680
Coal faces≥ 28002880
Ash faces≥ 380448
UV boundsinside 0..1(0.0006, 0.0006)–(0.9994, 0.9994)
UV AABB overlap≤ 1e-50.000000
Outer AABB0.696 × 0.696 × 0.612 m ± 0.0200.6960 × 0.6960 × 0.6118
Collider triangles≤ 400240 (four hulls)
Normal bake{'FINISHED'} with image data{'FINISHED'}, has_data=True
glTF exportfile written, non-empty~450 kB
Hygieneall zeroloose 0/0, non-manifold 0, zero-area 0, doubles 0, n-gons 0, coplanar cross-shell pairs 0
Grounded AABB\zmin\≤ 1e-40.00000
Named feet3 legs, each zmin ≤ 1e-33 at 0.00000
Legs bite the bowldeepest leg vertex inside the bowl ≥ 1.5 mm2.58 mm
Coal seat36 coals, deepest vertex inside the ash 4–30 mm6.40–15.69 mm
Freeboardrim above the highest coal or ash ≥ 30 mm53.95 mm
Tip angle≥ 13°18.30°
Coal interpenetration0 pairs0
Coal size spreadlargest footprint over smallest ≥ 1.355.64
+

Real-world size: a 0.70 m bowl, rim 0.61 m off the floor — a courtyard or hall brazier.

+

Construction

+
  • Bowl. A half-ellipsoid spun from sheet iron, with a rolled bead at the lip. The inner wall is the outer one offset along its own normal by WALL (8 mm), so the wall keeps its thickness where the bowl runs flat.
  • Legs. A chamfered-square bar (no 90° edges), swept in one piece:
  • The bar leaves the bowl along the wall's normal, starting LEG_BITE inside the iron.
  • It bends down and out on a Bézier whose last handle is level, so it arrives flat.
  • It runs level on the floor with a flat face down, and ends in a curled toe.
  • The section frame is fixed to each leg's own vertical plane.
  • Ash. A lathed dome whose edge follows the inner wall ASH_BITE outside it. The bed is held by the bowl, not resting beside it.
  • Coals. Each coal is an icosphere scaled to a lump, tumbled a few degrees and cleaved by three closed-form planes, so it reads as split charcoal and not a pebble ("rock is broken"). They are flat-shaded. Thirty-six are packed on a golden-angle spiral: each walks outward until it clears every placed coal by the two lumps' own plan radii (read off their vertices) plus COAL_GAP, and stays inside the inner wall. Each is then sunk into the ash by a fraction that varies with its index.
+

Findings the budgets forced

+
  • Coal count. Packing "as many coals as fit" made the coal count, and so the triangle budget, move with any falsifier that changed the space or the spacing. --overfill, --pile-coals and --uniform-coals all exited 4 or 5 instead of their targets. The heap is now a fixed N_COALS = 36.
  • Coplanar faces. One coplanar cross-shell pair: two buried underside triangles on neighbouring lumps, 0.2° apart in normal and 0.04 mm apart in plane. Stood level, similar lumps put near-parallel faces side by side. Each lump now leans a closed-form few degrees, and the count is 0.
  • Stale face references. A dict keyed by BMFace returned the wrong face after create_icosphere(subdivisions=2) freed and reallocated face memory. UV islands now live in a face int layer, and the provisional strip UVs are written straight into the loop layer.
+

Conventions walked

+

Every convention in showcase/README.md, and whether it applies here.

+
ConventionAppliesHow
Deterministic, budgets declared, assertions recomputeyesno RNG; every value above is read off the mesh
Falsifier fails the budget it targetsyestable below, proven on all three binaries
Hygiene incl. cross-shell coplanaryesexit 15
Named supportsyesthree feet (--float-foot)
A joint bitesyeseach leg's deepest vertex inside the bowl by signed distance (--short-legs)
A bent bar is one sweepyeseach leg is one sweep from bowl to toe
Iron is not chromeyesnear-black and rust, roughness 0.55–0.85, metallic 0.65
A vessel has a base and a bellyyesthe bowl's wall is offset along its normal
A vessel holds its contentsyesthe ash bites the inner wall; freeboard is asserted (--overfill)
Carried parts bite their bearersyescoals seated in the ash, banded (--float-coals)
Rock is broken, not smoothyescleaved, flat-shaded lumps
Scattered parts do not interpenetrateyesexit 20 (--pile-coals)
Scatter varies in sizeyesexit 21 (--uniform-coals)
Shading is part of the modelyesbowl and ash smooth (spun, powdery); legs flat (forged bar); coals flat (broken)
One substance, one slotyesiron, coal, ash
Edge treatment: no right anglesyes, by constructionchamfered-square legs (45° steps), lathed bowl; not a separate budget
Sort bmesh operator inputsn/ano set-fed operator is run
Level on the stage; stage 60 myesturned about Z only; 60 m floor and wall
Keep a falsifier's envelope stillyesevery falsifier leaves the AABB unchanged
Timber, masonry, rope, fixtures, roofs, rings, paintnothe piece has none of these
+

Falsifiers

+

Each breaks one pipeline stage so a named budget fails. All ten were run on 4.5.11, 5.1.2 and 5.2.1 and exited the same declared code on all three.

+
FlagTarget budgetBreaksExit
--skip-decimateLOD1 ratiodrops the DECIMATE modifiers, LOD1 ratio goes to 1.00009
--stray-vertmesh hygieneadds one loose vertex under the bowl15
--lift-zgrounded zminlifts the whole mesh 50 mm16
--float-footnamed feetlifts one foot 12 mm; the other two still ground the AABB16
--short-legsleg-to-bowl bitestarts every leg 12 mm off the wall; −10.0 mm17
--float-coalscoal seatlifts every coal 30 mm off the ash; −13.9 to −23.3 mm18
--overfillfreeboardraises the ash and heap 45 mm, still below the rim; 8.3 mm18
--tuck-legstip anglebrings the feet in to 0.15 m of the axis; 10.23°19
--pile-coalscoal interpenetrationpacks coals at 0.6 of their spacing; 40 pairs20
--uniform-coalscoal size spreadone size for every coal; 1.19821
+

Exit codes

+

File-local and sequential. 9 is a valid check code. 1 is the FATAL wrapper — a crash, never a named check.

+
CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Mesh did not build, or has no UV layer
4Base triangle count outside band
5Material slots, or a material's face floor
6UVs outside 0..1
7UV AABB overlap above tolerance
8Outer AABB off declared size
9LOD1 or LOD2 ratio outside band (--skip-decimate)
10Framing gate (examples/gallery_framing.py, render path only)
11Collider triangles above ceiling
12Normal bake failed or produced no image data
13glTF export missing or empty
14--output produced no file
15Mesh hygiene (--stray-vert)
16Grounded zmin, or a foot floating (--lift-z, --float-foot)
17A leg not biting the bowl (--short-legs)
18Coal seat or freeboard (--float-coals, --overfill)
19Tip angle below floor (--tuck-legs)
20Coals interpenetrate (--pile-coals)
21Coal size spread below floor (--uniform-coals)
+

Run it

+
# Budget check, no render. ~2.2 s on 4.5, ~2.0 s on 5.1, ~2.4 s on 5.2.
+blender --background --python brazier.py --
+
+# Falsifier: the legs tuck in under the bowl. Must exit 19.
+blender --background --python brazier.py -- --tuck-legs
+
+# Falsifier: the bowl is filled to the brim. Must exit 18.
+blender --background --python brazier.py -- --overfill
+
+# Render the gallery still (EEVEE; --engine cycles on a GPU-less host).
+blender --background --python brazier.py -- --output brazier.webp
+

Smoke runs the check-only path. It does not pass --output or any falsifier.

+

The render adds two render-only lights of its own: a point light above the coals for the fire, and a spot for the warm floor pool. The camera is low and close, so the house area wedge landed on the back wall, and an area lamp brought near lit the whole floor.

+

Cross-version measurements

+
Value4.5.115.1.25.2.1
Base triangles696069606960
LOD1 / LOD2 tris3480 / 1530samesame
Face counts (iron / coal / ash)1680 / 2880 / 448samesame
Outer AABB0.6960 × 0.6960 × 0.6118samesame
Collider tris240240240
Mass / tip angle51.87 kg / 18.30°samesame
glTF bytes450940450940450936
Check wall-clock~2.2 s~2.0 s~2.4 s
+
+
+

Source

+
+ showcase/brazier/brazier.py + View on GitHub → +
+
"""Game-ready brazier — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural iron brazier: a spun bowl
+with a rolled rim on three forged legs, a bed of ash in the bowl and a
+heap of broken, glowing coals on it. Carried through UVs, three
+materials (iron, coal, ash), a high-to-low normal bake, an LOD chain, a
+compound convex collider, and a Unity glTF export.
+
+The budget that matters here is the one a brazier can fail invisibly: it
+holds burning coals, so it must not tip when it is knocked. The piece
+recomputes the centre of mass from the closed shells, weighted by the
+density of each material, and the support polygon from the feet that
+touch the floor, and asserts the angle it can lean before it tips.
+Legs tucked in under the bowl still ground the piece and still fit its
+bounding box; only the tip angle knows the difference.
+
+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-foot`` the named feet,
+``--short-legs`` the leg-to-bowl bite, ``--float-coals`` the coal seat,
+``--overfill`` the freeboard, ``--tuck-legs`` the tip angle,
+``--pile-coals`` coal interpenetration, ``--uniform-coals`` coal size
+variation.
+
+No randomness: every coal's size, shape and cleave 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 brazier.py --
+    blender --background --python brazier.py -- --tuck-legs
+    blender --background --python brazier.py -- --output brazier.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 bowl: a half-ellipsoid spun from sheet iron, rim at RIM_Z, with a
+# rolled bead at the lip. The inner wall is the outer wall offset along
+# its own normal by WALL.
+RIM_R = 0.340
+RIM_Z = 0.600
+DEPTH = 0.200
+WALL = 0.008
+BEAD_R = 0.010
+BOWL_SEG = 32
+BOWL_STEPS = 14
+
+# Three forged legs: a chamfered-square bar swept in one piece from inside
+# the bowl wall, out and down to a flat foot, ending in a small curl.
+N_LEGS = 3
+LEG_A = 0.011
+LEG_CHAMFER = 0.30
+ATTACH_PHI = math.radians(40.0)
+LEG_BITE = 0.003
+LEG_CONE = 0.25
+FOOT_R = 0.290
+TUCK_FOOT_R = 0.150
+SHORT_LEG_GAP = 0.012
+FLOAT_FOOT = 0.012
+
+# Ash: a shallow dome whose rim bites into the bowl's inner wall.
+ASH_Z = 0.490
+ASH_DOME = 0.035
+ASH_BITE = 0.003
+ASH_SEG = 32
+OVERFILL = 0.045
+
+# Coals: broken lumps (a cleaved icosphere, flat-shaded) sunk into the
+# ash. Sizes vary by a closed-form draw on the index.
+COAL_BASE = 0.026
+COAL_VAR = 0.40
+COAL_SINK = 0.30
+COAL_GAP = 0.002
+COAL_TRIES = 90
+# A heap is a fixed number of coals: packing as many as fit made the
+# count, and so the triangle budget, move with every falsifier that
+# changes the space or the spacing.
+N_COALS = 36
+PILE_SCALE = 0.60
+FLOAT_COALS = 0.030
+
+DENSITY = {0: 7850.0, 1: 500.0, 2: 600.0}
+
+BBOX_TOL = 0.020
+OUTER_SIZE = (0.696, 0.696, 0.612)
+
+BASE_TRIS_MIN = 6600
+BASE_TRIS_MAX = 8200
+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 = 3
+IRON_FACES_MIN = 1400
+COAL_FACES_MIN = 2800
+ASH_FACES_MIN = 380
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 400
+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
+FOOT_Z_MAX = 1e-3
+LEG_BITE_MIN = 0.0015
+COAL_SEAT_MIN = 0.004
+COAL_SEAT_MAX = 0.030
+FREEBOARD_MIN = 0.030
+TIP_MIN = math.radians(13.0)
+COAL_SPREAD_MIN = 1.35
+
+IRON_IDX = 0
+COAL_IDX = 1
+ASH_IDX = 2
+
+
+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()
+
+
+# --- profiles ---------------------------------------------------------------
+
+
+def outer_point(phi):
+    """Outer bowl wall at polar angle phi (0 at the bottom pole, pi/2 at the rim)."""
+    return Vector((RIM_R * math.sin(phi), RIM_Z - DEPTH * math.cos(phi)))
+
+
+def outer_normal(phi):
+    p = outer_point(phi)
+    n = Vector((p.x / RIM_R ** 2, (p.y - RIM_Z) / DEPTH ** 2))
+    return n.normalized()
+
+
+def bowl_profile():
+    """(r, z) loop from the outer pole, over the rolled bead, to the inner pole.
+
+    The inner wall is the outer one offset along its own normal, so the
+    wall is WALL thick everywhere; a sideways offset thins it to nothing
+    where the bowl runs flat.
+    """
+    phi_top = math.acos((BEAD_R + 0.002) / DEPTH)
+    phis = [phi_top * k / BOWL_STEPS for k in range(BOWL_STEPS + 1)]
+    outer = [outer_point(p) for p in phis]
+    inner = [outer_point(p) - outer_normal(p) * WALL for p in phis]
+    centre = Vector((RIM_R - 0.002, RIM_Z + 0.002))
+    bead = []
+    lo = math.atan2(outer[-1].y - centre.y, outer[-1].x - centre.x)
+    hi = math.atan2(inner[-1].y - centre.y, inner[-1].x - centre.x) + 2.0 * math.pi
+    for k in range(1, 8):
+        a = lo + (hi - lo) * k / 8
+        bead.append(centre + Vector((math.cos(a), math.sin(a))) * BEAD_R)
+    loop = [Vector((0.0, outer[0].y))] + outer[1:] + bead + list(reversed(inner[1:]))
+    loop.append(Vector((0.0, inner[0].y)))
+    return loop, inner
+
+
+def inner_radius(inner, z):
+    """Inner wall radius at height z, linear between the offset samples."""
+    pts = sorted(inner, key=lambda p: p.y)
+    if z <= pts[0].y:
+        return 0.0
+    for a, b in zip(pts, pts[1:]):
+        if a.y <= z <= b.y:
+            f = (z - a.y) / (b.y - a.y) if b.y > a.y else 0.0
+            return a.x + (b.x - a.x) * f
+    return pts[-1].x
+
+
+# --- construction -----------------------------------------------------------
+
+
+def stamp(ctx, face, island, uvmap):
+    """Tag ``face`` with its UV island and write its provisional strip UVs.
+
+    Kept in bmesh layers, not a dict keyed by BMFace: an operator that
+    frees and reallocates faces (create_icosphere with subdivisions=2)
+    hands a later face a dead face's identity, and the dict then answers
+    for the wrong face.
+    """
+    face[ctx["isl"]] = island
+    for loop in face.loops:
+        loop[ctx["uv"]].uv = uvmap[loop.vert]
+
+
+def new_island(ctx):
+    ctx["next"] += 1
+    return ctx["next"]
+
+
+def lathe(bm, profile, n, mat_idx, ctx, smooth=True):
+    """Revolve an (r, z) profile about Z; r == 0 at the ends makes a pole.
+
+    One strip island (profile length by angle); pole fans put their pole
+    half a column over so no two faces overlap in UV.
+    """
+    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((0.0, 0.0, p.y)))
+            continue
+        rings.append([bm.verts.new((p.x * math.cos(2 * math.pi * k / n),
+                                    p.x * math.sin(2 * math.pi * k / n), p.y))
+                      for k in range(n)])
+    faces = []
+    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.smooth = smooth
+            stamp(ctx, f, island, uv)
+            faces.append(f)
+    return faces
+
+
+def leg_section():
+    """A chamfered square bar, flat side down: (side, up) offsets."""
+    a, c = LEG_A, LEG_A * LEG_CHAMFER
+    pts = [(a, -(a - c)), (a, a - c), (a - c, a), (-(a - c), a),
+           (-a, a - c), (-a, -(a - c)), (-(a - c), -a), (a - c, -a)]
+    return pts
+
+
+def bezier(p0, p1, p2, p3, n):
+    out = []
+    for k in range(n + 1):
+        t = k / n
+        u = 1.0 - t
+        out.append(p0 * u ** 3 + p1 * 3 * u * u * t + p2 * 3 * u * t * t + p3 * t ** 3)
+    return out
+
+
+def leg_path(foot_r, short=False, float_foot=False):
+    """(rho, z) centreline, from inside the bowl wall to the curled foot.
+
+    One sweep: the bar leaves the bowl along the wall's normal, bends down
+    and out, runs level on the floor with a flat face down, and curls up.
+    """
+    base = outer_point(ATTACH_PHI)
+    n = outer_normal(ATTACH_PHI)
+    start = base + n * (SHORT_LEG_GAP if short else -LEG_BITE)
+    p1 = base + n * 0.030
+    lift = FLOAT_FOOT if float_foot else 0.0
+    foot0 = Vector((foot_r - 0.015, LEG_A + lift))
+    foot1 = Vector((foot_r + 0.015, LEG_A + lift))
+    pts = [start]
+    # The handle into the foot is level, so the bar arrives flat on the floor.
+    pts += bezier(p1, p1 + n * 0.10, foot0 - Vector((0.10, 0.0)), foot0, 12)
+    pts += [foot0 + (foot1 - foot0) * (k / 3) for k in range(1, 4)]
+    curl = bezier(foot1, foot1 + Vector((0.012, 0.0)), foot1 + Vector((0.024, 0.006)),
+                  foot1 + Vector((0.026, 0.020)), 4)
+    pts += curl[1:]
+    return pts
+
+
+def sweep_bar(bm, pts, section, side, mat_idx, ctx, cone=LEG_CONE):
+    """Sweep ``section`` along ``pts`` (3D), frame fixed to the path's plane.
+
+    Ends close in a blunt cone so there is no n-gon cap. Returns vertices.
+    """
+    island = new_island(ctx)
+    m = len(pts)
+    tans = []
+    for i in range(m):
+        d = pts[min(i + 1, m - 1)] - pts[max(i - 1, 0)]
+        tans.append(d.normalized())
+    n = len(section)
+    rings = []
+    for p, t in zip(pts, tans):
+        s = side - t * side.dot(t)
+        s.normalize()
+        up = t.cross(s)
+        rings.append([bm.verts.new(p + s * x + up * y) for x, y in section])
+    arc = [0.0]
+    for i in range(1, m):
+        arc.append(arc[-1] + (pts[i] - pts[i - 1]).length)
+    for k in range(m - 1):
+        a, b = rings[k], rings[k + 1]
+        for i in range(n):
+            j = (i + 1) % n
+            f = bm.faces.new((a[i], a[j], b[j], b[i]))
+            f.material_index = mat_idx
+            f.smooth = False
+            stamp(ctx, f, island, {a[i]: (arc[k], i / n), a[j]: (arc[k], (i + 1) / n),
+                                   b[j]: (arc[k + 1], (i + 1) / n), b[i]: (arc[k + 1], i / n)})
+    reach = cone * LEG_A
+    for ring, p, t, sign, s_end in ((rings[0], pts[0], tans[0], -1.0, arc[0]),
+                                    (rings[-1], pts[-1], tans[-1], 1.0, arc[-1])):
+        pole = bm.verts.new(p + t * (sign * reach))
+        for i in range(n):
+            j = (i + 1) % n
+            f = bm.faces.new((pole, ring[i], ring[j]))
+            f.material_index = mat_idx
+            f.smooth = False
+            stamp(ctx, f, island, {pole: (s_end + sign * reach, (i + 0.5) / n),
+                                   ring[i]: (s_end, i / n), ring[j]: (s_end, (i + 1) / n)})
+    return [v for r in rings for v in r]
+
+
+def coal_size(i, uniform=False):
+    """Closed-form size draw per coal: golden-ratio spacing, no RNG."""
+    if uniform:
+        # One draw for every coal, small enough that the full heap still
+        # fits: the coals do not set the envelope, so nothing else moves.
+        return COAL_BASE * (1.0 - COAL_VAR * 0.5)
+    f = (i * 0.6180339887 + 0.37) % 1.0
+    return COAL_BASE * (1.0 + COAL_VAR * (f - 0.5) * 2.0)
+
+
+def coal_axes(i, size):
+    """Ellipsoid half-axes: a charcoal lump is longer than it is tall."""
+    e = (i * 0.4142135 + 0.2) % 1.0
+    return (size * (1.15 + 0.25 * e), size * (0.95 - 0.15 * e), size * 0.72)
+
+
+_ICO = {}
+
+
+def ico_unit():
+    """Unit icosphere (subdivisions 2): vertex positions and face index lists."""
+    if not _ICO:
+        tmp = bmesh.new()
+        try:
+            bmesh.ops.create_icosphere(tmp, subdivisions=2, radius=1.0)
+            tmp.verts.index_update()
+            _ICO["verts"] = [v.co.copy() for v in tmp.verts]
+            _ICO["faces"] = [[v.index for v in f.verts] for f in tmp.faces]
+        finally:
+            tmp.free()
+    return _ICO["verts"], _ICO["faces"]
+
+
+def coal_shape(axes, i):
+    """A broken lump, centred on the origin: a scaled icosphere cleaved by three planes.
+
+    Every vertex beyond a plane is projected onto it, so the lump reads as
+    split charcoal rather than a pebble.
+    """
+    unit, _faces = ico_unit()
+    yaw = i * 2.39996
+    c, s = math.cos(yaw), math.sin(yaw)
+    pts = []
+    for u in unit:
+        x, y, z = u.x * axes[0], u.y * axes[1], u.z * axes[2]
+        pts.append(Vector((c * x - s * y, s * x + c * y, z)))
+    # Tumbled, not set upright: each lump leans by a closed-form few
+    # degrees. Stood level, two similar lumps once carried a pair of
+    # near-parallel buried faces in one plane (a coplanar pair).
+    tilt = Matrix.Rotation(0.18 * (((i * 0.6180340) % 1.0) - 0.5), 3, "X") @         Matrix.Rotation(0.18 * (((i * 0.4142136) % 1.0) - 0.5), 3, "Y")
+    pts = [tilt @ p for p in pts]
+    for k in range(3):
+        a = yaw + k * 2.1 + 0.4
+        el = 0.35 + 0.25 * ((i + k) % 3)
+        nrm = Vector((math.cos(a) * math.cos(el), math.sin(a) * math.cos(el), math.sin(el)))
+        d = 0.70 * min(axes) + 0.18 * max(axes) * (((i * 7 + k * 3) % 5) / 5.0)
+        for p in pts:
+            h = p.dot(nrm)
+            if h > d:
+                p -= nrm * (h - d)
+    return pts
+
+
+def add_coal(bm, centre, shape, mat_idx):
+    """Emit a lump built by ``coal_shape`` at ``centre``. Flat-shaded."""
+    _unit, faces = ico_unit()
+    vs = [bm.verts.new(p + centre) for p in shape]
+    for idx in faces:
+        f = bm.faces.new([vs[k] for k in idx])
+        f.material_index = mat_idx
+        f.smooth = False
+    return vs
+
+
+def coal_layout(inner, ash_z, r_edge, pile=False, uniform=False):
+    """Greedy sunflower packing: each coal at the smallest radius that clears the rest.
+
+    Coal ``i`` tries the golden angle times ``i`` and walks outward until
+    its centre is at least the sum of its own and each placed coal's plan
+    radius bound (plus COAL_GAP) from every one of them, and its bound
+    stays inside the inner wall at the height of its base. Bounds come
+    from the same constants that size each coal, so a bigger coal takes
+    more room. ``pile`` shrinks the spacing so neighbours interpenetrate.
+    """
+    shapes = {}
+
+    def shape(i):
+        if i not in shapes:
+            shapes[i] = coal_shape(coal_axes(i, coal_size(i, uniform)), i)
+        return shapes[i]
+
+    def bound(i):
+        # The lump's own plan radius after cleaving, read off its vertices.
+        return max(math.hypot(p.x, p.y) for p in shape(i))
+
+    def ash_at(rho):
+        return ash_z + ASH_DOME * (1.0 - min(1.0, (rho / r_edge) ** 2))
+
+    scale = PILE_SCALE if pile else 1.0
+    placed = []
+    for i in range(COAL_TRIES):
+        if len(placed) == N_COALS:
+            break
+        b = bound(i)
+        theta = i * 2.39996323
+        rho = 0.0
+        while True:
+            x, y = rho * math.cos(theta), rho * math.sin(theta)
+            limit = inner_radius(inner, ash_at(rho) - 0.02) - COAL_GAP
+            if rho + b > limit:
+                break
+            if all(math.hypot(x - px, y - py) >= (b + pb) * scale + COAL_GAP
+                   for px, py, pb, _i in placed):
+                placed.append((x, y, b, i))
+                break
+            rho += 0.002
+    coals = []
+    for x, y, _b, i in placed:
+        size = coal_size(i, uniform)
+        axes = coal_axes(i, size)
+        # Each lump settles a little differently. Sunk to one fraction, two
+        # buried undersides once landed in one plane (a coplanar pair).
+        sink = COAL_SINK + 0.06 * (((i * 0.3819660) % 1.0) - 0.5)
+        z = ash_at(math.hypot(x, y)) + axes[2] * (1.0 - 2.0 * sink)
+        coals.append((Vector((x, y, z)), shape(i), i))
+    return coals
+
+
+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 build_brazier_mesh(
+    name,
+    stray_vert=False,
+    float_foot=False,
+    short_legs=False,
+    float_coals=False,
+    overfill=False,
+    tuck_legs=False,
+    pile_coals=False,
+    uniform_coals=False,
+):
+    profile, inner = bowl_profile()
+    ash_z = ASH_Z + (OVERFILL if overfill else 0.0)
+    r_edge = inner_radius(inner, ash_z) + ASH_BITE
+    bm = bmesh.new()
+    try:
+        ctx = {"uv": bm.loops.layers.uv.new("UVMap"),
+               "isl": bm.faces.layers.int.new("UVIsland"), "next": 0}
+        lathe(bm, profile, BOWL_SEG, IRON_IDX, ctx)
+        foot_r = TUCK_FOOT_R if tuck_legs else FOOT_R
+        for k in range(N_LEGS):
+            az = 2.0 * math.pi * k / N_LEGS + math.pi / 2.0
+            radial = Vector((math.cos(az), math.sin(az), 0.0))
+            side = Vector((-math.sin(az), math.cos(az), 0.0))
+            path = leg_path(foot_r, short=short_legs, float_foot=(float_foot and k == 0))
+            pts = [radial * p.x + Vector((0.0, 0.0, p.y)) for p in path]
+            sweep_bar(bm, pts, leg_section(), side, IRON_IDX, ctx)
+        # Ash: the dome, then down the inner wall (biting it) to the bottom.
+        ash = [Vector((0.0, inner[0].y - ASH_BITE))]
+        lows = sorted(inner, key=lambda p: p.y)
+        for p in lows:
+            if inner[0].y + 0.004 < p.y < ash_z - 0.004:
+                ash.append(Vector((inner_radius(inner, p.y) + ASH_BITE, p.y)))
+        ash.append(Vector((r_edge, ash_z)))
+        for k in range(1, 6):
+            rho = r_edge * (1.0 - k / 6.0)
+            ash.append(Vector((rho, ash_z + ASH_DOME * (1.0 - (rho / r_edge) ** 2))))
+        ash.append(Vector((0.0, ash_z + ASH_DOME)))
+        lathe(bm, ash, ASH_SEG, ASH_IDX, ctx)
+        for centre, shape, _i in coal_layout(inner, ash_z, r_edge, pile=pile_coals,
+                                             uniform=uniform_coals):
+            if float_coals:
+                centre = centre + Vector((0.0, 0.0, FLOAT_COALS))
+            add_coal(bm, centre, shape, COAL_IDX)
+        if stray_vert:
+            bm.verts.new((0.0, 0.0, 0.25))
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        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()
+    obj = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(obj)
+    return obj
+
+
+# --- surface ----------------------------------------------------------------
+
+
+def _sock(sockets, identifier):
+    return next(sk for sk in sockets if sk.identifier == identifier)
+
+
+def iron_material(name):
+    """Forged iron: near-black, rough, rusted in patches. Not chrome."""
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    tc = nt.nodes.new("ShaderNodeTexCoord")
+    noise = nt.nodes.new("ShaderNodeTexNoise")
+    noise.inputs["Scale"].default_value = 14.0
+    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.45
+    ramp.color_ramp.elements[0].color = (0.035, 0.033, 0.031, 1.0)
+    ramp.color_ramp.elements[1].position = 0.78
+    ramp.color_ramp.elements[1].color = (0.20, 0.085, 0.035, 1.0)
+    nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
+    nt.links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"])
+    bsdf.inputs["Metallic"].default_value = 0.65
+    rough = nt.nodes.new("ShaderNodeMapRange")
+    rough.inputs["To Min"].default_value = 0.55
+    rough.inputs["To Max"].default_value = 0.85
+    nt.links.new(noise.outputs["Fac"], rough.inputs["Value"])
+    nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"])
+    return mat
+
+
+def coal_material(name):
+    """Charcoal: black and matte, glowing orange in the cracks."""
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = (0.022, 0.020, 0.019, 1.0)
+    bsdf.inputs["Roughness"].default_value = 0.9
+    tc = nt.nodes.new("ShaderNodeTexCoord")
+    vor = nt.nodes.new("ShaderNodeTexVoronoi")
+    vor.feature = "DISTANCE_TO_EDGE"
+    vor.inputs["Scale"].default_value = 60.0
+    nt.links.new(tc.outputs["Object"], vor.inputs["Vector"])
+    crack = nt.nodes.new("ShaderNodeMapRange")
+    crack.inputs["From Min"].default_value = 0.0
+    crack.inputs["From Max"].default_value = 0.04
+    crack.inputs["To Min"].default_value = 1.0
+    crack.inputs["To Max"].default_value = 0.0
+    nt.links.new(vor.outputs["Distance"], crack.inputs["Value"])
+    # Hotter lower down: the heap burns from the ash up.
+    sep = nt.nodes.new("ShaderNodeSeparateXYZ")
+    nt.links.new(tc.outputs["Object"], sep.inputs["Vector"])
+    heat = nt.nodes.new("ShaderNodeMapRange")
+    heat.inputs["From Min"].default_value = 0.60
+    heat.inputs["From Max"].default_value = 0.47
+    heat.inputs["To Min"].default_value = 0.35
+    heat.inputs["To Max"].default_value = 1.0
+    nt.links.new(sep.outputs["Z"], heat.inputs["Value"])
+    # Not every crack burns: a noise mask breaks the network up, so a lump
+    # glows in patches instead of wearing an even honeycomb.
+    mask_n = nt.nodes.new("ShaderNodeTexNoise")
+    mask_n.inputs["Scale"].default_value = 22.0
+    mask_n.inputs["Detail"].default_value = 3.0
+    nt.links.new(tc.outputs["Object"], mask_n.inputs["Vector"])
+    mask = nt.nodes.new("ShaderNodeMapRange")
+    mask.inputs["From Min"].default_value = 0.42
+    mask.inputs["From Max"].default_value = 0.62
+    nt.links.new(mask_n.outputs["Fac"], mask.inputs["Value"])
+    burn = nt.nodes.new("ShaderNodeMath")
+    burn.operation = "MULTIPLY"
+    nt.links.new(crack.outputs["Result"], burn.inputs[0])
+    nt.links.new(mask.outputs["Result"], burn.inputs[1])
+    glow = nt.nodes.new("ShaderNodeMath")
+    glow.operation = "MULTIPLY"
+    nt.links.new(burn.outputs["Value"], glow.inputs[0])
+    nt.links.new(heat.outputs["Result"], glow.inputs[1])
+    strength = nt.nodes.new("ShaderNodeMath")
+    strength.operation = "MULTIPLY"
+    strength.inputs[1].default_value = 7.0
+    nt.links.new(glow.outputs["Value"], strength.inputs[0])
+    bsdf.inputs["Emission Color"].default_value = (1.0, 0.32, 0.06, 1.0)
+    nt.links.new(strength.outputs["Value"], bsdf.inputs["Emission Strength"])
+    return mat
+
+
+def ash_material(name):
+    """Ash: pale, matte, with darker cinders mottled through it."""
+    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 = 40.0
+    noise.inputs["Detail"].default_value = 6.0
+    nt.links.new(tc.outputs["Object"], noise.inputs["Vector"])
+    ramp = nt.nodes.new("ShaderNodeValToRGB")
+    ramp.color_ramp.elements[0].position = 0.35
+    ramp.color_ramp.elements[0].color = (0.06, 0.056, 0.053, 1.0)
+    ramp.color_ramp.elements[1].position = 0.70
+    ramp.color_ramp.elements[1].color = (0.26, 0.245, 0.225, 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 = 0.97
+    return mat
+
+
+def brazier_materials():
+    return iron_material("BrazierIron"), coal_material("BrazierCoal"), ash_material("BrazierAsh")
+
+
+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 shell_tree(me, group):
+    member = set(group)
+    remap = {v: k for k, v in enumerate(group)}
+    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 group], polys)
+
+
+def inside_depth(tree, pts):
+    """Deepest point of ``pts`` inside ``tree``'s surface; negative is a gap."""
+    best = -99.0
+    for p in pts:
+        loc, nrm, _i, dist = tree.find_nearest(p)
+        if loc is None:
+            continue
+        d = dist if (p - loc).dot(nrm) < 0.0 else -dist
+        best = max(best, d)
+    return best
+
+
+def classify(me):
+    mats = {}
+    for p in me.polygons:
+        for i in p.vertices:
+            mats.setdefault(i, p.material_index)
+    out = {"bowl": [], "leg": [], "ash": [], "coal": [], "other": []}
+    for g in shells(me):
+        pts = [me.vertices[i].co.copy() for i in g]
+        rec = {"g": g, "pts": pts, "c": sum(pts, Vector()) / len(pts)}
+        m = mats.get(g[0], -1)
+        if m == IRON_IDX:
+            out["bowl" if len(g) > 400 else "leg"].append(rec)
+        elif m == ASH_IDX:
+            out["ash"].append(rec)
+        elif m == COAL_IDX:
+            out["coal"].append(rec)
+        else:
+            out["other"].append(rec)
+    return out
+
+
+def mass_centre(me):
+    """Centre of mass of the closed shells, each triangle weighted by density.
+
+    Signed tetrahedra from the origin: every shell is closed and wound
+    outward, so interior volumes cancel and each material contributes
+    its volume times its density.
+    """
+    me.calc_loop_triangles()
+    m_tot = 0.0
+    acc = Vector((0.0, 0.0, 0.0))
+    for tri in me.loop_triangles:
+        a, b, c = (me.vertices[i].co for i in tri.vertices)
+        v = a.dot(b.cross(c)) / 6.0
+        rho = DENSITY.get(me.polygons[tri.polygon_index].material_index, 0.0)
+        m_tot += v * rho
+        acc += (a + b + c) * (v * rho / 4.0)
+    return acc / m_tot if m_tot else Vector(), m_tot
+
+
+def convex_hull_2d(pts):
+    pts = sorted(set((round(p[0], 9), round(p[1], 9)) for p in pts))
+    if len(pts) < 3:
+        return pts
+
+    def cross(o, a, b):
+        return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
+
+    lower, upper = [], []
+    for p in pts:
+        while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
+            lower.pop()
+        lower.append(p)
+    for p in reversed(pts):
+        while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
+            upper.pop()
+        upper.append(p)
+    return lower[:-1] + upper[:-1]
+
+
+def brazier_audit(me):
+    parts = classify(me)
+    bowl = parts["bowl"][0] if parts["bowl"] else None
+    ash = parts["ash"][0] if parts["ash"] else None
+    out = {k: len(v) for k, v in parts.items()}
+    out["foot_worst"] = max((min(p.z for p in r["pts"]) for r in parts["leg"]), default=99.0)
+
+    # Legs bite the bowl wall: deepest leg vertex inside the bowl shell.
+    bowl_tree = shell_tree(me, bowl["g"]) if bowl else None
+    bites = [inside_depth(bowl_tree, r["pts"]) for r in parts["leg"]] if bowl_tree else []
+    out["leg_bite"] = min(bites, default=-99.0)
+
+    # Coals sit in the ash: deepest coal vertex inside the ash shell.
+    ash_tree = shell_tree(me, ash["g"]) if ash else None
+    seats = [inside_depth(ash_tree, r["pts"]) for r in parts["coal"]] if ash_tree else []
+    out["seat_min"] = min(seats, default=-99.0)
+    out["seat_max"] = max(seats, default=99.0)
+
+    # Freeboard: the rim above the highest coal or ash.
+    rim = max(p.z for p in bowl["pts"]) if bowl else 0.0
+    top = max((p.z for r in parts["coal"] + parts["ash"] for p in r["pts"]), default=99.0)
+    out["freeboard"] = rim - top
+
+    # Coals do not interpenetrate one another.
+    trees = [shell_tree(me, r["g"]) for r in parts["coal"]]
+    hits = 0
+    for i in range(len(trees)):
+        for j in range(i + 1, len(trees)):
+            if trees[i].overlap(trees[j]):
+                hits += 1
+    out["coal_hits"] = hits
+
+    # Coal sizes vary: largest footprint over smallest.
+    foot = []
+    for r in parts["coal"]:
+        xs = [p.x for p in r["pts"]]
+        ys = [p.y for p in r["pts"]]
+        foot.append((max(xs) - min(xs)) * (max(ys) - min(ys)))
+    out["coal_spread"] = (max(foot) / min(foot)) if foot else 0.0
+
+    # Tip angle: distance from the mass centre to the nearest edge of the
+    # support polygon, over its height.
+    com, mass = mass_centre(me)
+    contacts = [(p.x, p.y) for r in parts["leg"] for p in r["pts"] if p.z <= 1e-6]
+    hull = convex_hull_2d(contacts)
+    d_edge = -99.0
+    if len(hull) >= 3:
+        d_edge = 99.0
+        for a, b in zip(hull, hull[1:] + hull[:1]):
+            ex, ey = b[0] - a[0], b[1] - a[1]
+            ln = math.hypot(ex, ey)
+            d = (ex * (com.y - a[1]) - ey * (com.x - a[0])) / ln
+            d_edge = min(d_edge, d)
+    out["com"] = com
+    out["mass"] = mass
+    out["d_edge"] = d_edge
+    out["tip"] = math.atan2(d_edge, com.z) if com.z > 0 else -1.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):
+    """Compound collider: one convex hull for the bowl and its contents, one per leg.
+
+    A single hull over the whole brazier fills the space between the legs,
+    which a character should be able to kick a foot through.
+    """
+    me = obj.data
+    parts = classify(me)
+    # Sampled in build order: the bowl is rings of BOWL_SEG, a leg rings
+    # of eight. The ash and coals sit below the rim, inside the bowl's hull.
+    bowl = sorted(parts["bowl"][0]["g"])
+    groups = [[me.vertices[v].co.copy() for k, v in enumerate(bowl)
+               if (k // BOWL_SEG) % 2 == 0 and (k % BOWL_SEG) % 4 == 0]
+              + [me.vertices[bowl[-1]].co.copy()]]
+    for r in parts["leg"]:
+        leg = sorted(r["g"])
+        groups.append([me.vertices[v].co.copy() for k, v in enumerate(leg)
+                       if (k // 8) % 4 == 0 and k % 2 == 0] + [me.vertices[leg[-1]].co.copy()])
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        for pts in groups:
+            tmp = bmesh.new()
+            try:
+                vs = [tmp.verts.new(p) for p in pts]
+                bmesh.ops.convex_hull(tmp, input=vs)
+                remap = {}
+                for f in tmp.faces:
+                    for v in f.verts:
+                        if v not in remap:
+                            remap[v] = bm.verts.new(v.co)
+                    bm.faces.new([remap[v] for v in f.verts])
+            finally:
+                tmp.free()
+        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("BrazierNrm", 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 = IRON_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_brazier_mesh("BrazierLow", **flags)
+    hi_flags = {k: v for k, v in flags.items() if k != "stray_vert"}
+    high = build_brazier_mesh("BrazierHigh", **hi_flags)
+    mats = brazier_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("brazier 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[IRON_IDX], BAKE_RES)
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "BrazierLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "BrazierLOD2", 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, "BrazierCollider")
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(tempfile.gettempdir(), f"bdt_brazier_{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)
+    ba = brazier_audit(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 bowl={ba['bowl']} legs={ba['leg']} ash={ba['ash']} "
+          f"coals={ba['coal']} other={ba['other']} foot_worst={ba['foot_worst']:.5f} "
+          f"leg_bite={ba['leg_bite']:.5f} seat=({ba['seat_min']:.5f},{ba['seat_max']:.5f}) "
+          f"freeboard={ba['freeboard']:.5f} coal_hits={ba['coal_hits']} "
+          f"coal_spread={ba['coal_spread']:.3f}")
+    print(f"measured stability mass={ba['mass']:.2f}kg com=({ba['com'].x:.5f},"
+          f"{ba['com'].y:.5f},{ba['com'].z:.5f}) d_edge={ba['d_edge']:.5f} "
+          f"tip={math.degrees(ba['tip']):.2f}deg")
+
+    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, label in ((IRON_IDX, IRON_FACES_MIN, "iron"),
+                              (COAL_IDX, COAL_FACES_MIN, "coal"),
+                              (ASH_IDX, ASH_FACES_MIN, "ash")):
+        if idx_counts.get(idx, 0) < floor:
+            return (fail(f"{label} 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 ba["leg"] != N_LEGS or ba["foot_worst"] > FOOT_Z_MAX:
+        return (fail(f"feet: {ba['leg']} of {N_LEGS} legs, worst foot z={ba['foot_worst']:.5f} "
+                     f"> {FOOT_Z_MAX} (--float-foot is the designed fail)", 16),) + nothing
+    if ba["leg_bite"] < LEG_BITE_MIN:
+        return (fail(f"legs bite the bowl {ba['leg_bite']:.5f} < {LEG_BITE_MIN} "
+                     "(--short-legs is the designed fail)", 17),) + nothing
+    if ba["seat_min"] < COAL_SEAT_MIN or ba["seat_max"] > COAL_SEAT_MAX:
+        return (fail(f"coal seat ({ba['seat_min']:.5f}, {ba['seat_max']:.5f}) outside "
+                     f"[{COAL_SEAT_MIN}, {COAL_SEAT_MAX}] (--float-coals is the designed fail)", 18),) + nothing
+    if ba["freeboard"] < FREEBOARD_MIN:
+        return (fail(f"freeboard {ba['freeboard']:.5f} < {FREEBOARD_MIN} "
+                     "(--overfill is the designed fail)", 18),) + nothing
+    if ba["tip"] < TIP_MIN:
+        return (fail(f"tip angle {math.degrees(ba['tip']):.2f} deg < "
+                     f"{math.degrees(TIP_MIN):.1f} (--tuck-legs is the designed fail)", 19),) + nothing
+    if ba["coal_hits"]:
+        return (fail(f"{ba['coal_hits']} coal pairs interpenetrate, need 0 "
+                     "(--pile-coals is the designed fail)", 20),) + nothing
+    if ba["coal_spread"] < COAL_SPREAD_MIN:
+        return (fail(f"coal size spread {ba['coal_spread']:.3f} < {COAL_SPREAD_MIN} "
+                     "(--uniform-coals is the designed fail)", 21),) + 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[IRON_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(8.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.6, -3.4, 3.8), 300.0, 3.5, (1.0, 0.95, 0.88), (46, 0, -38))
+    light("Fill", "AREA", (3.4, -2.6, 1.8), 40.0, 6.0, (0.74, 0.84, 1.0), (66, 0, 52))
+    light("Rim", "AREA", (-1.6, 2.8, 2.4), 200.0, 3.0, (0.62, 0.78, 1.0), (-60, 0, 200))
+    # The warm floor pool behind the piece: a spot aimed at a floor point.
+    # This camera is low and close; the house area wedge put the pool on
+    # the back wall, and an area lamp moved nearer lit the whole floor.
+    ld = bpy.data.lights.new("Wedge", "SPOT")
+    ld.energy, ld.color = 170.0, (1.0, 0.66, 0.34)
+    ld.spot_size, ld.spot_blend, ld.shadow_soft_size = math.radians(44.0), 1.0, 0.3
+    wedge = bpy.data.objects.new("Wedge", ld)
+    wedge.location = (0.45, 1.55, 1.70)
+    wedge.rotation_euler = (Vector((0.10, 0.30, 0.0)) - wedge.location).to_track_quat(
+        "-Z", "Y").to_euler()
+    scene.collection.objects.link(wedge)
+    # Render-only: the fire's own light, well above the coals so the ash
+    # is lit by it rather than burnt out.
+    light("Fire", "POINT", (0.0, 0.0, 0.78), 9.0, 0.25, (1.0, 0.45, 0.14))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (1.10, -1.95, 1.30)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.31)
+    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-foot", action="store_true")
+    p.add_argument("--short-legs", action="store_true")
+    p.add_argument("--float-coals", action="store_true")
+    p.add_argument("--overfill", action="store_true")
+    p.add_argument("--tuck-legs", action="store_true")
+    p.add_argument("--pile-coals", action="store_true")
+    p.add_argument("--uniform-coals", 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_foot=args.float_foot,
+        short_legs=args.short_legs,
+        float_coals=args.float_coals,
+        overfill=args.overfill,
+        tuck_legs=args.tuck_legs,
+        pile_coals=args.pile_coals,
+        uniform_coals=args.uniform_coals,
+    )
+    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("brazier 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)
+
+
+
+ + + An iron fire bowl with a rolled rim on three S-curved forged legs with curled feet, filled with ash and a heap of charcoal glowing orange in its cracks. + +
+
+ generated from showcase/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/docs/gallery/contact-sheets/brazier-contact-sheet.webp b/docs/gallery/contact-sheets/brazier-contact-sheet.webp new file mode 100644 index 00000000..31f1aedf Binary files /dev/null and b/docs/gallery/contact-sheets/brazier-contact-sheet.webp differ diff --git a/docs/gallery/index.html b/docs/gallery/index.html index b570981d..b4eac134 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -291,7 +291,7 @@

Examples and Showcase

autocomplete="off" spellcheck="false" aria-label="Search examples and showcase pieces" /> - 51 examples, 30 showcase pieces + 51 examples, 31 showcase pieces
@@ -1232,6 +1232,17 @@

grain-sacks

View showcase piece grain-sacks
+
+ +
+

brazier

+

A procedural iron brazier — spun bowl with a rolled rim on three forged legs, a bed of ash and a heap of broken glowing charcoal — carried through UVs, bake, LOD, compound collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 6960 tris, three materials with 1680 iron, 2880 coal and 448 ash faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.696×0.696×0.612 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, three feet grounded, every leg biting 2.6 mm into the bowl wall, 36 coals seated 6.4–15.7 mm into the ash with none interpenetrating, 54 mm of freeboard under the rim, and a 51.9 kg density-weighted mass centre 0.466 m up and 154 mm inside the support polygon — an 18.3° tip angle. --tuck-legs exits 19 at 10.2° while the feet still ground and the envelope is unchanged; --overfill exits 18 on the freeboard.

+ View showcase piece brazier +
+
@@ -1261,7 +1272,7 @@

grain-sacks

var tagsToggle = document.getElementById('tagsToggle'); var toTop = document.getElementById('toTop'); var total = cards.length; - var COUNT_LABEL = '51 examples, 30 showcase pieces'; + var COUNT_LABEL = '51 examples, 31 showcase pieces'; var LS_KEY = 'bdt-gallery-density'; var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; diff --git a/showcase/brazier/README.md b/showcase/brazier/README.md new file mode 100644 index 00000000..26638882 --- /dev/null +++ b/showcase/brazier/README.md @@ -0,0 +1,236 @@ +# brazier + +![A spun iron brazier bowl with a rolled rim on three S-curved forged legs with curled feet, holding a bed of ash and a heap of glowing charcoal](preview.webp) + +A fire brazier: a spun-iron bowl with a rolled rim, three forged legs +swept in one piece from the bowl wall to curled feet, a bed of ash in the +bowl and a heap of thirty-six broken, glowing charcoal lumps on it. +**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` | lathed bowl and ash, swept legs, cleaved coal lumps in one `bmesh` | +| `skills/procedural-materials-and-shaders` | rusted rough iron, pale ash, charcoal with emissive cracks masked by noise | +| `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 for the bowl, one per leg, merged into a compound | +| `snippets/lod_chain.py` | LOD naming and ratio pattern | +| `examples/mesh-hygiene-audit` | hygiene combinatorics (copied, not imported) | + +## The budget that matters + +A brazier holds burning coals, so the thing it must not do is tip when +someone knocks it. That is invisible to every envelope check: legs tucked +in under the bowl still reach the floor, still ground the piece, and +still fit inside the bowl's own bounding box. + +The piece recomputes stability from the finished mesh: + +- **Centre of mass.** Signed tetrahedra over every closed shell, each + triangle weighted by its material's density (iron 7850, coal 500, + ash 600 kg/m³). The bowl dominates: 51.9 kg in all, centred on the axis + at 0.466 m. +- **Support polygon.** The convex hull, in plan, of the leg vertices that + lie on the floor. +- **Tip angle.** `atan(d / z)`, where `d` is the distance from the mass + centre's plan position to the nearest support edge and `z` is its + height. This is the lean the brazier survives before it goes over. + Measured 18.30° against a 13° floor. + +`--tuck-legs` is the falsifier built for exactly this. It brings the feet +in from 0.29 m to 0.15 m of the axis. They still ground (zmin and the +three-feet gate pass), the rim still sets the envelope (the AABB is +unchanged), and every other budget passes. Only the tip angle sees it: +10.23°. + +## 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 4 bytes, which +is exporter metadata and not a budget). + +| Budget | Band | Measured | +| --- | --- | --- | +| Base triangles | 6600–8200 | 6960 | +| LOD1 ratio | 0.32–0.62 | 0.5000 | +| LOD2 ratio | 0.10–0.35 | 0.2198 | +| Material slots | exactly 3, distinct | 3 | +| Iron faces | ≥ 1400 | 1680 | +| Coal faces | ≥ 2800 | 2880 | +| Ash faces | ≥ 380 | 448 | +| UV bounds | inside 0..1 | (0.0006, 0.0006)–(0.9994, 0.9994) | +| UV AABB overlap | ≤ 1e-5 | 0.000000 | +| Outer AABB | 0.696 × 0.696 × 0.612 m ± 0.020 | 0.6960 × 0.6960 × 0.6118 | +| Collider triangles | ≤ 400 | 240 (four hulls) | +| Normal bake | `{'FINISHED'}` with image data | `{'FINISHED'}`, `has_data=True` | +| glTF export | file written, non-empty | ~450 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 feet | 3 legs, each zmin ≤ 1e-3 | 3 at 0.00000 | +| Legs bite the bowl | deepest leg vertex inside the bowl ≥ 1.5 mm | 2.58 mm | +| Coal seat | 36 coals, deepest vertex inside the ash 4–30 mm | 6.40–15.69 mm | +| Freeboard | rim above the highest coal or ash ≥ 30 mm | 53.95 mm | +| Tip angle | ≥ 13° | 18.30° | +| Coal interpenetration | 0 pairs | 0 | +| Coal size spread | largest footprint over smallest ≥ 1.35 | 5.64 | + +Real-world size: a 0.70 m bowl, rim 0.61 m off the floor — a courtyard or +hall brazier. + +## Construction + +- **Bowl.** A half-ellipsoid spun from sheet iron, with a rolled bead at + the lip. The inner wall is the outer one offset along its own normal by + `WALL` (8 mm), so the wall keeps its thickness where the bowl runs flat. +- **Legs.** A chamfered-square bar (no 90° edges), swept in one piece: + - The bar leaves the bowl along the wall's normal, starting `LEG_BITE` + inside the iron. + - It bends down and out on a Bézier whose last handle is level, so it + arrives flat. + - It runs level on the floor with a flat face down, and ends in a + curled toe. + - The section frame is fixed to each leg's own vertical plane. +- **Ash.** A lathed dome whose edge follows the inner wall `ASH_BITE` + outside it. The bed is held by the bowl, not resting beside it. +- **Coals.** Each coal is an icosphere scaled to a lump, tumbled a few + degrees and cleaved by three closed-form planes, so it reads as split + charcoal and not a pebble ("rock is broken"). They are flat-shaded. + Thirty-six are packed on a golden-angle spiral: each walks outward until + it clears every placed coal by the two lumps' own plan radii (read off + their vertices) plus `COAL_GAP`, and stays inside the inner wall. Each + is then sunk into the ash by a fraction that varies with its index. + +## Findings the budgets forced + +- **Coal count.** Packing "as many coals as fit" made the coal count, and + so the triangle budget, move with any falsifier that changed the space + or the spacing. `--overfill`, `--pile-coals` and `--uniform-coals` all + exited 4 or 5 instead of their targets. The heap is now a fixed + `N_COALS` = 36. +- **Coplanar faces.** One coplanar cross-shell pair: two buried underside + triangles on neighbouring lumps, 0.2° apart in normal and 0.04 mm apart + in plane. Stood level, similar lumps put near-parallel faces side by + side. Each lump now leans a closed-form few degrees, and the count is 0. +- **Stale face references.** A dict keyed by `BMFace` returned the wrong + face after `create_icosphere(subdivisions=2)` freed and reallocated face + memory. UV islands now live in a face int layer, and the provisional + strip UVs are written straight into the loop layer. + +## 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 | +| Named supports | yes | three feet (`--float-foot`) | +| A joint bites | yes | each leg's deepest vertex inside the bowl by signed distance (`--short-legs`) | +| A bent bar is one sweep | yes | each leg is one sweep from bowl to toe | +| Iron is not chrome | yes | near-black and rust, roughness 0.55–0.85, metallic 0.65 | +| A vessel has a base and a belly | yes | the bowl's wall is offset along its normal | +| A vessel holds its contents | yes | the ash bites the inner wall; freeboard is asserted (`--overfill`) | +| Carried parts bite their bearers | yes | coals seated in the ash, banded (`--float-coals`) | +| Rock is broken, not smooth | yes | cleaved, flat-shaded lumps | +| Scattered parts do not interpenetrate | yes | exit 20 (`--pile-coals`) | +| Scatter varies in size | yes | exit 21 (`--uniform-coals`) | +| Shading is part of the model | yes | bowl and ash smooth (spun, powdery); legs flat (forged bar); coals flat (broken) | +| One substance, one slot | yes | iron, coal, ash | +| Edge treatment: no right angles | yes, by construction | chamfered-square legs (45° steps), lathed bowl; not a separate budget | +| Sort bmesh operator inputs | n/a | no set-fed operator is run | +| 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 | +| Timber, masonry, rope, fixtures, roofs, rings, paint | no | the piece has none of these | + +## Falsifiers + +Each breaks one pipeline stage so a **named** budget fails. All ten 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 under the bowl | 15 | +| `--lift-z` | grounded zmin | lifts the whole mesh 50 mm | 16 | +| `--float-foot` | named feet | lifts one foot 12 mm; the other two still ground the AABB | 16 | +| `--short-legs` | leg-to-bowl bite | starts every leg 12 mm off the wall; −10.0 mm | 17 | +| `--float-coals` | coal seat | lifts every coal 30 mm off the ash; −13.9 to −23.3 mm | 18 | +| `--overfill` | freeboard | raises the ash and heap 45 mm, still below the rim; 8.3 mm | 18 | +| `--tuck-legs` | tip angle | brings the feet in to 0.15 m of the axis; 10.23° | 19 | +| `--pile-coals` | coal interpenetration | packs coals at 0.6 of their spacing; 40 pairs | 20 | +| `--uniform-coals` | coal size spread | one size for every coal; 1.198 | 21 | + +## Exit codes + +File-local and sequential. `9` is a valid check code. `1` is the FATAL +wrapper — a crash, never a named check. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Mesh did not build, or has no UV layer | +| 4 | Base triangle count outside band | +| 5 | Material slots, or a material's face floor | +| 6 | UVs outside 0..1 | +| 7 | UV AABB overlap above tolerance | +| 8 | Outer AABB off declared size | +| 9 | LOD1 or LOD2 ratio outside band (`--skip-decimate`) | +| 10 | Framing gate (`examples/gallery_framing.py`, render path only) | +| 11 | Collider triangles above ceiling | +| 12 | Normal bake failed or produced no image data | +| 13 | glTF export missing or empty | +| 14 | `--output` produced no file | +| 15 | Mesh hygiene (`--stray-vert`) | +| 16 | Grounded zmin, or a foot floating (`--lift-z`, `--float-foot`) | +| 17 | A leg not biting the bowl (`--short-legs`) | +| 18 | Coal seat or freeboard (`--float-coals`, `--overfill`) | +| 19 | Tip angle below floor (`--tuck-legs`) | +| 20 | Coals interpenetrate (`--pile-coals`) | +| 21 | Coal size spread below floor (`--uniform-coals`) | + +## Run it + +```bash +# Budget check, no render. ~2.2 s on 4.5, ~2.0 s on 5.1, ~2.4 s on 5.2. +blender --background --python brazier.py -- + +# Falsifier: the legs tuck in under the bowl. Must exit 19. +blender --background --python brazier.py -- --tuck-legs + +# Falsifier: the bowl is filled to the brim. Must exit 18. +blender --background --python brazier.py -- --overfill + +# Render the gallery still (EEVEE; --engine cycles on a GPU-less host). +blender --background --python brazier.py -- --output brazier.webp +``` + +Smoke runs the check-only path. It does not pass `--output` or any falsifier. + +The render adds two render-only lights of its own: a point light above the +coals for the fire, and a spot for the warm floor pool. The camera is low +and close, so the house area wedge landed on the back wall, and an area +lamp brought near lit the whole floor. + +## Cross-version measurements + +| Value | 4.5.11 | 5.1.2 | 5.2.1 | +| --- | --- | --- | --- | +| Base triangles | 6960 | 6960 | 6960 | +| LOD1 / LOD2 tris | 3480 / 1530 | same | same | +| Face counts (iron / coal / ash) | 1680 / 2880 / 448 | same | same | +| Outer AABB | 0.6960 × 0.6960 × 0.6118 | same | same | +| Collider tris | 240 | 240 | 240 | +| Mass / tip angle | 51.87 kg / 18.30° | same | same | +| glTF bytes | 450940 | 450940 | 450936 | +| Check wall-clock | ~2.2 s | ~2.0 s | ~2.4 s | diff --git a/showcase/brazier/brazier.py b/showcase/brazier/brazier.py new file mode 100644 index 00000000..d34d904c --- /dev/null +++ b/showcase/brazier/brazier.py @@ -0,0 +1,1371 @@ +"""Game-ready brazier — a showcase piece, not an example. + +Asserts budget conformance of a procedural iron brazier: a spun bowl +with a rolled rim on three forged legs, a bed of ash in the bowl and a +heap of broken, glowing coals on it. Carried through UVs, three +materials (iron, coal, ash), a high-to-low normal bake, an LOD chain, a +compound convex collider, and a Unity glTF export. + +The budget that matters here is the one a brazier can fail invisibly: it +holds burning coals, so it must not tip when it is knocked. The piece +recomputes the centre of mass from the closed shells, weighted by the +density of each material, and the support polygon from the feet that +touch the floor, and asserts the angle it can lean before it tips. +Legs tucked in under the bowl still ground the piece and still fit its +bounding box; only the tip angle knows the difference. + +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-foot`` the named feet, +``--short-legs`` the leg-to-bowl bite, ``--float-coals`` the coal seat, +``--overfill`` the freeboard, ``--tuck-legs`` the tip angle, +``--pile-coals`` coal interpenetration, ``--uniform-coals`` coal size +variation. + +No randomness: every coal's size, shape and cleave 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 brazier.py -- + blender --background --python brazier.py -- --tuck-legs + blender --background --python brazier.py -- --output brazier.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 bowl: a half-ellipsoid spun from sheet iron, rim at RIM_Z, with a +# rolled bead at the lip. The inner wall is the outer wall offset along +# its own normal by WALL. +RIM_R = 0.340 +RIM_Z = 0.600 +DEPTH = 0.200 +WALL = 0.008 +BEAD_R = 0.010 +BOWL_SEG = 32 +BOWL_STEPS = 14 + +# Three forged legs: a chamfered-square bar swept in one piece from inside +# the bowl wall, out and down to a flat foot, ending in a small curl. +N_LEGS = 3 +LEG_A = 0.011 +LEG_CHAMFER = 0.30 +ATTACH_PHI = math.radians(40.0) +LEG_BITE = 0.003 +LEG_CONE = 0.25 +FOOT_R = 0.290 +TUCK_FOOT_R = 0.150 +SHORT_LEG_GAP = 0.012 +FLOAT_FOOT = 0.012 + +# Ash: a shallow dome whose rim bites into the bowl's inner wall. +ASH_Z = 0.490 +ASH_DOME = 0.035 +ASH_BITE = 0.003 +ASH_SEG = 32 +OVERFILL = 0.045 + +# Coals: broken lumps (a cleaved icosphere, flat-shaded) sunk into the +# ash. Sizes vary by a closed-form draw on the index. +COAL_BASE = 0.026 +COAL_VAR = 0.40 +COAL_SINK = 0.30 +COAL_GAP = 0.002 +COAL_TRIES = 90 +# A heap is a fixed number of coals: packing as many as fit made the +# count, and so the triangle budget, move with every falsifier that +# changes the space or the spacing. +N_COALS = 36 +PILE_SCALE = 0.60 +FLOAT_COALS = 0.030 + +DENSITY = {0: 7850.0, 1: 500.0, 2: 600.0} + +BBOX_TOL = 0.020 +OUTER_SIZE = (0.696, 0.696, 0.612) + +BASE_TRIS_MIN = 6600 +BASE_TRIS_MAX = 8200 +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 = 3 +IRON_FACES_MIN = 1400 +COAL_FACES_MIN = 2800 +ASH_FACES_MIN = 380 +UV_EPS = 1e-4 +UV_OVERLAP_MAX = 1e-5 +COLLIDER_TRIS_MAX = 400 +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 +FOOT_Z_MAX = 1e-3 +LEG_BITE_MIN = 0.0015 +COAL_SEAT_MIN = 0.004 +COAL_SEAT_MAX = 0.030 +FREEBOARD_MIN = 0.030 +TIP_MIN = math.radians(13.0) +COAL_SPREAD_MIN = 1.35 + +IRON_IDX = 0 +COAL_IDX = 1 +ASH_IDX = 2 + + +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() + + +# --- profiles --------------------------------------------------------------- + + +def outer_point(phi): + """Outer bowl wall at polar angle phi (0 at the bottom pole, pi/2 at the rim).""" + return Vector((RIM_R * math.sin(phi), RIM_Z - DEPTH * math.cos(phi))) + + +def outer_normal(phi): + p = outer_point(phi) + n = Vector((p.x / RIM_R ** 2, (p.y - RIM_Z) / DEPTH ** 2)) + return n.normalized() + + +def bowl_profile(): + """(r, z) loop from the outer pole, over the rolled bead, to the inner pole. + + The inner wall is the outer one offset along its own normal, so the + wall is WALL thick everywhere; a sideways offset thins it to nothing + where the bowl runs flat. + """ + phi_top = math.acos((BEAD_R + 0.002) / DEPTH) + phis = [phi_top * k / BOWL_STEPS for k in range(BOWL_STEPS + 1)] + outer = [outer_point(p) for p in phis] + inner = [outer_point(p) - outer_normal(p) * WALL for p in phis] + centre = Vector((RIM_R - 0.002, RIM_Z + 0.002)) + bead = [] + lo = math.atan2(outer[-1].y - centre.y, outer[-1].x - centre.x) + hi = math.atan2(inner[-1].y - centre.y, inner[-1].x - centre.x) + 2.0 * math.pi + for k in range(1, 8): + a = lo + (hi - lo) * k / 8 + bead.append(centre + Vector((math.cos(a), math.sin(a))) * BEAD_R) + loop = [Vector((0.0, outer[0].y))] + outer[1:] + bead + list(reversed(inner[1:])) + loop.append(Vector((0.0, inner[0].y))) + return loop, inner + + +def inner_radius(inner, z): + """Inner wall radius at height z, linear between the offset samples.""" + pts = sorted(inner, key=lambda p: p.y) + if z <= pts[0].y: + return 0.0 + for a, b in zip(pts, pts[1:]): + if a.y <= z <= b.y: + f = (z - a.y) / (b.y - a.y) if b.y > a.y else 0.0 + return a.x + (b.x - a.x) * f + return pts[-1].x + + +# --- construction ----------------------------------------------------------- + + +def stamp(ctx, face, island, uvmap): + """Tag ``face`` with its UV island and write its provisional strip UVs. + + Kept in bmesh layers, not a dict keyed by BMFace: an operator that + frees and reallocates faces (create_icosphere with subdivisions=2) + hands a later face a dead face's identity, and the dict then answers + for the wrong face. + """ + face[ctx["isl"]] = island + for loop in face.loops: + loop[ctx["uv"]].uv = uvmap[loop.vert] + + +def new_island(ctx): + ctx["next"] += 1 + return ctx["next"] + + +def lathe(bm, profile, n, mat_idx, ctx, smooth=True): + """Revolve an (r, z) profile about Z; r == 0 at the ends makes a pole. + + One strip island (profile length by angle); pole fans put their pole + half a column over so no two faces overlap in UV. + """ + 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((0.0, 0.0, p.y))) + continue + rings.append([bm.verts.new((p.x * math.cos(2 * math.pi * k / n), + p.x * math.sin(2 * math.pi * k / n), p.y)) + for k in range(n)]) + faces = [] + 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.smooth = smooth + stamp(ctx, f, island, uv) + faces.append(f) + return faces + + +def leg_section(): + """A chamfered square bar, flat side down: (side, up) offsets.""" + a, c = LEG_A, LEG_A * LEG_CHAMFER + pts = [(a, -(a - c)), (a, a - c), (a - c, a), (-(a - c), a), + (-a, a - c), (-a, -(a - c)), (-(a - c), -a), (a - c, -a)] + return pts + + +def bezier(p0, p1, p2, p3, n): + out = [] + for k in range(n + 1): + t = k / n + u = 1.0 - t + out.append(p0 * u ** 3 + p1 * 3 * u * u * t + p2 * 3 * u * t * t + p3 * t ** 3) + return out + + +def leg_path(foot_r, short=False, float_foot=False): + """(rho, z) centreline, from inside the bowl wall to the curled foot. + + One sweep: the bar leaves the bowl along the wall's normal, bends down + and out, runs level on the floor with a flat face down, and curls up. + """ + base = outer_point(ATTACH_PHI) + n = outer_normal(ATTACH_PHI) + start = base + n * (SHORT_LEG_GAP if short else -LEG_BITE) + p1 = base + n * 0.030 + lift = FLOAT_FOOT if float_foot else 0.0 + foot0 = Vector((foot_r - 0.015, LEG_A + lift)) + foot1 = Vector((foot_r + 0.015, LEG_A + lift)) + pts = [start] + # The handle into the foot is level, so the bar arrives flat on the floor. + pts += bezier(p1, p1 + n * 0.10, foot0 - Vector((0.10, 0.0)), foot0, 12) + pts += [foot0 + (foot1 - foot0) * (k / 3) for k in range(1, 4)] + curl = bezier(foot1, foot1 + Vector((0.012, 0.0)), foot1 + Vector((0.024, 0.006)), + foot1 + Vector((0.026, 0.020)), 4) + pts += curl[1:] + return pts + + +def sweep_bar(bm, pts, section, side, mat_idx, ctx, cone=LEG_CONE): + """Sweep ``section`` along ``pts`` (3D), frame fixed to the path's plane. + + Ends close in a blunt cone so there is no n-gon cap. Returns vertices. + """ + island = new_island(ctx) + m = len(pts) + tans = [] + for i in range(m): + d = pts[min(i + 1, m - 1)] - pts[max(i - 1, 0)] + tans.append(d.normalized()) + n = len(section) + rings = [] + for p, t in zip(pts, tans): + s = side - t * side.dot(t) + s.normalize() + up = t.cross(s) + rings.append([bm.verts.new(p + s * x + up * y) for x, y in section]) + arc = [0.0] + for i in range(1, m): + arc.append(arc[-1] + (pts[i] - pts[i - 1]).length) + for k in range(m - 1): + a, b = rings[k], rings[k + 1] + for i in range(n): + j = (i + 1) % n + f = bm.faces.new((a[i], a[j], b[j], b[i])) + f.material_index = mat_idx + f.smooth = False + stamp(ctx, f, island, {a[i]: (arc[k], i / n), a[j]: (arc[k], (i + 1) / n), + b[j]: (arc[k + 1], (i + 1) / n), b[i]: (arc[k + 1], i / n)}) + reach = cone * LEG_A + for ring, p, t, sign, s_end in ((rings[0], pts[0], tans[0], -1.0, arc[0]), + (rings[-1], pts[-1], tans[-1], 1.0, arc[-1])): + pole = bm.verts.new(p + t * (sign * reach)) + for i in range(n): + j = (i + 1) % n + f = bm.faces.new((pole, ring[i], ring[j])) + f.material_index = mat_idx + f.smooth = False + stamp(ctx, f, island, {pole: (s_end + sign * reach, (i + 0.5) / n), + ring[i]: (s_end, i / n), ring[j]: (s_end, (i + 1) / n)}) + return [v for r in rings for v in r] + + +def coal_size(i, uniform=False): + """Closed-form size draw per coal: golden-ratio spacing, no RNG.""" + if uniform: + # One draw for every coal, small enough that the full heap still + # fits: the coals do not set the envelope, so nothing else moves. + return COAL_BASE * (1.0 - COAL_VAR * 0.5) + f = (i * 0.6180339887 + 0.37) % 1.0 + return COAL_BASE * (1.0 + COAL_VAR * (f - 0.5) * 2.0) + + +def coal_axes(i, size): + """Ellipsoid half-axes: a charcoal lump is longer than it is tall.""" + e = (i * 0.4142135 + 0.2) % 1.0 + return (size * (1.15 + 0.25 * e), size * (0.95 - 0.15 * e), size * 0.72) + + +_ICO = {} + + +def ico_unit(): + """Unit icosphere (subdivisions 2): vertex positions and face index lists.""" + if not _ICO: + tmp = bmesh.new() + try: + bmesh.ops.create_icosphere(tmp, subdivisions=2, radius=1.0) + tmp.verts.index_update() + _ICO["verts"] = [v.co.copy() for v in tmp.verts] + _ICO["faces"] = [[v.index for v in f.verts] for f in tmp.faces] + finally: + tmp.free() + return _ICO["verts"], _ICO["faces"] + + +def coal_shape(axes, i): + """A broken lump, centred on the origin: a scaled icosphere cleaved by three planes. + + Every vertex beyond a plane is projected onto it, so the lump reads as + split charcoal rather than a pebble. + """ + unit, _faces = ico_unit() + yaw = i * 2.39996 + c, s = math.cos(yaw), math.sin(yaw) + pts = [] + for u in unit: + x, y, z = u.x * axes[0], u.y * axes[1], u.z * axes[2] + pts.append(Vector((c * x - s * y, s * x + c * y, z))) + # Tumbled, not set upright: each lump leans by a closed-form few + # degrees. Stood level, two similar lumps once carried a pair of + # near-parallel buried faces in one plane (a coplanar pair). + tilt = Matrix.Rotation(0.18 * (((i * 0.6180340) % 1.0) - 0.5), 3, "X") @ Matrix.Rotation(0.18 * (((i * 0.4142136) % 1.0) - 0.5), 3, "Y") + pts = [tilt @ p for p in pts] + for k in range(3): + a = yaw + k * 2.1 + 0.4 + el = 0.35 + 0.25 * ((i + k) % 3) + nrm = Vector((math.cos(a) * math.cos(el), math.sin(a) * math.cos(el), math.sin(el))) + d = 0.70 * min(axes) + 0.18 * max(axes) * (((i * 7 + k * 3) % 5) / 5.0) + for p in pts: + h = p.dot(nrm) + if h > d: + p -= nrm * (h - d) + return pts + + +def add_coal(bm, centre, shape, mat_idx): + """Emit a lump built by ``coal_shape`` at ``centre``. Flat-shaded.""" + _unit, faces = ico_unit() + vs = [bm.verts.new(p + centre) for p in shape] + for idx in faces: + f = bm.faces.new([vs[k] for k in idx]) + f.material_index = mat_idx + f.smooth = False + return vs + + +def coal_layout(inner, ash_z, r_edge, pile=False, uniform=False): + """Greedy sunflower packing: each coal at the smallest radius that clears the rest. + + Coal ``i`` tries the golden angle times ``i`` and walks outward until + its centre is at least the sum of its own and each placed coal's plan + radius bound (plus COAL_GAP) from every one of them, and its bound + stays inside the inner wall at the height of its base. Bounds come + from the same constants that size each coal, so a bigger coal takes + more room. ``pile`` shrinks the spacing so neighbours interpenetrate. + """ + shapes = {} + + def shape(i): + if i not in shapes: + shapes[i] = coal_shape(coal_axes(i, coal_size(i, uniform)), i) + return shapes[i] + + def bound(i): + # The lump's own plan radius after cleaving, read off its vertices. + return max(math.hypot(p.x, p.y) for p in shape(i)) + + def ash_at(rho): + return ash_z + ASH_DOME * (1.0 - min(1.0, (rho / r_edge) ** 2)) + + scale = PILE_SCALE if pile else 1.0 + placed = [] + for i in range(COAL_TRIES): + if len(placed) == N_COALS: + break + b = bound(i) + theta = i * 2.39996323 + rho = 0.0 + while True: + x, y = rho * math.cos(theta), rho * math.sin(theta) + limit = inner_radius(inner, ash_at(rho) - 0.02) - COAL_GAP + if rho + b > limit: + break + if all(math.hypot(x - px, y - py) >= (b + pb) * scale + COAL_GAP + for px, py, pb, _i in placed): + placed.append((x, y, b, i)) + break + rho += 0.002 + coals = [] + for x, y, _b, i in placed: + size = coal_size(i, uniform) + axes = coal_axes(i, size) + # Each lump settles a little differently. Sunk to one fraction, two + # buried undersides once landed in one plane (a coplanar pair). + sink = COAL_SINK + 0.06 * (((i * 0.3819660) % 1.0) - 0.5) + z = ash_at(math.hypot(x, y)) + axes[2] * (1.0 - 2.0 * sink) + coals.append((Vector((x, y, z)), shape(i), i)) + return coals + + +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 build_brazier_mesh( + name, + stray_vert=False, + float_foot=False, + short_legs=False, + float_coals=False, + overfill=False, + tuck_legs=False, + pile_coals=False, + uniform_coals=False, +): + profile, inner = bowl_profile() + ash_z = ASH_Z + (OVERFILL if overfill else 0.0) + r_edge = inner_radius(inner, ash_z) + ASH_BITE + bm = bmesh.new() + try: + ctx = {"uv": bm.loops.layers.uv.new("UVMap"), + "isl": bm.faces.layers.int.new("UVIsland"), "next": 0} + lathe(bm, profile, BOWL_SEG, IRON_IDX, ctx) + foot_r = TUCK_FOOT_R if tuck_legs else FOOT_R + for k in range(N_LEGS): + az = 2.0 * math.pi * k / N_LEGS + math.pi / 2.0 + radial = Vector((math.cos(az), math.sin(az), 0.0)) + side = Vector((-math.sin(az), math.cos(az), 0.0)) + path = leg_path(foot_r, short=short_legs, float_foot=(float_foot and k == 0)) + pts = [radial * p.x + Vector((0.0, 0.0, p.y)) for p in path] + sweep_bar(bm, pts, leg_section(), side, IRON_IDX, ctx) + # Ash: the dome, then down the inner wall (biting it) to the bottom. + ash = [Vector((0.0, inner[0].y - ASH_BITE))] + lows = sorted(inner, key=lambda p: p.y) + for p in lows: + if inner[0].y + 0.004 < p.y < ash_z - 0.004: + ash.append(Vector((inner_radius(inner, p.y) + ASH_BITE, p.y))) + ash.append(Vector((r_edge, ash_z))) + for k in range(1, 6): + rho = r_edge * (1.0 - k / 6.0) + ash.append(Vector((rho, ash_z + ASH_DOME * (1.0 - (rho / r_edge) ** 2)))) + ash.append(Vector((0.0, ash_z + ASH_DOME))) + lathe(bm, ash, ASH_SEG, ASH_IDX, ctx) + for centre, shape, _i in coal_layout(inner, ash_z, r_edge, pile=pile_coals, + uniform=uniform_coals): + if float_coals: + centre = centre + Vector((0.0, 0.0, FLOAT_COALS)) + add_coal(bm, centre, shape, COAL_IDX) + if stray_vert: + bm.verts.new((0.0, 0.0, 0.25)) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + 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() + obj = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(obj) + return obj + + +# --- surface ---------------------------------------------------------------- + + +def _sock(sockets, identifier): + return next(sk for sk in sockets if sk.identifier == identifier) + + +def iron_material(name): + """Forged iron: near-black, rough, rusted in patches. Not chrome.""" + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + tc = nt.nodes.new("ShaderNodeTexCoord") + noise = nt.nodes.new("ShaderNodeTexNoise") + noise.inputs["Scale"].default_value = 14.0 + 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.45 + ramp.color_ramp.elements[0].color = (0.035, 0.033, 0.031, 1.0) + ramp.color_ramp.elements[1].position = 0.78 + ramp.color_ramp.elements[1].color = (0.20, 0.085, 0.035, 1.0) + nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) + nt.links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"]) + bsdf.inputs["Metallic"].default_value = 0.65 + rough = nt.nodes.new("ShaderNodeMapRange") + rough.inputs["To Min"].default_value = 0.55 + rough.inputs["To Max"].default_value = 0.85 + nt.links.new(noise.outputs["Fac"], rough.inputs["Value"]) + nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"]) + return mat + + +def coal_material(name): + """Charcoal: black and matte, glowing orange in the cracks.""" + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = (0.022, 0.020, 0.019, 1.0) + bsdf.inputs["Roughness"].default_value = 0.9 + tc = nt.nodes.new("ShaderNodeTexCoord") + vor = nt.nodes.new("ShaderNodeTexVoronoi") + vor.feature = "DISTANCE_TO_EDGE" + vor.inputs["Scale"].default_value = 60.0 + nt.links.new(tc.outputs["Object"], vor.inputs["Vector"]) + crack = nt.nodes.new("ShaderNodeMapRange") + crack.inputs["From Min"].default_value = 0.0 + crack.inputs["From Max"].default_value = 0.04 + crack.inputs["To Min"].default_value = 1.0 + crack.inputs["To Max"].default_value = 0.0 + nt.links.new(vor.outputs["Distance"], crack.inputs["Value"]) + # Hotter lower down: the heap burns from the ash up. + sep = nt.nodes.new("ShaderNodeSeparateXYZ") + nt.links.new(tc.outputs["Object"], sep.inputs["Vector"]) + heat = nt.nodes.new("ShaderNodeMapRange") + heat.inputs["From Min"].default_value = 0.60 + heat.inputs["From Max"].default_value = 0.47 + heat.inputs["To Min"].default_value = 0.35 + heat.inputs["To Max"].default_value = 1.0 + nt.links.new(sep.outputs["Z"], heat.inputs["Value"]) + # Not every crack burns: a noise mask breaks the network up, so a lump + # glows in patches instead of wearing an even honeycomb. + mask_n = nt.nodes.new("ShaderNodeTexNoise") + mask_n.inputs["Scale"].default_value = 22.0 + mask_n.inputs["Detail"].default_value = 3.0 + nt.links.new(tc.outputs["Object"], mask_n.inputs["Vector"]) + mask = nt.nodes.new("ShaderNodeMapRange") + mask.inputs["From Min"].default_value = 0.42 + mask.inputs["From Max"].default_value = 0.62 + nt.links.new(mask_n.outputs["Fac"], mask.inputs["Value"]) + burn = nt.nodes.new("ShaderNodeMath") + burn.operation = "MULTIPLY" + nt.links.new(crack.outputs["Result"], burn.inputs[0]) + nt.links.new(mask.outputs["Result"], burn.inputs[1]) + glow = nt.nodes.new("ShaderNodeMath") + glow.operation = "MULTIPLY" + nt.links.new(burn.outputs["Value"], glow.inputs[0]) + nt.links.new(heat.outputs["Result"], glow.inputs[1]) + strength = nt.nodes.new("ShaderNodeMath") + strength.operation = "MULTIPLY" + strength.inputs[1].default_value = 7.0 + nt.links.new(glow.outputs["Value"], strength.inputs[0]) + bsdf.inputs["Emission Color"].default_value = (1.0, 0.32, 0.06, 1.0) + nt.links.new(strength.outputs["Value"], bsdf.inputs["Emission Strength"]) + return mat + + +def ash_material(name): + """Ash: pale, matte, with darker cinders mottled through it.""" + 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 = 40.0 + noise.inputs["Detail"].default_value = 6.0 + nt.links.new(tc.outputs["Object"], noise.inputs["Vector"]) + ramp = nt.nodes.new("ShaderNodeValToRGB") + ramp.color_ramp.elements[0].position = 0.35 + ramp.color_ramp.elements[0].color = (0.06, 0.056, 0.053, 1.0) + ramp.color_ramp.elements[1].position = 0.70 + ramp.color_ramp.elements[1].color = (0.26, 0.245, 0.225, 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 = 0.97 + return mat + + +def brazier_materials(): + return iron_material("BrazierIron"), coal_material("BrazierCoal"), ash_material("BrazierAsh") + + +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 shell_tree(me, group): + member = set(group) + remap = {v: k for k, v in enumerate(group)} + 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 group], polys) + + +def inside_depth(tree, pts): + """Deepest point of ``pts`` inside ``tree``'s surface; negative is a gap.""" + best = -99.0 + for p in pts: + loc, nrm, _i, dist = tree.find_nearest(p) + if loc is None: + continue + d = dist if (p - loc).dot(nrm) < 0.0 else -dist + best = max(best, d) + return best + + +def classify(me): + mats = {} + for p in me.polygons: + for i in p.vertices: + mats.setdefault(i, p.material_index) + out = {"bowl": [], "leg": [], "ash": [], "coal": [], "other": []} + for g in shells(me): + pts = [me.vertices[i].co.copy() for i in g] + rec = {"g": g, "pts": pts, "c": sum(pts, Vector()) / len(pts)} + m = mats.get(g[0], -1) + if m == IRON_IDX: + out["bowl" if len(g) > 400 else "leg"].append(rec) + elif m == ASH_IDX: + out["ash"].append(rec) + elif m == COAL_IDX: + out["coal"].append(rec) + else: + out["other"].append(rec) + return out + + +def mass_centre(me): + """Centre of mass of the closed shells, each triangle weighted by density. + + Signed tetrahedra from the origin: every shell is closed and wound + outward, so interior volumes cancel and each material contributes + its volume times its density. + """ + me.calc_loop_triangles() + m_tot = 0.0 + acc = Vector((0.0, 0.0, 0.0)) + for tri in me.loop_triangles: + a, b, c = (me.vertices[i].co for i in tri.vertices) + v = a.dot(b.cross(c)) / 6.0 + rho = DENSITY.get(me.polygons[tri.polygon_index].material_index, 0.0) + m_tot += v * rho + acc += (a + b + c) * (v * rho / 4.0) + return acc / m_tot if m_tot else Vector(), m_tot + + +def convex_hull_2d(pts): + pts = sorted(set((round(p[0], 9), round(p[1], 9)) for p in pts)) + if len(pts) < 3: + return pts + + def cross(o, a, b): + return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) + + lower, upper = [], [] + for p in pts: + while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0: + lower.pop() + lower.append(p) + for p in reversed(pts): + while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0: + upper.pop() + upper.append(p) + return lower[:-1] + upper[:-1] + + +def brazier_audit(me): + parts = classify(me) + bowl = parts["bowl"][0] if parts["bowl"] else None + ash = parts["ash"][0] if parts["ash"] else None + out = {k: len(v) for k, v in parts.items()} + out["foot_worst"] = max((min(p.z for p in r["pts"]) for r in parts["leg"]), default=99.0) + + # Legs bite the bowl wall: deepest leg vertex inside the bowl shell. + bowl_tree = shell_tree(me, bowl["g"]) if bowl else None + bites = [inside_depth(bowl_tree, r["pts"]) for r in parts["leg"]] if bowl_tree else [] + out["leg_bite"] = min(bites, default=-99.0) + + # Coals sit in the ash: deepest coal vertex inside the ash shell. + ash_tree = shell_tree(me, ash["g"]) if ash else None + seats = [inside_depth(ash_tree, r["pts"]) for r in parts["coal"]] if ash_tree else [] + out["seat_min"] = min(seats, default=-99.0) + out["seat_max"] = max(seats, default=99.0) + + # Freeboard: the rim above the highest coal or ash. + rim = max(p.z for p in bowl["pts"]) if bowl else 0.0 + top = max((p.z for r in parts["coal"] + parts["ash"] for p in r["pts"]), default=99.0) + out["freeboard"] = rim - top + + # Coals do not interpenetrate one another. + trees = [shell_tree(me, r["g"]) for r in parts["coal"]] + hits = 0 + for i in range(len(trees)): + for j in range(i + 1, len(trees)): + if trees[i].overlap(trees[j]): + hits += 1 + out["coal_hits"] = hits + + # Coal sizes vary: largest footprint over smallest. + foot = [] + for r in parts["coal"]: + xs = [p.x for p in r["pts"]] + ys = [p.y for p in r["pts"]] + foot.append((max(xs) - min(xs)) * (max(ys) - min(ys))) + out["coal_spread"] = (max(foot) / min(foot)) if foot else 0.0 + + # Tip angle: distance from the mass centre to the nearest edge of the + # support polygon, over its height. + com, mass = mass_centre(me) + contacts = [(p.x, p.y) for r in parts["leg"] for p in r["pts"] if p.z <= 1e-6] + hull = convex_hull_2d(contacts) + d_edge = -99.0 + if len(hull) >= 3: + d_edge = 99.0 + for a, b in zip(hull, hull[1:] + hull[:1]): + ex, ey = b[0] - a[0], b[1] - a[1] + ln = math.hypot(ex, ey) + d = (ex * (com.y - a[1]) - ey * (com.x - a[0])) / ln + d_edge = min(d_edge, d) + out["com"] = com + out["mass"] = mass + out["d_edge"] = d_edge + out["tip"] = math.atan2(d_edge, com.z) if com.z > 0 else -1.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): + """Compound collider: one convex hull for the bowl and its contents, one per leg. + + A single hull over the whole brazier fills the space between the legs, + which a character should be able to kick a foot through. + """ + me = obj.data + parts = classify(me) + # Sampled in build order: the bowl is rings of BOWL_SEG, a leg rings + # of eight. The ash and coals sit below the rim, inside the bowl's hull. + bowl = sorted(parts["bowl"][0]["g"]) + groups = [[me.vertices[v].co.copy() for k, v in enumerate(bowl) + if (k // BOWL_SEG) % 2 == 0 and (k % BOWL_SEG) % 4 == 0] + + [me.vertices[bowl[-1]].co.copy()]] + for r in parts["leg"]: + leg = sorted(r["g"]) + groups.append([me.vertices[v].co.copy() for k, v in enumerate(leg) + if (k // 8) % 4 == 0 and k % 2 == 0] + [me.vertices[leg[-1]].co.copy()]) + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + for pts in groups: + tmp = bmesh.new() + try: + vs = [tmp.verts.new(p) for p in pts] + bmesh.ops.convex_hull(tmp, input=vs) + remap = {} + for f in tmp.faces: + for v in f.verts: + if v not in remap: + remap[v] = bm.verts.new(v.co) + bm.faces.new([remap[v] for v in f.verts]) + finally: + tmp.free() + 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("BrazierNrm", 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 = IRON_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_brazier_mesh("BrazierLow", **flags) + hi_flags = {k: v for k, v in flags.items() if k != "stray_vert"} + high = build_brazier_mesh("BrazierHigh", **hi_flags) + mats = brazier_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("brazier 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[IRON_IDX], BAKE_RES) + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "BrazierLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "BrazierLOD2", 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, "BrazierCollider") + col_tris = triangle_count(collider.data) + + export_path = os.path.join(tempfile.gettempdir(), f"bdt_brazier_{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) + ba = brazier_audit(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 bowl={ba['bowl']} legs={ba['leg']} ash={ba['ash']} " + f"coals={ba['coal']} other={ba['other']} foot_worst={ba['foot_worst']:.5f} " + f"leg_bite={ba['leg_bite']:.5f} seat=({ba['seat_min']:.5f},{ba['seat_max']:.5f}) " + f"freeboard={ba['freeboard']:.5f} coal_hits={ba['coal_hits']} " + f"coal_spread={ba['coal_spread']:.3f}") + print(f"measured stability mass={ba['mass']:.2f}kg com=({ba['com'].x:.5f}," + f"{ba['com'].y:.5f},{ba['com'].z:.5f}) d_edge={ba['d_edge']:.5f} " + f"tip={math.degrees(ba['tip']):.2f}deg") + + 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, label in ((IRON_IDX, IRON_FACES_MIN, "iron"), + (COAL_IDX, COAL_FACES_MIN, "coal"), + (ASH_IDX, ASH_FACES_MIN, "ash")): + if idx_counts.get(idx, 0) < floor: + return (fail(f"{label} 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 ba["leg"] != N_LEGS or ba["foot_worst"] > FOOT_Z_MAX: + return (fail(f"feet: {ba['leg']} of {N_LEGS} legs, worst foot z={ba['foot_worst']:.5f} " + f"> {FOOT_Z_MAX} (--float-foot is the designed fail)", 16),) + nothing + if ba["leg_bite"] < LEG_BITE_MIN: + return (fail(f"legs bite the bowl {ba['leg_bite']:.5f} < {LEG_BITE_MIN} " + "(--short-legs is the designed fail)", 17),) + nothing + if ba["seat_min"] < COAL_SEAT_MIN or ba["seat_max"] > COAL_SEAT_MAX: + return (fail(f"coal seat ({ba['seat_min']:.5f}, {ba['seat_max']:.5f}) outside " + f"[{COAL_SEAT_MIN}, {COAL_SEAT_MAX}] (--float-coals is the designed fail)", 18),) + nothing + if ba["freeboard"] < FREEBOARD_MIN: + return (fail(f"freeboard {ba['freeboard']:.5f} < {FREEBOARD_MIN} " + "(--overfill is the designed fail)", 18),) + nothing + if ba["tip"] < TIP_MIN: + return (fail(f"tip angle {math.degrees(ba['tip']):.2f} deg < " + f"{math.degrees(TIP_MIN):.1f} (--tuck-legs is the designed fail)", 19),) + nothing + if ba["coal_hits"]: + return (fail(f"{ba['coal_hits']} coal pairs interpenetrate, need 0 " + "(--pile-coals is the designed fail)", 20),) + nothing + if ba["coal_spread"] < COAL_SPREAD_MIN: + return (fail(f"coal size spread {ba['coal_spread']:.3f} < {COAL_SPREAD_MIN} " + "(--uniform-coals is the designed fail)", 21),) + 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[IRON_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(8.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.6, -3.4, 3.8), 300.0, 3.5, (1.0, 0.95, 0.88), (46, 0, -38)) + light("Fill", "AREA", (3.4, -2.6, 1.8), 40.0, 6.0, (0.74, 0.84, 1.0), (66, 0, 52)) + light("Rim", "AREA", (-1.6, 2.8, 2.4), 200.0, 3.0, (0.62, 0.78, 1.0), (-60, 0, 200)) + # The warm floor pool behind the piece: a spot aimed at a floor point. + # This camera is low and close; the house area wedge put the pool on + # the back wall, and an area lamp moved nearer lit the whole floor. + ld = bpy.data.lights.new("Wedge", "SPOT") + ld.energy, ld.color = 170.0, (1.0, 0.66, 0.34) + ld.spot_size, ld.spot_blend, ld.shadow_soft_size = math.radians(44.0), 1.0, 0.3 + wedge = bpy.data.objects.new("Wedge", ld) + wedge.location = (0.45, 1.55, 1.70) + wedge.rotation_euler = (Vector((0.10, 0.30, 0.0)) - wedge.location).to_track_quat( + "-Z", "Y").to_euler() + scene.collection.objects.link(wedge) + # Render-only: the fire's own light, well above the coals so the ash + # is lit by it rather than burnt out. + light("Fire", "POINT", (0.0, 0.0, 0.78), 9.0, 0.25, (1.0, 0.45, 0.14)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (1.10, -1.95, 1.30) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.31) + 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-foot", action="store_true") + p.add_argument("--short-legs", action="store_true") + p.add_argument("--float-coals", action="store_true") + p.add_argument("--overfill", action="store_true") + p.add_argument("--tuck-legs", action="store_true") + p.add_argument("--pile-coals", action="store_true") + p.add_argument("--uniform-coals", 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_foot=args.float_foot, + short_legs=args.short_legs, + float_coals=args.float_coals, + overfill=args.overfill, + tuck_legs=args.tuck_legs, + pile_coals=args.pile_coals, + uniform_coals=args.uniform_coals, + ) + 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("brazier OK") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + traceback.print_exc() + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(1) diff --git a/showcase/brazier/preview.webp b/showcase/brazier/preview.webp new file mode 100644 index 00000000..59dac5ea Binary files /dev/null and b/showcase/brazier/preview.webp differ diff --git a/showcase/gallery.json b/showcase/gallery.json index a6e1449c..676ce283 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -400,6 +400,19 @@ "mesh", "export" ] + }, + { + "name": "brazier", + "dir": "showcase/brazier", + "teaches": "A procedural iron brazier — spun bowl with a rolled rim on three forged legs, a bed of ash and a heap of broken glowing charcoal — carried through UVs, bake, LOD, compound collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "alt": "An iron fire bowl with a rolled rim on three S-curved forged legs with curled feet, filled with ash and a heap of charcoal glowing orange in its cracks.", + "witnessesFix": "Recomputed: 6960 tris, three materials with 1680 iron, 2880 coal and 448 ash faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.696×0.696×0.612 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, three feet grounded, every leg biting 2.6 mm into the bowl wall, 36 coals seated 6.4–15.7 mm into the ash with none interpenetrating, 54 mm of freeboard under the rim, and a 51.9 kg density-weighted mass centre 0.466 m up and 154 mm inside the support polygon — an 18.3° tip angle. --tuck-legs exits 19 at 10.2° while the feet still ground and the envelope is unchanged; --overfill exits 18 on the freeboard.", + "hero": "docs/gallery/assets/brazier-hero.webp", + "preview": "showcase/brazier/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index 18791aef..7b6af26e 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -103,5 +103,6 @@ {"name": "crate-stack", "script": "showcase/crate-stack/crate_stack.py"}, {"name": "stone-archway", "script": "showcase/stone-archway/stone_archway.py"}, {"name": "rope-bridge", "script": "showcase/rope-bridge/rope_bridge.py"}, - {"name": "grain-sacks", "script": "showcase/grain-sacks/grain_sacks.py"} + {"name": "grain-sacks", "script": "showcase/grain-sacks/grain_sacks.py"}, + {"name": "brazier", "script": "showcase/brazier/brazier.py"} ]