Examples and Showcase
GitHub

traffic-cones

A procedural roadworks set on a cut-out patch of asphalt — a broken-edged asphalt slab with a worn white road line and two sealed tar cracks; seven 720 mm PVC traffic cones, each a hollow lathed body with a moulded bead and a rolled lip round an open tip, bonded into a square rubber base with chamfered corners, a raked top, a collar, four moulded lugs and a recess underneath, and hooped with 4 in and 6 in retroreflective collars; three standing in a taper line, three nested in a stack whose wall thickness sets the nesting pitch, and one knocked over onto the edge of its base and the lip of its tip, its roll solved so both touch; a folding A-frame barricade of four square steel legs hinged in pairs on pivot bolts, spread by pinned straps on spacers, each foot in a rubber pad, carrying two boards on each face split on 45-degree lines into orange and white retroreflective stripes and bolted to the legs; and a warning lamp with amber fresnel lenses on a bracket bolted to the top board — through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

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

witnesses Recomputed: 45816 tris, eleven materials with 6048 cone PVC, 4088 reflective, 8096 rubber, 596 asphalt, 56 road paint, 496 tar, 64 orange sheeting, 384 board, 2912 steel, 720 black plastic and 696 lens faces, UVs in 0..1 with zero AABB overlap, outer AABB 3.037×2.037×1.335 m, zmin 0, hygiene 0 including zero coplanar cross-shell pairs, all 10 supports (five cone bases, the fallen cone's body and four barricade pads) 1.5 mm into the slab (band 0.8–3.0 mm), 4 boards biting their legs 1.5 mm at all 8 joints (band 0.8–3.0 mm), 14 collars seated 0.600 mm into their bodies and 1.400 mm proud, 6 upright cones 0.0000° off plumb at 0.720 m on bases 0.356 m across the flats, the barricade's 4 legs mirrored within 0.0001 mm, one stack of 3 at a 0.036 m pitch with each inner wall bearing 0.847–1.105 mm on the wall below (band 0.4–2.0 mm), one connected assembly of 125 shells, LOD ratios in band, collider 493 tris, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z 16; --float-cone 16; --gap-board 17; --float-band 18; --lean-cone 19; --skew-leg 19; --loose-stack 20; --loose-lamp 21.

category Vehicles

tags mesh export showcase

blender --background --python showcase/traffic-cones/traffic_cones.py --

A showcase piece, not an example, and the seventh in the vehicles category: things that direct movement. It builds a roadworks set standing on a cut-out patch of asphalt:

Everything that stands on the patch bears 1.5 mm into it: every base, the fallen cone's base and body, and the barricade's pads. Boards bear 1.5 mm into the legs they are bolted to; collars bear 0.6 mm into their bodies and stand 1.4 mm proud of them.

The stack is the piece's own invariant. A cone's wall is thick enough, for the slope of its body, that the next cone down seats on it one nesting pitch lower: WALL_H = BODY_SLOPE × STACK_PITCH + NEST_BITE. So each upper cone's inner wall bears 1 mm on the lower cone's outer wall along the whole overlap, and the bases hang 36 mm apart with the lower collar inside the upper recess (6 mm clear above it, 16 mm around it).

It asserts budget conformance of the generated result. It does not witness an API contract. "It rendered without error" is not a check.

Composes skills mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, and snippets bake_normal_high_to_low.py, setup_bake_target_image.py, lod_chain.py / decimate_to_budget.py, convex_hull_collider.py, export_preset_unity.py (helpers copied inline, not imported).

Intended size: the patch is 3.04 × 2.04 m with its jittered edge; the lamp housing sets the top at 1.335 m. A cone is 0.720 m tall on a base 0.356 m across the flats; the barricade's hinges stand 1.0 m over the slab. The origin is under the patch centre at the slab's underside.

Budgets#

Every budget is declared as a named constant. Every gate recomputes its value from the mesh, materials, UVs, evaluated LOD, collider, or export file.

AxisDeclaredMeasured (5.2.1)
Base triangles45300–4640045816
LOD1 ratio0.32–0.62 of base0.5000
LOD2 ratio0.10–0.35 of base0.2200
Materialsexactly 11 distinct; ≥5560 cone PVC, ≥3760 reflective, ≥7440 rubber, ≥540 asphalt, ≥50 road paint, ≥450 tar, ≥58 orange sheeting, ≥350 board plastic, ≥2670 steel, ≥660 black plastic, ≥640 lens faces11 slots; 6048 / 4088 / 8096 / 596 / 56 / 496 / 64 / 384 / 2912 / 720 / 696
UVsin 0..1, AABB overlap ≤ 1e-5in range, overlap 0
Outer AABB(3.0365, 2.0374, 1.3354) m ± 0.01, read off the vertices(3.0365, 2.0374, 1.3354), zmin 0
Collider tris≤ 540493
Exportwritten, size > 0, removed after measuring3335792 bytes

Every falsifier leaves the triangle count at 45816 and the envelope at (3.0365, 2.0374, 1.3354): they move parts, never add or remove them.

DECIMATE COLLAPSE triangle counts are not identical across Blender series, so the LOD gate is a ratio band, not an exact count. Bake pixels are stochastic, so the bake gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG; two default runs print identical measurements, and 4.5.11 and 5.1.2 print the same measurements as 5.2.1 (the export differs by 28 bytes).

Hygiene#

Recomputed from the generated mesh, not asserted about the script.

AxisDeclaredMeasured
Non-manifold edges00
Loose verts / edges0 / 00 / 0
Doubles merged at 1e-500
Zero-area faces00
N-gons00
Coplanar cross-shell face pairs (KD range 0.05 m, plane ε 1e-4)00
Grounded: zminwithin 1e-4 of 00.0000
Supports: every standing base, the stack's bottom base, the fallen cone's base and body, and the four pads, each against the slab top read off the asphalt10 supports; sink 0.8–3.0 mm10; 1.5 mm each

The first draft measured 205 coplanar pairs, none of them in a part:

Stance, joints, collars, size and assembly#

AxisDeclaredMeasured
Boards into legs: each board's back face against the front face of each leg it is bolted to, both planes read off the faces and compared along the board's normal4 boards, 8 joints; bite 0.8–3.0 mm4, 8; 1.5 mm each
Collar seat: per collar, per angular segment, the innermost vertex against the host body's outer wall (a ray toward the axis along the vertex's own radial), and the outermost vertex's stand-off14 collars, each on a host; seat 0.2–1.5 mm; proud ≥ 1.0 mm14; 0.600 mm; 1.400 mm
Plumb and size: each upright body's bottom-slab against top-slab centroid; its top over its base's bottom; the base's width across the flats (support function about the axis)6 upright cones; tilt ≤ 0.2°; 0.720 m and 0.356 m ± 3 mm6; 0.0000°; 0.720; 0.356
Mirror: the barricade's four legs paired across the boards' centre, extents compared4 legs; ≤ 1 mm4; 0.0001 mm
Nesting: coaxial upright bodies form stacks; per pair, the pitch between body bottoms and, by rays at 40 mm stations and 16 angles along the overlap, the lower's outer wall less the upper's inner wall1 stack of 3; pitch 0.030–0.042 m; bite 0.4–2.0 mm1 of 3; 0.036, 0.036; 0.847–0.949 and 1.034–1.105 mm
One connected assembly (union of shells whose BVH trees overlap)1 component1 (125 shells)

The nesting bite varies round each pair because the stacked cones are yawed differently, so a lower facet meets an upper facet off its middle; the chord sag of a 48-segment body is 0.29 mm at the bottom.

Falsifiers#

Each falsifier violates one named budget. Every one was run on 5.2.1 and 4.5.11 and exited its declared code, with the triangle count and envelope unchanged and every budget checked before the target green.

