Examples and Showcase
GitHub

butter-churn

A procedural plunge butter churn — sixteen tapered oak staves, three iron hoops, a three-board bottom, a two-board lid and a maple cross dasher — 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: 5684 tris, three materials with 2240 oak, 160 maple and 576 iron faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.340×0.340×1.050 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, all sixteen staves on the floor, three bottom boards with 1.2 mm seams, the handle 14 mm into the slats, three hoops 2.5 mm into the staves and 4.9 mm proud, the lid 1.4–2.0 mm onto the stave tops, 5 mm of clearance round the handle, and a 514 mm plunge stroke for a 101 mm reach. --wide-dasher exits 20 at 140 mm while the plunger still clears the staves at rest; --tight-hole exits 18.

category Village

tags mesh export showcase

blender --background --python showcase/butter-churn/butter_churn.py --

A plunge butter churn: a tall body of sixteen oak staves that narrows toward the top, three forged iron hoops, a bottom of three boards set in a croze, a lid of two boards with a hole for the handle, and a maple cross dasher whose handle runs up through the lid. 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-bmeshlofted staves, banded hoops, clipped board polygons, lathed handle, all in one bmesh
skills/custom-propertiesface attributes (PlankTone, GrainDir) read by the wood shaders
skills/procedural-materials-and-shadersoak with grain along each stave, pale maple, rusted rough iron
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 body and lid, one for the handle above it
snippets/lod_chain.pyLOD naming and ratio pattern
examples/mesh-hygiene-audithygiene combinatorics (copied, not imported)

The budget that matters#

A churn is for plunging. The body narrows toward the top, so a plunger that clears the staves at the bottom can jam partway up the stroke, and nothing else notices:

The piece reads the inner wall off the staves ring by ring and the plunger's reach off the dasher's slats, about the handle's axis. It finds the height at which the wall comes within WALL_CLEAR (10 mm) of the plunger, stops at the lid if that comes first, and asserts the travel from the plunger's rest height as a stroke floor of 400 mm. Measured: 513.8 mm, for a 100.6 mm reach.

--wide-dasher is the falsifier built for exactly this. It widens the slats to 125 mm. The plunger still clears the staves at rest, but it can travel only 140.4 mm before it would meet the wall, and the run exits

  1. Every other budget passes and the AABB is unchanged.

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

BudgetBandMeasured
Base triangles5000–65005684
LOD1 ratio0.32–0.620.5000
LOD2 ratio0.10–0.350.2199
Material slotsexactly 3, distinct3
Oak / maple / iron faces≥ 2000 / 140 / 5002240 / 160 / 576
UV boundsinside 0..1(0.0013, 0.0013)–(0.9987, 0.9987)
UV AABB overlap≤ 1e-50.000000
Outer AABB0.340 × 0.340 × 1.050 m ± 0.0200.3401 × 0.3401 × 1.0500
Collider triangles≤ 200178 (two hulls)
Normal bake{'FINISHED'} with image data{'FINISHED'}, has_data=True
glTF exportfile written, non-empty~240 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 staves16 staves, each zmin ≤ 1e-416 at 0.00000
Bottom boards3 boards, seams 0.6–2.5 mm3, 1.20 mm
Handle bitehandle's foot 8–20 mm into the slats14.0 mm
Hoop seat3 hoops, inner face 1.0–4.5 mm into the staves, outer face ≥ 3 mm proud2.50 mm in, 4.90 mm proud
Lid seat2 boards, each 1–4 mm onto the stave tops1.40–2.00 mm
Handle clearancelid hole over handle radius, 2–10 mm5.00 mm
Plunge stroke≥ 400 mm513.8 mm

Real-world size: 0.34 m across the foot, 0.75 m to the rim and 1.05 m to the top of the handle. It is a farmhouse plunge churn.

Construction#

Findings the budgets forced#

The fixes:

The count is 0.

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; canted stave ends and stepped boards keep it at 0
Named supportsyesall sixteen staves (--short-stave)
Seat conformance, sampled per segmentyesevery hoop vertex against the stave vertex under it (--float-hoops)
Bands on a curved host are built on the host's arcyeshoop vertices at the staves' own angles
A band wraps its host, never sunk into ityesinner face bites, outer face asserted proud
A head or bottom is boards, not a slabyesthree bottom boards with a banded seam (--one-piece-bottom); the lid is two boards
A vessel holds its contentsn/athe churn shows no contents
A joint bites; touching is not joiningyeshandle into the slats (--short-handle)
Carried parts bite their bearersyeslid on the stave tops (--float-lid)
What passes through a part must clear its mountyeshandle through the lid's hole (--tight-hole)
Identical boards read as CGyesper-stave and per-board PlankTone, grain along each piece's longest extent
Hoops are forged iron, not brightyesnear-black and rust, roughness 0.55–0.85, metallic 0.65
Shading is part of the modelyesstaves smooth across their width with every edge over 35° hard, so they read as coopered boards; handle smooth (turned)
One substance, one slotyesoak, maple, iron
Chamfer n-gon caps, then triangulateyesbottom and lid boards
Sort bmesh operator inputsyesbevel edges sorted by index
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
Rope, masonry, roofs, rings, scatter, mirrored assembliesnothe 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 inside the body15
--lift-zgrounded zminlifts the whole mesh 50 mm16
--short-stavenamed stavesstops one stave's foot 6 mm above the floor; the rest ground the AABB16
--one-piece-bottombottom boardsone disk instead of three boards17
--short-handlehandle bitestarts the handle 5 mm above the slats; −5.0 mm17
--float-hoopshoop seatbuilds every hoop 4 mm off the staves; −3.5 mm18
--float-lidlid seatlifts the lid 5 mm; −3.6 to −3.0 mm18
--tight-holehandle clearancecuts the hole 2 mm inside the handle; −2.0 mm18
--wide-dasherplunge strokewidens the slats to 125 mm; 140.4 mm20

Exit codes#

