diff --git a/app.py b/app.py
index d5745c8..d959572 100644
--- a/app.py
+++ b/app.py
@@ -10,6 +10,7 @@ Run:
from __future__ import annotations
+import json
import os
import sys
import traceback
@@ -68,7 +69,7 @@ def holder_render():
"""Render STL for the supplied parameter overrides."""
body = request.get_json(silent=True) or {}
try:
- data = holder.render_stl(body.get("params", {}))
+ data, dims = holder.render_stl(body.get("params", {}))
except ValueError as e:
return jsonify({"error": str(e)}), 400
except (FileNotFoundError, RuntimeError) as e:
@@ -76,7 +77,15 @@ def holder_render():
return Response(
data,
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",
+ },
)
diff --git a/holder.py b/holder.py
index 81aff00..bae3857 100644
--- a/holder.py
+++ b/holder.py
@@ -14,6 +14,7 @@ from __future__ import annotations
import json
import math
import os
+import re
import shutil
import subprocess
import tempfile
@@ -220,8 +221,33 @@ def _filter_params(params: dict) -> dict:
return out
-def render_stl(params: dict) -> bytes:
- """Render the .scad with given parameter overrides; return STL 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*$')
+
+
+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)
if not openscad_available():
raise RuntimeError(
@@ -249,4 +275,4 @@ def render_stl(params: dict) -> bytes:
raise RuntimeError(f"openscad failed (exit {r.returncode}):\n{err[-800:]}")
if not out.exists() or out.stat().st_size == 0:
raise RuntimeError("openscad produced no STL (geometry empty?)")
- return out.read_bytes()
+ return out.read_bytes(), _parse_echo_dims(r.stderr or b"")
diff --git a/static/holder.css b/static/holder.css
index 7ee82a3..2757eb9 100644
--- a/static/holder.css
+++ b/static/holder.css
@@ -111,3 +111,35 @@
color: var(--danger, #d94a4a);
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; }
diff --git a/static/holder.html b/static/holder.html
index a26cfc3..71c3616 100644
--- a/static/holder.html
+++ b/static/holder.html
@@ -56,6 +56,32 @@
+
+
diff --git a/static/js/holder-app.js b/static/js/holder-app.js
index 03c536f..407ad1d 100644
--- a/static/js/holder-app.js
+++ b/static/js/holder-app.js
@@ -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();
});
diff --git a/static/js/holder-viewer.js b/static/js/holder-viewer.js
index d1f96f3..152a461 100644
--- a/static/js/holder-viewer.js
+++ b/static/js/holder-viewer.js
@@ -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;