# SPDX-License-Identifier: Apache-2.0
"""Goldfish v01c — printable pebble enclosure, designed for drops and hands.

    ~/.venvs/goldfish-cad/bin/python pebble_enclosure.py      # writes out_pebble/

Rigid parts (PETG): top_shell, bottom_shell, eye_guide (white), eye_lens (translucent), read_button, slider_caps.
Soft parts (TPU 95A): bumper_band, foot, post_washer (one per board post).
Also layout.json (every placed part, masses, centre of mass) for the viewer, the site and validate.py.

What changed from v01b, and why:
- 2.2 mm walls (was 1.8) for drop strength.
- A TPU band around the parting line, 0.4 mm proud: takes side impacts, gives grip, hides the seam,
  and its stretch holds the halves together if a snap lets go in a fall.
- A TPU foot on the base: grip on a table, a cushion for flat drops.
- Six snaps (was four).
- Board hung on TPU washers (shock mount); battery held by ribs.
- No black eye: a white light guide under a translucent, shell-tinted lens set in a shallow dish.
  When the ring is off the eye nearly disappears; when lit, the segments glow through the shell colour.
- Smoother surfaces: 64 loft stations × 96 points per section.
"""
from __future__ import annotations

import json
import math
from pathlib import Path

from build123d import Align, Box as BBox, Compound, Cylinder, Location, Sphere, export_step, export_stl, scale

from pebble_layout import (BOSS_CAP, BOSS_D, DECK_T, INSERT_DEPTH, INSERT_HOLE, NFC_ICON, NUT_AF, NUT_CLEAR, NUT_M, POST_D,
                           STRAP_T, STRAP_W, POST_FLARE_OUT, POST_FLARE_R, Cavity, Spec, flare_height, pack, post_flare)

OUT = Path(__file__).parent / "out_pebble"
OUT.mkdir(exist_ok=True)

SPEC = Spec(**json.loads((Path(__file__).parent / "pebble_spec.json").read_text()))
W_ = SPEC.wall
FIT, TONGUE_W, TONGUE_H = 0.15, 1.0, 4.0   # tall tongue: it is also the land the light strip is glued to
BAND_LO, BAND_HI, BAND_DEPTH, BAND_PROUD = -3.5, 4.0, 0.8, 0.4   # light band about the seam: natural (translucent) TPU over the strip
LED_PITCH, PERF_W, PERF_H, LED_DZ = 6.94, 3.0, 3.6, 0.5          # Mini Skinny strip (144/m); one opening per LED; LED centre above the seam
FOOT_A, FOOT_B, FOOT_RECESS, FOOT_PROUD = 34.0, 13.0, 0.6, 0.4
PAD_TOP = 4.5                   # soft thermal pad between the heatsink and the roof patch: three 1.5 mm layers (ADR-0020; 1.5 → 3.0 in ADR-0018)
RING_LEDS = 50                  # LEDs on the seam: display.py and goldfish.js drive exactly this many
DISH = 1.0                      # depth of the eye dish (ADR-0015: 1 mm, so a palm picking it up rides over the eye)
import os
RES = dict(stations=34, n=48)   # loft shape definition; the surface is a smooth B-spline at any station count (denser lofts broke the CAD kernel's booleans)
DENS = {"PETG": 1.27, "TPU": 1.21}   # g/cm³
LOFTS = {}                       # the lofted body at each inset, built on first use (not checkpointed: ≈ 0.5 s each)


def clean_stl(path):
    """Drop zero-volume fragments that tessellation leaves on degenerate faces, and close a tessellation crack of a
    few edges (a sliver face where two cuts meet: 6 open edges on the band at the USB-C cut in ADR-0020's build). A
    bigger opening is left alone, so "Printable meshes" still fails on a real hole."""
    import numpy as np
    import trimesh
    m = trimesh.load(path)
    parts = m.split(only_watertight=False)
    keep = [p for p in parts if abs(p.volume) > 1e-3]
    if len(keep) < len(parts):
        m = trimesh.util.concatenate(keep)
        m.export(path)
    if not m.is_watertight:
        u, c = np.unique(m.edges_sorted, axis=0, return_counts=True)
        if (c == 1).sum() <= 24:
            trimesh.repair.fill_holes(m)
            if m.is_watertight:
                m.export(path)
                print(f"  closed a {int((c == 1).sum())}-edge tessellation crack in {path.name}")


def half(z0, up=True):
    return BBox(600, 600, 600, align=(Align.CENTER, Align.CENTER, Align.MIN if up else Align.MAX)).moved(Location((0, 0, z0)))


def slab(z0, z1):
    return half(z0, True) & half(z1, False)


# Root flares (ADR-0016): a cylinder that meets the roof at a sharp corner snaps there, and in a print the layers
# part at that corner first. Each post gets a concave fillet of radius R (vertical where it leaves the post) that
# turns into a 45° flare until it is `out` wider at the roof: the 45° part prints without new supports. The sizes
# and the anchoring live in pebble_layout.py (POST_FLARE_R, POST_FLARE_OUT, post_flare), shared with the packer.
def root_flare(cx, cy, r, z_top, R, out):
    """Solid of revolution about (cx, cy): radius r at z_top − flare_height, r + out at z_top, then 3 mm of
    full width above z_top (buried in the wall; the caller clips it to the body)."""
    from build123d import Face, Line, ThreePointArc, Wire, revolve, Axis
    zb = z_top - flare_height(R, out)
    c45, s45 = math.cos(math.pi / 4), math.sin(math.pi / 4)
    a_mid = math.radians(157.5)
    p0, p45 = (r, 0, zb), (r + R - R * c45, 0, zb + R * s45)
    pm = (r + R + R * math.cos(a_mid), 0, zb + R * math.sin(a_mid))
    pt, pu = (r + out, 0, z_top), (r + out, 0, z_top + 3.0)
    wire = Wire([Line((0, 0, zb), p0), ThreePointArc(p0, pm, p45), Line(p45, pt), Line(pt, pu),
                 Line(pu, (0, 0, z_top + 3.0)), Line((0, 0, z_top + 3.0), (0, 0, zb))])
    return revolve(Face(wire), Axis.Z, 360).moved(Location((cx, cy, 0)))


SCREW_CLEAR = 2.8    # M2.5 clearance hole
SCREW_PROTRUDE = 1.6 # an M2.5 × 6 through a 1.6 mm board (or strap), the 0.8 mm cap and the 2.0 mm nut leaves 1.6 mm


def hex_prism(af, h):
    """A hexagonal prism, af across flats, h tall, from z = 0 up, centred on the z axis."""
    from build123d import RegularPolygon, extrude
    return extrude(RegularPolygon(af / math.sqrt(3), 6), h, dir=(0, 0, 1))


def flared_cylinder(cx, cy, r, z_root, z_end, R=1.0, out=1.0):
    """A boss of radius r from z_root to z_end with a flared root (fillet into 45°, as ADR-0016) at z_root; the root
    side is extended 3 mm past z_root at full width so it buries in the wall. Works upward or downward."""
    from build123d import Plane, mirror
    up = z_end > z_root
    h = abs(z_end - z_root)
    body_ = Cylinder(r, h + 3.0, align=(Align.CENTER, Align.CENTER, Align.MIN)).moved(Location((0, 0, -3.0)))
    fl = root_flare(0, 0, r, 0.0, R, out)              # wide end at z = 0, narrowing downward
    if up:
        fl = mirror(fl, about=Plane.XY)                 # wide end at z = 0, narrowing upward
    else:
        body_ = mirror(body_, about=Plane.XY)
    return (body_ + fl).moved(Location((cx, cy, z_root)))


