Files
wenil dfef1453aa holder: fit-check — translucent max-box + manual enclosure overlay
The hex_cell.scad echoes its computed dimensions (pack_height_holder,
total_*_holder, box_total_*) to stderr. Capture those and surface them
in the viewer so the user can sanity-check whether the assembled
battery will fit in a target case.

Backend:
- holder.py: render_stl() now returns (stl_bytes, dims) where dims is
  a {name: float} dict parsed from openscad ECHO lines.
- app.py: /api/holder/render emits the dims dict as an
  X-Holder-Dimensions response header (JSON) with the matching
  Access-Control-Expose-Headers entry so fetch() can read it under
  any proxy / future CORS setup.

Viewer (holder-viewer.js):
- New setGhostBox(name, {w,d,h}, {visible,color}) and clearGhostBox(name)
  helpers. Each ghost is a Group of a translucent BoxGeometry mesh +
  matching EdgesGeometry wireframe, positioned to match how the STL
  mesh is placed (centred XY, bottom on Z=0).

UI (holder.html / holder.css):
- New "Fit check" panel under Status with two sections:
    • Show max bounding box (auto, from ECHO — defaults to box_total_*
      dims, falls back to total_*_holder + pack_height_holder).
    • Show enclosure (manual W × D × H inputs in mm).
- Verdict line under the enclosure inputs: "✓ fits" green or
  "✗ too small — battery won't fit" red.

Controller (holder-app.js):
- Reads X-Holder-Dimensions after each render, updates the max-box
  ghost in blue, prints the dimensions label.
- Watches enclosure inputs + toggles, drives the enclosure ghost
  (green if it fits, red if smaller than the max box on any axis).
- Fit comparison is orientation-independent in the XY plane (sorted
  W,D pair) but strict on Z (height).
2026-05-25 12:19:31 +03:00

209 lines
6.4 KiB
JavaScript

