# SPDX-License-Identifier: Apache-2.0
"""Goldfish v01b — pebble layout by greedy packing against the analytic cavity.

Coordinates as layout.py: X along the body (head +X), Y across (belly −Y),
Z up with z = 0 at the pebble's widest plane (not the floor — the floor is curved).

Each part has a mounting rule (floor / roof / fixed to the board / tail tip)
and a preferred position. `pack()` walks candidate positions outward from the
preference and keeps the first one where the part is fully inside the cavity
and touches nothing already placed. `search()` finds the smallest pebble that packs.
"""
from __future__ import annotations

import functools
import itertools
import math
from dataclasses import dataclass, field

from pebble import Pebble, inside

PHI = (1 + 5 ** 0.5) / 2
# NFC "tap here" icon pocketed into the roof (pebble_enclosure.py): a dot over the icon LED and three half-arcs toward
# the head. ADR-0016: 3/4 of the first icon (arcs r 3.5 / 6.5 / 9.5 × 1.1 wide), so post 3.5,26.5's flared root clears it.
# ADR-0017: skin 0.36 mm (three 0.12 mm layers) under a 0.2 mm deboss; the 0.4 mm pocket plus the deboss had left 0.2 mm.
NFC_ICON = dict(arcs=(2.6, 4.9, 7.1), w=0.85, dot=1.05, dot_dx=-1.0, skin=0.36, deboss=0.2)
NFC_ICON_R = NFC_ICON["arcs"][-1] + NFC_ICON["w"]   # 7.95 mm
# Board posts (ADR-0015) and their flared roots (ADR-0016): a concave fillet of radius R that turns into a 45° flare
# `out` wider at the roof, anchored at the lowest point where the post meets the roof. The packer, the enclosure,
# the router and the checks all read these.
# Heat-set insert: ruthex RX-M2.5x5.7 (bom.csv). 3DJake's table: "⌀ D1 4,6 · ⌀ D2 3,9 · ⌀ D3 4,0 · L 5,7 · W (mind.) 1,6";
# CNC Kitchen's table for the same insert names D3 the hole and W the minimum wall. ADR-0017: the hole was 3.6 mm.
INSERT_HOLE, INSERT_L, INSERT_WALL = 4.0, 5.7, 1.6
INSERT_DEPTH = INSERT_L + 1.0                  # "slightly deeper than the length of the insert (approx. 1 mm)" (CNC Kitchen)
INSERT_OD = 4.6                                # D1: the knurl, after it has melted in
POST_D = INSERT_HOLE + 2 * INSERT_WALL         # 7.2 mm (was 6.4)
# M2.5 × 6 ISO 14580 low-head screw (CNC Kitchen via 3DJake: "Kopfdurchmesser (dk) 4,5 mm", "Kopfhöhe (k1) 1,85 mm", TX8),
# from under the board, through the 1.6 mm board and the 1 mm TPU washer into the insert
SCREW_L, SCREW_HEAD_D, SCREW_HEAD_K, WASHER_T = 6.0, 4.5, 1.85, 1.0
FASTENER = "fastener"   # role of an insert or a screw head: named "<what> of <owner>"
POST_FLARE_R, POST_FLARE_OUT = 2.0, 1.6        # root 10.4 mm across, as before the wider post (ADR-0017)
KEEP_OUT = "keep-out"   # role of a box that is space, not a part: named "<what> of <owner>", the owner may overlap it


def flare_height(R=POST_FLARE_R, out=POST_FLARE_OUT):
    return out + R * (math.sqrt(2) - 1)          # fillet rise R·sin45 + the 45° run out − R·(1 − cos45)


def post_flare(cav, cx, cy):
    """Where a post centred at (cx, cy) meets the roof: z_ref (lowest point of the root, where the flare is
    anchored), its foot z0, and the collar height on the uphill side."""
    ring = [cav.roof(cx + POST_D / 2 * math.cos(t), cy + POST_D / 2 * math.sin(t)) for t in (2 * math.pi * k / 72 for k in range(72))]
    z_ref = min(ring)
    return {"z_ref": z_ref, "z0": z_ref - flare_height(), "collar": max(ring) - z_ref}


# ADR-0018: every board is screwed down, for a prototype that is built and taken apart many times.
# Boards stand on bosses holding a captive M2.5 hex nut (slid in from the side; on the deck, pressed in from below),
# so the thread is metal on metal and survives any number of rebuilds. One screw for everything: M2.5 × 6.
NUT_AF, NUT_M = 5.0, 2.0          # M2.5 hex nut, ISO 4032 / DIN 934: 5.0 mm across flats, 2.0 mm thick
NUT_CLEAR = 0.2                   # pocket clearance on the flats
BOSS_CAP = 0.8                    # PETG between the nut and the board
BOSS_D = 2 * ((NUT_AF + NUT_CLEAR) / math.sqrt(3)) + 2 * 1.2   # across the pocket's corners + 1.2 mm walls: 8.4 mm
BOSS_H = BOSS_CAP + NUT_M + 0.3 + 0.4                           # 3.5 mm: cap, nut, clearance, 0.4 mm below the pocket
DECK_T = 2.0                      # the battery deck: a printed plate over the cell that clamps it and carries boards
DECK_POST_Y = (18.0, 14.0, 22.0, 10.0)   # where the deck's four floor posts may stand, off the cell's ends
# Mounting holes in each board's packer box frame (mm from the box's min corner, in the box's own orientation).
# From the vendor models in vendor/models/*/ *_pcb.stl, measured as through-holes ≥ 1.9 mm (ADR-0018):
#   ATECC608 (Adafruit 4314): PCB 25.4 × 17.78, four Ø2.5 at 2.54 from each corner; the box is the PCB turned 90°.
#   bq24074 (Adafruit 4755): PCB x 3.23–41.33 in the full model, four Ø2.5 at 2.54 from the PCB's corners.
#   SPH0645 (Adafruit 3421): two Ø2.5 at (2.54, 10.16), (13.97, 10.16); mounted flipped (port up), so y → 12.7 − y.
#   ST25DV (SparkFun 21274): two Ø3.3 on one edge, (2.54, 2.54), (22.86, 2.54); M2.5 with a washer.
#   USB-C (Adafruit 4090): two Ø2.5 flanking the socket, (2.54, 11.44), (17.78, 11.43); the box is the PCB turned
#   so the socket faces −X: x = 13.97 − y_pcb, y = x_pcb.
BOARD_HOLES = {
    "ATECC608 secure element": [(2.54, 2.54), (15.24, 2.54), (2.54, 22.86), (15.24, 22.86)],
    "bq24074 charger (Adafruit 4755)": [(5.77, 2.54), (5.78, 30.49), (38.79, 2.54), (38.79, 30.48)],
    "mic head (SPH0645)": [(2.54, 2.54), (13.97, 2.54)],
    "mic tail (SPH0645)": [(2.54, 2.54), (13.97, 2.54)],
    "NFC tag ST25DV64KC": [(2.54, 2.54), (22.86, 2.54)],
    "USB-C breakout (charge port)": [(2.53, 2.54), (2.53, 17.78)],
    # Adafruit 5189 PCF8523: "Adafrruit PCF8523 QT.brd" (sic) on GitHub, MOUNTINGHOLE_3.0_PLATEDTHIN, four Ø3.0 at 2.54
    # from the corners of the 25.4 × 17.78 PCB (the product page's "Two mounting holes" is left over from the 3295)
    "RTC PCF8523 + CR1220 (STEMMA QT)": [(2.54, 2.54), (22.86, 2.54), (2.54, 15.24), (22.86, 15.24)],
}
# No holes for M2.5, so strapped: Pololu 4012 boost and 2810 switch (only Ø1.02 pin holes, per their dimension PDFs);
# the ERM disc; the 1612 NeoPixel Mini (none, from its photos); the LM66200 (5830): two Ø2.5 plated holes, which an
# M2.5 thread won't pass, and a strap avoids a second screw size.
# Boards with no usable holes are held by a printed strap across them, screwed to two bosses beside them.
STRAPPED = ("5 V boost U3V40F5", "vibration disc (ERM)", "power switch Pololu 2810", "ideal diode LM66200 (Adafruit 5830)", "NFC icon LED (NeoPixel Mini)")
STRAP_W, STRAP_T = 5.0, 1.6
RADXA_BOSS_R = 3.8                # ADR-0020: the Radxa's underside parts come within 4.0 mm of its holes
LID_CONNECTOR = (22.0, 12.0, 6.0)  # ADR-0020: Harwin Gecko G125 2 × 6 latched, mated 11.45 × 4.90 × 9.60 mm (Harwin), lying flat, leads bending out along its length
STRAP_GAP = 0.8                   # strap to board top: a 1 mm closed-cell foam pad, squeezed 0.2 mm, presses the board down
# ADR-0021: a strap must not sit on a solder joint. The tap-icon LED's pads are on the face its strap presses: its wires
# run flat across that face, so its strap stands 1.3 mm off (a 1.5 mm foam pad, squeezed 0.2 mm) over 1.0 mm wire;
# any more and its screw heads take the NFC tag's place under the tail roof.
# The ideal diode's header runs along one long edge (board y 2.54): its strap crosses the other half, along x at y 7.4.
STRAP_GAP_OF = {"NFC icon LED (NeoPixel Mini)": 1.3}
# The NFC tag's header runs along its y 1.27 edge: its strap crosses the far half, and the tag turns at most 90°, so its
# antenna (the board's upper half) stays under the tap icon
STRAP_LINE = {"ideal diode LM66200 (Adafruit 5830)": (7.4,), "NFC tag ST25DV64KC": (14.0, 16.0, 18.0, 12.0, 10.0, 8.0)}   # board-frame y of the strap's centre line (tried in turn)
STRAP_TURNS = {"NFC tag ST25DV64KC": (0, 90)}   # turned 90°, its antenna half is the tail-ward half, still under the icon