FlagBudget violatedExit
--skip-decimateLOD1 ratio band (measured 1.0000)9
--stray-vertloose vertex count is 0 (measured 1, placed inside the envelope)15
--lift-zbounding box zmin is 0 (measured 0.05000)16
--float-coneevery support bedded in the slab (the right-hand standing cone lifted 4 mm: sink −2.5 mm, the rest 1.5 mm)16
--gap-boardboards bite their legs (the lower front board 3 mm off its legs: −1.5 mm at both)17
--float-bandcollar seat (the left cone's 6 in collar 2 mm out: seat −1.4 mm)18
--lean-coneplumb (the middle standing cone's body tipped 1.5° in its base: tilt 1.431°)19
--skew-legleg mirror symmetry (the right front leg 2.5 mm toward the centre: 2.500 mm)19
--loose-stacknesting (the top cone lifted 10 mm: pitch 0.046, bite −0.576 to −0.505 mm)20
--loose-lampone connected assembly (the lamp 3 mm off its board: 2 components, 9 and 116 shells)21

--float-cone lifts a whole cone while the slab still grounds the box. --lean-cone tips the body and its collars about the collar top and leaves the base flat, so the supports hold; its measured tilt is a little under 1.5° because the slabs it compares are cut level through a leaning body. --skew-leg moves the front leg inward, into the 3 mm gap its washer fills, so it stays clear of its strap and its board's bite is unchanged. --loose-stack first lifted the top cone 12 mm, which put its base bottom on the middle cone's collar-top plane and exited 15 on 192 coplanar pairs; 10 mm keeps every horizontal plane of the two cones at least 2 mm apart. --loose-lamp pulls the lamp level off the board rather than along the board's normal, which had lifted the envelope's top 0.8 mm.

Run#

blender --background --python traffic_cones.py --
blender --background --python traffic_cones.py -- --skip-decimate
blender --background --python traffic_cones.py -- --stray-vert
blender --background --python traffic_cones.py -- --lift-z
blender --background --python traffic_cones.py -- --float-cone
blender --background --python traffic_cones.py -- --gap-board
blender --background --python traffic_cones.py -- --float-band
blender --background --python traffic_cones.py -- --lean-cone
blender --background --python traffic_cones.py -- --skew-leg
blender --background --python traffic_cones.py -- --loose-stack
blender --background --python traffic_cones.py -- --loose-lamp
blender --background --python traffic_cones.py -- --output cones.png

Smoke passes no flags.

The hero looks from the front left and above, so the taper line of cones reads in front of the barricade, the stack's three bases show at the left and the fallen cone's open base and recess face the lens. The wall stands 6 m behind the patch, and the warm wedge pools on it to the right. Default stage; no deviation.

Shading#

The cone bodies, bases, lens, housing and every lathe are smooth-shaded; chamfers, the patch's rims and every material boundary stay crisp through sharp edges above 35°. The stripes are cut into the board's own faces, so their boundaries are hard edges on a flat face and cannot z-fight. Every cone, board and leg carries a PartTone face attribute that the shaders read to move each part between two tones, so no two cones are the same orange. Grime rises from the slab on every part; scuffs are sparse and small. The cone PVC and its collars also carry level rub marks, black on the orange and grey on the sheeting: a noise squashed sixteen-fold in height, so each mark runs round the cone like a tyre or boot rub rather than up it. The steel is galvanised, grey and rough, with a studio reflection term so it does not read as plastic on a dark stage.

Exit codes#

File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path. 15–19 are the hygiene and joint-fit family. 20 and 21 are file-local. 22 is the asset-quality floor on the render path: check_asset_quality returns 11, which this piece already spends on the collider ceiling, so the call site remaps it.

CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Mesh did not build / no UV layer
4Base triangle count outside range
5Material count ≠ 11 distinct slots, or a face-count floor missed
6UVs outside 0..1
7UV AABB overlap above tolerance
8World AABB off declared outer size
9LOD ratio band (--skip-decimate lands here)
10Framing gate (render path only)
11Collider triangle count above ceiling
12Bake did not finish or image has no data
13Export file missing or empty
14--output produced no file
15Mesh hygiene: loose, non-manifold, zero-area, doubles, n-gons, coplanar cross-shell pairs
16Not grounded: bounding box zmin off 0, or a support outside its sink band, or not 10 supports (--lift-z, --float-cone)
17A board's bite into a leg outside its band, or not 4 boards and 8 joints (--gap-board)
18A collar's seat or stand-off outside its band, or a collar with no host, or not 14 collars (--float-band)
19A cone not plumb, its height or base width off, or the legs not mirror-symmetric, or not 6 upright cones and 4 legs (--lean-cone, --skew-leg)
20Nesting: not one stack of 3, or a pitch or wall bite outside its band (--loose-stack)
21Assembly splits into more than one connected component (--loose-lamp)
22Asset-quality floor (render path only; remapped from 11)

Source

showcase/traffic-cones/traffic_cones.py 2256 lines · View on GitHub →
"""Game-ready traffic cone and barrier set — a showcase piece, not an example.

Asserts budget conformance of a procedural roadworks set after composing
shipped pipeline pieces: bmesh construction, UVs, eleven materials,
high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

A patch of asphalt, broken at its edges, carries a worn white road line and
two sealed cracks. On it stand three 720 mm PVC traffic cones in a taper
line, a stack of three cones nested one inside the next, and a fourth cone
knocked over onto its side. Every cone is a moulded body (a hollow frustum
with a bead above the collar and a rolled lip round an open tip) bonded into
a square rubber base with chamfered corners, a raked top, a collar, four
moulded lugs and a recess underneath, and two retroreflective collar bands.
Behind them stands a folding A-frame barricade: four square steel legs hinged
in pairs on pivot bolts, spread by straps on spacer pins, each foot in a
rubber pad; two striped boards on each face, bolted to the legs; and a
warning lamp on a bracket at one end of the top board.

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-cone`` every support bedded
on the slab, ``--gap-board`` the boards' bite into the legs,
``--float-band`` the collar bands' seat, ``--lean-cone`` and
``--skew-leg`` plumb, size and mirror symmetry, ``--loose-stack`` the
nesting of the stack, ``--loose-lamp`` one connected assembly.

No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
are not byte-identical across Blender versions — the LOD gate is a
ratio band, not an exact count.

    blender --background --python traffic_cones.py --
    blender --background --python traffic_cones.py -- --skip-decimate
    blender --background --python traffic_cones.py -- --output cones.png
"""
import argparse
import math
import os
import sys
import tempfile
import traceback

import bmesh
import bpy
import numpy as np
from mathutils import Matrix, Vector
from mathutils.bvhtree import BVHTree
from mathutils.kdtree import KDTree

# Showcase lives at repo-root/showcase/, not under examples/. The framing
# helper is the repo's only shared import and lives next to the examples;
# resolve the repo root so we do not move gallery_framing.py.
_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
import gallery_asset_quality  # noqa: E402

# --- Asphalt patch ------------------------------------------------------------
SLAB_T = 0.060
SLAB_HX = 1.50
SLAB_HY = 1.00
SLAB_CORNER = 0.16
SLAB_PERIM = 120
SLAB_JITTER = 0.024          # broken edge, closed-form
SLAB_TOP_CH = 0.010
SLAB_BOT_CH = 0.004
SEAT = 0.0015                # every support bears this far into the slab
LINE_Y = -0.84               # worn white road line
LINE_HALF_W = 0.050
LINE_END_CLEAR = 0.030
PAINT_TOP = 0.0010
PAINT_SINK = 0.0020
TAR_HALF_W = 0.011           # sealed cracks
TAR_TOP = 0.0008
TAR_SINK = 0.0024

# --- Cone (local frame: base bottom at z=0, axis +Z) --------------------------
CONE_H = 0.720               # 28 in cone
BASE_HALF = 0.178            # 14 in square base
BASE_CUT = 0.046             # chamfered corners
BASE_EDGE_T = 0.022
BASE_CH = 0.004
BASE_TOP_R = 0.165           # raked top meets the collar here
BASE_TOP_Z = 0.028
COLLAR_R = 0.150
COLLAR_Z = 0.044
COLLAR_TOP = 0.048
RECESS_R = 0.166             # moulded recess under the base: takes the collar below
RECESS_H = 0.018
BODY_BITE = 0.0015           # base hole bonded this far inside the body's wall
BODY_Z0 = 0.024
BODY_R0 = 0.137              # body outer radius at the collar top
BODY_R1 = 0.032              # ... at the top of the straight wall
BODY_TOP = 0.700
BODY_SLOPE = (BODY_R0 - BODY_R1) / (BODY_TOP - COLLAR_TOP)
STACK_PITCH = 0.036          # nesting pitch: set by the wall, not by the bases
NEST_BITE = 0.0010           # an upper cone's inner wall bears this far on the lower's outer wall
WALL_H = BODY_SLOPE * STACK_PITCH + NEST_BITE
BEAD = 0.0015
LIP = 0.0025
BODY_SEGS = 48
BAND_Z = ((0.326, 0.428), (0.479, 0.631))   # 4 in and 6 in collars
BAND_T = 0.0014
BAND_CH = 0.0006
BAND_BITE = 0.0006
LUG_RHO = 0.190
LUG_R = 0.016

STANDING = ((-0.74, -0.30, 12.0), (-0.12, -0.50, -7.0), (1.02, 0.34, 21.0))
STACK_XY = (-1.06, 0.44)
STACK_YAW = (3.1, -8.7, 14.9)  # never a multiple of the base's angle step apart
TIPPED_XY = (0.52, -0.46)    # base centre in plan
TIPPED_YAW = 126.87          # tip toward the back right: its base faces the lens
CONE_TONES = (0.15, 0.85, 0.45, 0.30, 0.65, 0.95, 0.05)

# --- Barricade ---------------------------------------------------------------
BAR_X = 0.10
BAR_Y = 0.46
LEG_X = 0.520                # hinge stations at BAR_X +- LEG_X
LEG_SPLIT = 0.0175           # leg centres off the hinge station: 3 mm between the pair
LEG_HALF = 0.016             # 32 mm square tube
LEG_RC = 0.003
HINGE_Z = 1.000
FOOT_Y = 0.300
LEG_OVER = 0.045
PAD_HX = 0.042
PAD_HY = 0.078
PAD_T = 0.016
LEG_IN_PAD = 0.007
BOARD_L = 1.240
BOARD_H = 0.200
BOARD_T = 0.018
BOARD_RC = 0.004
BOARD_Z = (0.800, 0.500)     # board centres, height above the slab, on the leg
BOARD_BITE = 0.0015
STRIPE_W = 0.100
STRIPE_PHASE = 0.0371
BOLT_V = 0.060
BOLT_SINK = 0.0008
STRAP_Z = 0.300
STRAP_W = 0.028
STRAP_T = 0.004
STRAP_GAP = 0.004
# lamp on a bracket at the left end of the top front board
LAMP_DX = 0.105
BR_T = 0.005
BR_HALF_W = 0.020
BR_BITE = 0.0008
BOX_H = 0.120
HOUSING_R = 0.098

# --- Falsifier sizes ---------------------------------------------------------
FLOAT_CONE = 0.004           # --float-cone: standing cone 2
GAP_BOARD = 0.003            # --gap-board: lower front board
FLOAT_BAND = 0.002           # --float-band: cone 0's upper band
LEAN_DEG = 1.5               # --lean-cone: cone 1's body in its base
SKEW_LEG = 0.0025            # --skew-leg: right front leg toward the centre
LOOSE_STACK = 0.010          # --loose-stack: the top cone of the stack (12 mm put its
                             # base bottom on the middle cone's collar-top plane)
LOOSE_LAMP = 0.003           # --loose-lamp: lamp off its board

BBOX_TOL = 0.01
# Fitted after locking geometry. Recomputed from the vertices.
OUTER_SIZE = (3.0365, 2.0374, 1.3354)
BASE_TRIS_MIN = 45300
BASE_TRIS_MAX = 46400
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 = 11
UV_EPS = 1e-4
UV_OVERLAP_MAX = 1e-5
COLLIDER_TRIS_MAX = 540
BAKE_RES = 1024
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
# Supports: every standing base, the stack's bottom base, the fallen cone's
# base and body, and the four barricade pads bear into the slab in a band.
SUPPORT_COUNT = 10
SINK_MIN = 0.0008
SINK_MAX = 0.0030
# Boards bite the legs they are bolted to.
BOARD_COUNT = 4
BOARD_LEG_JOINTS = 8
BOARD_BITE_MIN = 0.0008
BOARD_BITE_MAX = 0.0030
# Collar bands: inner face seated into the body, outer face proud of it.
BANDS = 14
BAND_SEAT_MIN = 0.0002
BAND_SEAT_MAX = 0.0015
BAND_PROUD_MIN = 0.0010
# Plumb and real-world size; the barricade's legs mirror about its centre.
PLUMB_CONES = 6
PLUMB_MAX_DEG = 0.2
CONE_H_TOL = 0.003
BASE_W = 2.0 * BASE_HALF
BASE_W_TOL = 0.003
LEGS = 4
MIRROR_EPS = 0.001
# Nesting: one stack of three, each on the one below at the nesting pitch.
STACK_N = 3
PITCH_MIN = 0.030
PITCH_MAX = 0.042
NEST_MIN = 0.0004
NEST_MAX = 0.0020

HERO_YAW_DEG = 0.0
CAM_VIEW = (-0.42, -0.91)
CAM_DIST = 5.9
CAM_LENS = 50.0
CAM_LIFT = 2.7
AIM_OFFSET = (0.0, 0.0, -0.32)
WALL_Y = 6.0

CONE_IDX = 0
REFLECT_IDX = 1
RUBBER_IDX = 2
ASPHALT_IDX = 3
PAINT_IDX = 4
TAR_IDX = 5
SHEETING_IDX = 6
BOARD_IDX = 7
STEEL_IDX = 8
PLASTIC_IDX = 9
LENS_IDX = 10

FACE_FLOORS = {
    CONE_IDX: 5560, REFLECT_IDX: 3760, RUBBER_IDX: 7440, ASPHALT_IDX: 540, PAINT_IDX: 50,
    TAR_IDX: 450, SHEETING_IDX: 58, BOARD_IDX: 350, STEEL_IDX: 2670, PLASTIC_IDX: 660,
    LENS_IDX: 640,
}
MAT_LABELS = ("cone PVC", "reflective", "rubber", "asphalt", "road paint", "tar",
              "sheeting", "board", "steel", "plastic", "lens")

X = Vector((1.0, 0.0, 0.0))
Y = Vector((0.0, 1.0, 0.0))
Z = Vector((0.0, 0.0, 1.0))
TONE = "PartTone"


def eevee_engine_id():
    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"


def fail(msg, code):
    print(f"ERROR: {msg}", file=sys.stderr)
    return code


def triangle_count(mesh):
    mesh.calc_loop_triangles()
    return len(mesh.loop_triangles)


def evaluated_triangle_count(obj):
    # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package).
    depsgraph = bpy.context.evaluated_depsgraph_get()
    eval_obj = obj.evaluated_get(depsgraph)
    eval_mesh = eval_obj.to_mesh()
    try:
        eval_mesh.calc_loop_triangles()
        return len(eval_mesh.loop_triangles)
    finally:
        eval_obj.to_mesh_clear()


# --------------------------------------------------------------------------
# Construction helpers
# --------------------------------------------------------------------------

def _mark(faces, mat_idx):
    for f in faces:
        f.material_index = mat_idx
    return faces


def _tone(bm, faces, t):
    layer = bm.faces.layers.float.get(TONE)
    for f in faces:
        f[layer] = t


def faces_of(verts):
    return {f for v in verts for f in v.link_faces}


def frame(ez, ex_hint):
    """Rotation whose local Z is ``ez`` and local X is ``ex_hint`` made
    orthogonal to it (columns ex, ey, ez)."""
    ez = Vector(ez).normalized()
    ex = Vector(ex_hint)
    ex = (ex - ez * ex.dot(ez)).normalized()
    ey = ez.cross(ex)
    return Matrix((ex, ey, ez)).transposed()


def m4(rot3, loc):
    m = rot3.to_4x4()
    m.translation = Vector(loc)
    return m


def add_lathe(bm, profile, segs, mat_idx, center=(0.0, 0.0, 0.0), rot=None,
              phase=0.0, solid=False):
    """Revolve a profile [(r, z), ...] about local Z. ``solid``: an open
    polyline closed by n-gon caps; otherwise a closed polygon revolved into a
    ring shell."""
    c = Vector(center)
    m = rot if rot is not None else Matrix.Identity(3)
    rings = []
    for i in range(segs):
        a = phase + 2.0 * math.pi * i / segs
        ca, sa = math.cos(a), math.sin(a)
        rings.append([bm.verts.new(c + m @ Vector((r * ca, r * sa, z))) for r, z in profile])
    n = len(profile)
    last = n - 1 if solid else n
    faces = []
    for i in range(segs):
        r0, r1 = rings[i], rings[(i + 1) % segs]
        for j in range(last):
            k = (j + 1) % n
            faces.append(bm.faces.new((r0[j], r1[j], r1[k], r0[k])))
    if solid:
        faces.append(bm.faces.new([rings[i][0] for i in reversed(range(segs))]))
        faces.append(bm.faces.new([rings[i][n - 1] for i in range(segs)]))
    _mark(faces, mat_idx)
    return [v for ring in rings for v in ring]


def revolve(bm, profile, segs, mat_idx, M, phase=0.0):
    """Closed (r, z) polygon revolved about local Z, placed by the 4x4 ``M``."""
    rings = []
    for i in range(segs):
        a = phase + 2.0 * math.pi * i / segs
        ca, sa = math.cos(a), math.sin(a)
        rings.append([bm.verts.new(M @ Vector((r * ca, r * sa, z))) for r, z in profile])
    n = len(profile)
    faces = []
    for i in range(segs):
        r0, r1 = rings[i], rings[(i + 1) % segs]
        for j in range(n):
            k = (j + 1) % n
            faces.append(bm.faces.new((r0[j], r1[j], r1[k], r0[k])))
    return _mark(faces, mat_idx)


def loft_loops(bm, loops, mat_idx, M):
    """Closed loops of equal length joined in a closed ring of loops (a torus)."""
    vs = [[bm.verts.new(M @ Vector(p)) for p in loop] for loop in loops]
    n, k = len(vs), len(vs[0])
    faces = []
    for j in range(n):
        a, b = vs[j], vs[(j + 1) % n]
        for i in range(k):
            i2 = (i + 1) % k
            faces.append(bm.faces.new((a[i], a[i2], b[i2], b[i])))
    return _mark(faces, mat_idx)


def rrect(ha, hb, rc, n_corner=4):
    """Rounded rectangle loop (counter-clockwise)."""
    rc = max(min(rc, ha - 1e-4, hb - 1e-4), 0.0006)
    pts = []
    for k, (sx, sy) in enumerate(((1, 1), (-1, 1), (-1, -1), (1, -1))):
        cx, cy = sx * (ha - rc), sy * (hb - rc)
        a0 = 0.5 * math.pi * k
        for s in range(n_corner + 1):
            a = a0 + 0.5 * math.pi * s / n_corner
            pts.append((cx + rc * math.cos(a), cy + rc * math.sin(a)))
    return pts


def add_rbox(bm, ha, hb, rc, profile, origin, rot, mat_idx, n_corner=4):
    """Loft of rounded rectangles along local Z: profile [(inset, z)], each
    loop inset from (ha, hb, rc); n-gon caps at both ends."""
    o = Vector(origin)
    rings = []
    for inset, z in profile:
        loop = rrect(ha - inset, hb - inset, rc - inset, n_corner)
        rings.append([bm.verts.new(o + rot @ Vector((x, y, z))) for x, y in loop])
    n = len(rings[0])
    faces = []
    for r0, r1 in zip(rings, rings[1:]):
        for k in range(n):
            m = (k + 1) % n
            faces.append(bm.faces.new((r0[k], r0[m], r1[m], r1[k])))
    faces.append(bm.faces.new(tuple(reversed(rings[0]))))
    faces.append(bm.faces.new(tuple(rings[-1])))
    _mark(faces, mat_idx)
    return [v for ring in rings for v in ring]


def add_prism(bm, outline, w0, w1, origin, rot, mat_idx):
    """Planar outline [(u, v)] extruded along local Z from w0 to w1."""
    o = Vector(origin)
    a = [bm.verts.new(o + rot @ Vector((u, v, w0))) for u, v in outline]
    b = [bm.verts.new(o + rot @ Vector((u, v, w1))) for u, v in outline]
    n = len(outline)
    faces = [bm.faces.new((a[i], a[(i + 1) % n], b[(i + 1) % n], b[i])) for i in range(n)]
    faces.append(bm.faces.new(tuple(reversed(a))))
    faces.append(bm.faces.new(tuple(b)))
    _mark(faces, mat_idx)
    return a + b


def fillet_path(pts, rf, steps=4):
    pts = [Vector(p) for p in pts]
    out = [pts[0]]
    for i in range(1, len(pts) - 1):
        a, p, b = pts[i - 1], pts[i], pts[i + 1]
        r = min(rf, (a - p).length * 0.45, (b - p).length * 0.45)
        p0 = p + (a - p).normalized() * r
        p1 = p + (b - p).normalized() * r
        for k in range(steps + 1):
            t = k / steps
            out.append((1 - t) ** 2 * p0 + 2 * (1 - t) * t * p + t * t * p1)
    out.append(pts[-1])
    return out


def add_bar(bm, pts, wax, half_w, half_t, rc, mat_idx, fillet=0.008):
    """Flat bar bent in the plane normal to ``wax``: its width lies along
    ``wax``, its thickness in the bending plane; rounded-rectangle section."""
    pts = fillet_path(pts, fillet)
    wax = Vector(wax).normalized()
    sec = rrect(half_w, half_t, rc, 2)
    rings = []
    for i, p in enumerate(pts):
        a = pts[max(i - 1, 0)]
        b = pts[min(i + 1, len(pts) - 1)]
        t = (b - a).normalized()
        w = (wax - t * wax.dot(t)).normalized()
        th = t.cross(w)
        rings.append([bm.verts.new(p + w * x + th * y) for x, y in sec])
    n = len(sec)
    faces = []
    for r0, r1 in zip(rings, rings[1:]):
        for k in range(n):
            m = (k + 1) % n
            faces.append(bm.faces.new((r0[k], r0[m], r1[m], r1[k])))
    faces.append(bm.faces.new(tuple(reversed(rings[0]))))
    faces.append(bm.faces.new(tuple(rings[-1])))
    _mark(faces, mat_idx)
    return [v for ring in rings for v in ring]


def add_ribbon(bm, pts2d, half_w, z0, z1, mat_idx):
    """A flat strip laid along a plan polyline: a six-point section with its
    top corners chamfered, swept with capped ends."""
    pts = [Vector((x, y)) for x, y in pts2d]
    sec = [(-half_w, z0), (half_w, z0), (half_w, z1 - 0.0008), (half_w * 0.55, z1),
           (-half_w * 0.55, z1), (-half_w, z1 - 0.0008)]
    rings = []
    for i, p in enumerate(pts):
        a = pts[max(i - 1, 0)]
        b = pts[min(i + 1, len(pts) - 1)]
        t = (b - a).normalized()
        nrm = Vector((-t.y, t.x))
        rings.append([bm.verts.new((p.x + nrm.x * s, p.y + nrm.y * s, z)) for s, z in sec])
    n = len(sec)
    faces = []
    for r0, r1 in zip(rings, rings[1:]):
        for k in range(n):
            m = (k + 1) % n
            faces.append(bm.faces.new((r0[k], r0[m], r1[m], r1[k])))
    faces.append(bm.faces.new(tuple(reversed(rings[0]))))
    faces.append(bm.faces.new(tuple(rings[-1])))
    return _mark(faces, mat_idx)


def hull2d(pts):
    """Convex hull, counter-clockwise (monotone chain)."""
    pts = sorted(set((round(x, 9), round(z, 9)) for x, z in 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) <= 1e-12:
            lower.pop()
        lower.append(p)
    for p in reversed(pts):
        while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 1e-12:
            upper.pop()
        upper.append(p)
    return lower[:-1] + upper[:-1]


def lug_outline(circles, n=16):
    pts = []
    for u, v, r in circles:
        for k in range(n):
            a = 2.0 * math.pi * (k + 0.5) / n
            pts.append((u + r * math.cos(a), v + r * math.sin(a)))
    return hull2d(pts)


def merge_bm(dst, src, M):
    """Copy every face of ``src`` into ``dst`` through the 4x4 ``M``, keeping
    material index and tone."""
    sl = src.faces.layers.float.get(TONE)
    dl = dst.faces.layers.float.get(TONE)
    vmap = {v: dst.verts.new(M @ v.co) for v in src.verts}
    out = []
    for f in src.faces:
        nf = dst.faces.new([vmap[v] for v in f.verts])
        nf.material_index = f.material_index
        nf[dl] = f[sl]
        out.append(nf)
    return out


def triangulate_ngons(bm):
    faces = [f for f in bm.faces if len(f.verts) > 4]
    if faces:
        bmesh.ops.triangulate(bm, faces=faces)


def pack_uvs(bm, margin=0.08):
    uv = bm.loops.layers.uv.new("UVMap")
    faces = list(bm.faces)
    n = len(faces)
    cols = max(1, math.ceil(math.sqrt(n)))
    rows = max(1, math.ceil(n / cols))
    cell_w = 1.0 / cols
    cell_h = 1.0 / rows
    pad_u = margin * cell_w * 0.5
    pad_v = margin * cell_h * 0.5
    usable_w = cell_w - 2.0 * pad_u
    usable_h = cell_h - 2.0 * pad_v
    for i, face in enumerate(faces):
        col = i % cols
        row = i // cols
        nrm = face.normal
        ax, ay, az = abs(nrm.x), abs(nrm.y), abs(nrm.z)
        coords = []
        for loop in face.loops:
            co = loop.vert.co
            if az >= ax and az >= ay:
                coords.append((co.x, co.y))
            elif ax >= ay:
                coords.append((co.y, co.z))
            else:
                coords.append((co.x, co.z))
        xs = [c[0] for c in coords]
        ys = [c[1] for c in coords]
        minx, maxx = min(xs), max(xs)
        miny, maxy = min(ys), max(ys)
        dx = max(maxx - minx, 1e-8)
        dy = max(maxy - miny, 1e-8)
        origin_u = col * cell_w + pad_u
        origin_v = row * cell_h + pad_v
        for loop, (x, y) in zip(face.loops, coords):
            loop[uv].uv = (
                origin_u + (x - minx) / dx * usable_w,
                origin_v + (y - miny) / dy * usable_h,
            )


# --------------------------------------------------------------------------
# The asphalt patch
# --------------------------------------------------------------------------

def slab_outline():
    """Rounded rectangle resampled evenly and pushed along its normal by a
    closed-form jitter: the broken edge of a cut-out patch of road."""
    loop = [Vector(p) for p in rrect(SLAB_HX, SLAB_HY, SLAB_CORNER, 10)]
    seg = [(loop[i], loop[(i + 1) % len(loop)]) for i in range(len(loop))]
    total = sum((b - a).length for a, b in seg)
    out = []
    for k in range(SLAB_PERIM):
        target = total * k / SLAB_PERIM
        acc = 0.0
        for a, b in seg:
            ln = (b - a).length
            if acc + ln >= target:
                out.append(a + (b - a) * ((target - acc) / ln))
                break
            acc += ln
    pts = []
    for k, p in enumerate(out):
        a = out[k - 1]
        b = out[(k + 1) % len(out)]
        t = (b - a).normalized()
        nrm = Vector((t.y, -t.x))
        s = k / SLAB_PERIM
        j = SLAB_JITTER * (0.50 * math.sin(2 * math.pi * 3 * s + 0.4)
                           + 0.30 * math.sin(2 * math.pi * 7 * s + 1.3)
                           + 0.20 * math.sin(2 * math.pi * 19 * s + 2.1))
        pts.append((p + nrm * j, nrm))
    return pts


def inset_ring(outline, inset):
    # the normal at each point is an average of neighbours; re-derive it so
    # the inset follows the jittered edge
    base = [p for p, _n in outline]
    out = []
    for k, p in enumerate(base):
        t = (base[(k + 1) % len(base)] - base[k - 1]).normalized()
        nrm = Vector((t.y, -t.x))
        out.append(p - nrm * inset)
    return out


def slab_top_polygon():
    return inset_ring(slab_outline(), SLAB_TOP_CH)


def x_range_at(poly, y):
    xs = []
    n = len(poly)
    for k in range(n):
        a, b = poly[k], poly[(k + 1) % n]
        if (a.y - y) * (b.y - y) < 0.0:
            t = (y - a.y) / (b.y - a.y)
            xs.append(a.x + (b.x - a.x) * t)
    return min(xs), max(xs)


def build_slab(bm):
    outline = slab_outline()
    rings_2d = [(inset_ring(outline, SLAB_BOT_CH), 0.0), (inset_ring(outline, 0.0), SLAB_BOT_CH),
                (inset_ring(outline, 0.0), SLAB_T - SLAB_TOP_CH), (inset_ring(outline, SLAB_TOP_CH), SLAB_T)]
    rings = [[bm.verts.new((p.x, p.y, z)) for p in ring] for ring, z in rings_2d]
    n = len(rings[0])
    faces = []
    for r0, r1 in zip(rings, rings[1:]):
        for k in range(n):
            m = (k + 1) % n
            faces.append(bm.faces.new((r0[k], r0[m], r1[m], r1[k])))
    faces.append(bm.faces.new(tuple(reversed(rings[0]))))
    faces.append(bm.faces.new(tuple(rings[-1])))
    _mark(faces, ASPHALT_IDX)
    _tone(bm, faces, 0.5)

    # the road line runs to within a clearance of the patch's broken edge
    top = slab_top_polygon()
    lo0, hi0 = x_range_at(top, LINE_Y - LINE_HALF_W)
    lo1, hi1 = x_range_at(top, LINE_Y + LINE_HALF_W)
    x0 = max(lo0, lo1) + LINE_END_CLEAR
    x1 = min(hi0, hi1) - LINE_END_CLEAR
    line = [(x0 + (x1 - x0) * k / 8, LINE_Y) for k in range(9)]
    _tone(bm, add_ribbon(bm, line, LINE_HALF_W, SLAB_T - PAINT_SINK, SLAB_T + PAINT_TOP,
                         PAINT_IDX), 0.5)

    # two sealed cracks, closed-form meanders
    crack_a = []
    for k in range(41):
        t = k / 40
        x = -1.28 + 1.10 * t
        y = -0.60 + 0.07 * math.sin(2.3 * x + 0.4) + 0.025 * math.sin(7.9 * x + 1.1) + 0.10 * t
        crack_a.append((x, y))
    crack_b = []
    for k in range(41):
        t = k / 40
        crack_b.append((0.98 + 0.34 * t + 0.030 * math.sin(9.0 * t + 0.3),
                        -0.66 + 0.30 * t + 0.022 * math.sin(13.0 * t + 1.7)))
    for path, tone in ((crack_a, 0.3), (crack_b, 0.7)):
        _tone(bm, add_ribbon(bm, path, TAR_HALF_W, SLAB_T - TAR_SINK, SLAB_T + TAR_TOP, TAR_IDX),
              tone)


# --------------------------------------------------------------------------
# Traffic cone
# --------------------------------------------------------------------------

def r_out(z):
    return BODY_R0 - BODY_SLOPE * (z - COLLAR_TOP)


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


def base_angles():
    """Uniform angles plus the eight corners of the chamfered square, so every
    square ring carries its corners exactly and every round ring follows."""
    a, c = BASE_HALF, BASE_CUT
    t1 = math.atan2(a - c, a)
    corners = []
    for q in range(4):
        corners += [t1 + q * 0.5 * math.pi, 0.5 * math.pi - t1 + q * 0.5 * math.pi]
    uni = [2.0 * math.pi * k / 56 for k in range(56)]
    keep = [u for u in uni
            if all(abs(((u - cc + math.pi) % (2 * math.pi)) - math.pi) > math.radians(2.0)
                   for cc in corners)]
    return sorted(keep + corners)


def sq_r(theta, a):
    c = BASE_CUT * a / BASE_HALF
    ca, sa = abs(math.cos(theta)), abs(math.sin(theta))
    r = (2.0 * a - c) / (ca + sa)
    if ca > 1e-9:
        r = min(r, a / ca)
    if sa > 1e-9:
        r = min(r, a / sa)
    return r


def base_loops():
    angs = base_angles()

    def sq(a, z):
        return [(sq_r(t, a) * math.cos(t), sq_r(t, a) * math.sin(t), z) for t in angs]

    def circ(r, z):
        return [(r * math.cos(t), r * math.sin(t), z) for t in angs]

    return [
        sq(BASE_HALF - BASE_CH, 0.0),
        sq(BASE_HALF, BASE_CH),
        sq(BASE_HALF, BASE_EDGE_T - BASE_CH),
        sq(BASE_HALF - BASE_CH, BASE_EDGE_T),
        circ(BASE_TOP_R, BASE_TOP_Z),
        circ(COLLAR_R, BASE_TOP_Z + 0.002),
        circ(COLLAR_R, COLLAR_Z),
        circ(COLLAR_R - 0.004, COLLAR_TOP),
        circ(r_out(COLLAR_TOP) - BODY_BITE, COLLAR_TOP),
        circ(r_out(RECESS_H) - BODY_BITE, RECESS_H),
        circ(RECESS_R, RECESS_H),
        circ(RECESS_R, 0.003),
        circ(RECESS_R + 0.003, 0.0),
    ]


def base_top_z(theta, rho):
    """Height of the base's raked top at (theta, rho): linear between the
    square edge ring and the round ring at that angle."""
    r3 = sq_r(theta, BASE_HALF - BASE_CH)
    t = (r3 - rho) / (r3 - BASE_TOP_R)
    return BASE_EDGE_T + t * (BASE_TOP_Z - BASE_EDGE_T)


def body_profile():
    pts = [(r_out(BODY_Z0), BODY_Z0), (r_out(0.064), 0.064), (r_out(0.069) + BEAD, 0.069),
           (r_out(0.080) + BEAD, 0.080), (r_out(0.085), 0.085)]
    for z0, z1 in BAND_Z:
        pts += [(r_out(z0), z0), (r_out(z1), z1)]
    pts += [(r_out(0.690), 0.690), (r_out(0.697) + LIP, 0.697), (r_out(0.709) + LIP, 0.709),
            (r_out(0.7165) + 0.0008, 0.7165), (r_out(CONE_H) - 0.0015, CONE_H),
            (r_in(CONE_H) + 0.0015, CONE_H), (r_in(0.716), 0.716), (r_in(0.700), 0.700),
            (r_in(BODY_Z0), BODY_Z0)]
    return pts


def band_profile(z0, z1, extra=0.0):
    e = extra
    return [(r_out(z0) - BAND_BITE + e, z0), (r_out(z0) + BAND_T - BAND_CH + e, z0),
            (r_out(z0 + BAND_CH) + BAND_T + e, z0 + BAND_CH),
            (r_out(z1 - BAND_CH) + BAND_T + e, z1 - BAND_CH),
            (r_out(z1) + BAND_T - BAND_CH + e, z1), (r_out(z1) - BAND_BITE + e, z1)]


def cone_bm(tone, lean_deg=0.0, float_band=0.0):
    """One cone in its local frame, in its own bmesh."""
    cb = bmesh.new()
    layer = cb.faces.layers.float.new(TONE)
    ident = Matrix.Identity(4)
    loft_loops(cb, base_loops(), RUBBER_IDX, ident)
    # four moulded lugs on the raked top, one toward each corner
    for q in range(4):
        th = math.radians(45.0 + 90.0 * q)
        zc = base_top_z(th, LUG_RHO)
        prof = [(LUG_R, -0.003), (LUG_R, 0.002), (LUG_R - 0.004, 0.0048), (0.004, 0.0060)]
        add_lathe(cb, prof, 16, RUBBER_IDX,
                  center=(LUG_RHO * math.cos(th), LUG_RHO * math.sin(th), zc), solid=True)
    lean = (Matrix.Translation((0.0, 0.0, COLLAR_TOP))
            @ Matrix.Rotation(math.radians(lean_deg), 4, "X")
            @ Matrix.Translation((0.0, 0.0, -COLLAR_TOP)))
    revolve(cb, body_profile(), BODY_SEGS, CONE_IDX, lean)
    for bi, (z0, z1) in enumerate(BAND_Z):
        revolve(cb, band_profile(z0, z1, float_band if bi == 1 else 0.0), BODY_SEGS,
                REFLECT_IDX, lean)
    for f in cb.faces:
        f[layer] = tone
    return cb


def tipped_matrix(cb):
    """The fallen cone lies on the edge of its base and the lip of its tip:
    solve the roll about local X at which both touch, then yaw and seat it."""
    base = [v.co.copy() for v in cb.verts
            if any(f.material_index == RUBBER_IDX for f in v.link_faces)]
    lip = [v.co.copy() for v in cb.verts if v.co.z > 0.65
           and any(f.material_index == CONE_IDX for f in v.link_faces)]

    def gap(phi):
        r = Matrix.Rotation(phi, 3, "X")
        return min((r @ p).z for p in base) - min((r @ p).z for p in lip)

    lo, hi = math.radians(90.0), math.radians(120.0)
    for _ in range(60):
        mid = 0.5 * (lo + hi)
        if gap(mid) < 0.0:
            lo = mid
        else:
            hi = mid
    phi = 0.5 * (lo + hi)
    rot = Matrix.Rotation(math.radians(TIPPED_YAW), 4, "Z") @ Matrix.Rotation(phi, 4, "X")
    zmin = min((rot @ v.co).z for v in cb.verts)
    return Matrix.Translation((TIPPED_XY[0], TIPPED_XY[1], SLAB_T - SEAT - zmin)) @ rot, phi


def build_cones(bm, flags):
    k = 0
    for i, (x, y, yaw) in enumerate(STANDING):
        cb = cone_bm(CONE_TONES[k], lean_deg=LEAN_DEG if (flags["lean_cone"] and i == 1) else 0.0,
                     float_band=FLOAT_BAND if (flags["float_band"] and i == 0) else 0.0)
        lift = FLOAT_CONE if (flags["float_cone"] and i == 2) else 0.0
        M = Matrix.Translation((x, y, SLAB_T - SEAT + lift)) @ Matrix.Rotation(
            math.radians(yaw), 4, "Z")
        merge_bm(bm, cb, M)
        cb.free()
        k += 1
    for j, yaw in enumerate(STACK_YAW):
        cb = cone_bm(CONE_TONES[k])
        lift = LOOSE_STACK if (flags["loose_stack"] and j == len(STACK_YAW) - 1) else 0.0
        M = Matrix.Translation((STACK_XY[0], STACK_XY[1],
                                SLAB_T - SEAT + j * STACK_PITCH + lift)) @ Matrix.Rotation(
            math.radians(yaw), 4, "Z")
        merge_bm(bm, cb, M)
        cb.free()
        k += 1
    cb = cone_bm(CONE_TONES[k])
    M, _phi = tipped_matrix(cb)
    merge_bm(bm, cb, M)
    cb.free()


# --------------------------------------------------------------------------
# A-frame barricade
# --------------------------------------------------------------------------

def leg_line(sgn, back, dx=0.0):
    """Foot-to-pivot line of one leg: (bottom end, top end, direction,
    outward face normal, pad centre)."""
    xs = BAR_X + sgn * LEG_X + (sgn * LEG_SPLIT if back else -sgn * LEG_SPLIT) + dx
    ys = 1.0 if back else -1.0
    H = Vector((xs, BAR_Y, SLAB_T + HINGE_Z))
    G = Vector((xs, BAR_Y + ys * FOOT_Y, SLAB_T))
    d = (H - G).normalized()
    pad_top = SLAB_T - SEAT + PAD_T
    E = G + d * ((pad_top - LEG_IN_PAD - G.z) / d.z)
    T = H + d * LEG_OVER
    padc = G + d * ((pad_top - G.z) / d.z)
    n_out = X.cross(d) * (-1.0 if back else 1.0)
    return E, T, d, n_out, padc


def leg_y(back, z):
    ys = 1.0 if back else -1.0
    return BAR_Y + ys * FOOT_Y * (1.0 - (z - SLAB_T) / HINGE_Z)


def board_bm(n_corner, tone):
    """A striped board in its own frame (u along its length, v up the leg, w
    through it), its faces split on 45-degree lines into orange and white."""
    bb = bmesh.new()
    layer = bb.faces.layers.float.new(TONE)
    L, H, T = BOARD_L, BOARD_H, BOARD_T
    ch = 0.003
    rings = []
    for inset, u in ((ch, -L / 2), (0.0, -L / 2 + ch), (0.0, L / 2 - ch), (ch, L / 2)):
        loop = rrect(H / 2 - inset, T / 2 - inset, BOARD_RC - inset, n_corner)
        rings.append([bb.verts.new((u, v, w)) for v, w in loop])
    n = len(rings[0])
    for r0, r1 in zip(rings, rings[1:]):
        for k in range(n):
            m = (k + 1) % n
            bb.faces.new((r0[k], r0[m], r1[m], r1[k]))
    bb.faces.new(tuple(reversed(rings[0])))
    bb.faces.new(tuple(rings[-1]))
    pitch = STRIPE_W * math.sqrt(2.0)
    no = Vector((1.0, 1.0, 0.0)).normalized()
    j0 = math.floor((-(L + H) / 2 - STRIPE_PHASE) / pitch)
    j1 = math.ceil(((L + H) / 2 - STRIPE_PHASE) / pitch)
    for j in range(j0, j1 + 1):
        c = STRIPE_PHASE + j * pitch
        geom = list(bb.verts) + list(bb.edges) + list(bb.faces)
        bmesh.ops.bisect_plane(bb, geom=geom, plane_co=(c, 0.0, 0.0), plane_no=no)
    bb.normal_update()
    for f in bb.faces:
        if abs(f.normal.z) > 0.95:
            s = sum(v.co.x + v.co.y for v in f.verts) / len(f.verts)
            k = math.floor((s - STRIPE_PHASE) / pitch)
            f.material_index = SHEETING_IDX if k % 2 == 0 else REFLECT_IDX
        else:
            f.material_index = BOARD_IDX
        f[layer] = tone
    return bb


def add_dome(bm, center, axis, mat_idx, r=0.012, h=0.0062, segs=16):
    prof = [(r, -BOLT_SINK), (r, 0.0012), (r * 0.86, 0.0034), (r * 0.55, 0.0052),
            (r * 0.18, h)]
    return add_lathe(bm, prof, segs, mat_idx, center=center, rot=frame(axis, X if abs(
        Vector(axis).x) < 0.9 else Y), solid=True)


def add_hex(bm, center, axis, a, b, r, mat_idx):
    prof = [(r, a), (r, b - 0.001), (r * 0.82, b)]
    return add_lathe(bm, prof, 6, mat_idx, center=center, rot=frame(axis, Y), phase=math.pi / 6,
                     solid=True)


def add_rod(bm, center, axis, a, b, r, mat_idx, segs=12):
    return add_lathe(bm, [(r, a), (r, b)], segs, mat_idx, center=center,
                     rot=frame(axis, Y), solid=True)


def build_barrier(bm, n_corner, bevel_verts, flags):
    parts = []   # (faces, tone)

    def keep(verts, tone):
        parts.append((faces_of(verts), tone))

    for sgn in (-1.0, 1.0):
        for back in (False, True):
            dx = (-sgn * SKEW_LEG) if (flags["skew_leg"] and sgn > 0 and not back) else 0.0
            E, T, d, n_out, padc = leg_line(sgn, back, dx)
            rot = frame(d, X)
            ln = (T - E).length
            keep(add_rbox(bm, LEG_HALF, LEG_HALF, LEG_RC,
                          [(0.0015, 0.0), (0.0, 0.0015), (0.0, ln - 0.0015), (0.0015, ln)],
                          E, rot, STEEL_IDX, n_corner), 0.4 + 0.1 * sgn + (0.1 if back else 0.0))
            keep(add_rbox(bm, LEG_HALF + 0.0016, LEG_HALF + 0.0016, 0.0045,
                          [(0.0, 0.0), (0.0, 0.011), (0.003, 0.0145), (0.0075, 0.0165)],
                          T - d * 0.012, rot, PLASTIC_IDX, n_corner), 0.5)
            # the pad stays under the design foot; --skew-leg moves the leg alone
            _E, _T, _d, _n, padc0 = leg_line(sgn, back)
            keep(add_rbox(bm, PAD_HX, PAD_HY, 0.012,
                          [(0.003, 0.0), (0.0, 0.003), (0.0, PAD_T - 0.004), (0.004, PAD_T)],
                          (padc0.x, padc0.y, SLAB_T - SEAT), Matrix.Identity(3), RUBBER_IDX,
                          n_corner), 0.4 if back else 0.6)

        # hinge: washer between the pair, a pivot bolt with a head outside and a nut inside
        xst = BAR_X + sgn * LEG_X
        ph = Vector((xst, BAR_Y, SLAB_T + HINGE_Z))
        ax = X * sgn
        reach = LEG_SPLIT + LEG_HALF
        keep(add_lathe(bm, [(0.0125, -0.002), (0.0125, 0.002)], 20, STEEL_IDX, center=ph,
                       rot=frame(ax, Y), solid=True), 0.5)
        keep(add_rod(bm, ph, ax, -(reach + 0.0068), reach + 0.0066, 0.006, STEEL_IDX), 0.5)
        keep(add_hex(bm, ph, ax, reach - 0.0006, reach + 0.0072, 0.0115, STEEL_IDX), 0.5)
        keep(add_hex(bm, ph, -ax, reach - 0.0006, reach + 0.0072, 0.0105, STEEL_IDX), 0.5)

        # spreader strap on the inner side, pinned to the front leg and, through a
        # spacer, to the back leg
        zs = SLAB_T + STRAP_Z
        x_front = xst - sgn * LEG_SPLIT
        x_back = xst + sgn * LEG_SPLIT
        x_strap = x_front - sgn * (LEG_HALF + STRAP_GAP + STRAP_T / 2)
        yf, yb = leg_y(False, zs), leg_y(True, zs)
        outline = lug_outline([(yf, zs, STRAP_W / 2), (yb, zs, STRAP_W / 2)])
        srot = Matrix(((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)))
        sv = add_prism(bm, outline, -STRAP_T / 2, STRAP_T / 2, (x_strap, 0.0, 0.0), srot,
                       STEEL_IDX)
        bevel_verts.extend(sv)
        keep(sv, 0.5)
        x_in = x_strap - sgn * STRAP_T / 2
        for yy, xl, spacer in ((yf, x_front, False), (yb, x_back, True)):
            p0 = Vector((x_in, yy, zs))
            far = abs((xl + sgn * LEG_HALF) - x_in)
            keep(add_rod(bm, p0, ax, -0.003, far + 0.0052, 0.005, STEEL_IDX), 0.5)
            keep(add_dome(bm, p0, -ax, STEEL_IDX, r=0.0095, h=0.0046, segs=16), 0.5)
            keep(add_hex(bm, Vector((xl + sgn * LEG_HALF, yy, zs)), ax, -0.0006, 0.0040,
                         0.0085, STEEL_IDX), 0.5)
            if spacer:
                s0 = x_strap + sgn * (STRAP_T / 2 - 0.0005)
                s1 = x_back - sgn * (LEG_HALF - 0.0005)
                keep(add_rod(bm, Vector((s0, yy, zs)), ax, 0.0, abs(s1 - s0), 0.0085,
                             STEEL_IDX, segs=16), 0.5)

    # boards: two on each face, bolted to the legs of that face
    lamp_board = None
    for back in (False, True):
        _E, _T, d, n_out, _p = leg_line(1.0, back)
        for bi, zb in enumerate(BOARD_Z):
            G = Vector((BAR_X, BAR_Y + (FOOT_Y if back else -FOOT_Y), SLAB_T))
            A = G + d * ((SLAB_T + zb - G.z) / d.z)
            gap = GAP_BOARD if (flags["gap_board"] and not back and bi == 1) else 0.0
            C = A + n_out * (LEG_HALF - BOARD_BITE + BOARD_T / 2 + gap)
            M = Matrix((X, d, n_out)).transposed().to_4x4()
            M.translation = C
            bb = board_bm(n_corner, 0.2 + 0.2 * bi + (0.4 if back else 0.0))
            merge_bm(bm, bb, M)
            bb.free()
            ux = LEG_X + (LEG_SPLIT if back else -LEG_SPLIT)
            for su in (-1.0, 1.0):
                for sv_ in (-1.0, 1.0):
                    p = C + X * (su * ux) + d * (sv_ * BOLT_V) + n_out * (BOARD_T / 2)
                    keep(add_dome(bm, p, n_out, STEEL_IDX), 0.5)
            if not back and bi == 0:
                lamp_board = (C, d, n_out)

    # warning lamp: a bracket bolted to the top front board, a battery box, a
    # drum housing with a fresnel lens and bezel on each face
    C, d, n_out = lamp_board
    # --loose-lamp pulls the lamp level off the board, so the envelope's top
    # (the housing) does not move
    level = Vector((0.0, n_out.y, 0.0)).normalized()
    shift = level * (LOOSE_LAMP if flags["loose_lamp"] else 0.0)
    xl = BAR_X - LEG_X + LAMP_DX
    f_top = C + d * (BOARD_H / 2) + n_out * (BOARD_T / 2) + X * (xl - BAR_X) + shift
    off = n_out * (BR_T / 2 - BR_BITE)
    q0 = f_top + off - d * 0.090
    q1 = f_top + off + d * 0.006
    q2 = q1 + Z * 0.035
    q3 = q2 + Z * 0.045
    keep(add_bar(bm, [q0, q1, q2, q3], X, BR_HALF_W, BR_T / 2, 0.001, STEEL_IDX, fillet=0.012),
         0.5)
    # two bolts 40 mm apart on one bracket: the second sits 0.3 mm deeper, or
    # their base caps share a plane
    for s, deep in ((0.070, 0.0), (0.030, 0.0003)):
        keep(add_dome(bm, f_top + n_out * (BR_T - BR_BITE - deep) - d * s, n_out, STEEL_IDX,
                      r=0.008, h=0.0042, segs=12), 0.5)
    z_box = q3.z - 0.020
    keep(add_rbox(bm, 0.075, 0.042, 0.010,
                  [(0.003, 0.0), (0.0, 0.003), (0.0, BOX_H - 0.004), (0.004, BOX_H)],
                  (xl, q3.y, z_box), Matrix.Identity(3), PLASTIC_IDX, n_corner), 0.5)
    hc = Vector((xl, q3.y, z_box + BOX_H + HOUSING_R - 0.012))
    keep(add_lathe(bm, [(0.080, -0.036), (0.094, -0.034), (0.098, -0.029), (0.098, 0.029),
                        (0.094, 0.034), (0.080, 0.036)], 32, PLASTIC_IDX, center=hc,
                   rot=frame(Y, X), solid=True), 0.5)
    lens = [(0.082, 0.0348), (0.082, 0.0400), (0.068, 0.0465), (0.068, 0.0450), (0.052, 0.0505),
            (0.052, 0.0490), (0.034, 0.0535), (0.034, 0.0520), (0.012, 0.0555), (0.004, 0.0558)]
    bezel = [(0.080, 0.0340), (0.090, 0.0340), (0.090, 0.0395), (0.086, 0.0432), (0.080, 0.0432)]
    for ax in (-Y, Y):
        keep(add_lathe(bm, lens, 32, LENS_IDX, center=hc, rot=frame(ax, X), solid=True), 0.5)
        f = revolve(bm, bezel, 32, PLASTIC_IDX, m4(frame(ax, X), hc))
        parts.append((set(f), 0.5))

    for faces, tone in parts:
        _tone(bm, [f for f in faces if f.is_valid], tone)


def build_mesh(name, n_corner, bevel_segments, flags):
    bm = bmesh.new()
    try:
        bm.faces.layers.float.new(TONE)
        bevel_verts = []
        build_slab(bm)
        build_cones(bm, flags)
        build_barrier(bm, n_corner, bevel_verts, flags)
        # chamfer the straps' rims (prisms), material= set, over sorted edges
        bm.edges.index_update()
        edges = sorted(
            {e for v in bevel_verts if v.is_valid for e in v.link_edges
             if len(e.link_faces) == 2
             and all(f.material_index == STEEL_IDX for f in e.link_faces)
             and e.calc_face_angle() > math.radians(60.0)},
            key=lambda e: e.index,
        )
        if edges:
            bmesh.ops.bevel(bm, geom=edges, offset=0.0008, segments=bevel_segments,
                            profile=0.5, affect="EDGES", clamp_overlap=True, material=STEEL_IDX)
        bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-5)
        bmesh.ops.dissolve_degenerate(bm, dist=1e-6)
        triangulate_ngons(bm)
        pack_uvs(bm)
        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
        # Lathed and moulded bodies are smooth-shaded; chamfers, stripes and
        # material boundaries stay crisp.
        for face in bm.faces:
            face.smooth = True
        for edge in bm.edges:
            mats = {f.material_index for f in edge.link_faces}
            if len(mats) > 1 or not edge.is_manifold or len(edge.link_faces) != 2:
                edge.smooth = False
            else:
                edge.smooth = edge.calc_face_angle() < math.radians(35.0)
        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


# --------------------------------------------------------------------------
# Materials
# --------------------------------------------------------------------------

def _node(nt, kind, **inputs):
    n = nt.nodes.new(kind)
    for k, v in inputs.items():
        n.inputs[k].default_value = v
    return n


def _ramp(nt, a, ca, b, cb):
    r = nt.nodes.new("ShaderNodeValToRGB")
    r.color_ramp.elements[0].position = a
    r.color_ramp.elements[0].color = ca
    r.color_ramp.elements[1].position = b
    r.color_ramp.elements[1].color = cb
    return r


def _mix(nt, fac_socket, c1, c2):
    mx = nt.nodes.new("ShaderNodeMixRGB")
    mx.blend_type = "MIX"
    nt.links.new(fac_socket, mx.inputs[0])
    for i, c in ((1, c1), (2, c2)):
        if isinstance(c, tuple):
            mx.inputs[i].default_value = c
        else:
            nt.links.new(c, mx.inputs[i])
    return mx.outputs[0]


def _gray(v):
    return (v, v, v, 1.0)


def weathered(name, col_a, col_b, rough, dirt_col, dirt_top, dirt_amt, scuff_col, scuff_amt,
              scuff_scale=38.0, metallic=0.0, rough_var=0.08, bump=0.0, bump_scale=600.0,
              coat=0.0, rub=0.0, rub_col=(0.030, 0.027, 0.025, 1.0)):
    """A designed surface: a per-part tone between two colours (the PartTone
    face attribute), grime rising from the slab to ``dirt_top``, sparse
    scuffs, ``rub``: level black tyre and boot rubs (a noise squashed flat,
    so each mark runs round the part, not up it), roughness breakup and an
    optional fine bump."""
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    nt = mat.node_tree
    bsdf = nt.nodes["Principled BSDF"]
    bsdf.inputs["Metallic"].default_value = metallic
    if coat > 0.0 and "Coat Weight" in bsdf.inputs:
        bsdf.inputs["Coat Weight"].default_value = coat
    coord = nt.nodes.new("ShaderNodeTexCoord")
    attr = nt.nodes.new("ShaderNodeAttribute")
    attr.attribute_name = TONE
    tone = _mix(nt, attr.outputs["Fac"], col_a, col_b)
    sep = nt.nodes.new("ShaderNodeSeparateXYZ")
    nt.links.new(coord.outputs["Object"], sep.inputs[0])
    hmap = _node(nt, "ShaderNodeMapRange")
    hmap.inputs["From Min"].default_value = SLAB_T
    hmap.inputs["From Max"].default_value = SLAB_T + dirt_top
    hmap.inputs["To Min"].default_value = dirt_amt
    hmap.inputs["To Max"].default_value = 0.0
    nt.links.new(sep.outputs["Z"], hmap.inputs["Value"])
    dn = _node(nt, "ShaderNodeTexNoise", Scale=4.0, Detail=6.0)
    nt.links.new(coord.outputs["Object"], dn.inputs["Vector"])
    dr = _ramp(nt, 0.35, _gray(0.25), 0.70, _gray(1.0))
    nt.links.new(dn.outputs["Fac"], dr.inputs["Fac"])
    dm = nt.nodes.new("ShaderNodeMath")
    dm.operation = "MULTIPLY"
    nt.links.new(hmap.outputs["Result"], dm.inputs[0])
    nt.links.new(dr.outputs["Color"], dm.inputs[1])
    grime = _mix(nt, dm.outputs["Value"], tone, dirt_col)
    sn = _node(nt, "ShaderNodeTexNoise", Scale=scuff_scale, Detail=10.0)
    nt.links.new(coord.outputs["Object"], sn.inputs["Vector"])
    sr = _ramp(nt, 0.60, _gray(0.0), 0.70, _gray(scuff_amt))
    nt.links.new(sn.outputs["Fac"], sr.inputs["Fac"])
    col = _mix(nt, sr.outputs["Color"], grime, scuff_col)
    if rub > 0.0:
        mp = nt.nodes.new("ShaderNodeMapping")
        mp.inputs["Scale"].default_value = (1.0, 1.0, 16.0)
        nt.links.new(coord.outputs["Object"], mp.inputs["Vector"])
        rn = _node(nt, "ShaderNodeTexNoise", Scale=3.2, Detail=6.0, Roughness=0.62)
        nt.links.new(mp.outputs["Vector"], rn.inputs["Vector"])
        rk = _ramp(nt, 0.635, _gray(0.0), 0.700, _gray(rub))
        nt.links.new(rn.outputs["Fac"], rk.inputs["Fac"])
        col = _mix(nt, rk.outputs["Color"], col, rub_col)
    nt.links.new(col, bsdf.inputs["Base Color"])
    rr = _ramp(nt, 0.30, _gray(max(0.03, rough - rough_var)), 0.70,
               _gray(min(0.95, rough + rough_var)))
    nt.links.new(dn.outputs["Fac"], rr.inputs["Fac"])
    radd = nt.nodes.new("ShaderNodeMath")
    radd.operation = "ADD"
    radd.use_clamp = True
    nt.links.new(rr.outputs["Color"], radd.inputs[0])
    nt.links.new(dm.outputs["Value"], radd.inputs[1])
    nt.links.new(radd.outputs["Value"], bsdf.inputs["Roughness"])
    if bump > 0.0:
        vor = nt.nodes.new("ShaderNodeTexVoronoi")
        vor.inputs["Scale"].default_value = bump_scale
        nt.links.new(coord.outputs["Object"], vor.inputs["Vector"])
        bp = _node(nt, "ShaderNodeBump", Strength=bump)
        bp.inputs["Distance"].default_value = 0.0005
        nt.links.new(vor.outputs["Distance"], bp.inputs["Height"])
        nt.links.new(bp.outputs["Normal"], bsdf.inputs["Normal"])
    return mat


def add_studio(mat, color, env, stops):
    """A studio carried in the material (after espresso-machine): the
    world-space reflection vector looks up a soft band of softboxes round the
    horizon, brighter on the key's side, added as emission. A metal on a dark
    stage mirrors the dark stage and reads as grey plastic without it."""
    nt = mat.node_tree
    bsdf = nt.nodes["Principled BSDF"]
    out = nt.nodes["Material Output"]
    coord = nt.nodes.new("ShaderNodeTexCoord")
    sep = nt.nodes.new("ShaderNodeSeparateXYZ")
    nt.links.new(coord.outputs["Reflection"], sep.inputs[0])
    mz = nt.nodes.new("ShaderNodeMapRange")
    mz.inputs["From Min"].default_value = -1.0
    mz.inputs["From Max"].default_value = 1.0
    nt.links.new(sep.outputs["Z"], mz.inputs["Value"])
    ramp = nt.nodes.new("ShaderNodeValToRGB")
    cr = ramp.color_ramp
    cr.elements[0].position, cr.elements[0].color = stops[0][0], (stops[0][1],) * 3 + (1.0,)
    cr.elements[1].position, cr.elements[1].color = stops[-1][0], (stops[-1][1],) * 3 + (1.0,)
    for pos, val in stops[1:-1]:
        e = cr.elements.new(pos)
        e.color = (val, val, val, 1.0)
    nt.links.new(mz.outputs["Result"], ramp.inputs["Fac"])
    mx = nt.nodes.new("ShaderNodeMapRange")
    mx.inputs["From Min"].default_value = -1.0
    mx.inputs["From Max"].default_value = 1.0
    mx.inputs["To Min"].default_value = 1.0
    mx.inputs["To Max"].default_value = 0.40
    nt.links.new(sep.outputs["X"], mx.inputs["Value"])
    side = nt.nodes.new("ShaderNodeMath")
    side.operation = "MULTIPLY"
    nt.links.new(mx.outputs["Result"], side.inputs[0])
    side.inputs[1].default_value = env
    tint = nt.nodes.new("ShaderNodeMixRGB")
    tint.blend_type = "MULTIPLY"
    tint.inputs[0].default_value = 1.0
    tint.inputs[2].default_value = color
    nt.links.new(ramp.outputs["Color"], tint.inputs[1])
    em = nt.nodes.new("ShaderNodeEmission")
    nt.links.new(tint.outputs[0], em.inputs["Color"])
    nt.links.new(side.outputs["Value"], em.inputs["Strength"])
    add = nt.nodes.new("ShaderNodeAddShader")
    nt.links.new(bsdf.outputs["BSDF"], add.inputs[0])
    nt.links.new(em.outputs["Emission"], add.inputs[1])
    nt.links.new(add.outputs["Shader"], out.inputs["Surface"])
    return mat


def asphalt_material():
    """Dense asphalt: a dark binder with light aggregate showing through, a
    few large darker oil stains that are also glossier, and the aggregate
    again as a bump."""
    mat = bpy.data.materials.new("Asphalt")
    mat.use_nodes = True
    nt = mat.node_tree
    bsdf = nt.nodes["Principled BSDF"]
    coord = nt.nodes.new("ShaderNodeTexCoord")
    vor = nt.nodes.new("ShaderNodeTexVoronoi")
    vor.inputs["Scale"].default_value = 140.0
    nt.links.new(coord.outputs["Object"], vor.inputs["Vector"])
    agg = _ramp(nt, 0.0, (0.150, 0.144, 0.136, 1.0), 0.30, (0.030, 0.029, 0.028, 1.0))
    nt.links.new(vor.outputs["Distance"], agg.inputs["Fac"])
    stones = nt.nodes.new("ShaderNodeTexVoronoi")
    stones.inputs["Scale"].default_value = 60.0
    nt.links.new(coord.outputs["Object"], stones.inputs["Vector"])
    coarse = _ramp(nt, 0.0, (0.125, 0.118, 0.108, 1.0), 0.22, (0.030, 0.029, 0.028, 1.0))
    nt.links.new(stones.outputs["Distance"], coarse.inputs["Fac"])
    light = nt.nodes.new("ShaderNodeMixRGB")
    light.blend_type = "LIGHTEN"
    light.inputs[0].default_value = 1.0
    nt.links.new(agg.outputs["Color"], light.inputs[1])
    nt.links.new(coarse.outputs["Color"], light.inputs[2])
    big = _node(nt, "ShaderNodeTexNoise", Scale=1.3, Detail=4.0)
    nt.links.new(coord.outputs["Object"], big.inputs["Vector"])
    stain = _ramp(nt, 0.38, _gray(0.55), 0.62, _gray(1.0))
    nt.links.new(big.outputs["Fac"], stain.inputs["Fac"])
    mul = nt.nodes.new("ShaderNodeMixRGB")
    mul.blend_type = "MULTIPLY"
    mul.inputs[0].default_value = 1.0
    nt.links.new(light.outputs[0], mul.inputs[1])
    nt.links.new(stain.outputs["Color"], mul.inputs[2])
    nt.links.new(mul.outputs[0], bsdf.inputs["Base Color"])
    rr = _ramp(nt, 0.38, _gray(0.62), 0.62, _gray(0.92))
    nt.links.new(big.outputs["Fac"], rr.inputs["Fac"])
    nt.links.new(rr.outputs["Color"], bsdf.inputs["Roughness"])
    hsum = nt.nodes.new("ShaderNodeMath")
    hsum.operation = "ADD"
    nt.links.new(vor.outputs["Distance"], hsum.inputs[0])
    nt.links.new(stones.outputs["Distance"], hsum.inputs[1])
    bp = _node(nt, "ShaderNodeBump", Strength=0.40)
    bp.inputs["Distance"].default_value = 0.002
    nt.links.new(hsum.outputs["Value"], bp.inputs["Height"])
    nt.links.new(bp.outputs["Normal"], bsdf.inputs["Normal"])
    return mat


def road_paint_material():
    """Road paint worn through to the binder where a noise mask says so."""
    mat = bpy.data.materials.new("RoadPaint")
    mat.use_nodes = True
    nt = mat.node_tree
    bsdf = nt.nodes["Principled BSDF"]
    bsdf.inputs["Roughness"].default_value = 0.72
    coord = nt.nodes.new("ShaderNodeTexCoord")
    wear = _node(nt, "ShaderNodeTexNoise", Scale=13.0, Detail=9.0)
    nt.links.new(coord.outputs["Object"], wear.inputs["Vector"])
    ramp = _ramp(nt, 0.30, (0.050, 0.048, 0.046, 1.0), 0.40, (0.62, 0.61, 0.57, 1.0))
    nt.links.new(wear.outputs["Fac"], ramp.inputs["Fac"])
    vor = nt.nodes.new("ShaderNodeTexVoronoi")
    vor.inputs["Scale"].default_value = 140.0
    nt.links.new(coord.outputs["Object"], vor.inputs["Vector"])
    speck = _ramp(nt, 0.0, _gray(0.72), 0.25, _gray(1.0))
    nt.links.new(vor.outputs["Distance"], speck.inputs["Fac"])
    mul = nt.nodes.new("ShaderNodeMixRGB")
    mul.blend_type = "MULTIPLY"
    mul.inputs[0].default_value = 1.0
    nt.links.new(ramp.outputs["Color"], mul.inputs[1])
    nt.links.new(speck.outputs["Color"], mul.inputs[2])
    nt.links.new(mul.outputs[0], bsdf.inputs["Base Color"])
    bp = _node(nt, "ShaderNodeBump", Strength=0.25)
    bp.inputs["Distance"].default_value = 0.0015
    nt.links.new(vor.outputs["Distance"], bp.inputs["Height"])
    nt.links.new(bp.outputs["Normal"], bsdf.inputs["Normal"])
    return mat


def principled(name, color, metallic, roughness):
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    bsdf = mat.node_tree.nodes["Principled BSDF"]
    bsdf.inputs["Base Color"].default_value = color
    bsdf.inputs["Metallic"].default_value = metallic
    bsdf.inputs["Roughness"].default_value = roughness
    return mat


def _emission(bsdf, color, strength):
    for key in ("Emission Color", "Emission"):
        if key in bsdf.inputs:
            bsdf.inputs[key].default_value = color
            break
    if "Emission Strength" in bsdf.inputs:
        bsdf.inputs["Emission Strength"].default_value = strength


def set_materials():
    """Slot order: cone PVC, reflective sheeting, rubber, asphalt, road
    paint, tar, orange sheeting, board plastic, galvanised steel, black
    plastic, amber lens. Shared by the check and the render."""
    cone = weathered("ConePVC", (0.90, 0.19, 0.012, 1.0), (0.74, 0.20, 0.040, 1.0), 0.46,
                     (0.13, 0.085, 0.050, 1.0), 0.26, 0.85, (0.30, 0.10, 0.04, 1.0), 0.55,
                     scuff_scale=26.0, coat=0.15, rub=0.92)
    reflect = weathered("ReflectiveWhite", (0.78, 0.78, 0.76, 1.0), (0.66, 0.66, 0.63, 1.0), 0.30,
                        (0.36, 0.32, 0.26, 1.0), 0.40, 0.60, (0.50, 0.50, 0.49, 1.0), 0.65,
                        scuff_scale=70.0, bump=0.12, bump_scale=900.0, rub=0.6,
                        rub_col=(0.10, 0.095, 0.088, 1.0))
    rubber = weathered("RubberBase", (0.036, 0.035, 0.034, 1.0), (0.050, 0.048, 0.045, 1.0), 0.78,
                       (0.17, 0.155, 0.14, 1.0), 0.03, 0.55, (0.10, 0.098, 0.095, 1.0), 0.8,
                       scuff_scale=20.0, bump=0.25, bump_scale=400.0)
    asphalt = asphalt_material()
    paint = road_paint_material()
    tar = weathered("CrackSeal", (0.016, 0.015, 0.014, 1.0), (0.022, 0.020, 0.018, 1.0), 0.32,
                    (0.05, 0.048, 0.045, 1.0), 0.01, 0.4, (0.06, 0.058, 0.055, 1.0), 0.5)
    sheeting = weathered("OrangeSheeting", (0.86, 0.23, 0.020, 1.0), (0.78, 0.25, 0.045, 1.0),
                         0.30, (0.24, 0.13, 0.07, 1.0), 0.70, 0.50, (0.55, 0.30, 0.16, 1.0),
                         0.55, scuff_scale=70.0, bump=0.12, bump_scale=900.0)
    board = weathered("BoardPlastic", (0.66, 0.65, 0.61, 1.0), (0.60, 0.59, 0.55, 1.0), 0.50,
                      (0.30, 0.26, 0.21, 1.0), 0.80, 0.5, (0.45, 0.44, 0.41, 1.0), 0.5,
                      scuff_scale=60.0)
    steel = weathered("GalvanisedSteel", (0.56, 0.57, 0.58, 1.0), (0.48, 0.49, 0.50, 1.0), 0.42,
                      (0.22, 0.19, 0.15, 1.0), 0.35, 0.7, (0.32, 0.28, 0.23, 1.0), 0.5,
                      metallic=1.0, rough_var=0.12, scuff_scale=18.0)
    add_studio(steel, (0.55, 0.57, 0.60, 1.0), 0.55,
               [(0.0, 0.02), (0.30, 0.05), (0.42, 0.45), (0.50, 1.0), (0.62, 0.30), (1.0, 0.12)])
    plastic = weathered("BlackPlastic", (0.030, 0.030, 0.032, 1.0), (0.040, 0.040, 0.042, 1.0),
                        0.42, (0.16, 0.15, 0.13, 1.0), 0.05, 0.3, (0.12, 0.12, 0.12, 1.0), 0.6)
    lens = principled("AmberLens", (0.80, 0.26, 0.010, 1.0), 0.0, 0.10)
    lb = lens.node_tree.nodes["Principled BSDF"]
    if "Coat Weight" in lb.inputs:
        lb.inputs["Coat Weight"].default_value = 1.0
    _emission(lb, (1.0, 0.36, 0.02, 1.0), 0.55)
    return cone, reflect, rubber, asphalt, paint, tar, sheeting, board, steel, plastic, lens


def assign_slots(obj, wanted):
    # Do not materials.clear() — that resets polygon material_index to 0.
    mats = obj.data.materials
    for i, mat in enumerate(wanted):
        if i < len(mats):
            mats[i] = mat
        else:
            mats.append(mat)


# --------------------------------------------------------------------------
# Audits
# --------------------------------------------------------------------------

def vert_bbox(me):
    # read the vertices: bound_box is cached and an in-place edit does not refresh it
    co = np.empty(len(me.vertices) * 3)
    me.vertices.foreach_get("co", co)
    co = co.reshape(-1, 3)
    lo, hi = co.min(axis=0), co.max(axis=0)
    return (lo[0], lo[1], lo[2], hi[0], hi[1], hi[2])


def uv_stats(mesh):
    uv = mesh.uv_layers.active
    if uv is None:
        return 0.0, 0.0, 1.0, 1.0, 0, 1.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)))
    aabbs.sort()
    overlap = 0.0
    for i, a in enumerate(aabbs):
        for j in range(i + 1, len(aabbs)):
            b = aabbs[j]
            if b[0] >= a[2]:
                break
            x0 = max(a[0], b[0])
            y0 = max(a[1], b[1])
            x1 = min(a[2], b[2])
            y1 = min(a[3], b[3])
            overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)


