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).
This commit is contained in:
@@ -3,6 +3,13 @@
|
||||
* 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";
|
||||
@@ -13,6 +20,8 @@ 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;
|
||||
@@ -123,9 +132,77 @@ const HolderViewer = (() => {
|
||||
mesh.material.dispose();
|
||||
mesh = null;
|
||||
}
|
||||
for (const name of Object.keys(ghosts)) clearGhostBox(name);
|
||||
}
|
||||
|
||||
return { init, loadSTL, clear };
|
||||
// ----- 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;
|
||||
|
||||
Reference in New Issue
Block a user