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:
@@ -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` |
|
||||
|
||||
@@ -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
225
cad/viewer.html
Normal 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>
|
||||
Reference in New Issue
Block a user