def face_area(me, poly):
    vs = [me.vertices[i].co for i in poly.vertices]
    if len(vs) < 3:
        return 0.0
    v0 = vs[0]
    area = 0.0
    for i in range(1, len(vs) - 1):
        area += (vs[i] - v0).cross(vs[i + 1] - v0).length * 0.5
    return area


def hygiene_audit(me):
    # Combinatorics match examples/mesh-hygiene-audit.audit (copied, not imported).
    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, groups):
    """Coplanar face pairs from *different shells* (copied from showcase/grindstone)."""
    owner = {}
    for si, g in enumerate(groups):
        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_polys(me, groups):
    owner = [0] * len(me.vertices)
    for si, g in enumerate(groups):
        for vi in g:
            owner[vi] = si
    polys = [[] for _ in groups]
    for p in me.polygons:
        polys[owner[p.vertices[0]]].append(p)
    return polys


class Shell:
    def __init__(self, me, idx, verts, polys):
        self.idx = idx
        self.verts = verts
        pts = [me.vertices[i].co.copy() for i in verts]
        self.pts = pts
        self.lo = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts)))
        self.hi = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts)))
        self.size = self.hi - self.lo
        self.centre = (self.lo + self.hi) * 0.5
        self.mean = sum(pts, Vector()) / len(pts)
        mats = {}
        for p in polys:
            mats[p.material_index] = mats.get(p.material_index, 0) + 1
        self.mat = max(mats, key=mats.get) if mats else None
        self.mats = set(mats)
        remap = {vi: n for n, vi in enumerate(verts)}
        self.tri_idx = [[remap[v] for v in p.vertices] for p in polys]
        self.tree = BVHTree.FromPolygons([tuple(p) for p in pts], self.tri_idx)
        self.polys = polys


