From dfef1453aa568be1c90ca9e1f06771bad6507531 Mon Sep 17 00:00:00 2001 From: wenil Date: Mon, 25 May 2026 12:19:31 +0300 Subject: [PATCH] =?UTF-8?q?holder:=20fit-check=20=E2=80=94=20translucent?= =?UTF-8?q?=20max-box=20+=20manual=20enclosure=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- app.py | 13 ++++- holder.py | 32 ++++++++++-- static/holder.css | 32 ++++++++++++ static/holder.html | 26 ++++++++++ static/js/holder-app.js | 102 +++++++++++++++++++++++++++++++++++++ static/js/holder-viewer.js | 79 +++++++++++++++++++++++++++- 6 files changed, 278 insertions(+), 6 deletions(-) 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 @@

+ +
+

Fit check

+ + +

— render first

+ +
+ + +
+ + × + + × + + mm +
+

+
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;