gen_caped_crusader.py (16174B)
1 #!/usr/bin/env python3 2 r""" 3 gen_caped_crusader.py — slide-on CAPED-CRUSADER frame for the badge case (accessory). 4 5 A fork of gen_cat_ears.py: the SAME proven retention chassis (a rectangular band that 6 rides the badge's perimeter walls, back-loaded, tied by an inward LEDGE ring behind the 7 back face), but the top rail carries a bat COWL (two sharp, close-set ears flanking the 8 lanyard-tab slot) and the two side edges bloom into bold, scalloped cape WINGS that sweep 9 OUTBOARD of the badge — so the OLED / buttons / vents on the front stay clear. 10 11 OUTLINE (looking at the badge face-on) 12 13 /\ /\ sharp close-set bat ears, either side of the 14 / \ / \ tab slot (the tab pokes up between them) 15 ____/ \____/ \____ 16 / ┌──────────────┐ \ wings sweep out past the badge outline 17 / ╲ │ │ ╱ \ 18 / ╲ ╲ │ badge W x H │ ╱ ╱ \ (badge face — kept clear) 19 \ ╲╲ │ │ ╱╱ / 20 \ ╲ └──────────────┘ ╱ / 21 \ ╱╲ ╱╲ / scalloped cape hem, outboard 22 \╱ ╲________________╱ ╲╱ 23 24 WHY THE FRAME MOUNT (not the tophat's tab-drop): the cape + cowl carry real mass and want 25 a robust anchor. The band grips the whole perimeter and bottoms out on the ledge — plenty 26 — and because the lanyard tab threads the top-rail SLOT (exactly as the cat-ear frame), the 27 tab's side bore is untouched: cord routing is unchanged, no crown-slot compromise. 28 29 COWL, NOT A BROW BRIDGE: the tab is full-depth, so the band stays a C (open at the slot) — 30 the two ears can't be joined by a brow across the band without fouling the tab. They're 31 tied instead by the LEDGE ring behind (unbroken across the top), same trick that keeps the 32 cat ears out of cantilever. The cowl read comes from the ears being sharp, tall and 33 close-set at the slot; a connecting front brow relief is a v0.2 refinement. 34 35 WINGS ARE OUTBOARD AND CHUNKY: each wing replaces the straight side edge with a convex 36 top sweep out to a wingtip, then a scalloped hem back down to the bottom corner. They're 37 extruded at band depth (bold, cartoon-superhero — not thin membrane, per the brief), solid 38 out to the tip, so the near-perimeter part still grips the badge and the rest cantilevers 39 from a thick, stiff root. 40 41 Badge/tab dimensions are IMPORTED from gen_stl; the pure mesh helpers (arc/fillet/cap/wall/ 42 extrude) are IMPORTED from gen_cat_ears so the watertight-concave-profile fixes are shared. 43 44 Print FLAT, ledge-side down — the ledge is the widest section and sits on the bed, so every 45 transition is an upward step: no supports. PETG. 46 47 Out: ./stl/accessories/caped_crusader_<ver>.stl 48 """ 49 50 import math 51 import os 52 import sys 53 54 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) 55 56 from gen_stl import ( # noqa: E402 57 P, plate_outline, cavity_depth, 58 ensure_ccw, rounded_rect, write_stl, bbox, edge_manifold_issues, 59 mesh_volume_cc, signed_area, _DENSITY_G_CC, git_rev, 60 ) 61 from gen_cat_ears import ( # noqa: E402 (shared pure helpers) 62 arc, fillet, _dedup, cap, wall, extrude, 63 ) 64 65 # ---------------------------------------------------------------------------- 66 # PARAMS — the accessory's own numbers. Everything about the BADGE is imported. 67 # ---------------------------------------------------------------------------- 68 VERSION = "v0.1" 69 LAYOUT = "B" 70 71 TOL = 0.25 # per-side slide clearance, frame ↔ badge wall 72 RAIL = 3.0 # frame border width (the grip, all four sides) 73 BAND_Z_WANT = 8.0 # how far the band WANTS to ride down the badge's sides 74 USB_CLEAR = 2.5 # keep the band's top edge this far below the USB slot 75 LEDGE_W = 2.5 # how far the back lip turns inward over the badge back face 76 LEDGE_Z = 1.2 # lip thickness 77 SLOT_TOL = 0.4 # per-side clearance around the lanyard tab in the top rail 78 79 # --- the bat cowl (two sharp ears flanking the tab slot) ---------------------- 80 EAR_BASE = 16.0 # ear base width (fatter) 81 EAR_OUT = 5.0 # gap from the slot corner to the ear's inner base — spreads the 82 # ears outboard (left/right) so they flank the tab more widely 83 EAR_H = 30.0 # ear height above the frame's outer top edge (tall, iconic) 84 EAR_TILT = 3.0 # apex offset OUTBOARD of the base centre (the ear lean) 85 EAR_TIP_R = 0.7 # tip rounding (small → sharp) 86 TIP_SEG = 6 87 88 # --- the cape (drapes below + around the badge, scalloped hem) ----------------- 89 WING_SPAN = 22.0 # how far the wingtip reaches beyond the frame side edge 90 WING_TIP_UP = 9.0 # wingtip height above the badge bottom (a low, wide sweep) 91 WING_LEAD_BULGE = 3.0 # convex bulge of the wing leading edge (shoulder → wingtip) 92 WING_LEAD_SEG = 12 93 CAPE_DROP = 16.0 # how far the cape hem hangs below the badge bottom (centre point) 94 SCALLOP_SAG = 3.5 # hem scoop depth (concave, toward the badge) 95 SCALLOP_SEG = 9 96 97 SEG = P["corner_seg"] 98 MATERIAL = P["material"] 99 INFILL = P["infill"] 100 101 # ---------------------------------------------------------------------------- 102 # DERIVED — badge geometry, straight from gen_stl. 103 # ---------------------------------------------------------------------------- 104 W, H = plate_outline(LAYOUT) 105 BADGE_D = 2 * P["plate_t"] + cavity_depth(LAYOUT) 106 BADGE_R = P["corner_r"] 107 CENTROID = (W / 2.0, H / 2.0) 108 109 USB_SLOT_Z0 = (P["plate_t"] + P["fb_standoff_h"] + P["usb_slot_top_margin"] 110 + P["usb_slot_z_offset"] - P["usb_slot_h"]) 111 BAND_Z = min(BAND_Z_WANT, USB_SLOT_Z0 - USB_CLEAR) if P["usb_cutout"] else BAND_Z_WANT 112 113 TAB_X0 = (W - P["ear_w"]) / 2.0 114 TAB_X1 = TAB_X0 + P["ear_w"] 115 SLOT_X0, SLOT_X1 = TAB_X0 - SLOT_TOL, TAB_X1 + SLOT_TOL 116 117 # ear base corners in x: inner base sits EAR_OUT outboard of the slot corner, then the 118 # base runs a further EAR_BASE outward. 119 EARL_IN = SLOT_X0 - EAR_OUT # left ear inner base 120 EARL_OUT = EARL_IN - EAR_BASE # left ear outer base 121 EARR_IN = SLOT_X1 + EAR_OUT # right ear inner base 122 EARR_OUT = EARR_IN + EAR_BASE # right ear outer base 123 124 IX0, IY0 = -TOL, -TOL 125 IX1, IY1 = W + TOL, H + TOL 126 IR = BADGE_R + TOL 127 OX0, OY0 = IX0 - RAIL, IY0 - RAIL 128 OX1, OY1 = IX1 + RAIL, IY1 + RAIL 129 OR_ = IR + RAIL 130 131 LX0, LY0 = LEDGE_W, LEDGE_W 132 LX1, LY1 = W - LEDGE_W, H - LEDGE_W 133 LR = max(0.5, BADGE_R - LEDGE_W) 134 135 Z_LEDGE0, Z_LEDGE1 = 0.0, LEDGE_Z 136 Z_BAND0, Z_BAND1 = LEDGE_Z - 0.4, LEDGE_Z + BAND_Z 137 138 OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stl", "accessories") 139 140 141 # ---------------------------------------------------------------------------- 142 # the bat cowl — sharp ears whose inner base sits ON the tab-slot corner 143 # ---------------------------------------------------------------------------- 144 145 def _ear_apex(side): 146 """Apex point of one ear. side=-1 left, +1 right; base centre from the base corners.""" 147 base_c = (EARL_IN + EARL_OUT) / 2.0 if side < 0 else (EARR_IN + EARR_OUT) / 2.0 148 return (base_c + side * EAR_TILT, OY1 + EAR_H) 149 150 151 def left_ear_leftward(): 152 """Left ear on the band's left-going walk: inner base → sharp tip → outer base. 153 Returns the run inclusive of both base corners.""" 154 inner = (EARL_IN, OY1) 155 outer = (EARL_OUT, OY1) 156 return [inner] + fillet(inner, _ear_apex(-1), outer, EAR_TIP_R, TIP_SEG) + [outer] 157 158 159 def right_ear_toward_slot(): 160 """Right ear on the band's right-return walk: outer base → sharp tip → inner base.""" 161 outer = (EARR_OUT, OY1) 162 inner = (EARR_IN, OY1) 163 return [outer] + fillet(outer, _ear_apex(+1), inner, EAR_TIP_R, TIP_SEG) + [inner] 164 165 166 # ---------------------------------------------------------------------------- 167 # the cape wings — convex top sweep + scalloped hem, outboard 168 # ---------------------------------------------------------------------------- 169 170 def _outward(p): 171 """Unit vector from the badge centroid to p (points away from the badge).""" 172 dx, dy = p[0] - CENTROID[0], p[1] - CENTROID[1] 173 L = math.hypot(dx, dy) or 1.0 174 return (dx / L, dy / L) 175 176 177 def _convex_sweep(p0, p1, bulge, seg): 178 """p0→p1 bulged AWAY from the badge centroid by `bulge` (a bold wing leading edge). 179 Includes p0, excludes p1.""" 180 out = [] 181 for s in range(seg): 182 t = s / seg 183 mx = p0[0] + (p1[0] - p0[0]) * t 184 my = p0[1] + (p1[1] - p0[1]) * t 185 ox, oy = _outward((mx, my)) 186 b = bulge * math.sin(math.pi * t) 187 out.append((mx + ox * b, my + oy * b)) 188 return out 189 190 191 def _hem_through(tips, sag, seg): 192 """Connect a list of hem tips with concave scoops, each scoop curving toward the 193 badge centroid → a scalloped cape edge with pointed tips. Includes tips[0], 194 excludes tips[-1] (the caller adds it).""" 195 out = [] 196 for i in range(len(tips) - 1): 197 p0, p1 = tips[i], tips[i + 1] 198 mx, my = (p0[0] + p1[0]) / 2.0, (p0[1] + p1[1]) / 2.0 199 ox, oy = _outward((mx, my)) # scoop dips toward the badge (−outward) 200 for s in range(seg): 201 t = s / seg 202 sx = p0[0] + (p1[0] - p0[0]) * t 203 sy = p0[1] + (p1[1] - p0[1]) * t 204 dip = sag * math.sin(math.pi * t) 205 out.append((sx - ox * dip, sy - oy * dip)) 206 return out 207 208 209 def lower_cape(): 210 """The cape, CCW (the lower way round): left shoulder (OX0,OY1) → convex leading 211 edge → left wingtip (low, wide) → scalloped hem draping BELOW the badge → right 212 wingtip → convex leading edge → right shoulder (OX1,OY1). The hem's centre tip hangs 213 lowest for a classic caped point.""" 214 LW = (OX0 - WING_SPAN, OY0 + WING_TIP_UP) 215 RW = (OX1 + WING_SPAN, OY0 + WING_TIP_UP) 216 pts = _convex_sweep((OX0, OY1), LW, WING_LEAD_BULGE, WING_LEAD_SEG) + [LW] 217 hem_tips = [LW, 218 (W * 0.28, OY0 - CAPE_DROP * 0.85), 219 (W * 0.50, OY0 - CAPE_DROP * 1.30), 220 (W * 0.72, OY0 - CAPE_DROP * 0.85), 221 RW] 222 pts += _hem_through(hem_tips, SCALLOP_SAG, SCALLOP_SEG) 223 pts += [RW] 224 pts += _convex_sweep(RW, (OX1, OY1), WING_LEAD_BULGE, WING_LEAD_SEG) + [(OX1, OY1)] 225 return pts 226 227 228 # ---------------------------------------------------------------------------- 229 # frame profiles 230 # ---------------------------------------------------------------------------- 231 232 def inner_walk_cw(x_start, x_end): 233 """Inner opening (badge) traversed CW — the return leg of the C, material on the left.""" 234 pts = [(x_start, IY1), (IX1 - IR, IY1)] 235 pts += arc(IX1 - IR, IY1 - IR, IR, 90, 0) 236 pts += arc(IX1 - IR, IY0 + IR, IR, 0, -90) 237 pts += [(IX0 + IR, IY0)] 238 pts += arc(IX0 + IR, IY0 + IR, IR, 270, 180) 239 pts += arc(IX0 + IR, IY1 - IR, IR, 180, 90) 240 pts += [(x_end, IY1)] 241 return pts 242 243 244 def band_outer(): 245 """Outer boundary of the band, CCW, from the slot-left corner (SLOT_X0,OY1) all 246 the way round (left ear, left wing, bottom, right wing, right ear) to the 247 slot-right corner (SLOT_X1,OY1). The slot itself is closed by band_profile.""" 248 p = left_ear_leftward() # (SLOT_X0,OY1) → tip → (SLOT_X0-EAR_BASE,OY1) 249 p += lower_cape() # (implicit brow to left shoulder) → cape → right shoulder 250 p += right_ear_toward_slot() # (implicit brow to right ear) → (SLOT_X1,OY1) 251 return p 252 253 254 def band_profile(): 255 """The band cross-section: a C, the top rail cut by the tab slot → one CCW polygon.""" 256 pts = [(SLOT_X0, OY1)] 257 pts += band_outer() 258 pts += [(SLOT_X1, OY1), (SLOT_X1, IY1)] # down the slot's right wall 259 pts += inner_walk_cw(SLOT_X1, SLOT_X0) 260 pts += [(SLOT_X0, IY1)] # up the slot's left wall 261 poly = ensure_ccw(_dedup(pts)) 262 assert signed_area(poly) > 0 263 return poly 264 265 266 def cowl_top_run_left(): 267 """The unbroken cowl top for the LEDGE ring, moving LEFT: right ear (outer→tip→inner) 268 → flat across the slot → left ear (inner→tip→outer). This ties the two ear stubs 269 together behind the back face (they are NOT cantilevers).""" 270 p = [(EARR_OUT, OY1)] 271 p += fillet((EARR_OUT, OY1), _ear_apex(+1), (EARR_IN, OY1), EAR_TIP_R, TIP_SEG) 272 p += [(EARR_IN, OY1), (EARL_IN, OY1)] # flat across the cowl (ties the ear stubs) 273 p += fillet((EARL_IN, OY1), _ear_apex(-1), (EARL_OUT, OY1), EAR_TIP_R, TIP_SEG) 274 p += [(EARL_OUT, OY1)] 275 return p 276 277 278 def ledge_profile(): 279 """The retaining lip: a plain perimeter RING (no wings) with the cowl ears on top, 280 behind the back face. Wings are band-only; the ledge just retains + ties the ears.""" 281 pts = [(OX1 - OR_, OY1)] 282 pts += cowl_top_run_left() # both ears + flat top, moving left 283 pts += arc(OX0 + OR_, OY1 - OR_, OR_, 90, 180) # top-left 284 pts += arc(OX0 + OR_, OY0 + OR_, OR_, 180, 270) # bottom-left 285 pts += [(OX1 - OR_, OY0)] # bottom edge 286 pts += arc(OX1 - OR_, OY0 + OR_, OR_, -90, 0) # bottom-right 287 pts += arc(OX1 - OR_, OY1 - OR_, OR_, 0, 90) # top-right 288 outer = ensure_ccw(_dedup(pts)) 289 hole = list(reversed(ensure_ccw(rounded_rect(LX0, LY0, LX1, LY1, LR, SEG)))) 290 return outer, hole 291 292 293 # ---------------------------------------------------------------------------- 294 # build 295 # ---------------------------------------------------------------------------- 296 297 def make_frame(): 298 tris = [] 299 expect = 0.0 300 301 l_outer, l_hole = ledge_profile() 302 tris += extrude(l_outer, [l_hole], Z_LEDGE0, Z_LEDGE1) 303 expect += (signed_area(l_outer) + signed_area(l_hole)) * (Z_LEDGE1 - Z_LEDGE0) 304 305 band = band_profile() 306 tris += extrude(band, [], Z_BAND0, Z_BAND1) 307 expect += signed_area(band) * (Z_BAND1 - Z_BAND0) 308 309 return tris, expect 310 311 312 def main(): 313 os.makedirs(OUT_DIR, exist_ok=True) 314 tris, expect_mm3 = make_frame() 315 316 # invariant: the mesh's signed volume must match the analytic one (ledge + band, each 317 # a closed solid; their overlap is double-counted identically on both sides). A flipped 318 # wall normal or a fan-tiled concave cap fails this by a mile. 319 vol = mesh_volume_cc(tris) 320 assert abs(vol * 1000.0 - expect_mm3) <= 0.02 * abs(expect_mm3), ( 321 "mesh volume %.1f mm^3 != analytic %.1f mm^3 — check cap/wall normals" 322 % (vol * 1000.0, expect_mm3)) 323 324 path = os.path.join(OUT_DIR, "caped_crusader_%s.stl" % VERSION) 325 write_stl(path, tris, "badge caped-crusader frame %s / case %s" % (VERSION, P["case_version"])) 326 327 bw, bh, bd = bbox(tris) 328 grams = vol * _DENSITY_G_CC[MATERIAL] * INFILL # approx (overlap double-counts) 329 bad = edge_manifold_issues(tris) 330 331 print("caped-crusader frame %s (badge case %s, layout %s, git %s)" % ( 332 VERSION, P["case_version"], LAYOUT, git_rev() or "?")) 333 print(" badge it wraps %.1f x %.1f x %.1f mm" % (W, H, BADGE_D)) 334 print(" frame envelope %.1f x %.1f x %.1f mm (%d tris)" % (bw, bh, bd, len(tris))) 335 print(" bat cowl ears base %.0f, height %.0f, tip r%.1f, spread %.0f outboard of the slot" % ( 336 EAR_BASE, EAR_H, EAR_TIP_R, EAR_OUT)) 337 print(" cape span %.0f outboard, hem drops %.0f below the badge, sag %.1f" % ( 338 WING_SPAN, CAPE_DROP, SCALLOP_SAG)) 339 print(" band ride depth %.1f mm%s" % ( 340 BAND_Z, " <- clamped from %.1f (USB slot at z=%.1f, keep %.1f clear)" 341 % (BAND_Z_WANT, USB_SLOT_Z0, USB_CLEAR) if BAND_Z < BAND_Z_WANT else "")) 342 print(" tab slot x %.1f..%.1f (tab %.1f..%.1f + %.2f/side)" % ( 343 SLOT_X0, SLOT_X1, TAB_X0, TAB_X1, SLOT_TOL)) 344 print(" material %s @ %.0f%% -> ~%.1f cc, ~%.0f g (approx, overlap-composed)" % ( 345 MATERIAL, INFILL * 100, vol, grams)) 346 print(" manifold %s" % ("OK (closed)" if bad == 0 else 347 "%d open edges (overlap-composed: ledge/band union)" % bad)) 348 print(" -> %s" % path) 349 350 351 if __name__ == "__main__": 352 main()