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()