diff --git a/README.md b/README.md index 86e1dc3..2af6f13 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,20 @@ pio device monitor -b 115200 | `cyd_28r_inv` | 颜色反相 / 发白 | | `cyd_28_st7789` | USB-C 双口 / ST7789 变体 | +## 3D 外壳 + +桌面斜立分体壳(前框 + 后盖支架 + DHT11 通风仓),可直接打印: + +- STL:[`cad/export/front.stl`](cad/export/front.stl)、[`cad/export/back.stl`](cad/export/back.stl) +- 参数源 / 说明:[`cad/README.md`](cad/README.md) + +```bash +pip install trimesh manifold3d numpy +python cad/generate_stl.py +``` + +固定用 `4 × M3×10`;DHT11 装后盖通风仓,线接 CN1。 + ## 项目结构 ``` @@ -46,6 +60,7 @@ platformio.ini # 板级与库依赖 include/lv_conf.h # LVGL 配置 src/main.cpp # 仪表盘主程序 scripts/exclude_lvgl_asm.py # 排除 ESP32 不兼容的 LVGL ASM +cad/ # 3D 外壳(OpenSCAD + STL) .cursor/rules/ # Cursor 协作规则 README.md # 本文档 ``` @@ -66,3 +81,4 @@ README.md # 本文档 | 2026-10-17 | 初版:LVGL 仪表盘 + DHT11(GPIO22),平滑/舒适度/趋势/背光 | | 2026-10-17 | 曾加入 ECO 熄屏休眠,后改回常亮正常版 | | 2026-10-17 | 初始化 Git,补充 README 与提交约定 | +| 2026-10-17 | 新增 CYD+DHT11 桌面斜立 3D 外壳(OpenSCAD / STL) | diff --git a/cad/README.md b/cad/README.md new file mode 100644 index 0000000..8aaf8af --- /dev/null +++ b/cad/README.md @@ -0,0 +1,59 @@ +# CYD 温湿度仪表盘 3D 外壳 + +桌面斜立分体外壳,适配 **ESP32-2432S028R(CYD)** + **DHT11(CN1 / GPIO22)**。 + +## 零件 + +| 文件 | 说明 | +|------|------| +| `export/front.stl` | 前框(屏窗 + 压边) | +| `export/back.stl` | 后盖 + 斜立支脚 + DHT11 通风仓 | +| `cyd_climate_case.scad` | 参数化源文件(OpenSCAD,可改尺寸) | +| `generate_stl.py` | 无 OpenSCAD 时用 Python 导出 STL | + +## 设计要点 + +- 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` 顶部参数后重新导出。 + +## 装配 + +1. DHT11 放入后盖通风仓,杜邦线经线槽接到 **CN1**(`3V3 / IO22 / GND`,按你现有接线) +2. CYD 屏朝前框放入,USB 对齐侧孔 +3. 合上后盖,四角 M3×10 拧紧(勿过紧防裂) +4. 桌面斜立放置;点屏可调背光 + +若孔位与你的板子有 ±1 mm 偏差,优先改 `generate_stl.py` 里 `USB_Y` / `MH_INSET` 后重导。 diff --git a/cad/cyd_climate_case.scad b/cad/cyd_climate_case.scad new file mode 100644 index 0000000..e8a64a8 --- /dev/null +++ b/cad/cyd_climate_case.scad @@ -0,0 +1,300 @@ +/* + * CYD ESP32-2432S028R + DHT11 桌面斜立外壳 + * Climate Dashboard Case — parametric OpenSCAD + * + * 零件: + * part = "front" 前框(屏窗) + * part = "back" 后盖 + 斜立支架 + DHT 通风仓 + * part = "assembly" 装配预览(半透明板子) + * + * 导出 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 + */ + +/* [Which part] */ +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 孔 + +/* [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; + +/* [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; // 支脚接触深度 + +/* [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 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; + + difference() { + union() { + // 前表面 + rounded_rect(outer_l, outer_w, bezel_front_t, 3); + // 侧壁向下包住屏+PCB 前半 + translate([0, 0, bezel_front_t]) + 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); + } + } + + // 可视窗 + 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]); + } + } +} + +module dht_bay() { + // 局部:仓体贴在 outer 长边外侧 + 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]); + } +} + +// 带斜立支架的后盖(打印时支脚朝下) +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]) + 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([outer_l * 0.5 - 1.2, -stand_foot + 1, wall]) + cube([2.4, stand_foot - 1, shell_h - wall - 1]); + } +} + +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_bezel(); +else if (part == "back") + back_with_stand(); +else + assembly(); diff --git a/cad/export/back.stl b/cad/export/back.stl new file mode 100644 index 0000000..77f0eb2 Binary files /dev/null and b/cad/export/back.stl differ diff --git a/cad/export/front.stl b/cad/export/front.stl new file mode 100644 index 0000000..1daf666 Binary files /dev/null and b/cad/export/front.stl differ diff --git a/cad/generate_stl.py b/cad/generate_stl.py new file mode 100644 index 0000000..122f28c --- /dev/null +++ b/cad/generate_stl.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Generate printable STL for CYD climate case using trimesh + manifold boolean.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import trimesh +from trimesh.creation import box, cylinder + +# ---- mm, aligned with cyd_climate_case.scad ---- +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 +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 +SCREW = 3.3 +BOSS_OD, BOSS_ID = 7.2, 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 + +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), +] + + +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 + m = box(extents=[dx, dy, dz]) + m.apply_translation([x0 + dx / 2, y0 + dy / 2, z0 + dz / 2]) + return m + + +def u(*meshes: trimesh.Trimesh) -> trimesh.Trimesh: + result = meshes[0] + for m in meshes[1:]: + result = result.union(m, engine="manifold") + return result + + +def d(a: trimesh.Trimesh, *subs: trimesh.Trimesh) -> trimesh.Trimesh: + result = a + for s in subs: + result = result.difference(s, engine="manifold") + return result + + +def make_front() -> trimesh.Trimesh: + skirt_h = DISP_H + PCB_T_HALF + z1 = BEZEL_T + skirt_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) + + 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) + + # 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) + + out = d(body, cavity, view, pocket) + + 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]) + 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) + + # 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) + + # SD slot on +Y + sd = aabb(WALL + GAP + 28, OUTER_W - WALL - 1, WALL + 2, WALL + GAP + 44, OUTER_W + 1, WALL + 5.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]) + + # vent slots on floor + 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) + + out = d(body, cavity, usb, sd, btn1, btn2, *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) + 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 + 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 = 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) + 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) + + out.merge_vertices() + return out + + +def main() -> None: + out_dir = Path(__file__).resolve().parent / "export" + out_dir.mkdir(parents=True, exist_ok=True) + + print("Building front…") + front = make_front() + print("Building back…") + 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"Wrote {out_dir / 'front.stl'}") + print(f"Wrote {out_dir / 'back.stl'}") + + +if __name__ == "__main__": + main()