File-local and sequential. 9 is a valid check code. 1 is the FATAL wrapper — a crash, never a named check. 19 (plumb and real-world size) is reserved across pieces and unused here.

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 stave floating (--lift-z, --short-stave)
17Bottom boards or handle bite (--one-piece-bottom, --short-handle)
18Hoop seat, lid seat or handle clearance (--float-hoops, --float-lid, --tight-hole)
20Plunge stroke below floor (--wide-dasher)

Run it#

# Budget check, no render. ~1.8 s on 4.5 and 5.2.
blender --background --python butter_churn.py --

# Falsifier: the plunger jams partway up the narrowing body. Must exit 20.
blender --background --python butter_churn.py -- --wide-dasher

# Falsifier: the lid's hole is narrower than the handle. Must exit 18.
blender --background --python butter_churn.py -- --tight-hole

# Render the gallery still (EEVEE; --engine cycles on a GPU-less host).
blender --background --python butter_churn.py -- --output butter_churn.webp

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

Cross-version measurements#

Value4.5.115.1.25.2.1
Base triangles568456845684
LOD1 / LOD2 tris2842 / 1250samesame
Face counts (oak / maple / iron)2240 / 160 / 576samesame
Outer AABB0.3401 × 0.3401 × 1.0500samesame
Collider tris178178178
Plunge stroke / reach513.8 mm / 100.6 mmsamesame
glTF bytes239804239804239796

Source

showcase/butter-churn/butter_churn.py 1272 lines · View on GitHub →
"""Game-ready butter churn — a showcase piece, not an example.

Asserts budget conformance of a procedural plunge churn: a tall staved
body that narrows toward the top, three iron hoops, a bottom of three
boards set in a croze, a lid of two boards with a hole for the handle,
and a cross dasher whose handle runs up through the lid. Carried through
UVs, three materials (oak, maple, iron), 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 churn fails invisibly: the
dasher has to plunge. The body narrows toward the top, so a plunger that
clears the staves at rest can jam a hand's width up the stroke. It still
sits inside the body without touching it, the handle still clears the lid
and the bounding box does not move; only the stroke knows. The piece
reads the inner wall off the staves and the plunger's reach off the
dasher, and asserts how far the plunger can travel before it would meet
the wall or the lid.

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, ``--short-stave`` the named staves,
``--one-piece-bottom`` the bottom boards, ``--short-handle`` the handle
bite, ``--float-hoops`` the hoop seat, ``--float-lid`` the lid seat,
``--tight-hole`` the handle clearance, ``--wide-dasher`` the stroke.

No randomness: every stave's tone 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 butter_churn.py --
    blender --background --python butter_churn.py -- --wide-dasher
    blender --background --python butter_churn.py -- --output butter_churn.png
"""
import argparse
import math
import os
import sys
import tempfile
import traceback

import bmesh
import bpy
from mathutils import Matrix, Vector
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 body: sixteen staves on a straight taper, wider at the foot.
HEIGHT = 0.750
R_BOT = 0.170
R_TOP = 0.120
N_STAVES = 16
STAVE_T = 0.018
STAVE_GAP = 0.0008
STAVE_FRACS = (0.0, 0.25, 0.75, 1.0)
BODY_RINGS = 6
CHAMFER = 0.0015
SHORT_STAVE = 0.006
# Each stave's foot and top are cut sloping inward, so the churn stands on
# its outer edges. Alternate staves take a double cant: at one cant, two
# neighbours' end faces are only 5.6 degrees apart in bearing and still
# match as one plane.
END_CANT = 0.0015

# Hoops: banded iron sampled at each stave's interior vertices, so every
# hoop vertex sits radially over a stave vertex at the same height.
HOOP_ZS = (0.060, 0.360, 0.655)
HOOP_H = 0.024
HOOP_BITE = 0.0025
HOOP_PROUD = 0.005
HOOP_CH = 0.0015
FLOAT_HOOP = 0.004

# Bottom: three boards in a croze cut into the staves' inner face.
BOTTOM_Z = 0.012
BOTTOM_T = 0.022
CROZE = 0.0035
N_BOARDS = 3
BOARD_SEAM = 0.0012
DISK_SEG = 48
BOARD_STEP = 0.0006

# Lid: two boards resting on the stave tops, a hole for the handle.
LID_T = 0.022
LID_SEAT = 0.002
LID_OVER = 0.008
LID_SEAM = 0.0012
HOLE_CLEAR = 0.005
TIGHT_HOLE = -0.002
FLOAT_LID = 0.005
LID_ARC = 24
LID_STEP = 0.0006

# Dasher: a round maple handle through the lid, two crossed slats below.
HANDLE_R = 0.016
HANDLE_TOP = 1.050
HANDLE_BITE = 0.014
SHORT_HANDLE = -0.005
PLUNGER_Z = BOTTOM_Z + BOTTOM_T + 0.050
SLAT_REACH = 0.100
WIDE_REACH = 0.125
SLAT_W = 0.024
SLAT_T = 0.020
SLAT_STEP = 0.004
WALL_CLEAR = 0.010

BBOX_TOL = 0.020
OUTER_SIZE = (0.340, 0.340, 1.050)

BASE_TRIS_MIN = 5000
BASE_TRIS_MAX = 6500
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
FACE_FLOORS = {0: 2000, 1: 140, 2: 500}
UV_EPS = 1e-4
UV_OVERLAP_MAX = 1e-5
COLLIDER_TRIS_MAX = 200
BAKE_RES = 512
CAGE_EXTRUSION = 0.01
ZMIN_EPS = 1e-4
DOUBLES_EPS = 1e-5
AREA_EPS = 1e-10
COPLANAR_NORMAL_EPS = 1e-4
COPLANAR_PLANE_EPS = 1e-4
COPLANAR_CENTRE_MAX = 0.05
LIFT_Z = 0.05
STAVE_Z_MAX = 1e-4
BOARD_SEAM_MIN = 0.0006
BOARD_SEAM_MAX = 0.0025
HANDLE_BITE_MIN = 0.008
HANDLE_BITE_MAX = 0.020
HOOP_BITE_MIN = 0.0010
HOOP_BITE_MAX = 0.0045
HOOP_PROUD_MIN = 0.0030
LID_SEAT_MIN = 0.0010
LID_SEAT_MAX = 0.0040
HOLE_CLEAR_MIN = 0.002
HOLE_CLEAR_MAX = 0.010
STROKE_MIN = 0.400