def nut_boss_cuts(cx, cy, z_face, toward, side):
    """The cuts for one captive-nut boss: hex pocket, side slot (toward a unit vector in xy, for sliding the nut in)
    and the screw hole. z_face is the boss face the board (or strap) bears on; side is 'up' (board above the boss)
    or 'down' (board below, the boss hangs from the roof)."""
    af, hp = NUT_AF + NUT_CLEAR, NUT_M + 0.3
    if side == "up":
        z_pk = z_face - BOSS_CAP - hp
        hole = Cylinder(SCREW_CLEAR / 2, BOSS_CAP + hp + SCREW_PROTRUDE + 0.3, align=(Align.CENTER, Align.CENTER, Align.MAX)).moved(Location((cx, cy, z_face + 0.01)))
    else:
        z_pk = z_face + BOSS_CAP
        hole = Cylinder(SCREW_CLEAR / 2, BOSS_CAP + hp + SCREW_PROTRUDE + 0.3, align=(Align.CENTER, Align.CENTER, Align.MIN)).moved(Location((cx, cy, z_face - 0.01)))
    pocket = hex_prism(af, hp).moved(Location((cx, cy, z_pk)))
    ang = math.degrees(math.atan2(toward[1], toward[0]))
    slot = BBox(BOSS_D, af, hp, align=(Align.MIN, Align.CENTER, Align.MIN)).moved(Location((cx, cy, z_pk)) * Location((0, 0, 0), (0, 0, ang)))
    tip = (z_pk - SCREW_PROTRUDE - 0.3) if side == "up" else (z_pk + hp + SCREW_PROTRUDE + 0.3)
    return pocket + slot + hole, tip


def fuse_strict(base, piece, what):
    """Union that must take in the whole piece and stay one valid solid; OCC unions on spline faces can fail silently.
    Judged on the small piece (is any of it left outside the result?), not on the shell's volume: a shell's own
    volume carries hundreds of mm³ of integration error on its spline faces (ADR-0018)."""
    vp = piece.volume
    for tol in (None, 0.01, 0.05):
        try:
            new = base.fuse(piece, tol=tol) if tol else base + piece
        except Exception:
            continue
        if len(new.solids()) != 1 or not new.is_valid:
            continue
        left = piece - new
        if left is None or left.volume < 0.01 * vp + 0.01:
            return new
    raise RuntimeError(f"{what} could not be fused")