/* holder-viewer.js — Three.js scene for STL preview.
*
* Exports a single global `HolderViewer` (UMD-ish, attached to window) so the
* non-module app script can use it. Set up via HolderViewer.init(canvasEl);
* load STL bytes via HolderViewer.loadSTL(arrayBuffer).
*
* Also exposes "ghost box" helpers for fit-checking the assembled battery
* against a user-defined enclosure:
* - setGhostBox(name, {w, d, h}, {visible, color})
* - clearGhostBox(name)
* Ghost boxes are centred on (0, 0) in XY with their bottom face at Z=0,
* matching how loadSTL positions the printed-part mesh.
*/
import * as THREE from "three";
import { STLLoader } from "three/addons/loaders/STLLoader.js";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
const HolderViewer = (() => {
let scene, camera, renderer, controls;
let mesh = null;
let host = null;
// Named ghost boxes for fit-check: { name: { group, dims, color } }
const ghosts = Object.create(null);
function init(hostEl) {
host = hostEl;
const w = host.clientWidth || 800;
const h = host.clientHeight || 600;
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0d12);
camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 5000);
camera.position.set(120, 120, 120);
camera.up.set(0, 0, 1); // Z-up (CAD convention)
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(w, h);
host.innerHTML = "";
host.appendChild(renderer.domElement);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.screenSpacePanning = true;
// Lights
scene.add(new THREE.AmbientLight(0xffffff, 0.45));
const key = new THREE.DirectionalLight(0xffffff, 0.7);
key.position.set(1, 1, 2).normalize();
scene.add(key);
const fill = new THREE.DirectionalLight(0xaccfff, 0.35);
fill.position.set(-1, -1, 0.5).normalize();
scene.add(fill);
// Build plate grid (10 mm minor, 50 mm major) at Z=0
const grid = new THREE.GridHelper(400, 40, 0x2a3140, 0x1f2530);
grid.rotateX(Math.PI / 2); // make it the XY plane (camera up is Z)
scene.add(grid);
// Axes (small)
const axes = new THREE.AxesHelper(20);
scene.add(axes);
// Resize observer
new ResizeObserver(_onResize).observe(host);
_animate();
}
function _onResize() {
if (!host) return;
const w = host.clientWidth, h = host.clientHeight;
if (w === 0 || h === 0) return;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}
function _animate() {
requestAnimationFrame(_animate);
controls.update();
renderer.render(scene, camera);
}
/** Load STL bytes (ArrayBuffer) and replace any existing mesh. */
function loadSTL(buf) {
const loader = new STLLoader();
const geom = loader.parse(buf);
geom.computeVertexNormals();
// Centre the geometry on its bounding box and align bottom to Z=0.
geom.computeBoundingBox();
const bb = geom.boundingBox;
const cx = (bb.min.x + bb.max.x) / 2;
const cy = (bb.min.y + bb.max.y) / 2;
geom.translate(-cx, -cy, -bb.min.z);
if (mesh) {
scene.remove(mesh);
mesh.geometry.dispose();
mesh.material.dispose();
}
const mat = new THREE.MeshStandardMaterial({
color: 0xf08a24,
metalness: 0.15,
roughness: 0.65,
flatShading: false,
});
mesh = new THREE.Mesh(geom, mat);
scene.add(mesh);
_fitCameraToMesh(geom);
}
function _fitCameraToMesh(geom) {
const bb = geom.boundingBox;
const size = bb.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const dist = maxDim * 1.6;
camera.position.set(dist * 0.8, -dist * 0.8, dist * 0.8);
controls.target.set(0, 0, size.z / 2);
camera.lookAt(controls.target);
camera.updateProjectionMatrix();
}
function clear() {
if (mesh) {
scene.remove(mesh);
mesh.geometry.dispose();
mesh.material.dispose();
mesh = null;
}
for (const name of Object.keys(ghosts)) clearGhostBox(name);
}
// ----- Ghost boxes (fit-check) -------------------------------------------
function _disposeGroup(g) {
g.traverse((o) => {
if (o.geometry) o.geometry.dispose();
if (o.material) {
if (Array.isArray(o.material)) o.material.forEach((m) => m.dispose());
else o.material.dispose();
}
});
}
function _makeGhostGroup(w, d, h, color) {
const group = new THREE.Group();
const geom = new THREE.BoxGeometry(w, d, h);
const fillMat = new THREE.MeshBasicMaterial({
color, transparent: true, opacity: 0.08,
depthWrite: false, side: THREE.DoubleSide,
});
group.add(new THREE.Mesh(geom, fillMat));
const edges = new THREE.EdgesGeometry(geom);
const lineMat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.85 });
group.add(new THREE.LineSegments(edges, lineMat));
// Centre XY at (0,0), bottom at Z=0 — matches mesh placement in loadSTL.
group.position.set(0, 0, h / 2);
return group;
}
/** Create / update / hide a named ghost box.
* dims: {w, d, h} in mm. Pass `{visible: false}` to hide without disposing. */
function setGhostBox(name, dims, opts = {}) {
if (!dims || !(dims.w > 0) || !(dims.d > 0) || !(dims.h > 0)) {
clearGhostBox(name);
return;
}
const visible = opts.visible !== false;
const color = opts.color ?? 0x4a9eff;
const existing = ghosts[name];
const needsRebuild = !existing
|| existing.dims.w !== dims.w
|| existing.dims.d !== dims.d
|| existing.dims.h !== dims.h
|| existing.color !== color;
if (existing && needsRebuild) {
scene.remove(existing.group);
_disposeGroup(existing.group);
delete ghosts[name];
}
if (needsRebuild) {
const group = _makeGhostGroup(dims.w, dims.d, dims.h, color);
group.visible = visible;
scene.add(group);
ghosts[name] = { group, dims: { ...dims }, color };
} else {
existing.group.visible = visible;
}
}
function clearGhostBox(name) {
const g = ghosts[name];
if (!g) return;
scene.remove(g.group);
_disposeGroup(g.group);
delete ghosts[name];
}
return { init, loadSTL, clear, setGhostBox, clearGhostBox };
})();
window.HolderViewer = HolderViewer;