def pca_axis(pts, largest=True):
    p = np.array([tuple(v) for v in pts], dtype=np.float64)
    c = p.mean(axis=0)
    q = p - c
    _w, vecs = np.linalg.eigh(q.T @ q)
    axis = vecs[:, -1] if largest else vecs[:, 0]
    if axis[2] < 0.0:
        axis = -axis
    return Vector(c), Vector(axis)


def classify(me):
    groups = shells(me)
    polys = shell_polys(me, groups)
    parts = [Shell(me, i, g, polys[i]) for i, g in enumerate(groups)]
    out = {"all": parts, "groups": groups}
    slabs = [s for s in parts if s.mat == ASPHALT_IDX]
    out["slab"] = max(slabs, key=lambda s: len(s.verts)) if slabs else None
    out["slab_top"] = out["slab"].hi.z if out["slab"] else 0.0
    bodies = [s for s in parts if s.mat == CONE_IDX and max(s.size) > 0.5]
    for b in bodies:
        b.axis_c, b.axis = pca_axis(b.pts)
        b.tilt = math.degrees(math.acos(min(1.0, abs(b.axis.z))))
    out["bodies"] = bodies
    rubber = [s for s in parts if s.mat == RUBBER_IDX]
    out["bases"] = [s for s in rubber if max(s.size) > 0.30]
    out["pads"] = [s for s in rubber if 0.06 < max(s.size) < 0.25]
    out["bands"] = [s for s in parts if s.mats == {REFLECT_IDX}]
    out["boards"] = [s for s in parts if BOARD_IDX in s.mats]
    out["legs"] = [s for s in parts if s.mat == STEEL_IDX and s.size.z > 0.8]
    return out


