Files
temperature/cad/viewer.html
DESKTOP-PUHFO60\dsx2016 482be39c01 Document stable tag v1.1-case-snaps for easy rollback.
Keep branch keep/v1.1-case-snaps alongside the prior v1.0-case-closed snapshot.

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

278 lines
13 KiB
HTML
Raw Permalink 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 — 合盖预览</title>
<style>
:root { --bg:#0b1220; --panel:#121c2aee; --text:#e8eef7; --muted:#8a9bb5; --accent:#3db8a8; --line:#243247; --warn:#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%; }
.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; backdrop-filter:blur(10px); }
h1 { margin:0 0 4px; font-size:15px; } .hint { margin:0 0 10px; font-size:11px; color:var(--muted); line-height:1.45; }
.sec { margin-top:10px; padding-top:10px; border-top:1px solid var(--line); }
.t { 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; }
.slider { display:grid; grid-template-columns:64px 1fr 40px; gap:8px; align-items:center; margin:6px 0; font-size:12px; color:var(--muted); }
.slider input { width:100%; accent-color:var(--accent); }
.val { text-align:right; color:var(--text); }
#status { margin-top:10px; font-size:11px; color:var(--muted); }
#status.err { color:#e05a5a; }
.faq { font-size:11px; color:var(--warn); line-height:1.5; margin-top:8px; }
</style>
</head>
<body>
<div class="hud">
<h1>CYD 外壳预览</h1>
<p class="hint">左键旋转 · 滚轮缩放 · 右键平移</p>
<div class="t">合盖</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>
</div>
<div class="slider"><span>开合度</span><input id="lid" type="range" min="0" max="100" value="0" /><span class="val" id="lidv">0%</span></div>
<div class="slider"><span>分解</span><input id="exp" type="range" min="0" max="100" value="0" /><span class="val" id="expv">0</span></div>
<div class="sec">
<div class="t">部件</div>
<div class="row">
<label class="tog"><input type="checkbox" id="cf" checked /> 前框</label>
<label class="tog"><input type="checkbox" id="cb" checked /> 后盖</label>
<label class="tog"><input type="checkbox" id="cd" checked /> DHT示意</label>
<label class="tog"><input type="checkbox" id="cx" /> 透视外壳</label>
</div>
</div>
<div class="sec">
<div class="t">视角</div>
<div class="row">
<button type="button" data-v="iso">等轴</button>
<button type="button" data-v="front">看屏幕</button>
<button type="button" data-v="back">看后盖/DHT</button>
<button type="button" id="reset">复位</button>
</div>
</div>
<p class="faq">
v1.1:外轮廓圆角 + 长边四侧悬臂卡扣合盖;后盖中心微凸是 <b>DHT11 卡位通风口</b>。<br/>
已去掉旁边多余的大“格子”。稳定版标签 <code>v1.1-case-snaps</code>;旧无卡扣版 <code>v1.0-case-closed</code>。
</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 st = $("status");
const canvas = $("c");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: 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);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
const L1 = new THREE.DirectionalLight(0xffffff, 1.05); L1.position.set(70, 100, 80); scene.add(L1);
const L2 = new THREE.DirectionalLight(0x88aacc, 0.4); L2.position.set(-50, 40, -60); scene.add(L2);
const grid = new THREE.GridHelper(200, 20, 0x3db8a8, 0x1e2c40); grid.position.y = -20; scene.add(grid);
const root = new THREE.Group();
scene.add(root);
const matF = new THREE.MeshStandardMaterial({ color: 0x6a7b92, roughness: 0.5, flatShading: true, side: THREE.DoubleSide });
const matB = new THREE.MeshStandardMaterial({ color: 0x2f9f92, roughness: 0.48, flatShading: true, side: THREE.DoubleSide });
const matD = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.65, flatShading: true });
const matS = new THREE.MeshStandardMaterial({ color: 0x2a5cff, roughness: 0.4 });
let frontG, backG, dhtG, mate;
let lid = 0, exp = 0, anim = null;
function msg(t, err=false) { st.textContent = t; st.classList.toggle("err", err); }
function loadSTL(url, mat) {
return new Promise((res, rej) => {
new STLLoader().load(url, (geo) => {
geo.computeVertexNormals();
// keep authoring coords (min at ~0)
const m = new THREE.Mesh(geo, mat);
res(m);
}, undefined, rej);
});
}
function fit() {
const box = new THREE.Box3().setFromObject(root);
const size = box.getSize(new THREE.Vector3());
const c = box.getCenter(new THREE.Vector3());
controls.target.copy(c);
const d = Math.max(size.x, size.y, size.z) * 1.75;
camera.position.set(c.x + d * 0.7, c.y - d * 0.55, c.z + d * 0.45);
camera.near = d / 200; camera.far = d * 50; camera.updateProjectionMatrix();
controls.update();
}
/**
* Authoring coords (generate_stl.py):
* back: z=0 exterior (+DHT proud negative), rim/female at z=back_body_h .. +lip
* front: z=0 screen face, male lip at z=front_body_h .. +lip
*
* Closed: flip front 180° about X so lip points down into back groove,
* place so lip nests in [back_body_h, back_body_h+lip_h].
*/
function applyPose() {
if (!frontG || !backG || !mate) return;
const { outer_w, front_body_h, back_body_h, lip_h } = mate;
const frontH = front_body_h + lip_h;
backG.position.set(0, 0, -exp * 20);
backG.rotation.set(0, 0, 0);
// closed base placement
const insert = lip_h * 0.9; // how deep lip sits in groove
// After rotX=PI: worldZ = Pz - localZ, worldY = Py - localY
// Want lip tip (localZ=frontH) at worldZ = back_body_h + lip_h - insert
// => Pz - frontH = back_body_h + lip_h - insert
// => Pz = back_body_h + lip_h - insert + frontH
const closedPz = back_body_h + lip_h - insert + frontH;
// open: lift along Z and hinge around top edge (y = outer_w)
const openAng = lid * THREE.MathUtils.degToRad(100);
const lift = lid * 22 + exp * 28;
frontG.rotation.set(Math.PI + openAng, 0, 0);
// Y compensation for PI flip: Py = outer_w so y maps correctly when closed
// Extra hinge: approximate by shifting
const hy = Math.sin(openAng) * (outer_w * 0.35);
const hz = (1 - Math.cos(openAng)) * (outer_w * 0.15);
frontG.position.set(0, outer_w + hy, closedPz + lift + hz);
if (dhtG) {
dhtG.position.set(mate.outer_l / 2, mate.outer_w / 2, -0.2 - exp * 2);
}
}
function setLid(v, ui=true) {
lid = THREE.MathUtils.clamp(v, 0, 1);
if (ui) { $("lid").value = Math.round(lid*100); $("lidv").textContent = `${Math.round(lid*100)}%`; }
$("btn-close").classList.toggle("active", lid < 0.05);
$("btn-open").classList.toggle("active", lid > 0.95);
applyPose();
}
function setExp(v, ui=true) {
exp = THREE.MathUtils.clamp(v, 0, 1);
if (ui) { $("exp").value = Math.round(exp*100); $("expv").textContent = Math.round(exp*100); }
applyPose();
}
function ease(t){ return t<.5?2*t*t:1-Math.pow(-2*t+2,2)/2; }
function play(kind){
const now = performance.now();
if (kind==="close") anim={k:"c",t0:now,dur:800,fl:lid,tl:0,fe:exp,te:0};
if (kind==="open") anim={k:"o",t0:now,dur:800,fl:lid,tl:1,fe:exp,te:0};
if (kind==="play") anim={k:"p",phase:0,t0:now,dur:650,fl:lid,tl:1,fe:exp,te:1};
msg(kind==="close"?"合盖中…":kind==="open"?"开盖中…":"装配播放…");
}
function tickAnim(now){
if(!anim) return;
const u=ease(Math.min(1,(now-anim.t0)/anim.dur));
if(anim.k==="p"){
if(anim.phase===0){ setLid(THREE.MathUtils.lerp(anim.fl,1,u)); setExp(THREE.MathUtils.lerp(anim.fe,1,u));
if(u>=1) anim={k:"p",phase:1,t0:now,dur:700,fl:1,tl:1,fe:1,te:0}; }
else if(anim.phase===1){ setExp(THREE.MathUtils.lerp(1,0,u));
if(u>=1) anim={k:"p",phase:2,t0:now,dur:850,fl:1,tl:0,fe:0,te:0}; }
else { setLid(THREE.MathUtils.lerp(1,0,u)); if(u>=1){ anim=null; msg("已合盖:前框止口落入后盖槽"); } }
return;
}
setLid(THREE.MathUtils.lerp(anim.fl,anim.tl,u));
setExp(THREE.MathUtils.lerp(anim.fe,anim.te,u));
if(u>=1){ anim=null; msg(lid<0.05?"已合盖":"已开盖"); }
}
async function boot(){
try {
mate = await (await fetch("./export/mate.json")).json();
const [fm, bm] = await Promise.all([
loadSTL("./export/front.stl", matF),
loadSTL("./export/back.stl", matB),
]);
frontG = new THREE.Group(); frontG.add(fm);
backG = new THREE.Group(); backG.add(bm);
// DHT visual in the proud grill area
dhtG = new THREE.Group();
const body = new THREE.Mesh(new THREE.BoxGeometry(15.5, 12, 6.5), matD);
const tip = new THREE.Mesh(new THREE.BoxGeometry(11, 8.5, 2), matS);
tip.position.z = -4.2;
dhtG.add(body); dhtG.add(tip);
root.add(backG); root.add(frontG); root.add(dhtG);
setLid(0); setExp(0); fit();
const ver = mate.version ? ` · ${mate.version}` : "";
msg(`合盖对齐已按 mate.json${ver} · 外壳 ${mate.outer_l}×${mate.outer_w} mm · R${mate.corner_r ?? "?"} · DHT 突出 ${mate.dht_proud} mm`);
} catch (e) {
console.error(e);
msg("加载失败:请用 python -m http.server 8765 --directory cad", true);
}
}
$("lid").oninput = e => { anim=null; setLid(+e.target.value/100,false); $("lidv").textContent=e.target.value+"%"; };
$("exp").oninput = e => { anim=null; setExp(+e.target.value/100,false); $("expv").textContent=e.target.value; };
$("btn-close").onclick = () => play("close");
$("btn-open").onclick = () => play("open");
$("btn-play").onclick = () => play("play");
$("cf").onchange = e => { if(frontG) frontG.visible=e.target.checked; };
$("cb").onchange = e => { if(backG) backG.visible=e.target.checked; };
$("cd").onchange = e => { if(dhtG) dhtG.visible=e.target.checked; };
$("cx").onchange = e => {
for (const m of [matF, matB]) {
m.transparent = e.target.checked; m.opacity = e.target.checked ? 0.35 : 1;
m.depthWrite = !e.target.checked; m.needsUpdate = true;
}
};
function view(n){
const t=controls.target.clone(); const d=camera.position.distanceTo(t)||160;
const map={
iso:new THREE.Vector3(t.x+d*.7,t.y-d*.5,t.z+d*.45),
front:new THREE.Vector3(t.x,t.y,t.z+d),
back:new THREE.Vector3(t.x,t.y,t.z-d),
};
camera.position.copy(map[n]||map.iso); controls.update();
}
document.querySelectorAll("[data-v]").forEach(b=>b.onclick=()=>view(b.dataset.v));
$("reset").onclick=()=>fit();
addEventListener("resize",()=>{ camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth,innerHeight,false); });
addEventListener("keydown",e=>{
if(e.target.matches("input")) return;
if(e.key==="c") play("close"); if(e.key==="o") play("open"); if(e.key===" ") { e.preventDefault(); play("play"); }
});
function tick(now){ requestAnimationFrame(tick); tickAnim(now); controls.update(); renderer.render(scene,camera); }
boot(); requestAnimationFrame(tick);
</script>
</body>
</html>