Files
temperature/cad/viewer.html
DESKTOP-PUHFO60\dsx2016 5d9132d754 Enhance case viewer with lid open/close and assembly controls.
Add explode slider, mock board/DHT, cutaway, wireframe, views, and shortcuts.

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

573 lines
21 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 Climate Case — Interactive Preview</title>
<style>
:root {
--bg: #0b1220;
--panel: #121c2aee;
--text: #e8eef7;
--muted: #8a9bb5;
--accent: #3db8a8;
--line: #243247;
}
* { 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(360px, calc(100vw - 28px));
max-height: calc(100vh - 28px); overflow: auto;
background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
padding: 14px 14px 12px; backdrop-filter: blur(10px);
box-shadow: 0 12px 40px #0006;
}
.hud h1 { margin: 0 0 4px; font-size: 15px; font-weight: 650; }
.hint { margin: 0 0 12px; font-size: 11px; color: var(--muted); line-height: 1.4; }
.sec { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--line); }
.sec-title { font-size: 11px; color: var(--accent); letter-spacing: 0.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; }
button:disabled { opacity: 0.45; cursor: not-allowed; }
label.tog { display: inline-flex; align-items: center; gap: 5px; user-select: none; }
.slider {
display: grid; grid-template-columns: 72px 1fr 40px; gap: 8px; align-items: center;
margin: 6px 0; font-size: 12px; color: var(--muted);
}
.slider input[type=range] { 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; }
.dims { font-size: 11px; color: var(--muted); line-height: 1.5; }
</style>
</head>
<body>
<div class="hud">
<h1>CYD Climate Case</h1>
<p class="hint">左键旋转 · 滚轮缩放 · 右键平移 · 触屏双指缩放</p>
<div class="sec-title">合盖 / 开盖</div>
<div class="row">
<button type="button" id="btn-close">合盖</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="sec">
<div class="sec-title">分解距离</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>
<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 /> DHT示意</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-cut" /> 剖切</label>
<label class="tog"><input type="checkbox" id="chk-ortho" /> 正交</label>
</div>
<div class="slider" id="cut-row" style="display:none">
<span>剖切面</span>
<input type="range" id="cut" min="0" max="100" value="50" />
<span class="val" id="cut-val">50</span>
</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="side">侧面</button>
<button type="button" data-view="top">顶视</button>
<button type="button" id="btn-reset">复位</button>
<button type="button" id="btn-shot">截图</button>
</div>
</div>
<div class="sec dims" id="dims">外壳约 91.6 × 55.6 mm(外轮廓)</div>
<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;
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 controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.target.set(46, 8, 5);
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.4);
fill.position.set(-70, 50, -50);
scene.add(fill);
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: 0x6a7b92, metalness: 0.12, roughness: 0.52, flatShading: true,
side: THREE.DoubleSide, clippingPlanes: [], clipShadows: true,
});
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,
});
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 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, 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();
}
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 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 [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);
// Lay flat-ish for hinge animation: rotate STLs onto XZ plane
meshBack.rotation.x = -Math.PI / 2;
meshFront.rotation.x = -Math.PI / 2;
// 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(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);
$("dims").textContent =
`外轮廓约 ${size.x.toFixed(1)} × ${size.z.toFixed(1)} mm(后盖包围盒) · 开合度可拖动`;
setStatus("就绪:拖「开合度」或点合盖/开盖/播放装配");
} catch (e) {
console.error(e);
setStatus("加载失败:请用 python -m http.server 8765 --directory cad 后打开 viewer.html", 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;
};
$("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 (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.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", () => {
const aspect = innerWidth / innerHeight;
cameraPersp.aspect = aspect;
cameraPersp.updateProjectionMatrix();
syncOrtho();
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 === " ") { 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();
requestAnimationFrame(tick);
</script>
</body>
</html>