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 — 3D Preview + CYD Climate Case — Interactive Preview

CYD Climate Case

-

拖拽旋转 · 滚轮缩放 · 右键平移

+

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

+ +
合盖 / 开盖
- - - - + + + +
-
- - - +
+ 开合度 + + 0%
+ +
+
分解距离
+
+ 分解 + + 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);