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>
This commit is contained in:
@@ -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 | 预览增强:合盖/开盖、分解、装配动画、剖切/线框等 |
|
||||
|
||||
@@ -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/)。
|
||||
|
||||
## 零件
|
||||
|
||||
|
||||
549
cad/viewer.html
549
cad/viewer.html
@@ -3,54 +3,118 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CYD Climate Case — 3D Preview</title>
|
||||
<title>CYD Climate Case — Interactive Preview</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b1220;
|
||||
--panel: #152238;
|
||||
--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", system-ui, sans-serif; overflow: hidden; }
|
||||
#c { display: block; width: 100%; height: 100%; }
|
||||
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: 16px; top: 16px; z-index: 2;
|
||||
background: color-mix(in srgb, var(--panel) 92%, transparent);
|
||||
border: 1px solid #243247; border-radius: 12px; padding: 14px 16px;
|
||||
max-width: min(340px, calc(100vw - 32px)); backdrop-filter: blur(8px);
|
||||
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 6px; font-size: 16px; font-weight: 650; letter-spacing: 0.02em; }
|
||||
.hud p { margin: 0 0 10px; font-size: 12px; color: var(--muted); line-height: 1.45; }
|
||||
.row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
||||
.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 10px; font-size: 12px; cursor: pointer;
|
||||
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: 6px; user-select: none; }
|
||||
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>拖拽旋转 · 滚轮缩放 · 右键平移</p>
|
||||
<p class="hint">左键旋转 · 滚轮缩放 · 右键平移 · 触屏双指缩放</p>
|
||||
|
||||
<div class="sec-title">合盖 / 开盖</div>
|
||||
<div class="row">
|
||||
<button type="button" id="btn-both" class="active">整体</button>
|
||||
<button type="button" id="btn-front">仅前框</button>
|
||||
<button type="button" id="btn-back">仅后盖</button>
|
||||
<button type="button" id="btn-explode">分解 / 合拢</button>
|
||||
<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="row">
|
||||
<label class="tog"><input type="checkbox" id="chk-grid" checked /> 网格</label>
|
||||
<label class="tog"><input type="checkbox" id="chk-auto" checked /> 自转</label>
|
||||
<button type="button" id="btn-reset">复位视角</button>
|
||||
<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>
|
||||
@@ -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);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user