OAK_IDX = 0
MAPLE_IDX = 1
IRON_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()


def r_out(z):
    return R_BOT + (R_TOP - R_BOT) * z / HEIGHT


def r_in(z):
    return r_out(z) - STAVE_T


def stave_angles(k):
    """Angles of stave ``k``'s vertices across its width, gap split either side."""
    span = 2.0 * math.pi / N_STAVES
    gap = STAVE_GAP / R_TOP
    a0 = k * span + gap * 0.5
    width = span - gap
    return [a0 + width * f for f in STAVE_FRACS]


def ring_zs():
    """Stave ring heights: a uniform run plus both edges of every hoop and the croze."""
    zs = {round(HEIGHT * k / BODY_RINGS, 6) for k in range(BODY_RINGS + 1)}
    for z in HOOP_ZS:
        zs |= {round(z, 6), round(z + HOOP_H, 6)}
    zs |= {round(BOTTOM_Z, 6), round(BOTTOM_Z + BOTTOM_T, 6)}
    return sorted(zs)


# --- construction -----------------------------------------------------------


def new_island(ctx):
    ctx["next"] += 1
    return ctx["next"]


def stamp(ctx, face, island, uvmap):
    face[ctx["isl"]] = island
    for loop in face.loops:
        loop[ctx["uv"]].uv = uvmap[loop.vert]


def loft(bm, rings, mat_idx, ctx, closed=True):
    """Quads between consecutive rings of equal length; one strip island."""
    island = new_island(ctx)
    n = len(rings[0])
    arc = [0.0]
    for a, b in zip(rings, rings[1:]):
        ca = sum((v.co for v in a), Vector()) / n
        cb = sum((v.co for v in b), Vector()) / n
        arc.append(arc[-1] + (cb - ca).length)
    faces = []
    for k in range(len(rings) - 1):
        a, b = rings[k], rings[k + 1]
        for i in range(n if closed else n - 1):
            j = (i + 1) % n
            f = bm.faces.new((a[i], a[j], b[j], b[i]))
            f.material_index = mat_idx
            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)})
            faces.append(f)
    return faces


def add_stave(bm, k, ctx, z0, verts_out):
    """One stave: an annular sector on the taper, capped by quads at both ends."""
    angs = stave_angles(k)
    rings = []
    for z in ring_zs():
        # ``z0`` lifts only the foot ring, so a short stave stops above the
        # floor while its hoop and croze rings stay where the others are.
        zz = z if z > 0.0 else z0
        c = END_CANT * (1 + k % 2)
        cant = c if z <= 0.0 else (-c if z >= HEIGHT else 0.0)
        outer = [Vector((r_out(z) * math.cos(a), r_out(z) * math.sin(a), zz)) for a in angs]
        inner = [Vector((r_in(z) * math.cos(a), r_in(z) * math.sin(a), zz + cant))
                 for a in reversed(angs)]
        rings.append([bm.verts.new(p) for p in outer + inner])
    loft(bm, rings, OAK_IDX, ctx)
    m = len(angs)
    for ring, flip in ((rings[0], True), (rings[-1], False)):
        for i in range(m - 1):
            o0, o1 = ring[i], ring[i + 1]
            i1, i0 = ring[2 * m - 2 - i], ring[2 * m - 1 - i]
            vs = (o0, o1, i1, i0)
            f = bm.faces.new(tuple(reversed(vs)) if flip else vs)
            f.material_index = OAK_IDX
            f[ctx["isl"]] = 0
    for r in rings:
        verts_out.extend(r)


def add_hoop(bm, z, ctx, float_hoop=False):
    """A banded hoop sampled at every stave's interior vertex angles.

    Its inner face sits HOOP_BITE inside the stave's outer face at each
    edge height, read from the same taper, so the band follows the staves.
    """
    angs = [a for k in range(N_STAVES) for a in stave_angles(k)[1:-1]]
    off_in = FLOAT_HOOP if float_hoop else -HOOP_BITE
    sec = [(off_in, 0.0), (HOOP_PROUD - HOOP_CH, 0.0), (HOOP_PROUD, HOOP_CH),
           (HOOP_PROUD, HOOP_H - HOOP_CH), (HOOP_PROUD - HOOP_CH, HOOP_H), (off_in, HOOP_H)]
    cols = []
    for a in angs:
        c, s = math.cos(a), math.sin(a)
        cols.append([bm.verts.new(((r_out(z + dz) + dr) * c, (r_out(z + dz) + dr) * s, z + dz))
                     for dr, dz in sec])
    island = new_island(ctx)
    n, m = len(cols), len(sec)
    for i in range(n):
        a, b = cols[i], cols[(i + 1) % n]
        for j in range(m):
            jj = (j + 1) % m
            f = bm.faces.new((a[j], a[jj], b[jj], b[j]))
            f.material_index = IRON_IDX
            stamp(ctx, f, island, {a[j]: (i / n, j / m), a[jj]: (i / n, (j + 1) / m),
                                   b[jj]: ((i + 1) / n, (j + 1) / m), b[j]: ((i + 1) / n, j / m)})


def clip_x(poly, x0, x1):
    """Clip a convex (x, y) polygon to x0 <= x <= x1 (Sutherland-Hodgman)."""
    def cut(pts, keep, xc):
        out = []
        for i, p in enumerate(pts):
            q = pts[(i + 1) % len(pts)]
            pin, qin = keep(p[0]), keep(q[0])
            if pin:
                out.append(p)
            if pin != qin:
                t = (xc - p[0]) / (q[0] - p[0])
                out.append((xc, p[1] + (q[1] - p[1]) * t))
        return out
    pts = cut(poly, lambda x: x >= x0, x0)
    return cut(pts, lambda x: x <= x1, x1)


