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:
@@ -10,6 +10,7 @@ Run:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
@@ -68,7 +69,7 @@ def holder_render():
|
|||||||
"""Render STL for the supplied parameter overrides."""
|
"""Render STL for the supplied parameter overrides."""
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
try:
|
try:
|
||||||
data = holder.render_stl(body.get("params", {}))
|
data, dims = holder.render_stl(body.get("params", {}))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
except (FileNotFoundError, RuntimeError) as e:
|
except (FileNotFoundError, RuntimeError) as e:
|
||||||
@@ -76,7 +77,15 @@ def holder_render():
|
|||||||
return Response(
|
return Response(
|
||||||
data,
|
data,
|
||||||
mimetype="model/stl",
|
mimetype="model/stl",
|
||||||
headers={"Content-Disposition": 'attachment; filename="hex_holder.stl"'},
|
headers={
|
||||||
|
"Content-Disposition": 'attachment; filename="hex_holder.stl"',
|
||||||
|
# ECHO values from hex_cell.scad (pack/box dimensions). Used by
|
||||||
|
# the viewer to draw a max-bounding-box ghost for fit-check.
|
||||||
|
"X-Holder-Dimensions": json.dumps(dims),
|
||||||
|
# Make the custom header visible to fetch() in any CORS/proxy
|
||||||
|
# setup (Access-Control-Expose-Headers; harmless on same-origin).
|
||||||
|
"Access-Control-Expose-Headers": "X-Holder-Dimensions",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -220,8 +221,33 @@ def _filter_params(params: dict) -> dict:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def render_stl(params: dict) -> bytes:
|
_ECHO_RE = re.compile(r'^ECHO:\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?)\s*$')
|
||||||
"""Render the .scad with given parameter overrides; return STL bytes."""
|
|
||||||
|
|
||||||
|
def _parse_echo_dims(stderr_bytes: bytes) -> dict:
|
||||||
|
"""Pick up `ECHO: name = N` numeric pairs from openscad stderr.
|
||||||
|
|
||||||
|
The hex_cell.scad echoes derived measurements (pack_height_holder,
|
||||||
|
total_length_holder, box_total_height, etc.) — useful for fit-check
|
||||||
|
against a user enclosure without us mirroring all the SCAD math.
|
||||||
|
"""
|
||||||
|
out: dict = {}
|
||||||
|
for line in (stderr_bytes or b"").decode(errors="replace").splitlines():
|
||||||
|
m = _ECHO_RE.match(line.strip())
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
out[m.group(1)] = float(m.group(2))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def render_stl(params: dict) -> tuple[bytes, dict]:
|
||||||
|
"""Render the .scad with given parameter overrides.
|
||||||
|
|
||||||
|
Returns (stl_bytes, dimensions) where dimensions is a {name: float}
|
||||||
|
dict harvested from openscad ECHO output (pack/box/holder sizes).
|
||||||
|
"""
|
||||||
_check_cell_limit(params)
|
_check_cell_limit(params)
|
||||||
if not openscad_available():
|
if not openscad_available():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -249,4 +275,4 @@ def render_stl(params: dict) -> bytes:
|
|||||||
raise RuntimeError(f"openscad failed (exit {r.returncode}):\n{err[-800:]}")
|
raise RuntimeError(f"openscad failed (exit {r.returncode}):\n{err[-800:]}")
|
||||||
if not out.exists() or out.stat().st_size == 0:
|
if not out.exists() or out.stat().st_size == 0:
|
||||||
raise RuntimeError("openscad produced no STL (geometry empty?)")
|
raise RuntimeError("openscad produced no STL (geometry empty?)")
|
||||||
return out.read_bytes()
|
return out.read_bytes(), _parse_echo_dims(r.stderr or b"")
|
||||||
|
|||||||
@@ -111,3 +111,35 @@
|
|||||||
color: var(--danger, #d94a4a);
|
color: var(--danger, #d94a4a);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fit-check panel */
|
||||||
|
.fit-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.fit-toggle input { margin: 0; }
|
||||||
|
.fit-sep {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border, #2a2f3a);
|
||||||
|
margin: 10px 0 8px;
|
||||||
|
}
|
||||||
|
.enc-inputs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 6px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.enc-inputs input {
|
||||||
|
width: 56px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.enc-inputs .unit { color: var(--muted); margin-left: 2px; }
|
||||||
|
|
||||||
|
#fit-verdict.fit-ok { color: #4ad97a; font-weight: 600; }
|
||||||
|
#fit-verdict.fit-bad { color: var(--danger, #d94a4a); font-weight: 600; }
|
||||||
|
|||||||
@@ -56,6 +56,32 @@
|
|||||||
<p class="hint" id="render-time"></p>
|
<p class="hint" id="render-time"></p>
|
||||||
<p class="hint" id="warning"></p>
|
<p class="hint" id="warning"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Fit check</h2>
|
||||||
|
|
||||||
|
<label class="fit-toggle">
|
||||||
|
<input type="checkbox" id="show-max-box" checked>
|
||||||
|
<span>Show max bounding box</span>
|
||||||
|
</label>
|
||||||
|
<p class="hint" id="max-box-dims">— render first</p>
|
||||||
|
|
||||||
|
<hr class="fit-sep">
|
||||||
|
|
||||||
|
<label class="fit-toggle">
|
||||||
|
<input type="checkbox" id="show-enclosure">
|
||||||
|
<span>Show enclosure</span>
|
||||||
|
</label>
|
||||||
|
<div class="enc-inputs">
|
||||||
|
<input id="enc-w" type="number" min="1" step="1" placeholder="W">
|
||||||
|
<span>×</span>
|
||||||
|
<input id="enc-d" type="number" min="1" step="1" placeholder="D">
|
||||||
|
<span>×</span>
|
||||||
|
<input id="enc-h" type="number" min="1" step="1" placeholder="H">
|
||||||
|
<span class="unit">mm</span>
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="fit-verdict"></p>
|
||||||
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="right">
|
<section class="right">
|
||||||
|
|||||||
@@ -20,8 +20,15 @@ const state = {
|
|||||||
elapsedTimer: null,
|
elapsedTimer: null,
|
||||||
rendererReady: false,
|
rendererReady: false,
|
||||||
maxCells: 1000, // server-published cap; overridden by /api/holder/params
|
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 ----------------------------------------------------------
|
// ----- viewer init ----------------------------------------------------------
|
||||||
// HolderViewer is set up by holder-viewer.js (loaded as ES module) and
|
// HolderViewer is set up by holder-viewer.js (loaded as ES module) and
|
||||||
// attached to window. We wait briefly for it to be ready.
|
// 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 {}
|
try { msg = (await res.json()).error || msg; } catch {}
|
||||||
throw new Error(msg);
|
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();
|
state.lastBlob = await res.blob();
|
||||||
const buf = await state.lastBlob.arrayBuffer();
|
const buf = await state.lastBlob.arrayBuffer();
|
||||||
window.HolderViewer.loadSTL(buf);
|
window.HolderViewer.loadSTL(buf);
|
||||||
const dt = ((performance.now() - t0) / 1000).toFixed(1);
|
const dt = ((performance.now() - t0) / 1000).toFixed(1);
|
||||||
$("render-status").textContent = `✓ rendered in ${dt}s · ${(state.lastBlob.size/1024).toFixed(0)} kB`;
|
$("render-status").textContent = `✓ rendered in ${dt}s · ${(state.lastBlob.size/1024).toFixed(0)} kB`;
|
||||||
_clearDirty();
|
_clearDirty();
|
||||||
|
_updateFitCheck();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.name === "AbortError") {
|
if (e.name === "AbortError") {
|
||||||
$("render-status").textContent = "✗ cancelled";
|
$("render-status").textContent = "✗ cancelled";
|
||||||
@@ -231,8 +244,97 @@ function _updateStatus() {
|
|||||||
$("btn-to-busbar").disabled = overLimit;
|
$("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 --------------------------------------------------------------
|
// ----- 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", () => {
|
$("btn-render").addEventListener("click", () => {
|
||||||
if (state.rendererReady) _doRender();
|
if (state.rendererReady) _doRender();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,13 @@
|
|||||||
* Exports a single global `HolderViewer` (UMD-ish, attached to window) so the
|
* 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);
|
* non-module app script can use it. Set up via HolderViewer.init(canvasEl);
|
||||||
* load STL bytes via HolderViewer.loadSTL(arrayBuffer).
|
* 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 * as THREE from "three";
|
||||||
@@ -13,6 +20,8 @@ const HolderViewer = (() => {
|
|||||||
let scene, camera, renderer, controls;
|
let scene, camera, renderer, controls;
|
||||||
let mesh = null;
|
let mesh = null;
|
||||||
let host = null;
|
let host = null;
|
||||||
|
// Named ghost boxes for fit-check: { name: { group, dims, color } }
|
||||||
|
const ghosts = Object.create(null);
|
||||||
|
|
||||||
function init(hostEl) {
|
function init(hostEl) {
|
||||||
host = hostEl;
|
host = hostEl;
|
||||||
@@ -123,9 +132,77 @@ const HolderViewer = (() => {
|
|||||||
mesh.material.dispose();
|
mesh.material.dispose();
|
||||||
mesh = null;
|
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;
|
window.HolderViewer = HolderViewer;
|
||||||
|
|||||||
Reference in New Issue
Block a user