Examples and Showcase
GitHub

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.

category Village

tags mesh export showcase

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:

--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#

Findings the budgets forced#

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 1371 lines · 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.