def extrude_polygon(bm, poly, z0, z1, mat_idx, verts_out):
    """A board from an (x, y) outline: two n-gon caps and quad sides."""
    lo = [bm.verts.new((x, y, z0)) for x, y in poly]
    hi = [bm.verts.new((x, y, z1)) for x, y in poly]
    faces = [bm.faces.new(lo), bm.faces.new(list(reversed(hi)))]
    n = len(poly)
    for i in range(n):
        j = (i + 1) % n
        faces.append(bm.faces.new((lo[j], lo[i], hi[i], hi[j])))
    for f in faces:
        f.material_index = mat_idx
    verts_out.extend(lo + hi)


def add_bottom(bm, one_piece, verts_out):
    zm = BOTTOM_Z + BOTTOM_T * 0.5
    rr = r_in(zm) + CROZE
    disk = [(rr * math.cos(2 * math.pi * i / DISK_SEG), rr * math.sin(2 * math.pi * i / DISK_SEG))
            for i in range(DISK_SEG)]
    if one_piece:
        extrude_polygon(bm, disk, BOTTOM_Z, BOTTOM_Z + BOTTOM_T, OAK_IDX, verts_out)
        return
    w = (2.0 * rr - BOARD_SEAM * (N_BOARDS - 1)) / N_BOARDS
    for k in range(N_BOARDS):
        x0 = -rr + k * (w + BOARD_SEAM)
        # Alternate boards step up and in by BOARD_STEP: level with their
        # neighbours, their faces and the rim chord a seam splits would each
        # be one plane shared by two boards.
        step = BOARD_STEP if k % 2 else 0.0
        src = [(x * (rr - step) / rr, y * (rr - step) / rr) for x, y in disk]
        poly = clip_x(src, x0 - (1.0 if k == 0 else 0.0), x0 + w + (1.0 if k == N_BOARDS - 1 else 0.0))
        extrude_polygon(bm, poly, BOTTOM_Z + step, BOTTOM_Z + BOTTOM_T + step, OAK_IDX, verts_out)


def add_lid(bm, hole_r, lift, verts_out):
    """Two half-annulus boards, seam across the hole, resting on the stave tops."""
    ro = r_out(HEIGHT) + LID_OVER
    z0 = HEIGHT - LID_SEAT + lift
    s = LID_SEAM * 0.5
    t_o = math.acos(s / ro)
    t_i = math.acos(s / hole_r)
    for side in (1.0, -1.0):
        outer = [(ro * math.cos(-t_o + 2 * t_o * k / LID_ARC), ro * math.sin(-t_o + 2 * t_o * k / LID_ARC))
                 for k in range(LID_ARC + 1)]
        inner = [(hole_r * math.cos(t_i - 2 * t_i * k / 8), hole_r * math.sin(t_i - 2 * t_i * k / 8))
                 for k in range(9)]
        poly = [(side * x, y) for x, y in outer + inner]
        if side < 0:
            poly.reverse()
        # One board a step proud of the other, so the two do not share a face plane.
        step = LID_STEP if side < 0 else 0.0
        extrude_polygon(bm, poly, z0 + step, z0 + step + LID_T, OAK_IDX, verts_out)