def support_audit(cls):
    """Every standing base, the stack's bottom base, the fallen cone's base
    and body and every barricade pad bear into the slab in a band."""
    top = cls["slab_top"]
    sup = []
    for b in cls["bases"]:
        if b.lo.z < top + 0.012:
            sup.append(("base", top - b.lo.z))
    for b in cls["bodies"]:
        if b.tilt > 45.0:
            sup.append(("fallen body", top - b.lo.z))
    for p in cls["pads"]:
        sup.append(("pad", top - p.lo.z))
    return sup


def board_audit(cls):
    """Each board's back face against the front face of every leg it is
    bolted to: planes read off the faces, compared along the board normal."""
    res = []
    for bd in cls["boards"]:
        c, n = pca_axis(bd.pts, largest=False)
        for leg in cls["legs"]:
            _lc, ld = pca_axis(leg.pts)
            if abs(ld.dot(n)) > 0.02:
                continue
            if leg.hi.x < bd.lo.x or leg.lo.x > bd.hi.x:
                continue
            dist = (c - leg.mean).dot(n)
            if abs(dist) > 0.06:
                continue
            n_out = n if dist > 0.0 else -n
            bvs = [p for p in bd.polys if Vector(p.normal).dot(-n_out) > 0.999]
            lvs = [p for p in leg.polys if Vector(p.normal).dot(n_out) > 0.999]
            if not bvs or not lvs:
                res.append(-1.0)
                continue
            me_pts = bd.pts
            remap_b = {vi: k for k, vi in enumerate(bd.verts)}
            remap_l = {vi: k for k, vi in enumerate(leg.verts)}
            db = [me_pts[remap_b[v]].dot(n_out) for p in bvs for v in p.vertices]
            dl = [leg.pts[remap_l[v]].dot(n_out) for p in lvs for v in p.vertices]
            res.append(sum(dl) / len(dl) - sum(db) / len(db))
    return res


