Files
temperature/cad/generate_stl.py
DESKTOP-PUHFO60\dsx2016 892e9d04c6 Optimize case v1.1 with rounded corners and FDM cantilever snaps.
Adds catch windows, locating pegs, and a snap coupon for print tuning; keep lip alignment and rear DHT bay.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 15:20:09 +08:00

399 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()