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