Add browser Three.js viewer for case STL preview.

Serve with python http.server under cad/ and open viewer.html locally.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-17 15:01:24 +08:00
parent c35ed7a7a9
commit 4898087dc7
3 changed files with 247 additions and 0 deletions

View File

@@ -44,9 +44,15 @@ pio device monitor -b 115200
桌面斜立分体壳(前框 + 后盖支架 + DHT11 通风仓),可直接打印:
- STL:[`cad/export/front.stl`](cad/export/front.stl)、[`cad/export/back.stl`](cad/export/back.stl)
- 浏览器预览:[`cad/viewer.html`](cad/viewer.html)(需本地 HTTP,见下)
- 参数源 / 说明:[`cad/README.md`](cad/README.md)
```bash
# 浏览器查看
python -m http.server 8765 --directory cad
# 打开 http://localhost:8765/viewer.html
# 重新导出 STL
pip install trimesh manifold3d numpy
python cad/generate_stl.py
```
@@ -82,3 +88,4 @@ README.md # 本文档
| 2026-10-17 | 曾加入 ECO 熄屏休眠,后改回常亮正常版 |
| 2026-10-17 | 初始化 Git,补充 README 与提交约定 |
| 2026-10-17 | 新增 CYD+DHT11 桌面斜立 3D 外壳(OpenSCAD / STL) |
| 2026-10-17 | 增加浏览器 Three.js STL 预览页 `cad/viewer.html` |

View File

@@ -2,10 +2,25 @@
桌面斜立分体外壳,适配 **ESP32-2432S028R(CYD)** + **DHT11(CN1 / GPIO22)**。
## 浏览器预览
不要直接双击 HTML(`file://` 无法加载 STL)。在项目根目录执行:
```bash
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/)。
## 零件
| 文件 | 说明 |
|------|------|
| `viewer.html` | 浏览器 3D 预览 |
| `export/front.stl` | 前框(屏窗 + 压边) |
| `export/back.stl` | 后盖 + 斜立支脚 + DHT11 通风仓 |
| `cyd_climate_case.scad` | 参数化源文件(OpenSCAD,可改尺寸) |

225
cad/viewer.html Normal file
View File

@@ -0,0 +1,225 @@
<!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>