def host_radius(body, p):
    """The host body's outer surface at the vertex's own station and angle:
    a ray from outside toward the axis, along the vertex's radial."""
    c, a = body.axis_c, body.axis
    rel = p - c
    s = rel.dot(a)
    q = rel - a * s
    rho = q.length
    if rho < 1e-6:
        return None, rho
    u = q / rho
    origin = c + a * s + u * (rho + 0.03)
    hit, _n, _i, _d = body.tree.ray_cast(origin, -u, 0.08)
    if hit is None:
        return None, rho
    return (hit - (c + a * s)).length, rho


def band_audit(cls):
    """Per band: the host is the coaxial body whose outer wall is nearest the
    band; per angular segment, the innermost vertex's seat depth and the
    outermost's stand-off."""
    seats, prouds, orphans = [], [], 0
    for band in cls["bands"]:
        best, best_d = None, 9.0
        for b in cls["bodies"]:
            rel = band.mean - b.axis_c
            if (rel - b.axis * rel.dot(b.axis)).length > 0.01:
                continue
            hr, rho = host_radius(b, band.pts[0])
            if hr is None:
                continue
            if abs(rho - hr) < best_d:
                best, best_d = b, abs(rho - hr)
        if best is None:
            orphans += 1
            continue
        bins = {}
        for p in band.pts:
            hr, rho = host_radius(best, p)
            if hr is None:
                orphans += 1
                continue
            rel = p - best.axis_c
            q = rel - best.axis * rel.dot(best.axis)
            ref = Vector((1.0, 0.0, 0.0)) if abs(best.axis.x) < 0.9 else Vector((0.0, 1.0, 0.0))
            e1 = (ref - best.axis * ref.dot(best.axis)).normalized()
            e2 = best.axis.cross(e1)
            ang = math.atan2(q.dot(e2), q.dot(e1))
            key = round(ang / (2.0 * math.pi / BODY_SEGS)) % BODY_SEGS
            lo_d, hi_d = bins.get(key, (9.0, -9.0))
            d = rho - hr
            bins[key] = (min(lo_d, d), max(hi_d, d))
        for lo_d, hi_d in bins.values():
            seats.append(-lo_d)
            prouds.append(hi_d)
    return seats, prouds, orphans


