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:
wenil
2026-05-25 12:19:31 +03:00
parent 3418e01689
commit dfef1453aa
6 changed files with 278 additions and 6 deletions
+102
View File
@@ -20,8 +20,15 @@ const state = {
elapsedTimer: null,
rendererReady: false,
maxCells: 1000, // server-published cap; overridden by /api/holder/params
dimensions: null, // last ECHO dims dict from X-Holder-Dimensions
maxBox: null, // {w, d, h} of assembled-battery bounding box
enclosure: null, // {w, d, h} of user-defined case (or null)
};
const COLOR_MAX_BOX = 0x4a9eff; // blue
const COLOR_ENC_OK = 0x4ad97a; // green
const COLOR_ENC_BAD = 0xd94a4a; // red
// ----- viewer init ----------------------------------------------------------
// HolderViewer is set up by holder-viewer.js (loaded as ES module) and
// attached to window. We wait briefly for it to be ready.
@@ -188,12 +195,18 @@ async function _doRender() {
try { msg = (await res.json()).error || msg; } catch {}
throw new Error(msg);
}
// Pick up the ECHO dimensions header before we read the body (whichever
// order is fine, but keep header access close to the response).
const dimHdr = res.headers.get("X-Holder-Dimensions");
state.dimensions = dimHdr ? _safeParseJSON(dimHdr) : null;
state.lastBlob = await res.blob();
const buf = await state.lastBlob.arrayBuffer();
window.HolderViewer.loadSTL(buf);
const dt = ((performance.now() - t0) / 1000).toFixed(1);
$("render-status").textContent = `✓ rendered in ${dt}s · ${(state.lastBlob.size/1024).toFixed(0)} kB`;
_clearDirty();
_updateFitCheck();
} catch (e) {
if (e.name === "AbortError") {
$("render-status").textContent = "✗ cancelled";
@@ -231,8 +244,97 @@ function _updateStatus() {
$("btn-to-busbar").disabled = overLimit;
}
// ----- fit check ------------------------------------------------------------
function _safeParseJSON(s) {
try { return JSON.parse(s); } catch { return null; }
}
function _extractMaxBox(dims) {
if (!dims) return null;
// Prefer the fully-assembled-case dims; fall back to plain-holder dims.
const w = dims.box_total_length ?? dims.total_length_holder;
const d = dims.box_total_width ?? dims.total_width_holder;
const h = dims.box_total_height ?? dims.pack_height_holder;
if (!(w > 0) || !(d > 0) || !(h > 0)) return null;
return { w, d, h };
}
function _readEnclosureInputs() {
const w = +$("enc-w").value;
const d = +$("enc-d").value;
const h = +$("enc-h").value;
if (!(w > 0) || !(d > 0) || !(h > 0)) return null;
return { w, d, h };
}
function _checkFit(maxBox, enc) {
// Compare orientation-independently in the XY plane (rotating the battery
// 90° around Z is usually free), but keep Z (height) fixed.
if (!maxBox || !enc) return null;
const mxxy = [maxBox.w, maxBox.d].sort((a, b) => a - b);
const exxy = [enc.w, enc.d].sort((a, b) => a - b);
return exxy[0] + 1e-6 >= mxxy[0]
&& exxy[1] + 1e-6 >= mxxy[1]
&& enc.h + 1e-6 >= maxBox.h;
}
function _fmt(n) { return (Math.round(n * 10) / 10).toFixed(1); }
function _updateFitCheck() {
state.maxBox = _extractMaxBox(state.dimensions);
state.enclosure = _readEnclosureInputs();
// Max-box ghost + label
if (state.maxBox) {
$("max-box-dims").textContent =
`${_fmt(state.maxBox.w)} × ${_fmt(state.maxBox.d)} × ${_fmt(state.maxBox.h)} mm`;
window.HolderViewer.setGhostBox("max", state.maxBox, {
visible: $("show-max-box").checked,
color: COLOR_MAX_BOX,
});
} else {
$("max-box-dims").textContent = "— render first";
window.HolderViewer.clearGhostBox("max");
}
// Enclosure ghost + verdict
const verdict = $("fit-verdict");
verdict.className = "hint";
if (state.enclosure) {
const fits = _checkFit(state.maxBox, state.enclosure);
const color = fits === false ? COLOR_ENC_BAD : COLOR_ENC_OK;
window.HolderViewer.setGhostBox("enc", state.enclosure, {
visible: $("show-enclosure").checked,
color,
});
if (fits === true) {
verdict.textContent = "✓ fits";
verdict.classList.add("fit-ok");
} else if (fits === false) {
verdict.textContent = "✗ too small — battery won't fit";
verdict.classList.add("fit-bad");
} else {
verdict.textContent = "render to compare";
}
} else {
window.HolderViewer.clearGhostBox("enc");
verdict.textContent = "";
}
}
// ----- buttons --------------------------------------------------------------
$("show-max-box").addEventListener("change", _updateFitCheck);
$("show-enclosure").addEventListener("change", _updateFitCheck);
for (const id of ["enc-w", "enc-d", "enc-h"]) {
$(id).addEventListener("input", () => {
// Auto-enable the toggle when the user starts typing dims.
if (_readEnclosureInputs()) $("show-enclosure").checked = true;
_updateFitCheck();
});
}
$("btn-render").addEventListener("click", () => {
if (state.rendererReady) _doRender();
});
+78 -1
View File
@@ -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;