Relocate DHT11 to rear snap bay and fix front/back closing lips.

Regenerate STLs, update viewer closed pose, and document assembly.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-17 15:10:51 +08:00
parent 5d9132d754
commit ce04e7bb1a
7 changed files with 564 additions and 737 deletions

View File

@@ -90,3 +90,4 @@ README.md # 本文档
| 2026-10-17 | 新增 CYD+DHT11 桌面斜立 3D 外壳(OpenSCAD / STL) |
| 2026-10-17 | 增加浏览器 Three.js STL 预览页 `cad/viewer.html` |
| 2026-10-17 | 预览增强:合盖/开盖、分解、装配动画、剖切/线框等 |
| 2026-10-17 | 外壳改定:DHT11 后盖卡位微突出;前后盖止口合盖 |

View File

@@ -1,86 +1,55 @@
# CYD 温湿度仪表盘 3D 外壳
桌面斜立分体外壳,适配 **ESP32-2432S028R(CYD)** + **DHT11(CN1 / GPIO22)**。
桌面斜立分体外壳,适配 **ESP32-2432S028R(CYD)** + **DHT11**。
## 设计要点(当前)
- **DHT11 在后盖外侧**:卡位槽 + 左右卡舌 + 前端止挡,模块从背面按入
- **传感器微突出**约 **1.8 mm**(通风口/探头露出后盖平面)
- **前后盖合盖**:前框公止口落入后盖母槽;可用 `4×M3×10` 辅助固定
- USB 侧孔、BOOT/RST 开孔、桌面支脚
## 浏览器预览
不要直接双击 HTML(`file://` 无法加载 STL)。在项目根目录执行:
```bash
python -m http.server 8765 --directory cad
```
然后打开:**http://localhost:8765/viewer.html**
### 预览操作
打开:**http://localhost:8765/viewer.html**
| 操作 | 说明 |
|------|------|
| 合盖 / 开盖 | 前框铰链开合动画 |
| 开合度滑条 | 0% 合拢 → 100% 打开 |
| 分解滑条 | 拉开零件看内部 |
| 播放装配 | 分解 → 合拢 → 合盖 全程 |
| 模拟板 / DHT | 显示 PCB 与传感器示意 |
| 线框 / 透视 / 剖切 | 看结构与开孔 |
| 等轴/正/侧/顶 | 快速视角 |
| 截图 | 导出 PNG |
| 快捷键 | `O` 开盖 · `C` 合盖 · `空格` 播放 · `W` 线框 · `X` 透视 |
其它查看方式:切片软件打开 `export/*.stl`,或 [viewstl.com](https://www.viewstl.com/) / [3dviewer.net](https://3dviewer.net/)。
| 合盖 / 开盖 | 前框相对后盖开合 |
| 开合度 / 分解 | 拖动查看止口与 DHT 仓 |
| 看后盖 | 快速转到背面看 DHT 突出 |
| 模拟屏板 / DHT11模块 | 示意件开关 |
| 快捷键 | `C` 合盖 · `O` 开盖 · `空格` 播放装配 |
## 零件
| 文件 | 说明 |
|------|------|
| `viewer.html` | 浏览器 3D 预览 |
| `export/front.stl` | 前框(屏窗 + 压边) |
| `export/back.stl` | 后盖 + 斜立支脚 + DHT11 通风仓 |
| `cyd_climate_case.scad` | 参数化源文件(OpenSCAD,可改尺寸) |
| `generate_stl.py` | 无 OpenSCAD 时用 Python 导出 STL |
| `export/front.stl` | 前框(屏窗 + 公止口) |
| `export/back.stl` | 后盖(母止口 + DHT 卡位仓 + 支脚) |
| `generate_stl.py` | Python 生成 STL |
| `cyd_climate_case.scad` | OpenSCAD 参数源 |
| `viewer.html` | 交互预览 |
## 设计要点
- PCB 基准:`86 × 50 × 1.6` mm,四角安装孔
- 前框可视窗约 `59.5 × 45` mm
- 后盖背面净空约 7 mm;Micro-USB / USB-C 合并开孔
- BOOT / RST 背面开孔;底部散热缝
- **DHT11 独立通风仓**(后盖长边外侧),减少板温干扰;线槽通向 CN1
- 固定:`4 × M3×10` 自攻进后盖塑料柱(孔径约 2.5 mm)
## 打印建议
| 参数 | 建议 |
|------|------|
| 材料 | PLA 或 PETG |
| 层高 | 0.2 mm |
| 喷嘴 | 0.4 mm |
| 墙厚 | 2–3 圈 |
| 填充 | 15–20% |
| 支撑 | **不需要**(按 STL 默认朝向) |
| 前框 | 屏窗朝上打印 |
| 后盖 | 底板朝下,支脚伸出床外方向按切片预览摆放 |
## 重新导出 STL
## 重新导出
```bash
pip install trimesh manifold3d numpy
python cad/generate_stl.py
```
或用 OpenSCAD:
## 打印
```bash
openscad -o cad/export/front.stl -D part=\"front\" cad/cyd_climate_case.scad
openscad -o cad/export/back.stl -D part=\"back\" cad/cyd_climate_case.scad
```
改尺寸:编辑 `cyd_climate_case.scad` / `generate_stl.py` 顶部参数后重新导出。
PLA/PETG,0.2 mm,墙 2–3 圈,填充 15–20%,一般无需支撑。
后盖建议 **外底朝下**(DHT 仓朝上或按切片预览,必要时对 DHT 卡舌加少量支撑)。
## 装配
1. DHT11 放入后盖通风仓,杜邦线经线槽接到 **CN1**(`3V3 / IO22 / GND`,按你现有接线)
2. CYD 屏朝前框放入,USB 对齐侧孔
3. 合上后盖,四角 M3×10 拧紧(勿过紧防裂)
4. 桌面斜立放置;点屏可调背光
若孔位与你的板子有 ±1 mm 偏差,优先改 `generate_stl.py` 里 `USB_Y` / `MH_INSET` 后重导。
1. 将 DHT11 模块从 **后盖背面** 按入卡位,探头朝外微突出;线经仓内小孔接到 CN1(GPIO22)
2. CYD 放入后盖腔体,USB 对齐
3. 前框止口对准后盖槽 **合盖**,四角螺丝固定
4. 桌面斜立使用

View File

@@ -1,300 +1,131 @@
/*
* CYD ESP32-2432S028R + DHT11 桌面斜立外壳
* Climate Dashboard Case — parametric OpenSCAD
* CYD + DHT11 外壳(与 generate_stl.py 一致)
*
* 零件:
* part = "front" 前框(屏窗)
* part = "back" 后盖 + 斜立支架 + DHT 通风仓
* part = "assembly" 装配预览(半透明板子)
* - DHT11:后盖外侧卡位仓,传感器微突出
* - 前后盖:公/母止口合盖
*
* 导出 STL(本机安装 OpenSCAD 后):
* openscad -o export/front.stl -D part=\"front\" cyd_climate_case.scad
* openscad -o export/back.stl -D part=\"back\" cyd_climate_case.scad
* part = "front" | "back" | "assembly"
*/
/* [Which part] */
part = "assembly"; // ["front", "back", "assembly"]
part = "assembly"; // ["front","back","assembly"]
/* [PCB / CYD] */
pcb_l = 86.0;
pcb_w = 50.0;
pcb_t = 1.6;
pcb_corner_r = 4.0;
mh_inset = 4.0; // 安装孔距角边中心
mh_dia = 3.2; // PCB 孔
pcb_l = 86; pcb_w = 50;
wall = 2.4; gap = 0.35;
bezel_t = 2.2; cavity = 7.5;
lip_h = 2.4; lip_t = 1.15;
disp_h = 4; skirt_h = disp_h + 1;
view_l = 59.5; view_w = 45; view_ix = 7; view_iy = 2.5; disp_x = 8;
mh_inset = 4; screw = 3.3; boss_od = 7; boss_id = 2.5;
usb_w = 15; usb_h = 9; usb_y = 30.5;
stand_foot = 16;
dht_l = 16; dht_w = 12.5; dht_h = 8; dht_proud = 1.8; dht_clip = 1.1;
/* [Display] */
disp_l = 69.0;
disp_w = 50.0;
disp_h = 4.0;
disp_x = 8.0; // 屏相对 PCB 左边偏移
view_l = 59.5;
view_w = 45.0;
view_inset_x = 7.0; // 相对屏模组
view_inset_y = 2.5;
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;
$fn = 40;
/* [Case shell] */
wall = 2.4;
gap = 0.4; // 板与壳间隙
front_lip = 1.2; // 屏边压边宽度
bezel_front_t = 2.0; // 前框面板厚度
cavity_back = 7.0; // 背面元件净空
stand_angle = 60; // 桌面倾角(度,相对水平)
stand_foot = 18; // 支脚接触深度
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]];
/* [USB dual cutout] */
usb_dual = true;
usb_w = 14.0; // 双口合并开孔宽
usb_h = 8.5;
usb_y = 30.5; // 相对 PCB 原点(短边为 X=0 侧)
/* [Buttons] */
btn_enable = true;
btn_dia = 4.5;
/* [DHT11 bay] */
dht_enable = true;
dht_bay_l = 22;
dht_bay_w = 18;
dht_bay_h = 14;
dht_vent_slot = 1.6;
dht_vent_gap = 2.8;
/* [Screws] */
screw_m3 = 3.2;
boss_od = 7.2;
boss_id = 2.6; // 自攻进塑料
$fn = 48;
// ---- derived ----
inner_l = pcb_l + 2 * gap;
inner_w = pcb_w + 2 * gap;
outer_l = inner_l + 2 * wall;
outer_w = inner_w + 2 * wall;
case_h = bezel_front_t + disp_h + pcb_t + cavity_back + wall;
mh_pts = [
[mh_inset, mh_inset],
[pcb_l - mh_inset, mh_inset],
[pcb_l - mh_inset, pcb_w - mh_inset],
[mh_inset, pcb_w - mh_inset]
];
module rounded_rect(l, w, h, r) {
hull() {
for (x = [r, l - r], y = [r, w - r])
translate([x, y, 0]) cylinder(h = h, r = r);
}
module rr(l,w,h,r) {
hull() for (x=[r,l-r], y=[r,w-r]) translate([x,y,0]) cylinder(h=h,r=r);
}
module cyd_mock() {
color("gold")
difference() {
rounded_rect(pcb_l, pcb_w, pcb_t, pcb_corner_r);
for (p = mh_pts)
translate([p[0], p[1], -0.1])
cylinder(d = mh_dia, h = pcb_t + 0.2);
}
color("black")
translate([disp_x, 0, pcb_t])
cube([disp_l, disp_w, disp_h]);
color("navy")
translate([disp_x + view_inset_x, view_inset_y, pcb_t + disp_h])
cube([view_l, view_w, 0.2]);
}
// 前框:从屏面方向看,Z=0 为最前表面
module front_bezel() {
view_x = wall + gap + disp_x + view_inset_x;
view_y = wall + gap + view_inset_y;
module front() {
z1 = bezel_t + skirt_h;
difference() {
union() {
// 前表面
rounded_rect(outer_l, outer_w, bezel_front_t, 3);
// 侧壁向下包住屏+PCB 前半
translate([0, 0, bezel_front_t])
rr(outer_l, outer_w, z1, 3);
// male lip
translate([wall-lip_t, wall-lip_t, z1-0.05])
difference() {
rounded_rect(outer_l, outer_w, disp_h + pcb_t * 0.5, 3);
translate([wall, wall, -0.1])
rounded_rect(inner_l, inner_w, disp_h + pcb_t + 1, 2);
}
// 止口(与后盖搭接)
translate([wall * 0.45, wall * 0.45, bezel_front_t + disp_h + pcb_t * 0.5 - 0.01])
difference() {
rounded_rect(outer_l - wall * 0.9, outer_w - wall * 0.9, 2.2, 2.4);
translate([wall * 0.55, wall * 0.55, -0.1])
rounded_rect(outer_l - wall * 2.0, outer_w - wall * 2.0, 3, 1.6);
rr(outer_l-2*(wall-lip_t), outer_w-2*(wall-lip_t), lip_h, 2.2);
translate([lip_t+0.15, lip_t+0.15, -0.1])
rr(outer_l-2*wall-0.3, outer_w-2*wall-0.3, lip_h+0.5, 1.4);
}
}
// 可视窗
translate([view_x - 0.3, view_y - 0.3, -0.1])
cube([view_l + 0.6, view_w + 0.6, bezel_front_t + 1]);
// 屏模组沉台(背面)
translate([wall + gap + disp_x - 0.3, wall + gap - 0.2, bezel_front_t - 0.05])
cube([disp_l + 0.6, disp_w + 0.4, disp_h + 0.5]);
// 螺丝通孔
for (p = mh_pts)
translate([wall + gap + p[0], wall + gap + p[1], -0.1])
cylinder(d = screw_m3 + 0.2, h = case_h);
}
// 压屏边框内侧(窄边)
difference() {
translate([wall + gap + disp_x, wall + gap, bezel_front_t])
cube([disp_l, disp_w, 0.8]);
translate([view_x - 0.2, view_y - 0.2, bezel_front_t - 0.1])
cube([view_l + 0.4, view_w + 0.4, 2]);
}
}
// 后盖主体(不含支架倾转);本地坐标:PCB 底面朝上放置时的壳
module back_shell_body() {
shell_h = cavity_back + wall;
pcb_z = wall + cavity_back; // PCB 底面高度(元件朝下进腔)
difference() {
union() {
rounded_rect(outer_l, outer_w, shell_h, 3);
// 止口凹槽配合前框(外壳顶部一圈台阶)
translate([0, 0, shell_h - 0.01])
difference() {
rounded_rect(outer_l, outer_w, 2.0, 3);
translate([wall * 0.5, wall * 0.5, -0.05])
rounded_rect(outer_l - wall, outer_w - wall, 2.5, 2.4);
}
// 螺丝柱
for (p = mh_pts) {
translate([wall + gap + p[0], wall + gap + p[1], wall])
difference() {
cylinder(d = boss_od, h = cavity_back - 0.2);
translate([0, 0, 1])
cylinder(d = boss_id, h = cavity_back);
}
}
// DHT 通风仓(PCB 长边外侧,靠近 CN1:x≈51)
if (dht_enable) {
dht_x = wall + gap + 48;
dht_y = outer_w - wall * 0.2;
translate([dht_x, dht_y, wall])
dht_bay();
}
}
// 内腔
translate([wall, wall, wall])
rounded_rect(inner_l, inner_w, shell_h, 1.8);
// USB 开孔(短边 x=0 侧)
usb_cut_y = wall + gap + usb_y - usb_w / 2;
translate([-0.1, usb_cut_y, wall + 1.5])
cube([wall + 1.2, usb_w, usb_h]);
// SD 卡槽(可选宽缝)
translate([wall + gap + 28, outer_w - wall - 0.1, wall + 2])
cube([16, wall + 1, 3.2]);
// BOOT / RST 软按开孔(背面)
if (btn_enable) {
translate([wall + gap + 10, wall + gap + 12, -0.1])
cylinder(d = btn_dia, h = wall + 0.3);
translate([wall + gap + 10, wall + gap + 22, -0.1])
cylinder(d = btn_dia, h = wall + 0.3);
}
// 散热缝
for (i = [0:4])
translate([wall + 12 + i * 12, wall + 8, -0.1])
hull() {
cylinder(d = 2.2, h = wall + 0.3);
translate([0, 10, 0]) cylinder(d = 2.2, h = wall + 0.3);
}
// DHT 线槽通向 CN1
if (dht_enable) {
translate([wall + gap + 48, outer_w - wall - 2, wall + 2])
cube([8, wall + 4, 4]);
}
translate([wall,wall,bezel_t]) rr(inner_l, inner_w, z1, 1.8);
// view
vx = wall+gap+disp_x+view_ix; vy = wall+gap+view_iy;
translate([vx-0.25, vy-0.25, -0.1]) cube([view_l+0.5, view_w+0.5, bezel_t+1]);
// screen pocket
translate([wall+gap+disp_x-0.25, wall+gap-0.15, bezel_t-0.05])
cube([69.5, 50.3, disp_h+0.8]);
for (p=mh)
translate([wall+gap+p[0], wall+gap+p[1], -0.1])
cylinder(d=screw+0.2, h=z1+lip_h+1);
}
}
module dht_bay() {
// 局部:仓体贴在 outer 长边外侧
cx = wall+gap+62; cy = wall+gap+pcb_w/2;
bay_l = dht_l+3.2; bay_w = dht_w+3.2;
bay_h = dht_h + wall*0.6 + dht_proud;
difference() {
translate([0, 0, 0])
rounded_rect(dht_bay_l, dht_bay_w + wall, dht_bay_h, 2);
translate([wall * 0.6, wall * 0.6, wall * 0.6])
cube([dht_bay_l - wall * 1.2, dht_bay_w, dht_bay_h]);
// 顶面百叶通风
for (i = [0:4])
translate([3 + i * dht_vent_gap, dht_bay_w * 0.3, dht_bay_h - wall - 0.1])
cube([dht_vent_slot, dht_bay_w * 0.55, wall + 0.3]);
// 侧面通风
for (i = [0:2])
translate([4 + i * 5.5, dht_bay_w + wall - 0.2, 3 + i * 0.2])
cube([dht_vent_slot, wall + 0.4, 6]);
translate([cx-bay_l/2, cy-bay_w/2, -bay_h+wall*0.35])
cube([bay_l, bay_w, bay_h+0.2]);
// cavity
translate([cx-dht_l/2-0.25, cy-dht_w/2-0.25, -bay_h+wall*0.35+1])
cube([dht_l+0.5, dht_w+0.5, bay_h]);
// grill
translate([cx-dht_l/2+1.2, cy-dht_w/2+1.2, -bay_h-0.2])
cube([dht_l-2.4, dht_w-2.4, wall+dht_proud+0.8]);
// wire hole into case
translate([cx-3.5, cy-4, wall-0.5]) cube([7, 8, 3]);
}
// 卡位 left/right
z0 = -bay_h+wall*0.35+1.2;
translate([cx-dht_l/2-1.2, cy-3.5, z0]) cube([1.2+dht_clip, 7, 3.2]);
translate([cx+dht_l/2-dht_clip, cy-3.5, z0]) cube([1.2+dht_clip, 7, 3.2]);
// stop ledge
translate([cx-dht_l/2+1.5, cy-dht_w/2+0.8, -bay_h+wall*0.35+0.8])
cube([dht_l-3, dht_w-1.6, 1]);
}
// 带斜立支架的后盖(打印时支脚朝下)
module back_with_stand() {
shell_h = cavity_back + wall;
// 后盖:PCB 元件朝下;支架使屏幕斜立
// 组装时:屏幕朝向 -Z 转 stand_angle
union() {
back_shell_body();
// 三角形支脚(沿 PCB 长边下方)
stand_len = outer_l * 0.72;
stand_x = (outer_l - stand_len) / 2;
// 支脚从后盖底面向后延伸
translate([stand_x, -stand_foot + 0.5, 0])
module back() {
difference() {
union() {
rr(outer_l, outer_w, shell_h, 3);
// collar + female slot
difference() {
cube([stand_len, stand_foot, shell_h]);
// 挖空减料
translate([2, 2, wall])
cube([stand_len - 4, stand_foot - 2, shell_h]);
// 斜切贴合桌面视觉(打印可平放)
translate([-0.1, 0, shell_h])
rotate([-stand_angle + 90, 0, 0])
cube([stand_len + 0.2, 40, 40]);
translate([0,0,shell_h-0.05]) rr(outer_l, outer_w, lip_h, 3);
translate([wall+0.2, wall+0.2, shell_h-0.2])
rr(outer_l-2*wall-0.4, outer_w-2*wall-0.4, lip_h+0.5, 1.6);
}
// 后支撑肋
translate([outer_l * 0.5 - 1.2, -stand_foot + 1, wall])
cube([2.4, stand_foot - 1, shell_h - wall - 1]);
for (p=mh)
translate([wall+gap+p[0], wall+gap+p[1], wall])
difference() {
cylinder(d=boss_od, h=cavity-0.4);
translate([0,0,1]) cylinder(d=boss_id, h=cavity);
}
// stand
translate([(outer_l-outer_l*0.7)/2, -stand_foot+0.4, 0])
difference() {
cube([outer_l*0.7, stand_foot, shell_h*0.85]);
translate([2,2,wall]) cube([outer_l*0.7-4, stand_foot-2, shell_h]);
}
dht_bay();
}
translate([wall,wall,wall]) rr(inner_l, inner_w, shell_h+lip_h, 1.8);
// female ring cut
translate([wall-lip_t-0.12, wall-lip_t-0.12, shell_h+0.15])
difference() {
cube([outer_l-2*(wall-lip_t-0.12), outer_w-2*(wall-lip_t-0.12), lip_h-0.3]);
translate([lip_t+0.15, lip_t+0.15, -0.1])
cube([outer_l-2*wall-0.1, outer_w-2*wall-0.1, lip_h]);
}
// USB
translate([-0.1, wall+gap+usb_y-usb_w/2, wall+1.2]) cube([wall+1.2, usb_w, usb_h]);
}
}
module assembly() {
// 后盖在下
color("SteelBlue", 0.85) back_with_stand();
// PCB:元件朝下坐在腔顶
translate([wall + gap, wall + gap, wall + cavity_back])
rotate([180, 0, 0])
translate([0, -pcb_w, -pcb_t])
%cyd_mock();
// 前框盖上
translate([0, 0, wall + cavity_back + pcb_t + disp_h + bezel_front_t])
rotate([180, 0, 0])
translate([0, -outer_w, 0])
color("SlateGray", 0.7) front_bezel();
if (part=="front") front();
else if (part=="back") back();
else {
color("teal", 0.85) back();
translate([0,0,shell_h-2.2]) color("slategray", 0.75)
translate([0, outer_w, bezel_t+skirt_h+lip_h]) rotate([180,0,0]) front();
}
if (part == "front")
front_bezel();
else if (part == "back")
back_with_stand();
else
assembly();

Binary file not shown.

Binary file not shown.

View File

@@ -1,33 +1,43 @@
#!/usr/bin/env python3
"""Generate printable STL for CYD climate case using trimesh + manifold boolean."""
"""
CYD climate case STL generator.
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 (合盖)
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import trimesh
from trimesh.creation import box, cylinder
from trimesh.creation import box, cylinder # noqa: F401 — cylinder used for holes/bosses
# ---- mm, aligned with cyd_climate_case.scad ----
# ---- mm ----
PCB_L, PCB_W = 86.0, 50.0
MH_INSET = 4.0
DISP_L, DISP_W, DISP_H, DISP_X = 69.0, 50.0, 4.0, 8.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.4
BEZEL_T, CAVITY = 2.0, 7.0
PCB_T_HALF = 0.8
USB_W, USB_H, USB_Y = 14.0, 8.5, 30.5
STAND_FOOT = 18.0
DHT_L, DHT_W, DHT_H = 22.0, 18.0, 14.0
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 合盖
USB_W, USB_H, USB_Y = 15.0, 9.0, 30.5
STAND_FOOT = 16.0
SCREW = 3.3
BOSS_OD, BOSS_ID = 7.2, 2.5
BOSS_OD, BOSS_ID = 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 卡位
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
MH = [
(MH_INSET, MH_INSET),
@@ -37,141 +47,240 @@ MH = [
]
def place(mesh: trimesh.Trimesh, center: tuple[float, float, float]) -> trimesh.Trimesh:
m = mesh.copy()
m.apply_translation(np.array(center) - m.bounding_box.centroid)
return m
def aabb(x0, y0, z0, x1, y1, z1) -> trimesh.Trimesh:
dx, dy, dz = x1 - x0, y1 - y0, z1 - z0
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 dx < 1e-6 or dy < 1e-6 or dz < 1e-6:
raise ValueError(f"degenerate box {[xa,ya,za,xb,yb,zb]}")
m = box(extents=[dx, dy, dz])
m.apply_translation([x0 + dx / 2, y0 + dy / 2, z0 + dz / 2])
m.apply_translation([xa + dx / 2, ya + dy / 2, za + dz / 2])
return m
def u(*meshes: trimesh.Trimesh) -> trimesh.Trimesh:
result = meshes[0]
r = meshes[0]
for m in meshes[1:]:
result = result.union(m, engine="manifold")
return result
r = r.union(m, engine="manifold")
return r
def d(a: trimesh.Trimesh, *subs: trimesh.Trimesh) -> trimesh.Trimesh:
result = a
r = a
for s in subs:
result = result.difference(s, engine="manifold")
return result
r = r.difference(s, engine="manifold")
return r
def make_front() -> trimesh.Trimesh:
skirt_h = DISP_H + PCB_T_HALF
z1 = BEZEL_T + skirt_h
"""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, z1)
cavity = aabb(WALL, WALL, BEZEL_T, OUTER_L - WALL, OUTER_W - WALL, z1 + 1)
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)
vx = WALL + GAP + DISP_X + VIEW_IX
vy = WALL + GAP + VIEW_IY
view = aabb(vx - 0.3, vy - 0.3, -0.5, vx + VIEW_L + 0.3, vy + VIEW_W + 0.3, BEZEL_T + 0.5)
view = aabb(vx - 0.25, vy - 0.25, -0.5, vx + VIEW_L + 0.25, vy + VIEW_W + 0.25, BEZEL_T + 0.6)
# screen pocket
sx = WALL + GAP + DISP_X - 0.3
pocket = aabb(sx, WALL + GAP - 0.2, BEZEL_T - 0.05, sx + DISP_L + 0.6, WALL + GAP + DISP_W + 0.2, z1 + 0.5)
sx = WALL + GAP + DISP_X - 0.25
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,
)
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,
)
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 = d(lip_outer, lip_inner)
out = u(out, lip)
# screw clearance through bezel
for px, py in MH:
x = WALL + GAP + px
y = WALL + GAP + py
hole = cylinder(radius=SCREW / 2, height=z1 + 2, sections=32)
hole.apply_translation([x, y, z1 / 2])
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)
out.merge_vertices()
return out
def make_back() -> trimesh.Trimesh:
body = aabb(0, 0, 0, OUTER_L, OUTER_W, SHELL_H)
cavity = aabb(WALL, WALL, WALL, OUTER_L - WALL, OUTER_W - WALL, SHELL_H + 1)
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
# USB on -X
usb_y0 = WALL + GAP + USB_Y - USB_W / 2
usb = aabb(-1, usb_y0, WALL + 1.5, WALL + 1, usb_y0 + USB_W, WALL + 1.5 + USB_H)
# 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
# SD slot on +Y
sd = aabb(WALL + GAP + 28, OUTER_W - WALL - 1, WALL + 2, WALL + GAP + 44, OUTER_W + 1, WALL + 5.2)
# 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,
)
# button holes on floor
btn1 = cylinder(radius=2.3, height=WALL + 2, sections=24)
btn1.apply_translation([WALL + GAP + 10, WALL + GAP + 12, WALL / 2])
btn2 = cylinder(radius=2.3, height=WALL + 2, sections=24)
btn2.apply_translation([WALL + GAP + 10, WALL + GAP + 22, WALL / 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,
)
# vent slots on floor
# 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(5):
v = aabb(WALL + 12 + i * 12, WALL + 8, -0.5, WALL + 14 + i * 12, WALL + 18, WALL + 0.5)
vents.append(v)
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,
))
out = d(body, cavity, usb, sd, btn1, btn2, *vents)
# 卡位: 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)
# female groove for front lip (near top rim)
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,
)
# 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,
)
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)
# 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)
# 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):
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)
# 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.3, sections=32)
boss.apply_translation([x, y, WALL + (CAVITY - 0.3) / 2])
hole = cylinder(radius=BOSS_ID / 2, height=CAVITY + 1, sections=24)
boss = cylinder(radius=BOSS_OD / 2, height=CAVITY - 0.4, sections=28)
boss.apply_translation([x, y, WALL + (CAVITY - 0.4) / 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)
# stand foot
stand_len = OUTER_L * 0.72
# stand foot (desk)
stand_len = OUTER_L * 0.7
sx = (OUTER_L - stand_len) / 2
foot = aabb(sx, -STAND_FOOT + 0.5, 0, sx + stand_len, WALL, SHELL_H)
# hollow foot
foot_in = aabb(sx + 2, -STAND_FOOT + 2.5, WALL, sx + stand_len - 2, WALL - 0.2, SHELL_H + 1)
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 * 0.5 - 1.2, -STAND_FOOT + 1, WALL, OUTER_L * 0.5 + 1.2, WALL, SHELL_H - 1)
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)
# DHT bay
dx = WALL + GAP + 48
dy = OUTER_W - 0.2
bay = aabb(dx, dy, WALL, dx + DHT_L, dy + DHT_W + WALL, WALL + DHT_H)
bay_in = aabb(dx + 1.2, dy + 1.2, WALL + 1.0, dx + DHT_L - 1.2, dy + DHT_W, WALL + DHT_H + 1)
# top vents
top_vents = []
for i in range(5):
tv = aabb(
dx + 2.5 + i * 3.6,
dy + 3,
WALL + DHT_H - WALL - 0.2,
dx + 2.5 + i * 3.6 + 1.5,
dy + DHT_W - 2,
WALL + DHT_H + 0.5,
)
top_vents.append(tv)
# side vents
for i in range(3):
sv = aabb(
dx + 4 + i * 5.5,
dy + DHT_W + WALL - 0.5,
WALL + 3,
dx + 4 + i * 5.5 + 1.5,
dy + DHT_W + WALL + 1,
WALL + 9,
)
top_vents.append(sv)
bay = d(bay, bay_in, *top_vents)
# cable channel from bay to CN1
channel = aabb(dx + 2, OUTER_W - WALL - 2, WALL + 2, dx + 10, OUTER_W + DHT_W, WALL + 6)
out = d(u(out, bay), channel)
# DHT11 snap bay on outer back (protruding)
out = u(out, dht_pocket_on_back())
out.merge_vertices()
return out
@@ -181,19 +290,18 @@ def main() -> None:
out_dir = Path(__file__).resolve().parent / "export"
out_dir.mkdir(parents=True, exist_ok=True)
print("Building front…")
print("Building front (合盖公止口)…")
front = make_front()
print("Building back…")
print("Building back (DHT后盖卡位 + 母止口)…")
back = make_back()
front.export(out_dir / "front.stl")
back.export(out_dir / "back.stl")
print(f"front: {front.faces.shape[0]} faces, watertight={front.is_watertight}")
print(f"back: {back.faces.shape[0]} faces, watertight={back.is_watertight}")
print(f"Outer: {OUTER_L:.1f} x {OUTER_W:.1f} x front/back shells")
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")
if __name__ == "__main__":