def base_under(cls, body):
    """The base bonded to a body: the rubber shell on its axis whose bottom is
    just under the body's (in a stack, the bases of the cones above are also
    under the body's middle, so compare bottoms)."""
    a = body.axis
    body_bot = min(p.dot(a) for p in body.pts)
    best, best_d = None, 9.0
    for b in cls["bases"]:
        rel = b.mean - body.axis_c
        off = (rel - a * rel.dot(a)).length
        if off > 0.03:
            continue
        dz = body_bot - min(p.dot(a) for p in b.pts)
        if 0.0 < dz < best_d:
            best, best_d = b, dz
    return best


def across_flats(pts, cx, cy):
    p = np.array([(v.x - cx, v.y - cy) for v in pts])
    th = np.radians(np.arange(0.0, 90.0, 0.02))
    dirs = np.stack([np.cos(th), np.sin(th)], axis=1)
    h = (p @ dirs.T).max(axis=0)
    h2 = (-(p @ dirs.T)).max(axis=0)
    return float((h + h2).min())


def plumb_audit(cls):
    res = []
    for b in cls["bodies"]:
        if b.tilt > 45.0:
            continue
        zs = [p.z for p in b.pts]
        z0, z1 = min(zs), max(zs)
        lo = [p for p in b.pts if p.z < z0 + 0.03]
        hi = [p for p in b.pts if p.z > z1 - 0.03]
        cl = sum(lo, Vector()) / len(lo)
        ch = sum(hi, Vector()) / len(hi)
        tilt = math.degrees(math.atan2(math.hypot(ch.x - cl.x, ch.y - cl.y), ch.z - cl.z))
        base = base_under(cls, b)
        h = (z1 - base.lo.z) if base else 0.0
        w = across_flats(base.pts, b.axis_c.x, b.axis_c.y) if base else 0.0
        res.append((tilt, h, w))
    return res


def mirror_audit(cls):
    boards = cls["boards"]
    if not boards:
        return 9.0, 0
    xm = sum(0.5 * (b.lo.x + b.hi.x) for b in boards) / len(boards)
    legs = cls["legs"]
    left = [l for l in legs if l.centre.x < xm]
    right = [l for l in legs if l.centre.x > xm]
    worst = 0.0
    if len(left) != len(right):
        return 9.0, len(legs)
    for L in left:
        R = min(right, key=lambda r: abs(r.centre.y - L.centre.y))
        worst = max(worst, abs((xm - L.lo.x) - (R.hi.x - xm)), abs((xm - L.hi.x) - (R.lo.x - xm)),
                    abs(L.lo.y - R.lo.y), abs(L.hi.y - R.hi.y), abs(L.lo.z - R.lo.z),
                    abs(L.hi.z - R.hi.z))
    return worst, len(legs)


def stack_audit(cls):
    """Coaxial plumb bodies form stacks; for each pair, the pitch between
    body bottoms and the upper's inner wall against the lower's outer wall,
    by rays at stations along the overlap."""
    plumb = [b for b in cls["bodies"] if b.tilt < 45.0]
    stacks = []
    used = set()
    for b in plumb:
        if b.idx in used:
            continue
        grp = [c for c in plumb if math.hypot(c.axis_c.x - b.axis_c.x, c.axis_c.y - b.axis_c.y) < 0.01]
        for c in grp:
            used.add(c.idx)
        if len(grp) > 1:
            stacks.append(sorted(grp, key=lambda s: s.lo.z))
    pairs = []
    for st in stacks:
        for lo_b, up_b in zip(st, st[1:]):
            pitch = up_b.lo.z - lo_b.lo.z
            ax, ay = lo_b.axis_c.x, lo_b.axis_c.y
            bites = []
            z = up_b.lo.z + 0.06
            while z < lo_b.hi.z - 0.05:
                for k in range(16):
                    a = 2.0 * math.pi * (k + 0.25) / 16
                    d = Vector((math.cos(a), math.sin(a), 0.0))
                    o = Vector((ax, ay, z))
                    h_up = up_b.tree.ray_cast(o, d, 0.5)[0]
                    h_lo = lo_b.tree.ray_cast(o + d * 0.4, -d, 0.5)[0]
                    if h_up is None or h_lo is None:
                        bites.append(-1.0)
                        continue
                    r_up = math.hypot(h_up.x - ax, h_up.y - ay)
                    r_lo = math.hypot(h_lo.x - ax, h_lo.y - ay)
                    bites.append(r_lo - r_up)
                z += 0.04
            pairs.append((pitch, min(bites) if bites else -1.0, max(bites) if bites else -1.0))
    return stacks, pairs


def connected_components(cls):
    parts = cls["all"]
    n = len(parts)
    parent = list(range(n))

    def find(i):
        while parent[i] != i:
            parent[i] = parent[parent[i]]
            i = parent[i]
        return i

    for i in range(n):
        a = parts[i]
        for j in range(i + 1, n):
            b = parts[j]
            if (a.lo.x > b.hi.x or b.lo.x > a.hi.x or a.lo.y > b.hi.y or b.lo.y > a.hi.y
                    or a.lo.z > b.hi.z or b.lo.z > a.hi.z):
                continue
            if find(i) == find(j):
                continue
            if a.tree.overlap(b.tree):
                parent[find(i)] = find(j)
    roots = {find(i) for i in range(n)}
    sizes = {}
    for i in range(n):
        sizes[find(i)] = sizes.get(find(i), 0) + 1
    return len(roots), sorted(sizes.values())


