Serve with python http.server under cad/ and open viewer.html locally. Co-authored-by: Cursor <cursoragent@cursor.com>
226 lines
7.9 KiB
HTML
226 lines
7.9 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 Climate Case — 3D Preview</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0b1220;
|
||
--panel: #152238;
|
||
--text: #e8eef7;
|
||
--muted: #8a9bb5;
|
||
--accent: #3db8a8;
|
||
}
|
||
* { 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%; }
|
||
.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);
|
||
}
|
||
.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; }
|
||
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;
|
||
}
|
||
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; }
|
||
#status { margin-top: 10px; font-size: 11px; color: var(--muted); }
|
||
#status.err { color: #e05a5a; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="hud">
|
||
<h1>CYD Climate Case</h1>
|
||
<p>拖拽旋转 · 滚轮缩放 · 右键平移</p>
|
||
<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>
|
||
</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>
|
||
<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 statusEl = document.getElementById("status");
|
||
const canvas = document.getElementById("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(45, innerWidth / innerHeight, 0.1, 2000);
|
||
camera.position.set(120, 90, 140);
|
||
|
||
const controls = new OrbitControls(camera, canvas);
|
||
controls.enableDamping = true;
|
||
controls.target.set(45, 10, 20);
|
||
|
||
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
|
||
const key = new THREE.DirectionalLight(0xffffff, 1.05);
|
||
key.position.set(80, 120, 60);
|
||
scene.add(key);
|
||
const fill = new THREE.DirectionalLight(0x88aacc, 0.45);
|
||
fill.position.set(-60, 40, -40);
|
||
scene.add(fill);
|
||
|
||
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 matFront = new THREE.MeshStandardMaterial({
|
||
color: 0x5a6a80, metalness: 0.15, roughness: 0.55, flatShading: true,
|
||
});
|
||
const matBack = new THREE.MeshStandardMaterial({
|
||
color: 0x3db8a8, metalness: 0.12, roughness: 0.5, flatShading: true,
|
||
});
|
||
|
||
let meshFront = null;
|
||
let meshBack = null;
|
||
let exploded = false;
|
||
let autoRotate = true;
|
||
|
||
function setStatus(msg, err = false) {
|
||
statusEl.textContent = msg;
|
||
statusEl.classList.toggle("err", err);
|
||
}
|
||
|
||
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();
|
||
controls.update();
|
||
}
|
||
|
||
const loader = new STLLoader();
|
||
|
||
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
|
||
);
|
||
});
|
||
}
|
||
|
||
async function boot() {
|
||
try {
|
||
const [front, back] = await Promise.all([
|
||
loadSTL("./export/front.stl", matFront),
|
||
loadSTL("./export/back.stl", matBack),
|
||
]);
|
||
meshFront = front;
|
||
meshBack = back;
|
||
|
||
// Lay parts for a readable assembly preview:
|
||
// back on "desk", front slightly above / in front
|
||
meshBack.rotation.x = -Math.PI / 2;
|
||
meshFront.rotation.x = -Math.PI / 2;
|
||
meshFront.position.y = 12;
|
||
|
||
root.add(meshBack);
|
||
root.add(meshFront);
|
||
fitCamera(root);
|
||
setStatus("已加载 front.stl + back.stl(单位 mm)");
|
||
} catch (e) {
|
||
console.error(e);
|
||
setStatus(
|
||
"加载失败:请用本地 HTTP 打开(不要双击 file://)。在项目根目录运行: python -m http.server 8765",
|
||
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";
|
||
}
|
||
|
||
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;
|
||
};
|
||
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);
|
||
};
|
||
|
||
addEventListener("resize", () => {
|
||
camera.aspect = innerWidth / innerHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(innerWidth, innerHeight, false);
|
||
});
|
||
|
||
function tick() {
|
||
requestAnimationFrame(tick);
|
||
if (autoRotate) root.rotation.y += 0.004;
|
||
controls.update();
|
||
renderer.render(scene, camera);
|
||
}
|
||
|
||
boot();
|
||
tick();
|
||
</script>
|
||
</body>
|
||
</html>
|