diff --git a/README.md b/README.md
index a327884..aad871c 100644
--- a/README.md
+++ b/README.md
@@ -89,3 +89,4 @@ README.md # 本文档
| 2026-10-17 | 初始化 Git,补充 README 与提交约定 |
| 2026-10-17 | 新增 CYD+DHT11 桌面斜立 3D 外壳(OpenSCAD / STL) |
| 2026-10-17 | 增加浏览器 Three.js STL 预览页 `cad/viewer.html` |
+| 2026-10-17 | 预览增强:合盖/开盖、分解、装配动画、剖切/线框等 |
diff --git a/cad/README.md b/cad/README.md
index 6a1bd25..1e9b35a 100644
--- a/cad/README.md
+++ b/cad/README.md
@@ -12,9 +12,21 @@ python -m http.server 8765 --directory cad
然后打开:**http://localhost:8765/viewer.html**
-可旋转 / 缩放,切换仅前框、仅后盖,或分解视图。
+### 预览操作
-其它查看方式:用 [Bambu Studio](https://bambulab.com/)、Cura、PrusaSlicer 直接打开 `export/*.stl`;或把 STL 拖进 [viewstl.com](https://www.viewstl.com/) / [3dviewer.net](https://3dviewer.net/)。
+| 操作 | 说明 |
+|------|------|
+| 合盖 / 开盖 | 前框铰链开合动画 |
+| 开合度滑条 | 0% 合拢 → 100% 打开 |
+| 分解滑条 | 拉开零件看内部 |
+| 播放装配 | 分解 → 合拢 → 合盖 全程 |
+| 模拟板 / DHT | 显示 PCB 与传感器示意 |
+| 线框 / 透视 / 剖切 | 看结构与开孔 |
+| 等轴/正/侧/顶 | 快速视角 |
+| 截图 | 导出 PNG |
+| 快捷键 | `O` 开盖 · `C` 合盖 · `空格` 播放 · `W` 线框 · `X` 透视 |
+
+其它查看方式:切片软件打开 `export/*.stl`,或 [viewstl.com](https://www.viewstl.com/) / [3dviewer.net](https://3dviewer.net/)。
## 零件
diff --git a/cad/viewer.html b/cad/viewer.html
index 85552f7..020ea3a 100644
--- a/cad/viewer.html
+++ b/cad/viewer.html
@@ -3,54 +3,118 @@
CYD Climate Case
-
拖拽旋转 · 滚轮缩放 · 右键平移
+
左键旋转 · 滚轮缩放 · 右键平移 · 触屏双指缩放
+
+
合盖 / 开盖
-
-
-
-
+
+
+
+
-
-
-
-
+
+ 开合度
+
+ 0%
+
+
+
+
+
+
+
+
+
视角
+
+
+
+
+
+
+
+
+
+
+
外壳约 91.6 × 55.6 mm(外轮廓)
加载中…
@@ -68,158 +132,441 @@
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { STLLoader } from "three/addons/loaders/STLLoader.js";
- const statusEl = document.getElementById("status");
- const canvas = document.getElementById("c");
+ const $ = (id) => document.getElementById(id);
+ const statusEl = $("status");
+ const canvas = $("c");
- const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true });
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 camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 2000);
- camera.position.set(120, 90, 140);
+ 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 controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
- controls.target.set(45, 10, 20);
+ controls.target.set(46, 8, 5);
- scene.add(new THREE.AmbientLight(0xffffff, 0.55));
- const key = new THREE.DirectionalLight(0xffffff, 1.05);
- key.position.set(80, 120, 60);
+ scene.add(new THREE.AmbientLight(0xffffff, 0.5));
+ const key = new THREE.DirectionalLight(0xffffff, 1.1);
+ key.position.set(90, 130, 70);
scene.add(key);
- const fill = new THREE.DirectionalLight(0x88aacc, 0.45);
- fill.position.set(-60, 40, -40);
+ const fill = new THREE.DirectionalLight(0x88aacc, 0.4);
+ fill.position.set(-70, 50, -50);
scene.add(fill);
- const grid = new THREE.GridHelper(200, 20, 0x3db8a8, 0x1e2c40);
- grid.position.y = -20;
+ const grid = new THREE.GridHelper(220, 22, 0x3db8a8, 0x1e2c40);
+ grid.position.y = -22;
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: 0x5a6a80, metalness: 0.15, roughness: 0.55, flatShading: true,
+ color: 0x6a7b92, metalness: 0.12, roughness: 0.52, flatShading: true,
+ side: THREE.DoubleSide, clippingPlanes: [], clipShadows: true,
});
const matBack = new THREE.MeshStandardMaterial({
- color: 0x3db8a8, metalness: 0.12, roughness: 0.5, flatShading: true,
+ 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,
+ });
+ 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,
});
let meshFront = null;
let meshBack = null;
- let exploded = false;
- let autoRotate = true;
+ 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,
+ };
function setStatus(msg, err = false) {
statusEl.textContent = msg;
statusEl.classList.toggle("err", err);
}
+ function loadSTL(url) {
+ const loader = new STLLoader();
+ return new Promise((resolve, reject) => {
+ loader.load(url, (geo) => {
+ geo.computeVertexNormals();
+ geo.center();
+ resolve(geo);
+ }, undefined, reject);
+ });
+ }
+
+ function makeBoard() {
+ 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,
+ }));
+ bezel.position.set(4, 1.2, 0);
+ g.add(bezel);
+ g.add(pcb);
+ 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);
+ }
+ 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;
+ g.add(body);
+ g.add(grill);
+ return g;
+ }
+
function fitCamera(object) {
const box = new THREE.Box3().setFromObject(object);
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);
- const dist = maxDim * 1.8;
- camera.position.set(center.x + dist * 0.7, center.y + dist * 0.45, center.z + dist * 0.7);
- camera.near = maxDim / 100;
- camera.far = maxDim * 50;
- camera.updateProjectionMatrix();
+ 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);
controls.update();
}
- const loader = new STLLoader();
+ 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();
+ }
- function loadSTL(url, material) {
- return new Promise((resolve, reject) => {
- loader.load(
- url,
- (geometry) => {
- geometry.computeVertexNormals();
- const mesh = new THREE.Mesh(geometry, material);
- // STL is in mm; keep 1 unit = 1 mm
- resolve(mesh);
- },
- undefined,
- reject
- );
- });
+ function applyPose() {
+ if (!hinge || !meshBack) return;
+
+ // explode separates parts along Y / hinge axis
+ const ex = explodeT * 28;
+ meshBack.position.set(0, -ex * 0.35, 0);
+
+ // 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);
+
+ // board sits between shells
+ if (boardGroup) {
+ boardGroup.position.set(0, 4 + ex * 0.15, 0);
+ boardGroup.rotation.set(0, 0, 0);
+ }
+ if (dhtMesh) {
+ dhtMesh.position.set(28, 2 + ex * 0.1, 36);
+ }
+ }
+
+ function setLid(v, updateUi = true) {
+ lidT = THREE.MathUtils.clamp(v, 0, 1);
+ if (updateUi) {
+ $("lid").value = String(Math.round(lidT * 100));
+ $("lid-val").textContent = `${Math.round(lidT * 100)}%`;
+ }
+ applyPose();
+ }
+
+ function setExplode(v, updateUi = true) {
+ explodeT = THREE.MathUtils.clamp(v, 0, 1);
+ if (updateUi) {
+ $("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 tickAnim(now) {
+ if (!anim) return;
+ const u = ease(Math.min(1, (now - anim.t0) / anim.dur));
+ if (anim.kind === "play") {
+ if (anim.phase === 0) {
+ setLid(THREE.MathUtils.lerp(anim.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 };
+ }
+ } 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 };
+ }
+ } else {
+ setLid(THREE.MathUtils.lerp(1, 0, u), true);
+ 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);
+ 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 [front, back] = await Promise.all([
- loadSTL("./export/front.stl", matFront),
- loadSTL("./export/back.stl", matBack),
+ const [geoF, geoB] = await Promise.all([
+ loadSTL("./export/front.stl"),
+ loadSTL("./export/back.stl"),
]);
- meshFront = front;
- meshBack = back;
- // Lay parts for a readable assembly preview:
- // back on "desk", front slightly above / in front
+ meshBack = new THREE.Mesh(geoB, matBack);
+ meshFront = new THREE.Mesh(geoF, matFront);
+
+ // Lay flat-ish for hinge animation: rotate STLs onto XZ plane
meshBack.rotation.x = -Math.PI / 2;
meshFront.rotation.x = -Math.PI / 2;
- meshFront.position.y = 12;
+
+ // 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());
+
+ 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);
+
+ 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);
+
+ boardGroup = makeBoard();
+ boardGroup.scale.set(1, 1, 1);
+ dhtMesh = makeDht();
root.add(meshBack);
- root.add(meshFront);
+ root.add(hinge);
+ 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);
+
+ setLid(0);
+ setExplode(0);
fitCamera(root);
- setStatus("已加载 front.stl + back.stl(单位 mm)");
+ $("dims").textContent =
+ `外轮廓约 ${size.x.toFixed(1)} × ${size.z.toFixed(1)} mm(后盖包围盒) · 开合度可拖动`;
+ setStatus("就绪:拖「开合度」或点合盖/开盖/播放装配");
} catch (e) {
console.error(e);
- setStatus(
- "加载失败:请用本地 HTTP 打开(不要双击 file://)。在项目根目录运行: python -m http.server 8765",
- true
- );
+ setStatus("加载失败:请用 python -m http.server 8765 --directory cad 后打开 viewer.html", true);
}
}
- function setMode(mode) {
- document.querySelectorAll(".row button").forEach((b) => b.classList.remove("active"));
- if (mode === "both") document.getElementById("btn-both").classList.add("active");
- if (mode === "front") document.getElementById("btn-front").classList.add("active");
- if (mode === "back") document.getElementById("btn-back").classList.add("active");
- if (meshFront) meshFront.visible = mode !== "back";
- if (meshBack) meshBack.visible = mode !== "front";
- }
+ // 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;
+ };
+ $("btn-close").onclick = () => startAnim("close");
+ $("btn-open").onclick = () => startAnim("open");
+ $("btn-play").onclick = () => startAnim("play");
+ $("btn-stop").onclick = () => { anim = null; setStatus("已停止动画"); };
- document.getElementById("btn-both").onclick = () => setMode("both");
- document.getElementById("btn-front").onclick = () => setMode("front");
- document.getElementById("btn-back").onclick = () => setMode("back");
- document.getElementById("btn-explode").onclick = () => {
- exploded = !exploded;
- if (meshFront) meshFront.position.y = exploded ? 28 : 12;
- if (meshBack) meshBack.position.y = exploded ? -8 : 0;
+ $("chk-front").onchange = (e) => { if (hinge) hinge.visible = e.target.checked; };
+ $("chk-back").onchange = (e) => { if (meshBack) meshBack.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-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;
+ }
+ controls.object = camera;
+ controls.update();
};
- document.getElementById("chk-grid").onchange = (e) => {
- grid.visible = e.target.checked;
- };
- document.getElementById("chk-auto").onchange = (e) => {
- autoRotate = e.target.checked;
- };
- document.getElementById("btn-reset").onclick = () => {
- if (root.children.length) fitCamera(root);
+
+ document.querySelectorAll("[data-view]").forEach((btn) => {
+ btn.onclick = () => setView(btn.dataset.view);
+ });
+ $("btn-reset").onclick = () => { if (root.children.length) fitCamera(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.click();
+ setStatus("已截图下载");
};
addEventListener("resize", () => {
- camera.aspect = innerWidth / innerHeight;
- camera.updateProjectionMatrix();
+ const aspect = innerWidth / innerHeight;
+ cameraPersp.aspect = aspect;
+ cameraPersp.updateProjectionMatrix();
+ syncOrtho();
renderer.setSize(innerWidth, innerHeight, false);
});
- function tick() {
+ // 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 === " ") { 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;
controls.update();
renderer.render(scene, camera);
}
boot();
- tick();
+ requestAnimationFrame(tick);