def shell_of(c: dict, z_split: float, comps: dict | None = None) -> str:
    """Which shell a layout part is fixed to: "top" or "bottom" (ADR-0018). By how it is mounted, not by height: the
    battery deck and its boards sit above the parting line but belong to the bottom shell. c is a layout.json comp."""
    n, m = c["name"], c.get("mount")
    if m:
        return "top" if m["side"] == "roof" else "bottom"
    if " of " in n:                       # a boss, strap, head or insert: its owner's shell
        return None
    if n.startswith(("battery", "deck ", "USB-C", "power switch", "mic switch", "LED")) and not n.startswith("power switch Pololu"):
        return "bottom"
    if n.startswith(("radxa", "SoC thermal")) and comps and comps.get("radxa pcb", {}).get("mount"):
        return shell_of(comps["radxa pcb"], z_split)       # ADR-0020: the board and its heat column stand on the deck
    if n.startswith(("radxa", "post ", "SoC thermal", "eye", "mic-live")):
        return "top"
    return "top" if c["at"][2] > z_split else "bottom"


TRACE: dict = {}   # debugging: set TRACE["name"] = collections.Counter() to count why that part's candidates fail


def turned_holes(holes, size, turned):
    """Holes of a board placed turned 90° (its box's x and y swapped)."""
    return [(y, x) for x, y in holes] if turned else holes


EYE_POST_CLEAR = 16.5 + (POST_D - 6.4) / 2   # mm from the eye axis to a post centre (flange r 11 + hanger r 13 margin + post r 3.2, + the wider post)
WALL = 1.8
CL = 0.3


@dataclass
class Box:
    name: str
    size: tuple
    at: tuple = (0, 0, 0)
    group: str = ""
    role: str = ""
    mount: dict | None = None   # ADR-0018: {"holes": [(x, y) world], "side": "floor" | "deck" | "roof", "kind": "nut" | "strap"}

    def overlaps(self, o: "Box", pad: float = 0.0) -> bool:
        return all(self.at[i] < o.at[i] + o.size[i] + pad and o.at[i] < self.at[i] + self.size[i] + pad for i in range(3))


def samples(b: Box, n=6):
    (sx, sy, sz), (ax, ay, az) = b.size, b.at
    for i in range(n):
        for j in range(n):
            u, v = i / (n - 1), j / (n - 1)
            for fx, fy, fz in ((u, v, 0), (u, v, 1), (u, 0, v), (u, 1, v), (0, u, v), (1, u, v)):
                yield ax + fx * sx, ay + fy * sy, az + fz * sz


class Cavity:
    def __init__(self, pb: Pebble, wall=WALL):
        self.pb, self.wall = pb, wall

    def contains(self, b: Box) -> bool:
        return all(inside(self.pb, x, y, z, self.wall) for x, y, z in samples(b))

    def _wh(self, x):
        pb, t = self.pb, self.wall
        return pb.profile(x, t)

    @functools.lru_cache(maxsize=1 << 20)
    def roof(self, x, y):
        w, ht, _ = self._wh(x)
        if w <= abs(y) or ht <= 0:
            return -99
        return ht * (1 - (abs(y) / w) ** self.pb.n_top) ** (1 / self.pb.n_top)

    @functools.lru_cache(maxsize=1 << 20)
    def floor(self, x, y):
        w, _, hb = self._wh(x)
        if w <= abs(y) or hb <= 0:
            return 99
        return -hb * (1 - (abs(y) / w) ** self.pb.n_bot) ** (1 / self.pb.n_bot)

    @functools.lru_cache(maxsize=1 << 20)
    def roof_over(self, x0, x1, y0, y1, n=7):
        return min(self.roof(x0 + (x1 - x0) * i / (n - 1), y0 + (y1 - y0) * j / (n - 1)) for i in range(n) for j in range(n))

    @functools.lru_cache(maxsize=1 << 20)
    def floor_under(self, x0, x1, y0, y1, n=7):
        return max(self.floor(x0 + (x1 - x0) * i / (n - 1), y0 + (y1 - y0) * j / (n - 1)) for i in range(n) for j in range(n))