def lathe_z(bm, profile, n, mat_idx, ctx, base):
    """Revolve an (r, z) profile about the vertical axis through ``base``."""
    rings, poles = [], []
    for p in profile:
        if p.x <= 0.0:
            poles.append(bm.verts.new(base + Vector((0.0, 0.0, p.y))))
        else:
            rings.append([bm.verts.new(base + Vector((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)])
    loft(bm, rings, mat_idx, ctx)
    island = new_island(ctx)
    for pole, ring, flip in ((poles[0], rings[0], True), (poles[1], rings[-1], False)):
        for i in range(n):
            j = (i + 1) % n
            vs = (pole, ring[j], ring[i]) if flip else (pole, ring[i], ring[j])
            f = bm.faces.new(vs)
            f.material_index = mat_idx
            stamp(ctx, f, island, {pole: (0.5 if flip else 1.5, (i + 0.5) / n),
                                   ring[i]: (0.0 if flip else 1.0, i / n),
                                   ring[j]: (0.0 if flip else 1.0, (i + 1) / n)})


def handle_profile(z_start, lid_z0):
    """(r, z) from the handle's foot, with rings at both faces of the lid."""
    r = HANDLE_R
    top = HANDLE_TOP - z_start
    rings = [(r * 0.85, 0.0), (r, 0.004), (r, lid_z0 - z_start), (r, lid_z0 + LID_T - z_start),
             (r, top - 0.060), (r * 1.25, top - 0.035), (r * 1.35, top - 0.018), (r * 1.05, top)]
    return [Vector((0.0, 0.0))] + [Vector(p) for p in rings] + [Vector((0.0, top))]


def build_churn_mesh(
    name,
    stray_vert=False,
    short_stave=False,
    one_piece_bottom=False,
    short_handle=False,
    float_hoops=False,
    float_lid=False,
    tight_hole=False,
    wide_dasher=False,
):
    bm = bmesh.new()
    try:
        ctx = {"uv": bm.loops.layers.uv.new("UVMap"),
               "isl": bm.faces.layers.int.new("UVIsland"), "next": 0}
        wood = []
        for k in range(N_STAVES):
            add_stave(bm, k, ctx, SHORT_STAVE if (short_stave and k == 0) else 0.0, wood)
        add_bottom(bm, one_piece_bottom, wood)
        hole_r = HANDLE_R + (TIGHT_HOLE if tight_hole else HOLE_CLEAR)
        lid_lift = FLOAT_LID if float_lid else 0.0
        add_lid(bm, hole_r, lid_lift, wood)
        # Dasher: two crossed slats, the second a step higher so no two of
        # their faces share a plane, and the handle's foot biting both.
        reach = WIDE_REACH if wide_dasher else SLAT_REACH
        maple = []
        extrude_polygon(bm, [(-reach, -SLAT_W / 2), (reach, -SLAT_W / 2), (reach, SLAT_W / 2), (-reach, SLAT_W / 2)],
                PLUNGER_Z, PLUNGER_Z + SLAT_T, MAPLE_IDX, maple)
        extrude_polygon(bm, [(-SLAT_W / 2, -reach), (SLAT_W / 2, -reach), (SLAT_W / 2, reach), (-SLAT_W / 2, reach)],
                PLUNGER_Z + SLAT_STEP, PLUNGER_Z + SLAT_STEP + SLAT_T, MAPLE_IDX, maple)
        # Real edges only (a stave's width and a disk's rim meet at a few
        # degrees), sorted by index, each pass pinned to its own material.
        for verts, mat_idx in ((wood, OAK_IDX), (maple, MAPLE_IDX)):
            bm.edges.index_update()
            edges = sorted({e for v in verts if v.is_valid for e in v.link_edges
                            if e.calc_face_angle(0.0) > math.radians(20.0)},
                           key=lambda e: e.index)
            bmesh.ops.bevel(bm, geom=edges, offset=CHAMFER, segments=1, profile=0.5,
                            affect="EDGES", clamp_overlap=True, material=mat_idx)
        bmesh.ops.triangulate(bm, faces=[f for f in bm.faces if len(f.verts) > 4])
        for z in HOOP_ZS:
            add_hoop(bm, z, ctx, float_hoop=float_hoops)
        z_start = PLUNGER_Z + SLAT_STEP + SLAT_T - (SHORT_HANDLE if short_handle else HANDLE_BITE)
        lathe_z(bm, handle_profile(z_start, HEIGHT - LID_SEAT + lid_lift), 12, MAPLE_IDX, ctx,
                Vector((0.0, 0.0, z_start)))
        if stray_vert:
            bm.verts.new((0.0, 0.0, 0.3))
        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
        for f in bm.faces:
            f.smooth = True
        for e in bm.edges:
            if len(e.link_faces) == 2 and e.calc_face_angle(0.0) > math.radians(35.0):
                e.smooth = False
        pack_uvs(bm, ctx)
        bm.faces.layers.int.remove(ctx["isl"])
        me = bpy.data.meshes.new(name)
        bm.to_mesh(me)
        me.update()
    finally:
        bm.free()
    paint_pieces(me)
    obj = bpy.data.objects.new(name, me)
    bpy.context.collection.objects.link(obj)
    return obj


def pack_uvs(bm, ctx, margin=0.06):
    """One grid cell per UV island: strip islands by their layer tag, else a face each."""
    uv, isl = ctx["uv"], ctx["isl"]
    bm.faces.index_update()
    islands, order = {}, []
    for face in bm.faces:
        key = ("s", face[isl]) if face[isl] else ("f", face.index)
        if key not in islands:
            islands[key] = []
            order.append(key)
        islands[key].append(face)
    cols = max(1, math.ceil(math.sqrt(len(order))))
    rows = max(1, math.ceil(len(order) / cols))
    cw, ch = 1.0 / cols, 1.0 / rows
    pu, pv = margin * cw * 0.5, margin * ch * 0.5
    for idx, key in enumerate(order):
        faces = islands[key]
        coords = {}
        for face in faces:
            if face[isl]:
                coords[face.index] = [tuple(loop[uv].uv) for loop in face.loops]
                continue
            nrm = face.normal
            ax, ay, az = abs(nrm.x), abs(nrm.y), abs(nrm.z)
            pts = []
            for loop in face.loops:
                co = loop.vert.co
                if az >= ax and az >= ay:
                    pts.append((co.x, co.y))
                elif ax >= ay:
                    pts.append((co.y, co.z))
                else:
                    pts.append((co.x, co.z))
            coords[face.index] = pts
        allc = [c for cs in coords.values() for c in cs]
        minx, maxx = min(c[0] for c in allc), max(c[0] for c in allc)
        miny, maxy = min(c[1] for c in allc), max(c[1] for c in allc)
        dx, dy = max(maxx - minx, 1e-8), max(maxy - miny, 1e-8)
        ou, ov = (idx % cols) * cw + pu, (idx // cols) * ch + pv
        for face in faces:
            for loop, (x, y) in zip(face.loops, coords[face.index]):
                loop[uv].uv = (ou + (x - minx) / dx * (cw - 2 * pu),
                               ov + (y - miny) / dy * (ch - 2 * pv))


def paint_pieces(me):
    """Per-piece ``PlankTone`` and ``GrainDir`` (each shell's longest extent)."""
    npoly = len(me.polygons)
    tone = [0.5] * npoly
    grain = [(0.0, 0.0, 1.0)] * npoly
    vf = [[] for _ in range(len(me.vertices))]
    for p in me.polygons:
        for i in p.vertices:
            vf[i].append(p.index)
    for k, g in enumerate(shells(me)):
        pts = [me.vertices[i].co for i in g]
        ext = [max(p[a] for p in pts) - min(p[a] for p in pts) for a in range(3)]
        axis = ext.index(max(ext))
        d = tuple(1.0 if a == axis else 0.0 for a in range(3))
        t = 0.5 + 0.34 * (((k * 0.6180339887 + 0.3) % 1.0) - 0.5)
        for fi in {fi for i in g for fi in vf[i]}:
            tone[fi] = t
            grain[fi] = d
    a = me.attributes.new("PlankTone", "FLOAT", "FACE")
    a.data.foreach_set("value", tone)
    b = me.attributes.new("GrainDir", "FLOAT_VECTOR", "FACE")
    b.data.foreach_set("vector", [c for v in grain for c in v])


# --- surface ----------------------------------------------------------------


def _sock(sockets, identifier):
    return next(sk for sk in sockets if sk.identifier == identifier)


def wood_material(name, dark, light, rough=(0.72, 0.52)):
    """Timber whose grain runs along ``GrainDir`` and whose tone varies by piece."""
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    nt = mat.node_tree
    bsdf = nt.nodes["Principled BSDF"]
    coord = nt.nodes.new("ShaderNodeTexCoord")
    gdir = nt.nodes.new("ShaderNodeAttribute")
    gdir.attribute_name = "GrainDir"
    tone = nt.nodes.new("ShaderNodeAttribute")
    tone.attribute_name = "PlankTone"
    dot = nt.nodes.new("ShaderNodeVectorMath")
    dot.operation = "DOT_PRODUCT"
    nt.links.new(coord.outputs["Object"], dot.inputs[0])
    nt.links.new(gdir.outputs["Vector"], dot.inputs[1])
    squash = nt.nodes.new("ShaderNodeMath")
    squash.operation = "MULTIPLY"
    squash.inputs[1].default_value = 0.94
    nt.links.new(dot.outputs["Value"], squash.inputs[0])
    along = nt.nodes.new("ShaderNodeVectorMath")
    along.operation = "SCALE"
    nt.links.new(gdir.outputs["Vector"], along.inputs[0])
    nt.links.new(squash.outputs["Value"], along.inputs["Scale"])
    grain_co = nt.nodes.new("ShaderNodeVectorMath")
    grain_co.operation = "SUBTRACT"
    nt.links.new(coord.outputs["Object"], grain_co.inputs[0])
    nt.links.new(along.outputs["Vector"], grain_co.inputs[1])
    shift = nt.nodes.new("ShaderNodeVectorMath")
    shift.operation = "ADD"
    nt.links.new(grain_co.outputs["Vector"], shift.inputs[0])
    nt.links.new(tone.outputs["Fac"], shift.inputs[1])
    noise = nt.nodes.new("ShaderNodeTexNoise")
    noise.inputs["Scale"].default_value = 34.0
    noise.inputs["Detail"].default_value = 6.0
    noise.inputs["Roughness"].default_value = 0.62
    nt.links.new(shift.outputs["Vector"], noise.inputs["Vector"])
    ramp = nt.nodes.new("ShaderNodeValToRGB")
    ramp.color_ramp.elements[0].position = 0.30
    ramp.color_ramp.elements[0].color = (*dark, 1.0)
    ramp.color_ramp.elements[1].position = 0.72
    ramp.color_ramp.elements[1].color = (*light, 1.0)
    nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
    gain = nt.nodes.new("ShaderNodeMath")
    gain.operation = "MULTIPLY_ADD"
    gain.inputs[1].default_value = 1.2
    gain.inputs[2].default_value = 0.40
    nt.links.new(tone.outputs["Fac"], gain.inputs[0])
    mix = nt.nodes.new("ShaderNodeMix")
    mix.data_type = "RGBA"
    mix.blend_type = "MULTIPLY"
    _sock(mix.inputs, "Factor_Float").default_value = 1.0
    nt.links.new(ramp.outputs["Color"], _sock(mix.inputs, "A_Color"))
    nt.links.new(gain.outputs["Value"], _sock(mix.inputs, "B_Color"))
    nt.links.new(_sock(mix.outputs, "Result_Color"), bsdf.inputs["Base Color"])
    rmap = nt.nodes.new("ShaderNodeMapRange")
    rmap.inputs["To Min"].default_value = rough[0]
    rmap.inputs["To Max"].default_value = rough[1]
    nt.links.new(noise.outputs["Fac"], rmap.inputs["Value"])
    nt.links.new(rmap.outputs["Result"], bsdf.inputs["Roughness"])
    return mat


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 = 40.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 churn_materials():
    return (
        wood_material("ChurnOak", (0.085, 0.040, 0.016), (0.28, 0.14, 0.060)),
        wood_material("ChurnMaple", (0.26, 0.17, 0.09), (0.55, 0.40, 0.24), rough=(0.62, 0.45)),
        iron_material("ChurnIron"),
    )


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 classify(me):
    mats = {}
    for p in me.polygons:
        for i in p.vertices:
            mats.setdefault(i, p.material_index)
    out = {"stave": [], "board": [], "lid": [], "hoop": [], "slat": [], "handle": [], "other": []}
    for g in shells(me):
        pts = [me.vertices[i].co.copy() for i in g]
        lo = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts)))
        hi = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts)))
        rec = {"g": g, "pts": pts, "lo": lo, "hi": hi, "ext": hi - lo,
               "c": sum(pts, Vector()) / len(pts)}
        m = mats.get(g[0], -1)
        e = rec["ext"]
        if m == OAK_IDX:
            if e.z > HEIGHT * 0.5:
                out["stave"].append(rec)
            elif lo.z > HEIGHT * 0.5:
                out["lid"].append(rec)
            else:
                out["board"].append(rec)
        elif m == MAPLE_IDX:
            out["handle" if e.z > 0.3 else "slat"].append(rec)
        elif m == IRON_IDX:
            out["hoop"].append(rec)
        else:
            out["other"].append(rec)
    return out