def add_stray_vert(me):
    bm = bmesh.new()
    try:
        bm.from_mesh(me)
        # inside the envelope, so only the hygiene budget can see it
        bm.verts.new((0.0, 0.0, 0.5))
        bm.to_mesh(me)
        me.update()
    finally:
        bm.free()


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 convex_hull_collider(obj, name):
    # Duplicated from snippets/convex_hull_collider.py (not a package).
    mesh = bpy.data.meshes.new(name)
    bm = bmesh.new()
    try:
        bm.from_mesh(obj.data)
        result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
        interior = result.get("geom_interior") or []
        unused = result.get("geom_unused") or []
        if interior:
            bmesh.ops.delete(bm, geom=interior, context="VERTS")
        # a loose vertex inside the hull is both interior and unused
        unused = [g for g in unused if g.is_valid]
        if unused:
            bmesh.ops.delete(bm, geom=unused, context="VERTS")
        bm.to_mesh(mesh)
        mesh.update()
    finally:
        bm.free()
    collider = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(collider)
    collider.matrix_world = obj.matrix_world.copy()
    return collider


def setup_bake_image(obj, target_mat, size=BAKE_RES):
    # Adapted from snippets/setup_bake_target_image.py — do not replace slots.
    if not obj.data.uv_layers:
        return None, None
    img = bpy.data.images.new("ConesNrm", 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 = BOARD_IDX
    return img, tex


def bake_normal(high, low):
    # Duplicated from snippets/bake_normal_high_to_low.py (not a package).
    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):
    # Duplicated from snippets/export_preset_unity.py (not a package).
    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, stray_vert=False, **flags):
    bpy.ops.wm.read_factory_settings(use_empty=True)
    low = build_mesh("ConesLow", n_corner=1, bevel_segments=1, flags=flags)
    high = build_mesh("ConesHigh", n_corner=3, bevel_segments=3, flags=flags)
    mats = set_materials()
    assign_slots(low, mats)
    assign_slots(high, mats)
    # The bake targets the board plastic: the boards' edges are where the
    # high mesh's rounded corners differ from the low mesh's chamfers.
    target = mats[BOARD_IDX]

    if stray_vert:
        add_stray_vert(low.data)
    if lift_z:
        for v in low.data.vertices:
            v.co.z += LIFT_Z
        low.data.update()

    none3 = (None, None, None)
    if low.data is None or len(low.data.polygons) < 6:
        return (fail("mesh did not build", 3),) + none3

    base_tris = triangle_count(low.data)
    slots = [s for s in low.data.materials if s is not None]
    nmat = len(slots)
    distinct_mats = 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
    print(f"measured mat_index_counts={dict(sorted(idx_counts.items()))}")
    u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data)
    bb = vert_bbox(low.data)
    size_x, size_y, size_z = bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2]
    hyg = hygiene_audit(low.data)
    cls = classify(low.data)
    zf = zfight_pairs(low.data, cls["groups"])
    sup = support_audit(cls)
    brd = board_audit(cls)
    seats, prouds, orphans = band_audit(cls)
    plumb = plumb_audit(cls)
    mirror, nlegs = mirror_audit(cls)
    stacks, pairs = stack_audit(cls)
    ncomp, comp_sizes = connected_components(cls)

    img, tex = setup_bake_image(low, target)
    if img is None:
        return (fail("mesh has no UV layer", 3),) + none3
    bake_result = bake_normal(high, low)

    lod1 = make_lod(low, "ConesLOD1", LOD1_TARGET, skip_decimate)
    lod2 = make_lod(low, "ConesLOD2", 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 = convex_hull_collider(low, "ConesCollider")
    col_tris = triangle_count(collider.data)

    export_path = os.path.join(tempfile.gettempdir(), f"bdt_traffic_cones_{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
    if os.path.isfile(export_path):
        try:
            os.remove(export_path)
        except OSError:
            pass

    sinks = [s for _l, s in sup]
    print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
    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} min=({bb[0]:.4f},{bb[1]:.4f})")
    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 shells={len(cls['all'])} slab_top={cls['slab_top']:.5f} "
          f"supports={len(sup)} {[(l, round(s * 1000, 3)) for l, s in sup]}")
    print(f"measured boards={len(cls['boards'])} board_bites_mm={[round(b * 1000, 3) for b in brd]}")
    if seats:
        print(f"measured bands={len(cls['bands'])} seat_mm=({min(seats) * 1000:.3f},"
              f"{max(seats) * 1000:.3f}) proud_min_mm={min(prouds) * 1000:.3f} orphans={orphans}")
    print(f"measured plumb={[(round(t, 4), round(h, 5), round(w, 5)) for t, h, w in plumb]}")
    print(f"measured legs={nlegs} mirror_mm={mirror * 1000:.4f}")
    print(f"measured stacks={[len(s) for s in stacks]} pairs="
          f"{[(round(p, 5), round(a * 1000, 3), round(b * 1000, 3)) for p, a, b in pairs]}")
    print(f"measured components={ncomp} sizes={comp_sizes[-5:]}")

    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),) + none3
    if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
        return (fail(f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}", 5),) + none3
    for idx, floor in FACE_FLOORS.items():
        if idx_counts.get(idx, 0) < floor:
            return (fail(f"{MAT_LABELS[idx]} faces {idx_counts.get(idx, 0)} < {floor}", 5),) + none3
    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),) + none3
    if overlap > UV_OVERLAP_MAX:
        return (fail(f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7),) + none3
    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),) + none3
    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),) + none3
    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),) + none3
    if col_tris > COLLIDER_TRIS_MAX:
        return (fail(f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", 11),) + none3
    if bake_result != {"FINISHED"} or not img.has_data:
        return (fail(f"bake failed result={bake_result} has_data={img.has_data}", 12),) + none3
    if export_size <= 0:
        return (fail("export file missing or empty", 13),) + none3
    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}", 15),) + none3
    if bb[2] > ZMIN_EPS or bb[2] < -ZMIN_EPS:
        return (fail(f"grounded zmin={bb[2]:.5f}", 16),) + none3
    if len(sup) != SUPPORT_COUNT or any(not (SINK_MIN <= s <= SINK_MAX) for s in sinks):
        return (fail(f"supports: {len(sup)} (want {SUPPORT_COUNT}), sink into the slab "
                     f"{[(l, round(s * 1000, 3)) for l, s in sup]} mm (band "
                     f"[{SINK_MIN * 1000}, {SINK_MAX * 1000}])", 16),) + none3
    if (len(cls["boards"]) != BOARD_COUNT or len(brd) != BOARD_LEG_JOINTS
            or any(not (BOARD_BITE_MIN <= b <= BOARD_BITE_MAX) for b in brd)):
        return (fail(f"boards into legs: {len(cls['boards'])} boards, {len(brd)} joints "
                     f"{[round(b * 1000, 3) for b in brd]} mm (band [{BOARD_BITE_MIN * 1000}, "
                     f"{BOARD_BITE_MAX * 1000}])", 17),) + none3
    if (len(cls["bands"]) != BANDS or orphans or not seats
            or min(seats) < BAND_SEAT_MIN or max(seats) > BAND_SEAT_MAX
            or min(prouds) < BAND_PROUD_MIN):
        return (fail(f"band seat: {len(cls['bands'])} bands, orphans {orphans}, seat "
                     f"{min(seats) * 1000 if seats else 0:.3f}..{max(seats) * 1000 if seats else 0:.3f} mm "
                     f"(band [{BAND_SEAT_MIN * 1000}, {BAND_SEAT_MAX * 1000}]), proud min "
                     f"{min(prouds) * 1000 if prouds else 0:.3f} mm", 18),) + none3
    if (len(plumb) != PLUMB_CONES or any(t > PLUMB_MAX_DEG or abs(h - CONE_H) > CONE_H_TOL
                                         or abs(w - BASE_W) > BASE_W_TOL for t, h, w in plumb)
            or nlegs != LEGS or mirror > MIRROR_EPS):
        return (fail(f"plumb/size/mirror: {[(round(t, 3), round(h, 4), round(w, 4)) for t, h, w in plumb]}"
                     f", legs {nlegs}, mirror {mirror * 1000:.3f} mm", 19),) + none3
    if (len(stacks) != 1 or len(stacks[0]) != STACK_N
            or any(not (PITCH_MIN <= p <= PITCH_MAX) or a < NEST_MIN or b > NEST_MAX
                   for p, a, b in pairs)):
        return (fail(f"nesting: stacks {[len(s) for s in stacks]}, pairs "
                     f"{[(round(p, 4), round(a * 1000, 3), round(b * 1000, 3)) for p, a, b in pairs]}"
                     f" (pitch [{PITCH_MIN}, {PITCH_MAX}], bite [{NEST_MIN * 1000}, "
                     f"{NEST_MAX * 1000}] mm)", 20),) + none3
    if ncomp != 1:
        return (fail(f"assembly splits into {ncomp} components {comp_sizes}", 21),) + none3
    return 0, low, target, tex


def wire_normal(mat, tex):
    nt = mat.node_tree
    bsdf = nt.nodes["Principled BSDF"]
    nrm = nt.nodes.new("ShaderNodeNormalMap")
    nrm.inputs["Strength"].default_value = 1.0
    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])


def render_still(low, target, tex, path, engine):
    scene = bpy.context.scene
    wire_normal(target, tex)
    for ob in list(scene.objects):
        if ob.type == "MESH" and ob != low:
            ob.hide_render = True
            ob.hide_viewport = True

    low.rotation_euler.z = math.radians(HERO_YAW_DEG)
    bpy.context.view_layer.update()
    corners = [low.matrix_world @ Vector(c) for c in low.bound_box]
    lo = Vector((min(c.x for c in corners), min(c.y for c in corners), min(c.z for c in corners)))
    hi = Vector((max(c.x for c in corners), max(c.y for c in corners), max(c.z for c in corners)))
    centre = (lo + hi) * 0.5

    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)
    floor.location.z = -0.001
    scene.collection.objects.link(floor)
    wall = bpy.data.objects.new("Wall", floor_me.copy())
    wall.location = (0.0, WALL_Y, 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, offset, energy, size, col, target=None, spread=None):
        ld = bpy.data.lights.new(name, "AREA")
        ld.energy = energy
        ld.size = size
        ld.color = col
        if spread is not None:
            ld.spread = math.radians(spread)
        ob = bpy.data.objects.new(name, ld)
        ob.location = centre + Vector(offset)
        aim_at = centre if target is None else Vector(target)
        ob.rotation_euler = (aim_at - ob.location).normalized().to_track_quat("-Z", "Y").to_euler()
        scene.collection.objects.link(ob)

    # Key from the camera's left and high, a cool fill low right, a cool rim
    # behind to lift the cones and the barricade off the wall, and the warm
    # wedge pooled on the back wall.
    light("Key", (-3.6, -4.6, 4.4), 205.0, 3.0, (1.0, 0.95, 0.90), spread=32.0)
    light("Fill", (5.0, -3.4, 1.2), 26.0, 7.0, (0.72, 0.82, 1.0))
    light("Rim", (-1.4, 3.0, 3.0), 170.0, 2.5, (0.62, 0.78, 1.0))
    light("Wedge", (3.4, 2.4, 2.6), 330.0, 4.0, (1.0, 0.68, 0.38),
          target=(centre.x + 2.6, WALL_Y, 0.4))

    cam_data = bpy.data.cameras.new("Cam")
    cam_data.lens = CAM_LENS
    cam = bpy.data.objects.new("Cam", cam_data)
    view = Vector((CAM_VIEW[0], CAM_VIEW[1], 0.0)).normalized()
    cam.location = centre + view * CAM_DIST + Vector((0.0, 0.0, CAM_LIFT))
    scene.collection.objects.link(cam)
    aim = bpy.data.objects.new("Aim", None)
    aim.location = centre + Vector(AIM_OFFSET)
    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
    # Standard, not AgX: AgX washes the fluorescent orange toward pastel
    scene.view_settings.view_transform = "Standard"

    fcode = gallery_framing.check_framing(scene, cam, hero=[low], elements=[low],
                                          stage=[floor, wall])
    if fcode:
        return fcode
    # asset-quality floors return 11, which this piece spends on the
    # collider ceiling; remap at the call site
    if gallery_asset_quality.check_asset_quality(scene, cam, [low], stage=[floor, wall]):
        return 22
    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-cone", action="store_true")
    p.add_argument("--gap-board", action="store_true")
    p.add_argument("--float-band", action="store_true")
    p.add_argument("--lean-cone", action="store_true")
    p.add_argument("--skew-leg", action="store_true")
    p.add_argument("--loose-stack", action="store_true")
    p.add_argument("--loose-lamp", action="store_true")
    args = p.parse_args(argv)

    code, low, target, tex = check(
        args.skip_decimate,
        lift_z=args.lift_z,
        stray_vert=args.stray_vert,
        float_cone=args.float_cone,
        gap_board=args.gap_board,
        float_band=args.float_band,
        lean_cone=args.lean_cone,
        skew_leg=args.skew_leg,
        loose_stack=args.loose_stack,
        loose_lamp=args.loose_lamp,
    )
    if code:
        return code
    if args.output:
        rcode = render_still(low, target, tex, os.path.abspath(args.output), args.engine)
        if rcode:
            return rcode
        print(f"rendered still {args.output}")
    print("traffic-cones 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)
Scuffed orange cones on a patch of asphalt, three in a row, three stacked and one knocked over showing its open base, before a striped A-frame barricade with an amber lamp.