onlyn00bs-badge

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

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