Files
temperature/cad/viewer.html
DESKTOP-PUHFO60\dsx2016 ce04e7bb1a Relocate DHT11 to rear snap bay and fix front/back closing lips.
Regenerate STLs, update viewer closed pose, and document assembly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 15:10:51 +08:00

491 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CYD Case — 合盖 / DHT后盖卡位</title>
<style>
:root {
--bg: #0b1220; --panel: #121c2aee; --text: #e8eef7; --muted: #8a9bb5;
--accent: #3db8a8; --line: #243247; --warm: #f0a04b;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
font-family: "Segoe UI", "PingFang SC", system-ui, sans-serif; overflow: hidden; }
#c { display: block; width: 100%; height: 100%; touch-action: none; }
.hud {
position: fixed; left: 14px; top: 14px; z-index: 2; width: min(370px, calc(100vw - 28px));
max-height: calc(100vh - 28px); overflow: auto; background: var(--panel);
border: 1px solid var(--line); border-radius: 14px; padding: 14px;
backdrop-filter: blur(10px); box-shadow: 0 12px 40px #0006;
}
h1 { margin: 0 0 4px; font-size: 15px; font-weight: 650; }
.hint { margin: 0 0 10px; font-size: 11px; color: var(--muted); line-height: 1.4; }
.sec { margin-top: 11px; padding-top: 10px; border-top: 1px solid var(--line); }
.sec-title { font-size: 11px; color: var(--accent); letter-spacing: .06em; margin-bottom: 8px; text-transform: uppercase; }
.row { display: flex; flex-wrap: wrap; gap: 6px; }
button, label.tog {
appearance: none; border: 1px solid #2c3d56; background: #1a2740; color: var(--text);
border-radius: 8px; padding: 6px 9px; font-size: 12px; cursor: pointer;
}
button:hover, label.tog:hover { border-color: var(--accent); }
button.active { background: #1e3d3a; border-color: var(--accent); color: #b6f0e6; }
label.tog { display: inline-flex; align-items: center; gap: 5px; user-select: none; }
.slider {
display: grid; grid-template-columns: 72px 1fr 42px; gap: 8px; align-items: center;
margin: 6px 0; font-size: 12px; color: var(--muted);
}
.slider input { width: 100%; accent-color: var(--accent); }
.slider .val { text-align: right; color: var(--text); font-variant-numeric: tabular-nums; }
#status { margin-top: 10px; font-size: 11px; color: var(--muted); }
#status.err { color: #e05a5a; }
.note { font-size: 11px; color: var(--warm); line-height: 1.45; margin-top: 6px; }
</style>
</head>
<body>
<div class="hud">
<h1>CYD Case Preview</h1>
<p class="hint">DHT11 在后盖外侧卡位,微微突出 · 前后盖止口合盖</p>
<div class="sec-title">合盖</div>
<div class="row">
<button type="button" id="btn-close" class="active">合盖</button>
<button type="button" id="btn-open">开盖</button>
<button type="button" id="btn-play">播放装配</button>
<button type="button" id="btn-stop">停止</button>
</div>
<div class="slider">
<span>开合度</span>
<input type="range" id="lid" min="0" max="100" value="0" />
<span class="val" id="lid-val">0%</span>
</div>
<div class="slider">
<span>分解</span>
<input type="range" id="explode" min="0" max="100" value="0" />
<span class="val" id="explode-val">0</span>
</div>
<div class="sec">
<div class="sec-title">部件</div>
<div class="row">
<label class="tog"><input type="checkbox" id="chk-front" checked /> 前框</label>
<label class="tog"><input type="checkbox" id="chk-back" checked /> 后盖</label>
<label class="tog"><input type="checkbox" id="chk-board" checked /> 模拟屏板</label>
<label class="tog"><input type="checkbox" id="chk-dht" checked /> DHT11模块</label>
</div>
</div>
<div class="sec">
<div class="sec-title">显示</div>
<div class="row">
<label class="tog"><input type="checkbox" id="chk-grid" checked /> 网格</label>
<label class="tog"><input type="checkbox" id="chk-auto" /> 自转</label>
<label class="tog"><input type="checkbox" id="chk-wire" /> 线框</label>
<label class="tog"><input type="checkbox" id="chk-xray" /> 透视</label>
<label class="tog"><input type="checkbox" id="chk-backview" /> 看后盖</label>
</div>
</div>
<div class="sec">
<div class="sec-title">视角</div>
<div class="row">
<button type="button" data-view="iso">等轴</button>
<button type="button" data-view="front">正面</button>
<button type="button" data-view="back">背面</button>
<button type="button" data-view="side">侧面</button>
<button type="button" id="btn-reset">复位</button>
<button type="button" id="btn-shot">截图</button>
</div>
</div>
<p class="note">合盖=0%:前框止口落入后盖槽。DHT 从后盖背面卡入并突出约 1.8mm。</p>
<div id="status">加载中…</div>
</div>
<canvas id="c"></canvas>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.170.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.170.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { STLLoader } from "three/addons/loaders/STLLoader.js";
const $ = (id) => document.getElementById(id);
const statusEl = $("status");
const canvas = $("c");
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;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1220);
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;
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
const key = new THREE.DirectionalLight(0xffffff, 1.1);
key.position.set(80, 120, 60);
scene.add(key);
const fill = new THREE.DirectionalLight(0x88aacc, 0.4);
fill.position.set(-60, 40, -80);
scene.add(fill);
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 matFront = new THREE.MeshStandardMaterial({
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,
});
const matBoard = new THREE.MeshStandardMaterial({ color: 0xc9a227, metalness: 0.3, roughness: 0.4, flatShading: true });
const matScreen = new THREE.MeshStandardMaterial({
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, 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;
statusEl.classList.toggle("err", err);
}
function loadSTL(url) {
const loader = new STLLoader();
return new Promise((resolve, reject) => {
loader.load(url, (geo) => {
geo.computeVertexNormals();
resolve(geo);
}, undefined, reject);
});
}
/** 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, 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, 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);
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, 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(sensor);
return g;
}
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.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();
}
/**
* 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 (!backPivot || !frontPivot) return;
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);
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);
}
if (boardGroup) {
// 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 (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, ui = true) {
lidT = THREE.MathUtils.clamp(v, 0, 1);
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, ui = true) {
explodeT = THREE.MathUtils.clamp(v, 0, 1);
if (ui) {
$("explode").value = String(Math.round(explodeT * 100));
$("explode-val").textContent = String(Math.round(explodeT * 100));
}
applyPose();
}
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) {
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.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));
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));
if (u >= 1) { anim = null; setStatus("已合盖:前框止口落入后盖"); }
}
return;
}
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 ? "已合盖" : "已开盖");
}
}
async function boot() {
try {
const [geoF, geoB] = await Promise.all([
loadSTL("./export/front.stl"),
loadSTL("./export/back.stl"),
]);
meshBack = new THREE.Mesh(geoB, matBack);
meshFront = new THREE.Mesh(geoF, matFront);
backSize = normalizeMesh(meshBack);
const frontSize = normalizeMesh(meshFront);
// Align both on XY; back Z=0 exterior, grows +Z; front will stack on rim
backPivot = new THREE.Group();
backPivot.add(meshBack);
frontPivot = new THREE.Group();
frontPivot.add(meshFront);
// 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
closedGap = backH - 2.2; // seat lip into groove (~LIP_H)
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);
}
dhtGroup = makeDht();
root.add(backPivot);
root.add(frontPivot);
root.add(boardGroup);
root.add(dhtGroup);
setLid(0);
setExplode(0);
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", true);
}
}
$("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("已停止"); };
$("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 (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) => {
for (const m of [matFront, matBack, matBoard, matScreen, matDht, matBlue]) {
m.wireframe = e.target.checked; m.needsUpdate = true;
}
};
$("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");
};
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.download = "cyd-case.png";
a.href = canvas.toDataURL("image/png");
a.click();
};
addEventListener("resize", () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight, false);
});
addEventListener("keydown", (e) => {
if (e.target.matches("input,textarea")) return;
if (e.key === "c") startAnim("close");
if (e.key === "o") startAnim("open");
if (e.key === " ") { e.preventDefault(); startAnim("play"); }
});
function tick(now) {
requestAnimationFrame(tick);
tickAnim(now);
if (autoRotate) root.rotation.z += 0.003;
controls.update();
renderer.render(scene, camera);
}
boot();
requestAnimationFrame(tick);
</script>
</body>
</html>