Keep branch keep/v1.1-case-snaps alongside the prior v1.0-case-closed snapshot. Co-authored-by: Cursor <cursoragent@cursor.com>
278 lines
13 KiB
HTML
278 lines
13 KiB
HTML
<!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>
|