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_cat_ears.py (22346B)


      1 #!/usr/bin/env python3
      2 r"""
      3 gen_cat_ears.py — slide-on CAT EAR frame for the badge case (accessory).
      4 
      5 A rectangular frame that mirrors the badge outline plus a slide tolerance, with two
      6 pointed (round-tipped) ears rising off the top rail, one either side of the centred
      7 lanyard tab. It loads from the BACK and rides the perimeter walls.
      8 
      9     OUTLINE (looking at the badge face-on)
     10 
     11            /\                              /\
     12           /  \        ┌─────┐             /  \        ears, on the top-rail stubs
     13          /    \       │ tab │            /    \
     14       ┌─┴──────┴──────┤     ├───────────┴──────┴─┐
     15       │ ╔═════════════╧═════╧══════════════════╗ │    slot: the lanyard tab passes
     16       │ ║                                      ║ │    through, keying the frame in XY
     17       │ ║        badge outline  W x H          ║ │
     18       │ ╚══════════════════════════════════════╝ │
     19       └──────────────────────────────────────────┘
     20 
     21     SECTION (the badge's depth axis runs left→right)
     22 
     23        ledge          band
     24       ┌────┐──────────────────┐
     25       │////│//////////////////│                 // = frame
     26       │////└──────────────────┴─────────────────────────┐
     27       │    ┌────────────────────────────────────────────┤
     28       └────┤   badge:  tray ─────────────── lid         │
     29      z=-LZ │                                            │
     30           z=0                                        z=BADGE_D
     31       (badge back face)
     32 
     33 WHY IT LOADS FROM THE BACK: the lanyard tab (gen_stl `lanyard_tab`) is FULL DEPTH on
     34 the tray — it spans badge z 0→(plate_t + cavity). A frame coming from the front would
     35 foul it at the wall top; coming from the back, the tab simply threads through the top
     36 rail's slot. That slot is also the anti-rotation key.
     37 
     38 RETENTION: the LEDGE — the last LEDGE_Z mm of the part turn inward past the badge
     39 outline, so the frame bottoms out flush against the tray's back face instead of
     40 sliding on through. Because the ledge ends up BEHIND the back face (badge z < 0) it
     41 never has to clear the tab, so it stays a closed ring: the two top-rail stubs are
     42 tied together at the back and the ears are not cantilevers. It also lands 1.5mm of
     43 flat contact inboard of the case's 1mm back chamfer, and still clears the M3 screw
     44 counterbores (which start 3.8mm in) and the back vent field (11mm in).
     45 
     46 BAND DEPTH IS DERIVED, NOT CHOSEN: the −y wall carries the USB-C slot, whose bottom
     47 edge sits at `USB_SLOT_Z0` above the back face. A rail standing proud of that wall
     48 anywhere near the port would hold a cable's overmould off the case, so BAND_Z is
     49 clamped to USB_SLOT_Z0 − USB_CLEAR. Move the slot in gen_stl and this follows.
     50 
     51 Badge dimensions are IMPORTED from gen_stl (plate_outline / cavity_depth / P), so the
     52 frame can never drift out of sync with the case it wraps.
     53 
     54 Print FLAT, ledge-side down — the ledge is the widest section and it's on the bed, so
     55 every transition is an upward step: no supports, no bridges. PETG.
     56 
     57 Out: ./stl/accessories/cat_ears_<ver>.stl
     58 """
     59 
     60 import math
     61 import os
     62 import sys
     63 
     64 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
     65 
     66 from gen_stl import (                                    # noqa: E402
     67     P, plate_outline, cavity_depth,
     68     ensure_ccw, rounded_rect, _bridge_holes, _triangulate, _tri_area2, _orient,
     69     write_stl, bbox, edge_manifold_issues, mesh_volume_cc, signed_area,
     70     _DENSITY_G_CC, git_rev,
     71 )
     72 
     73 # ----------------------------------------------------------------------------
     74 # PARAMS — the accessory's own numbers. Everything about the BADGE is imported.
     75 # ----------------------------------------------------------------------------
     76 VERSION = "v1.0"
     77 LAYOUT = "B"               # the production layout
     78 
     79 TOL = 0.25                 # per-side slide clearance, frame ↔ badge wall. 0.25 is a
     80                            # push fit in PETG at 0.2 layers; go 0.35 if it binds.
     81 RAIL = 3.0                 # frame border width (the visible edge, all four sides)
     82 BAND_Z_WANT = 8.0          # how far the band WANTS to ride down the badge's sides
     83 USB_CLEAR = 2.5            # keep the band's top edge this far below the USB slot
     84 
     85 LEDGE_W = 2.5              # how far the back lip turns inward over the badge back face
     86                            #   (1mm of that sits over the case's back chamfer)
     87 LEDGE_Z = 1.2              # lip thickness — how far the frame stands proud of the back
     88 
     89 SLOT_TOL = 0.4             # per-side clearance around the lanyard tab in the top rail
     90 
     91 EAR_GAP = 1.0              # ear's outer base corner, inboard of the top edge's straight run
     92 EAR_BASE = 26.0            # ear base width
     93 EAR_H = 20.0               # ear height above the frame's outer top edge
     94 EAR_TILT = 3.0             # apex offset OUTBOARD of the base centre (the feline lean)
     95 EAR_TIP_R = 2.5            # tip rounding
     96 
     97 EAR_RECESS = True          # recessed inner-ear on the front-facing side (2nd-colour swap)
     98 EAR_RECESS_D = 0.6         # recess depth — matches front_text_h, so the same filament
     99                            #   change trick as the lid lettering works here
    100 EAR_RECESS_INSET = 3.5     # wall left between the ear outline and the recess
    101 EAR_RECESS_TIP_R = 1.6
    102 
    103 SEG = P["corner_seg"]      # arc resolution — mirrors the badge's own corners
    104 TIP_SEG = 10
    105 MATERIAL = P["material"]
    106 INFILL = P["infill"]
    107 
    108 # ----------------------------------------------------------------------------
    109 # DERIVED — badge geometry, straight from gen_stl. Nothing here is a magic number.
    110 # ----------------------------------------------------------------------------
    111 W, H = plate_outline(LAYOUT)                     # badge face, mm
    112 BADGE_D = 2 * P["plate_t"] + cavity_depth(LAYOUT)  # assembled depth
    113 BADGE_R = P["corner_r"]
    114 
    115 # USB-C slot bottom edge, above the back face — the same expression gen_stl's
    116 # usb_slot_profile uses for z1, minus the slot height.
    117 USB_SLOT_Z0 = (P["plate_t"] + P["fb_standoff_h"] + P["usb_slot_top_margin"]
    118                + P["usb_slot_z_offset"] - P["usb_slot_h"])
    119 BAND_Z = min(BAND_Z_WANT, USB_SLOT_Z0 - USB_CLEAR) if P["usb_cutout"] else BAND_Z_WANT
    120 
    121 # the lanyard tab we have to thread: centred, ear_w wide, sitting above the top edge
    122 TAB_X0 = (W - P["ear_w"]) / 2.0
    123 TAB_X1 = TAB_X0 + P["ear_w"]
    124 SLOT_X0, SLOT_X1 = TAB_X0 - SLOT_TOL, TAB_X1 + SLOT_TOL
    125 
    126 # frame loops, in badge face coordinates (badge spans x 0..W, y 0..H)
    127 IX0, IY0 = -TOL, -TOL                            # inner opening = badge + tolerance
    128 IX1, IY1 = W + TOL, H + TOL
    129 IR = BADGE_R + TOL
    130 OX0, OY0 = IX0 - RAIL, IY0 - RAIL                # outer edge
    131 OX1, OY1 = IX1 + RAIL, IY1 + RAIL
    132 OR_ = IR + RAIL
    133 
    134 LX0, LY0 = LEDGE_W, LEDGE_W                      # ledge opening = badge − LEDGE_W
    135 LX1, LY1 = W - LEDGE_W, H - LEDGE_W
    136 LR = max(0.5, BADGE_R - LEDGE_W)
    137 
    138 # z stack, part-local (bed at 0). The ledge starts below so the two solids OVERLAP
    139 # rather than meeting on a coincident face — compose-by-overlap, as gen_stl does for
    140 # the bosses and the lanyard tab.
    141 Z_LEDGE0, Z_LEDGE1 = 0.0, LEDGE_Z
    142 Z_BAND0, Z_BAND1 = LEDGE_Z - 0.4, LEDGE_Z + BAND_Z
    143 
    144 OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stl", "accessories")
    145 
    146 
    147 # ----------------------------------------------------------------------------
    148 # 2D helpers
    149 # ----------------------------------------------------------------------------
    150 
    151 def arc(cx, cy, r, a0, a1, seg=SEG):
    152     """Points along a circular arc, degrees, both ends inclusive."""
    153     return [(cx + r * math.cos(math.radians(a0 + (a1 - a0) * s / seg)),
    154              cy + r * math.sin(math.radians(a0 + (a1 - a0) * s / seg)))
    155             for s in range(seg + 1)]
    156 
    157 
    158 def fillet(prev_p, v, next_p, r, seg):
    159     """Round the corner at `v` between the edges v→prev_p and v→next_p. Returns the
    160     arc from the prev-side tangent point to the next-side tangent point, so a walk
    161     reads [..., prev_p] + fillet(prev_p, v, next_p, ...) + [next_p, ...] and `v`
    162     itself drops out. Sweep direction follows the shorter way round, so it works for
    163     a corner traversed either handedness."""
    164     ux, uy = prev_p[0] - v[0], prev_p[1] - v[1]
    165     vx, vy = next_p[0] - v[0], next_p[1] - v[1]
    166     lu, lv = math.hypot(ux, uy), math.hypot(vx, vy)
    167     if lu < 1e-9 or lv < 1e-9:
    168         return [v]
    169     ux, uy, vx, vy = ux / lu, uy / lu, vx / lv, vy / lv
    170     half = math.acos(max(-1.0, min(1.0, ux * vx + uy * vy))) / 2.0
    171     if half < 1e-6 or half > math.pi / 2 - 1e-6:
    172         return [v]
    173     t = r / math.tan(half)
    174     if t > 0.98 * min(lu, lv):                    # radius too big for these edges
    175         return [v]
    176     bx, by = ux + vx, uy + vy
    177     lb = math.hypot(bx, by)
    178     if lb < 1e-9:
    179         return [v]
    180     c = (v[0] + bx / lb * (r / math.sin(half)), v[1] + by / lb * (r / math.sin(half)))
    181     t1 = (v[0] + ux * t, v[1] + uy * t)
    182     t2 = (v[0] + vx * t, v[1] + vy * t)
    183     a1 = math.atan2(t1[1] - c[1], t1[0] - c[0])
    184     a2 = math.atan2(t2[1] - c[1], t2[0] - c[0])
    185     d = (a2 - a1) % (2 * math.pi)
    186     if d > math.pi:
    187         d -= 2 * math.pi
    188     return [(c[0] + r * math.cos(a1 + d * s / seg), c[1] + r * math.sin(a1 + d * s / seg))
    189             for s in range(seg + 1)]
    190 
    191 
    192 def _dedup(poly, eps=1e-7):
    193     """Drop consecutive (and wrap-around) duplicate points."""
    194     out = []
    195     for p in poly:
    196         if not out or math.hypot(p[0] - out[-1][0], p[1] - out[-1][1]) > eps:
    197             out.append(p)
    198     while len(out) > 1 and math.hypot(out[0][0] - out[-1][0], out[0][1] - out[-1][1]) <= eps:
    199         out.pop()
    200     return out
    201 
    202 
    203 # ----------------------------------------------------------------------------
    204 # the ears
    205 # ----------------------------------------------------------------------------
    206 
    207 def ear_triangle(side):
    208     """The three defining points of one ear, as (base_outer, base_inner, apex).
    209     `side` is -1 for the left ear, +1 for the right. The base sits ON the frame's
    210     outer top edge; the apex leans OUTBOARD by EAR_TILT."""
    211     if side < 0:
    212         bx_out = OX0 + OR_ + EAR_GAP                 # clear of the corner arc
    213         bx_in = bx_out + EAR_BASE
    214     else:
    215         bx_out = OX1 - OR_ - EAR_GAP
    216         bx_in = bx_out - EAR_BASE
    217     apex_x = (bx_out + bx_in) / 2.0 + side * EAR_TILT
    218     return ((bx_out, OY1), (bx_in, OY1), (apex_x, OY1 + EAR_H))
    219 
    220 
    221 def ear_run(side):
    222     """The ear's contribution to a top-edge walk travelling RIGHT→LEFT (the CCW
    223     direction along the top). Returns points from the first base corner reached to
    224     the second, going up one side, round the tip, and down the other."""
    225     base_out, base_in, apex = ear_triangle(side)
    226     first, second = (base_in, base_out) if side < 0 else (base_out, base_in)
    227     return [first] + fillet(first, apex, second, EAR_TIP_R, TIP_SEG) + [second]
    228 
    229 
    230 def ear_recess_loop(side):
    231     """CCW loop of the inner-ear recess: the ear triangle inset by EAR_RECESS_INSET,
    232     with a rounded tip. Convex, so it caps with a plain fan."""
    233     tri = ear_triangle(side)
    234     cx = sum(p[0] for p in tri) / 3.0
    235     cy = sum(p[1] for p in tri) / 3.0
    236     inset = []
    237     for i, v in enumerate(tri):
    238         a, b = tri[(i + 1) % 3], tri[(i + 2) % 3]
    239         ux, uy = a[0] - v[0], a[1] - v[1]
    240         vx, vy = b[0] - v[0], b[1] - v[1]
    241         lu, lv = math.hypot(ux, uy), math.hypot(vx, vy)
    242         ux, uy, vx, vy = ux / lu, uy / lu, vx / lv, vy / lv
    243         half = math.acos(max(-1.0, min(1.0, ux * vx + uy * vy))) / 2.0
    244         bx, by = ux + vx, uy + vy
    245         lb = math.hypot(bx, by)
    246         inset.append((v[0] + bx / lb * (EAR_RECESS_INSET / math.sin(half)),
    247                       v[1] + by / lb * (EAR_RECESS_INSET / math.sin(half))))
    248     b_out, b_in, apex = inset
    249     loop = [b_out, b_in] + fillet(b_in, apex, b_out, EAR_RECESS_TIP_R, TIP_SEG)
    250     loop = _dedup(loop)
    251     # sanity: the recess must stay inside the ear and above the rail
    252     assert min(p[1] for p in loop) > OY1 + 0.1, "recess base dips into the top rail"
    253     assert cy > 0
    254     return ensure_ccw(loop)
    255 
    256 
    257 # ----------------------------------------------------------------------------
    258 # the frame profiles
    259 # ----------------------------------------------------------------------------
    260 
    261 def _top_run(x_from, x_to):
    262     """Walk the OUTER top edge leftward from x_from to x_to, emitting any ear whose
    263     base falls inside the span. Endpoints are emitted by the caller's arcs/dives."""
    264     pts = []
    265     for side in (+1, -1):                            # right ear first (higher x)
    266         base_out, base_in, _ = ear_triangle(side)
    267         lo, hi = sorted((base_out[0], base_in[0]))
    268         if x_to <= lo and hi <= x_from:
    269             pts += ear_run(side)
    270     return pts
    271 
    272 
    273 def outer_walk(x_start, x_end):
    274     """The frame's outer boundary, CCW, from (x_start, OY1) on the top edge all the
    275     way round to (x_end, OY1). With x_start > x_end it is a closed loop's worth of
    276     travel; the caller supplies the two endpoints and whatever joins them."""
    277     pts = _top_run(x_start, OX0 + OR_)
    278     pts += arc(OX0 + OR_, OY1 - OR_, OR_, 90, 180)         # top-left
    279     pts += arc(OX0 + OR_, OY0 + OR_, OR_, 180, 270)        # bottom-left
    280     pts += [(OX1 - OR_, OY0)]                              # bottom edge, L→R
    281     pts += arc(OX1 - OR_, OY0 + OR_, OR_, -90, 0)          # bottom-right
    282     pts += arc(OX1 - OR_, OY1 - OR_, OR_, 0, 90)           # top-right
    283     pts += _top_run(OX1 - OR_, x_end)
    284     return pts
    285 
    286 
    287 def inner_walk_cw(x_start, x_end):
    288     """The inner opening traversed CLOCKWISE from (x_start, IY1) on its top edge
    289     round to (x_end, IY1) — the return leg of the C profile, so material stays on
    290     the left of travel throughout."""
    291     pts = [(x_start, IY1), (IX1 - IR, IY1)]
    292     pts += arc(IX1 - IR, IY1 - IR, IR, 90, 0)              # top-right, CW
    293     pts += arc(IX1 - IR, IY0 + IR, IR, 0, -90)             # bottom-right, CW
    294     pts += [(IX0 + IR, IY0)]                               # bottom edge, R→L
    295     pts += arc(IX0 + IR, IY0 + IR, IR, 270, 180)           # bottom-left, CW
    296     pts += arc(IX0 + IR, IY1 - IR, IR, 180, 90)            # top-left, CW
    297     pts += [(x_end, IY1)]
    298     return pts
    299 
    300 
    301 def band_profile():
    302     """The sliding band's cross-section: a C — the top rail is cut through by the
    303     lanyard-tab slot, so outer and inner boundaries join into ONE simple CCW polygon
    304     with no hole."""
    305     pts = [(SLOT_X0, OY1)]
    306     pts += outer_walk(SLOT_X0, SLOT_X1)
    307     pts += [(SLOT_X1, OY1), (SLOT_X1, IY1)]                # down the slot's right wall
    308     pts += inner_walk_cw(SLOT_X1, SLOT_X0)
    309     pts += [(SLOT_X0, IY1)]                                # up the slot's left wall
    310     poly = ensure_ccw(_dedup(pts))
    311     assert signed_area(poly) > 0
    312     return poly
    313 
    314 
    315 def ledge_profile():
    316     """The retaining lip's cross-section: a plain RING. It sits entirely behind the
    317     badge's back face, so the tab never reaches it and the top rail stays unbroken —
    318     which is what stops the ear stubs from being cantilevers."""
    319     pts = [(OX1 - OR_, OY1)]
    320     pts += _top_run(OX1 - OR_, OX0 + OR_)
    321     pts += arc(OX0 + OR_, OY1 - OR_, OR_, 90, 180)
    322     pts += arc(OX0 + OR_, OY0 + OR_, OR_, 180, 270)
    323     pts += [(OX1 - OR_, OY0)]
    324     pts += arc(OX1 - OR_, OY0 + OR_, OR_, -90, 0)
    325     pts += arc(OX1 - OR_, OY1 - OR_, OR_, 0, 90)
    326     outer = ensure_ccw(_dedup(pts))
    327     hole = list(reversed(ensure_ccw(rounded_rect(LX0, LY0, LX1, LY1, LR, SEG))))
    328     return outer, hole
    329 
    330 
    331 # ----------------------------------------------------------------------------
    332 # build
    333 # ----------------------------------------------------------------------------
    334 
    335 def cap(outer, holes, z, updir):
    336     """Flat cap of a CCW outer loop with CW holes. Unlike gen_stl's cap_with_holes we
    337     NEVER fall back to a fan: every profile here is concave (the ear bulges, the tab
    338     slot, the C), and a fan would quietly tile the convex hull — which is exactly the
    339     bug this function exists to prevent. The area assertion is the guard: a cap that
    340     doesn't sum to its polygon's own area is a broken cap, full stop."""
    341     merged = _dedup(_bridge_holes(list(outer), holes) if holes else list(outer))
    342     tris2d = _triangulate(merged)
    343     want = abs(signed_area(merged))
    344     got = sum(abs(_tri_area2(a, b, c)) for a, b, c in tris2d) / 2.0
    345     assert abs(got - want) <= 1e-3 * max(1.0, want), (
    346         "cap triangulation lost area: %.2f vs %.2f mm^2" % (got, want))
    347     return [_orient((a[0], a[1], z), (b[0], b[1], z), (c[0], c[1], z), updir)
    348             for a, b, c in tris2d]
    349 
    350 
    351 def wall(loop, z0, z1):
    352     """Vertical wall around a closed loop, z0→z1, with normals taken from the EDGE
    353     DIRECTION: material sits to the left of travel, so outward is (dy, −dx).
    354 
    355     gen_stl's _side_wall points normals away from the loop's CENTROID instead, which
    356     is fine for the simple rings it was written for but WRONG here — the band's C
    357     profile carries its outer edge and its inner opening in a single loop, and a
    358     centroid rule flips every normal on the inner run. The edge rule needs no such
    359     assumption, and it serves outer loops, CW holes and pocket walls identically:
    360     just hand it the loop wound so the material is on the left."""
    361     tris = []
    362     k = len(loop)
    363     for i in range(k):
    364         x0, y0 = loop[i]
    365         x1, y1 = loop[(i + 1) % k]
    366         tgt = (y1 - y0, -(x1 - x0), 0.0)
    367         a = (x0, y0, z0); b = (x1, y1, z0); c = (x1, y1, z1); d = (x0, y0, z1)
    368         tris.append(_orient(a, b, c, tgt))
    369         tris.append(_orient(a, c, d, tgt))
    370     return tris
    371 
    372 
    373 def extrude(outer, holes, z0, z1):
    374     """Closed solid from a CCW outer loop + CW hole loops, z0→z1."""
    375     tris = cap(outer, holes, z0, (0, 0, -1))
    376     tris += cap(outer, holes, z1, (0, 0, 1))
    377     tris += wall(outer, z0, z1)
    378     for h in holes:                                   # already CW → material on the left
    379         tris += wall(h, z0, z1)
    380     return tris
    381 
    382 
    383 def make_frame():
    384     """Returns (tris, expected_volume_mm3). The volume is computed analytically from
    385     the profiles so main() can assert the MESH agrees — a normal-orientation or
    386     triangulation slip shows up there as a wildly wrong number, which is exactly how
    387     the first two bugs in this part were caught."""
    388     tris = []
    389     expect = 0.0
    390 
    391     # --- ledge: the back lip that stops the slide (a closed ring) ---------------
    392     l_outer, l_hole = ledge_profile()
    393     tris += extrude(l_outer, [l_hole], Z_LEDGE0, Z_LEDGE1)
    394     ledge_area = signed_area(l_outer) + signed_area(l_hole)      # hole is CW → negative
    395     expect += ledge_area * (Z_LEDGE1 - Z_LEDGE0)
    396 
    397     # --- band: rides the perimeter walls, slotted for the lanyard tab -----------
    398     band = band_profile()
    399     recesses = [ear_recess_loop(-1), ear_recess_loop(+1)] if EAR_RECESS else []
    400     holes = [list(reversed(r)) for r in recesses]
    401     band_area = signed_area(band)
    402     expect += band_area * (Z_BAND1 - Z_BAND0)
    403 
    404     tris += cap(band, [], Z_BAND0, (0, 0, -1))
    405     tris += wall(band, Z_BAND0, Z_BAND1)
    406     tris += cap(band, holes, Z_BAND1, (0, 0, 1))
    407 
    408     # --- inner-ear pockets: floor + walls, sunk into the front-facing side ------
    409     for r in recesses:
    410         zf = Z_BAND1 - EAR_RECESS_D
    411         tris += cap(r, [], zf, (0, 0, 1))            # pocket floor, faces up
    412         tris += wall(list(reversed(r)), zf, Z_BAND1)  # pocket walls — CW, material outside
    413         expect -= signed_area(r) * EAR_RECESS_D
    414 
    415     # the ledge and band solids overlap by (Z_LEDGE1 − Z_BAND0); the band's section is
    416     # wholly inside the ledge ring there, so the printed part is `expect` minus that.
    417     solid = expect - band_area * (Z_LEDGE1 - Z_BAND0)
    418     return tris, expect, solid
    419 
    420 
    421 def main():
    422     os.makedirs(OUT_DIR, exist_ok=True)
    423     tris, expect_mm3, solid_mm3 = make_frame()
    424 
    425     # the invariant: the mesh's own signed volume must match the analytic one. A
    426     # flipped wall normal or a fan-tiled concave cap fails this by a mile.
    427     vol = mesh_volume_cc(tris)
    428     assert abs(vol * 1000.0 - expect_mm3) <= 0.02 * abs(expect_mm3), (
    429         "mesh volume %.1f mm^3 != analytic %.1f mm^3 — check cap/wall normals"
    430         % (vol * 1000.0, expect_mm3))
    431 
    432     path = os.path.join(OUT_DIR, "cat_ears_%s.stl" % VERSION)
    433     write_stl(path, tris, "badge cat-ear frame %s / case %s" % (VERSION, P["case_version"]))
    434 
    435     bw, bh, bd = bbox(tris)
    436     vol = solid_mm3 / 1000.0                          # printed volume (overlap resolved)
    437     grams = vol * _DENSITY_G_CC[MATERIAL] * INFILL
    438     bad = edge_manifold_issues(tris)
    439 
    440     print("cat-ear frame %s  (badge case %s, layout %s, git %s)" % (
    441         VERSION, P["case_version"], LAYOUT, git_rev() or "?"))
    442     print("  badge it wraps   %.1f x %.1f x %.1f mm" % (W, H, BADGE_D))
    443     print("  frame envelope   %.1f x %.1f x %.1f mm  (%d tris)" % (bw, bh, bd, len(tris)))
    444     print("  inner opening    %.2f x %.2f mm   (badge + %.2f/side, r%.2f)" % (
    445         IX1 - IX0, IY1 - IY0, TOL, IR))
    446     print("  band ride depth  %.1f mm%s" % (
    447         BAND_Z, "   <- CLAMPED from %.1f: USB slot starts at z=%.1f, keeping %.1f clear"
    448         % (BAND_Z_WANT, USB_SLOT_Z0, USB_CLEAR) if BAND_Z < BAND_Z_WANT else ""))
    449     print("  back ledge       %.1f mm inward x %.1f mm proud" % (LEDGE_W, LEDGE_Z))
    450     print("  tab slot         x %.1f..%.1f  (tab %.1f..%.1f + %.2f/side)" % (
    451         SLOT_X0, SLOT_X1, TAB_X0, TAB_X1, SLOT_TOL))
    452     print("  ears             base %.0f, height %.0f, tip r%.1f, tilt %.1f%s" % (
    453         EAR_BASE, EAR_H, EAR_TIP_R, EAR_TILT,
    454         ", inner recess %.1f deep" % EAR_RECESS_D if EAR_RECESS else ""))
    455     print("  material         %s @ %.0f%% -> %.1f cc, ~%.0f g" % (
    456         MATERIAL, INFILL * 100, vol, grams))
    457     print("  manifold         %s" % ("OK (closed)" if bad == 0 else
    458                                      "%d open edges (overlap-composed: ledge/band union)" % bad))
    459     print("  -> %s" % path)
    460 
    461 
    462 if __name__ == "__main__":
    463     main()