onlyn00bs-badge

OnlyN00bs: a DEF CON 34 friend-finder badge. ESP32 firmware, Web Bluetooth setup app, printable case
git clone https://git.virtualshack.io/onlyn00bs-badge.git
Log | Files | Refs | README | LICENSE

gen_stl.py (117659B)


      1 #!/usr/bin/env python3
      2 """
      3 DefCon friend-finder badge — prototype enclosure STL generator (dependency-free).
      4 
      5 Emits binary STL meshes for a two-piece tray+lid case (flat-sandwich fallback via
      6 PARAMS["walls"]). No external libraries: geometry is built from axis-aligned boxes,
      7 walls, and rounded corners, written as raw triangles. Watertight by construction.
      8 
      9 Two layouts (locked: B primary, A backup):
     10   B  Coplanar  — battery beside electronics. Bigger face, slimmer.   ~95 x 90 x 24 mm  <- PRIMARY
     11   A  Stacked   — battery behind electronics. Compact face, deeper.   ~95 x 74 x 30 mm
     12 
     13 Each layout produces TWO printable parts:
     14   *_front.stl  front bezel / LID — display-window cut + rounded corners + ears (flat)
     15   *_back.stl   back TRAY — floor + perimeter walls + M3 corner screw bosses + ears
     16                (set PARAMS["walls"]=False for the old flat slab)
     17 
     18 Run:  python3 gen_stl.py
     19 Out:  ./stl/badge_<ver>_{A,B}_{front,back}.stl  (ver from PARAMS) — stl/ holds only
     20       the CURRENT version; each run sweeps prior versions to ./stl_archive/. Plus a
     21       console size/weight report; each STL's 80-byte header names its version.
     22 
     23 All dims in mm. Tweak the PARAMS block; everything else is derived.
     24 """
     25 
     26 import struct
     27 import math
     28 import os
     29 import subprocess
     30 
     31 # ----------------------------------------------------------------------------
     32 # PARAMS  — every real-world measurement lives here. Verify w/ calipers on
     33 # arrival; these are datasheet/vendor numbers (the enclosure spec is ./README.md).
     34 # ----------------------------------------------------------------------------
     35 P = {
     36     # version stamp — baked into each STL's filename AND its 80-byte header, so
     37     # every iteration persists for posterity and a shared file names its version.
     38     # Bump MINOR for tweaks, MAJOR for a layout/structural milestone (v0 = the old
     39     # flat sandwich; v1 = walled tray + M3 bosses). The git short-rev is printed in
     40     # the console only (NOT baked into the STL, so regen stays churn-free) — mirrors
     41     # the firmware FW_VERSION (human) / GIT_REV (exact) split.
     42     "case_version": "v1.51",   # v1.51: maker URL virtualshack.io → LID top band (pixel font, band-centred, printable), off the cramped tray strip. v1.50: house in the battery pocket
     43 
     44     # OLED 2.42" SSD1309 module (the FACE driver) — v1.15: RE-BASED to caliper
     45     # actuals (2026-06-11, checklist #10–#13); the 71.0×43.5 datasheet ran 1.0
     46     # wide and 4.5 SHORT of the real module (the v1.10→v1.13 collar saga's root
     47     # cause). The window/collar OPENINGS are preserved exactly via the clearance/
     48     # extra mapping below — the dialed fit carries over; only the basis is real now.
     49     "oled_pcb_w": 70.0,        # PCB long edge (measured; datasheet said 71.0)
     50     "oled_pcb_h": 48.0,        # PCB short edge (measured; datasheet said 43.5!)
     51     "oled_active_w": 55.0,     # lit area (reference)
     52     "oled_active_h": 27.5,
     53     # v1.5: rectangular PASSTHROUGH window — sized to the glass panel + a small gap so
     54     # the OLED glass nests THROUGH the opening (PCB captured behind the lid). Replaced
     55     # the old active-area + bezel-lip window: easier assembly, no glass-offset guess.
     56     "oled_glass_w": 61.0,      # glass panel, measured: 4.5mm offset all around → CENTRED
     57     "oled_glass_h": 39.0,
     58     "window_clearance": 0.5,   # per-side glass→opening gap (re-base: opening stays 63.0×40.0)
     59     "window_extra_w": 1.0,     # extra WIDTH on top of the clearance (the validated v1.9 slack)
     60     "window_extra_h": 0.0,     # extra HEIGHT (the v1.8+v1.10 slack now lives in the real glass dims)
     61     # guide ridges — a short collar on the LID's inner face, framing the OLED PCB
     62     # outline; the module nests into it for XY registration during assembly (doesn't
     63     # touch the window opening, so it doesn't shrink the visible screen).
     64     "guide_ridges": True,
     65     "guide_h": 2.5,            # ridge height into the cavity
     66     "guide_t": 1.2,            # ridge wall thickness
     67     "guide_clearance": 0.25,   # gap PCB→ridge per side (re-base: opening stays 74.0×48.5)
     68     # collar-FOOTPRINT trim — adjusts the collar's XY opening ONLY (not the plate
     69     # outline or the display window). v1.15 re-base: the validated opening maps to
     70     # real dims as 0.25/side snug height + the historic width slack as extra_w.
     71     "collar_extra_w": 3.5,     # collar WIDTH slack on top of the clearance (historic, harmless)
     72     "collar_extra_h": 0.0,     # HEIGHT: the 0.25/side IS the validated v1.13 snug fit
     73     # OLED header notch — the module's 4-pin header sits on its RIGHT edge
     74     # (screen-facing; user pick 2026-06-11), so the collar wall on that edge gets a
     75     # centred gap for the header + leads. MIRROR NOTE (empirical, v1.17): the
     76     # v1.15 derivation put viewer-right at lid −x — the FIT PRINT proved that
     77     # WRONG (notch landed opposite the tray's USB wall). As assembled, viewer-
     78     # right = +x on BOTH parts; the notch lives on +x. Trust the print, not the
     79     # chirality argument.
     80     "oled_header_notch_w": 22.0,  # notch width (18mm header span + tolerance)
     81     # OLED mounting pegs — the measured pattern (#13): 4× Ø2.0 holes @ 64.66×42.21
     82     # c2c (near-edge caliper method). PEGS, not screws: an M2 can't engage a 2mm
     83     # lid and a boss can't pass the module's own Ø2 holes. Assembly: module drops
     84     # over the pegs (glass through the window, PCB flush on the lid), then melt
     85     # the peg tips with an iron to stake — or leave friction-fit.
     86     "oled_pegs": True,
     87     "oled_mount_w": 65.16,     # peg c2c, long axis: measured 64.66 + 0.5 width slack (v1.25 fit — height pair was perfect)
     88     "oled_mount_h": 42.21,     # hole-centre spacing, short axis
     89     "oled_peg_d": 1.8,         # peg Ø — clearance into the Ø2.0 holes
     90     "oled_peg_h": 4.0,         # ≈PCB (~1.6) + ~2.4 proud for heat-staking
     91 
     92     # front case button — EXTERNAL panel-mount momentary, wired GPIO27→GND in
     93     # parallel with the onboard dome: a round through-hole
     94     # in the LID below the screen, centred on W, mid-band between the bottom wall
     95     # and the guide collar. Hole Ø = the part's THREAD OD. The part of record is
     96     # the Twidec PBS-110 (7 mm momentary, pre-soldered leads) — VALIDATED on glass
     97     # 2026-06-11: it threads into the v1.14 as-printed Ø7.0 hole, nut seats flat.
     98     # Auto-skipped if the band is too tight to web the hole (layout A).
     99     "btn_hole": True,
    100     "btn_hole_d": 7.0,         # Twidec PBS-110 thread — validated by fit test
    101 
    102     # FireBeetle 2 ESP32-E (the DEPTH driver's neighbor) — caliper pass 2026-06-10:
    103     # 60.0 × 25.0 (short edge 0.4 under datasheet); USB-C overhangs the short edge
    104     # 2.0mm (62.0 total), 9.0mm wide, 3.45mm above the 1.55mm PCB. The USB wall slot
    105     # itself is deferred to the board-mounting pass (its vertical position needs the
    106     # standoff design); the outline trim lands here.
    107     "fb_w": 60.0,
    108     "fb_h": 25.0,              # measured (was 25.4 datasheet) — B plate height −0.4
    109     "fb_usb_overhang": 2.0,    # USB-C proud of the short edge (62.0 board+connector total)
    110     # FireBeetle mounting — the CAD-exact M2 pattern (±28.4 along the length,
    111     # ±11.1 across, about the board centre). Standoff bosses
    112     # rise from the TRAY floor; COMPONENTS FACE THE FLOOR (the 7mm JST sets the
    113     # height), keeping the lid side clear for the case button. M2 screws drop in
    114     # from the front during back-insertion assembly. B placement (v1.33): PORTRAIT,
    115     # mounted top-LEFT with the USB short edge facing the −y (bottom) wall and the
    116     # antenna short edge getting the +y margin. The board stays COMPONENT-DOWN (no
    117     # flip — standoff stack unchanged from v1.32), which lands the JST on the board's
    118     # right side, facing the top-RIGHT battery: a short, tidy wire run (the "mirror"
    119     # of a board flip — same wiring win, no re-validation). (v1.32 was landscape with
    120     # USB on the +x/right wall; recover it with fb_orient="landscape", usb_wall="+x".)
    121     "fb_standoffs": True,
    122     "fb_orient": "portrait",   # B board orientation: "portrait" (USB short edge → −y) | "landscape" (→ +x, v1.32)
    123     "usb_wall": "-y",          # wall the USB-C slot punches through: "-y" (bottom) | "+x" (right, v1.32)
    124     "fb_hole_dx": 28.4,        # hole offset along the board length (from vendor CAD)
    125     "fb_hole_dy": 11.1,        # hole offset across the board width  (from vendor CAD)
    126     "fb_standoff_h": 9.5,      # JST 7.0 + 2.5 floor clearance (v1.16: 8.5→9.5 — raises the
    127                                #   board, and with it the USB dremel path, +1mm off the back)
    128     "fb_standoff_r": 2.25,     # boss Ø4.5 — ~1.4mm wall around the M2 pilot
    129     "fb_pilot_r": 0.8,         # M2 self-tap pilot, Ø1.6
    130 
    131     # USB-C cutout (v1.20; relocated to the −y/bottom wall in v1.33) — a REAL through-
    132     # slot, replacing the v1.19 raised dremel outline (the slot position is geometrically
    133     # locked, so we model the hole for the fleet instead of cutting 20 by hand). Position
    134     # tracks the board so it can't drift: for the bottom wall (usb_wall "-y") it's X-centred
    135     # on the FireBeetle's −y short edge (the connector); for "+x" it's Y-centred on the +x
    136     # edge. z is slaved to the standoff height either way (so "the dremel path rises with the
    137     # board" — v1.16 — is automatic, and the board mounts identically, component-down). The
    138     # optional slot TOP 45° inverted-V roof prints SUPPORT-FREE floor-down (a flat top would
    139     # be a ~10mm bridge); the flat bottom is fine (an upward face). usb_cutout:False reverts
    140     # to a solid wall (usb_roof_45:False → plain rectangle). The wall is set by usb_wall above.
    141     "usb_cutout": True,
    142     "usb_slot_w": 10.0,        # slot opening along the wall (y) — 9mm connector + tolerance
    143     "usb_slot_h": 4.5,         # rectangular opening height (z) — the USB-C body
    144     "usb_slot_top_margin": 0.5,# rectangle top above the board plane (ft + fb_standoff_h)
    145     "usb_slot_z_offset": 1.0,  # v1.37: 0.0 → +1.0 (another 1mm UP); v1.34 took it −2.0 → 0.0 (− = DOWN) —
    146                                #   empirical fit nudge, kept separate from top_margin so it stays slaved to the standoff
    147     "usb_roof_45": False,      # plain rectangular slot — X1C/H2D bridge 10mm fine, and a tiny
    148                                #   support peels right off; the peak added no usable plug clearance
    149 
    150     # EEMB 103454 LiPo, 3.7V 2000mAh — the cell in hand (×4); plugs straight into
    151     # the FireBeetle JST-PH, no holder. Caliper-confirm thickness — pouches run a
    152     # hair over the 10.0 nominal. (Thinner alt on hand: Qimoo 503035, 5×30×35,
    153     # 500mAh — slimmer case but well under a con day, so a backup not the hero.)
    154     "bat_w": 54.0,    # length (103454 = 4mm longer than the old 103450 model)
    155     "bat_h": 34.0,    # width  → drives layout-B face height
    156     "bat_t": 10.0,    # thickness → the depth driver
    157 
    158     # battery retention (v1.34) — a FULL collar cradling the 103454 pouch (vertical, in
    159     # B-portrait's RIGHT column, v1.35 hard against the right wall — leads are long). Walls
    160     # stand on the tray floor (compose-by-overlap like the standoffs). The floor under the
    161     # pocket stays SOLID (no vents — debris/short keepout).
    162     # B-portrait only (A stacks the cell behind the board, no floor pocket).
    163     "bat_collar": True,
    164     "bat_collar_h": 8.0,       # wall height (z) — cradles ~8 of the 10mm pouch; the soft top bulges free
    165     "bat_collar_t": 2.0,       # wall thickness
    166     "bat_collar_clear": 1.0,   # clearance per side on the TOP/BOTTOM (horizontal, y) walls
    167     "bat_collar_clear_x": 0.5, # clearance per side on the LEFT/RIGHT (vertical, x) walls — v1.35: 0.5 (1mm snugger)
    168     "bat_notch": False,        # JST-lead notch in the −x wall — v1.35: OFF (leads route around; long enough)
    169     "bat_notch_w": 16.0,       # notch width (y span) when bat_notch is on, biased low toward the FB's JST
    170 
    171     # plate / shell
    172     "plate_t": 2.0,            # thickness of each flat plate
    173     "cavity_pad": 4.0,         # extra internal depth clearance (adds to cavity → +overall depth)
    174     "edge_margin": 12.0,       # plastic border — keeps the 7mm corner bosses clear of the OLED guide
    175                                # collar (v1.7: 6→11; v1.12: 11→12 once the collar grew +2mm)
    176     "corner_r": 3.0,           # rounded outer corners
    177     "corner_seg": 8,           # arc resolution per corner
    178     # v1.18: soften the two EXPOSED face↔wall edges (lid front, tray back) with a
    179     # 45° chamfer — comfort against the chest, snag + chip resistance. Outer-only
    180     # and modest, so it never reaches the window/button/bosses or the wall interior.
    181     # The lid↔tray PARTING SEAM stays square (flush seat). Chamfer (not fillet):
    182     # prints clean in any orientation and stays watertight in this box-soup builder.
    183     "chamfer": True,
    184     "chamfer_size": 1.0,       # 45° bevel depth on the exposed face edges (mm)
    185     # honeycomb venting — hex perforations in BOTH the back floor (a full field) and the
    186     # front lid (a grille below the window). Built per-cell (each cell = rect minus one
    187     # hex) so a field stays watertight; cells go solid near keepouts (screw bosses, FB
    188     # standoffs, OLED pegs). Same hex spec on both faces (5/1.6). The front's converging
    189     # vent notches need the robust diagonal-split cap triangulator (_triangulate).
    190     "vent_back": True,         # honeycomb hex field in the back floor (spliced as a notch)
    191     "vent_hex_flats": 5.0,     # hex across-flats (mm)
    192     "vent_wall": 1.6,          # min wall between hexes (mm) — sturdy + printable
    193     "vent_margin": 9.0,        # back: keep the field this far inside the perimeter walls
    194     "vent_keepout_r": 6.0,     # leave a cell solid within this of a screw/standoff/peg centre
    195     # front-lid grille (v1.23, now OFF): the hex-strip-below-the-window machinery. vent_front
    196     # is False since v1.45 (the two front portholes replaced it), so front_vent_regions now
    197     # only positions those portholes. B only (A's centred window has no clean bottom band).
    198     "vent_front": False,       # v1.45: front honeycomb grille OFF (replaced by the front_hole_d portholes)
    199     "vent_front_margin": 3.5,  # keep the field this far inside the lid edge (> corner_r, off the arcs)
    200     "vent_front_gap": 1.2,     # clearance from the collar footprint / button / window
    201     # v1.45: two large round holes where the front grille used to be — one centred in each grille
    202     # band flanking the button (reuses front_vent_regions for placement). Each seats a bottom-edge
    203     # vertex below it (_with_base) so it bridges straight down. Replaces the hex grille + the top holes.
    204     "front_holes": True,
    205     "front_hole_d": 12.0,      # porthole diameter
    206     "front_hole_seg": 36,      # circle resolution
    207     "front_hole_dx": 7.0,      # v1.47: shift each porthole this far toward its nearer L/R edge (outward from
    208                                #   the region centre). Left porthole is the limit — at +2 up it's ~2.6mm from the
    209                                #   outer-bottom FB standoff (13.4,5.6) at dx=7 (was 4mm at dx=5).
    210     "front_hole_dy": 2.0,      # v1.47: shift the portholes UP (leaves ~1.7mm to the OLED collar — near the max)
    211     "vent_front_btn_clear_cols": 1,  # v1.39: leave this many innermost hex columns SOLID beside the button
    212                                # (each field: the left field's rightmost col + the right field's leftmost col).
    213                                # Widens the clear band around the button; every other hex stays bit-identical.
    214     # side-wall honeycomb vents (v1.38) — hex perforations through the −x/+x perimeter walls
    215     # (B only), matching the floor + lid fields. FLAT-TOP hexes (circle_loop's default) so the
    216     # vertical wall prints SUPPORT-FREE: the hole top is a short ~3mm bridge and the side faces
    217     # sit 30° off vertical. Inset with a solid border (doesn't reach the edges, like the lid
    218     # grille) + clears the corner bosses. −y wall (USB slot + floor keyhole) and +y wall (ear)
    219     # stay solid. Same flats/wall as the other fields (vent_hex_flats / vent_wall).
    220     "vent_walls": True,        # hex vents through the two side (−x/+x) walls (B)
    221     "vent_wall_margin": 2.5,   # solid border between the field and the wall edges / corner bosses
    222 
    223     # tray walls + M3 corner screw bosses — the back piece is now a TRAY (floor +
    224     # perimeter walls), the front stays the flat LID. Board-independent geometry,
    225     # so it's safe to print for reference before the boards are measured.
    226     "walls": True,             # False → fall back to the v0 flat back slab
    227     "wall_t": 2.0,             # perimeter wall thickness
    228     # M3 corner lid screws — ONE boss design serves both fastening methods: the
    229     # post is sized for a heat-set insert (the larger bore), and "fastener" picks
    230     # the hole — self-tap for the prototype, insert for production. Same posts and
    231     # positions either way, so the self-tap prototype validates the layout for both.
    232     "fastener": "selftap",     # "selftap" (prototype, threads into plastic) | "insert" (production heat-set)
    233     "boss_r": 3.5,             # post radius — sized for the insert (~1.5mm wall around a 4mm bore)
    234     "boss_hole_selftap": 1.25, # M3 self-tap pilot, 2.5mm dia
    235     "boss_hole_insert": 2.0,   # M3 heat-set insert bore, 4.0mm dia — confirm to YOUR insert's datasheet
    236     "boss_seg": 24,            # boss cylinder resolution
    237     "boss_clear_r": 1.7,       # screw-shank clearance hole (M3 ~3.4mm) in the tray floor (back insertion)
    238 
    239     # lanyard ears (two, top corners) — now with a real through-hole
    240     "ear_front": False,        # v1.42: top loop REMOVED from the front lid (relocating the attachment)
    241     "ear_back": True,          # still on the back tray for now (next steps move it)
    242     "ear_w": 9.0,              # v1.48: 10→9 — trim the excess front-on width; with ear_h 9 the tab is square
    243     "ear_h": 9.0,              # v1.48: 7→9 — taller tab to hold the 4mm bore (1mm lip + 4 bore + 1 wall + 3 cap)
    244     "ear_hole_r": 2.0,         # 4mm hole, ~1.5mm walls inside a 7mm ear
    245     "ear_hole_seg": 16,
    246     "ear_full_depth": True,    # v1.43: solid block (vs a flat 2mm tab), extruded z0→z1. v1.48: z1 is the tray
    247                                #   WALL TOP (plate_t + cavity), so the tab ends flush with the walls — it no
    248                                #   longer overhangs the extra plate_t out to the front-face plane
    249     "ear_corner_r": 3.0,       # v1.43: round the tab's top corners (mirrors the badge's corner_r)
    250     "ear_bore": True,          # v1.44: side-to-side (X) rounded-rect bore through the tab — the lanyard interface
    251     "ear_bore_y": 4.0,         # v1.48: 2→4 — taller bore opening (more room for cord/clip)
    252     "ear_bore_z": 6.0,         # bore depth (Z) — centred at the tab's mid-depth (≈ the depth-wise CoG) so the
    253                                #   badge hangs face-vertical: a worn badge tips in pitch until its CoG sits
    254                                #   under the cord, so putting the cord over the CoG ≈ zero tip
    255     "ear_bore_y0": 1.0,        # bore bottom this far above the badge top edge (thin non-load lip below)
    256     "ear_bore_r": 0.8,         # bore corner radius (rounded rectangle)
    257 
    258     # build-number imprint — raised 7-seg digits of the case version on the inner
    259     # face of each part (readable on a loose printed part; hidden once assembled).
    260     "imprint": True,
    261     "imprint_h": 0.8,          # raised height
    262     "imprint_digit_w": 4.0,    # digit cell width
    263     "imprint_digit_h": 6.0,    # digit cell height (v1.7: bigger so it prints legibly)
    264     "imprint_seg": 1.0,        # segment thickness
    265 
    266     # maker's mark (v1.50) — an ASCII house raised in the (solid) battery pocket of the
    267     # BACK tray: a signature found on teardown, hidden under the cell once assembled. The
    268     # build-number, which used to share the pocket, moves up above the FireBeetle. v1.51:
    269     # the site-tag URL LEAVES the cramped tray bottom strip (0.5mm px wouldn't print) and
    270     # takes the LID's inner-face top band instead — 76mm of clear run there fits it at
    271     # near-version size, still hidden on teardown (placed in make_front). Both at imprint_h.
    272     "maker_mark": True,        # the house in the battery pocket
    273     "maker_px": 1.5,           # house pixel size (mm) — grid is 12x10 → ~18 x 15mm
    274     "maker_tag": "virtualshack.io",  # site-tag URL, now on the lid's top band ("" = off → lid keeps the 7-seg version stamp)
    275     "maker_tag_px": 0.8,       # lid tag pixel size (mm) → 5.6mm glyphs; ~71mm run centred in the 76mm clear band
    276 
    277     # front-face text (v1.26) — raised 5x7 pixel text on the LID's outer face, in the solid
    278     # bands the honeycomb grille leaves clear: TOP runs horizontal above the window; the SIDES
    279     # run vertical alongside it (left reads up, right reads down). Set the three strings to your
    280     # text (A-Z 0-9 space - . / ! : + #); "" = that zone is blank. B-tuned; A renders too.
    281     "front_text": True,
    282     "front_text_top": "OnlyN00bs",    # v1.46: restored — the top band is clear again (top holes gone in v1.45)
    283                                       #   (was blanked in v1.40 when the top hole row lived here)
    284     "front_text_left": "DC34",        # vertical up the left side
    285     "front_text_right": "2026",       # vertical down the right side
    286     "front_text_px": 1.2,      # pixel size (mm): a glyph is 5px wide x 7px tall (~6 x 8.4mm)
    287     "front_text_h": 0.6,       # colour-inlay depth INTO the face (mm) — the 2nd-filament layer thickness
    288     "front_text_col_gap": 1.0, # blank columns between glyphs (in px units)
    289 
    290     # top-band hole row (v1.40) — a centred row of round through-holes across the top band of the B
    291     # lid (above the OLED window), replacing the old top text. Each hole is auto-skipped if it can't
    292     # keep clear of the two top corner M3 bosses (so it's safe if n/pitch grow). The lid's version
    293     # imprint is dropped when this is on (the band is now holes); the TRAY keeps its imprint.
    294     "top_holes": False,        # v1.45: top Ø12 row removed (restores the full OLED collar)
    295     "top_hole_d": 12.0,        # hole diameter (v1.41: 5→12 — too big for the band above the collar, so the
    296                                #   collar's top bar is notched around each hole; ~2mm of OLED PCB edge shows
    297                                #   in the bottom of each hole, and the bar survives between/beside the holes)
    298     "top_hole_pitch": 30.0,    # centre-to-centre spacing along x
    299     "top_hole_n": 2,           # number of holes (centred about the lid midline)
    300     "top_hole_y": 75.0,        # centre y — balanced ~2mm walls to the top edge and the window
    301     "top_hole_seg": 36,        # circle resolution
    302 
    303     # material — the print material of record (also drives the weight estimate)
    304     "material": "PETG",        # PETG for the build (heat/impact-tolerant for Vegas); PLA is fine for fit tests
    305     "infill": 0.30,            # rough effective fill incl. perimeters/top-bottom
    306 }
    307 
    308 # filament density (g/cc) for the weight estimate — PLA ~1.24, PETG ~1.27.
    309 _DENSITY_G_CC = {"PLA": 1.24, "PETG": 1.27}
    310 
    311 # ----------------------------------------------------------------------------
    312 # tiny mesh kit — triangles as ((x,y,z),(x,y,z),(x,y,z))
    313 # ----------------------------------------------------------------------------
    314 
    315 def box(x0, y0, z0, x1, y1, z1):
    316     """Axis-aligned box -> 12 triangles, outward normals."""
    317     # 8 corners
    318     p = [
    319         (x0, y0, z0), (x1, y0, z0), (x1, y1, z0), (x0, y1, z0),  # bottom 0-3
    320         (x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1),  # top    4-7
    321     ]
    322     # faces as quads (ccw seen from outside)
    323     quads = [
    324         (0, 3, 2, 1),  # bottom (-z)
    325         (4, 5, 6, 7),  # top    (+z)
    326         (0, 1, 5, 4),  # front  (-y)
    327         (2, 3, 7, 6),  # back   (+y)
    328         (1, 2, 6, 5),  # right  (+x)
    329         (3, 0, 4, 7),  # left   (-x)
    330     ]
    331     tris = []
    332     for a, b, c, d in quads:
    333         tris.append((p[a], p[b], p[c]))
    334         tris.append((p[a], p[c], p[d]))
    335     return tris
    336 
    337 
    338 def prism(poly, z0, z1):
    339     """Extrude a CCW 2D polygon [(x,y),...] from z0 to z1 -> closed mesh.
    340     Caps via fan triangulation (poly must be convex for correct caps)."""
    341     tris = []
    342     n = len(poly)
    343     # walls
    344     for i in range(n):
    345         x0, y0 = poly[i]
    346         x1, y1 = poly[(i + 1) % n]
    347         a = (x0, y0, z0); b = (x1, y1, z0)
    348         c = (x1, y1, z1); d = (x0, y0, z1)
    349         tris.append((a, b, c))
    350         tris.append((a, c, d))
    351     # bottom cap (-z, reversed for outward normal) + top cap (+z)
    352     for i in range(1, n - 1):
    353         a = (poly[0][0], poly[0][1], z0)
    354         b = (poly[i][0], poly[i][1], z0)
    355         c = (poly[i + 1][0], poly[i + 1][1], z0)
    356         tris.append((a, c, b))                       # bottom faces -z
    357         ta = (poly[0][0], poly[0][1], z1)
    358         tb = (poly[i][0], poly[i][1], z1)
    359         tc = (poly[i + 1][0], poly[i + 1][1], z1)
    360         tris.append((ta, tb, tc))                     # top faces +z
    361     return tris
    362 
    363 
    364 def rounded_rect(x0, y0, x1, y1, r, seg):
    365     """CCW polygon of an axis-aligned rect with rounded corners."""
    366     if r <= 0:
    367         return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
    368     pts = []
    369     # corner centers + sweep ranges (ccw starting bottom-right)
    370     corners = [
    371         (x1 - r, y0 + r, -90, 0),    # bottom-right
    372         (x1 - r, y1 - r, 0, 90),     # top-right
    373         (x0 + r, y1 - r, 90, 180),   # top-left
    374         (x0 + r, y0 + r, 180, 270),  # bottom-left
    375     ]
    376     for cx, cy, a0, a1 in corners:
    377         for s in range(seg + 1):
    378             ang = math.radians(a0 + (a1 - a0) * s / seg)
    379             pts.append((cx + r * math.cos(ang), cy + r * math.sin(ang)))
    380     return pts
    381 
    382 
    383 def disc(cx, cy, r, z0, z1, seg):
    384     """A solid cylinder (used as a negative — but we build positive then skip)."""
    385     poly = [(cx + r * math.cos(2 * math.pi * i / seg),
    386              cy + r * math.sin(2 * math.pi * i / seg)) for i in range(seg)]
    387     return prism(poly, z0, z1)
    388 
    389 
    390 def ensure_ccw(poly):
    391     """Return poly wound counter-clockwise (positive signed area)."""
    392     return poly if signed_area(poly) >= 0 else poly[::-1]
    393 
    394 
    395 def circle_loop(cx, cy, r, k):
    396     """CCW loop of k points on a circle (a hole boundary)."""
    397     return [(cx + r * math.cos(2 * math.pi * i / k),
    398              cy + r * math.sin(2 * math.pi * i / k)) for i in range(k)]
    399 
    400 
    401 def perimeter_sample(poly, k):
    402     """Resample a closed polygon's perimeter into exactly k points, spaced evenly
    403     by arc length, preserving orientation. Lets a hole loop carry the SAME point
    404     count as its outer loop so the two can be stitched index-to-index."""
    405     n = len(poly)
    406     seglen, total = [], 0.0
    407     for i in range(n):
    408         x0, y0 = poly[i]; x1, y1 = poly[(i + 1) % n]
    409         d = math.hypot(x1 - x0, y1 - y0); seglen.append(d); total += d
    410     step = total / k
    411     pts, i, acc = [], 0, 0.0
    412     for j in range(k):
    413         target = j * step
    414         while i < n and acc + seglen[i] < target - 1e-9:
    415             acc += seglen[i]; i += 1
    416         if i >= n:
    417             i = n - 1
    418         x0, y0 = poly[i]; x1, y1 = poly[(i + 1) % n]
    419         t = 0.0 if seglen[i] == 0 else (target - acc) / seglen[i]
    420         pts.append((x0 + (x1 - x0) * t, y0 + (y1 - y0) * t))
    421     return pts
    422 
    423 
    424 def _orient(a, b, c, target):
    425     """Return the triangle wound so its normal points along `target` (dot ≥ 0)."""
    426     nx, ny, nz = tri_normal((a, b, c))
    427     return (a, b, c) if nx*target[0] + ny*target[1] + nz*target[2] >= 0 else (a, c, b)
    428 
    429 
    430 def annulus_slab(outer, inner, z0, z1):
    431     """Watertight slab of the region between an outer loop and an inner (hole)
    432     loop — both CCW with the SAME point count. Caps tile the ring; outer walls
    433     face outward, inner walls face into the hole. Normals are oriented by
    434     construction, so the result is a closed 2-manifold (use perimeter_sample /
    435     circle_loop to match counts). This is the one primitive behind the display
    436     window, the ear holes, and any future screw holes."""
    437     assert len(outer) == len(inner), "outer/inner loops must match point count"
    438     k = len(outer)
    439     ocx = sum(p[0] for p in outer) / k; ocy = sum(p[1] for p in outer) / k
    440     icx = sum(p[0] for p in inner) / k; icy = sum(p[1] for p in inner) / k
    441     tris = []
    442     for i in range(k):
    443         j = (i + 1) % k
    444         oA, oB = outer[i], outer[j]
    445         iA, iB = inner[i], inner[j]
    446         # caps: ring quad [oA, oB, iB, iA] at each z (−z bottom, +z top)
    447         for zz, up in ((z0, (0, 0, -1)), (z1, (0, 0, 1))):
    448             A = (oA[0], oA[1], zz); B = (oB[0], oB[1], zz)
    449             C = (iB[0], iB[1], zz); D = (iA[0], iA[1], zz)
    450             tris.append(_orient(A, B, C, up))
    451             tris.append(_orient(A, C, D, up))
    452         # outer wall — normal points away from the outer centroid
    453         tgt = (oA[0] - ocx, oA[1] - ocy, 0)
    454         A = (oA[0], oA[1], z0); B = (oB[0], oB[1], z0)
    455         C = (oB[0], oB[1], z1); D = (oA[0], oA[1], z1)
    456         tris.append(_orient(A, B, C, tgt)); tris.append(_orient(A, C, D, tgt))
    457         # inner (hole) wall — normal points toward the hole centroid
    458         tgt = (icx - iA[0], icy - iA[1], 0)
    459         A = (iA[0], iA[1], z0); B = (iB[0], iB[1], z0)
    460         C = (iB[0], iB[1], z1); D = (iA[0], iA[1], z1)
    461         tris.append(_orient(A, B, C, tgt)); tris.append(_orient(A, C, D, tgt))
    462     return tris
    463 
    464 
    465 def edge_manifold_issues(tris):
    466     """Count undirected edges NOT shared by exactly two triangles. 0 → the mesh
    467     is a closed 2-manifold (watertight). Stronger than the tri-count==bytes check."""
    468     cnt = {}
    469     def key(p, q):
    470         a = (round(p[0], 4), round(p[1], 4), round(p[2], 4))
    471         b = (round(q[0], 4), round(q[1], 4), round(q[2], 4))
    472         return (a, b) if a <= b else (b, a)
    473     for a, b, c in tris:
    474         for e in (key(a, b), key(b, c), key(c, a)):
    475             cnt[e] = cnt.get(e, 0) + 1
    476     return sum(1 for v in cnt.values() if v != 2)
    477 
    478 
    479 def signed_area(poly):
    480     a = 0.0
    481     n = len(poly)
    482     for i in range(n):
    483         x0, y0 = poly[i]; x1, y1 = poly[(i + 1) % n]
    484         a += x0 * y1 - x1 * y0
    485     return a / 2.0
    486 
    487 
    488 def tri_normal(t):
    489     (ax, ay, az), (bx, by, bz), (cx, cy, cz) = t
    490     ux, uy, uz = bx - ax, by - ay, bz - az
    491     vx, vy, vz = cx - ax, cy - ay, cz - az
    492     nx, ny, nz = uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx
    493     L = math.sqrt(nx*nx + ny*ny + nz*nz) or 1.0
    494     return nx/L, ny/L, nz/L
    495 
    496 
    497 def mesh_volume_cc(tris):
    498     """Signed volume via divergence (sum of tetra), mm^3 -> cc."""
    499     vol = 0.0
    500     for (a, b, c) in tris:
    501         vol += (a[0]*(b[1]*c[2]-b[2]*c[1])
    502                 - a[1]*(b[0]*c[2]-b[2]*c[0])
    503                 + a[2]*(b[0]*c[1]-b[1]*c[0])) / 6.0
    504     return abs(vol) / 1000.0
    505 
    506 
    507 def git_rev():
    508     """Git short rev of the tree generating these STLs, for the console report
    509     (mirrors the firmware's GIT_REV). A trailing '+' marks uncommitted tracked
    510     changes under hardware/cad/; 'nogit' if not in a repo. Deliberately NOT baked
    511     into the STL header — that would re-stamp every file on each regen."""
    512     here = os.path.dirname(os.path.abspath(__file__))
    513 
    514     def _git(args, default=""):
    515         try:
    516             return subprocess.check_output(["git"] + args, cwd=here,
    517                                            stderr=subprocess.DEVNULL).decode().strip()
    518         except Exception:
    519             return default
    520 
    521     rev = _git(["rev-parse", "--short", "HEAD"], "nogit")
    522     if rev != "nogit":
    523         dirty = subprocess.call(["git", "diff", "--quiet", "HEAD", "--", "."],
    524                                 cwd=here, stderr=subprocess.DEVNULL) != 0
    525         if dirty:
    526             rev += "+"
    527     return rev
    528 
    529 
    530 def write_stl(path, tris, header=""):
    531     # binary-STL header is a free 80-byte comment field — stamp it (version label)
    532     head = header.encode("ascii", "replace")[:80]
    533     head = head + b"\0" * (80 - len(head))
    534     with open(path, "wb") as f:
    535         f.write(head)
    536         f.write(struct.pack("<I", len(tris)))
    537         for t in tris:
    538             nx, ny, nz = tri_normal(t)
    539             f.write(struct.pack("<3f", nx, ny, nz))
    540             for v in t:
    541                 f.write(struct.pack("<3f", *v))
    542             f.write(struct.pack("<H", 0))
    543 
    544 
    545 def bbox(tris):
    546     xs = [v[0] for t in tris for v in t]
    547     ys = [v[1] for t in tris for v in t]
    548     zs = [v[2] for t in tris for v in t]
    549     return (max(xs)-min(xs), max(ys)-min(ys), max(zs)-min(zs))
    550 
    551 # ----------------------------------------------------------------------------
    552 # tray + boss kit — open surface pieces that compose into ONE closed solid.
    553 # (annulus_slab handles single-hole slabs; a tray is a floor + walls, so it
    554 # needs the floor/wall/rim pieces below to stay watertight without an interior
    555 # cap.) Normals are forced per-triangle via _orient, so winding can't go wrong.
    556 # ----------------------------------------------------------------------------
    557 
    558 def cavity_depth(layout):
    559     """Internal clearance between the two plate inner faces (front lid ↔ back
    560     floor). OLED zone + the behind-stack; the tray wall height equals this."""
    561     oled_zone = 6.0
    562     behind = (6.0 + P["bat_t"]) if layout == "A" else max(6.0, P["bat_t"])
    563     return oled_zone + behind + P["cavity_pad"]   # +pad. A: 6+16+pad,  B: 6+10+pad
    564 
    565 
    566 def _fan_cap(poly, z, updir):
    567     """Flat cap of a CONVEX polygon at height z, every tri forced to face updir."""
    568     tris = []
    569     a = (poly[0][0], poly[0][1], z)
    570     for i in range(1, len(poly) - 1):
    571         b = (poly[i][0], poly[i][1], z)
    572         c = (poly[i + 1][0], poly[i + 1][1], z)
    573         tris.append(_orient(a, b, c, updir))
    574     return tris
    575 
    576 
    577 def _ring_cap(outer, inner, z, updir):
    578     """Flat ring between matched outer/inner loops (same point count) at height z."""
    579     k = len(outer)
    580     tris = []
    581     for i in range(k):
    582         j = (i + 1) % k
    583         A = (outer[i][0], outer[i][1], z); B = (outer[j][0], outer[j][1], z)
    584         C = (inner[j][0], inner[j][1], z); D = (inner[i][0], inner[i][1], z)
    585         tris.append(_orient(A, B, C, updir)); tris.append(_orient(A, C, D, updir))
    586     return tris
    587 
    588 
    589 def _side_wall(loop, z0, z1, outward, skip=None):
    590     """Vertical wall around a closed loop, z0→z1. Normals face away from the loop
    591     centroid (outward=True) or toward it (outward=False). `skip(a, b)` → True drops
    592     the edge between loop points a and b (v1.20: the +x straight run is skipped so a
    593     windowed face — `_vwall_with_window` — can replace it with the USB-C cutout)."""
    594     k = len(loop)
    595     cx = sum(p[0] for p in loop) / k; cy = sum(p[1] for p in loop) / k
    596     tris = []
    597     for i in range(k):
    598         j = (i + 1) % k
    599         if skip and skip(loop[i], loop[j]):
    600             continue
    601         A = (loop[i][0], loop[i][1], z0); B = (loop[j][0], loop[j][1], z0)
    602         C = (loop[j][0], loop[j][1], z1); D = (loop[i][0], loop[i][1], z1)
    603         mx = (loop[i][0] + loop[j][0]) / 2.0; my = (loop[i][1] + loop[j][1]) / 2.0
    604         tgt = (mx - cx, my - cy, 0) if outward else (cx - mx, cy - my, 0)
    605         tris.append(_orient(A, B, C, tgt)); tris.append(_orient(A, C, D, tgt))
    606     return tris
    607 
    608 
    609 def boss_positions(W, H):
    610     """Four screw bosses, one per corner, inset just inside the perimeter walls."""
    611     d = P["wall_t"] + P["boss_r"]
    612     return [(d, d), (W - d, d), (d, H - d), (W - d, H - d)]
    613 
    614 
    615 def screw_bosses(W, H, z0, z1):
    616     """M3 corner bosses: closed tubes (post + bore) standing on the cavity floor.
    617     The bore bottoms out on the floor below (blind hole from the top), so a screw
    618     through the lid threads straight in. "fastener" picks the bore — a narrow
    619     self-tap pilot (prototype) or a wider heat-set-insert bore (production); the
    620     post is the same size either way. Each is its own watertight solid that
    621     overlaps the floor — same compose-by-overlap trick as the lanyard ears."""
    622     bs, seg = P["boss_r"], P["boss_seg"]
    623     hr = P["boss_hole_insert"] if P["fastener"] == "insert" else P["boss_hole_selftap"]
    624     tris = []
    625     for cx, cy in boss_positions(W, H):
    626         outer = ensure_ccw(circle_loop(cx, cy, bs, seg))
    627         inner = ensure_ccw(circle_loop(cx, cy, hr, seg))
    628         tris += annulus_slab(outer, inner, z0, z1)
    629     return tris
    630 
    631 
    632 # ----------------------------------------------------------------------------
    633 # polygon-with-holes triangulation — ear clipping + hole bridging. Lets a flat
    634 # face carry MULTIPLE true through-holes (screw clearance, USB, vents) where the
    635 # single-hole annulus can't. Holes are CW loops inside a CCW outer loop.
    636 # ----------------------------------------------------------------------------
    637 _EPS = 1e-9
    638 
    639 def _tri_area2(a, b, c):
    640     return (b[0]-a[0])*(c[1]-a[1]) - (c[0]-a[0])*(b[1]-a[1])
    641 
    642 
    643 def _strictly_inside(p, a, b, c):
    644     d1=_tri_area2(p,a,b); d2=_tri_area2(p,b,c); d3=_tri_area2(p,c,a)
    645     if abs(d1)<_EPS or abs(d2)<_EPS or abs(d3)<_EPS:
    646         return False                      # on an edge / coincident → doesn't block an ear
    647     return (d1>0)==(d2>0)==(d3>0)
    648 
    649 
    650 def _earclip(poly):
    651     """Triangulate a simple CCW polygon to coord-triples (ear clipping, O(n²))."""
    652     pts=list(poly); idx=list(range(len(pts))); tris=[]; guard=0
    653     while len(idx)>3 and guard<50000:
    654         guard+=1; made=False
    655         for ii in range(len(idx)):
    656             i0,i1,i2=idx[(ii-1)%len(idx)],idx[ii],idx[(ii+1)%len(idx)]
    657             a,b,c=pts[i0],pts[i1],pts[i2]
    658             if _tri_area2(a,b,c)<=_EPS:                          # reflex / degenerate
    659                 continue
    660             if any(_strictly_inside(pts[j],a,b,c) for j in idx if j not in (i0,i1,i2)):
    661                 continue
    662             tris.append((a,b,c)); idx.pop(ii); made=True; break
    663         if not made:
    664             break
    665     if len(idx)==3:
    666         tris.append((pts[idx[0]],pts[idx[1]],pts[idx[2]]))
    667     return tris
    668 
    669 
    670 def _on_seg(p, a, b):
    671     """p on the CLOSED segment a-b (collinear within tol AND inside the bbox)."""
    672     if abs(_tri_area2(a, b, p)) > 1e-7:
    673         return False
    674     return (min(a[0], b[0]) - 1e-7 <= p[0] <= max(a[0], b[0]) + 1e-7 and
    675             min(a[1], b[1]) - 1e-7 <= p[1] <= max(a[1], b[1]) + 1e-7)
    676 
    677 
    678 def _proper_cross(a, b, c, d):
    679     """True iff segments a-b and c-d cross at a point interior to BOTH (strict)."""
    680     d1 = _tri_area2(c, d, a); d2 = _tri_area2(c, d, b)
    681     d3 = _tri_area2(a, b, c); d4 = _tri_area2(a, b, d)
    682     return (((d1 > _EPS and d2 < -_EPS) or (d1 < -_EPS and d2 > _EPS)) and
    683             ((d3 > _EPS and d4 < -_EPS) or (d3 < -_EPS and d4 > _EPS)))
    684 
    685 
    686 def _pt_in_poly(p, poly):
    687     """Even-odd ray cast: True iff p is strictly inside the polygon."""
    688     ins = False; n = len(poly)
    689     for i in range(n):
    690         a, b = poly[i], poly[(i + 1) % n]
    691         if (a[1] > p[1]) != (b[1] > p[1]):
    692             xi = a[0] + (p[1] - a[1]) / (b[1] - a[1]) * (b[0] - a[0])
    693             if p[0] < xi:
    694                 ins = not ins
    695     return ins
    696 
    697 
    698 def _valid_diagonal(poly, i, j):
    699     """True iff poly[i]-poly[j] is a real internal diagonal: it crosses no edge, no other
    700     vertex lies on it, and its midpoint is inside the polygon. This is what makes the
    701     triangulator robust to the keyhole slits — a diagonal that would span a zero-width
    702     slit fails the on-segment / midpoint test, where a plain convex-ear test does not."""
    703     n = len(poly); a, b = poly[i], poly[j]
    704     for k in range(n):
    705         if k in (i, j) or (k + 1) % n in (i, j):
    706             continue                                  # edge incident to a diagonal endpoint
    707         u, v = poly[k], poly[(k + 1) % n]
    708         if _proper_cross(a, b, u, v):
    709             return False
    710         if _on_seg(u, a, b):                          # a non-incident vertex sits on the diagonal
    711             return False
    712     return _pt_in_poly(((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0), poly)
    713 
    714 
    715 def _dedup(poly):
    716     """Drop consecutive (and wrap-around) duplicate vertices — the zero-length edges the
    717     keyhole bridges/slits leave behind, which otherwise stall the triangulator."""
    718     out = []
    719     for p in poly:
    720         if not out or abs(p[0] - out[-1][0]) > 1e-7 or abs(p[1] - out[-1][1]) > 1e-7:
    721             out.append(p)
    722     while len(out) > 1 and abs(out[0][0] - out[-1][0]) <= 1e-7 and abs(out[0][1] - out[-1][1]) <= 1e-7:
    723         out.pop()
    724     return out
    725 
    726 
    727 def _triangulate(poly):
    728     """Triangulate a (weakly-)simple CCW polygon to coord-triples, Steiner-free (uses only
    729     input vertices, so the cap keeps matching the walls/cells that share its loops). Ear-clips
    730     where it can; when it STALLS — which converging v1.22 keyhole slits cause in one cap (the
    731     front grille: vent notches flanking the button, plus the window) — it splits on a valid
    732     internal diagonal and recurses. Both halves share that diagonal, so the mesh stays
    733     watertight. Supersedes plain _earclip, which can't finish such a cap. CAVEAT: the diagonal-
    734     split path still mis-meshes some thin/tall single-/two-column notches — cap_with_holes
    735     guards against shipping those via an area check. Replace with a vetted CDT to lift that."""
    736     poly = _dedup(poly)
    737     if len(poly) < 3:
    738         return []
    739     tris = []
    740     work = poly
    741     while len(work) > 3:
    742         m = len(work); clipped = False
    743         for ii in range(m):
    744             i0, i1, i2 = (ii - 1) % m, ii, (ii + 1) % m
    745             a, b, c = work[i0], work[i1], work[i2]
    746             if _tri_area2(a, b, c) <= _EPS:                      # reflex / collinear
    747                 continue
    748             if any(k not in (i0, i1, i2) and _strictly_inside(work[k], a, b, c) for k in range(m)):
    749                 continue
    750             if not _valid_diagonal(work, i0, i2):                # blocks slit-spanning ears
    751                 continue
    752             tris.append((a, b, c)); work = work[:ii] + work[ii + 1:]; clipped = True; break
    753         if clipped:
    754             continue
    755         # stalled: split on a valid diagonal and recurse — the two halves share the cut edge
    756         split = None
    757         for ai in range(m):
    758             for bi in range(ai + 2, m):
    759                 if ai == 0 and bi == m - 1:
    760                     continue
    761                 if _valid_diagonal(work, ai, bi):
    762                     split = (ai, bi); break
    763             if split:
    764                 break
    765         if split is None:
    766             break                                                # unreachable for a valid polygon
    767         ai, bi = split
    768         return tris + _triangulate(work[ai:bi + 1]) + _triangulate(work[bi:] + work[:ai + 1])
    769     if len(work) == 3:
    770         tris.append((work[0], work[1], work[2]))
    771     return tris
    772 
    773 
    774 def _hole_loop(cx, cy, r, seg):
    775     """CW circle loop — a hole boundary for cap_with_holes."""
    776     return list(reversed(circle_loop(cx, cy, r, seg)))
    777 
    778 
    779 def _bridge_holes(outer, holes):
    780     """Merge CW holes into a CCW outer loop via short corner-facing slits → one
    781     simple CCW polygon. Each hole bridges outward to its nearest outer vertex
    782     (using the hole vertex facing it), so the slit can't cut back across the hole."""
    783     assign={}
    784     for hole in holes:
    785         hx=sum(p[0] for p in hole)/len(hole); hy=sum(p[1] for p in hole)/len(hole)
    786         oi=min(range(len(outer)), key=lambda k:(outer[k][0]-hx)**2+(outer[k][1]-hy)**2)
    787         ov=outer[oi]
    788         hj=min(range(len(hole)), key=lambda k:(hole[k][0]-ov[0])**2+(hole[k][1]-ov[1])**2)
    789         assign.setdefault(oi,[]).append(hole[hj:]+hole[:hj])
    790     merged=[]
    791     for i,ov in enumerate(outer):
    792         merged.append(ov)
    793         for rot in assign.get(i,[]):
    794             merged += rot + [rot[0], ov]
    795     return merged
    796 
    797 
    798 def cap_with_holes(outer, holes, z, updir):
    799     """Flat cap of a CCW outer loop with CW holes at height z, facing updir.
    800     Falls back to a plain fan when there are no holes, so it's a drop-in cap."""
    801     if not holes:
    802         return _fan_cap(outer, z, updir)
    803     poly = _dedup(_bridge_holes(outer, holes))
    804     tris2d = _earclip(poly)
    805     # earclip can STALL (too few tris) or, worse, run to full count yet place a triangle
    806     # across a keyhole slit (covering a vent cell) — caught by comparing the triangulated
    807     # area to the polygon's own area. Either failure → the robust diagonal-split path.
    808     want = abs(signed_area(poly))
    809     got = sum(abs(_tri_area2(a, b, c)) for a, b, c in tris2d) / 2.0
    810     if len(tris2d) < len(poly) - 2 or abs(got - want) > 1e-3 * max(1.0, want):
    811         tris2d = _triangulate(poly)
    812     return [_orient((a[0],a[1],z),(b[0],b[1],z),(c[0],c[1],z),updir) for a,b,c in tris2d]
    813 
    814 
    815 def honeycomb_region(x0, y0, x1, y1, z0, z1, flats, wall, keep=None):
    816     """Honeycomb-vented slab section filling [x0,x1]x[y0,y1] from z0 to z1. Tiled into
    817     a grid of cells, each capped top+bottom as 'cell rect minus one hex' — a SINGLE
    818     hole per cell, so hole-bridging is always valid (cap_with_holes is robust for one
    819     hole, NOT for a whole field of them). Adjacent cells share corner-matched edges, so
    820     the field is watertight by construction. keep(cx,cy)->True leaves a cell solid (no
    821     hex) for keepouts. Returns (tris, meta) where meta=(x0,y0,x1,y1,nx,ny,cw,ch) — the
    822     grid metrics the host slab uses to splice this region in as a notch (splice_vent_notch)."""
    823     pitch = flats + wall
    824     nx = max(1, int(round((x1 - x0) / pitch)))
    825     ny = max(1, int(round((y1 - y0) / pitch)))
    826     cw = (x1 - x0) / nx
    827     ch = (y1 - y0) / ny
    828     r = (min(cw, ch) - wall) / math.sqrt(3.0)         # circumradius → across-flats = min(cw,ch)-wall
    829     tris = []
    830     for j in range(ny):
    831         for i in range(nx):
    832             ax, ay = x0 + i * cw, y0 + j * ch
    833             bx, by = ax + cw, ay + ch
    834             rect = [(ax, ay), (bx, ay), (bx, by), (ax, by)]      # CCW cell
    835             cx, cy = (ax + bx) / 2.0, (ay + by) / 2.0
    836             holes = [] if (keep and keep(cx, cy)) else [_hole_loop(cx, cy, r, 6)]
    837             tris += cap_with_holes(rect, holes, z1, (0, 0, 1))   # top face (up)
    838             tris += cap_with_holes(rect, holes, z0, (0, 0, -1))  # bottom face (down)
    839             for h in holes:
    840                 tris += _side_wall(h, z0, z1, False)             # hex wall, faces into the hole
    841     return tris, (x0, y0, x1, y1, nx, ny, cw, ch)                # grid metrics for the notch splice
    842 
    843 
    844 def splice_vent_notch(outline, meta, slit_idx):
    845     """Splice the honeycomb region's rectangular boundary into a CCW floor `outline` as
    846     a NOTCH (concavity) reachable by one zero-width slit rising from the outline's bottom
    847     edge at the vent's slit_idx column — instead of a hole. So the floor cap only has to
    848     bridge the few remaining real holes (the corner screws), which it does fine; the big
    849     central opening that crossed bridge-slits is gone. Vent edges are subdivided to the
    850     cell grid so the cells stitch with no T-junctions."""
    851     x0, y0, x1, y1, nx, ny, cw, ch = meta
    852     si = max(1, min(nx - 1, slit_idx))
    853     slit_x = x0 + si * cw
    854     B = [(x0 + i * cw, y0) for i in range(nx + 1)]       # bottom edge L→R
    855     L = [(x0, y0 + j * ch) for j in range(ny + 1)]       # left edge   B→T
    856     T = [(x0 + i * cw, y1) for i in range(nx + 1)]       # top edge    L→R
    857     R = [(x1, y0 + j * ch) for j in range(ny + 1)]       # right edge  B→T
    858     vloop  = [B[i] for i in range(si, -1, -1)]           # B[si]..B[0]   (=L[0])
    859     vloop += [L[j] for j in range(1, ny + 1)]            # L[1]..L[ny]   (=T[0])
    860     vloop += [T[i] for i in range(1, nx + 1)]            # T[1]..T[nx]   (=R[ny])
    861     vloop += [R[j] for j in range(ny - 1, -1, -1)]       # R[ny-1]..R[0] (=B[nx])
    862     vloop += [B[i] for i in range(nx - 1, si, -1)]       # B[nx-1]..B[si+1]  (CW around the vent)
    863     ymin = min(p[1] for p in outline); eps = 1e-6
    864     base = (slit_x, ymin)
    865     for k, pp in enumerate(outline):                     # base already a shared vertex (preferred)
    866         if abs(pp[0] - slit_x) < eps and abs(pp[1] - ymin) < eps:
    867             return outline[:k + 1] + vloop + [B[si], base] + outline[k + 1:]
    868     for k in range(len(outline)):                        # else splice it onto the bottom segment
    869         p, q = outline[k], outline[(k + 1) % len(outline)]
    870         if abs(p[1] - ymin) < eps and abs(q[1] - ymin) < eps and \
    871            min(p[0], q[0]) - eps < slit_x < max(p[0], q[0]) + eps:
    872             return outline[:k + 1] + [base] + vloop + [B[si], base] + outline[k + 1:]
    873     return outline                                       # no bottom edge found → unchanged
    874 
    875 
    876 def _with_base(outline, slit_x):
    877     """Insert a collinear vertex at (slit_x, ymin) on the outline's bottom edge so the
    878     vent-slit base is SHARED by the floor cap and the skirt / walls / rim that touch the
    879     same edge — without it the cap subdivides the bottom edge and they don't (T-junction)."""
    880     ymin = min(p[1] for p in outline); eps = 1e-6
    881     for k in range(len(outline)):
    882         p, q = outline[k], outline[(k + 1) % len(outline)]
    883         if abs(p[1] - ymin) < eps and abs(q[1] - ymin) < eps and \
    884            min(p[0], q[0]) - eps < slit_x < max(p[0], q[0]) + eps:
    885             return outline[:k + 1] + [(slit_x, ymin)] + outline[k + 1:]
    886     return outline
    887 
    888 
    889 def _with_top(outline, hole_x):
    890     """Insert a collinear vertex at (hole_x, ymax) on the outline's TOP edge — the mirror of
    891     `_with_base`. Gives each top-band through-hole a nearby outer vertex straight above it, so
    892     cap_with_holes bridges it with a short vertical slit instead of a long diagonal to a far
    893     corner (which, when two holes share that corner, crosses itself → non-manifold)."""
    894     ymax = max(p[1] for p in outline); eps = 1e-6
    895     for k in range(len(outline)):
    896         p, q = outline[k], outline[(k + 1) % len(outline)]
    897         if abs(p[1] - ymax) < eps and abs(q[1] - ymax) < eps and \
    898            min(p[0], q[0]) - eps < hole_x < max(p[0], q[0]) + eps:
    899             return outline[:k + 1] + [(hole_x, ymax)] + outline[k + 1:]
    900     return outline
    901 
    902 
    903 # ----------------------------------------------------------------------------
    904 # plate builders
    905 # ----------------------------------------------------------------------------
    906 
    907 def plate_outline(layout):
    908     """Return (W, H) of the outer plate for the given layout, OLED-driven width,
    909     content-driven height."""
    910     em = P["edge_margin"]
    911     W = P["oled_pcb_w"] + 2 * em
    912     if layout == "A":
    913         # face = OLED + margins (battery/board hide behind)
    914         H = P["oled_pcb_h"] + 2 * em
    915     else:
    916         # B coplanar: OLED on top; FireBeetle + battery sit BEHIND it (in z) and only the
    917         # overage pokes below. v1.33 portrait — the FB (left) is BOTTOM-PINNED at the USB
    918         # wall and a vertical battery (right) sits beside it; their ROW width (fb_h + bat_h =
    919         # 25+34 = 59) stays under the OLED's 70 so the OLED keeps driving W (no growth). We
    920         # deliberately keep the v1.32 PLATE HEIGHT rather than growing to the raw 60mm board
    921         # length: bottom-pinned, the 60mm FB tops out ~19mm below the +y wall at H=83 (ample
    922         # antenna margin), AND 83 is the tallest height the validated front-vent grille
    923         # triangulates cleanly (84+ trips a thin-notch bug in the cap triangulator — see _triangulate).
    924         electronics_col = P["fb_h"] + P["bat_h"]      # 25.4 + 34 = 59.4 → H 83 (both boards fit within it)
    925         H = max(P["oled_pcb_h"], electronics_col) + 2 * em
    926     return W, H
    927 
    928 
    929 def ear(x, y, t):
    930     """A lanyard ear with a round through-hole. (x, y) is its lower-left corner;
    931     it sits ear_w × ear_h above the plate's top edge. Watertight via annulus."""
    932     ew, eh = P["ear_w"], P["ear_h"]
    933     hr, hseg = P["ear_hole_r"], P["ear_hole_seg"]
    934     rect = ensure_ccw([(x, y), (x + ew, y), (x + ew, y + eh), (x, y + eh)])
    935     outer = perimeter_sample(rect, hseg)
    936     inner = ensure_ccw(circle_loop(x + ew / 2.0, y + eh / 2.0, hr, hseg))
    937     return annulus_slab(outer, inner, 0, t)
    938 
    939 
    940 def _swap_to_x(tris):
    941     """Re-map every vertex (a,b,c)→(c,a,b): a Z-extruded build whose 2D loops are (worldY, worldZ)
    942     becomes a tunnel running along worldX. The swap is cyclic (det +1), so annulus_slab's
    943     by-construction normals survive (same trick as the v1.38 side-wall vents)."""
    944     return [((a[2], a[0], a[1]), (b[2], b[0], b[1]), (c[2], c[0], c[1])) for a, b, c in tris]
    945 
    946 
    947 def lanyard_tab(x, y, z0, z1):
    948     """Lanyard tab (ear_w × ear_h above the plate top edge) extruded z0→z1 (the FULL case depth, vs
    949     the old flat ear), with rounded TOP corners (ear_corner_r) to mirror the badge. With `ear_bore`,
    950     a side-to-side (X) rounded-rectangular bore — the lanyard interface — is cut through the lower
    951     block, centred at MID-DEPTH (≈ the depth-wise CoG) so the cord sits over the CoG and the badge
    952     hangs face-vertical (no pitch tip). Built as region A (the bore tube — an X-extruded washer via
    953     annulus_slab + coord-swap) ∪ region B (the rounded cap); each a closed solid overlapping the
    954     other (compose-by-overlap, watertight)."""
    955     ew, eh = P["ear_w"], P["ear_h"]
    956     r = min(P["ear_corner_r"], ew / 2.0, eh - 0.5)
    957     seg = P["corner_seg"]
    958     cyr = y + eh - r                                  # top-corner arc-centre height
    959 
    960     def cap_poly(ybot):                              # rounded-top footprint from a straight bottom edge
    961         poly = [(x, ybot), (x + ew, ybot)]
    962         for s in range(seg + 1):                     # top-RIGHT arc 0°→90°
    963             a = math.radians(90.0 * s / seg)
    964             poly.append((x + ew - r + r * math.cos(a), cyr + r * math.sin(a)))
    965         for s in range(seg + 1):                     # top-LEFT arc 90°→180°
    966             a = math.radians(90.0 + 90.0 * s / seg)
    967             poly.append((x + r + r * math.cos(a), cyr + r * math.sin(a)))
    968         return ensure_ccw(poly)
    969 
    970     if not P["ear_bore"]:
    971         return prism(cap_poly(y), z0, z1)            # solid block (no interface yet)
    972 
    973     # region A — lower block y[y, y+eh-r], z[z0,z1], with the side-to-side bore (an X tunnel)
    974     by0 = y + P["ear_bore_y0"]; by1 = by0 + P["ear_bore_y"]
    975     bz0 = z0 + (z1 - z0) / 2.0 - P["ear_bore_z"] / 2.0; bz1 = bz0 + P["ear_bore_z"]   # centred at mid-depth
    976     yA1 = y + eh - r                                  # block top = where the corner rounding starts
    977     N = 64
    978     outer = perimeter_sample(ensure_ccw([(y, z0), (yA1, z0), (yA1, z1), (y, z1)]), N)  # (worldY,worldZ) rect
    979     inner = perimeter_sample(ensure_ccw(rounded_rect(by0, bz0, by1, bz1, P["ear_bore_r"], 8)), N)
    980     A = _swap_to_x(annulus_slab(outer, inner, x, x + ew))                            # tunnel along worldX
    981     # region B — rounded cap above the bore (down to the bore top → overlaps A, no coincident faces)
    982     B = prism(cap_poly(by1), z0, z1)
    983     return A + B
    984 
    985 
    986 # 7-segment glyphs — lit segments per char (a=top b=up-R c=lo-R d=bottom e=lo-L f=up-L g=mid)
    987 _SEG7 = {"0":"abcdef", "1":"bc", "2":"abdeg", "3":"abcdg", "4":"bcfg", "5":"acdfg",
    988          "6":"acdefg", "7":"abc", "8":"abcdefg", "9":"abcdfg",
    989          "A":"abcefg", "B":"cdefg"}   # A; B as 7-seg lowercase 'b' (uppercase B = 8)
    990 
    991 def _seg_boxes(ch, x0, y0, z0, z1):
    992     """Raised boxes for one 7-seg char at cell origin (x0,y0). '.' → a small dot.
    993     Segments are gapped apart so no two boxes share an exact edge (stays manifold,
    994     like the bosses) — they only sit on the face cap below them."""
    995     w, h, s = P["imprint_digit_w"], P["imprint_digit_h"], P["imprint_seg"]
    996     g = 0.3
    997     if ch == ".":
    998         return box(x0, y0, z0, x0 + s, y0 + s, z1)
    999     rects = {
   1000         "a": (x0+s+g, y0+h-s,        x0+w-s-g, y0+h),
   1001         "g": (x0+s+g, y0+(h-s)/2,    x0+w-s-g, y0+(h+s)/2),
   1002         "d": (x0+s+g, y0,            x0+w-s-g, y0+s),
   1003         "f": (x0,     y0+h/2+g,      x0+s,     y0+h-g),
   1004         "b": (x0+w-s, y0+h/2+g,      x0+w,     y0+h-g),
   1005         "e": (x0,     y0+g,          x0+s,     y0+h/2-g),
   1006         "c": (x0+w-s, y0+g,          x0+w,     y0+h/2-g),
   1007     }
   1008     tris = []
   1009     for k in _SEG7.get(ch, ""):
   1010         ax, ay, bx, by = rects[k]
   1011         tris += box(ax, ay, z0, bx, by, z1)
   1012     return tris
   1013 
   1014 def imprint_text(text, x0, y0, z_face):
   1015     """Raised 7-seg `text` starting at (x0, y0) on a face at z_face (raised +z)."""
   1016     if not P["imprint"]:
   1017         return []
   1018     z0, z1 = z_face, z_face + P["imprint_h"]
   1019     adv, dotadv = P["imprint_digit_w"] + 1.0, P["imprint_seg"] + 1.0
   1020     tris, x = [], x0
   1021     for ch in text:
   1022         tris += _seg_boxes(ch, x, y0, z0, z1)
   1023         x += dotadv if ch == "." else adv
   1024     return tris
   1025 
   1026 
   1027 def _imprint_run_len(text):
   1028     """Width (mm) of an imprint_text run — for centring. Sums per-char advances and
   1029     drops the trailing gap after the last glyph (cf. the advances in imprint_text)."""
   1030     adv, dotadv = P["imprint_digit_w"] + 1.0, P["imprint_seg"] + 1.0
   1031     w = sum(dotadv if ch == "." else adv for ch in text)
   1032     return max(0.0, w - 1.0)
   1033 
   1034 
   1035 # ----------------------------------------------------------------------------
   1036 # 5x7 pixel font — for parametric raised text on the front face (top + sides).
   1037 # Each glyph is 7 rows (top→bottom) x 5 cols, '1' = a lit pixel. Lit pixels become
   1038 # raised boxes (box-soup native, watertight by construction like the 7-seg imprint).
   1039 # ----------------------------------------------------------------------------
   1040 _FONT5X7 = {
   1041     " ": ["00000","00000","00000","00000","00000","00000","00000"],
   1042     "A": ["01110","10001","10001","11111","10001","10001","10001"],
   1043     "B": ["11110","10001","10001","11110","10001","10001","11110"],
   1044     "C": ["01110","10001","10000","10000","10000","10001","01110"],
   1045     "D": ["11110","10001","10001","10001","10001","10001","11110"],
   1046     "E": ["11111","10000","10000","11110","10000","10000","11111"],
   1047     "F": ["11111","10000","10000","11110","10000","10000","10000"],
   1048     "G": ["01110","10001","10000","10111","10001","10001","01111"],
   1049     "H": ["10001","10001","10001","11111","10001","10001","10001"],
   1050     "I": ["01110","00100","00100","00100","00100","00100","01110"],
   1051     "J": ["00111","00010","00010","00010","00010","10010","01100"],
   1052     "K": ["10001","10010","10100","11000","10100","10010","10001"],
   1053     "L": ["10000","10000","10000","10000","10000","10000","11111"],
   1054     "M": ["10001","11011","10101","10101","10001","10001","10001"],
   1055     "N": ["10001","10001","11001","10101","10011","10001","10001"],
   1056     "O": ["01110","10001","10001","10001","10001","10001","01110"],
   1057     "P": ["11110","10001","10001","11110","10000","10000","10000"],
   1058     "Q": ["01110","10001","10001","10001","10101","10010","01101"],
   1059     "R": ["11110","10001","10001","11110","10100","10010","10001"],
   1060     "S": ["01111","10000","10000","01110","00001","00001","11110"],
   1061     "T": ["11111","00100","00100","00100","00100","00100","00100"],
   1062     "U": ["10001","10001","10001","10001","10001","10001","01110"],
   1063     "V": ["10001","10001","10001","10001","10001","01010","00100"],
   1064     "W": ["10001","10001","10001","10101","10101","11011","10001"],
   1065     "X": ["10001","10001","01010","00100","01010","10001","10001"],
   1066     "Y": ["10001","10001","01010","00100","00100","00100","00100"],
   1067     "Z": ["11111","00001","00010","00100","01000","10000","11111"],
   1068     "0": ["01110","10001","10011","10101","11001","10001","01110"],
   1069     "1": ["00100","01100","00100","00100","00100","00100","01110"],
   1070     "2": ["01110","10001","00001","00010","00100","01000","11111"],
   1071     "3": ["11111","00010","00100","00010","00001","10001","01110"],
   1072     "4": ["00010","00110","01010","10010","11111","00010","00010"],
   1073     "5": ["11111","10000","11110","00001","00001","10001","01110"],
   1074     "6": ["00110","01000","10000","11110","10001","10001","01110"],
   1075     "7": ["11111","00001","00010","00100","01000","01000","01000"],
   1076     "8": ["01110","10001","10001","01110","10001","10001","01110"],
   1077     "9": ["01110","10001","10001","01111","00001","00010","01100"],
   1078     "-": ["00000","00000","00000","11111","00000","00000","00000"],
   1079     ".": ["00000","00000","00000","00000","00000","01100","01100"],
   1080     "/": ["00001","00010","00010","00100","01000","01000","10000"],
   1081     "!": ["00100","00100","00100","00100","00100","00000","00100"],
   1082     ":": ["00000","01100","01100","00000","01100","01100","00000"],
   1083     "+": ["00000","00100","00100","11111","00100","00100","00000"],
   1084     "#": ["01010","01010","11111","01010","11111","01010","01010"],
   1085     # lowercase — 8 rows: baseline stays at row 6 (shared with the 7-row caps/digits), an
   1086     # extra row 7 carries descenders (g j p q y). x-height letters fill rows 2-6, ascenders
   1087     # (b d f h k l t) go full height. pixel_text maps row 7 to -px, so they hang below.
   1088     "a": ["00000","00000","01110","00001","01111","10001","01111","00000"],
   1089     "b": ["10000","10000","10000","11110","10001","10001","11110","00000"],
   1090     "c": ["00000","00000","01110","10000","10000","10000","01110","00000"],  # open right side (no col-4 closers) so it doesn't read as 'o' when printed
   1091     "d": ["00001","00001","00001","01111","10001","10001","01111","00000"],
   1092     "e": ["00000","00000","01110","10001","11111","10000","01110","00000"],
   1093     "f": ["00110","01000","11110","01000","01000","01000","01000","00000"],
   1094     "g": ["00000","00000","01111","10001","10001","01111","00001","01110"],
   1095     "h": ["10000","10000","10000","11110","10001","10001","10001","00000"],
   1096     "i": ["00100","00000","00100","00100","00100","00100","00100","00000"],
   1097     "j": ["00010","00000","00010","00010","00010","00010","10010","01100"],
   1098     "k": ["10000","10000","10010","10100","11000","10100","10010","00000"],
   1099     "l": ["01100","00100","00100","00100","00100","00100","01110","00000"],
   1100     "m": ["00000","00000","11010","10101","10101","10101","10101","00000"],
   1101     "n": ["00000","00000","10110","11001","10001","10001","10001","00000"],
   1102     "o": ["00000","00000","01110","10001","10001","10001","01110","00000"],
   1103     "p": ["00000","00000","11110","10001","10001","11110","10000","10000"],
   1104     "q": ["00000","00000","01111","10001","10001","01111","00001","00001"],
   1105     "r": ["00000","00000","10110","11001","10000","10000","10000","00000"],
   1106     "s": ["00000","00000","01111","10000","01110","00001","11110","00000"],
   1107     "t": ["01000","01000","11110","01000","01000","01001","00110","00000"],
   1108     "u": ["00000","00000","10001","10001","10001","10011","01101","00000"],
   1109     "v": ["00000","00000","10001","10001","10001","01010","00100","00000"],
   1110     "w": ["00000","00000","10001","10001","10101","10101","01010","00000"],
   1111     "x": ["00000","00000","10001","01010","00100","01010","10001","00000"],
   1112     "y": ["00000","00000","10001","10001","10001","01111","00001","01110"],
   1113     "z": ["00000","00000","11111","00010","00100","01000","11111","00000"],
   1114 }
   1115 
   1116 
   1117 def _text_run_len(text, px, col_gap):
   1118     """Length of `text` along the run direction (mm) — 5-wide glyphs spaced by col_gap."""
   1119     n = len(text)
   1120     return ((n * (5 + col_gap) - col_gap) * px) if n else 0.0
   1121 
   1122 
   1123 def pixel_text(text, ox, oy, run, rise, z0, z1, px, col_gap=1.0, row_gap=0.2):
   1124     """Raised 5x7 pixel `text` on a face, z0→z1. `run`=(rx,ry) is the unit direction along the
   1125     text line (also each glyph's column/width axis); `rise`=(ux,uy) the unit direction along the
   1126     glyph rows (height); both must be axis-aligned (±x/±y). Horizontal text: run=(1,0),rise=(0,1).
   1127     Vertical: run=(0,±1),rise=(∓1,0). Lit pixels are merged into per-row run boxes (solid strokes)
   1128     with a thin row_gap so no two boxes share an edge (stays manifold, like the imprint segments);
   1129     a sub-nozzle gap prints as a solid block. Unknown chars advance as a space."""
   1130     tris = []
   1131     bx, by = ox, oy
   1132     for ch in text:
   1133         glyph = _FONT5X7.get(ch) or _FONT5X7.get(ch.upper())   # exact case first, else fold to caps
   1134         if glyph:
   1135             for row in range(len(glyph)):                       # 7-row caps/digits or 8-row lowercase
   1136                 line, col = glyph[row], 0
   1137                 while col < 5:
   1138                     if line[col] == "1":
   1139                         c0 = col
   1140                         while col < 5 and line[col] == "1":
   1141                             col += 1
   1142                         rs0 = (6 - row) * px                 # this row's band along rise
   1143                         corners = [(c0 * px, rs0), (col * px, rs0),
   1144                                    (c0 * px, rs0 + px - row_gap), (col * px, rs0 + px - row_gap)]
   1145                         xs = [bx + rl * run[0] + rsv * rise[0] for rl, rsv in corners]
   1146                         ys = [by + rl * run[1] + rsv * rise[1] for rl, rsv in corners]
   1147                         tris += box(min(xs), min(ys), z0, max(xs), max(ys), z1)
   1148                     else:
   1149                         col += 1
   1150         bx += (5 + col_gap) * px * run[0]
   1151         by += (5 + col_gap) * px * run[1]
   1152     return tris
   1153 
   1154 
   1155 # ASCII-house maker's mark (v1.50) — a bitmap grid, '#'=raised pixel, top row first.
   1156 # CAPPED chimney at the top-right corner (the body's right wall carries up as its outer
   1157 # wall); gable roof; one window. Rendered by pixel_art as raised boxes (box-soup native,
   1158 # watertight like pixel_text). 12 wide x 10 tall.
   1159 _MAKER_HOUSE = [
   1160     "...####..###",
   1161     "..#....#.#.#",
   1162     ".#......##.#",
   1163     "#........#.#",
   1164     "############",
   1165     "#..........#",
   1166     "#..######..#",
   1167     "#..#....#..#",
   1168     "#..#....#..#",
   1169     "############",
   1170 ]
   1171 
   1172 
   1173 def pixel_art(grid, ox, oy, z0, z1, px, row_gap=0.2):
   1174     """Raised bitmap `grid` (rows of '#'/'1' = lit, top→bottom) on a face, z0→z1. (ox,oy)
   1175     is the art's bottom-left; +x runs along columns, +y up the rows. Lit pixels merge into
   1176     per-row run boxes with a thin row_gap so no two boxes share an edge (stays manifold —
   1177     the same trick as pixel_text / the imprint segments)."""
   1178     tris = []
   1179     n = len(grid)
   1180     for r, line in enumerate(grid):
   1181         yb = (n - 1 - r) * px                             # row 0 = top row
   1182         col, w = 0, len(line)
   1183         while col < w:
   1184             if line[col] in "1#":
   1185                 c0 = col
   1186                 while col < w and line[col] in "1#":
   1187                     col += 1
   1188                 tris += box(ox + c0 * px, oy + yb, z0,
   1189                             ox + col * px, oy + yb + px - row_gap, z1)
   1190             else:
   1191                 col += 1
   1192     return tris
   1193 
   1194 
   1195 def guide_collar(cx, cy, z_face, top_gaps=None):
   1196     """Short ridge frame on the lid's inner face around the OLED PCB outline, for
   1197     XY registration during assembly (v1.10). v1.15: rebuilt from overlapping BOXES
   1198     (was a closed annulus) so the header edge carries a NOTCH — the module's pin
   1199     header exits through a gap instead of fouling the wall. Viewer-right = model
   1200     −x on the lid (see the mirror note in PARAMS), so the notch lives on −x.
   1201     Boxes overlap at the corners but the top/bottom walls are inset 0.6 where
   1202     they meet the side walls — no two boxes share an exact face/edge (coincident
   1203     geometry breaks the manifold check; same trick as the imprint segments).
   1204     v1.41: `top_gaps` = [(x_centre, half_width), …] cuts those x-spans out of the TOP
   1205     bar so a top-band through-hole doesn't leave collar fragments poking into it —
   1206     the bar survives as the segments between the gaps (OLED still registers on the
   1207     pegs + the 3 other sides + the remaining top segments)."""
   1208     if not P["guide_ridges"]:
   1209         return []
   1210     gc, gt = P["guide_clearance"], P["guide_t"]
   1211     hw = P["oled_pcb_w"] / 2.0 + gc + P["collar_extra_w"] / 2.0
   1212     hh = P["oled_pcb_h"] / 2.0 + gc + P["collar_extra_h"] / 2.0
   1213     z0, z1 = z_face, z_face + P["guide_h"]
   1214     x0, x1 = cx - hw, cx + hw                    # collar INNER faces
   1215     y0, y1 = cy - hh, cy + hh
   1216     ov = 0.6                                     # corner overlap inset
   1217     nw2 = P["oled_header_notch_w"] / 2.0
   1218     tris = []
   1219     tris += box(x0 - gt, y0 - gt, z0, x0, y1 + gt, z1)             # −x wall (viewer-LEFT), full
   1220     tris += box(x1, y0 - gt, z0, x1 + gt, cy - nw2, z1)            # +x wall below the notch
   1221     tris += box(x1, cy + nw2, z0, x1 + gt, y1 + gt, z1)            # +x wall above the notch
   1222     tris += box(x0 - gt + ov, y0 - gt, z0, x1 + gt - ov, y0, z1)   # bottom wall
   1223     # top wall — split into the segments left clear by top_gaps (else one solid bar)
   1224     txl, txr = x0 - gt + ov, x1 + gt - ov
   1225     cuts = sorted((max(txl, g0 - g1), min(txr, g0 + g1)) for g0, g1 in (top_gaps or []))
   1226     cur = txl
   1227     for clo, chi in cuts:
   1228         if clo > cur + 0.5:
   1229             tris += box(cur, y1, z0, clo, y1 + gt, z1)
   1230         cur = max(cur, chi)
   1231     if txr > cur + 0.5:
   1232         tris += box(cur, y1, z0, txr, y1 + gt, z1)
   1233     return tris
   1234 
   1235 
   1236 def oled_pegs(cx, cy, z_face):
   1237     """Four Ø-oled_peg_d posts on the lid's inner face at the OLED's measured
   1238     mounting pattern (checklist #13). The module drops over them — glass through
   1239     the window, PCB flush on the lid — then the tips get heat-staked (or stay
   1240     friction-fit). Each peg is a closed cylinder overlapping the lid cap, the
   1241     same compose-by-overlap as the bosses."""
   1242     if not P["oled_pegs"]:
   1243         return []
   1244     r = P["oled_peg_d"] / 2.0
   1245     dx, dy = P["oled_mount_w"] / 2.0, P["oled_mount_h"] / 2.0
   1246     tris = []
   1247     for sx in (-1, 1):
   1248         for sy in (-1, 1):
   1249             tris += disc(cx + sx * dx, cy + sy * dy, r,
   1250                          z_face, z_face + P["oled_peg_h"], 16)
   1251     return tris
   1252 
   1253 
   1254 def fb_board_center(layout, W, H):
   1255     """FireBeetle centre in the face plane. B portrait (v1.33): mounted top-LEFT, USB
   1256     short edge facing the −y/bottom wall (the 2.0mm connector overhang reaching the wall
   1257     inner face), antenna short edge getting the +y margin. B landscape (v1.32): top edge
   1258     on the OLED's top line, USB short edge toward the +x wall. A (backup): plate centre."""
   1259     if layout == "A":
   1260         return W / 2.0, H / 2.0
   1261     if P["fb_orient"] == "portrait":
   1262         # x: board (fb_h wide) seated at the left edge_margin, clear of the corner bosses.
   1263         # y: USB end at the bottom wall — cy = wall + overhang + half the board LENGTH.
   1264         cx = P["edge_margin"] + P["fb_h"] / 2.0
   1265         cy = P["wall_t"] + P["fb_usb_overhang"] + P["fb_w"] / 2.0
   1266         return cx, cy
   1267     cx = W - P["wall_t"] - P["fb_usb_overhang"] - P["fb_w"] / 2.0
   1268     cy = (H - P["edge_margin"]) - P["fb_h"] / 2.0
   1269     return cx, cy
   1270 
   1271 
   1272 def fb_standoff_positions(layout, W, H):
   1273     """The four M2 standoff centres at the FireBeetle's CAD-exact hole pattern (±28.4 along
   1274     the board LENGTH, ±11.1 across the WIDTH), rotated for the board's orientation. Single
   1275     source of truth so the standoff bosses and the vent keepouts can never disagree."""
   1276     cx, cy = fb_board_center(layout, W, H)
   1277     if layout == "B" and P["fb_orient"] == "portrait":
   1278         ax, ay = P["fb_hole_dy"], P["fb_hole_dx"]   # portrait: length(28.4) runs along y, width(11.1) along x
   1279     else:
   1280         ax, ay = P["fb_hole_dx"], P["fb_hole_dy"]   # landscape / A: length along x
   1281     return [(cx + sx * ax, cy + sy * ay) for sx in (-1, 1) for sy in (-1, 1)]
   1282 
   1283 
   1284 def fb_standoffs(layout, W, H, z0):
   1285     """Four M2 standoff bosses on the tray floor at the FireBeetle's CAD-exact
   1286     hole pattern. Height clears the 7mm JST (components face the floor); screws
   1287     drop in from the front during back-insertion assembly. Same closed-tube
   1288     primitive as the M3 lid bosses."""
   1289     if not P["fb_standoffs"]:
   1290         return []
   1291     tris = []
   1292     for px, py in fb_standoff_positions(layout, W, H):
   1293         outer = ensure_ccw(circle_loop(px, py, P["fb_standoff_r"], P["boss_seg"]))
   1294         inner = ensure_ccw(circle_loop(px, py, P["fb_pilot_r"], P["boss_seg"]))
   1295         tris += annulus_slab(outer, inner, z0, z0 + P["fb_standoff_h"])
   1296     return tris
   1297 
   1298 
   1299 def battery_footprint(layout, W, H):
   1300     """The 103454 LiPo pouch's floor footprint (34 wide × 54 tall, VERTICAL) for B-portrait.
   1301     v1.35: hard against the RIGHT (opposite the FB — leads are long), the collar's outer wall
   1302     1mm clear of the right corner bosses. Returns (x0,y0,x1,y1) or None (A / landscape stack
   1303     the cell elsewhere — no floor pocket)."""
   1304     if layout != "B" or P["fb_orient"] != "portrait":
   1305         return None
   1306     boss_left = W - P["wall_t"] - 2 * P["boss_r"]                      # left edge of the right corner bosses (~85)
   1307     x1 = (boss_left - 1.0) - P["bat_collar_t"] - P["bat_collar_clear_x"]   # pouch right edge (wall 1mm off the boss)
   1308     x0 = x1 - P["bat_h"]                                               # 34mm across (vertical pouch)
   1309     # v1.36: top-pinned (as far UP as possible) — the −y wall sits 1mm off the cavity's top
   1310     # inner wall, clearing the centred case-button terminals down at the bottom band.
   1311     y1 = (H - P["wall_t"] - 1.0) - P["bat_collar_t"] - P["bat_collar_clear"]   # pouch top edge
   1312     y0 = y1 - P["bat_w"]                                               # 54mm tall
   1313     return (x0, y0, x1, y1)
   1314 
   1315 
   1316 def battery_keepout_rect(layout, W, H):
   1317     """The floor rectangle kept SOLID (no vent cells) under the battery collar — the wall-outer
   1318     rect grown half a hex so no wall foots on a half-cell. None when there's no pocket."""
   1319     fp = battery_footprint(layout, W, H)
   1320     if fp is None or not P["bat_collar"]:
   1321         return None
   1322     gx = P["bat_collar_clear_x"] + P["bat_collar_t"] + P["vent_hex_flats"] / 2.0
   1323     gy = P["bat_collar_clear"] + P["bat_collar_t"] + P["vent_hex_flats"] / 2.0
   1324     return (fp[0] - gx, fp[1] - gy, fp[2] + gx, fp[3] + gy)
   1325 
   1326 
   1327 def battery_collar(layout, W, H, ft):
   1328     """Full retention collar for the pouch: four walls framing the footprint, standing on the
   1329     tray floor. LEFT/RIGHT (vertical) walls use bat_collar_clear_x; TOP/BOTTOM use bat_collar_
   1330     clear. Each wall is a closed box that overlaps its neighbours at the corners and embeds into
   1331     the floor — the codebase's compose-by-overlap trick (watertight by construction; the slicer
   1332     unions the overlaps). Optional JST-lead notch (bat_notch) splits the −x wall."""
   1333     fp = battery_footprint(layout, W, H)
   1334     if fp is None or not P["bat_collar"]:
   1335         return []
   1336     x0, y0, x1, y1 = fp
   1337     cx, cy, t, h = P["bat_collar_clear_x"], P["bat_collar_clear"], P["bat_collar_t"], P["bat_collar_h"]
   1338     px0, py0, px1, py1 = x0 - cx, y0 - cy, x1 + cx, y1 + cy            # pocket inner faces (per-axis clearance)
   1339     ox0, oy0, ox1, oy1 = px0 - t, py0 - t, px1 + t, py1 + t            # wall outer faces
   1340     z0, z1 = ft - 0.5, ft + h                                          # embed 0.5 into the floor for a clean union
   1341     ih = t / 2.0                                                       # left/right walls own the corners (full y)
   1342     tris  = box(px1, oy0, z0, ox1, oy1, z1)                            # right (+x) wall, full y
   1343     if P["bat_notch"]:
   1344         ny0 = y0 + 1.0; ny1 = ny0 + P["bat_notch_w"]                  # JST-lead notch, biased low
   1345         tris += box(ox0, oy0, z0, px0, ny0, z1)                       # −x wall below the notch
   1346         tris += box(ox0, ny1, z0, px0, oy1, z1)                       # −x wall above the notch
   1347     else:
   1348         tris += box(ox0, oy0, z0, px0, oy1, z1)                       # −x (FB-facing) wall, solid (no notch)
   1349     tris += box(ox0 + ih, py1, z0, ox1 - ih, oy1, z1)                 # top (+y) wall — inset x, interpenetrates the sides
   1350     tris += box(ox0 + ih, oy0, z0, ox1 - ih, py0, z1)                 # bottom (−y) wall — inset x, no coincident corner edge
   1351     return tris
   1352 
   1353 
   1354 def usb_slot_profile(layout, W, H, ft):
   1355     """The USB-C cutout opening as a CW loop in the USB wall's (u, z) plane — u is the
   1356     in-wall coordinate: x for the −y/bottom wall, y for the +x/right wall. Centred on the
   1357     FireBeetle's USB short edge (x-centre for "-y", y-centre for "+x"); z slaved to the
   1358     standoff height so the slot rises with the board (the board mounts identically either
   1359     way). Optionally topped by a 45° inverted-V (usb_roof_45). Returns None when off. CW
   1360     because a hole loop inside the CCW wall-face rectangle is wound opposite (cf. _hole_loop)."""
   1361     if not P["usb_cutout"]:
   1362         return None
   1363     wall = P["usb_wall"] if layout == "B" else "+x"        # A (centered backup) stays on the +x wall
   1364     cx, cy = fb_board_center(layout, W, H)
   1365     u = cx if wall == "-y" else cy                         # connector centred on the USB short edge
   1366     u0, u1 = u - P["usb_slot_w"] / 2.0, u + P["usb_slot_w"] / 2.0
   1367     z1 = ft + P["fb_standoff_h"] + P["usb_slot_top_margin"] + P["usb_slot_z_offset"]  # top — slaved to the board + offset
   1368     z0 = z1 - P["usb_slot_h"]                                # rectangle bottom (flat, prints clean)
   1369     if P["usb_roof_45"]:
   1370         hw = (u1 - u0) / 2.0                             # 45° apex: rises half the width above z1
   1371         prof = [(u0, z0), (u1, z0), (u1, z1), ((u0 + u1) / 2.0, z1 + hw), (u0, z1)]
   1372     else:
   1373         prof = [(u0, z0), (u1, z0), (u1, z1), (u0, z1)]   # plain rectangle (supports needed)
   1374     return prof if signed_area(prof) < 0 else prof[::-1]  # force CW (hole winding)
   1375 
   1376 
   1377 def _vwall_with_window(const, rect_uv, hole_cw, outward, plane="x"):
   1378     """A FLAT vertical wall face at a constant coordinate, carrying a CW hole in a CCW
   1379     rectangle (ear-clip + hole-bridge, the same machinery as the lid window), then lifted
   1380     to 3D. plane="x": face at x=const, (u,v)=(y,z), normal (outward,0,0) — the +x/right
   1381     wall. plane="y": face at y=const, (u,v)=(x,z), normal (0,outward,0) — the −y/bottom
   1382     wall. The hole boundary edges land exactly on the tunnel quads, and the rectangle
   1383     boundary edges land on the loop walls / skirt / rim that surround this run — watertight."""
   1384     tris2d = _earclip(_bridge_holes(ensure_ccw(list(rect_uv)), [hole_cw]))
   1385     if plane == "x":
   1386         lift = lambda u, v: (const, u, v); tgt = (outward, 0, 0)
   1387     else:
   1388         lift = lambda u, v: (u, const, v); tgt = (0, outward, 0)
   1389     return [_orient(lift(*a), lift(*b), lift(*c), tgt) for a, b, c in tris2d]
   1390 
   1391 
   1392 def usb_tunnel(hole_cw, c_out, c_in, plane="x"):
   1393     """The slot passage: one quad per hole edge, bridging the exterior hole (const=c_out)
   1394     to the interior hole (const=c_in). Normals face INTO the passage (toward the hole's
   1395     centroid), like a clearance-hole wall. plane="x": (u,v)=(y,z) at constant x. plane="y":
   1396     (u,v)=(x,z) at constant y. Shares every hole edge with the two windowed faces."""
   1397     k = len(hole_cw)
   1398     cu = sum(p[0] for p in hole_cw) / k; cv = sum(p[1] for p in hole_cw) / k
   1399     lift = (lambda c, u, v: (c, u, v)) if plane == "x" else (lambda c, u, v: (u, c, v))
   1400     tris = []
   1401     for i in range(k):
   1402         j = (i + 1) % k
   1403         Pi, Pj = hole_cw[i], hole_cw[j]
   1404         A = lift(c_out, Pi[0], Pi[1]); B = lift(c_out, Pj[0], Pj[1])
   1405         C = lift(c_in, Pj[0], Pj[1]);  D = lift(c_in, Pi[0], Pi[1])
   1406         mu = (Pi[0] + Pj[0]) / 2.0; mv = (Pi[1] + Pj[1]) / 2.0
   1407         tgt = (0, cu - mu, cv - mv) if plane == "x" else (cu - mu, 0, cv - mv)
   1408         tris.append(_orient(A, B, C, tgt)); tris.append(_orient(A, C, D, tgt))
   1409     return tris
   1410 
   1411 
   1412 def _subdiv_rect(u0, v0, u1, v1, nu, nv):
   1413     """CCW rectangle perimeter subdivided on an nu×nv grid — shares every vertex with a
   1414     honeycomb_region of the same metrics, so a face that bridges this as a hole stitches to
   1415     the field cells with NO T-junctions."""
   1416     cw = (u1 - u0) / nu; ch = (v1 - v0) / nv
   1417     pts  = [(u0 + i * cw, v0) for i in range(nu)]          # bottom L→R
   1418     pts += [(u1, v0 + j * ch) for j in range(nv)]          # right  B→T
   1419     pts += [(u0 + (nu - i) * cw, v1) for i in range(nu)]   # top    R→L
   1420     pts += [(u0, v1 - j * ch) for j in range(nv)]          # left   T→B
   1421     return pts
   1422 
   1423 
   1424 def _vwall_cap(const, rect_uv, holes_cw, outward, plane="x"):
   1425     """Like _vwall_with_window but robust for a many-vertex hole: ear-clip with the
   1426     area-check + _triangulate fallback (cf. cap_with_holes), then lift to 3D. plane "x"
   1427     → face at x=const, (u,v)=(y,z); plane "y" → y=const, (u,v)=(x,z)."""
   1428     outer = ensure_ccw(list(rect_uv))
   1429     if holes_cw:
   1430         poly = _dedup(_bridge_holes(outer, holes_cw))
   1431         tris2d = _earclip(poly)
   1432         want = abs(signed_area(poly))
   1433         got = sum(abs(_tri_area2(a, b, c)) for a, b, c in tris2d) / 2.0
   1434         if len(tris2d) < len(poly) - 2 or abs(got - want) > 1e-3 * max(1.0, want):
   1435             tris2d = _triangulate(poly)
   1436     else:
   1437         tris2d = _earclip(outer)
   1438     lift = (lambda u, v: (const, u, v)) if plane == "x" else (lambda u, v: (u, const, v))
   1439     tgt = (outward, 0, 0) if plane == "x" else (0, outward, 0)
   1440     return [_orient(lift(*a), lift(*b), lift(*c), tgt) for a, b, c in tris2d]
   1441 
   1442 
   1443 def wall_vent(layout, W, H, side, ft, ext_z0, top, r, r_in):
   1444     """Honeycomb vent through one side ('+x' / '-x') wall of the B tray. The field is an
   1445     INSET box in the wall's (y,z) plane — flat-top hexes (support-free up a vertical wall).
   1446     Build the cells in a canonical honeycomb box (its x→wall-y, y→wall-z, z→wall-thickness)
   1447     then rotate (hx,hy,hz)→(hz,hx,hy) into place (a det=+1 cyclic swap → normals survive).
   1448     The exterior + interior faces are rebuilt here as `wall-run rect minus the field hole`
   1449     (a single hole → robust), the hole subdivided on the cell grid so it stitches to the
   1450     cells. The caller SKIPS this wall's straight run in _side_wall. [] when off / not B."""
   1451     if not (P["vent_walls"] and layout == "B"):
   1452         return []
   1453     wt = P["wall_t"]
   1454     m = P["vent_wall_margin"]; bd = wt + P["boss_r"]
   1455     uf0 = bd + P["boss_r"] + m;  uf1 = H - bd - P["boss_r"] - m       # along-wall (y): clear both corners
   1456     vf0 = ft + m;                vf1 = top - m                        # up (z): above floor, below rim
   1457     if side == "+x":
   1458         zt0, zt1 = W - wt, W                  # thickness span (→ wall x); z1 cap (normal +z→+x) = exterior
   1459         x_out, s_out, x_in, s_in = W, +1, W - wt, -1
   1460     else:                                     # "-x": z0 cap (normal −z→−x) at x=0 = exterior
   1461         zt0, zt1 = 0.0, wt
   1462         x_out, s_out, x_in, s_in = 0.0, -1, wt, +1
   1463     cells, vmeta = honeycomb_region(uf0, vf0, uf1, vf1, zt0, zt1, P["vent_hex_flats"], P["vent_wall"])
   1464     cells = [tuple((p[2], p[0], p[1]) for p in tri) for tri in cells]   # rotate canonical → wall (x,y,z)
   1465     x0, y0, x1, y1, nx, ny, cw, ch = vmeta
   1466     fh_cw = _subdiv_rect(x0, y0, x1, y1, nx, ny)[::-1]                  # field perimeter as a CW hole
   1467     ext_rect = [(r, ext_z0), (H - r, ext_z0), (H - r, top), (r, top)]              # exterior run (y,z)
   1468     in_rect  = [(wt + r_in, ft), (H - wt - r_in, ft), (H - wt - r_in, top), (wt + r_in, top)]
   1469     tris  = cells
   1470     tris += _vwall_cap(x_out, ext_rect, [fh_cw], s_out, "x")           # exterior face + field hole
   1471     tris += _vwall_cap(x_in,  in_rect,  [fh_cw], s_in,  "x")           # cavity face + field hole
   1472     return tris
   1473 
   1474 
   1475 def _chamfer_inset(W, H, r, seg, c):
   1476     """The outer rounded-rect inset by `c` on every side — same `seg`, so it carries
   1477     the SAME point count as the full outer and the two stitch index-to-index for a
   1478     bevel skirt. Corner radius shrinks with the inset (clamped ≥0.5)."""
   1479     return ensure_ccw(rounded_rect(c, c, W - c, H - c, max(0.5, r - c), seg))
   1480 
   1481 
   1482 def _skirt(lo, hi, zlo, zhi, zdir):
   1483     """45° bevel band linking loop `lo` @zlo (the inset face edge) to loop `hi` @zhi
   1484     (the full-size wall foot) — matched point counts. Normals face outward AND along
   1485     zdir (−1 = a front/underside chamfer, facing down-and-out). This is the softened
   1486     case edge; the wall continues vertically from `hi` above it."""
   1487     k = len(lo)
   1488     cx = sum(p[0] for p in lo) / k; cy = sum(p[1] for p in lo) / k
   1489     tris = []
   1490     for i in range(k):
   1491         j = (i + 1) % k
   1492         A = (lo[i][0], lo[i][1], zlo); B = (lo[j][0], lo[j][1], zlo)
   1493         C = (hi[j][0], hi[j][1], zhi); D = (hi[i][0], hi[i][1], zhi)
   1494         mx = (lo[i][0] + lo[j][0]) / 2.0; my = (lo[i][1] + lo[j][1]) / 2.0
   1495         tgt = (mx - cx, my - cy, zdir)
   1496         tris.append(_orient(A, B, C, tgt)); tris.append(_orient(A, C, D, tgt))
   1497     return tris
   1498 
   1499 
   1500 def front_vent_regions(layout, W, H, cx, cy, bx, bhw):
   1501     """Honeycomb bottom grille on the lid: a hex strip below the OLED window, split into two
   1502     by the case button. It sits below the guide-collar footprint and inside the corner bosses,
   1503     reached by a slit up from the bottom edge (the v1.22 keyhole). Returns (regions, keepc):
   1504     keepc = boss + peg centres left solid. B only — A's centred window leaves no clean band.
   1505     vent_front is OFF since v1.45, so this now only positions the two front portholes."""
   1506     if layout != "B":
   1507         return [], []
   1508     m, g = P["vent_front_margin"], P["vent_front_gap"]
   1509     col_hh = P["oled_pcb_h"] / 2.0 + P["guide_clearance"] + P["collar_extra_h"] / 2.0 + P["guide_t"]
   1510     col_y0 = cy - col_hh
   1511     by0, by1 = m, col_y0 - g                          # below the collar footprint
   1512     bd = P["wall_t"] + P["boss_r"]                     # corner-boss inset from the edge
   1513     x0, x1 = bd + P["vent_keepout_r"] + 1.0, W - bd - P["vent_keepout_r"] - 1.0   # clear the bosses
   1514     regions = []
   1515     if bx is not None:                                # split around the case button
   1516         regions.append((x0, by0, bx - bhw - g, by1))
   1517         regions.append((bx + bhw + g, by0, x1, by1))
   1518     else:
   1519         regions.append((x0, by0, x1, by1))
   1520     keepc = list(boss_positions(W, H))                # 4 corner M3 bosses
   1521     dx, dy = P["oled_mount_w"] / 2.0, P["oled_mount_h"] / 2.0
   1522     keepc += [(cx + sx * dx, cy + sy * dy) for sx in (-1, 1) for sy in (-1, 1)]   # 4 OLED pegs
   1523     return regions, keepc
   1524 
   1525 
   1526 def make_front(layout):
   1527     """Front bezel / LID: rounded plate with a true display-window hole, the M3
   1528     screw bosses (posts standing into the cavity — back insertion: screws come up
   1529     from the rear through the tray floor into these), and a single centred lanyard
   1530     loop. Clean front face — no screw heads on the screen side."""
   1531     W, H = plate_outline(layout)
   1532     t = P["plate_t"]; r = P["corner_r"]; seg = P["corner_seg"]
   1533 
   1534     win_w = P["oled_glass_w"] + 2 * P["window_clearance"] + P["window_extra_w"]   # glass passthrough (rectangular)
   1535     win_h = P["oled_glass_h"] + 2 * P["window_clearance"] + P["window_extra_h"]
   1536 
   1537     # OLED centered in A; in the top band for B
   1538     if layout == "A":
   1539         cx, cy = W / 2.0, H / 2.0
   1540     else:
   1541         cx = W / 2.0
   1542         cy = H - P["edge_margin"] - P["oled_pcb_h"] / 2.0
   1543 
   1544     wx0, wx1 = cx - win_w / 2.0, cx + win_w / 2.0
   1545     wy0, wy1 = cy - win_h / 2.0, cy + win_h / 2.0
   1546 
   1547     outer = ensure_ccw(rounded_rect(0, 0, W, H, r, seg))
   1548     window = ensure_ccw([(wx0, wy0), (wx1, wy0), (wx1, wy1), (wx0, wy1)])
   1549 
   1550     # v1.14: lid plate as a MULTI-hole slab (cap_with_holes — the same machinery as
   1551     # the tray floor's screw holes) instead of the single-hole annulus, so it can
   1552     # carry the display window AND the round front-button hole together.
   1553     holes = [list(reversed(window))]                     # CW hole loops
   1554     btn_cx = None                                        # case-button centre (splits the bottom vent)
   1555     if P["btn_hole"]:
   1556         hh_out = (P["oled_pcb_h"] / 2.0 + P["guide_clearance"]
   1557                   + P["collar_extra_h"] / 2.0 + P["guide_t"])
   1558         band_lo = P["wall_t"]                            # inner face of the bottom wall
   1559         band_hi = cy - hh_out                            # bottom edge of the guide collar
   1560         if band_hi - band_lo >= P["btn_hole_d"] + 3.0:   # hole + ~1.5mm web each side
   1561             bx, by = W / 2.0, (band_lo + band_hi) / 2.0
   1562             holes.append(_hole_loop(bx, by, P["btn_hole_d"] / 2.0, P["boss_seg"]))
   1563             btn_cx = bx
   1564     # v1.40: a centred row of Ø5 through-holes across the top band (B), replacing the old top text.
   1565     # Skip any hole that can't clear the two top corner M3 bosses (keeps it safe if n/pitch change).
   1566     # Each placed hole also seats a vertex on the top edge straight above it (top_xs → _with_top),
   1567     # so it bridges with a short vertical slit instead of a self-crossing diagonal to a far corner.
   1568     top_xs = []
   1569     if P["top_holes"] and layout == "B":
   1570         n, pitch, hy = P["top_hole_n"], P["top_hole_pitch"], P["top_hole_y"]
   1571         hr = P["top_hole_d"] / 2.0
   1572         min_boss = P["boss_r"] + hr + 1.0                 # ≥1mm web to a boss post
   1573         for i in range(n):
   1574             hx = W / 2.0 + (i - (n - 1) / 2.0) * pitch
   1575             if all((hx - bxx) ** 2 + (hy - byy) ** 2 >= min_boss ** 2 for bxx, byy in boss_positions(W, H)):
   1576                 holes.append(_hole_loop(hx, hy, hr, P["top_hole_seg"]))
   1577                 top_xs.append(hx)
   1578     # v1.45: large round holes where the front honeycomb grille used to be — one centred in each of
   1579     # the two grille bands flanking the button (reuses front_vent_regions for placement). Each seats a
   1580     # bottom-edge vertex below it (front_xs → _with_base) so it bridges straight down, not across.
   1581     front_xs = []
   1582     if P["front_holes"] and layout == "B":
   1583         fr = P["front_hole_d"] / 2.0
   1584         regions, _ = front_vent_regions(layout, W, H, cx, cy, btn_cx, P["btn_hole_d"] / 2.0)
   1585         for rx0, ry0, rx1, ry1 in regions:
   1586             fx, fy = (rx0 + rx1) / 2.0, (ry0 + ry1) / 2.0
   1587             fx += (-1.0 if fx < W / 2.0 else 1.0) * P["front_hole_dx"]   # v1.47: toward the nearer L/R edge
   1588             fy += P["front_hole_dy"]                                     #         and up
   1589             holes.append(_hole_loop(fx, fy, fr, P["front_hole_seg"]))
   1590             front_xs.append(fx)
   1591         if btn_cx is not None:
   1592             front_xs.append(btn_cx)                      # seat the button's bottom bridge too (no vent notch now)
   1593     # v1.23: honeycomb grille. Each region is a per-cell hex panel (0→t, watertight by
   1594     # construction); its rectangular boundary is spliced into BOTH lid caps as a notch
   1595     # reached by one slit up from the bottom edge (the v1.22 back-floor keyhole), so the
   1596     # caps only bridge the real window/button holes. The slit BASE of every region is shared
   1597     # into the outer + chamfer-inset outlines (_with_base) so the skirt/rim stitch with no
   1598     # T-junction. The converging slits make cap_with_holes fall back to _triangulate.
   1599     vmetas, slit_xs, vent_cells = [], [], []
   1600     if P["vent_front"]:
   1601         regions, keepc = front_vent_regions(layout, W, H, cx, cy, btn_cx, P["btn_hole_d"] / 2.0)
   1602         kr2 = P["vent_keepout_r"] ** 2
   1603         keep = lambda X, Y: any((X - kx) ** 2 + (Y - ky) ** 2 < kr2 for kx, ky in keepc)
   1604         ncols = P["vent_front_btn_clear_cols"]            # innermost columns to leave SOLID beside the button
   1605         split = btn_cx is not None and len(regions) == 2  # left/right fields flank the button
   1606         pitch = P["vent_hex_flats"] + P["vent_wall"]
   1607         for ridx, (rx0, ry0, rx1, ry1) in enumerate(regions):
   1608             rkeep = keep
   1609             if split and ncols > 0:                       # v1.39: button-side column(s) → solid (no hex)
   1610                 nx_r = max(1, int(round((rx1 - rx0) / pitch)))
   1611                 cw_r = (rx1 - rx0) / nx_r                 # SAME metric honeycomb_region uses → other cells untouched
   1612                 if ridx == 0:                             # LEFT field → its rightmost (button-adjacent) column
   1613                     thr = rx1 - ncols * cw_r
   1614                     rkeep = (lambda bk, x_: lambda X, Y: bk(X, Y) or X > x_)(keep, thr)
   1615                 else:                                     # RIGHT field → its leftmost (button-adjacent) column
   1616                     thr = rx0 + ncols * cw_r
   1617                     rkeep = (lambda bk, x_: lambda X, Y: bk(X, Y) or X < x_)(keep, thr)
   1618             cells, vm = honeycomb_region(rx0, ry0, rx1, ry1, 0.0, t,
   1619                                          P["vent_hex_flats"], P["vent_wall"], rkeep)
   1620             nx = vm[4]
   1621             sidx = max(1, min(nx - 1, nx // 2))          # interior slit column (perpendicular)
   1622             vent_cells += cells
   1623             vmetas.append((vm, sidx))
   1624             slit_xs.append(vm[0] + sidx * vm[6])
   1625 
   1626     def based(ol):                                       # share each slit base with skirt/rim
   1627         for sx in slit_xs:
   1628             ol = _with_base(ol, sx)
   1629         for hx in top_xs:                                # seat a vertex above each top-band hole
   1630             ol = _with_top(ol, hx)
   1631         for fx in front_xs:                              # seat a vertex below each front porthole / button
   1632             ol = _with_base(ol, fx)
   1633         if front_xs:                                     # front holes present → seat a TOP vertex above the
   1634             ol = _with_top(ol, cx)                       # window centre so it bridges UP, not down through the button
   1635         return ol
   1636 
   1637     def facecap(outline, z, updir):                      # splice every notch, then cap + bridge holes
   1638         ol = outline
   1639         for vm, sidx in vmetas:
   1640             ol = splice_vent_notch(ol, vm, sidx)
   1641         return cap_with_holes(ol, holes, z, updir)
   1642 
   1643     # v1.18: chamfer the exposed FRONT face↔rim edge (z=0). The front face shrinks
   1644     # to an inset outline, a 45° skirt ramps out to full size over `c`, and the rim
   1645     # wall starts at z=c. Holes (window/button) sit far from the perimeter, so they
   1646     # keep full-height walls untouched. The inner face (z=t, the lid↔tray seam) stays
   1647     # square for a flush seat.
   1648     c = P["chamfer_size"] if P["chamfer"] else 0.0
   1649     outer_b = based(outer)                               # bottom edge carries the shared slit bases
   1650     if 0 < c < t:
   1651         outer_in = based(_chamfer_inset(W, H, r, seg, c))
   1652         tris  = facecap(outer_in, 0, (0, 0, -1))         # inset front face + vent notches
   1653         tris += _skirt(outer_in, outer_b, 0, c, -1)      # 45° bevel
   1654         tris += _side_wall(outer_b, c, t, True)          # rim above the bevel
   1655     else:
   1656         tris  = facecap(outer_b, 0, (0, 0, -1))          # front face + vent notches
   1657         tris += _side_wall(outer_b, 0, t, True)          # plate rim
   1658     tris += facecap(outer_b, t, (0, 0, 1))   # inner face (seam side — square) + vent notches
   1659     for h in holes:
   1660         tris += _side_wall(h, 0, t, False)               # window + button hole walls
   1661     tris += vent_cells                                   # honeycomb cells fill the notches
   1662     # bosses live on the LID for back insertion — posts spanning the cavity, bore
   1663     # opening at the tip (which meets the tray floor when assembled).
   1664     tris += screw_bosses(W, H, t, t + cavity_depth(layout))
   1665     if P["ear_front"]:
   1666         tris += ear((W - P["ear_w"]) / 2.0, H, t)   # single centred lanyard loop
   1667     # LID top band (above the window/collar, clear of the top corner bosses): the maker URL,
   1668     # band-centred in the 5x7 pixel font (v1.51). px 0.8 → 5.6mm glyphs, ~the 7-seg version
   1669     # height, with a ~71mm run centred in the 76mm clear span. Inner face → hidden on teardown,
   1670     # and it prints clean (the lid prints face-DOWN, so this face is UP). The build-number is
   1671     # NOT duplicated here anymore; the TRAY carries it (above the FB) for teardown ID. A blank
   1672     # tag falls back to the 7-seg version stamp. v1.40: skipped when the top-hole row owns this band (B).
   1673     if not (P["top_holes"] and layout == "B"):
   1674         if P["maker_tag"]:
   1675             upx = P["maker_tag_px"]
   1676             uox = W / 2.0 - _text_run_len(P["maker_tag"], upx, 1.0) / 2.0   # centred on the plate midline (= band centre)
   1677             uoy = (H - 2.0) - 7 * upx                                       # ascender tops flush ~2mm below the top edge
   1678             tris += pixel_text(P["maker_tag"], uox, uoy, (1.0, 0.0), (0.0, 1.0),
   1679                                t, t + P["imprint_h"], upx)
   1680         else:
   1681             tris += imprint_text(P["case_version"].lstrip("v") + layout, 12.0, H - P["imprint_digit_h"] - 2.0, t)
   1682     # OLED registration ridges on the inner face — notch the top bar around any top-band holes
   1683     collar_gaps = [(hx, P["top_hole_d"] / 2.0 + 0.6) for hx in top_xs]
   1684     tris += guide_collar(cx, cy, t, top_gaps=collar_gaps)
   1685     tris += oled_pegs(cx, cy, t)      # OLED mounting pegs (measured #13 pattern)
   1686     # v1.30: the front face stays FLAT — raised text broke face-down FDM printing (it would sit
   1687     # below the bed). The front text is now a SEPARATE colour-inlay mesh (make_front_text →
   1688     # *_front_text.stl), extruded INTO the face, to drop into an AMS slice as a 2nd-filament part.
   1689     return tris
   1690 
   1691 
   1692 def _reflect_x(tris, xm):
   1693     """Mirror geometry across the vertical plane x=xm (x → 2·xm − x). Reflection reverses
   1694     orientation, so swap two vertices per triangle to keep normals pointing outward."""
   1695     out = []
   1696     for a, b, c in tris:
   1697         ra = (2 * xm - a[0], a[1], a[2])
   1698         rb = (2 * xm - b[0], b[1], b[2])
   1699         rc = (2 * xm - c[0], c[1], c[2])
   1700         out.append((ra, rc, rb))                         # swap b,c to restore winding
   1701     return out
   1702 
   1703 
   1704 def front_text_tris(layout, z0, z1):
   1705     """The front-face pixel text as glyph boxes spanning z0→z1, in the solid bands the grille
   1706     leaves clear: TOP horizontal above the window, SIDES vertical alongside it (left reads up,
   1707     right down). Factored out so the colour-inlay mesh and any future inline use share one
   1708     placement. B-tuned; A renders too. "" zones are skipped.
   1709 
   1710     MIRROR: the lid prints face-DOWN, so the show face is viewed along +z — where +x runs left,
   1711     which mirrors text laid out +x-right. Each block is reflected about its OWN x-centre, so it
   1712     reads correctly from the front while staying in its band (top centred, left at −x, right +x)."""
   1713     if not P["front_text"]:
   1714         return []
   1715     W, H = plate_outline(layout)
   1716     if layout == "A":
   1717         cx, cy = W / 2.0, H / 2.0
   1718     else:
   1719         cx, cy = W / 2.0, H - P["edge_margin"] - P["oled_pcb_h"] / 2.0
   1720     win_w = P["oled_glass_w"] + 2 * P["window_clearance"] + P["window_extra_w"]
   1721     win_h = P["oled_glass_h"] + 2 * P["window_clearance"] + P["window_extra_h"]
   1722     wx0, wx1, wy1 = cx - win_w / 2.0, cx + win_w / 2.0, cy + win_h / 2.0
   1723     px, cg, m = P["front_text_px"], P["front_text_col_gap"], 2.0
   1724 
   1725     def placed(s, ox, oy, run, rise):                    # render a block, then un-mirror in place
   1726         blk = pixel_text(s, ox, oy, run, rise, z0, z1, px, cg)
   1727         if not blk:
   1728             return []
   1729         xs = [v[0] for tr in blk for v in tr]
   1730         return _reflect_x(blk, (min(xs) + max(xs)) / 2.0)
   1731 
   1732     tris = []
   1733     if P["front_text_top"]:                              # horizontal, centred above the window
   1734         s = P["front_text_top"]
   1735         ox = (W - _text_run_len(s, px, cg)) / 2.0
   1736         oy = (wy1 + (H - m)) / 2.0 - 3.5 * px            # centre the 7px glyph in the top band
   1737         tris += placed(s, ox, oy, (1.0, 0.0), (0.0, 1.0))
   1738     if P["front_text_left"]:                             # vertical, reading up the left band
   1739         s = P["front_text_left"]
   1740         oy = cy - _text_run_len(s, px, cg) / 2.0
   1741         ox = (m + (wx0 - 1.5)) / 2.0 + 3.5 * px          # centre the 7px glyph across the band
   1742         tris += placed(s, ox, oy, (0.0, 1.0), (-1.0, 0.0))
   1743     if P["front_text_right"]:                            # vertical, reading down the right band
   1744         s = P["front_text_right"]
   1745         oy = cy + _text_run_len(s, px, cg) / 2.0
   1746         ox = ((wx1 + 1.5) + (W - m)) / 2.0 - 3.5 * px
   1747         tris += placed(s, ox, oy, (0.0, -1.0), (1.0, 0.0))
   1748     return tris
   1749 
   1750 
   1751 def make_front_text(layout):
   1752     """Front-face colour INLAY: the pixel text extruded from the face (z=0) INTO the plate by
   1753     front_text_h, as a standalone mesh (disjoint closed glyph boxes → passes the watertight
   1754     check). It OVERLAPS the flat lid; in the slicer, add it as a part of the lid object and
   1755     assign your 2nd filament — the slicer colours the glyph volume, the face stays flat, and it
   1756     prints face-down (the inlay is the bottom layers). 'Multi-colour by parts'."""
   1757     return front_text_tris(layout, 0.0, P["front_text_h"])
   1758 
   1759 
   1760 def make_back(layout):
   1761     """Back piece / TRAY (walls on, default): floor + rounded perimeter walls, open
   1762     to the front so the boards drop in. For BACK insertion the screw bosses live on
   1763     the lid, so the floor carries the M3 screw-shank CLEARANCE holes (heads sit on
   1764     the back). Single centred lanyard loop. Built as one closed solid: underside
   1765     floor + outer walls + top rim (lid seat) + inner cavity walls + cavity floor,
   1766     with clearance holes punched through the floor. walls off → v0 flat slab."""
   1767     W, H = plate_outline(layout)
   1768     t = P["plate_t"]; r = P["corner_r"]; seg = P["corner_seg"]
   1769 
   1770     if not P["walls"]:
   1771         poly = ensure_ccw(rounded_rect(0, 0, W, H, r, seg))
   1772         tris = prism(poly, 0, t)
   1773         if P["ear_back"]:
   1774             tris += ear((W - P["ear_w"]) / 2.0, H, t)   # single centred lanyard loop
   1775         return tris
   1776 
   1777     wt = P["wall_t"]
   1778     r_in = max(0.5, r - wt)
   1779     outer = ensure_ccw(rounded_rect(0, 0, W, H, r, seg))
   1780     inner = ensure_ccw(rounded_rect(wt, wt, W - wt, H - wt, r_in, seg))  # same pt count
   1781     ft = t                                # floor thickness
   1782     top = ft + cavity_depth(layout)       # wall top = lid seat
   1783 
   1784     # back-insertion: screws come up through the floor into the lid bosses, so the
   1785     # floor carries 4 clearance holes (bosses themselves now live on the lid).
   1786     clr = [_hole_loop(cx, cy, P["boss_clear_r"], P["boss_seg"]) for cx, cy in boss_positions(W, H)]
   1787 
   1788     # honeycomb vent field: a per-cell hex panel filling a margin-inset rect of the
   1789     # floor, cells left solid near the screw bosses + FB standoffs. It's a coplanar
   1790     # section of the floor slab (0→ft); its perimeter is punched as ONE subdivided hole
   1791     # in both floor faces (floor_holes, below) and the cells fill it (no perimeter wall —
   1792     # continuous slab). The screw-hole WALLS stay on `clr` only.
   1793     vents_tris, vmeta = [], None
   1794     if P["vent_back"]:
   1795         m = P["vent_margin"]
   1796         keepc = list(boss_positions(W, H)) + fb_standoff_positions(layout, W, H)
   1797         kr2 = P["vent_keepout_r"] ** 2
   1798         bk = battery_keepout_rect(layout, W, H)          # keep the floor SOLID under the battery collar
   1799         in_bat = (lambda cx, cy: bk[0] <= cx <= bk[2] and bk[1] <= cy <= bk[3]) if bk else (lambda cx, cy: False)
   1800         keep = lambda cx, cy: in_bat(cx, cy) or any((cx - kx) ** 2 + (cy - ky) ** 2 < kr2 for kx, ky in keepc)
   1801         vents_tris, vmeta = honeycomb_region(wt + m, wt + m, W - wt - m, H - wt - m,
   1802                                              0.0, ft, P["vent_hex_flats"], P["vent_wall"], keep)
   1803 
   1804     # the floor caps get the vent spliced in as a NOTCH (keyhole) so they only bridge
   1805     # the corner screw holes (clr); the cells (vents_tris) fill the notch. The slit base
   1806     # is shared (via _with_base) with the skirt/walls/rim so there's no T-junction there.
   1807     slit_x = (vmeta[0] + (vmeta[4] // 2) * vmeta[6]) if vmeta else None
   1808     based = (lambda ol: _with_base(ol, slit_x)) if vmeta else (lambda ol: ol)
   1809     def floorcap(outline, z, updir):
   1810         ol = splice_vent_notch(outline, vmeta, vmeta[4] // 2) if vmeta else outline
   1811         return cap_with_holes(ol, clr, z, updir)
   1812     # v1.18: chamfer the exposed BACK face↔wall edge (z=0), mirroring the lid front.
   1813     # Clearance holes sit ~5.5mm in (well clear of a ≤1.5mm inset); the top rim (the
   1814     # lid seat) stays square.
   1815     # v1.20: a real USB-C through-slot — its STRAIGHT run is skipped in both the exterior
   1816     # and interior loop walls; in its place go two windowed faces (flat verticals carrying
   1817     # the slot hole) joined by a tunnel. The hole boundary stitches to the tunnel, the
   1818     # rectangle boundary to the surrounding walls/skirt/rim. v1.33: the wall is set by
   1819     # usb_wall — "+x" (right) skips the +x run; "-y" (bottom) skips the bottom run (and the
   1820     # rebuilt bottom faces re-thread the vent-slit base vertex so the floor-cap notch still
   1821     # stitches). eps>0 isolates the straight edge(s); adjacent corner-arc points fall short.
   1822     penta = usb_slot_profile(layout, W, H, ft)
   1823     eps = 1e-6
   1824     usb_w = P["usb_wall"] if layout == "B" else "+x"     # A (centered backup) stays on the +x wall
   1825     vw = P["vent_walls"] and layout == "B"               # hex-vent the −x/+x side walls (rebuilt below)
   1826     o_conds, i_conds = [], []                            # straight runs to SKIP, rebuilt as windowed/vented faces
   1827     if penta and usb_w == "-y":                          # USB on the bottom wall: skip the y≈0 / y≈wt runs
   1828         o_conds.append(lambda a, b: a[1] < eps and b[1] < eps)
   1829         i_conds.append(lambda a, b: a[1] < wt + eps and b[1] < wt + eps)
   1830     elif penta:                                          # USB on the +x/right wall (v1.32 / A)
   1831         o_conds.append(lambda a, b: a[0] > W - eps and b[0] > W - eps)
   1832         i_conds.append(lambda a, b: a[0] > (W - wt) - eps and b[0] > (W - wt) - eps)
   1833     if vw:                                               # +x and −x runs → vented faces (wall_vent)
   1834         o_conds.append(lambda a, b: a[0] > W - eps and b[0] > W - eps)
   1835         o_conds.append(lambda a, b: a[0] < eps and b[0] < eps)
   1836         i_conds.append(lambda a, b: a[0] > (W - wt) - eps and b[0] > (W - wt) - eps)
   1837         i_conds.append(lambda a, b: a[0] < wt + eps and b[0] < wt + eps)
   1838     skip_o = (lambda a, b: any(c(a, b) for c in o_conds)) if o_conds else None
   1839     skip_i = (lambda a, b: any(c(a, b) for c in i_conds)) if i_conds else None
   1840 
   1841     # v1.18: chamfer the exposed BACK face↔wall edge (z=0), mirroring the lid front.
   1842     # Clearance holes sit ~5.5mm in (well clear of a ≤1.5mm inset); the top rim (the
   1843     # lid seat) stays square.
   1844     c = P["chamfer_size"] if P["chamfer"] else 0.0
   1845     outer_b = based(outer)                               # bottom edge carries the shared slit base
   1846     inner_b = based(inner)
   1847     if 0 < c < ft:
   1848         outer_in = based(_chamfer_inset(W, H, r, seg, c))
   1849         tris  = floorcap(outer_in, 0.0, (0, 0, -1))             # inset underside floor + vent notch
   1850         tris += _skirt(outer_in, outer_b, 0.0, c, -1)           # 45° bevel
   1851         tris += _side_wall(outer_b, c, top, True, skip=skip_o)  # exterior walls above the bevel
   1852         ext_z0 = c
   1853     else:
   1854         tris  = floorcap(outer_b, 0.0, (0, 0, -1))              # underside floor + screws + vent notch
   1855         tris += _side_wall(outer_b, 0.0, top, True, skip=skip_o)  # exterior walls (full height)
   1856         ext_z0 = 0.0
   1857     tris += _ring_cap(outer_b, inner_b, top, (0, 0, 1))     # top rim — the lid rests here
   1858     tris += _side_wall(inner_b, ft, top, False, skip=skip_i)  # cavity inner walls
   1859     tris += floorcap(inner_b, ft, (0, 0, 1))                # cavity floor + screws + vent notch
   1860     for h in clr:
   1861         tris += _side_wall(h, 0.0, ft, False)            # clearance-hole walls (face into the hole)
   1862     tris += vents_tris                                   # honeycomb vent cells (fill the punched region)
   1863     if vw:                                               # hex vents through the −x/+x side walls (B)
   1864         tris += wall_vent(layout, W, H, "+x", ft, ext_z0, top, r, r_in)
   1865         tris += wall_vent(layout, W, H, "-x", ft, ext_z0, top, r, r_in)
   1866     if penta and usb_w == "+x":                          # USB-C slot replaces the skipped +x run
   1867         ext_rect = [(r, ext_z0), (H - r, ext_z0), (H - r, top), (r, top)]   # +x exterior face (y,z)
   1868         in_rect  = [(wt + r_in, ft), (H - wt - r_in, ft),                   # +x interior face (y,z)
   1869                     (H - wt - r_in, top), (wt + r_in, top)]
   1870         tris += _vwall_with_window(W, ext_rect, penta, +1)       # exterior face + hole (faces +x)
   1871         tris += _vwall_with_window(W - wt, in_rect, penta, -1)   # cavity face + hole (faces −x)
   1872         tris += usb_tunnel(penta, W, W - wt)                     # the slot passage walls
   1873     elif penta:                                          # "-y" bottom wall — faces in the (x,z) plane
   1874         def _yrect(xL, xR, zb):                          # CCW (x,z) face; vent-slit vertex on BOTH horizontal
   1875             s = slit_x if (slit_x is not None and xL + eps < slit_x < xR - eps) else None
   1876             bot  = [(xL, zb), (s, zb), (xR, zb)] if s is not None else [(xL, zb), (xR, zb)]     # ← floor cap/skirt
   1877             top_ = [(xR, top), (s, top), (xL, top)] if s is not None else [(xR, top), (xL, top)]  # ← ring rim
   1878             return bot + top_
   1879         ext_rect = _yrect(r, W - r, ext_z0)                         # −y exterior (x,z)
   1880         in_rect  = _yrect(wt + r_in, W - wt - r_in, ft)             # −y interior (x,z)
   1881         tris += _vwall_with_window(0.0, ext_rect, penta, -1, "y")   # exterior bottom face (faces −y)
   1882         tris += _vwall_with_window(wt, in_rect, penta, +1, "y")     # cavity bottom face (faces +y)
   1883         tris += usb_tunnel(penta, 0.0, wt, "y")                     # the slot passage walls
   1884     tris += fb_standoffs(layout, W, H, ft)               # FireBeetle M2 standoff bosses
   1885     tris += battery_collar(layout, W, H, ft)             # B-portrait: pouch retention collar (top-right)
   1886     if P["ear_back"]:
   1887         ex = (W - P["ear_w"]) / 2.0                      # centred lanyard tab
   1888         if P["ear_full_depth"]:
   1889             tris += lanyard_tab(ex, H, 0.0, P["plate_t"] + cavity_depth(layout))  # v1.48: end at the tray WALL TOP
   1890             #   (flush with the perimeter walls / lid seat) — no longer runs the extra plate_t to the front face
   1891         else:
   1892             tris += ear(ex, H, t)                        # legacy flat 2mm loop
   1893     # inner-floor marks (v1.50). B-portrait has a solid battery pocket + top/bottom solid
   1894     # strips (the vent field is inset by vent_margin), so all three land on solid floor with
   1895     # no vent surgery: the maker's-mark HOUSE fills the pocket (hidden under the cell); the
   1896     # build-number moves UP above the FireBeetle; the site TAG runs the bottom strip. Other
   1897     # layouts (no pocket) keep the legacy bottom-left build-number.
   1898     vstr = P["case_version"].lstrip("v") + layout
   1899     bfp = battery_footprint(layout, W, H)
   1900     if bfp:
   1901         # maker's house — centred in the pocket
   1902         if P["maker_mark"]:
   1903             hpx = P["maker_px"]
   1904             hw, hh = len(_MAKER_HOUSE[0]) * hpx, len(_MAKER_HOUSE) * hpx
   1905             hox = (bfp[0] + bfp[2]) / 2.0 - hw / 2.0
   1906             hoy = (bfp[1] + bfp[3]) / 2.0 - hh / 2.0
   1907             tris += pixel_art(_MAKER_HOUSE, hox, hoy, ft, ft + P["imprint_h"], hpx)
   1908         # build-number → top solid strip above the FB (vent field ends at H-wt-vent_margin),
   1909         # centred on the board so it reads over the mounting area.
   1910         fbx, _ = fb_board_center(layout, W, H)
   1911         ix = fbx - _imprint_run_len(vstr) / 2.0
   1912         iy = H - P["wall_t"] - 1.0 - P["imprint_digit_h"]
   1913         tris += imprint_text(vstr, ix, iy, ft)
   1914         # v1.51: the site-tag URL moved OFF this bottom strip (0.5mm px was unprintable) up to the
   1915         # LID's top band — see make_front. The pocket now carries just the house + build-number.
   1916     else:
   1917         tris += imprint_text(vstr, 12.0, 2.0, ft)
   1918     return tris
   1919 
   1920 
   1921 def est_weight_g(tris):
   1922     cc = mesh_volume_cc(tris)
   1923     density = _DENSITY_G_CC.get(P["material"], 1.27)
   1924     return cc * density * P["infill"], cc
   1925 
   1926 # ----------------------------------------------------------------------------
   1927 # main
   1928 # ----------------------------------------------------------------------------
   1929 
   1930 def main():
   1931     here = os.path.dirname(os.path.abspath(__file__))
   1932     out = os.path.join(here, "stl")
   1933     os.makedirs(out, exist_ok=True)
   1934 
   1935     ver = P["case_version"]
   1936     rev = git_rev()
   1937     print(f"DefCon badge enclosure — STL generator   case {ver}  (git {rev})")
   1938     print(f"material {P['material']} (~{_DENSITY_G_CC.get(P['material'], 1.27):.2f} g/cc, "
   1939           f"{P['infill']*100:.0f}% fill — weight est. only)")
   1940     _bore = P['boss_hole_insert'] if P['fastener'] == 'insert' else P['boss_hole_selftap']
   1941     print(f"lid fastener: M3 {P['fastener']} (boss Ø{2*P['boss_r']:.0f}mm, bore Ø{2*_bore:.1f}mm)")
   1942     print(f"display window: {P['oled_glass_w']+2*P['window_clearance']+P['window_extra_w']:.1f} x "
   1943           f"{P['oled_glass_h']+2*P['window_clearance']+P['window_extra_h']:.1f} mm (glass passthrough"
   1944           f"{', + guide ridges' if P['guide_ridges'] else ''})")
   1945     if P["btn_hole"]:
   1946         print(f"case button: Ø{P['btn_hole_d']:.1f} mm lid hole below the screen (Twidec PBS-110, "
   1947               f"fit-validated); auto-skipped where the band is too tight")
   1948     if P["oled_pegs"]:
   1949         print(f"oled mount: 4x Ø{P['oled_peg_d']:.1f} pegs @ {P['oled_mount_w']:.2f} x "
   1950               f"{P['oled_mount_h']:.2f} c2c + {P['oled_header_notch_w']:.0f}mm collar notch "
   1951               f"(header edge: screen-right)")
   1952     if P["fb_standoffs"]:
   1953         _orient_note = (f"{P['fb_orient']}, USB → {P['usb_wall']} wall (B); JST faces the battery side"
   1954                         if P['fb_orient'] == 'portrait' else "landscape, USB toward viewer-right")
   1955         print(f"fb standoffs: 4x M2 bosses Ø{2*P['fb_standoff_r']:.1f} (pilot Ø{2*P['fb_pilot_r']:.1f}) "
   1956               f"h={P['fb_standoff_h']:.1f} — components face the floor; {_orient_note}")
   1957     if P["chamfer"]:
   1958         print(f"edge chamfer: {P['chamfer_size']:.1f}mm 45° on the exposed faces "
   1959               f"(lid front + tray back); parting seam left square")
   1960     if P["usb_cutout"]:
   1961         _wall_name = {"-y": "−y/bottom", "+x": "+x/right"}.get(P['usb_wall'], P['usb_wall'])
   1962         print(f"usb-c cutout: {P['usb_slot_w']:.0f}x{P['usb_slot_h']:.1f}mm through-slot in the {_wall_name} wall (B) "
   1963               f"(z slaved to standoffs){', 45° self-supporting roof' if P['usb_roof_45'] else ''}")
   1964     if P["bat_collar"]:
   1965         _notch = f" + {P['bat_notch_w']:.0f}mm JST notch" if P["bat_notch"] else " (no notch)"
   1966         print(f"battery collar (B): full {P['bat_collar_h']:.0f}mm pocket for the {P['bat_w']:.0f}x{P['bat_h']:.0f} pouch, "
   1967               f"hard-right; clearance {P['bat_collar_clear_x']:.1f}/side (x) · {P['bat_collar_clear']:.1f}/side (y){_notch}")
   1968     if P["vent_walls"]:
   1969         print(f"side-wall vents (B): flat-top {P['vent_hex_flats']:.0f}mm hexes through the −x/+x walls, "
   1970               f"inset {P['vent_wall_margin']:.1f}mm border (support-free)")
   1971     if P["imprint"]:
   1972         _lid = f"maker URL '{P['maker_tag']}' @ {P['maker_tag_px']:.1f}mm px" if P["maker_tag"] else "7-seg version"
   1973         print(f"imprint: 7-seg '{P['case_version'].lstrip('v')}' + variant (A/B) on the TRAY inner face; {_lid} on the LID top band")
   1974     if P["front_text"]:
   1975         zt = [z for z in (("top", P["front_text_top"]), ("left", P["front_text_left"]),
   1976                           ("right", P["front_text_right"])) if z[1]]
   1977         print(f"front text: 5x7 pixels @ {P['front_text_px']:.1f}mm px, {P['front_text_h']:.1f}mm colour "
   1978               f"INLAY (→ *_front_text.stl, add as an AMS part) — " + ", ".join(f"{k}='{v}'" for k, v in zt))
   1979     print("=" * 58)
   1980     total_plastic = {"A": 0.0, "B": 0.0}
   1981     for layout in ("A", "B"):
   1982         W, H = plate_outline(layout)
   1983         depth = 2 * P["plate_t"] + cavity_depth(layout)
   1984         label = "Stacked (battery behind)" if layout == "A" else "Coplanar (battery beside)"
   1985         kind = "walled tray + flat lid" if P["walls"] else "flat sandwich"
   1986         print(f"\n[{layout}] {label}")
   1987         print(f"    outer plate : {W:.1f} x {H:.1f} mm  (+ {P['ear_h']:.0f}mm lanyard ears)")
   1988         print(f"    assembled   : ~{depth:.0f} mm deep  ({kind}; "
   1989               f"{cavity_depth(layout):.0f}mm cavity)")
   1990         roles = {"front": "lid", "back": "tray" if P["walls"] else "back", "front_text": "colour inlay"}
   1991         builders = [("front", make_front), ("back", make_back)]
   1992         if P["front_text"]:
   1993             builders.append(("front_text", make_front_text))   # separate AMS 2nd-filament part
   1994         for part, builder in builders:
   1995             tris = builder(layout)
   1996             if not tris:                                       # e.g. front_text with all zones blank
   1997                 continue
   1998             path = os.path.join(out, f"badge_{ver}_{layout}_{part}.stl")
   1999             write_stl(path, tris, f"DEFCON badge case {ver} {P['fastener']} | {layout}_{part}")
   2000             g, cc = est_weight_g(tris)
   2001             bx, by, bz = bbox(tris)
   2002             if part != "front_text":                           # the inlay overlaps the lid — not extra plastic
   2003                 total_plastic[layout] += g
   2004             issues = edge_manifold_issues(tris)
   2005             wt = "watertight" if issues == 0 else f"⚠ {issues} open edges"
   2006             tag = f"{part} ({roles[part]})"
   2007             print(f"    - {tag:<12} {bx:5.1f} x {by:5.1f} x {bz:4.1f} mm  "
   2008                   f"~{g:4.1f} g plastic  [{wt}]  ->  {os.path.relpath(path, here)}")
   2009         # add the fixed payload for a total badge weight guess
   2010         payload = 22 + 38 + 6 + 8   # oled + battery + firebeetle + fasteners
   2011         print(f"    est. TOTAL badge: ~{total_plastic[layout] + payload:.0f} g "
   2012               f"({total_plastic[layout]:.0f} g plastic + {payload} g payload)")
   2013 
   2014     # keep stl/ to the CURRENT version — sweep any prior-version STLs into stl_archive/
   2015     archive = os.path.join(here, "stl_archive")
   2016     moved = 0
   2017     for fn in sorted(os.listdir(out)):
   2018         if fn.startswith("badge_") and fn.endswith(".stl") and f"_{ver}_" not in fn:
   2019             os.makedirs(archive, exist_ok=True)
   2020             os.replace(os.path.join(out, fn), os.path.join(archive, fn))
   2021             moved += 1
   2022     if moved:
   2023         print(f"\narchived {moved} prior-version STL(s) → stl_archive/  (stl/ keeps {ver})")
   2024 
   2025     print("\nNote: LID = window + button hole + M3 bosses + OLED collar/notch/pegs +")
   2026     print("centred loop + imprint; TRAY = floor (screw-clearance holes) + walls + FB")
   2027     print("standoffs (B: portrait, top-left) + USB-C through-slot in the −y/bottom wall +")
   2028     print("a full battery-retention collar (B, top-right) + honeycomb vents in the back floor")
   2029     print("AND the −x/+x side walls (B). Back insertion (heads on the rear). Each part is")
   2030     print("checked for watertightness (every edge shared by exactly two triangles).")
   2031 
   2032 
   2033 if __name__ == "__main__":
   2034     main()