def main():
    import time
    _t = [time.time()]

    def tick(what):
        now = time.time()
        print(f"  {what:22s} {now - _t[0]:6.1f} s", flush=True)
        _t[0] = now
    ok, fails, placed, info = pack(SPEC)
    assert ok, fails
    P = {b.name: b for b in placed}
    pb = SPEC.pebble()

    def body(inset=0.0):
        if inset not in LOFTS:
            LOFTS[inset] = pb.solid(inset=inset, **RES)
        return LOFTS[inset]
    tick('pack')
    outer, cavity = body(), body(W_)
    tick('lofts')
    shell = outer - cavity

    batt = P["battery"]
    z_split = batt.at[2] + batt.size[2] + 0.25
    top = shell & half(z_split, True)
    bottom = shell & half(z_split, False)

    tick('shell split')
    # tongue on the bottom shell (fused below the split, sliding fit above it)
    # tongue: extruded from the analytic outline at the parting line (the walls are near-vertical there);
    # this avoids loft-minus-loft booleans, which fail silently at some sizes
    def outline_face(d, z):
        from build123d import Polyline, Face, Wire
        def half_w(x):
            w, ht_, hb_ = pb.inset_of(pb.half_width, x, d), pb.inset_of(pb.top, x, d), pb.inset_of(pb.bot, x, d)
            n_, h_ = (pb.n_top, ht_) if z >= 0 else (pb.n_bot, hb_)
            if w <= 0 or h_ <= abs(z):
                return 0.0
            return w * (1 - (abs(z) / h_) ** n_) ** (1 / n_)
        Lh = SPEC.L / 2 - d * 2.6
        xs_ = [-Lh + 2 * Lh * (0.5 - 0.5 * math.cos(math.pi * i / 240)) for i in range(241)]
        pts = [(x, half_w(x)) for x in xs_ if half_w(x) > 0.3]
        ring = [(x, y, 0) for x, y in pts] + [(x, -y, 0) for x, y in reversed(pts)]
        return Face(Wire([Spline(*ring, periodic=True)]))
    from build123d import Spline, extrude
    import numpy as np
    from scipy import ndimage
    from pebble_layout import inside as _in

    def smooth_land(d_extra, r_min, zs, step=0.05):
        """Plan outline of the strip land: inside the cavity at every z in zs, d_extra in from it, and with
        no corner tighter than r_min (erode by d_extra + r_min, dilate by r_min). The analytic cavity is blunt
        at the tips; a strip cannot follow a blunt corner."""
        xs = np.arange(-SPEC.L / 2, SPEC.L / 2 + step, step); ys = np.arange(-SPEC.W / 2, SPEC.W / 2 + step, step)
        X, Y = np.meshgrid(xs, ys, indexing="ij")
        Lc = SPEC.L - 2 * W_ * 2.6
        U = np.where(X >= SPEC.x_peak * SPEC.L, (X - SPEC.x_peak * SPEC.L) / (SPEC.L / 2 - SPEC.x_peak * SPEC.L), (X - SPEC.x_peak * SPEC.L) / (SPEC.L / 2 + SPEC.x_peak * SPEC.L))
        se = lambda v, p_: np.power(np.clip(1 - np.power(np.clip(np.abs(v), 0, 1), p_), 0, 1), 1 / p_)
        mask = np.abs(X) <= Lc / 2
        hw = SPEC.W / 2 * se(U, SPEC.p_plan) - W_
        # the profiles depend on x only: evaluate them once per column and broadcast (np.vectorize over the whole
        # grid took ≈ 60 s of a 5 min build)
        top_x = np.broadcast_to(np.array([pb.top(x) for x in xs])[:, None], X.shape) - W_
        bot_x = np.broadcast_to(np.array([pb.bot(x) for x in xs])[:, None], X.shape) - W_
        for z in zs:
            hz = top_x if z >= 0 else bot_x
            n_ = pb.n_top if z >= 0 else pb.n_bot
            ok = (hw > 0) & (hz > abs(z))
            wz = np.where(ok, hw * np.power(np.clip(1 - np.power(abs(z) / np.where(ok, hz, 1), n_), 0, 1), 1 / n_), 0)
            mask &= ok & (np.abs(Y) <= wz)
        dist_in = ndimage.distance_transform_edt(mask) * step
        core = dist_in >= d_extra + r_min
        land = ndimage.distance_transform_edt(~core) * step <= r_min
        cx0 = float(xs[np.where(land.any(axis=1))[0]].mean())
        pts = []
        for k in range(1440):
            a = 2 * math.pi * k / 1440
            lo, hi = 0.0, SPEC.L / 2
            for _ in range(40):
                m = (lo + hi) / 2
                i = int(round((cx0 + m * math.cos(a) + SPEC.L / 2) / step)); j = int(round((m * math.sin(a) + SPEC.W / 2) / step))
                if 0 <= i < land.shape[0] and 0 <= j < land.shape[1] and land[i, j]: lo = m
                else: hi = m
            pts.append((cx0 + lo * math.cos(a), lo * math.sin(a)))
        return pts

    LAND_D, LAND_R = TONGUE_W + FIT, 10.0
    land_pts = smooth_land(LAND_D, LAND_R, [z_split - 3.6 + 0.6 * k for k in range(14)])
    def pts_face(pts):
        from build123d import Face, Wire
        return Face(Wire([Spline(*[(x, y, 0) for x, y in pts[::4]], periodic=True)]))
    def land_prism(d_out, z0, z1):
        zm = (z0 + z1) / 2
        f = outline_face(d_out, zm) - pts_face(land_pts)
        return extrude(f.moved(Location((0, 0, z0))), z1 - z0, dir=(0, 0, 1))
    def prism(d_out, d_in, z0, z1):
        zm = (z0 + z1) / 2
        f = outline_face(d_out, zm) - outline_face(d_in, zm)
        return extrude(f.moved(Location((0, 0, z0))), z1 - z0, dir=(0, 0, 1))
    tongue = land_prism(W_ + FIT, z_split - 0.3, z_split + TONGUE_H)   # rooted 0.3 mm into the land: no coplanar faces
    # clipped to the top shell's cavity less the fit: at the tips the analytic outline and the loft differ (ADR-0015, the
    # mechanical review found 3.6 mm of tongue inside the top shell's tip walls)
    _v = tongue.volume
    tongue = tongue & body(W_ + FIT)
    if not tongue.volume or tongue.volume > _v * 1.0001:
        raise RuntimeError("tongue clip failed")
    root = land_prism(W_ - 0.2, z_split - 3.6, z_split) & body(0.08)   # thick below the seam: the strip's land (smooth, r ≥ 10 mm)
    for piece in (root, tongue):   # small fuzzy tolerance: plain unions of these spline faces fail silently
        v0, vp = bottom.volume, piece.volume
        for tol in (None, 0.01, 0.05):
            new = bottom.fuse(piece, tol=tol) if tol else bottom + piece
            if len(new.solids()) == 1 and new.is_valid and v0 < new.volume <= v0 + vp * 1.03:
                bottom = new
                break
        else:
            raise RuntimeError("tongue/land could not be fused to the bottom shell")

    tick('tongue and land')
    # six snaps on the long sides
    zc = z_split + TONGUE_H - 0.8          # above the LED openings
    bumps, dimples = [], []
    for sx in (-38.0, -8.0, 24.0):
        sl = tongue & BBox(0.2, 80, 0.2).moved(Location((sx, 0, zc)))   # the tongue's real outer face at this x
        w = max(abs(sl.bounding_box().min.Y), abs(sl.bounding_box().max.Y)) if sl.volume > 0 else pb.half_width(sx) - W_ - FIT
        for sy in (-1, 1):
            # ADR-0015: 0.25 mm proud of the tongue (0.10 mm interference past the 0.15 fit), down from 0.35: PETG tongue
            # root strain ≈ 2 %, so the shells open many times without whitening
            bumps.append(Cylinder(0.75, 7.0, rotation=(0, 90, 0)).moved(Location((sx, sy * (w - 0.5), zc))))
            dimples.append(Cylinder(0.85, 7.6, rotation=(0, 90, 0)).moved(Location((sx, sy * (w - 0.5), zc))))
    for bump in bumps:                   # fused one by one with a small fuzzy tolerance: plain unions leave loose solids here
        bottom = bottom.fuse(bump, tol=0.01)
    assert len(bottom.solids()) == 1, f"bottom shell is {len(bottom.solids())} solids"
    # each bump sits 0.25 mm proud of the tongue, into its dimple; no clip needed
    top = top.fix() - Compound(dimples)

    tick('snaps')
    # ADR-0017: a 0.4 × 45° lead-in on the top shell's inner lip. The shell prints seam edge down, so the first layers
    # squish inward (elephant's foot) and narrow the opening the tongue enters.
    # Built from the lofted cavity itself, in four 0.1 mm steps (finer than a 0.12 mm layer, so it prints as a slope):
    # step k is the cavity grown by LIP − k·0.1 mm, between z_split + k·0.1 and the next step. A cutter lofted from
    # the analytic outline bit up to 56 mm³ per 6 mm into the tip walls, where the outline runs outside the loft.
    LIP, STEP_ = 0.4, 0.1
    cutter = None
    for k in range(round(LIP / STEP_)):      # overlapping steps (all from just below the seam), so no faces are shared
        ring_ = (body(W_ - (LIP - k * STEP_)) & slab(z_split - 0.05, z_split + (k + 1) * STEP_)) - body(W_)
        cutter = ring_ if cutter is None else cutter.fuse(ring_, tol=0.001)
    # measured as top ∩ cutter: the shell's own volume carries ≈ ±15 mm³ of integration error on its spline faces,
    # too much to measure a 50 mm³ cut by difference
    removed = (top & cutter).volume
    new = top - cutter
    if not (0 < removed <= cutter.volume * 1.001 and len(new.solids()) == len(top.solids()) and new.is_valid):
        raise RuntimeError(f"lip chamfer cut failed ({removed:.0f} of a {cutter.volume:.0f} mm³ cutter, {len(new.solids())} solids)")
    info["lip_chamfer_mm3"] = [round(removed, 1), round(cutter.volume, 1)]
    top = new
    tick('lip chamfer')

    # bumper band: groove across the seam in both shells, TPU band fills it and stands 0.4 proud
    zb0, zb1 = z_split + BAND_LO, z_split + BAND_HI
    groove = (outer - body(BAND_DEPTH)) & slab(zb0, zb1)
    top, bottom = top - groove, bottom - groove
    band = (body(-BAND_PROUD) - body(BAND_DEPTH)) & slab(zb0, zb1)
    if band.volume < 0:                      # OCC returns this slab-cut shell inside-out at some seam heights; turn it right side out
        from build123d import Solid
        band = Solid(band.solids()[0].wrapped.Reversed()) if len(band.solids()) == 1 else band
    assert band.volume > 0, "bumper band is inside-out"

    tick('band')
    # coin slot under the nose (through band and shell edge)
    xn = SPEC.L / 2 - 8
    # ADR-0015: 12 mm wide × 2.6 mm tall, straddling the seam, through the band and the wall but not the tongue,
    # so a coin (1.4–2.3 mm thick) bears on the top shell above and the tongue below and twists them apart
    slot = BBox(16, 12, 2.6, align=(Align.MIN, Align.CENTER, Align.CENTER)).moved(Location((xn - 4, 0, z_split)))
    slot = slot & (body(-1.0) - body(W_))
    _bv = band.volume
    top, bottom = top - slot, bottom - slot
    # OCC cuts on the band's spline faces can fail silently: nudge off coincident faces, and judge the cut by what is
    # left of the band inside the slot (measured as an intersection: the band's own volume carries hundreds of mm³ of
    # integration error at some shapes, so a volume difference can't tell a good cut from a bad one)
    for dz in (0.0, 0.017, -0.023, 0.041):
        s_ = slot.moved(Location((0.013, 0, dz)))
        had = (band & s_)
        nb = band - s_
        left = (nb & s_) if nb is not None else None
        if nb is not None and had is not None and had.volume > 1 and (left is None or left.volume < 0.05) and len(nb.solids()) == 1 and nb.is_valid:
            band = nb
            break
    else:
        raise RuntimeError(f"coin slot cut through the band failed ({_bv:.0f} mm³)")
    info["coin_slot"] = [12.0, 2.6]

    tick('coin slot')
    # eye: shallow dish, white light guide with 5 segment channels + centre pin, translucent lens
    x_eye = info["x_eye"]
    leds = [b for b in placed if b.name.startswith("LED")] or [b for b in placed if "sequin" in b.name]
    r_out = (18.4 + 1.6) if SPEC.eye_leds == 12 else 9.0 if SPEC.eye_leds == 0 else (SPEC.eye_r + 4.55 + 1.6)   # ring OD 36.8 mm; one light: Ø18 lens
    led_top = min(b.at[2] + b.size[2] for b in leds)
    z_eye_top = pb.top(x_eye)
    R = r_out ** 2 / (2 * DISH)
    dish = Sphere(R).moved(Location((x_eye, 0, z_eye_top + R - DISH)))
    top = top - dish
    top = top - Cylinder(r_out, 60).moved(Location((x_eye, 0, 0)))
    guide = Cylinder(r_out - FIT, 40, align=(Align.CENTER, Align.CENTER, Align.MIN)).moved(Location((x_eye, 0, led_top)))
    LENS = 1.0                                       # ADR-0015: a uniform 1 mm lens that follows the dish
    under_lens = Sphere(R + LENS).moved(Location((x_eye, 0, z_eye_top + R - DISH)))
    guide = guide - under_lens
    for b in (leds if SPEC.eye_leds else []):
        cx, cy = b.at[0] + b.size[0] / 2, b.at[1] + b.size[1] / 2
        a = math.atan2(cy, cx - x_eye)
        # one light well per dot: a short radial slot so neighbouring dots don't bleed
        chan = BBox(4.6 if SPEC.eye_leds == 12 else 5.2, 6.0 if SPEC.eye_leds == 12 else 8.0, 40, align=(Align.CENTER, Align.CENTER, Align.MIN)).moved(Location((cx, cy, led_top - 1)) * Location((0, 0, 0), (0, 0, math.degrees(a) + 90)))
        guide = guide - chan
    cradle_part = None
    if not SPEC.eye_button:
        guide = guide - Cylinder(2.6 if SPEC.eye_leds == 0 else 0.9, 40, align=(Align.CENTER, Align.CENTER, Align.MIN)).moved(Location((x_eye, 0, led_top - 1)))
    else:
        # the guide is the button: solid at the centre (the switch plunger presses there), loose in its opening,
        # held in by its flange; the cradle under it hangs from the roof on three arms clear of the eye light
        sw = P["eye switch (tactile, in cradle)"]
        z0, z_top = sw.at[2], sw.at[2] + sw.size[2]
        cup = (Cylinder(5.6, z_top - 0.4 - z0, align=(Align.CENTER, Align.CENTER, Align.MIN))
               - BBox(6.3, 6.3, 40, align=(Align.CENTER, Align.CENTER, Align.MIN)).moved(Location((0, 0, 1.2)))).moved(Location((x_eye, 0, z0)))
        arm_z0 = z_top - 2.4
        # ADR-0015: the hangers stay in the top shell as pins; the cradle is its own print with holes for them, so the
        # guide goes in first and the cradle presses on after (a drop of CA on each pin). The cup has notches for the
        # DTS-61K's legs (6.5 × 4.5 mm apart) and a floor slot for its wires.
        pins = None
        cradle_arms = None
        for ang in (-30, 150, 270):
            a = math.radians(ang)
            pin = Cylinder(1.4, 30, align=(Align.CENTER, Align.CENTER, Align.MIN)).moved(Location((x_eye + 13.0 * math.cos(a), 13.0 * math.sin(a), arm_z0)))
            arm = BBox(14.0, 2.4, 1.8, align=(Align.MIN, Align.CENTER, Align.MIN)).moved(Location((x_eye, 0, arm_z0)) * Location((0, 0, 0), (0, 0, ang)))
            boss = Cylinder(2.6, 1.8, align=(Align.CENTER, Align.CENTER, Align.MIN)).moved(Location((x_eye + 13.0 * math.cos(a), 13.0 * math.sin(a), arm_z0)))
            hole = Cylinder(1.55, 5, align=(Align.CENTER, Align.CENTER, Align.MIN)).moved(Location((x_eye + 13.0 * math.cos(a), 13.0 * math.sin(a), arm_z0 - 1)))
            pins = pin if pins is None else pins + pin
            part = arm + boss - hole
            cradle_arms = part if cradle_arms is None else cradle_arms + part
        legs = (BBox(1.4, 6.0, 20, align=(Align.CENTER, Align.CENTER, Align.MIN)).moved(Location((x_eye - 3.25, 0, z0 - 5)))
                + BBox(1.4, 6.0, 20, align=(Align.CENTER, Align.CENTER, Align.MIN)).moved(Location((x_eye + 3.25, 0, z0 - 5)))
                + BBox(3.0, 3.0, 5, align=(Align.CENTER, Align.CENTER, Align.MIN)).moved(Location((x_eye, 0, z0 - 2))))
        keep_out = Cylinder(r_out + 2.0 + FIT + 0.4, 5, align=(Align.CENTER, Align.CENTER, Align.MIN)).moved(Location((x_eye, 0, led_top - 0.2)))
        cradle_part = ((cup + cradle_arms) - legs) & body(0.2)
        cradle_part = cradle_part - keep_out
        top = top + ((pins & body(0.2)) - keep_out)
        info["eye_switch"] = [x_eye, 0.0, z_top]
    flange = Cylinder(r_out + 2.0, 1.0, align=(Align.CENTER, Align.CENTER, Align.MIN)).moved(Location((x_eye, 0, led_top)))
    guide = guide + ((flange - Cylinder(r_out - 3, 3, align=(Align.CENTER, Align.CENTER, Align.MIN)).moved(Location((x_eye, 0, led_top - 0.5)))) & body(W_ - 0.3))
    top = top - Cylinder(r_out + 2.0 + FIT, 1.0 + FIT, align=(Align.CENTER, Align.CENTER, Align.MIN)).moved(Location((x_eye, 0, led_top - FIT)))
    lens = ((under_lens - dish) & Cylinder(r_out - FIT, 60).moved(Location((x_eye, 0, 0))) & half(led_top, True))

    tick('eye')
    # ADR-0015 heat stack: SoC → 1.5 mm soft pad → 14 × 14 × 6 heatsink → 1.5 mm soft pad → aluminium tape on the roof.
    # A boss (or a shallow pocket) on the roof sets the squeeze to 0.5 mm, whatever the column height, so the pads
    # touch without loading the board (55 Shore 00 pads, a few newtons).
    col = P.get("SoC thermal pad (to the roof)")
    if col is not None:
        STACK, SQUEEZE = 1.5 + 6.0 + PAD_TOP, 0.5   # ADR-0018: a 3 mm top pad fills the taller case (gap pad, not PETG)
        h = info["soc_pad_gap"] - (STACK - SQUEEZE)
        roof_z = col.at[2] + col.size[2]
        pad = BBox(16.0, 16.0, abs(h) + 0.01, align=(Align.CENTER, Align.CENTER, Align.MAX if h > 0 else Align.MIN))
        pad = pad.moved(Location((col.at[0] + col.size[0] / 2, col.at[1] + col.size[1] / 2, roof_z + (0.01 if h > 0 else -0.01))))
        top = (top + (pad & body(0.0))) if h > 0 else (top - pad)
        info["heat_boss_mm"] = round(h, 2)

    tick('heat boss')
    # board posts: insert bores; the board sits on a 1 mm TPU washer under each post
    washers, flares = [], []
    cav = Cavity(pb, W_)
    for b in placed:
        if b.name.startswith("post"):
            cx, cy = b.at[0] + POST_D / 2, b.at[1] + POST_D / 2
            z0 = b.at[2] + 1.0
            post = Cylinder(POST_D / 2, 40, align=(Align.CENTER, Align.CENTER, Align.MIN)).moved(Location((cx, cy, z0))) & outer
            top = top + post
            # ADR-0016: flared root. Anchored at the lowest point where the post meets the roof, so the fillet is
            # whole on the downhill side; uphill (the roof slopes up to 3 mm across a post) it ends in a short
            # collar of the full flare width up to the roof.
            fl = post_flare(cav, cx, cy)
            flare = root_flare(cx, cy, POST_D / 2, fl["z_ref"], POST_FLARE_R, POST_FLARE_OUT) & body(0.2)
            top = fuse_strict(top, flare, f"root flare of {b.name}")
            flares.append({"post": b.name, "c": [round(cx, 2), round(cy, 2)], "r": POST_D / 2, "R": POST_FLARE_R, "out": POST_FLARE_OUT,
                           "z0": round(fl["z0"], 2), "z_ref": round(fl["z_ref"], 2), "collar_mm": round(fl["collar"], 2), "post_len": round(fl["z_ref"] - z0, 2)})
            top = top - Cylinder(INSERT_HOLE / 2, INSERT_DEPTH, align=(Align.CENTER, Align.CENTER, Align.MIN)).moved(Location((cx, cy, z0 - 0.01)))
            washers.append((Cylinder(3.0, 1.0, align=(Align.CENTER, Align.CENTER, Align.MIN)) - Cylinder(1.4, 1.0, align=(Align.CENTER, Align.CENTER, Align.MIN))).moved(Location((cx, cy, b.at[2]))))

    info["post_flares"] = flares

    tick('posts')

    # ADR-0018: every board is screwed down on captive-nut bosses (or strapped across two). Bosses grow from the floor
    # (bottom shell), the roof (top shell) or the battery deck (its own print), each with a flared root, a hex pocket,
    # a side slot to slide the nut in, and a blind screw hole. The deck sits on four floor posts with heat-set inserts.
    cav_out = Cavity(pb, 0.0)
    deck_info = info.get("deck")
    deck_part = None
    if deck_info:
        dx0, dy0, dx1, dy1, dz0, dz1 = deck_info
        deck_part = BBox(dx1 - dx0, dy1 - dy0, DECK_T, align=(Align.MIN, Align.MIN, Align.MIN)).moved(Location((dx0, dy0, dz0)))
        for b in placed:
            if b.name.startswith("deck tab"):
                deck_part = deck_part + BBox(*b.size, align=(Align.MIN, Align.MIN, Align.MIN)).moved(Location(tuple(b.at)))
        for cx_, cy_, cz_ in info.get("deck_posts", []):
            post_ = flared_cylinder(cx_, cy_, POST_D / 2, cz_, dz0, R=2.0, out=1.6) & body(0.2)
            bottom = fuse_strict(bottom, post_, f"deck post at {cx_:.0f},{cy_:.0f}")
            bottom = bottom - Cylinder(INSERT_HOLE / 2, INSERT_DEPTH, align=(Align.CENTER, Align.CENTER, Align.MAX)).moved(Location((cx_, cy_, dz0 + 0.01)))
            deck_part = deck_part - Cylinder(SCREW_CLEAR / 2, 10).moved(Location((cx_, cy_, dz0)))
    mounts, straps = [], []
    # ADR-0018: each nut slot points where there is room to slide a nut in: of eight directions, the one whose path
    # (5 mm past the boss) keeps furthest from every other boss, post and mic port tube in the same shell, inside the cavity
    from pebble import inside as _inside_
    obst = {"top": [], "bottom": [], "deck": []}
    for b in placed:
        mm_ = getattr(b, "mount", None)
        if mm_:
            for (hx, hy), sup in zip(mm_["holes"], mm_["supports"]):
                obst["top" if mm_["side"] == "roof" else sup if sup == "deck" else "bottom"].append((hx, hy, BOSS_D / 2 + 1.0))
        elif b.name.startswith("post "):
            obst["top"].append((b.at[0] + POST_D / 2, b.at[1] + POST_D / 2, POST_D / 2 + 2.0))
        elif b.name.startswith("deck post"):
            obst["bottom"].append((b.at[0] + POST_D / 2, b.at[1] + POST_D / 2, POST_D / 2 + 1.6))
    for n_ in ("mic head (SPH0645)", "mic tail (SPH0645)"):
        if n_ in P:
            obst["top"].append((P[n_].at[0] + 8.25, P[n_].at[1] + (12.7 - 7.8), 2.0 + 0.6))
    col_ = P.get("SoC thermal pad (to the roof)")
    if col_ is not None:   # the heat-stack boss (16 × 16 mm) on the roof, as the circle round it
        obst["top"].append((col_.at[0] + col_.size[0] / 2, col_.at[1] + col_.size[1] / 2, 16.0 / math.sqrt(2) + 0.5))
    for ang in (-30, 150, 270):   # the eye cradle's three pins at r 13
        obst["top"].append((x_eye + 13.0 * math.cos(math.radians(ang)), 13.0 * math.sin(math.radians(ang)), 1.4 + 1.0))

    def slot_dir(hx, hy, zp, group, prefer):
        best = None
        for k in range(8):
            a_ = math.radians(math.degrees(math.atan2(prefer[1], prefer[0])) + 45 * k)
            d_ = (math.cos(a_), math.sin(a_))
            pts_ = [(hx + d_[0] * r_, hy + d_[1] * r_) for r_ in np.arange(BOSS_D / 2 + 0.5, BOSS_D / 2 + 5.5, 0.5)]
            if not all(_inside_(pb, x_, y_, zp, W_) for x_, y_ in pts_):
                continue
            clear_ = min(math.hypot(x_ - ox, y_ - oy) - orr for x_, y_ in pts_ for ox, oy, orr in obst[group] if (ox, oy) != (hx, hy)) if len(obst[group]) > 1 else 99
            if best is None or clear_ > best[0] + 1e-6:
                best = (clear_, d_)
        return best[1] if best else prefer
    for b in placed:
        m = getattr(b, "mount", None)
        if not m:
            continue
        bcx, bcy = b.at[0] + b.size[0] / 2, b.at[1] + b.size[1] / 2
        strap_box = next((o for o in placed if o.name == f"strap of {b.name}"), None)
        r_b = m.get("boss_r", BOSS_D / 2)                    # ADR-0020: the Radxa's bosses are slimmer (r 3.8)
        for (hx, hy), sup, low in zip(m["holes"], m["supports"], m["lows"]):
            toward = (bcx - hx, bcy - hy)
            n_ = math.hypot(*toward) or 1.0
            toward = (toward[0] / n_, toward[1] / n_)
            if m["side"] == "roof":
                face_ = (strap_box.at[2] + STRAP_T) if strap_box else (b.at[2] + b.size[2])
                toward = slot_dir(hx, hy, face_ + BOSS_CAP + 1.15, "top", toward)
            else:
                face_ = strap_box.at[2] if strap_box else b.at[2]
                toward = slot_dir(hx, hy, face_ - BOSS_CAP - 1.15, "deck" if sup == "deck" else "bottom", toward)
            if m["side"] == "roof":
                face = (strap_box.at[2] + STRAP_T) if strap_box else (b.at[2] + b.size[2])
                boss_ = flared_cylinder(hx, hy, r_b, low, face) & body(0.2)
                cut_, tip = nut_boss_cuts(hx, hy, face, toward, "down")
                top = fuse_strict(top, boss_, f"boss under the roof for {b.name}") - cut_
                skin = cav_out.roof(hx, hy) - tip
            else:
                face = strap_box.at[2] if strap_box else b.at[2]
                if sup == "deck":
                    boss_ = flared_cylinder(hx, hy, r_b, deck_info[5], face)
                    cut_, tip = nut_boss_cuts(hx, hy, face, toward, "up")
                    deck_part = deck_part + boss_.moved(Location((0, 0, 0)))
                    deck_part = deck_part - cut_
                    skin = tip - deck_info[4]                    # the screw tip stays above the deck's underside (the cell)
                else:
                    boss_ = flared_cylinder(hx, hy, r_b, low, face) & body(0.2)
                    cut_, tip = nut_boss_cuts(hx, hy, face, toward, "up")
                    bottom = fuse_strict(bottom, boss_, f"floor boss for {b.name}") - cut_
                    skin = tip - cav_out.floor(hx, hy)
            mounts.append({"board": b.name, "at": [round(hx, 2), round(hy, 2)], "side": m["side"], "support": sup, "kind": m["kind"],
                           "slot": [round(toward[0], 3), round(toward[1], 3)],
                           "boss_mm": round(abs(face - low), 2), "skin_mm": round(skin, 2)})
        if strap_box is not None:
            st = BBox(*strap_box.size, align=(Align.MIN, Align.MIN, Align.MIN)).moved(Location(tuple(strap_box.at)))
            for hx, hy in m["holes"]:
                st = st - Cylinder(SCREW_CLEAR / 2, 10).moved(Location((hx, hy, strap_box.at[2])))
            straps.append(st)
    info["mounts"] = mounts
    tick('mounts')

    # ADR-0018: a printed port tube from each mic's pin-hole down to 0.7 mm above the mic, which now hangs on bosses
    # 3.5 mm or more under the roof; a 1.0 mm foam ring between tube and mic squeezes 30 % and seals the port
    for n in ("mic head (SPH0645)", "mic tail (SPH0645)"):
        m = P[n]
        px_, py_ = m.at[0] + 8.25, m.at[1] + (12.7 - 7.8)
        tube_end = m.at[2] + m.size[2] + 0.7
        tube = (flared_cylinder(px_, py_, 2.0, cav.roof(px_, py_), tube_end, R=0.6, out=0.6) & body(0.2))
        top = fuse_strict(top, tube, f"port tube for {n}")
        info.setdefault("mic_tubes", {})[n] = round(tube_end, 2)

    # mic ports over the real (flipped, bottom-port) sound holes
    for n in ("mic head (SPH0645)", "mic tail (SPH0645)"):
        m = P[n]
        top = top - Cylinder(0.5, 60).moved(Location((m.at[0] + 8.25, m.at[1] + (12.7 - 7.8), 0)))

    # tail USB-C (receptacle body z 0.57–4.77 on the Adafruit 4090) — cut through shells and band
    u = P["USB-C breakout (charge port)"]
    uz = u.at[2] + 2.7
    cut = BBox(16, 9.6, 4.0).moved(Location((u.at[0] - 5, 0, uz)))
    top, bottom = top - cut, bottom - cut
    band_before_usb = band
    # plug pocket: a USB-C overmould (≈12.4 × 6.5 mm) must reach the receptacle face, so the tip is pocketed to the mouth
    pocket = BBox(u.at[0] + SPEC.L / 2 + 2, 13.2, 7.4, align=(Align.MAX, Align.CENTER, Align.CENTER)).moved(Location((u.at[0] - 0.2, 0, uz)))
    top, bottom = top - pocket, bottom - pocket
    # the band: the same two cuts, nudged off coincident faces until the band tessellates watertight (ADR-0020: at
    # 184 mm the exact cut left a sliver face with a T-junction crack no mesh repair closes). Judged on the output.
    import tempfile
    import trimesh
    for dz in (0.0, 0.017, -0.023, 0.041, 0.063):
        nb = band_before_usb - cut.moved(Location((0, 0.011, dz))) - pocket.moved(Location((0.007, 0.013, dz)))
        with tempfile.NamedTemporaryFile(suffix=".stl") as tf:
            export_stl(nb, tf.name, tolerance=0.01, angular_tolerance=0.1)
            if trimesh.load(tf.name).is_watertight:
                band = nb
                break
    else:
        raise RuntimeError("the band's USB-C cut never tessellated watertight")

    tick('mic ports, USB-C')
    # controls on the belly-side flank, below the light band: each slot sits on its part's actuator
    side_y = lambda x: -pb.half_width(x)
    ctl = {k: P[k] for k in ("power switch", "mic switch", "read switch (tactile)") if k in P}
    def centre(b):
        return b.at[0] + b.size[0] / 2, b.at[2] + b.size[2] / 2
    caps = []
    for k in ("power switch", "mic switch"):
        if k in ctl:
            cx_, cz_ = centre(ctl[k])
            sy_ = -pb.half_width(cx_)                       # outer skin (approx. at this height)
            from pebble_layout import inside as _in
            while not _in(pb, cx_, sy_ + 0.05, cz_, 0.0) and sy_ < 0: sy_ += 0.05
            face = ctl[k].at[1]                             # switch body face toward the wall
            reach = face - sy_
            if SPEC.kit_friendly:
                # ADR-0015: a printed cap pushes onto the C&K actuator from outside and stands 1.8 mm proud, so a
                # fingertip moves it (0.6 mm of bare actuator needed a nail). Power: a smooth round-ended cap and one
                # raised dot above the slot; mic: a cap with three ridges and two dots. Both turn on toward the head.
                bottom = bottom - BBox(4.0 + 2.0 + 0.5, 12, 3.2).moved(Location((cx_, sy_, cz_)))   # slot: cap sleeve + 2 mm travel + clearance
                from build123d import Axis, fillet
                sl = reach + 0.2                                     # sleeve: switch face → just outside the skin
                sleeve = BBox(4.0, sl, 2.8, align=(Align.CENTER, Align.MAX, Align.CENTER))
                head = BBox(8.0, 1.8, 4.6, align=(Align.CENTER, Align.MAX, Align.CENTER)).moved(Location((0, -sl, 0)))
                if k == "power switch":
                    head = fillet(head.edges().filter_by(Axis.Y), 1.4)          # smooth, rounded ends
                else:
                    for dx in (-2.4, 0.0, 2.4):                                  # three ridges across the travel
                        head = head + BBox(0.8, 0.6, 4.6, align=(Align.CENTER, Align.MAX, Align.CENTER)).moved(Location((dx, -sl - 1.8 + 0.01, 0)))
                hole = BBox(2.1, 4.1, 2.2, align=(Align.CENTER, Align.MAX, Align.CENTER)).moved(Location((0, 0.01, 0)))   # the 4 mm actuator pushes in
                cap = (sleeve + head) - hole
                caps.append((k, cap.moved(Location((cx_, face, cz_)))))
                # tactile dots above the slot: one for power, two for the mics (raised 0.4 mm from the skin)
                for j in range(1 if k == "power switch" else 2):
                    dot = Sphere(0.9).moved(Location((cx_ - 1.2 + 2.4 * j if k != "power switch" else cx_, sy_ - 0.5, cz_ + 3.6)))
                    bottom = bottom + (dot & body(-0.4))
            else:
                bottom = bottom - BBox(4.0 + 2.6, 12, 2.4).moved(Location((cx_, sy_, cz_)))       # slot: the 4 mm cap + 2.6 mm of switch travel
                # printed slider cap: clips over the 1.2 mm actuator and stands 0.6 mm proud of the skin
                capb = BBox(4.0, reach + 0.6, 1.8, align=(Align.CENTER, Align.MAX, Align.CENTER)) - BBox(1.4, 2.2, 1.4, align=(Align.CENTER, Align.MAX, Align.CENTER)).moved(Location((0, 0, 0)))
                caps.append(capb.moved(Location((cx_, face, cz_))))
            info.setdefault("ctl", {})[k] = [cx_, sy_, cz_, reach]
    rb = ctl.get("read switch (tactile)")
    button = None
    zc_ctrl = z_split - 6
    if rb:                                                   # v01c only: the eye button (ADR-0013) replaces it
        bx, bz = centre(rb)
        by = -pb.half_width(bx)
        from pebble_layout import inside as _in
        while not _in(pb, bx, by + 0.05, bz, 0.0) and by < 0: by += 0.05
        bottom = bottom - Cylinder(2.1, 12, rotation=(90, 0, 0)).moved(Location((bx, by, bz)))
        plunge = rb.at[1] - by
        button = (Cylinder(1.9, plunge + 0.6, rotation=(90, 0, 0)).moved(Location((0, plunge / 2 - 0.3, 0))) + Cylinder(2.8, 0.8, rotation=(90, 0, 0)).moved(Location((0, plunge - 0.4, 0)))).moved(
            Location((bx, by, bz)))
        info.setdefault("ctl", {})["read switch (tactile)"] = [bx, by, bz, plunge]
        zc_ctrl = bz
    sx, px_ = (info["ctl"]["mic switch"][0] if "mic switch" in info.get("ctl", {}) else 10.0), (info["ctl"]["power switch"][0] if "power switch" in info.get("ctl", {}) else 30.0)
    bx, by, bz = (info["ctl"]["read switch (tactile)"][:3] if "read switch (tactile)" in info.get("ctl", {}) else (None, None, None))

    tick('controls')
    # ADR-0009 light ring: ONE continuous strip on the land (inner face of the tongue), from one side of the
    # USB-C pocket, round the nose, back to the other side. One opening per LED through land + wall; the
    # translucent band covers them. Software splits the ring into one segment per participant.
    strip_meta = {}
    if info.get("light_band"):
        zl = z_split + LED_DZ
        # LED line: 0.9 mm in from the land face (the strip's LED centreline), following the smooth land
        n_ = len(land_pts)
        cxl = sum(x for x, _ in land_pts) / n_
        line = []
        for i, (x, y) in enumerate(land_pts):
            ax_, ay_ = land_pts[i - 1]; bx2, by2 = land_pts[(i + 1) % n_]
            tx, ty = bx2 - ax_, by2 - ay_; tl = math.hypot(tx, ty) or 1
            nx, ny = ty / tl, -tx / tl
            if nx * (x - cxl) + ny * y < 0: nx, ny = -nx, -ny
            line.append((x - nx * 0.9, y - ny * 0.9))
        # order as +Y half from tail to nose, then −Y half from nose to tail
        upper = sorted([p for p in line if p[1] > 0], key=lambda p: p[0])
        lower = sorted([p for p in line if p[1] <= 0], key=lambda p: -p[0])
        pts = upper
        # start just past the USB pocket on +Y, go round the nose, end just short of it on −Y
        gap_y = 13.2 / 2 + 3.5
        tail_x = min(x for x, _ in line)
        keep = lambda p: not (p[0] < tail_x + 12 and abs(p[1]) < gap_y)
        h = len(line) // 2                               # land_pts run by angle from the nose; start the walk at the tail
        path = [p for p in (line[h:] + line[:h]) if keep(p)]
        seg = [math.dist(path[i], path[i + 1]) for i in range(len(path) - 1)]
        total = sum(seg)
        n_led = min(int((total - 4) // LED_PITCH) + 1, RING_LEDS)   # the firmware drives RING_LEDS; a longer seam just widens the tail gap
        s0 = (total - (n_led - 1) * LED_PITCH) / 2
        leds, cum, j = [], 0.0, 0
        for i in range(n_led):
            sv = s0 + i * LED_PITCH
            while j < len(seg) - 1 and cum + seg[j] < sv:
                cum += seg[j]; j += 1
            t = (sv - cum) / (seg[j] or 1)
            (ax, ay), (bx_, by_) = path[j], path[j + 1]
            lx, ly = ax + t * (bx_ - ax), ay + t * (by_ - ay)
            ang = math.atan2(by_ - ay, bx_ - ax)
            nx, ny = math.sin(ang), -math.cos(ang)
            if nx * lx + ny * ly < 0: nx, ny = -nx, -ny
            # outer surface point along the normal (for the top-view drawing)
            ox, oy = lx, ly
            for _ in range(200):
                if not _in(pb, ox + nx * 0.1, oy + ny * 0.1, zl, 0.0): break
                ox, oy = ox + nx * 0.1, oy + ny * 0.1
            leds.append([round(lx, 3), round(ly, 3), round(ang, 4), round(ox, 3), round(oy, 3)])
        perf = []
        for lx, ly, ang, _, _ in leds:
            nx, ny = math.sin(ang), -math.cos(ang)
            if nx * lx + ny * ly < 0: nx, ny = -nx, -ny
            perf.append(BBox(PERF_W, 9.0, PERF_H).moved(Location((lx + nx * 3.5, ly + ny * 3.5, zl)) * Location((0, 0, 0), (0, 0, math.degrees(ang)))))
        def cut_all(base, boxes):
            """Cut a shell half's openings in one boolean; refuse a result that grew (a failed OCC cut can)."""
            v0 = base.volume
            new = base - Compound(boxes)
            if not (v0 * 0.9 <= new.volume < v0 - 1e-3 and max(x.volume for x in new.solids()) >= 0.99 * new.volume):   # crumbs go in main_solid()
                raise RuntimeError(f"LED openings did not cut cleanly ({v0:.0f} → {new.volume:.0f} mm³, {len(new.solids())} solids, {[round(x.volume) for x in new.solids()][:5]})")
            return new
        top = cut_all(top, [b_ for b_ in perf if b_.bounding_box().max.Z > z_split])
        bottom = cut_all(bottom, [b_ for b_ in perf if b_.bounding_box().min.Z < z_split])
        strip_meta = {"leds": leds, "z": zl, "pitch": LED_PITCH, "length_mm": round((n_led - 1) * LED_PITCH + 7, 1), "n": n_led,
                      "open_fraction": round(PERF_W / LED_PITCH, 3)}
        strip_meta["land"] = [[round(x, 2), round(y, 2)] for x, y in land_pts[::6]]
        strip_meta["zone"] = [z_split + BAND_LO + 0.25, z_split + BAND_HI + 0.25]   # 7.5 mm strip, centred on the LEDs
        info["strip"] = strip_meta

    tick('light ring')
    # NFC "tap here" icon on the tail: a contactless symbol pocketed from inside (0.4 mm skin) and debossed 0.2 mm outside
    icon_led = P.get("NFC icon LED (NeoPixel Mini)")
    if icon_led:
        ix, iy = icon_led.at[0] + 4.55 - NFC_ICON["dot_dx"], icon_led.at[1] + 4.55
        arcs = None
        for r in NFC_ICON["arcs"]:             # ADR-0016: 3/4 of the first icon, clear of the posts' flared roots
            ring_ = Cylinder(r + NFC_ICON["w"], 60) - Cylinder(r, 60)
            wedge = BBox(30, 30, 60).moved(Location((15 - 2, 0, 0)))
            piece = (ring_ & wedge) - BBox(60, 60, 60).moved(Location((0, 0, 0)) * Location((0, 0, 0), (0, 0, 0))).moved(Location((-30 - 0.01, 0, 0)))
            arcs = piece if arcs is None else arcs + piece
        dot = Cylinder(NFC_ICON["dot"], 60).moved(Location((NFC_ICON["dot_dx"], 0, 0)))
        icon = (arcs + dot).moved(Location((ix, iy, 0)))
        # clip the small icon first, then the lofts: the same solids as icon & (body(0.4) - cavity), ≈ 4× faster
        top = top - ((icon & body(NFC_ICON["skin"] + NFC_ICON["deboss"])) - cavity)   # pocket from inside, leaving a thin glowing skin
        top = top - ((icon & outer) - body(NFC_ICON["deboss"]))                        # shallow deboss so it reads unlit too
        info["nfc_icon"] = [ix, iy]

    tick('icon')
    # foot: elliptical TPU pad in a 0.6 mm recess on the flat base
    floor_z = -pb.H_bot
    ell = scale(Cylinder(1, 20), by=(FOOT_A, FOOT_B, 1)).moved(Location((4, 0, floor_z)))
    recess = ell & half(floor_z + FOOT_RECESS, False)
    bottom = bottom - recess
    foot = (body(-FOOT_PROUD) & ell) & half(floor_z + FOOT_RECESS, False)

    def safe_add(base, *pieces):
        """Fuse the first piece that doesn't collapse the base: OCC unions can fail silently at some sizes."""
        for piece in pieces:
            try:
                new = base + piece
                if new.volume >= base.volume * 0.98:
                    return new
            except Exception:
                pass
        print("warning: a feature could not be fused and was skipped")
        return base

    # battery ribs (stop the cell sliding in a fall)
    bx0, by0 = batt.at[0], batt.at[1]
    for rx in (bx0 + 10, bx0 + batt.size[0] - 14):
        for ry in (by0 - 1.3, by0 + batt.size[1] + 0.3):
            rib = BBox(4.0, 1.0, batt.at[2] - floor_z + 3.4, align=(Align.MIN, Align.MIN, Align.MIN)).moved(Location((rx, ry, floor_z - 0.5)))   # rooted in the floor
            bottom = safe_add(bottom, rib & body(W_ - 0.3), rib & outer)

    tick('foot, ribs')
    def main_solid(shape):
        sols = sorted(shape.solids(), key=lambda x: -x.volume)
        return sols[0] if sols else shape
    top, bottom, lens, guide, band = (main_solid(x) for x in (top, bottom, lens, guide, band))

    tick('main solids')

    # ADR-0017: one test coupon, printed in the shells' PETG before them. Each feature in three or four sizes, the
    # middle one as designed, smallest on the left (one dot):
    #   insert bosses Ø3.8 / 4.0 / 4.2 mm, 6.7 deep (press the real inserts in at 245 °C);
    #   tongue slots for a 0.10 / 0.15 / 0.20 mm fit, with a loose 1.0 × 4 mm tongue printed upright like the real one;
    #   icon skins 0.24 / 0.36 / 0.48 mm under a 0.2 mm deboss (hold the NeoPixel behind them);
    #   cap sockets 2.0 / 2.1 / 2.2 / 2.3 mm (+0.1 mm the other way) for the slide-switch actuator.
    def coupon():
        A = (Align.MIN, Align.MIN, Align.MIN)
        C_ = (Align.CENTER, Align.CENTER, Align.MIN)
        plate = BBox(78, 46, 2.0, align=A)
        feats, cuts = [], []
        for i, hole in enumerate((INSERT_HOLE - 0.2, INSERT_HOLE, INSERT_HOLE + 0.2)):           # row 1: insert bosses
            x = 8 + 12 * i
            feats.append(Cylinder(POST_D / 2, INSERT_DEPTH + 1.5, align=C_).moved(Location((x, 8, 1.9))))
            cuts.append(Cylinder(hole / 2, INSERT_DEPTH + 0.01, align=C_).moved(Location((x, 8, 2.0 + 1.5))))
        feats.append(BBox(34, 10, 4.0, align=A).moved(Location((42, 3, 1.9))))                  # row 1: tongue slots
        for i, fit in enumerate((0.10, FIT, 0.20)):
            cuts.append(BBox(TONGUE_W + 2 * fit, 8.0, 4.0, align=C_).moved(Location((48 + 10 * i, 8, 2.0))))
        feats.append(BBox(34, 10, 1.4, align=A).moved(Location((2, 20, 1.9))))                  # row 2: icon skins
        for i, skin in enumerate((0.24, NFC_ICON["skin"], 0.48)):
            x = 8 + 11 * i
            top_z = 2.0 + 1.4
            cuts.append(BBox(6, 6, 0.2 + 0.01, align=C_).moved(Location((x, 25, top_z - 0.2))))   # deboss from the top
            cuts.append(BBox(5, 5, 20, align=(Align.CENTER, Align.CENTER, Align.MAX)).moved(Location((x, 25, top_z - 0.2 - skin))))   # pocket from below
        feats.append(BBox(34, 10, 5.0, align=A).moved(Location((42, 20, 1.9))))                 # row 2: cap sockets
        for i, w in enumerate((2.0, 2.1, 2.2, 2.3)):
            cuts.append(BBox(w, w + 0.1, 4.1 + 0.01, align=C_).moved(Location((47 + 8 * i, 25, 2.0 + 5.0 - 4.1))))
        for xd, yd in ((8, 14.5), (48, 14.5), (8, 32.5), (47, 32.5)):                             # a dot by each smallest size
            feats.append(Cylinder(0.6, 0.5, align=C_).moved(Location((xd, yd, 1.9))))
        body_ = plate
        for f_ in feats:
            body_ = body_ + f_
        for c_ in cuts:
            body_ = body_ - c_
        # the loose tongue: 24 × 1.0 × 4 mm upright on a 6 mm foot, beside the plate
        tongue_ = BBox(24, 6, 1.0, align=A).moved(Location((2, 50, 0))) + BBox(24, TONGUE_W, TONGUE_H + 1.0, align=A).moved(Location((2, 53 - TONGUE_W / 2, 0.9)))
        both = Compound([main_solid(body_), main_solid(tongue_)])
        assert all(x.volume > 0 for x in both.solids()) and len(both.solids()) == 2, "test coupon"
        return both
    coupons = coupon()
    rigid = {"top_shell": top, "bottom_shell": bottom, "eye_guide": guide, "eye_lens": lens}
    if button is not None:
        rigid["read_button"] = button
    for k_, c_ in caps if caps and isinstance(caps[0], tuple) else []:
        rigid["power_cap" if k_ == "power switch" else "mic_cap"] = main_solid(c_)
    if caps and not isinstance(caps[0], tuple):
        rigid["slider_caps"] = Compound(caps)
    if cradle_part is not None:
        rigid["eye_cradle"] = main_solid(cradle_part)
    soft = {"bumper_band": band, "foot": foot, **({"post_washer": washers[0]} if washers else {})}   # ADR-0020: no board posts, no washers
    if deck_part is not None:
        rigid["battery_deck"] = main_solid(deck_part)
    if straps:
        rigid["board_straps"] = Compound(straps)
    parts = {**rigid, **soft, "test_coupons": coupons}   # the coupon is printed, but not part of the device (not in the mass)
    for f in list(OUT.glob("*.stl")) + list(OUT.glob("*.step")):   # drop parts this variant no longer has (e.g. the read button)
        if f.stem not in parts:
            f.unlink()
    for n, s in parts.items():
        t0 = time.time()
        export_step(s, str(OUT / f"{n}.step"))
        t1 = time.time()
        export_stl(s, str(OUT / f"{n}.stl"), tolerance=0.01, angular_tolerance=0.1)   # 0.01 mm: ten times finer than a printer resolves (0.006 tripled the files once the bosses arrived)
        clean_stl(OUT / f"{n}.stl")
        bb = s.bounding_box()
        print(f"{n:13s} {s.volume/1000:6.2f} cm³  {bb.size.X:.1f}×{bb.size.Y:.1f}×{bb.size.Z:.1f}   STEP {t1 - t0:4.1f} s, STL {time.time() - t1:4.1f} s")

    # shells overlap? on the exported meshes (manifold boolean, 0.2 s; the exact OCC top & bottom took ≈ 50 s)
    import trimesh
    _ov = trimesh.boolean.intersection([trimesh.load(OUT / "top_shell.stl"), trimesh.load(OUT / "bottom_shell.stl")], engine="manifold")
    info["shell_overlap_mm3"] = round(abs(_ov.volume), 2) if len(_ov.faces) else 0.0
    tick('export')
    # mass and centre of mass (printed parts from volume; boards from datasheets or estimates)
    COMP_MASS = {"battery": 74, "radxa pcb": 14, "bq25185 charger": 5, "bq24074 charger (Adafruit 4755)": 8, "RTC PCF8523 + CR1220 (STEMMA QT)": 4, "power switch Pololu 2810": 1, "USB-C breakout (charge port)": 2, "5 V boost U3V40F5": 2,
                 "RV3028 clock": 3, "ATECC608 secure element": 2, "NFC tag ST25DV64KC": 3, "mic head (SPH0645)": 1,
                 "mic tail (SPH0645)": 1, "vibration disc (ERM)": 1, "LED": 0.4}
    masses = []
    import trimesh
    for n, s in rigid.items():   # volumes from the exported meshes: OCC's integration on spline faces can be far off
        masses.append((abs(trimesh.load(OUT / f"{n}.stl").volume) / 1000 * DENS["PETG"], s.center()))
    for n, s in soft.items():
        k = sum(1 for b in placed if b.name.startswith("post ")) if n == "post_washer" else 1   # one washer per board post
        masses.append((abs(trimesh.load(OUT / f"{n}.stl").volume) / 1000 * DENS["TPU"] * k, s.center()))
    for b in placed:
        m = COMP_MASS.get(b.name) or (COMP_MASS["LED"] if b.name.startswith("LED") else 0)
        if m:
            c = [b.at[i] + b.size[i] / 2 for i in range(3)]
            masses.append((m, type("V", (), {"X": c[0], "Y": c[1], "Z": c[2]})))
    M = sum(m for m, _ in masses) + 6   # + wiring, screws, inserts, tape (estimate)
    com = [sum(m * getattr(c, a) for m, c in masses) / sum(m for m, _ in masses) for a in "XYZ"]
    outer_bb = outer.bounding_box().size
    from pebble_layout import shell_of as _shell_of
    _comps_out = [{"name": b.name, "size": b.size, "at": b.at, "group": b.group, "role": b.role, **({"mount": b.mount} if b.mount else {})} for b in placed]
    _by = {c["name"]: c for c in _comps_out}
    for c in _comps_out:   # which shell each part is fixed to (ADR-0020), for the site's "Open" view and the checks
        c["shell"] = _shell_of(c, z_split, _by) or _shell_of(_by.get(c["name"].split(" of ", 1)[-1], c), z_split, _by)
    json.dump({
        "version": "v01c", "spec": SPEC.__dict__, "info": info, "z_split": z_split,
        "outer": [round(outer_bb.X, 1), round(outer_bb.Y, 1), round(outer_bb.Z + BAND_PROUD * 0, 1)],
        "volume_cm3": round(outer.volume / 1000, 1),
        "mass_g": round(M, 1), "com": [round(v, 2) for v in com], "floor_z": floor_z,
        "drop_energy_J": {h: round(M / 1000 * 9.81 * h, 2) for h in (0.75, 1.2, 1.5)},
        "comps": _comps_out,
        "ports": {"usb": [u.at[0] - 5, 0, uz], "slider": info.get("ctl", {}).get("mic switch", [sx, side_y(sx), zc_ctrl])[:3], "button": [bx, by, bz], "power": info.get("ctl", {}).get("power switch", [px_, side_y(px_), zc_ctrl])[:3]}, "controls": info.get("ctl", {}),
        "strip": info.get("strip", {}), "nfc_icon": info.get("nfc_icon"), "nfc_icon_shape": NFC_ICON,
        "eye": {"x": x_eye, "r_out": r_out, "led_top": led_top, "dish": DISH},
        "band": {"z0": zb0, "z1": zb1, "proud": BAND_PROUD}, "soft_parts": list(soft),
    }, open(OUT / "layout.json", "w"), indent=1, default=float)
    print("outer", outer_bb, "volume", round(outer.volume / 1000, 1), "cm³ · mass ≈", round(M), "g · COM z", round(com[2], 2), "floor", round(floor_z, 2))


if __name__ == "__main__":
    import sys
    import stages
    HERE = Path(__file__).parent
    # resumes from the last unchanged section (stages.py); --fresh rebuilds everything, as a release build should
    stages.run(main, sys.modules[__name__], inputs=[HERE / "pebble_spec.json", HERE / "pebble.py", HERE / "pebble_layout.py", HERE / "stages.py"],
               fresh="--fresh" in sys.argv, keep_last=2)
    import stamps
    stamps.write("enclosure", fresh="--fresh" in sys.argv)