@dataclass
class Spec:
    L: float = 120.0
    W: float = 52.0
    T: float = 23.0
    h_bot_frac: float = 0.36
    x_peak: float = 0.10
    battery: tuple = (62.5, 37.0, 7.5)
    eye_r: float = 10.5
    diffuser: float = 0.8
    eye_button: bool = False  # ADR-0013: press the eye → off the record; hold → arm the NFC hand-off
    kit_friendly: bool = False  # ADR-0013: plug-in modules instead of SMD parts on adapters
    board_dy: float = 0.0       # ADR-0014: Radxa moved sideways on the wide cell, to free a bay for the charger
    big_charger: bool = False   # ADR-0014: bq24074 board (runs the device from a USB-C phone charger)
    charger_jack: bool = True   # the 4755's unused 2.1 mm DC jack (12.6 mm tall) still fitted
    deck_ext: float = 0.0       # ADR-0020: the deck runs this far past the cell's nose end, on two posts under its far end
    chassis: bool = False       # ADR-0020: every board in the bottom shell (the Radxa on deck bosses); the top is a lid
    n_top: float = 2.6        # shoulder fullness of the dome
    n_bot: float = 5.0        # belly flatness
    p_bot: float = 4.0        # belly stays low further along the length
    stack_shift: float = 4.0  # battery + board offset toward the head (mm)
    p_plan: float = 2.8       # plan fullness (2 = ellipse)
    p_top: float = 3.0        # how long the dome stays high along the body
    wall: float = WALL        # shell wall (v01c: 2.0 for drops)
    eye_leds: int = 5         # 5 = one rung for the room (v01b); 12 = one dot per participant (ADR-0008); 0 = one light only, the mic-live light (ADR-0009)
    ring_h: float = 3.4       # 12-LED ring height (Adafruit lists 6.7 mm; a 5050 ring PCB is typically ~3.4)
    light_band: bool = False  # ADR-0009: one continuous NeoPixel strip around the seam behind a translucent TPU band; software splits it into one segment per participant
    band_z: float = 3.2       # strip zone starts this far above the parting line (negative: the strip straddles the seam, behind the bumper band)
    band_h: float = 7.5       # strip width (Adafruit 2969 bare: 7.5 mm)
    band_gap: float = 2.2     # strip + adhesive + clearance from the inner wall

    def pebble(self) -> Pebble:
        hb = self.T * self.h_bot_frac
        return Pebble(L=self.L, W=self.W, H_top=self.T - hb, H_bot=hb, x_peak=self.x_peak,
                      n_top=self.n_top, n_bot=self.n_bot, p_bot=self.p_bot, p_plan=self.p_plan, p_top=self.p_top)