View File

@@ -3,31 +3,26 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CYD Climate Case — Interactive Preview</title>
<title>CYD Case — 合盖 / DHT后盖卡位</title>
<style>
:root {
--bg: #0b1220;
--panel: #121c2aee;
--text: #e8eef7;
--muted: #8a9bb5;
--accent: #3db8a8;
--line: #243247;
--bg: #0b1220; --panel: #121c2aee; --text: #e8eef7; --muted: #8a9bb5;
--accent: #3db8a8; --line: #243247; --warm: #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(360px, calc(100vw - 28px));
max-height: calc(100vh - 28px); overflow: auto;
background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
padding: 14px 14px 12px; backdrop-filter: blur(10px);
box-shadow: 0 12px 40px #0006;
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;
}
.hud h1 { margin: 0 0 4px; font-size: 15px; font-weight: 650; }
.hint { margin: 0 0 12px; font-size: 11px; color: var(--muted); line-height: 1.4; }
.sec { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--line); }
.sec-title { font-size: 11px; color: var(--accent); letter-spacing: 0.06em; margin-bottom: 8px; text-transform: uppercase; }
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);
@@ -35,27 +30,26 @@
}
button:hover, label.tog:hover { border-color: var(--accent); }
button.active { background: #1e3d3a; border-color: var(--accent); color: #b6f0e6; }
button:disabled { opacity: 0.45; cursor: not-allowed; }
label.tog { display: inline-flex; align-items: center; gap: 5px; user-select: none; }
.slider {
display: grid; grid-template-columns: 72px 1fr 40px; gap: 8px; align-items: center;
display: grid; grid-template-columns: 72px 1fr 42px; gap: 8px; align-items: center;
margin: 6px 0; font-size: 12px; color: var(--muted);
}
.slider input[type=range] { width: 100%; accent-color: var(--accent); }
.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; }
.dims { font-size: 11px; color: var(--muted); line-height: 1.5; }
.note { font-size: 11px; color: var(--warm); line-height: 1.45; margin-top: 6px; }
</style>
</head>
<body>
<div class="hud">
<h1>CYD Climate Case</h1>
<p class="hint">左键旋转 · 滚轮缩放 · 右键平移 · 触屏双指缩放</p>
<h1>CYD Case Preview</h1>
<p class="hint">DHT11 在后盖外侧卡位,微微突出 · 前后盖止口合盖</p>
<div class="sec-title">合盖 / 开盖</div>
<div class="sec-title">合盖</div>
<div class="row">
<button type="button" id="btn-close">合盖</button>
<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>
@@ -65,14 +59,10 @@
<input type="range" id="lid" min="0" max="100" value="0" />
<span class="val" id="lid-val">0%</span>
</div>
<div class="sec">
<div class="sec-title">分解距离</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>
</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>
</div>
<div class="sec">
@@ -80,8 +70,8 @@
<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 /> DHT示意</label>
<label class="tog"><input type="checkbox" id="chk-board" checked /> 模拟屏板</label>
<label class="tog"><input type="checkbox" id="chk-dht" checked /> DHT11模块</label>
</div>
</div>
@@ -92,13 +82,7 @@
<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-cut" /> 剖切</label>
<label class="tog"><input type="checkbox" id="chk-ortho" /> 正交</label>
</div>
<div class="slider" id="cut-row" style="display:none">
<span>剖切面</span>
<input type="range" id="cut" min="0" max="100" value="50" />
<span class="val" id="cut-val">50</span>
<label class="tog"><input type="checkbox" id="chk-backview" /> 看后盖</label>
</div>
</div>
@@ -107,14 +91,14 @@
<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" data-view="top">顶视</button>
<button type="button" id="btn-reset">复位</button>
<button type="button" id="btn-shot">截图</button>
</div>
</div>
<div class="sec dims" id="dims">外壳约 91.6 × 55.6 mm(外轮廓)</div>
<p class="note">合盖=0%:前框止口落入后盖槽。DHT 从后盖背面卡入并突出约 1.8mm。</p>
<div id="status">加载中…</div>
</div>
<canvas id="c"></canvas>
@@ -140,70 +124,49 @@
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight, false);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.localClippingEnabled = true;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1220);
const cameraPersp = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 3000);
const cameraOrtho = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 3000);
let camera = cameraPersp;
camera.position.set(130, 95, 150);
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;
controls.target.set(46, 8, 5);
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
const key = new THREE.DirectionalLight(0xffffff, 1.1);
key.position.set(90, 130, 70);
key.position.set(80, 120, 60);
scene.add(key);
const fill = new THREE.DirectionalLight(0x88aacc, 0.4);
fill.position.set(-70, 50, -50);
fill.position.set(-60, 40, -80);
scene.add(fill);
const grid = new THREE.GridHelper(220, 22, 0x3db8a8, 0x1e2c40);
grid.position.y = -22;
const grid = new THREE.GridHelper(240, 24, 0x3db8a8, 0x1e2c40);
grid.position.y = -40;
scene.add(grid);
const root = new THREE.Group();
scene.add(root);
const clipPlane = new THREE.Plane(new THREE.Vector3(-1, 0, 0), 46);
const matFront = new THREE.MeshStandardMaterial({
color: 0x6a7b92, metalness: 0.12, roughness: 0.52, flatShading: true,
side: THREE.DoubleSide, clippingPlanes: [], clipShadows: true,
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, clippingPlanes: [],
});
const matBoard = new THREE.MeshStandardMaterial({
color: 0xc9a227, metalness: 0.35, roughness: 0.4, flatShading: true,
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: 0x152238, metalness: 0.2, roughness: 0.35, emissive: 0x1a3048, emissiveIntensity: 0.35,
});
const matDht = new THREE.MeshStandardMaterial({
color: 0x3a3a3a, metalness: 0.1, roughness: 0.7, flatShading: true,
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 });
let meshFront = null;
let meshBack = null;
let boardGroup = null;
let dhtMesh = null;
let hinge = null; // pivot for lid
let lidT = 0; // 0 closed .. 1 open
let explodeT = 0; // 0..1
let autoRotate = false;
let anim = null; // { from, to, t0, dur, kind }
const CLOSED = {
lid: 0,
explode: 0,
frontLift: 10.5,
};
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();
function setStatus(msg, err = false) {
statusEl.textContent = msg;
@@ -215,137 +178,143 @@
return new Promise((resolve, reject) => {
loader.load(url, (geo) => {
geo.computeVertexNormals();
geo.center();
resolve(geo);
}, undefined, reject);
});
}
function makeBoard() {
/** 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, 1.6, 50), matBoard);
const screen = new THREE.Mesh(new THREE.BoxGeometry(59.5, 0.6, 45), matScreen);
screen.position.set(4, 2.2, 0);
// bezel frame
const bezel = new THREE.Mesh(new THREE.BoxGeometry(69, 3.8, 50), new THREE.MeshStandardMaterial({
color: 0x222830, roughness: 0.6, flatShading: true,
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, 1.2, 0);
g.add(bezel);
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);
// mount pins visual
for (const [x, z] of [[-39, -21], [39, -21], [39, 21], [-39, 21]]) {
const pin = new THREE.Mesh(
new THREE.CylinderGeometry(1.2, 1.2, 2, 12),
new THREE.MeshStandardMaterial({ color: 0x888888 })
);
pin.position.set(x, -1.2, z);
g.add(pin);
}
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, 10, 12), matDht);
const grill = new THREE.Mesh(
new THREE.BoxGeometry(12, 0.4, 8),
new THREE.MeshStandardMaterial({ color: 0x666666 })
);
grill.position.y = 5.2;
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(grill);
g.add(sensor);
return g;
}
function fitCamera(object) {
const box = new THREE.Box3().setFromObject(object);
function fit(obj) {
const box = new THREE.Box3().setFromObject(obj);
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.85;
cameraPersp.position.set(center.x + dist * 0.75, center.y + dist * 0.42, center.z + dist * 0.75);
cameraPersp.near = maxDim / 200;
cameraPersp.far = maxDim * 80;
cameraPersp.updateProjectionMatrix();
syncOrtho();
if (camera === cameraOrtho) camera.position.copy(cameraPersp.position);
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();
controls.update();
}
function syncOrtho() {
const aspect = innerWidth / innerHeight;
const dist = cameraPersp.position.distanceTo(controls.target);
const half = dist * 0.35;
cameraOrtho.left = -half * aspect;
cameraOrtho.right = half * aspect;
cameraOrtho.top = half;
cameraOrtho.bottom = -half;
cameraOrtho.near = cameraPersp.near;
cameraOrtho.far = cameraPersp.far;
cameraOrtho.position.copy(cameraPersp.position);
cameraOrtho.quaternion.copy(cameraPersp.quaternion);
cameraOrtho.updateProjectionMatrix();
}
/**
* 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
*/
function applyPose() {
if (!hinge || !meshBack) return;
if (!backPivot || !frontPivot) return;
// explode separates parts along Y / hinge axis
const ex = explodeT * 28;
meshBack.position.set(0, -ex * 0.35, 0);
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);
// lid: 0 closed -> 1 fully open (~110 deg)
const angle = lidT * THREE.MathUtils.degToRad(110);
hinge.rotation.z = 0;
hinge.rotation.x = -angle;
hinge.position.set(0, CLOSED.frontLift + ex, 0);
backPivot.position.set(0, 0, -ex * 0.25);
backPivot.rotation.set(0, 0, 0);
// 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);
}
// board sits between shells
if (boardGroup) {
boardGroup.position.set(0, 4 + ex * 0.15, 0);
// 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);
}
if (dhtMesh) {
dhtMesh.position.set(28, 2 + ex * 0.1, 36);
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);
}
}
function setLid(v, updateUi = true) {
function setLid(v, ui = true) {
lidT = THREE.MathUtils.clamp(v, 0, 1);
if (updateUi) {
if (ui) {
$("lid").value = String(Math.round(lidT * 100));
$("lid-val").textContent = `${Math.round(lidT * 100)}%`;
}
applyPose();
$("btn-close").classList.toggle("active", lidT < 0.05);
$("btn-open").classList.toggle("active", lidT > 0.95);
}
function setExplode(v, updateUi = true) {
function setExplode(v, ui = true) {
explodeT = THREE.MathUtils.clamp(v, 0, 1);
if (updateUi) {
if (ui) {
$("explode").value = String(Math.round(explodeT * 100));
$("explode-val").textContent = String(Math.round(explodeT * 100));
}
applyPose();
}
function startAnim(kind) {
const now = performance.now();
if (kind === "close") {
anim = { kind, fromLid: lidT, toLid: 0, fromEx: explodeT, toEx: 0, t0: now, dur: 900 };
} else if (kind === "open") {
anim = { kind, fromLid: lidT, toLid: 1, fromEx: explodeT, toEx: 0, t0: now, dur: 900 };
} else if (kind === "play") {
// assembly sequence: explode open -> close explode -> close lid
anim = { kind: "play", phase: 0, t0: now, dur: 700, fromLid: lidT, toLid: 1, fromEx: explodeT, toEx: 1 };
}
setStatus(kind === "play" ? "播放装配动画…" : kind === "open" ? "开盖中…" : "合盖中…");
function ease(t) {
return t < 0.5 ? 2 * t * t : 1 - (Math.pow(-2 * t + 2, 2) / 2);
}
function ease(t) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
function startAnim(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" ? "开盖…" : "合盖…");
}
function tickAnim(now) {
@@ -353,81 +322,26 @@
const u = ease(Math.min(1, (now - anim.t0) / anim.dur));
if (anim.kind === "play") {
if (anim.phase === 0) {
setLid(THREE.MathUtils.lerp(anim.fromLid, 1, u), true);
setExplode(THREE.MathUtils.lerp(anim.fromEx, 1, u), true);
if (u >= 1) {
anim = { kind: "play", phase: 1, t0: now, dur: 800, fromLid: 1, toLid: 1, fromEx: 1, toEx: 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), true);
if (u >= 1) {
anim = { kind: "play", phase: 2, t0: now, dur: 900, fromLid: 1, toLid: 0, fromEx: 0, toEx: 0 };
}
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), true);
if (u >= 1) {
anim = null;
setStatus("装配完成(合盖)");
}
setLid(THREE.MathUtils.lerp(1, 0, u));
if (u >= 1) { anim = null; setStatus("已合盖:前框止口落入后盖"); }
}
return;
}
setLid(THREE.MathUtils.lerp(anim.fromLid, anim.toLid, u), true);
setExplode(THREE.MathUtils.lerp(anim.fromEx, anim.toEx, u), true);
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 ? "已合盖" : "已开盖");
}
}
function setWire(on) {
for (const m of [matFront, matBack, matBoard, matScreen, matDht]) {
m.wireframe = on;
m.needsUpdate = true;
}
}
function setXray(on) {
for (const m of [matFront, matBack]) {
m.transparent = on;
m.opacity = on ? 0.38 : 1;
m.depthWrite = !on;
m.needsUpdate = true;
}
}
function setCut(on) {
$("cut-row").style.display = on ? "grid" : "none";
const planes = on ? [clipPlane] : [];
matFront.clippingPlanes = planes;
matBack.clippingPlanes = planes;
matBoard.clippingPlanes = planes;
matScreen.clippingPlanes = planes;
matDht.clippingPlanes = planes;
updateCut();
}
function updateCut() {
const v = Number($("cut").value) / 100;
// slide plane across X
clipPlane.constant = 20 + v * 70;
$("cut-val").textContent = String(Math.round(v * 100));
}
function setView(name) {
const t = controls.target.clone();
const d = camera.position.distanceTo(t) || 180;
const map = {
iso: new THREE.Vector3(t.x + d * 0.7, t.y + d * 0.45, t.z + d * 0.7),
front: new THREE.Vector3(t.x, t.y, t.z + d),
side: new THREE.Vector3(t.x + d, t.y, t.z),
top: new THREE.Vector3(t.x, t.y + d, t.z + 0.01),
};
camera.position.copy(map[name] || map.iso);
if (camera === cameraOrtho) syncOrtho();
controls.update();
}
async function boot() {
try {
const [geoF, geoB] = await Promise.all([
@@ -437,130 +351,134 @@
meshBack = new THREE.Mesh(geoB, matBack);
meshFront = new THREE.Mesh(geoF, matFront);
backSize = normalizeMesh(meshBack);
const frontSize = normalizeMesh(meshFront);
// Lay flat-ish for hinge animation: rotate STLs onto XZ plane
meshBack.rotation.x = -Math.PI / 2;
meshFront.rotation.x = -Math.PI / 2;
// Align both on XY; back Z=0 exterior, grows +Z; front will stack on rim
backPivot = new THREE.Group();
backPivot.add(meshBack);
// Measure back size for hinge pivot at "top" edge (+Z after rotate)
meshBack.updateMatrixWorld(true);
const bb = new THREE.Box3().setFromObject(meshBack);
const size = bb.getSize(new THREE.Vector3());
const center = bb.getCenter(new THREE.Vector3());
frontPivot = new THREE.Group();
frontPivot.add(meshFront);
hinge = new THREE.Group();
// pivot near top edge of assembly
const pivotLocal = new THREE.Vector3(center.x, 0, bb.max.z - 1);
hinge.position.copy(pivotLocal);
// 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
const frontHolder = new THREE.Group();
frontHolder.add(meshFront);
// offset so when closed, front sits above back
meshFront.position.set(0, 0, 0);
frontHolder.position.set(-pivotLocal.x, 0, -pivotLocal.z);
hinge.add(frontHolder);
closedGap = backH - 2.2; // seat lip into groove (~LIP_H)
boardGroup = makeBoard();
boardGroup.scale.set(1, 1, 1);
dhtMesh = makeDht();
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);
}
root.add(meshBack);
root.add(hinge);
dhtGroup = makeDht();
root.add(backPivot);
root.add(frontPivot);
root.add(boardGroup);
root.add(dhtMesh);
// Center whole assembly at origin-ish
root.updateMatrixWorld(true);
const rootBox = new THREE.Box3().setFromObject(root);
const rootCenter = rootBox.getCenter(new THREE.Vector3());
root.position.sub(rootCenter);
root.add(dhtGroup);
setLid(0);
setExplode(0);
fitCamera(root);
$("dims").textContent =
`外轮廓约 ${size.x.toFixed(1)} × ${size.z.toFixed(1)} mm(后盖包围盒) · 开合度可拖动`;
setStatus("就绪:拖「开合度」或点合盖/开盖/播放装配");
fit(root);
setStatus(`就绪 · 后盖 ${backSize.x.toFixed(1)}×${backSize.y.toFixed(1)} mm · 点「合盖」或拖开合度`);
} catch (e) {
console.error(e);
setStatus("加载失败:请用 python -m http.server 8765 --directory cad 后打开 viewer.html", true);
setStatus("加载失败:请运行 python -m http.server 8765 --directory cad", true);
}
}
// UI bindings
$("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;
};
$("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("已停止动画"); };
$("btn-stop").onclick = () => { anim = null; setStatus("已停止"); };
$("chk-front").onchange = (e) => { if (hinge) hinge.visible = e.target.checked; };
$("chk-back").onchange = (e) => { if (meshBack) meshBack.visible = e.target.checked; };
$("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 (dhtMesh) dhtMesh.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) => setWire(e.target.checked);
$("chk-xray").onchange = (e) => setXray(e.target.checked);
$("chk-cut").onchange = (e) => setCut(e.target.checked);
$("cut").oninput = updateCut;
$("chk-ortho").onchange = (e) => {
if (e.target.checked) {
syncOrtho();
camera = cameraOrtho;
} else {
cameraPersp.position.copy(camera.position);
camera = cameraPersp;
$("chk-wire").onchange = (e) => {
for (const m of [matFront, matBack, matBoard, matScreen, matDht, matBlue]) {
m.wireframe = e.target.checked; m.needsUpdate = true;
}
controls.object = camera;
controls.update();
};
$("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");
};
document.querySelectorAll("[data-view]").forEach((btn) => {
btn.onclick = () => setView(btn.dataset.view);
});
$("btn-reset").onclick = () => { if (root.children.length) fitCamera(root); };
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),
};
camera.position.copy(map[name] || 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.href = renderer.domElement.toDataURL("image/png");
a.download = "cyd-case-preview.png";
a.download = "cyd-case.png";
a.href = canvas.toDataURL("image/png");
a.click();
setStatus("已截图下载");
};
addEventListener("resize", () => {
const aspect = innerWidth / innerHeight;
cameraPersp.aspect = aspect;
cameraPersp.updateProjectionMatrix();
syncOrtho();
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight, false);
});
// keyboard shortcuts
addEventListener("keydown", (e) => {
if (e.target.matches("input,textarea")) return;
if (e.key === "o") startAnim("open");
if (e.key === "c") startAnim("close");
if (e.key === "o") startAnim("open");
if (e.key === " ") { e.preventDefault(); startAnim("play"); }
if (e.key === "x") { $("chk-xray").checked = !$("chk-xray").checked; setXray($("chk-xray").checked); }
if (e.key === "w") { $("chk-wire").checked = !$("chk-wire").checked; setWire($("chk-wire").checked); }
});
function tick(now) {
requestAnimationFrame(tick);
tickAnim(now);
if (autoRotate) root.rotation.y += 0.004;
if (autoRotate) root.rotation.z += 0.003;
controls.update();
renderer.render(scene, camera);
}