From 2b94a6255a1fc243267e6086bd47cc1fc6c5cdd6 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:35:13 -0400 Subject: [PATCH] feat(showcase): add brazier A brazier was the next unclaimed showcase candidate. What an envelope check cannot see on it is whether it would tip when knocked: legs tucked in under the bowl still reach the floor and still fit its bounding box. The piece recomputes a density-weighted mass centre from the closed shells and the support polygon from the feet on the floor, and asserts the tip angle; --tuck-legs exits 19 at 10.2 degrees while every other budget passes. Also asserts the legs bite the bowl, the coals sit in the ash below the rim without interpenetrating and vary in size, each with a falsifier proven on 4.5.11, 5.1.2 and 5.2.1. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> --- .cursor-plugin/plugin.json | 3 +- CLAUDE.md | 2 +- README.md | 9 +- ROADMAP.md | 2 +- docs/gallery/asset-sheets/brazier.webp | Bin 0 -> 34722 bytes docs/gallery/assets/brazier-hero.webp | Bin 0 -> 25576 bytes docs/gallery/brazier/index.html | 1739 +++++++++++++++++ .../contact-sheets/brazier-contact-sheet.webp | Bin 0 -> 54150 bytes docs/gallery/index.html | 15 +- showcase/brazier/README.md | 236 +++ showcase/brazier/brazier.py | 1371 +++++++++++++ showcase/brazier/preview.webp | Bin 0 -> 23620 bytes showcase/gallery.json | 13 + tests/smoke/catalog.json | 3 +- 14 files changed, 3384 insertions(+), 9 deletions(-) create mode 100644 docs/gallery/asset-sheets/brazier.webp create mode 100644 docs/gallery/assets/brazier-hero.webp create mode 100644 docs/gallery/brazier/index.html create mode 100644 docs/gallery/contact-sheets/brazier-contact-sheet.webp create mode 100644 showcase/brazier/README.md create mode 100644 showcase/brazier/brazier.py create mode 100644 showcase/brazier/preview.webp 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 0000000000000000000000000000000000000000..bfa61b3f07773f3418692c35a416f003c38ee50a GIT binary patch literal 34722 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/assets/brazier-hero.webp b/docs/gallery/assets/brazier-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..70d44c4de76b8cb73053e782d82f2ff3bcdee400 GIT binary patch literal 25576 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 + + + + + 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 0000000000000000000000000000000000000000..31f1aedfe7f88fb81cd27ca8c9b580ec4e88fb62 GIT binary patch literal 54150 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 za(JaId2xNXopK83i~}G}hZ4vD0000000000000000000000000000000000000000 L0000000000EDfzb literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..59dac5ea22235395dd4ecde65cdfb08386d27f66 GIT binary patch literal 23620 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

4k;^kI$xk@085p85q0s%k&&{%g>zX(x08?d|Gm* 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 b=M0;#OJsKPSKb~81b1gfw#3Cx`~Uy|MSu^% literal 0 HcmV?d00001 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"} ]