def pack(s: Spec, verbose=False):
    pb = s.pebble()
    cav = Cavity(pb, s.wall)
    placed: list[Box] = []
    info: dict = {}
    fails: list[str] = []

    band_zone = [None]

    def lateral_ok(x, y, z):
        a, bb, p = pb.L / 2, pb.W / 2, pb.p_plan
        nx = math.copysign(abs(x / a) ** (p - 1) / a, x) if x else 0.0
        ny = math.copysign(abs(y / bb) ** (p - 1) / bb, y) if y else 0.0
        n = math.hypot(nx, ny) or 1.0
        g = s.band_gap + (1.0 if abs(x) > pb.L / 2 - 28 else 0.0)   # the land is rounded (r ≥ 10 mm) at the ends: it stands further in there
        return inside(pb, x + g * nx / n, y + g * ny / n, z, s.wall)

    def band_clear(b: Box) -> bool:
        if not band_zone[0] or b.name.startswith(("LED", "mic-live", "USB-C")) or b.name.endswith("of USB-C breakout (charge port)"):   # the strip is cut at the tail tip for the USB-C port
            return True
        z0, z1 = band_zone[0]
        if b.at[2] > z1 or b.at[2] + b.size[2] < z0:
            return True
        lo, hi = max(b.at[2], z0), min(b.at[2] + b.size[2], z1)
        return all(lateral_ok(x, y, z) for x, y, z in samples(Box(b.name, (b.size[0], b.size[1], hi - lo), (b.at[0], b.at[1], lo)), 5))

    def free(b: Box) -> bool:   # the cheap test first: most candidates fail on an overlap
        return not any(b.overlaps(o) for o in placed if not (b.group and b.group == o.group)) and cav.contains(b) and band_clear(b)

    def try_place(name, size, xs, ys, zf, group="", role=""):
        """zf(x0, x1, y0, y1) → z of the box's min corner, or None."""
        for x0 in xs:
            for y0 in ys:
                z0 = zf(x0, x0 + size[0], y0, y0 + size[1])
                if z0 is None:
                    continue
                b = Box(name, size, (x0, y0, z0), group, role)
                if free(b):
                    placed.append(b)
                    return b
        fails.append(name)
        return None

    def around(pref, lo, hi, step=0.5):
        out, k = [], 0
        while True:
            a, c = pref + k * step, pref - k * step
            added = False
            if lo <= a <= hi:
                out.append(a); added = True
            if k and lo <= c <= hi:
                out.append(c); added = True
            if not added and (a > hi and c < lo):
                break
            k += 1
        return out

    Li = s.L / 2 - s.wall
    on_floor = lambda pad=0.2: (lambda x0, x1, y0, y1: cav.floor_under(x0, x1, y0, y1) + pad)
    hang = lambda h, gap=0.4: (lambda x0, x1, y0, y1: cav.roof_over(x0, x1, y0, y1) - gap - h)

    # 1+2. battery and Radxa as one stack (RJ45 + header removed; keep-outs from its STEP model),
    # tried at every position along the body, nearest the preference first
    bx, by, bz = s.battery
    board = None
    for xs in around(s.x_peak * s.L - bx / 2 + s.stack_shift, -Li, Li - bx):
        fl = cav.floor_under(xs, xs + bx, -by / 2, by / 2) + 0.3
        batt = Box("battery", (bx, by, bz), (xs, -by / 2, fl), role="power")
        if not cav.contains(batt):
            continue
        batt_top = fl + bz
        if s.light_band:                                    # the strip zone hangs off the battery top: the cell must clear it too
            band_zone[0] = (batt_top + 0.25 + s.band_z, batt_top + 0.25 + s.band_z + s.band_h)
            ok_band = band_clear(batt)
            band_zone[0] = None
            if not ok_band:
                continue
        pcb_bot = batt_top + DECK_T + CL + 3.7          # ADR-0018: the battery deck lies on the cell, under the board
        pcb_top = pcb_bot + 1.6
        for xt in (xs - 1.0, xs - 2.5, xs + 0.5, xs - 4.0, xs + 2.0, xs - 5.5, xs + 3.5, xs - 7.0, xs + 5.0, xs - 8.5):
            y0 = -15.0 + s.board_dy
            parts = [("radxa pcb", (0, 65, 0, 30, pcb_bot, pcb_top)), ("radxa top parts", (0, 65, 0, 30, pcb_top, pcb_top + 1.0)),
                     ("radxa USB-C ports", (8, 17, -1.5, 10, pcb_top, pcb_top + 3.0)), ("radxa underside A", (9, 17, 6, 22, pcb_bot - 2.7, pcb_bot)),
                     ("radxa underside B", (49, 57, 6, 22, pcb_bot - 3.7, pcb_bot)), ("radxa underside C", (61, 65, 6, 26, pcb_bot - 2.2, pcb_bot))]
            boxes = [Box(n, (lx1 - lx0, ly1 - ly0, z1 - z0), (xt + lx0, y0 + ly0, z0), "board", "compute") for n, (lx0, lx1, ly0, ly1, z0, z1) in parts]
            # posts need ≥ 2 mm of roof above the board
            x_eye_ = s.L / 2 - s.L / PHI ** 2                       # the eye switch hangs from the roof there; posts reach the roof too
            # posts reach the roof, so each must clear the eye's opening and the guide's flange (Ø r_out+2 = 22 mm,
            # ADR-0013) plus the cradle's hangers at r 13: centre ≥ 11.5 + 3.2 (post radius) + 1.5 from the eye axis
            # a post that would land in the eye is left out: the board hangs on three posts (ADR-0015)
            holes = [(hx, hy) for hx in (3.5, 61.5) for hy in (3.5, 26.5)
                     if s.chassis or not (s.eye_button and math.hypot(xt + hx - x_eye_, y0 + hy) < EYE_POST_CLEAR)]
            pr = POST_D / 2
            post_ok = s.chassis or len(holes) >= 3 and all(cav.roof_over(xt + hx - pr, xt + hx + pr, y0 + hy - pr, y0 + hy + pr) - pcb_top >= 3.0 for hx, hy in holes)   # 1 mm TPU washer + 2 mm post
            if post_ok and all(cav.contains(bb) for bb in boxes):
                placed.append(batt)
                placed.extend(boxes)
                board = (xt, y0)
                info["posts"] = holes
                break
        if board:
            break
    if not board:
        fails.append("battery + radxa stack")
        return False, fails, placed, info
    x_t, y0 = board
    info.update(pcb_bot=pcb_bot, pcb_top=pcb_top, board_tail_x=x_t, board_y0=y0, batt_top=batt_top)
    if s.big_charger:
        # ADR-0014 heat path: a clear column from the RK3566 (vendor STEP: x 21–35.4, y 4.9–20.5 on the board) up to the roof,
        # for the thermal pad and the aluminium spreader; nothing else may sit there
        sx0, sy0 = x_t + 21.0, y0 + 4.9
        roof_soc = cav.roof_over(sx0, sx0 + 14.4, sy0, sy0 + 15.6)
        placed.append(Box("SoC thermal pad (to the roof)", (14.4, 15.6, roof_soc - pcb_top - 0.9), (sx0, sy0, pcb_top + 0.9), "board", "heat"))
        info["soc_pad_gap"] = roof_soc - pcb_top - 0.9
    if s.light_band:
        band_zone[0] = (batt_top + 0.25 + s.band_z, batt_top + 0.25 + s.band_z + s.band_h)

    # posts: from the board up to the roof (ADR-0020: none in the chassis build; the board stands on the deck)
    for hx, hy in ([] if s.chassis else info["posts"]):
            pr = POST_D / 2
            px, py = x_t + hx - pr, y0 + hy - pr
            top = cav.roof_over(px, px + POST_D, py, py + POST_D) - 0.05
            b = Box(f"post {hx},{hy}", (POST_D, POST_D, max(top - pcb_top, 0.5)), (px, py, pcb_top), "board", "structure")
            if top - pcb_top < 2.0:
                fails.append(f"post {hx},{hy} (roof too low)")
            placed.append(b)
            # ADR-0016: keep parts out of the post's flared root, at full width from its foot up to the highest roof
            # point over it (the uphill collar reaches that high)
            fl, rr = post_flare(cav, px + pr, py + pr), POST_D / 2 + POST_FLARE_OUT
            fx0, fy0 = px + pr - rr, py + pr - rr
            ftop = max(cav.roof(fx0 + 2 * rr * i / 8, fy0 + 2 * rr * j / 8) for i in range(9) for j in range(9))
            # ADR-0017: the insert in the post's free end and the screw head under the board, so the checks see them
            cx_, cy_ = px + pr, py + pr
            placed.append(Box(f"insert of post {hx},{hy}", (INSERT_OD, INSERT_OD, INSERT_L), (cx_ - INSERT_OD / 2, cy_ - INSERT_OD / 2, pcb_top + WASHER_T), "board", FASTENER))
            placed.append(Box(f"screw head of post {hx},{hy}", (SCREW_HEAD_D, SCREW_HEAD_D, SCREW_HEAD_K), (cx_ - SCREW_HEAD_D / 2, cy_ - SCREW_HEAD_D / 2, pcb_top - 1.6 - SCREW_HEAD_K - 0.02), "board", FASTENER))
            placed.append(Box(f"root flare of post {hx},{hy}", (2 * rr, 2 * rr, max(ftop - fl["z0"] + 0.3, 0.5)), (fx0, fy0, fl["z0"] - 0.3), "board", KEEP_OUT))

    # ADR-0018: the battery deck. A printed plate on the cell (foam between) that clamps it and carries boards; four
    # tabs reach floor posts just off the cell's ends, with heat-set inserts, so the deck comes off for a rebuild.
    deck_bot, deck_top = batt_top, batt_top + DECK_T
    dx0, dx1 = batt.at[0], batt.at[0] + batt.size[0]
    pr = POST_D / 2
    dx1e = dx1 + s.deck_ext                                  # ADR-0020: the deck may run past the cell's nose end
    ext_hw = None
    if s.deck_ext >= POST_D + 1.5:
        for w_ in (batt.size[1] - 6.0, 50.0, 45.0, 40.0, 35.0, 30.0):
            e_ = Box("deck tab (extension)", (s.deck_ext, w_, DECK_T), (dx1, -w_ / 2, deck_bot), "deck", "structure")
            if free(e_):
                ext_hw = w_ / 2
                break
    deck_posts = None
    for py_ in DECK_POST_Y:
        cand = []
        for cx_ in (dx0 - pr - 0.5, (dx1e - pr - 1.0) if ext_hw else (dx1 + pr + 0.5)):
            for cy_ in (-py_, py_):
                fl_ = cav.floor_under(cx_ - pr, cx_ + pr, cy_ - pr, cy_ + pr)
                cand.append(Box(f"deck post {len(cand) + 1}", (POST_D, POST_D, deck_bot - fl_ - 0.05), (cx_ - pr, cy_ - pr, fl_ + 0.05), "deck", "structure"))   # rooted in the floor by the enclosure
        tabs = [Box(f"deck tab {i + 1}", (POST_D + 2.0, POST_D + 2.0, DECK_T), (c.at[0] - 1.0, c.at[1] - 1.0, deck_bot), "deck", "structure") for i, c in enumerate(cand)]
        if all(free(c) for c in cand) and all(free(t) for t in tabs):
            deck_posts = (cand, tabs)
            break
    if not deck_posts:
        fails.append("battery deck posts")
        deck_posts = ([], [])
    deck = Box("battery deck", (dx1 - dx0, batt.size[1] - 6.0, DECK_T), (dx0, batt.at[1] + 3.0, deck_bot), "deck", "structure")   # 3 mm in from the cell's sides: clear of the light strip at the seam
    placed.append(deck)
    if ext_hw:
        placed.append(Box("deck tab (extension)", (s.deck_ext, 2 * ext_hw, DECK_T), (dx1, -ext_hw, deck_bot), "deck", "structure"))
        info["deck_ext"] = [dx1, dx1e, ext_hw]
    for c, t in zip(*deck_posts):
        placed.extend([c, t])
        cx_, cy_ = c.at[0] + pr, c.at[1] + pr
        placed.append(Box(f"insert of {c.name}", (INSERT_OD, INSERT_OD, INSERT_L), (cx_ - INSERT_OD / 2, cy_ - INSERT_OD / 2, deck_bot - INSERT_L), "deck", FASTENER))
        placed.append(Box(f"screw head of {c.name}", (SCREW_HEAD_D, SCREW_HEAD_D, SCREW_HEAD_K), (cx_ - SCREW_HEAD_D / 2, cy_ - SCREW_HEAD_D / 2, deck_top), "deck", FASTENER))
    info.update(deck=[dx0, batt.at[1] + 3.0, dx1, batt.at[1] + batt.size[1] - 3.0, deck_bot, deck_top], deck_posts=[[c.at[0] + pr, c.at[1] + pr, c.at[2]] for c in deck_posts[0]])

    if s.chassis:
        # ADR-0020: the Radxa stands on four captive-nut bosses from the deck, one at each mounting hole. They are
        # slimmer (r 3.8: 0.8 mm walls round the nut) because its underside parts come within 4.0 mm of the holes
        # (vendor model), and 4.0 mm tall: the underside parts need 3.7 mm.
        rx = next(b for b in placed if b.name == "radxa pcb")
        r_ = RADXA_BOSS_R
        hw_ = [(x_t + hx, y0 + hy) for hx, hy in info["posts"]]
        rx.mount = {"holes": [[round(h[0], 3), round(h[1], 3)] for h in hw_], "side": "deck", "kind": "nut", "supports": ["deck"] * len(hw_),
                    "lows": [round(deck_top, 3)] * len(hw_), "boss_r": r_}
        for i, (hx, hy) in enumerate(hw_):
            placed.append(Box(f"boss {i + 1} of radxa pcb", (2 * r_, 2 * r_, rx.at[2] - deck_top + 0.3), (hx - r_, hy - r_, deck_top - 0.3), "board", KEEP_OUT))
            placed.append(Box(f"screw head {i + 1} of radxa pcb", (SCREW_HEAD_D, SCREW_HEAD_D, SCREW_HEAD_K), (hx - SCREW_HEAD_D / 2, hy - SCREW_HEAD_D / 2, rx.at[2] + rx.size[2]), "board", FASTENER))

    def on_deck(x0, x1, y0_, y1):
        hw_d = (batt.size[1] - 6.0) / 2 - 0.5
        if ext_hw and x1 > dx1 - 0.5:                       # reaching onto the extension: its narrower width
            hw_d = min(hw_d, ext_hw - 0.5)
        return dx0 + 0.5 <= x0 and x1 <= (dx1e if ext_hw else dx1) - 0.5 and -hw_d + batt.at[1] + batt.size[1] / 2 <= y0_ and y1 <= hw_d + batt.at[1] + batt.size[1] / 2

    def mounted(name, size, holes, x0, y0_, side, role, group="", strap_at=None, z_at=None, turn=None):
        """The board and its bosses, nuts' screw heads and (for a strapped board) its strap, or None if they don't fit.
        holes are in the box frame; a strapped board (holes None) gets two bosses just off its ±x ends."""
        r = BOSS_D / 2
        strap = holes is None
        STRAP_GAP = STRAP_GAP_OF.get(name, globals()["STRAP_GAP"])   # noqa: N806 (this board's gap)
        if strap:
            holes = strap_at or [(-r - 0.5, size[1] / 2), (size[0] + r + 0.5, size[1] / 2)]
        hw_ = [(x0 + hx, y0_ + hy) for hx, hy in holes]
        bx0_, bx1_ = min([x0] + [h[0] - r for h in hw_]), max([x0 + size[0]] + [h[0] + r for h in hw_])
        by0_, by1_ = min([y0_] + [h[1] - r for h in hw_]), max([y0_ + size[1]] + [h[1] + r for h in hw_])
        if side == "deck":
            if not on_deck(bx0_, bx1_, by0_, by1_):
                return None
            z0 = deck_top + (0.3 if strap else BOSS_H)       # a strapped board lies on the deck (a foam pad under it)
            lows = [deck_top] * len(hw_)
        elif side == "floor":   # each boss stands on whatever is under it: the deck, or the floor beside the cell
            sup = ["deck" if on_deck(hx - r, hx + r, hy - r, hy + r) else "floor" for hx, hy in hw_]
            lows = [deck_top if sp == "deck" else cav.floor_under(hx - r, hx + r, hy - r, hy + r) for sp, (hx, hy) in zip(sup, hw_)]
            under = deck_top if on_deck(x0, x0 + size[0], y0_, y0_ + size[1]) else cav.floor_under(x0, x0 + size[0], y0_, y0_ + size[1])
            z0 = (under + 0.3) if strap else max(max(lows) + BOSS_H, under + 0.4)
        else:   # roof: the board hangs, components down; bosses from its top face up to the roof
            highs = [cav.roof_over(hx - r, hx + r, hy - r, hy + r) for hx, hy in hw_]
            roof_b = cav.roof_over(x0, x0 + size[0], y0_, y0_ + size[1])
            top = (roof_b - 0.6) if strap else min(min(highs) - BOSS_H, roof_b - 0.4)   # strapped: up against the roof
            z0 = top - size[2]
        if z_at is not None and not strap:   # standing taller to bridge over a lower part, or hanging lower under a higher one
            z0 = max(z0, z_at) if side != "roof" else min(z0, z_at - size[2])
        if strap:   # every strap boss must still be tall enough to hold its nut
            zs_ = (z0 - STRAP_GAP) if side == "roof" else (z0 + size[2] + STRAP_GAP)
            if any(abs(zs_ - v) < BOSS_H for v in (highs if side == "roof" else lows)):
                return None
        per = sup if side == "floor" else [side] * len(hw_)
        b = Box(name, size, (x0, y0_, z0), group, role,
                mount={"holes": [[round(h[0], 3), round(h[1], 3)] for h in hw_], "side": side, "kind": "strap" if strap else "nut",
                       "supports": per, "lows": [round(v, 3) for v in (lows if side != "roof" else highs)],
                       **({"turn": turn} if turn is not None else {})})
        extra = []
        for i, (hx, hy) in enumerate(hw_):
            if side == "roof":
                zb0, zb1 = z0 + size[2], highs[i] + 0.3
                head = (z0 + size[2] - 1.6 - SCREW_HEAD_K) if not strap else (z0 - STRAP_GAP - STRAP_T - SCREW_HEAD_K)
                if strap:
                    zb0 = z0 - STRAP_GAP - STRAP_T
            else:
                zb0, zb1 = lows[i] - 0.3, (z0 + size[2] + STRAP_GAP) if strap else z0
                head = (z0 + size[2] + STRAP_GAP + STRAP_T) if strap else (z0 + 1.6)
            extra.append(Box(f"boss {i + 1} of {name}", (BOSS_D, BOSS_D, max(zb1 - zb0, 0.5)), (hx - r, hy - r, zb0), group, KEEP_OUT))
            extra.append(Box(f"screw head {i + 1} of {name}", (SCREW_HEAD_D, SCREW_HEAD_D, SCREW_HEAD_K), (hx - SCREW_HEAD_D / 2, hy - SCREW_HEAD_D / 2, head), group, FASTENER))
        if strap:
            (ax, ay), (bx_, by_) = hw_
            zs = (z0 + size[2] + STRAP_GAP) if side != "roof" else (z0 - STRAP_GAP - STRAP_T)
            if abs(bx_ - ax) >= abs(by_ - ay):     # a strap along x
                extra.append(Box(f"strap of {name}", (abs(bx_ - ax) + BOSS_D, STRAP_W, STRAP_T), (min(ax, bx_) - r, ay - STRAP_W / 2, zs), group, "structure"))
            else:                                  # along y
                extra.append(Box(f"strap of {name}", (STRAP_W, abs(by_ - ay) + BOSS_D, STRAP_T), (ax - STRAP_W / 2, min(ay, by_) - r, zs), group, "structure"))
        tr = TRACE.get(name)
        if not free(b):
            if tr is not None:
                tr["board: " + ("outside the cavity" if not cav.contains(b) else "strip zone" if not band_clear(b) else
                                next((o.name for o in placed if b.overlaps(o) and not (b.group and b.group == o.group)), "?"))] += 1
            return None
        for e in extra:
            why = None
            if not e.name.startswith("boss") and not cav.contains(e):      # bosses root into the wall or the deck by design
                why = "outside the cavity"
            elif not band_clear(e):                                        # nothing may stand in the light strip's zone
                why = "strip zone"
            else:
                why = next((o.name for o in placed if e.overlaps(o) and not (o.name == "battery deck" and side in ("deck", "floor"))), None)
            if why:
                if tr is not None:
                    tr[e.name.split(" of ")[0] + ": " + why] += 1
                return None
        return [b] + extra

    def place_mounted(name, size, holes, xs, ys, sides, role, group="", turn=True):
        """Try each support, position and (for a non-square board) both turns; place the first that fits."""
        for side in sides:
            for turned in ((False, True) if turn and size[0] != size[1] else (False,)):
                sz = (size[1], size[0], size[2]) if turned else size
                hl = turned_holes(holes, sz, turned) if holes is not None else None
                r = BOSS_D / 2   # a strapped board tries its strap along x, then across (bosses off its ±y sides)
                straps = [None] if holes is not None else [None, [(sz[0] / 2, -r - 0.5), (sz[0] / 2, sz[1] + r + 0.5)]]
                turns = [None] * len(straps)
                if holes is None and name in STRAP_LINE:   # a strap kept off the board's pads (ADR-0021), the board any way round
                    straps, turns = [], []                 # (board frame → box: 0° as is, 90° (x, y) → (sx − y, x), and so on)
                    for yl in STRAP_LINE[name]:
                        if turned:
                            straps += [[(sz[0] - yl, -r - 0.5), (sz[0] - yl, sz[1] + r + 0.5)], [(yl, -r - 0.5), (yl, sz[1] + r + 0.5)]]
                            turns += [90, 270]
                        else:
                            straps += [[(-r - 0.5, yl), (sz[0] + r + 0.5, yl)], [(-r - 0.5, sz[1] - yl), (sz[0] + r + 0.5, sz[1] - yl)]]
                            turns += [0, 180]
                    keep = [i for i, t_ in enumerate(turns) if t_ in STRAP_TURNS.get(name, (0, 90, 180, 270))]
                    straps, turns = [straps[i] for i in keep], [turns[i] for i in keep]
                for strap_at, turn_ in zip(straps, turns):
                    for x0 in xs:
                        for y0_ in ys(sz) if callable(ys) else ys:
                            got = mounted(name, sz, hl, x0, y0_, side, role, group, strap_at=strap_at, turn=turn_)
                            if not got and holes is not None:
                                # bridge over a lower part on taller bosses (or, from the roof, hang under a higher one):
                                # try each part's top (bottom) under (over) the footprint
                                over = [o for o in placed if o.role not in (KEEP_OUT, FASTENER)
                                        and o.at[0] < x0 + sz[0] and x0 < o.at[0] + o.size[0] and o.at[1] < y0_ + sz[1] and y0_ < o.at[1] + o.size[1]]
                                levels = sorted({o.at[2] + o.size[2] + CL for o in over}) if side != "roof" else sorted({o.at[2] - CL for o in over}, reverse=True)
                                for z_ in levels:
                                    got = mounted(name, sz, hl, x0, y0_, side, role, group, z_at=z_)
                                    if got:
                                        break
                            if got:
                                placed.extend(got)
                                return got[0]
        fails.append(name)
        return None

    # 3. eye at the golden section from the head, hung from the roof
    x_eye = s.L / 2 - s.L / PHI ** 2
    roof_eye = cav.roof_over(x_eye - 16, x_eye + 16, -16, 16)
    info['eye_leds'] = s.eye_leds
    led_z = roof_eye - s.diffuser - 3.2
    n_led = s.eye_leds
    led_w, led_h, r_led = (9.1, 3.2, s.eye_r) if n_led == 5 else (6.0, s.ring_h, 15.0)   # ring: Ø36.8/Ø23.3 → LED circle r ≈ 15
    for i in range(n_led):
        a = math.radians(90 - i * 360 / n_led)
        cx, cy = x_eye + r_led * math.cos(a), r_led * math.sin(a)
        lz = cav.roof_over(cx - led_w / 2, cx + led_w / 2, cy - led_w / 2, cy + led_w / 2) - s.diffuser - led_h
        b = Box(f"LED {i+1}", (led_w, led_w, led_h), (cx - led_w / 2, cy - led_w / 2, lz), "eye", "eye")
        if not free(b):
            fails.append(b.name)
        placed.append(b)
    # the mic-live light: powered by the microphone rail, so it is on exactly while the microphones can hear
    eye_top = cav.roof_over(x_eye - 4.5, x_eye + 4.5, -4.5, 9.0) - s.diffuser       # underside of the eye's light guide
    if s.eye_button:
        # ADR-0013: the eye is a button. A through-hole 6 × 6 × 4.3 tactile switch in a printed cradle under the guide;
        # the cradle (6 × 6 pocket + 1.2 mm floor) hangs from the roof on three arms.
        placed.append(Box("eye switch (tactile, in cradle)", (7.6, 7.6, 4.3 + 1.2), (x_eye - 3.8, -3.8, eye_top + 0.05 - 4.3 - 1.2), "eye", "eye"))   # ADR-0015: 0.05 mm preload on the plunger, so the guide never rattles
        placed.append(Box("mic-live dot (sequin)", (9.0, 4.0, 2.0), (x_eye - 4.5, 4.4, eye_top - 2.0), "eye", "eye"))
    else:
        placed.append(Box("mic-live dot (sequin)", (4.0, 9.0, 2.0), (x_eye - 2.0, -4.5, eye_top - 2.0), "eye", "eye"))
    info["eye_top"] = eye_top
    info.update(x_eye=x_eye, led_z=led_z, roof_eye=roof_eye)

    # ADR-0018: every board below is screwed down: on the battery deck, the floor or the roof (see mounted()).
    def ys_grid(lo, hi, step=1.0):
        return lambda sz: [lo + k * step for k in range(int((hi - lo - sz[1]) / step) + 1)]
    deck_ys = ys_grid(batt.at[1] + 3.5, batt.at[1] + batt.size[1] - 3.5)
    all_ys = ys_grid(-s.W / 2 + s.wall, s.W / 2 - s.wall)

    # 5. tail tip: USB-C breakout on the floor. Its receptacle opens on a LONG edge (Adafruit 4090 STEP:
    # 20.32 × 13.97, socket at x 5.7–14.6 opening +Y), so it lies 14.2 along X × 20.4 across, socket facing −X.
    # ADR-0018: two screws beside the socket take the plug's push and pull.
    usb = place_mounted("USB-C breakout (charge port)", (14.2, 20.4, 5.0), BOARD_HOLES["USB-C breakout (charge port)"],
                        [(-Li + d) for d in [x * 0.25 for x in range(0, 100)]], [-10.2], ("floor",), "power", turn=False)
    info["usb_inset"] = (usb.at[0] + Li) if usb else None

    # 6. charger (ADR-0014: Adafruit 4755, bq24074). The board and its JSTs stand 6.4 mm; the unused 2.1 mm DC jack
    # (SMT, not removable without hot air) stands 12.6 mm over one corner, so it is placed as a second box. Board frame
    # from the vendor STEP: jack at x 0–12.5, y 17.2–26.9. ADR-0018: four screws, on the deck, the floor or the roof.
    ch = None
    x_pref = (usb.at[0] + 14.2 + 0.5) if usb else -Li + 10
    if s.big_charger:
        BX, BY = 41.4, 33.1
        def rot(k, x0, y0, x1, y1):
            pts = [(x0, y0), (x1, y1)]
            for _ in range(k):
                pts = [(-y, x) for x, y in pts]
            xs_, ys_ = [q[0] for q in pts], [q[1] for q in pts]
            return min(xs_), min(ys_), max(xs_), max(ys_)
        name = "bq24074 charger (Adafruit 4755)"
        for side in (("deck", "floor") if s.chassis else ("deck", "floor", "roof")):
            for k in range(4):
                bx0, by0, bx1, by1 = rot(k, 0, 0, BX, BY)
                jx0, jy0, jx1, jy1 = rot(k, 0, 17.2, 12.5, 26.9)
                hk = [(q[0] - bx0, q[1] - by0) for q in (rot(k, hx, hy, hx, hy)[:2] for hx, hy in BOARD_HOLES[name])]
                sx_, sy_ = bx1 - bx0, by1 - by0
                for x0 in around(x_pref, -Li, Li - sx_, 1.0):
                    for y0_ in [-sy_ / 2 + d for d in (0, 3, -3, 6, -6, 9, -9, 12, -12, 15, -15)]:
                        got = mounted(name, (sx_, sy_, 6.4), hk, x0, y0_, side, "power", "charger")
                        if not got:
                            continue
                        z0 = got[0].at[2]
                        jz = (z0 + 6.4 - 12.6) if side == "roof" else z0
                        jack = Box("charger DC jack (unused)", (jx1 - jx0, jy1 - jy0, 12.6), (x0 + jx0 - bx0, y0_ + jy0 - by0, jz), "charger", "power")
                        if not s.charger_jack:
                            ch = got
                            info["charger_down"] = side == "roof"
                            break
                        if free(jack) and not any(jack.overlaps(e) for e in got[1:]):
                            ch = got + [jack]
                            info["charger_down"] = side == "roof"
                            break
                    if ch: break
                if ch: break
            if ch: break
        if ch:
            placed.extend(ch)
        else:
            fails.append(name)
    else:
        ch = place_mounted("bq25185 charger", (25.4, 31.75, 7.2), None, around(x_pref, -Li, Li), all_ys, ("deck", "floor"), "power")

    # ADR-0018 order: the most constrained first. Screwed boards can't stack on one another, so after the charger
    # come the parts with fixed places (mics; the tap icon and the tag at the tail), then the clock and the secure
    # element, and the small strapped boards last, on the floor or the roof before the deck.
    # mics on the roof: one ahead of the eye, one behind it; screwed, with a printed port tube from each pin-hole down
    # to its mic (pebble_enclosure.py)
    place_mounted("mic head (SPH0645)", (16.7, 12.7, 2.6), BOARD_HOLES["mic head (SPH0645)"], around(x_eye + 19, x_eye + 15, Li), [-6.35, -4.6, -8, -2, -10], ("roof",), "ears", turn=False)
    place_mounted("mic tail (SPH0645)", (16.7, 12.7, 2.6), BOARD_HOLES["mic tail (SPH0645)"], around(x_t + 6, -Li, x_eye - 22), [-4.6, -6.35, -2, 2, -9], ("roof",), "ears", turn=False)

    # the tap icon's LED (a spare NeoPixel Mini from the 5-pack) up against the tail roof, strapped to two roof bosses
    # on ±y out beyond its light path (ADR-0016: nothing may sit between the LED and the icon's pockets)
    led = None
    if s.light_band:
        sy_off = NFC_ICON_R + 0.6 + BOSS_D / 2
        for x0 in around(-Li + 18, -Li, -Li + 45):
            for y0 in (-4.55, -2, -7, 0, -10, 3):
                got = mounted("NFC icon LED (NeoPixel Mini)", (9.1, 9.1, 3.2), None, x0, y0, "roof", "handoff",
                              strap_at=[(4.55 - NFC_ICON["dot_dx"], 4.55 - sy_off), (4.55 - NFC_ICON["dot_dx"], 4.55 + sy_off)])
                if not got:
                    continue
                b = got[0]
                ix, iy = x0 + 4.55 - NFC_ICON["dot_dx"], y0 + 4.55
                lx0, lx1 = ix + NFC_ICON["dot_dx"] - NFC_ICON["dot"] - 0.3, ix + NFC_ICON_R + 0.3
                ly0, ly1 = iy - NFC_ICON_R - 0.3, iy + NFC_ICON_R + 0.3
                lz0 = b.at[2] + b.size[2]
                lz1 = max(cav.roof(lx0 + (lx1 - lx0) * i / 8, ly0 + (ly1 - ly0) * j / 8) for i in range(9) for j in range(9))
                path = Box("light path of NFC icon LED (NeoPixel Mini)", (lx1 - lx0, ly1 - ly0, max(lz1 - lz0, 0.5)), (lx0, ly0, lz0), "", KEEP_OUT)
                if not any(path.overlaps(o) for o in placed + got[1:] if not o.name.startswith("strap of NFC icon")):
                    led = b
                    placed.extend(got + [path])
                    break
            if led:
                break
        if not led:
            fails.append("NFC icon LED (NeoPixel Mini)")

    # the NFC tag at the tail, under the icon if it can be: strapped up to the roof (antenna near the phone), or on its
    # two holes, hanging 3.5 mm lower
    near = (led.at[0] - 8) if led else -Li + 16
    place_mounted("NFC tag ST25DV64KC", (25.4, 25.4, 2.6), None, around(near, -Li, -Li + 45), [-12.7, -10, -15, -7, -18, -4], ("roof",), "handoff")
    if "NFC tag ST25DV64KC" in fails:
        fails.remove("NFC tag ST25DV64KC")
        place_mounted("NFC tag ST25DV64KC", (25.4, 25.4, 2.6), BOARD_HOLES["NFC tag ST25DV64KC"], around(near, -Li, -Li + 45), [-12.7, -10, -15, -7, -18, -4], ("roof",), "handoff")

    holed = (("RTC PCF8523 + CR1220 (STEMMA QT)", (25.4, 17.8, 7.8), "time"), ("ATECC608 secure element", (17.7, 25.5, 2.6), "security"))
    if not s.kit_friendly:
        holed = (("RV3028 clock", (19.0, 19.0, 4.0), "time"), ("ATECC608 secure element", (17.7, 25.5, 2.6), "security"))
    for name, size, role in holed:
        place_mounted(name, size, BOARD_HOLES.get(name), around(x_t + 32, -Li, Li), all_ys, ("deck", "floor") if s.chassis else ("deck", "floor", "roof"), role)

    # the small strapped boards (no holes for M2.5): anywhere left, floor and roof before the deck
    smalls = [("vibration disc (ERM)", (10.0, 10.0, 2.7), "touch"), ("5 V boost U3V40F5", (15.2, 15.2, 5.6), "power")]
    if s.kit_friendly:
        # ADR-0013 kit: plug-in (STEMMA QT / Qwiic) or header-pin modules instead of SMD parts on adapters
        smalls += [("power switch Pololu 2810", (15.2, 15.2, 3.5), "time"),   # motor driver: a PN2222A + 1N4148 in line, under heat-shrink (no board)
                   ("ideal diode LM66200 (Adafruit 5830)", (16.5, 10.0, 3.0), "time")]   # ring data: the tail icon LED, on a diode, is the level shifter
    for name, size, role in smalls:
        place_mounted(name, size, BOARD_HOLES.get(name), around(x_t + 66, -Li, Li), all_ys, ("floor", "deck") if s.chassis else ("floor", "roof", "deck"), role)

    if s.chassis:
        # the lid's one connector: a latching 12-way plug pair on the deck, strapped down (the lid side unplugs)
        # it is an inline plug pair: it can be fixed in either half; the lid's roof is nearly empty now
        place_mounted("lid connector (12-way)", LID_CONNECTOR, None, around(x_t + 32, -Li, Li), all_ys, ("deck", "floor", "roof"), "lid")

    # belly-side controls, below the light band: power slide switch, microphone slide switch, read button.
    # Each part sits against the inner wall; its actuator centre height becomes the slot height in the shell.
    if s.light_band:
        z_top_limit = band_zone[0][0] - 0.4
        usb_ = next((b for b in placed if b.name.startswith("USB-C")), None)
        # ADR-0021 (amended): C&K OS102011MS2QN1, the vertical switch, lying on its back: actuator out through the flank,
        # body 8.6 × 4.0 deep × 4.3 tall (datasheet: 8.60 × 4.30, 4.00 high), its three pins (2.80 mm) pointing in, trimmed
        # to 1.5 mm, with 1.5 mm beyond them for the solder joints: that space, 9.0 × 3.0 × 3.0, is a keep-out behind it
        sw_size = (8.6, 4.0, 4.3) if s.kit_friendly else (9.0, 4.0, 3.6)       # · JS102011SAQN (SMD)
        PINS = (9.0, 3.0, 3.0) if s.kit_friendly else None
        controls = (("power switch", sw_size, -36.0 if s.kit_friendly else 30.0, "power"),                 # right-angle slide switches, actuator through the flank
                    ("mic switch", sw_size, x_eye - 6, "ears"))                  # same part: the hardware microphone cut
        if not s.eye_button:
            controls += (("read switch (tactile)", (6.0, 3.6, 3.5), (usb_.at[0] + 34) if usb_ else -30.0, "handoff"),)
        for name, size, x_pref, role in controls:
            sw = None
            for xs in around(x_pref - size[0] / 2, -Li + 10, Li - 20, 1.0):
                for z0 in [z_top_limit - size[2] - k * 0.5 for k in range(0, 12)]:
                    for inset_ in (0.2, 0.6, 1.2, 2.0, 2.8, 3.6, 4.4):   # from the wall at the widest plane: lower down the belly has curved in
                        yw = -(pb.half_width(xs + size[0] / 2) - s.wall) + inset_
                        cand = Box(name, size, (xs, yw, z0), role=role)
                        pins = Box(f"pins and joints of {name}", PINS, (xs + (size[0] - PINS[0]) / 2, yw + size[1], z0 + (size[2] - PINS[2]) / 2), "", KEEP_OUT) if PINS else None
                        if free(cand) and (pins is None or free(pins)):
                            sw = cand; break
                    if sw: break
                if sw: break
            if sw:
                placed.append(sw); info.setdefault("controls", {})[name] = sw.at
                if pins is not None:
                    placed.append(pins)
                if name == "power switch":
                    info["power_switch"] = sw.at
            else:
                fails.append(name)

    # ADR-0009 light band: audit every placed part against the strip zone (posts included)
    if s.light_band:
        info["light_band"] = list(band_zone[0])
        for b in placed:
            if not band_clear(b):
                fails.append(f"band clearance: {b.name}")
    info["fails"] = fails
    if verbose:
        print(s)
        print({k: (round(v, 2) if isinstance(v, float) else v) for k, v in info.items()})
    return not fails, fails, placed, info


def search():
    best = None
    for T in (22.0, 23.0, 24.0, 25.0):
        for W in (48.0, 50.0, 52.0, 54.0):
            for L in range(112, 136, 2):
                for hbf in (0.32, 0.36, 0.40):
                    s = Spec(L=L, W=W, T=T, h_bot_frac=hbf)
                    ok, *_ = pack(s)
                    if ok:
                        vol = s.pebble().solid().volume
                        if best is None or vol < best[0]:
                            best = (vol, s)
                        break
                else:
                    continue
                break
    return best


if __name__ == "__main__":
    import sys
    if len(sys.argv) > 1 and sys.argv[1] == "search":
        v, s = search()
        print(round(v / 1000, 1), "cm³", s)
    else:
        ok, fails, placed, info = pack(Spec(), verbose=True)
        print("fits" if ok else fails)
