#!/usr/bin/env python3 """ CYD climate case v1.1 — rounded clamshell + FDM snap clips. Design refs (FDM snap-fit best practice): - Cantilever L≈12–16mm, root t≈1.6–2.0mm, width≥5mm - Undercut ≈0.7mm, lead-in ≈35°, root fillet ≥0.5·t - Mating clearance ≈0.4–0.5mm (FDM) - Prefer PETG for repeated open/close; print snap arms in XY bend plane Features: - Outer R=3.5mm / inner R≈2.0mm rounded shell - 4 side cantilever snaps (front) + catch windows (back) - Corner locating pegs / sockets for 合盖对齐 - DHT11 flush rear pocket with slight proud grill - Optional M3 bosses kept as backup """ from __future__ import annotations import json from pathlib import Path import numpy as np import trimesh from shapely.geometry import Point, box from trimesh.creation import cylinder, extrude_polygon # ---- board ---- PCB_L, PCB_W = 86.0, 50.0 MH_INSET = 4.0 DISP_X, DISP_L, DISP_W, DISP_H = 8.0, 69.0, 50.0, 4.0 VIEW_L, VIEW_W, VIEW_IX, VIEW_IY = 59.5, 45.0, 7.0, 2.5 # ---- shell ---- WALL, GAP = 2.4, 0.40 CORNER_R = 3.5 INNER_R = 2.0 BEZEL_T = 2.2 CAVITY = 8.0 LIP_H = 2.0 LIP_CLEAR = 0.45 # FDM clearance around lip USB_W, USB_H, USB_Y = 15.0, 9.0, 30.5 SCREW, BOSS_OD, BOSS_ID = 3.3, 7.0, 2.5 # ---- snap (cantilever) ---- SNAP_W = 6.5 SNAP_L = 11.0 # free length SNAP_T_ROOT = 1.7 SNAP_T_TIP = 0.95 SNAP_HOOK = 0.75 # undercut SNAP_LEAD = 0.9 # lead-in ramp height along arm SNAP_CLEAR = 0.45 # side gap around moving arm SNAP_Z_ROOT = 1.2 # root starts below front rim into skirt # ---- DHT ---- DHT_L, DHT_W, DHT_H = 16.0, 12.5, 7.5 DHT_PROUD = 1.6 INNER_L = PCB_L + 2 * GAP INNER_W = PCB_W + 2 * GAP OUTER_L = INNER_L + 2 * WALL OUTER_W = INNER_W + 2 * WALL SKIRT = DISP_H + 1.0 BACK_BODY_H = WALL + CAVITY FRONT_BODY_H = BEZEL_T + SKIRT MH = [ (MH_INSET, MH_INSET), (PCB_L - MH_INSET, MH_INSET), (PCB_L - MH_INSET, PCB_W - MH_INSET), (MH_INSET, PCB_W - MH_INSET), ] # snap centers along long sides (x), inset from ends SNAP_XS = [OUTER_L * 0.28, OUTER_L * 0.72] def rounded_prism(l: float, w: float, h: float, r: float, z0: float = 0.0) -> trimesh.Trimesh: r = min(r, l / 2 - 0.05, w / 2 - 0.05) poly = box(r, r, l - r, w - r).buffer(r, resolution=8) mesh = extrude_polygon(poly, height=h) mesh.apply_translation([0, 0, z0]) return mesh def aabb(x0, y0, z0, x1, y1, z1) -> trimesh.Trimesh: xa, xb = sorted((float(x0), float(x1))) ya, yb = sorted((float(y0), float(y1))) za, zb = sorted((float(z0), float(z1))) dx, dy, dz = xb - xa, yb - ya, zb - za if min(dx, dy, dz) < 1e-6: raise ValueError(f"degenerate box {xa,ya,za,xb,yb,zb}") m = trimesh.creation.box(extents=[dx, dy, dz]) m.apply_translation([xa + dx / 2, ya + dy / 2, za + dz / 2]) return m def U(*ms: trimesh.Trimesh) -> trimesh.Trimesh: r = ms[0] for m in ms[1:]: r = r.union(m, engine="manifold") return r def Diff(a: trimesh.Trimesh, *ss: trimesh.Trimesh) -> trimesh.Trimesh: r = a for s in ss: r = r.difference(s, engine="manifold") return r def cantilever_snap(x_center: float, y_wall: float, outward_sign: int) -> trimesh.Trimesh: """ Snap arm on front: root near FRONT_BODY_H, extends +Z (into back when closed). Hook faces outward (+/- Y). outward_sign: +1 for +Y wall (hook +Y), -1 for -Y wall. """ # Build in local: arm along +Z, thickness in Y, width in X # Root fillet approximated by stepped thickening pieces = [] n = 6 for i in range(n): t = i / (n - 1) # taper thickness root -> tip th = SNAP_T_ROOT * (1 - t) + SNAP_T_TIP * t z0 = FRONT_BODY_H - SNAP_Z_ROOT + t * (SNAP_L + SNAP_Z_ROOT) z1 = FRONT_BODY_H - SNAP_Z_ROOT + (t + 1 / n) * (SNAP_L + SNAP_Z_ROOT) + 0.05 # arm sits inside wall face, hook later if outward_sign > 0: y0 = y_wall - WALL + SNAP_CLEAR y1 = y0 + th else: y1 = y_wall + WALL - SNAP_CLEAR y0 = y1 - th pieces.append(aabb(x_center - SNAP_W / 2, y0, z0, x_center + SNAP_W / 2, y1, z1)) arm = U(*pieces) # hook wedge at tip (lead-in ramp) — min thickness keeps manifold volumes valid tip_z = FRONT_BODY_H - SNAP_Z_ROOT + SNAP_L + SNAP_Z_ROOT hook_steps = [] for i in range(5): t = (i + 1) / 5 # start above 0 so h>0 h = max(0.25, SNAP_HOOK * min(1.0, t / 0.55)) z0 = tip_z - SNAP_LEAD + (i / 5) * SNAP_LEAD z1 = tip_z - SNAP_LEAD + ((i + 1) / 5) * SNAP_LEAD + 0.08 if outward_sign > 0: y0 = y_wall - WALL + SNAP_CLEAR + SNAP_T_TIP y1 = y0 + h else: y1 = y_wall + WALL - SNAP_CLEAR - SNAP_T_TIP y0 = y1 - h hook_steps.append(aabb(x_center - SNAP_W / 2 + 0.4, y0, z0, x_center + SNAP_W / 2 - 0.4, y1, z1)) return U(arm, *hook_steps) def catch_window(x_center: float, y_wall: float, outward_sign: int) -> trimesh.Trimesh: """Opening / ledge cut in back wall for snap hook.""" # Window through wall where hook passes, then a ledge below for catch win_h = SNAP_L + 2.5 z0 = BACK_BODY_H - 1.0 z1 = z0 + win_h if outward_sign > 0: return aabb( x_center - SNAP_W / 2 - SNAP_CLEAR, y_wall - WALL - 0.2, z0, x_center + SNAP_W / 2 + SNAP_CLEAR, y_wall + 0.6, z1, ) return aabb( x_center - SNAP_W / 2 - SNAP_CLEAR, y_wall - 0.6, z0, x_center + SNAP_W / 2 + SNAP_CLEAR, y_wall + WALL + 0.2, z1, ) def catch_ledge(x_center: float, y_wall: float, outward_sign: int) -> trimesh.Trimesh: """Internal ledge the hook seats under (retention).""" z0 = BACK_BODY_H + 0.3 z1 = z0 + 1.4 if outward_sign > 0: return aabb( x_center - SNAP_W / 2 + 0.3, OUTER_W - WALL - 0.2, z0, x_center + SNAP_W / 2 - 0.3, OUTER_W - WALL + SNAP_HOOK + 0.3, z1, ) return aabb( x_center - SNAP_W / 2 + 0.3, WALL - SNAP_HOOK - 0.3, z0, x_center + SNAP_W / 2 - 0.3, WALL + 0.2, z1, ) def make_front() -> trimesh.Trimesh: body = rounded_prism(OUTER_L, OUTER_W, FRONT_BODY_H, CORNER_R, 0) hollow = rounded_prism(INNER_L, INNER_W, FRONT_BODY_H + 1, INNER_R, BEZEL_T) hollow.apply_translation([WALL, WALL, 0]) out = Diff(body, hollow) # view + pocket vx = WALL + GAP + DISP_X + VIEW_IX vy = WALL + GAP + VIEW_IY out = Diff(out, aabb(vx - 0.2, vy - 0.2, -0.5, vx + VIEW_L + 0.2, vy + VIEW_W + 0.2, BEZEL_T + 0.5)) sx = WALL + GAP + DISP_X - 0.2 out = Diff( out, aabb(sx, WALL + GAP - 0.1, BEZEL_T - 0.05, sx + DISP_L + 0.4, WALL + GAP + DISP_W + 0.1, BEZEL_T + DISP_H + 0.6), ) # male lip (rounded ring approx via outer-inner) lip_out = rounded_prism(OUTER_L - 1.2, OUTER_W - 1.2, LIP_H, CORNER_R - 0.6, FRONT_BODY_H - 0.05) lip_out.apply_translation([0.6, 0.6, 0]) lip_in = rounded_prism(INNER_L + LIP_CLEAR * 2, INNER_W + LIP_CLEAR * 2, LIP_H + 0.6, INNER_R, FRONT_BODY_H - 0.2) lip_in.apply_translation([WALL - LIP_CLEAR, WALL - LIP_CLEAR, 0]) out = U(out, Diff(lip_out, lip_in)) # locating pegs (导柱) near corners — stick into back sockets pegs = [] for px, py in [(8.0, 8.0), (OUTER_L - 8.0, 8.0), (OUTER_L - 8.0, OUTER_W - 8.0), (8.0, OUTER_W - 8.0)]: peg = cylinder(radius=1.4, height=LIP_H + 2.2, sections=20) peg.apply_translation([px, py, FRONT_BODY_H + (LIP_H + 2.2) / 2 - 0.3]) pegs.append(peg) out = U(out, *pegs) # 4 snaps on long sides snaps = [] for x in SNAP_XS: snaps.append(cantilever_snap(x, OUTER_W, +1)) snaps.append(cantilever_snap(x, 0.0, -1)) out = U(out, *snaps) # optional screw holes (backup) for px, py in MH: hole = cylinder(radius=SCREW / 2 + 0.1, height=FRONT_BODY_H + LIP_H + 6, sections=24) hole.apply_translation([WALL + GAP + px, WALL + GAP + py, FRONT_BODY_H / 2]) out = Diff(out, hole) out.merge_vertices() return out def make_back() -> trimesh.Trimesh: total_h = BACK_BODY_H + LIP_H body = rounded_prism(OUTER_L, OUTER_W, total_h, CORNER_R, 0) cavity = rounded_prism(INNER_L, INNER_W, total_h + 1, INNER_R, WALL) cavity.apply_translation([WALL, WALL, 0]) out = Diff(body, cavity) # female groove for lip groove = rounded_prism(OUTER_L - 0.8, OUTER_W - 0.8, LIP_H + 0.3, CORNER_R - 0.4, BACK_BODY_H + 0.1) groove.apply_translation([0.4, 0.4, 0]) keep = rounded_prism(INNER_L + 0.2, INNER_W + 0.2, LIP_H + 0.6, INNER_R, BACK_BODY_H - 0.1) keep.apply_translation([WALL - 0.1, WALL - 0.1, 0]) out = Diff(out, Diff(groove, keep)) # locating sockets for px, py in [(8.0, 8.0), (OUTER_L - 8.0, 8.0), (OUTER_L - 8.0, OUTER_W - 8.0), (8.0, OUTER_W - 8.0)]: sock = cylinder(radius=1.55, height=3.2, sections=20) sock.apply_translation([px, py, BACK_BODY_H + 1.4]) out = Diff(out, sock) # snap catch windows + ledges for x in SNAP_XS: out = Diff(out, catch_window(x, OUTER_W, +1)) out = Diff(out, catch_window(x, 0.0, -1)) out = U(out, catch_ledge(x, OUTER_W, +1)) out = U(out, catch_ledge(x, 0.0, -1)) # USB uy0 = WALL + GAP + USB_Y - USB_W / 2 out = Diff(out, aabb(-0.5, uy0, WALL + 1.0, WALL + 1.0, uy0 + USB_W, WALL + 1.0 + USB_H)) # buttons for y in (12.0, 22.0): btn = cylinder(radius=2.2, height=WALL + 2, sections=18) btn.apply_translation([WALL + GAP + 10, WALL + GAP + y, WALL / 2]) out = Diff(out, btn) # M3 bosses backup bosses = [] for px, py in MH: x, y = WALL + GAP + px, WALL + GAP + py boss = cylinder(radius=BOSS_OD / 2, height=CAVITY - 0.5, sections=24) boss.apply_translation([x, y, WALL + (CAVITY - 0.5) / 2]) hole = cylinder(radius=BOSS_ID / 2, height=CAVITY + 1, sections=18) hole.apply_translation([x, y, WALL + CAVITY / 2]) bosses.append(Diff(boss, hole)) out = U(out, *bosses) # DHT pocket + proud rounded grill cx, cy = OUTER_L / 2, OUTER_W / 2 out = Diff( out, aabb(cx - DHT_L / 2 - 0.3, cy - DHT_W / 2 - 0.3, -0.05, cx + DHT_L / 2 + 0.3, cy + DHT_W / 2 + 0.3, WALL + DHT_H - 1.0), ) out = U( out, aabb(cx - DHT_L / 2 - 0.3, cy - 3.2, 1.2, cx - DHT_L / 2 + 1.0, cy + 3.2, 3.4), aabb(cx + DHT_L / 2 - 1.0, cy - 3.2, 1.2, cx + DHT_L / 2 + 0.3, cy + 3.2, 3.4), aabb(cx - 5, cy - DHT_W / 2 + 0.5, WALL + DHT_H - 2.2, cx + 5, cy + DHT_W / 2 - 0.5, WALL + DHT_H - 1.0), ) # rounded proud grill via buffered square grill_poly = box(cx - DHT_L / 2 - 0.8, cy - DHT_W / 2 - 0.8, cx + DHT_L / 2 + 0.8, cy + DHT_W / 2 + 0.8).buffer( 1.2, resolution=6 ) grill = extrude_polygon(grill_poly, height=DHT_PROUD + 0.1) grill.apply_translation([0, 0, -DHT_PROUD]) hole_poly = box(cx - DHT_L / 2 + 1.6, cy - DHT_W / 2 + 1.6, cx + DHT_L / 2 - 1.6, cy + DHT_W / 2 - 1.6).buffer( 0.8, resolution=5 ) ghole = extrude_polygon(hole_poly, height=DHT_PROUD + 0.4) ghole.apply_translation([0, 0, -DHT_PROUD - 0.2]) slots = [] for i in range(4): slots.append( aabb(cx - 5 + i * 2.8, cy - 4.2, -DHT_PROUD - 0.2, cx - 5 + i * 2.8 + 1.2, cy + 4.2, 0.15) ) out = U(out, Diff(grill, ghole, *slots)) out.merge_vertices() return out def make_snap_coupon() -> trimesh.Trimesh: """Small test piece: one snap + one catch for tuning before full print.""" base = aabb(0, 0, 0, 40, 28, 3) # fake front strip with one snap strip = aabb(5, 20, 3, 35, 28, 3 + FRONT_BODY_H * 0.35) snap = cantilever_snap(20, 28, +1) # shift snap down a bit for coupon front = U(strip, snap) # fake back catch wall wall = aabb(5, 0, 0, 35, 3.2, 18) win = catch_window(20, 3.2, +1) # remap catch to coupon coords roughly catch = aabb(20 - SNAP_W / 2 - 0.5, -0.2, 8, 20 + SNAP_W / 2 + 0.5, 3.5, 18) ledge = aabb(20 - SNAP_W / 2 + 0.3, 2.5, 10, 20 + SNAP_W / 2 - 0.3, 2.5 + SNAP_HOOK + 0.4, 11.4) back = U(Diff(wall, catch), ledge) # place back beside front back.apply_translation([0, -8, 0]) label = aabb(2, 12, 3, 3.2, 18, 4.5) # marker nub return U(base, front, back, label) def main() -> None: out = Path(__file__).resolve().parent / "export" out.mkdir(parents=True, exist_ok=True) print("front (rounded + snaps)…") front = make_front() print("back (rounded + catches)…") back = make_back() print("snap coupon…") coupon = make_snap_coupon() front.export(out / "front.stl") back.export(out / "back.stl") coupon.export(out / "snap_coupon.stl") meta = { "outer_l": OUTER_L, "outer_w": OUTER_W, "front_body_h": FRONT_BODY_H, "back_body_h": BACK_BODY_H, "lip_h": LIP_H, "dht_proud": DHT_PROUD, "corner_r": CORNER_R, "snap_w": SNAP_W, "snap_l": SNAP_L, "snap_hook": SNAP_HOOK, "lip_clear": LIP_CLEAR, "version": "1.1-snaps", } (out / "mate.json").write_text( json.dumps(meta, indent=2) + "\n", encoding="utf-8", ) print(f"front {front.extents} wt={front.is_watertight}") print(f"back {back.extents} wt={back.is_watertight}") print(f"coupon {coupon.extents} wt={coupon.is_watertight}") print("Wrote front/back/snap_coupon + mate.json") if __name__ == "__main__": main()