diff --git a/README.md b/README.md index aad871c..bbe09f1 100644 --- a/README.md +++ b/README.md @@ -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 后盖卡位微突出;前后盖止口合盖 | diff --git a/cad/README.md b/cad/README.md index 1e9b35a..bfaddf5 100644 --- a/cad/README.md +++ b/cad/README.md @@ -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. 桌面斜立使用 diff --git a/cad/cyd_climate_case.scad b/cad/cyd_climate_case.scad index e8a64a8..8c795e6 100644 --- a/cad/cyd_climate_case.scad +++ b/cad/cyd_climate_case.scad @@ -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(); diff --git a/cad/export/back.stl b/cad/export/back.stl index 77f0eb2..3bb6f18 100644 Binary files a/cad/export/back.stl and b/cad/export/back.stl differ diff --git a/cad/export/front.stl b/cad/export/front.stl index 1daf666..bfdb986 100644 Binary files a/cad/export/front.stl and b/cad/export/front.stl differ diff --git a/cad/generate_stl.py b/cad/generate_stl.py index 122f28c..c76a900 100644 --- a/cad/generate_stl.py +++ b/cad/generate_stl.py @@ -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__": diff --git a/cad/viewer.html b/cad/viewer.html index 020ea3a..9437581 100644 --- a/cad/viewer.html +++ b/cad/viewer.html @@ -3,31 +3,26 @@ - CYD Climate Case — Interactive Preview + CYD Case — 合盖 / DHT后盖卡位
-

CYD Climate Case

-

左键旋转 · 滚轮缩放 · 右键平移 · 触屏双指缩放

+

CYD Case Preview

+

DHT11 在后盖外侧卡位,微微突出 · 前后盖止口合盖

-
合盖 / 开盖
+
合盖
- + @@ -65,14 +59,10 @@ 0%
- -
-
分解距离
-
- 分解 - - 0 -
+
+ 分解 + + 0
@@ -80,8 +70,8 @@
- - + +
@@ -92,13 +82,7 @@ - - -
-
@@ -107,14 +91,14 @@
+ -
-
外壳约 91.6 × 55.6 mm(外轮廓)
+

合盖=0%:前框止口落入后盖槽。DHT 从后盖背面卡入并突出约 1.8mm。

加载中…
@@ -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); }