Fix case closing alignment and remove confusing side boxes.

Put DHT11 in a flush rear snap pocket with slight proud grill; nest front lip into back groove in the viewer.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-17 15:13:32 +08:00
parent ce04e7bb1a
commit 646cf45797
7 changed files with 330 additions and 624 deletions

View File

@@ -91,3 +91,4 @@ README.md # 本文档
| 2026-10-17 | 增加浏览器 Three.js STL 预览页 `cad/viewer.html` |
| 2026-10-17 | 预览增强:合盖/开盖、分解、装配动画、剖切/线框等 |
| 2026-10-17 | 外壳改定:DHT11 后盖卡位微突出;前后盖止口合盖 |
| 2026-10-17 | 去掉旁侧多余凸块;修复合盖对齐;DHT 改为后盖平面卡位 |

View File

@@ -1,13 +1,21 @@
# CYD 温湿度仪表盘 3D 外壳
桌面斜立分体外壳,适配 **ESP32-2432S028R(CYD)** + **DHT11**。
## 结构说明(当前版)
## 设计要点(当前)
只有两件:**前框** + **后盖**。
- **DHT11 在后盖外侧**:卡位槽 + 左右卡舌 + 前端止挡,模块从背面按入
- **传感器微突出**约 **1.8 mm**(通风口/探头露出后盖平面)
- **前后盖合盖**:前框公止口落入后盖母槽;可用 `4×M3×10` 辅助固定
- USB 侧孔、BOOT/RST 开孔、桌面支脚
| 部位 | 作用 |
|------|------|
| 前框 | 屏窗;公止口,合盖时插入后盖 |
| 后盖 | 装板子;母止口;**背面中心 DHT11 卡位通风口**(探头微突出约 1.6mm) |
| DHT 示意 | 预览里蓝色/灰块,表示传感器装在后盖背面卡位里 |
**没有**外壳旁边的大“格子”。旧版里那两块是:侧向支脚块、外挂 DHT 仓——已删除,避免误解。
## 合盖
前框止口落入后盖槽;预览默认合盖(开合度 0%)。
对齐数据在 `export/mate.json`。
## 浏览器预览
@@ -15,41 +23,19 @@
python -m http.server 8765 --directory cad
```
打开:**http://localhost:8765/viewer.html**
打开 http://localhost:8765/viewer.html (请 **Ctrl+F5** 强制刷新)
| 操作 | 说明 |
|------|------|
| 合盖 / 开盖 | 前框相对后盖开合 |
| 开合度 / 分解 | 拖动查看止口与 DHT 仓 |
| 看后盖 | 快速转到背面看 DHT 突出 |
| 模拟屏板 / DHT11模块 | 示意件开关 |
| 快捷键 | `C` 合盖 · `O` 开盖 · `空格` 播放装配 |
- `C` 合盖 · `O` 开盖 · `空格` 播放
- 「看后盖/DHT」查看背面微凸通风口
## 零件
| 文件 | 说明 |
|------|------|
| `export/front.stl` | 前框(屏窗 + 公止口) |
| `export/back.stl` | 后盖(母止口 + DHT 卡位仓 + 支脚) |
| `generate_stl.py` | Python 生成 STL |
| `cyd_climate_case.scad` | OpenSCAD 参数源 |
| `viewer.html` | 交互预览 |
## 重新导出
## 导出 STL
```bash
pip install trimesh manifold3d numpy
python cad/generate_stl.py
```
## 打印
PLA/PETG,0.2 mm,墙 2–3 圈,填充 15–20%,一般无需支撑。
后盖建议 **外底朝下**(DHT 仓朝上或按切片预览,必要时对 DHT 卡舌加少量支撑)。
## 装配
1. 将 DHT11 模块从 **后盖背面** 按入卡位,探头朝外微突出;线经仓内小孔接到 CN1(GPIO22)
2. CYD 放入后盖腔体,USB 对齐
3. 前框止口对准后盖槽 **合盖**,四角螺丝固定
4. 桌面斜立使用
1. DHT11 从**后盖背面**按入中心卡位,探头朝外略突出,线进腔内接 CN1/GPIO22
2. CYD 放入后盖,前框合盖,四角 M3×10 可选固定

Binary file not shown.

Binary file not shown.

8
cad/export/mate.json Normal file
View File

@@ -0,0 +1,8 @@
{
"outer_l": 91.5,
"outer_w": 55.5,
"front_body_h": 7.2,
"back_body_h": 10.4,
"lip_h": 2.2,
"dht_proud": 1.6
}

View File

@@ -1,10 +1,10 @@
#!/usr/bin/env python3
"""
CYD climate case STL generator.
CYD climate case — clean clamshell.
Design intent:
- DHT11 on the BACK cover, held by snap/clip posts (卡位), tip slightly proud of back face
- Front + back close with a interlocking lip (合盖)
- Front: screen bezel + male lip
- Back: cavity + female groove + DHT11 snap pocket flush in back wall (tip proud ~1.6mm)
- No side “boxes”: removed bulky stand block and external DHT hang-on bay
"""
from __future__ import annotations
@@ -12,32 +12,31 @@ from __future__ import annotations
from pathlib import Path
import trimesh
from trimesh.creation import box, cylinder # noqa: F401 — cylinder used for holes/bosses
from trimesh.creation import box, cylinder
# ---- mm ----
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
WALL, GAP = 2.4, 0.35
BEZEL_T, CAVITY = 2.2, 7.5
LIP_H, LIP_T = 2.4, 1.15 # front male / back female for 合盖
BEZEL_T = 2.2
CAVITY = 8.0
LIP_H, LIP_CLEAR = 2.2, 0.2
USB_W, USB_H, USB_Y = 15.0, 9.0, 30.5
STAND_FOOT = 16.0
SCREW = 3.3
BOSS_OD, BOSS_ID = 7.0, 2.5
SCREW, BOSS_OD, BOSS_ID = 3.3, 7.0, 2.5
# DHT11 module (common PCB module ~15.5 x 12 x 7.5 + pins)
DHT_MOD_L, DHT_MOD_W, DHT_MOD_H = 16.0, 12.5, 8.0
DHT_PROUD = 1.8 # how much grill/tip sticks out past back outer face
DHT_CLIP = 1.1 # undercut lip thickness for 卡位
# DHT11 module pocket (from OUTSIDE of back face z=0)
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
SHELL_H = CAVITY + WALL
SKIRT_H = DISP_H + 1.0
SKIRT = DISP_H + 1.0
# Back total height to mating plane (top of female groove floor)
BACK_BODY_H = WALL + CAVITY # rim starts here
FRONT_BODY_H = BEZEL_T + SKIRT
MH = [
(MH_INSET, MH_INSET),
@@ -52,256 +51,182 @@ def aabb(x0, y0, z0, x1, y1, z1) -> trimesh.Trimesh:
ya, yb = sorted((float(y0), float(y1)))
za, zb = sorted((float(z0), float(z1)))
dx, dy, dz = xb - xa, yb - ya, zb - za
if dx < 1e-6 or dy < 1e-6 or dz < 1e-6:
raise ValueError(f"degenerate box {[xa,ya,za,xb,yb,zb]}")
if min(dx, dy, dz) < 1e-6:
raise ValueError(f"degenerate {[xa, ya, za, xb, yb, zb]}")
m = box(extents=[dx, dy, dz])
m.apply_translation([xa + dx / 2, ya + dy / 2, za + dz / 2])
return m
def u(*meshes: trimesh.Trimesh) -> trimesh.Trimesh:
r = meshes[0]
for m in meshes[1:]:
def U(*ms: trimesh.Trimesh) -> trimesh.Trimesh:
r = ms[0]
for m in ms[1:]:
r = r.union(m, engine="manifold")
return r
def d(a: trimesh.Trimesh, *subs: trimesh.Trimesh) -> trimesh.Trimesh:
def D(a: trimesh.Trimesh, *ss: trimesh.Trimesh) -> trimesh.Trimesh:
r = a
for s in subs:
for s in ss:
r = r.difference(s, engine="manifold")
return r
def make_front() -> trimesh.Trimesh:
"""Front bezel: screen window + skirt + male lip for closing onto back."""
z1 = BEZEL_T + SKIRT_H + LIP_H
body = aabb(0, 0, 0, OUTER_L, OUTER_W, BEZEL_T + SKIRT_H)
# hollow skirt
cavity = aabb(WALL, WALL, BEZEL_T, OUTER_L - WALL, OUTER_W - WALL, BEZEL_T + SKIRT_H + 1)
"""
Z=0: outer screen face
Z=FRONT_BODY_H: open rim with male lip extending to FRONT_BODY_H+LIP_H
"""
body = aabb(0, 0, 0, OUTER_L, OUTER_W, FRONT_BODY_H)
hollow = aabb(WALL, WALL, BEZEL_T, OUTER_L - WALL, OUTER_W - WALL, FRONT_BODY_H + 1)
vx = WALL + GAP + DISP_X + VIEW_IX
vy = WALL + GAP + VIEW_IY
view = aabb(vx - 0.25, vy - 0.25, -0.5, vx + VIEW_L + 0.25, vy + VIEW_W + 0.25, BEZEL_T + 0.6)
view = 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.25
sx = WALL + GAP + DISP_X - 0.2
pocket = aabb(
sx, WALL + GAP - 0.15, BEZEL_T - 0.05,
sx + DISP_L + 0.5, WALL + GAP + DISP_W + 0.15, BEZEL_T + DISP_H + 0.8,
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,
)
out = D(body, hollow, view, pocket)
out = d(body, cavity, view, pocket)
# male lip (inserts into back female groove)
lip_outer = aabb(
WALL - LIP_T, WALL - LIP_T, BEZEL_T + SKIRT_H - 0.05,
OUTER_L - (WALL - LIP_T), OUTER_W - (WALL - LIP_T), BEZEL_T + SKIRT_H + LIP_H,
# male lip: thin wall ring protruding past rim
lip_out = aabb(
WALL - 1.0, WALL - 1.0, FRONT_BODY_H - 0.05,
OUTER_L - (WALL - 1.0), OUTER_W - (WALL - 1.0), FRONT_BODY_H + LIP_H,
)
lip_inner = aabb(
WALL + 0.15, WALL + 0.15, BEZEL_T + SKIRT_H - 0.1,
OUTER_L - WALL - 0.15, OUTER_W - WALL - 0.15, BEZEL_T + SKIRT_H + LIP_H + 0.5,
lip_in = aabb(
WALL + LIP_CLEAR, WALL + LIP_CLEAR, FRONT_BODY_H - 0.2,
OUTER_L - WALL - LIP_CLEAR, OUTER_W - WALL - LIP_CLEAR, FRONT_BODY_H + LIP_H + 0.5,
)
lip = d(lip_outer, lip_inner)
out = u(out, lip)
out = U(out, D(lip_out, lip_in))
# screw clearance through bezel
for px, py in MH:
x = WALL + GAP + px
y = WALL + GAP + py
hole = cylinder(radius=SCREW / 2 + 0.1, height=z1 + 4, sections=28)
hole.apply_translation([x, y, (BEZEL_T + SKIRT_H) / 2])
out = d(out, hole)
hole = cylinder(radius=SCREW / 2 + 0.1, height=FRONT_BODY_H + LIP_H + 4, sections=28)
hole.apply_translation([WALL + GAP + px, WALL + GAP + py, FRONT_BODY_H / 2])
out = D(out, hole)
out.merge_vertices()
return out
def dht_pocket_on_back() -> trimesh.Trimesh:
"""
Pocket on outer back face (z=0 side is exterior bottom of shell).
Module inserts from outside; clips hold it; grill protrudes DHT_PROUD.
Coordinate: back shell sits z=0 exterior, z=SHELL_H toward PCB.
Pocket carved into floor from outside, clips on the outer face.
"""
# center DHT on back wall area away from USB (right-ish of board back)
cx = WALL + GAP + 62
cy = WALL + GAP + PCB_W / 2
# outer boss that contains the module (sits mostly outside z<=0)
bay_l = DHT_MOD_L + 3.2
bay_w = DHT_MOD_W + 3.2
bay_h = DHT_MOD_H + WALL * 0.6 + DHT_PROUD
# bay extends below z=0 (outside) and a bit into floor
bay = aabb(
cx - bay_l / 2, cy - bay_w / 2, -bay_h + WALL * 0.35,
cx + bay_l / 2, cy + bay_w / 2, WALL + 0.2,
)
# cavity for module (from outside)
cav = aabb(
cx - DHT_MOD_L / 2 - 0.25, cy - DHT_MOD_W / 2 - 0.25, -bay_h + WALL * 0.35 + 1.0,
cx + DHT_MOD_L / 2 + 0.25, cy + DHT_MOD_W / 2 + 0.25, WALL - 0.3,
)
# window through back wall into interior for wires (small)
wire = aabb(
cx - 3.5, cy - 4.0, WALL - 0.5,
cx + 3.5, cy + 4.0, WALL + 2.5,
)
# front face of bay: grill opening, leaving rim so tip is proud
grill = aabb(
cx - DHT_MOD_L / 2 + 1.2, cy - DHT_MOD_W / 2 + 1.2, -bay_h - 0.2,
cx + DHT_MOD_L / 2 - 1.2, cy + DHT_MOD_W / 2 - 1.2, -bay_h + WALL + DHT_PROUD + 0.5,
)
# side vent slots
vents = []
for i in range(3):
vents.append(aabb(
cx - DHT_MOD_L / 2 - 2, cy - 4 + i * 3.2, -bay_h + 3,
cx - DHT_MOD_L / 2 + 0.2, cy - 4 + i * 3.2 + 1.4, -bay_h + 8,
))
vents.append(aabb(
cx + DHT_MOD_L / 2 - 0.2, cy - 4 + i * 3.2, -bay_h + 3,
cx + DHT_MOD_L / 2 + 2, cy - 4 + i * 3.2 + 1.4, -bay_h + 8,
))
# 卡位: L/R snap ledges + end stops (press-fit module from outside)
z_base = -bay_h + WALL * 0.35 + 1.2
left_clip = aabb(
cx - DHT_MOD_L / 2 - 1.2, cy - 3.5, z_base,
cx - DHT_MOD_L / 2 + DHT_CLIP, cy + 3.5, z_base + 3.2,
)
right_clip = aabb(
cx + DHT_MOD_L / 2 - DHT_CLIP, cy - 3.5, z_base,
cx + DHT_MOD_L / 2 + 1.2, cy + 3.5, z_base + 3.2,
)
# end stop ledge near grill (prevents module falling out front)
stop = aabb(
cx - DHT_MOD_L / 2 + 1.5, cy - DHT_MOD_W / 2 + 0.8, -bay_h + WALL * 0.35 + 0.8,
cx + DHT_MOD_L / 2 - 1.5, cy + DHT_MOD_W / 2 - 0.8, -bay_h + WALL * 0.35 + 0.8 + 1.0,
)
# rear retention bar (卡入后沿)
retain = aabb(
cx - 5.0, cy - DHT_MOD_W / 2 - 0.2, WALL - 2.2,
cx + 5.0, cy + DHT_MOD_W / 2 + 0.2, WALL - 0.4,
)
bay = d(bay, cav, grill, wire, *vents)
bay = u(bay, left_clip, right_clip, stop, retain)
return bay
def make_back() -> trimesh.Trimesh:
"""Back shell: cavity, female lip, screw bosses, stand, DHT snap bay on outer back."""
body = aabb(0, 0, 0, OUTER_L, OUTER_W, SHELL_H + LIP_H)
cavity = aabb(WALL, WALL, WALL, OUTER_L - WALL, OUTER_W - WALL, SHELL_H + LIP_H + 1)
"""
Z=0: exterior back face (DHT grill here, slightly proud)
Z=BACK_BODY_H: mating plane; female groove from BACK_BODY_H to BACK_BODY_H+LIP_H
"""
total_h = BACK_BODY_H + LIP_H
body = aabb(0, 0, 0, OUTER_L, OUTER_W, total_h)
cavity = aabb(WALL, WALL, WALL, OUTER_L - WALL, OUTER_W - WALL, total_h + 1)
out = D(body, cavity)
# female groove for front lip (near top rim)
# female groove: cut a channel in the top rim for the male lip
# outer wall of groove
groove = aabb(
WALL - LIP_T - 0.15, WALL - LIP_T - 0.15, SHELL_H,
OUTER_L - (WALL - LIP_T - 0.15), OUTER_W - (WALL - LIP_T - 0.15), SHELL_H + LIP_H + 0.2,
WALL - 1.0 - LIP_CLEAR, WALL - 1.0 - LIP_CLEAR, BACK_BODY_H + 0.1,
OUTER_L - (WALL - 1.0 - LIP_CLEAR), OUTER_W - (WALL - 1.0 - LIP_CLEAR), total_h + 0.2,
)
# keep inner wall so groove is a channel, not open to cavity fully
# actually: cut a U-channel from the top rim
groove_cut = aabb(
WALL - LIP_T - 0.1, WALL - LIP_T - 0.1, SHELL_H - 0.05,
OUTER_L - (WALL - LIP_T - 0.1), OUTER_W - (WALL - LIP_T - 0.1), SHELL_H + LIP_H + 0.3,
groove_keep = aabb(
WALL + 0.05, WALL + 0.05, BACK_BODY_H - 0.1,
OUTER_L - WALL - 0.05, OUTER_W - WALL - 0.05, total_h + 0.3,
)
keep_inner = aabb(
WALL + 0.05, WALL + 0.05, SHELL_H - 0.2,
OUTER_L - WALL - 0.05, OUTER_W - WALL - 0.05, SHELL_H + LIP_H + 0.5,
)
# rim solid then groove: start from body, remove cavity, then remove groove channel between lip walls
out = d(body, cavity)
# Add rim collar then cut female slot
collar = aabb(0, 0, SHELL_H - 0.05, OUTER_L, OUTER_W, SHELL_H + LIP_H)
collar_in = aabb(WALL + 0.2, WALL + 0.2, SHELL_H - 0.2, OUTER_L - WALL - 0.2, OUTER_W - WALL - 0.2, SHELL_H + LIP_H + 0.5)
collar = d(collar, collar_in)
out = u(out, collar)
# female slot (accepts front male lip)
slot = aabb(
WALL - LIP_T - 0.12, WALL - LIP_T - 0.12, SHELL_H + 0.15,
OUTER_L - (WALL - LIP_T - 0.12), OUTER_W - (WALL - LIP_T - 0.12), SHELL_H + LIP_H - 0.15,
)
slot_keep = aabb(
WALL + 0.05, WALL + 0.05, SHELL_H,
OUTER_L - WALL - 0.05, OUTER_W - WALL - 0.05, SHELL_H + LIP_H + 0.2,
)
# slot is the ring between slot outer and slot_keep — cut only the ring
slot_ring = d(slot, slot_keep)
out = d(out, slot_ring)
out = D(out, D(groove, groove_keep))
# USB
usb_y0 = WALL + GAP + USB_Y - USB_W / 2
usb = aabb(-1, usb_y0, WALL + 1.2, WALL + 1.2, usb_y0 + USB_W, WALL + 1.2 + USB_H)
out = d(out, usb)
uy0 = WALL + GAP + USB_Y - USB_W / 2
out = D(out, aabb(-0.5, uy0, WALL + 1.0, WALL + 1.0, uy0 + USB_W, WALL + 1.0 + USB_H))
# SD optional slit
sd = aabb(WALL + GAP + 28, OUTER_W - WALL - 1, WALL + 2, WALL + GAP + 44, OUTER_W + 1, WALL + 5)
out = d(out, sd)
# BOOT/RST access
for y in (12, 22):
# button holes
for y in (12.0, 22.0):
btn = cylinder(radius=2.2, height=WALL + 2, sections=20)
btn.apply_translation([WALL + GAP + 10, WALL + GAP + y, WALL / 2])
out = d(out, btn)
# floor vents (not under DHT)
vents = []
for i in range(4):
vents.append(aabb(WALL + 10 + i * 12, WALL + 8, -0.5, WALL + 12 + i * 12, WALL + 16, WALL + 0.5))
out = d(out, *vents)
out = D(out, btn)
# screw bosses
bosses = []
for px, py in MH:
x = WALL + GAP + px
y = WALL + GAP + py
boss = cylinder(radius=BOSS_OD / 2, height=CAVITY - 0.4, sections=28)
boss.apply_translation([x, y, WALL + (CAVITY - 0.4) / 2])
x, y = WALL + GAP + px, WALL + GAP + py
boss = cylinder(radius=BOSS_OD / 2, height=CAVITY - 0.5, sections=28)
boss.apply_translation([x, y, WALL + (CAVITY - 0.5) / 2])
hole = cylinder(radius=BOSS_ID / 2, height=CAVITY + 1, sections=20)
hole.apply_translation([x, y, WALL + CAVITY / 2])
bosses.append(d(boss, hole))
out = u(out, *bosses)
bosses.append(D(boss, hole))
out = U(out, *bosses)
# stand foot (desk)
stand_len = OUTER_L * 0.7
sx = (OUTER_L - stand_len) / 2
foot = aabb(sx, -STAND_FOOT + 0.4, 0, sx + stand_len, WALL, SHELL_H * 0.85)
foot_in = aabb(sx + 2, -STAND_FOOT + 2.2, WALL, sx + stand_len - 2, WALL - 0.15, SHELL_H)
foot = d(foot, foot_in)
rib = aabb(OUTER_L / 2 - 1.2, -STAND_FOOT + 1, WALL, OUTER_L / 2 + 1.2, WALL, SHELL_H * 0.8)
out = u(out, foot, rib)
# ---- DHT11 flush pocket in back wall (NOT a side box) ----
# Center on back face
cx = OUTER_L * 0.5
cy = OUTER_W * 0.5
# Pocket carved from outside into wall+cavity enough for module
pocket = 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 = D(out, pocket)
# DHT11 snap bay on outer back (protruding)
out = u(out, dht_pocket_on_back())
# Snap rails (卡位) left/right inside pocket — thin ledges
rail_z0, rail_z1 = 1.2, 1.2 + 2.2
left = aabb(cx - DHT_L / 2 - 0.3, cy - 3.2, rail_z0, cx - DHT_L / 2 + 1.0, cy + 3.2, rail_z1)
right = aabb(cx + DHT_L / 2 - 1.0, cy - 3.2, rail_z0, cx + DHT_L / 2 + 0.3, cy + 3.2, rail_z1)
# bottom stop (prevents falling inward)
stop = 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)
out = U(out, left, right, stop)
# Proud grill ring on exterior (the only intentional protrusion)
grill_ring = aabb(
cx - DHT_L / 2 - 1.0, cy - DHT_W / 2 - 1.0, -DHT_PROUD,
cx + DHT_L / 2 + 1.0, cy + DHT_W / 2 + 1.0, 0.05,
)
grill_hole = aabb(
cx - DHT_L / 2 + 1.5, cy - DHT_W / 2 + 1.5, -DHT_PROUD - 0.2,
cx + DHT_L / 2 - 1.5, cy + DHT_W / 2 - 1.5, 0.2,
)
# slot vents in proud face
slots = []
for i in range(4):
slots.append(aabb(
cx - 5 + i * 2.8, cy - 4.5, -DHT_PROUD - 0.2,
cx - 5 + i * 2.8 + 1.2, cy + 4.5, 0.2,
))
out = U(out, D(grill_ring, grill_hole, *slots))
# tiny wire path already open via pocket into cavity
# Simple thin kickstand rib (not a hollow “格子”) — optional small triangle
# Skip bulky stand entirely per user feedback.
out.merge_vertices()
return out
def main() -> None:
out_dir = Path(__file__).resolve().parent / "export"
out_dir.mkdir(parents=True, exist_ok=True)
print("Building front (合盖公止口)…")
out = Path(__file__).resolve().parent / "export"
out.mkdir(parents=True, exist_ok=True)
print("front…")
front = make_front()
print("Building back (DHT后盖卡位 + 母止口)…")
print("back…")
back = make_back()
front.export(out / "front.stl")
back.export(out / "back.stl")
front.export(out_dir / "front.stl")
back.export(out_dir / "back.stl")
print(f"front watertight={front.is_watertight} faces={len(front.faces)}")
print(f"back watertight={back.is_watertight} faces={len(back.faces)}")
print(f"Wrote {out_dir / 'front.stl'}")
print(f"Wrote {out_dir / 'back.stl'}")
print(f"Outer ~ {OUTER_L:.1f} x {OUTER_W:.1f} mm; DHT proud ~{DHT_PROUD} mm")
# write mate info for viewer
meta = out / "mate.json"
meta.write_text(
"{\n"
f' "outer_l": {OUTER_L},\n'
f' "outer_w": {OUTER_W},\n'
f' "front_body_h": {FRONT_BODY_H},\n'
f' "back_body_h": {BACK_BODY_H},\n'
f' "lip_h": {LIP_H},\n'
f' "dht_proud": {DHT_PROUD}\n'
"}\n",
encoding="utf-8",
)
print(f"front {front.extents} watertight={front.is_watertight}")
print(f"back {back.extents} watertight={back.is_watertight}")
print("closed stack height ≈", BACK_BODY_H + FRONT_BODY_H, "mm (lips nest, not add full)")
print("Wrote STLs + mate.json")
if __name__ == "__main__":

View File

@@ -3,113 +3,77 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CYD Case — 合盖 / DHT后盖卡位</title>
<title>CYD Case — 合盖预览</title>
<style>
:root {
--bg: #0b1220; --panel: #121c2aee; --text: #e8eef7; --muted: #8a9bb5;
--accent: #3db8a8; --line: #243247; --warm: #f0a04b;
}
:root { --bg:#0b1220; --panel:#121c2aee; --text:#e8eef7; --muted:#8a9bb5; --accent:#3db8a8; --line:#243247; --warn:#f0a04b; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
font-family: "Segoe UI", "PingFang SC", system-ui, sans-serif; overflow: hidden; }
#c { display: block; width: 100%; height: 100%; touch-action: none; }
.hud {
position: fixed; left: 14px; top: 14px; z-index: 2; width: min(370px, calc(100vw - 28px));
max-height: calc(100vh - 28px); overflow: auto; background: var(--panel);
border: 1px solid var(--line); border-radius: 14px; padding: 14px;
backdrop-filter: blur(10px); box-shadow: 0 12px 40px #0006;
}
h1 { margin: 0 0 4px; font-size: 15px; font-weight: 650; }
.hint { margin: 0 0 10px; font-size: 11px; color: var(--muted); line-height: 1.4; }
.sec { margin-top: 11px; padding-top: 10px; border-top: 1px solid var(--line); }
.sec-title { font-size: 11px; color: var(--accent); letter-spacing: .06em; margin-bottom: 8px; text-transform: uppercase; }
.row { display: flex; flex-wrap: wrap; gap: 6px; }
button, label.tog {
appearance: none; border: 1px solid #2c3d56; background: #1a2740; color: var(--text);
border-radius: 8px; padding: 6px 9px; font-size: 12px; cursor: pointer;
}
button:hover, label.tog:hover { border-color: var(--accent); }
button.active { background: #1e3d3a; border-color: var(--accent); color: #b6f0e6; }
label.tog { display: inline-flex; align-items: center; gap: 5px; user-select: none; }
.slider {
display: grid; grid-template-columns: 72px 1fr 42px; gap: 8px; align-items: center;
margin: 6px 0; font-size: 12px; color: var(--muted);
}
.slider input { width: 100%; accent-color: var(--accent); }
.slider .val { text-align: right; color: var(--text); font-variant-numeric: tabular-nums; }
#status { margin-top: 10px; font-size: 11px; color: var(--muted); }
#status.err { color: #e05a5a; }
.note { font-size: 11px; color: var(--warm); line-height: 1.45; margin-top: 6px; }
html, body { margin:0; height:100%; background:var(--bg); color:var(--text); font-family:"Segoe UI","PingFang SC",system-ui,sans-serif; overflow:hidden; }
#c { display:block; width:100%; height:100%; }
.hud { position:fixed; left:14px; top:14px; z-index:2; width:min(360px, calc(100vw - 28px)); max-height:calc(100vh - 28px); overflow:auto;
background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:14px; backdrop-filter:blur(10px); }
h1 { margin:0 0 4px; font-size:15px; } .hint { margin:0 0 10px; font-size:11px; color:var(--muted); line-height:1.45; }
.sec { margin-top:10px; padding-top:10px; border-top:1px solid var(--line); }
.t { font-size:11px; color:var(--accent); letter-spacing:.06em; margin-bottom:8px; text-transform:uppercase; }
.row { display:flex; flex-wrap:wrap; gap:6px; }
button, label.tog { appearance:none; border:1px solid #2c3d56; background:#1a2740; color:var(--text); border-radius:8px; padding:6px 9px; font-size:12px; cursor:pointer; }
button:hover, label.tog:hover { border-color:var(--accent); }
button.active { background:#1e3d3a; border-color:var(--accent); color:#b6f0e6; }
label.tog { display:inline-flex; align-items:center; gap:5px; }
.slider { display:grid; grid-template-columns:64px 1fr 40px; gap:8px; align-items:center; margin:6px 0; font-size:12px; color:var(--muted); }
.slider input { width:100%; accent-color:var(--accent); }
.val { text-align:right; color:var(--text); }
#status { margin-top:10px; font-size:11px; color:var(--muted); }
#status.err { color:#e05a5a; }
.faq { font-size:11px; color:var(--warn); line-height:1.5; margin-top:8px; }
</style>
</head>
<body>
<div class="hud">
<h1>CYD Case Preview</h1>
<p class="hint">DHT11 在后盖外侧卡位,微微突出 · 前后盖止口合盖</p>
<h1>CYD 外壳预览</h1>
<p class="hint">左键旋转 · 滚轮缩放 · 右键平移</p>
<div class="sec-title">合盖</div>
<div class="t">合盖</div>
<div class="row">
<button type="button" id="btn-close" class="active">合盖</button>
<button type="button" id="btn-open">开盖</button>
<button type="button" id="btn-play">播放装配</button>
<button type="button" id="btn-stop">停止</button>
</div>
<div class="slider">
<span>开合度</span>
<input type="range" id="lid" min="0" max="100" value="0" />
<span class="val" id="lid-val">0%</span>
</div>
<div class="slider">
<span>分解</span>
<input type="range" id="explode" min="0" max="100" value="0" />
<span class="val" id="explode-val">0</span>
<button type="button" id="btn-play">播放</button>
</div>
<div class="slider"><span>开合度</span><input id="lid" type="range" min="0" max="100" value="0" /><span class="val" id="lidv">0%</span></div>
<div class="slider"><span>分解</span><input id="exp" type="range" min="0" max="100" value="0" /><span class="val" id="expv">0</span></div>
<div class="sec">
<div class="sec-title">部件</div>
<div class="t">部件</div>
<div class="row">
<label class="tog"><input type="checkbox" id="chk-front" checked /> 前框</label>
<label class="tog"><input type="checkbox" id="chk-back" checked /> 后盖</label>
<label class="tog"><input type="checkbox" id="chk-board" checked /> 模拟屏板</label>
<label class="tog"><input type="checkbox" id="chk-dht" checked /> DHT11模块</label>
<label class="tog"><input type="checkbox" id="cf" checked /> 前框</label>
<label class="tog"><input type="checkbox" id="cb" checked /> 后盖</label>
<label class="tog"><input type="checkbox" id="cd" checked /> DHT示意</label>
<label class="tog"><input type="checkbox" id="cx" /> 透视外壳</label>
</div>
</div>
<div class="sec">
<div class="sec-title">显示</div>
<div class="t">视角</div>
<div class="row">
<label class="tog"><input type="checkbox" id="chk-grid" checked /> 网格</label>
<label class="tog"><input type="checkbox" id="chk-auto" /> 自转</label>
<label class="tog"><input type="checkbox" id="chk-wire" /> 线框</label>
<label class="tog"><input type="checkbox" id="chk-xray" /> 透视</label>
<label class="tog"><input type="checkbox" id="chk-backview" /> 看后盖</label>
<button type="button" data-v="iso">等轴</button>
<button type="button" data-v="front">看屏幕</button>
<button type="button" data-v="back">看后盖/DHT</button>
<button type="button" id="reset">复位</button>
</div>
</div>
<div class="sec">
<div class="sec-title">视角</div>
<div class="row">
<button type="button" data-view="iso">等轴</button>
<button type="button" data-view="front">正面</button>
<button type="button" data-view="back">背面</button>
<button type="button" data-view="side">侧面</button>
<button type="button" id="btn-reset">复位</button>
<button type="button" id="btn-shot">截图</button>
</div>
</div>
<p class="note">合盖=0%:前框止口落入后盖槽。DHT 从后盖背面卡入并突出约 1.8mm。</p>
<p class="faq">
后盖中心微凸的是 <b>DHT11 卡位通风口</b>(传感器按入后盖背面,探头略突出)。<br/>
已去掉旁边多余的大“格子”(旧版支脚块 / 外挂仓)。
</p>
<div id="status">加载中…</div>
</div>
<canvas id="c"></canvas>
<script type="importmap">
{
"imports": {
{ "imports": {
"three": "https://unpkg.com/three@0.170.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.170.0/examples/jsm/"
}
}
}}
</script>
<script type="module">
import * as THREE from "three";
@@ -117,374 +81,196 @@
import { STLLoader } from "three/addons/loaders/STLLoader.js";
const $ = (id) => document.getElementById(id);
const statusEl = $("status");
const st = $("status");
const canvas = $("c");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true });
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight, false);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1220);
const camera = new THREE.PerspectiveCamera(42, innerWidth / innerHeight, 0.1, 4000);
camera.position.set(120, 80, 140);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
const key = new THREE.DirectionalLight(0xffffff, 1.1);
key.position.set(80, 120, 60);
scene.add(key);
const fill = new THREE.DirectionalLight(0x88aacc, 0.4);
fill.position.set(-60, 40, -80);
scene.add(fill);
const grid = new THREE.GridHelper(240, 24, 0x3db8a8, 0x1e2c40);
grid.position.y = -40;
scene.add(grid);
const L1 = new THREE.DirectionalLight(0xffffff, 1.05); L1.position.set(70, 100, 80); scene.add(L1);
const L2 = new THREE.DirectionalLight(0x88aacc, 0.4); L2.position.set(-50, 40, -60); scene.add(L2);
const grid = new THREE.GridHelper(200, 20, 0x3db8a8, 0x1e2c40); grid.position.y = -20; scene.add(grid);
const root = new THREE.Group();
scene.add(root);
const matFront = new THREE.MeshStandardMaterial({
color: 0x6a7b92, metalness: 0.12, roughness: 0.5, flatShading: true, side: THREE.DoubleSide,
});
const matBack = new THREE.MeshStandardMaterial({
color: 0x2f9f92, metalness: 0.1, roughness: 0.48, flatShading: true, side: THREE.DoubleSide,
});
const matBoard = new THREE.MeshStandardMaterial({ color: 0xc9a227, metalness: 0.3, roughness: 0.4, flatShading: true });
const matScreen = new THREE.MeshStandardMaterial({
color: 0x101820, emissive: 0x1a3048, emissiveIntensity: 0.4, roughness: 0.35,
});
const matDht = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.65, flatShading: true });
const matBlue = new THREE.MeshStandardMaterial({ color: 0x2a5cff, roughness: 0.4 });
const matF = new THREE.MeshStandardMaterial({ color: 0x6a7b92, roughness: 0.5, flatShading: true, side: THREE.DoubleSide });
const matB = new THREE.MeshStandardMaterial({ color: 0x2f9f92, roughness: 0.48, flatShading: true, side: THREE.DoubleSide });
const matD = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.65, flatShading: true });
const matS = new THREE.MeshStandardMaterial({ color: 0x2a5cff, roughness: 0.4 });
let meshFront, meshBack, boardGroup, dhtGroup;
let frontPivot, backPivot;
let closedGap = 0; // measured stack height between parts when closed
let lidT = 0, explodeT = 0, autoRotate = false, anim = null;
let backSize = new THREE.Vector3();
let frontG, backG, dhtG, mate;
let lid = 0, exp = 0, anim = null;
function setStatus(msg, err = false) {
statusEl.textContent = msg;
statusEl.classList.toggle("err", err);
}
function msg(t, err=false) { st.textContent = t; st.classList.toggle("err", err); }
function loadSTL(url) {
const loader = new STLLoader();
return new Promise((resolve, reject) => {
loader.load(url, (geo) => {
function loadSTL(url, mat) {
return new Promise((res, rej) => {
new STLLoader().load(url, (geo) => {
geo.computeVertexNormals();
resolve(geo);
}, undefined, reject);
// keep authoring coords (min at ~0)
const m = new THREE.Mesh(geo, mat);
res(m);
}, undefined, rej);
});
}
/** Put mesh so its bounding box min corner is at origin, Z up. */
function normalizeMesh(mesh) {
mesh.geometry.computeBoundingBox();
const bb = mesh.geometry.boundingBox.clone();
const size = new THREE.Vector3();
bb.getSize(size);
// STL from generator: XY = board plane, Z = thickness. Rotate to stand: screen faces +Z
mesh.rotation.set(0, 0, 0);
mesh.geometry.translate(-bb.min.x, -bb.min.y, -bb.min.z);
mesh.geometry.computeBoundingBox();
return size;
}
function makeBoard(w, h) {
const g = new THREE.Group();
const pcb = new THREE.Mesh(new THREE.BoxGeometry(86, 50, 1.6), matBoard);
const bezel = new THREE.Mesh(new THREE.BoxGeometry(69, 50, 3.8), new THREE.MeshStandardMaterial({
color: 0x222830, roughness: 0.55, flatShading: true,
}));
bezel.position.set(4, 0, 2.7);
const screen = new THREE.Mesh(new THREE.BoxGeometry(59.5, 45, 0.5), matScreen);
screen.position.set(4, 0, 4.7);
g.add(pcb);
g.add(bezel);
g.add(screen);
g.position.set(w / 2, h / 2, 0);
return g;
}
function makeDht() {
const g = new THREE.Group();
const body = new THREE.Mesh(new THREE.BoxGeometry(16, 12.5, 8), matDht);
const sensor = new THREE.Mesh(new THREE.BoxGeometry(12, 9, 2.2), matBlue);
sensor.position.z = -5.1; // toward outside (negative Z of back exterior)
g.add(body);
g.add(sensor);
return g;
}
function fit(obj) {
const box = new THREE.Box3().setFromObject(obj);
function fit() {
const box = new THREE.Box3().setFromObject(root);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
controls.target.copy(center);
const maxDim = Math.max(size.x, size.y, size.z, 1);
const dist = maxDim * 1.7;
camera.position.set(center.x + dist * 0.65, center.y - dist * 0.55, center.z + dist * 0.55);
camera.near = maxDim / 200;
camera.far = maxDim * 60;
camera.updateProjectionMatrix();
const c = box.getCenter(new THREE.Vector3());
controls.target.copy(c);
const d = Math.max(size.x, size.y, size.z) * 1.75;
camera.position.set(c.x + d * 0.7, c.y - d * 0.55, c.z + d * 0.45);
camera.near = d / 200; camera.far = d * 50; camera.updateProjectionMatrix();
controls.update();
}
/**
* Assembly frame (mm):
* - Back: exterior face at z=0 (DHT protrudes to z<0), rim toward +Z
* - Board: inside back cavity
* - Front: closes from +Z onto back rim when lidT=0
* Authoring coords (generate_stl.py):
* back: z=0 exterior (+DHT proud negative), rim/female at z=back_body_h .. +lip
* front: z=0 screen face, male lip at z=front_body_h .. +lip
*
* Closed: flip front 180° about X so lip points down into back groove,
* place so lip nests in [back_body_h, back_body_h+lip_h].
*/
function applyPose() {
if (!backPivot || !frontPivot) return;
if (!frontG || !backG || !mate) return;
const { outer_w, front_body_h, back_body_h, lip_h } = mate;
const frontH = front_body_h + lip_h;
const ex = explodeT * 35;
// open lid: lift + hinge rotate around Y at top edge
const open = lidT;
const lift = open * 28 + ex;
const hinge = open * THREE.MathUtils.degToRad(105);
backG.position.set(0, 0, -exp * 20);
backG.rotation.set(0, 0, 0);
backPivot.position.set(0, 0, -ex * 0.25);
backPivot.rotation.set(0, 0, 0);
// closed base placement
const insert = lip_h * 0.9; // how deep lip sits in groove
// After rotX=PI: worldZ = Pz - localZ, worldY = Py - localY
// Want lip tip (localZ=frontH) at worldZ = back_body_h + lip_h - insert
// => Pz - frontH = back_body_h + lip_h - insert
// => Pz = back_body_h + lip_h - insert + frontH
const closedPz = back_body_h + lip_h - insert + frontH;
// closed: front sits on top of back rim
frontPivot.position.set(0, 0, closedGap + lift);
frontPivot.rotation.set(hinge, 0, 0);
// hinge pivot offset: rotate around top (+Y) edge
if (meshFront) {
const hy = backSize.y;
frontPivot.position.y = Math.sin(hinge) * 0; // keep
// rotate around top edge: move pivot
frontPivot.position.set(0, hy * 0.5 * (1 - Math.cos(hinge)), closedGap + lift + hy * 0.15 * Math.sin(hinge));
frontPivot.rotation.set(hinge, 0, 0);
// compensate so mesh origin stays consistent — use sub-offset
meshFront.position.set(0, -hy * 0.0, 0);
}
// open: lift along Z and hinge around top edge (y = outer_w)
const openAng = lid * THREE.MathUtils.degToRad(100);
const lift = lid * 22 + exp * 28;
if (boardGroup) {
// sit inside back: above floor wall (~2.4)
boardGroup.position.set(backSize.x / 2, backSize.y / 2, 4.5 + ex * 0.1);
boardGroup.rotation.set(0, 0, 0);
}
frontG.rotation.set(Math.PI + openAng, 0, 0);
// Y compensation for PI flip: Py = outer_w so y maps correctly when closed
// Extra hinge: approximate by shifting
const hy = Math.sin(openAng) * (outer_w * 0.35);
const hz = (1 - Math.cos(openAng)) * (outer_w * 0.15);
frontG.position.set(0, outer_w + hy, closedPz + lift + hz);
if (dhtGroup) {
// DHT bay is on back exterior around x~62+wall, y center — match generator
const cx = 2.4 + 0.35 + 62;
const cy = 2.4 + 0.35 + 25;
dhtGroup.position.set(cx, cy, -2.0 - 0.2 * ex); // slightly outside back face
dhtGroup.rotation.set(0, 0, 0);
if (dhtG) {
dhtG.position.set(mate.outer_l / 2, mate.outer_w / 2, -0.2 - exp * 2);
}
}
function setLid(v, ui = true) {
lidT = THREE.MathUtils.clamp(v, 0, 1);
if (ui) {
$("lid").value = String(Math.round(lidT * 100));
$("lid-val").textContent = `${Math.round(lidT * 100)}%`;
}
function setLid(v, ui=true) {
lid = THREE.MathUtils.clamp(v, 0, 1);
if (ui) { $("lid").value = Math.round(lid*100); $("lidv").textContent = `${Math.round(lid*100)}%`; }
$("btn-close").classList.toggle("active", lid < 0.05);
$("btn-open").classList.toggle("active", lid > 0.95);
applyPose();
$("btn-close").classList.toggle("active", lidT < 0.05);
$("btn-open").classList.toggle("active", lidT > 0.95);
}
function setExplode(v, ui = true) {
explodeT = THREE.MathUtils.clamp(v, 0, 1);
if (ui) {
$("explode").value = String(Math.round(explodeT * 100));
$("explode-val").textContent = String(Math.round(explodeT * 100));
}
function setExp(v, ui=true) {
exp = THREE.MathUtils.clamp(v, 0, 1);
if (ui) { $("exp").value = Math.round(exp*100); $("expv").textContent = Math.round(exp*100); }
applyPose();
}
function ease(t) {
return t < 0.5 ? 2 * t * t : 1 - (Math.pow(-2 * t + 2, 2) / 2);
}
function startAnim(kind) {
function ease(t){ return t<.5?2*t*t:1-Math.pow(-2*t+2,2)/2; }
function play(kind){
const now = performance.now();
if (kind === "close") anim = { kind, t0: now, dur: 850, fromL: lidT, toL: 0, fromE: explodeT, toE: 0 };
if (kind === "open") anim = { kind, t0: now, dur: 850, fromL: lidT, toL: 1, fromE: explodeT, toE: 0 };
if (kind === "play") {
anim = { kind: "play", phase: 0, t0: now, dur: 700, fromL: lidT, toL: 1, fromE: explodeT, toE: 1 };
}
setStatus(kind === "play" ? "装配播放中…" : kind === "open" ? "开盖…" : "合盖…");
if (kind==="close") anim={k:"c",t0:now,dur:800,fl:lid,tl:0,fe:exp,te:0};
if (kind==="open") anim={k:"o",t0:now,dur:800,fl:lid,tl:1,fe:exp,te:0};
if (kind==="play") anim={k:"p",phase:0,t0:now,dur:650,fl:lid,tl:1,fe:exp,te:1};
msg(kind==="close"?"合盖中…":kind==="open"?"开盖中…":"装配播放…");
}
function tickAnim(now) {
if (!anim) return;
const u = ease(Math.min(1, (now - anim.t0) / anim.dur));
if (anim.kind === "play") {
if (anim.phase === 0) {
setLid(THREE.MathUtils.lerp(anim.fromL, 1, u));
setExplode(THREE.MathUtils.lerp(anim.fromE, 1, u));
if (u >= 1) anim = { kind: "play", phase: 1, t0: now, dur: 750, fromL: 1, toL: 1, fromE: 1, toE: 0 };
} else if (anim.phase === 1) {
setExplode(THREE.MathUtils.lerp(1, 0, u));
if (u >= 1) anim = { kind: "play", phase: 2, t0: now, dur: 900, fromL: 1, toL: 0, fromE: 0, toE: 0 };
} else {
setLid(THREE.MathUtils.lerp(1, 0, u));
if (u >= 1) { anim = null; setStatus("已合盖:前框止口落入后盖"); }
}
function tickAnim(now){
if(!anim) return;
const u=ease(Math.min(1,(now-anim.t0)/anim.dur));
if(anim.k==="p"){
if(anim.phase===0){ setLid(THREE.MathUtils.lerp(anim.fl,1,u)); setExp(THREE.MathUtils.lerp(anim.fe,1,u));
if(u>=1) anim={k:"p",phase:1,t0:now,dur:700,fl:1,tl:1,fe:1,te:0}; }
else if(anim.phase===1){ setExp(THREE.MathUtils.lerp(1,0,u));
if(u>=1) anim={k:"p",phase:2,t0:now,dur:850,fl:1,tl:0,fe:0,te:0}; }
else { setLid(THREE.MathUtils.lerp(1,0,u)); if(u>=1){ anim=null; msg("已合盖:前框止口落入后盖槽"); } }
return;
}
setLid(THREE.MathUtils.lerp(anim.fromL, anim.toL, u));
setExplode(THREE.MathUtils.lerp(anim.fromE, anim.toE, u));
if (u >= 1) {
anim = null;
setStatus(lidT < 0.05 ? "已合盖" : "已开盖");
}
setLid(THREE.MathUtils.lerp(anim.fl,anim.tl,u));
setExp(THREE.MathUtils.lerp(anim.fe,anim.te,u));
if(u>=1){ anim=null; msg(lid<0.05?"已合盖":"已开盖"); }
}
async function boot() {
async function boot(){
try {
const [geoF, geoB] = await Promise.all([
loadSTL("./export/front.stl"),
loadSTL("./export/back.stl"),
mate = await (await fetch("./export/mate.json")).json();
const [fm, bm] = await Promise.all([
loadSTL("./export/front.stl", matF),
loadSTL("./export/back.stl", matB),
]);
frontG = new THREE.Group(); frontG.add(fm);
backG = new THREE.Group(); backG.add(bm);
meshBack = new THREE.Mesh(geoB, matBack);
meshFront = new THREE.Mesh(geoF, matFront);
backSize = normalizeMesh(meshBack);
const frontSize = normalizeMesh(meshFront);
// DHT visual in the proud grill area
dhtG = new THREE.Group();
const body = new THREE.Mesh(new THREE.BoxGeometry(15.5, 12, 6.5), matD);
const tip = new THREE.Mesh(new THREE.BoxGeometry(11, 8.5, 2), matS);
tip.position.z = -4.2;
dhtG.add(body); dhtG.add(tip);
// Align both on XY; back Z=0 exterior, grows +Z; front will stack on rim
backPivot = new THREE.Group();
backPivot.add(meshBack);
frontPivot = new THREE.Group();
frontPivot.add(meshFront);
// closedGap ≈ back shell height to rim (geometry max.z)
meshBack.geometry.computeBoundingBox();
meshFront.geometry.computeBoundingBox();
const backH = meshBack.geometry.boundingBox.max.z;
const frontH = meshFront.geometry.boundingBox.max.z;
// Front STL: bezel at z=0 (screen face), lip at +Z. For closing onto back,
// flip front so lip faces -Z toward back rim, screen faces +Z toward user.
meshFront.rotation.x = Math.PI;
meshFront.position.z = frontH; // after 180° about X, compensate
meshFront.position.y = frontSize.y; // fix Y flip from rot X
closedGap = backH - 2.2; // seat lip into groove (~LIP_H)
boardGroup = makeBoard(backSize.x, backSize.y);
// board local was centered; remake simpler placement
boardGroup.position.set(0, 0, 0);
boardGroup.children.forEach(() => {});
// rebuild board at origin extents
root.remove(boardGroup);
boardGroup = new THREE.Group();
{
const pcb = new THREE.Mesh(new THREE.BoxGeometry(86, 50, 1.6), matBoard);
pcb.position.set(2.4 + 0.35 + 43, 2.4 + 0.35 + 25, 5.5);
const bez = new THREE.Mesh(new THREE.BoxGeometry(69, 50, 3.8), new THREE.MeshStandardMaterial({
color: 0x222830, roughness: 0.55, flatShading: true,
}));
bez.position.set(2.4 + 0.35 + 8 + 34.5, 2.4 + 0.35 + 25, 7.5);
const scr = new THREE.Mesh(new THREE.BoxGeometry(59.5, 45, 0.5), matScreen);
scr.position.set(2.4 + 0.35 + 8 + 7 + 29.75, 2.4 + 0.35 + 2.5 + 22.5, 9.6);
boardGroup.add(pcb, bez, scr);
}
dhtGroup = makeDht();
root.add(backPivot);
root.add(frontPivot);
root.add(boardGroup);
root.add(dhtGroup);
setLid(0);
setExplode(0);
fit(root);
setStatus(`就绪 · 后盖 ${backSize.x.toFixed(1)}×${backSize.y.toFixed(1)} mm · 点「合盖」或拖开合度`);
root.add(backG); root.add(frontG); root.add(dhtG);
setLid(0); setExp(0); fit();
msg(`合盖对齐已按 mate.json · 外壳 ${mate.outer_l}×${mate.outer_w} mm · DHT 突出 ${mate.dht_proud} mm`);
} catch (e) {
console.error(e);
setStatus("加载失败:请运行 python -m http.server 8765 --directory cad", true);
msg("加载失败:请用 python -m http.server 8765 --directory cad", true);
}
}
$("lid").oninput = (e) => { anim = null; setLid(Number(e.target.value) / 100, false); $("lid-val").textContent = `${e.target.value}%`; };
$("explode").oninput = (e) => { anim = null; setExplode(Number(e.target.value) / 100, false); $("explode-val").textContent = e.target.value; };
$("btn-close").onclick = () => startAnim("close");
$("btn-open").onclick = () => startAnim("open");
$("btn-play").onclick = () => startAnim("play");
$("btn-stop").onclick = () => { anim = null; setStatus("已停止"); };
$("chk-front").onchange = (e) => { if (frontPivot) frontPivot.visible = e.target.checked; };
$("chk-back").onchange = (e) => { if (backPivot) backPivot.visible = e.target.checked; };
$("chk-board").onchange = (e) => { if (boardGroup) boardGroup.visible = e.target.checked; };
$("chk-dht").onchange = (e) => { if (dhtGroup) dhtGroup.visible = e.target.checked; };
$("chk-grid").onchange = (e) => { grid.visible = e.target.checked; };
$("chk-auto").onchange = (e) => { autoRotate = e.target.checked; };
$("chk-wire").onchange = (e) => {
for (const m of [matFront, matBack, matBoard, matScreen, matDht, matBlue]) {
m.wireframe = e.target.checked; m.needsUpdate = true;
$("lid").oninput = e => { anim=null; setLid(+e.target.value/100,false); $("lidv").textContent=e.target.value+"%"; };
$("exp").oninput = e => { anim=null; setExp(+e.target.value/100,false); $("expv").textContent=e.target.value; };
$("btn-close").onclick = () => play("close");
$("btn-open").onclick = () => play("open");
$("btn-play").onclick = () => play("play");
$("cf").onchange = e => { if(frontG) frontG.visible=e.target.checked; };
$("cb").onchange = e => { if(backG) backG.visible=e.target.checked; };
$("cd").onchange = e => { if(dhtG) dhtG.visible=e.target.checked; };
$("cx").onchange = e => {
for (const m of [matF, matB]) {
m.transparent = e.target.checked; m.opacity = e.target.checked ? 0.35 : 1;
m.depthWrite = !e.target.checked; m.needsUpdate = true;
}
};
$("chk-xray").onchange = (e) => {
for (const m of [matFront, matBack]) {
m.transparent = e.target.checked;
m.opacity = e.target.checked ? 0.35 : 1;
m.depthWrite = !e.target.checked;
m.needsUpdate = true;
}
};
$("chk-backview").onchange = (e) => {
if (e.target.checked) setView("back");
else setView("iso");
};
function setView(name) {
const t = controls.target.clone();
const d = camera.position.distanceTo(t) || 160;
const map = {
iso: new THREE.Vector3(t.x + d * 0.65, t.y - d * 0.5, t.z + d * 0.55),
front: new THREE.Vector3(t.x, t.y, t.z + d),
back: new THREE.Vector3(t.x, t.y, t.z - d),
side: new THREE.Vector3(t.x + d, t.y, t.z),
function view(n){
const t=controls.target.clone(); const d=camera.position.distanceTo(t)||160;
const map={
iso:new THREE.Vector3(t.x+d*.7,t.y-d*.5,t.z+d*.45),
front:new THREE.Vector3(t.x,t.y,t.z+d),
back:new THREE.Vector3(t.x,t.y,t.z-d),
};
camera.position.copy(map[name] || map.iso);
controls.update();
camera.position.copy(map[n]||map.iso); controls.update();
}
document.querySelectorAll("[data-view]").forEach((b) => b.onclick = () => setView(b.dataset.view));
$("btn-reset").onclick = () => { if (root.children.length) fit(root); };
$("btn-shot").onclick = () => {
renderer.render(scene, camera);
const a = document.createElement("a");
a.download = "cyd-case.png";
a.href = canvas.toDataURL("image/png");
a.click();
};
addEventListener("resize", () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight, false);
});
addEventListener("keydown", (e) => {
if (e.target.matches("input,textarea")) return;
if (e.key === "c") startAnim("close");
if (e.key === "o") startAnim("open");
if (e.key === " ") { e.preventDefault(); startAnim("play"); }
document.querySelectorAll("[data-v]").forEach(b=>b.onclick=()=>view(b.dataset.v));
$("reset").onclick=()=>fit();
addEventListener("resize",()=>{ camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth,innerHeight,false); });
addEventListener("keydown",e=>{
if(e.target.matches("input")) return;
if(e.key==="c") play("close"); if(e.key==="o") play("open"); if(e.key===" ") { e.preventDefault(); play("play"); }
});
function tick(now) {
requestAnimationFrame(tick);
tickAnim(now);
if (autoRotate) root.rotation.z += 0.003;
controls.update();
renderer.render(scene, camera);
}
boot();
requestAnimationFrame(tick);
function tick(now){ requestAnimationFrame(tick); tickAnim(now); controls.update(); renderer.render(scene,camera); }
boot(); requestAnimationFrame(tick);
</script>
</body>
</html>