def radial(p):
    return math.hypot(p.x, p.y)


def churn_audit(me):
    parts = classify(me)
    out = {k: len(v) for k, v in parts.items()}
    staves = parts["stave"]
    out["stave_z"] = max((r["lo"].z for r in staves), default=99.0)

    # Bottom boards: count and the seams between neighbours.
    boards = sorted(parts["board"], key=lambda r: r["c"].x)
    seams = [b["lo"].x - a["hi"].x for a, b in zip(boards, boards[1:])]
    out["seam"] = (min(seams, default=-99.0), max(seams, default=99.0))

    # Hoops on the staves: every hoop vertex against the stave vertex it
    # was built over, measured radially.
    spts = [p for r in staves for p in r["pts"]]
    kd = KDTree(len(spts))
    for i, p in enumerate(spts):
        kd.insert(p, i)
    kd.balance()
    bites, prouds = [], []
    for h in parts["hoop"]:
        ds = sorted(radial(kd.find(p)[0]) - radial(p) for p in h["pts"])
        n_in = len(ds) // 3
        bites.append(min(ds[-n_in:]))
        prouds.append(-max(ds[:n_in]))
    out["hoop_bite"] = (min(bites, default=-99.0), max(bites, default=99.0))
    out["hoop_proud"] = min(prouds, default=-99.0)

    # The lid rests on the stave tops; the handle clears the lid's hole.
    top = max((r["hi"].z for r in staves), default=0.0)
    lids = parts["lid"]
    seats = [top - r["lo"].z for r in lids]
    out["lid_seat"] = (min(seats, default=-99.0), max(seats, default=99.0))
    out["hole_clear"] = -99.0
    handle = parts["handle"][0] if parts["handle"] else None
    if handle and lids:
        lz0 = min(r["lo"].z for r in lids)
        lz1 = max(r["hi"].z for r in lids)
        hr = max((radial(p) for p in handle["pts"] if lz0 - 1e-6 <= p.z <= lz1 + 1e-6), default=99.0)
        lr = min(radial(p) for r in lids for p in r["pts"])
        out["hole_clear"] = lr - hr

    # The handle's foot bites the slats.
    slats = parts["slat"]
    out["handle_bite"] = -99.0
    if handle and slats:
        out["handle_bite"] = max(r["hi"].z for r in slats) - handle["lo"].z

    # Stroke: the inner wall read off the staves, ring by ring, against the
    # plunger's reach about the handle's axis.
    out["stroke"] = -99.0
    if slats and staves:
        reach = max(radial(p) for r in slats for p in r["pts"])
        p_top = max(r["hi"].z for r in slats)
        walls = {}
        for p in spts:
            z = round(p.z, 5)
            walls[z] = min(walls.get(z, 99.0), radial(p))
        table = sorted(walls.items())
        need = reach + WALL_CLEAR
        limit = min((r["lo"].z for r in lids), default=top)
        z_ok = None
        for (za, ra), (zb, rb) in zip(table, table[1:]):
            if za < p_top - 1e-6:
                continue
            if rb >= need:
                continue
            if ra >= need:
                z_ok = za + (zb - za) * (ra - need) / (ra - rb)
            else:
                z_ok = za
            break
        if z_ok is None:
            z_ok = limit
        out["reach"] = reach
        out["stroke"] = min(z_ok, limit) - p_top
    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 hull for the staves and lid, one for the handle above it.

    The hoops stand 5 mm proud of the staves and are left out: in the hull
    they add triangles and nothing a character could collide with.
    """
    me = obj.data
    parts = classify(me)
    body = [p for k in ("stave", "lid") for r in parts[k] for p in r["pts"]]
    body = [p for i, p in enumerate(body) if i % 3 == 0]
    groups = [body]
    if parts["handle"]:
        top = max(p.z for r in parts["lid"] for p in r["pts"]) if parts["lid"] else HEIGHT
        groups.append([p for p in parts["handle"][0]["pts"] if p.z >= top - 0.01])
    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)
                bmesh.ops.dissolve_limit(tmp, angle_limit=math.radians(4.0),
                                         verts=list(tmp.verts), edges=list(tmp.edges))
                bmesh.ops.triangulate(tmp, faces=list(tmp.faces))
                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("ChurnNrm", 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 = OAK_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_churn_mesh("ChurnLow", **flags)
    hi_flags = {k: v for k, v in flags.items() if k != "stray_vert"}
    high = build_churn_mesh("ChurnHigh", **hi_flags)
    mats = churn_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("churn 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[OAK_IDX], BAKE_RES)
    bake_result = bake_normal(high, low)

    lod1 = make_lod(low, "ChurnLOD1", LOD1_TARGET, skip_decimate)
    lod2 = make_lod(low, "ChurnLOD2", 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, "ChurnCollider")
    col_tris = triangle_count(collider.data)

    export_path = os.path.join(tempfile.gettempdir(), f"bdt_churn_{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)
    ca = churn_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 staves={ca['stave']} boards={ca['board']} lid={ca['lid']} "
          f"hoops={ca['hoop']} slats={ca['slat']} handle={ca['handle']} other={ca['other']} "
          f"stave_z={ca['stave_z']:.5f}")
    print(f"measured joints seam=({ca['seam'][0]:.5f},{ca['seam'][1]:.5f}) "
          f"handle_bite={ca['handle_bite']:.5f}")
    print(f"measured seats hoop_bite=({ca['hoop_bite'][0]:.5f},{ca['hoop_bite'][1]:.5f}) "
          f"hoop_proud={ca['hoop_proud']:.5f} lid=({ca['lid_seat'][0]:.5f},{ca['lid_seat'][1]:.5f}) "
          f"hole_clear={ca['hole_clear']:.5f}")
    print(f"measured stroke={ca['stroke']:.5f} reach={ca.get('reach', 0.0):.5f}")

    if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
        return (fail(f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", 4),) + nothing
    if nmat != MATERIAL_COUNT or distinct != MATERIAL_COUNT:
        return (fail(f"material slots {nmat} distinct {distinct} != {MATERIAL_COUNT}", 5),) + nothing
    for idx, floor in FACE_FLOORS.items():
        if idx_counts.get(idx, 0) < floor:
            return (fail(f"material {idx} faces {idx_counts.get(idx, 0)} < {floor}", 5),) + nothing
    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
        return (fail(f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", 6),) + nothing
    if overlap > UV_OVERLAP_MAX:
        return (fail(f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7),) + nothing
    if (abs(size_x - OUTER_SIZE[0]) > BBOX_TOL or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
            or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL):
        return (fail(f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) off outer {OUTER_SIZE}", 8),) + nothing
    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
        return (fail(f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
                     "(--skip-decimate is the designed fail)", 9),) + nothing
    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
        return (fail(f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", 9),) + nothing
    if col_tris > COLLIDER_TRIS_MAX:
        return (fail(f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", 11),) + nothing
    if bake_result != {"FINISHED"} or not img.has_data:
        return (fail(f"bake failed result={bake_result} has_data={img.has_data}", 12),) + nothing
    if export_size <= 0:
        return (fail("export file missing or empty", 13),) + nothing
    if (hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"]
            or hyg["doubles"] or hyg["ngons"] or zf):
        return (fail(f"hygiene {hyg} zfight={zf} (--stray-vert is the designed fail)", 15),) + nothing
    if abs(bb[2]) > ZMIN_EPS:
        return (fail(f"zmin {bb[2]:.6f} not within {ZMIN_EPS} of 0 "
                     "(--lift-z is the designed fail)", 16),) + nothing
    if ca["stave"] != N_STAVES or ca["stave_z"] > STAVE_Z_MAX:
        return (fail(f"staves: {ca['stave']} of {N_STAVES}, worst stave z={ca['stave_z']:.5f} "
                     f"> {STAVE_Z_MAX} (--short-stave is the designed fail)", 16),) + nothing
    if (ca["board"] != N_BOARDS or ca["seam"][0] < BOARD_SEAM_MIN
            or ca["seam"][1] > BOARD_SEAM_MAX):
        return (fail(f"{ca['board']} of {N_BOARDS} bottom boards, seams {ca['seam']} outside "
                     f"[{BOARD_SEAM_MIN}, {BOARD_SEAM_MAX}] (--one-piece-bottom is the designed fail)",
                     17),) + nothing
    if not (HANDLE_BITE_MIN <= ca["handle_bite"] <= HANDLE_BITE_MAX):
        return (fail(f"handle bite {ca['handle_bite']:.5f} outside [{HANDLE_BITE_MIN}, "
                     f"{HANDLE_BITE_MAX}] (--short-handle is the designed fail)", 17),) + nothing
    if (ca["hoop"] != len(HOOP_ZS) or ca["hoop_bite"][0] < HOOP_BITE_MIN
            or ca["hoop_bite"][1] > HOOP_BITE_MAX or ca["hoop_proud"] < HOOP_PROUD_MIN):
        return (fail(f"{ca['hoop']} of {len(HOOP_ZS)} hoops, bite {ca['hoop_bite']} outside "
                     f"[{HOOP_BITE_MIN}, {HOOP_BITE_MAX}] or proud {ca['hoop_proud']:.5f} < "
                     f"{HOOP_PROUD_MIN} (--float-hoops is the designed fail)", 18),) + nothing
    if (ca["lid"] != 2 or ca["lid_seat"][0] < LID_SEAT_MIN or ca["lid_seat"][1] > LID_SEAT_MAX):
        return (fail(f"{ca['lid']} of 2 lid boards, seat {ca['lid_seat']} outside "
                     f"[{LID_SEAT_MIN}, {LID_SEAT_MAX}] (--float-lid is the designed fail)", 18),) + nothing
    if not (HOLE_CLEAR_MIN <= ca["hole_clear"] <= HOLE_CLEAR_MAX):
        return (fail(f"handle clears the lid hole by {ca['hole_clear']:.5f}, outside "
                     f"[{HOLE_CLEAR_MIN}, {HOLE_CLEAR_MAX}] (--tight-hole is the designed fail)",
                     18),) + nothing
    if ca["stroke"] < STROKE_MIN:
        return (fail(f"plunge stroke {ca['stroke']:.5f} < {STROKE_MIN} "
                     "(--wide-dasher is the designed fail)", 20),) + 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[OAK_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(10.0)

    floor_me = bpy.data.meshes.new("Floor")
    bm = bmesh.new()
    try:
        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=60.0)
        bm.to_mesh(floor_me)
    finally:
        bm.free()
    fmat = bpy.data.materials.new("Floor")
    fmat.use_nodes = True
    fb = fmat.node_tree.nodes["Principled BSDF"]
    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
    fb.inputs["Roughness"].default_value = 0.7
    floor_me.materials.append(fmat)
    floor = bpy.data.objects.new("Floor", floor_me)
    scene.collection.objects.link(floor)
    wall = bpy.data.objects.new("Wall", floor_me.copy())
    wall.location = (0.0, 8.5, 0.0)
    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
    scene.collection.objects.link(wall)

    world = bpy.data.worlds.new("World")
    world.use_nodes = True
    world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0)
    scene.world = world

    def light(name, kind, loc, energy, size, col, rot=(0, 0, 0)):
        ld = bpy.data.lights.new(name, kind)
        ld.energy = energy
        if kind == "AREA":
            ld.size = size
        else:
            ld.shadow_soft_size = size
        ld.color = col
        ob = bpy.data.objects.new(name, ld)
        ob.location = loc
        ob.rotation_euler = tuple(math.radians(a) for a in rot)
        scene.collection.objects.link(ob)

    light("Key", "AREA", (-2.4, -3.2, 3.2), 360.0, 3.0, (1.0, 0.95, 0.88), (50, 0, -35))
    light("Fill", "AREA", (3.2, -2.6, 1.6), 60.0, 5.0, (0.74, 0.84, 1.0), (68, 0, 50))
    light("Rim", "AREA", (-1.6, 2.6, 2.4), 220.0, 3.0, (0.62, 0.78, 1.0), (-55, 0, 200))
    ld = bpy.data.lights.new("Wedge", "SPOT")
    ld.energy, ld.color = 220.0, (1.0, 0.66, 0.34)
    ld.spot_size, ld.spot_blend, ld.shadow_soft_size = math.radians(50.0), 1.0, 0.3
    wedge = bpy.data.objects.new("Wedge", ld)
    wedge.location = (0.5, 1.6, 2.0)
    wedge.rotation_euler = (Vector((0.25, 0.5, 0.0)) - wedge.location).to_track_quat(
        "-Z", "Y").to_euler()
    scene.collection.objects.link(wedge)

    cam_data = bpy.data.cameras.new("Cam")
    cam_data.lens = 50.0
    cam = bpy.data.objects.new("Cam", cam_data)
    cam.location = (1.23, -2.86, 1.28)
    scene.collection.objects.link(cam)
    aim = bpy.data.objects.new("Aim", None)
    aim.location = (0.0, 0.0, 0.52)
    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("--short-stave", action="store_true")
    p.add_argument("--one-piece-bottom", action="store_true")
    p.add_argument("--short-handle", action="store_true")
    p.add_argument("--float-hoops", action="store_true")
    p.add_argument("--float-lid", action="store_true")
    p.add_argument("--tight-hole", action="store_true")
    p.add_argument("--wide-dasher", 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,
        short_stave=args.short_stave,
        one_piece_bottom=args.one_piece_bottom,
        short_handle=args.short_handle,
        float_hoops=args.float_hoops,
        float_lid=args.float_lid,
        tight_hole=args.tight_hole,
        wide_dasher=args.wide_dasher,
    )
    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("butter churn 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)
A tall staved oak butter churn narrowing toward the top, bound by three dark iron hoops, with a two-board lid and a pale maple dasher handle rising through it.