/* holder-app.js — controller for the Hex Holder Designer page. * * - Loads /api/holder/params for the form schema and builds inputs. * - Changing params marks the Render button "dirty"; user must click Render * (or Cancel) to drive POST /api/holder/render → Three.js viewer. * - "Download STL" re-uses the last rendered blob (no double trip). * - "Design busbars →" computes cells via /api/holder/cells, creates a new * busbar-designer project via /api/projects, redirects to /?p=. */ const $ = (id) => document.getElementById(id); const state = { params: {}, // current values, name → value schema: [], // [{name, label, kind, ...}] lastBlob: null, // last rendered STL blob (for download) rendering: false, dirty: false, // params changed since last successful render abortCtrl: null, // for cancelling in-flight render 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) scadEditor: null, // CodeMirror instance (lazy-initialised on first show) bundledSource: null, // text fetched from /api/holder/source scadVisible: false, }; 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. function _whenViewerReady(cb) { if (window.HolderViewer) { cb(); return; } let tries = 0; const t = setInterval(() => { tries++; if (window.HolderViewer) { clearInterval(t); cb(); } else if (tries > 50) { clearInterval(t); console.error("HolderViewer never loaded"); } }, 60); } // ----- form generation ------------------------------------------------------ const GROUP_ORDER = [ "part", "cell", "size", "holder", "cap", "box", "insulator", "bolts", "wires", "stacking", "template", "advanced", ]; const GROUP_LABELS = { part: "Part / pack", cell: "Cell", size: "Pack size", holder: "Holder", cap: "Cap", box: "Box", insulator: "Insulator", bolts: "Bolts & zipties", wires: "Wires & clamp", stacking: "Vertical stacking", template: "Busbar template", advanced: "Advanced", }; // Groups that start collapsed (user opens with a click). Basics stay open. const GROUPS_COLLAPSED = new Set([ "cap", "box", "insulator", "bolts", "wires", "stacking", "template", "advanced", ]); function _renderForm(schema, defaults) { const root = $("param-form"); root.innerHTML = ""; // Group params. const byGroup = new Map(); for (const p of schema) { if (!byGroup.has(p.group)) byGroup.set(p.group, []); byGroup.get(p.group).push(p); } const groups = GROUP_ORDER.filter((g) => byGroup.has(g)) .concat([...byGroup.keys()].filter((g) => !GROUP_ORDER.includes(g))); for (const g of groups) { const wrap = document.createElement("div"); wrap.className = "param-group"; if (GROUPS_COLLAPSED.has(g)) wrap.classList.add("collapsed"); const title = document.createElement("h3"); title.className = "param-group-title"; title.textContent = GROUP_LABELS[g] || g; title.addEventListener("click", () => wrap.classList.toggle("collapsed")); wrap.appendChild(title); const body = document.createElement("div"); body.className = "param-group-body"; wrap.appendChild(body); for (const p of byGroup.get(g)) { const row = document.createElement("div"); row.className = "param-row"; const label = document.createElement("label"); label.textContent = p.label; row.appendChild(label); let inp; if (p.kind === "select") { inp = document.createElement("select"); for (const opt of p.options || []) { const o = document.createElement("option"); o.value = opt; o.textContent = opt; if (opt === state.params[p.name]) o.selected = true; inp.appendChild(o); } } else if (p.kind === "bool") { inp = document.createElement("input"); inp.type = "checkbox"; inp.checked = !!state.params[p.name]; } else { inp = document.createElement("input"); inp.type = "number"; if (p.min != null) inp.min = p.min; if (p.max != null) inp.max = p.max; if (p.step != null) inp.step = p.step; inp.value = state.params[p.name] ?? p.default; } inp.name = p.name; inp.dataset.kind = p.kind; inp.addEventListener("change", _onParamChange); inp.addEventListener("input", _onParamChange); row.appendChild(inp); if (p.help) { const h = document.createElement("div"); h.className = "param-help"; h.textContent = p.help; row.appendChild(h); } body.appendChild(row); } root.appendChild(wrap); } } function _onParamChange(e) { const el = e.target; const k = el.name; const kind = el.dataset.kind; let v; if (kind === "bool") v = el.checked; else if (kind === "number") v = el.value === "" ? null : Number(el.value); else v = el.value; state.params[k] = v; _updateStatus(); _markDirty(); } // ----- render orchestration ------------------------------------------------- function _markDirty() { state.dirty = true; $("btn-render").classList.add("dirty"); } function _clearDirty() { state.dirty = false; $("btn-render").classList.remove("dirty"); } function _showProgress(show) { $("render-progress").hidden = !show; $("btn-render").hidden = show; $("btn-cancel").hidden = !show; } function _classifyError(msg) { // Backend RuntimeError messages all carry "openscad" in them. Distinguish: if (/not found on PATH/i.test(msg)) return { hint: "OpenSCAD binary not found on the server (apt install openscad)." }; if (/timed out/i.test(msg)) return { hint: "Render timed out. Try fewer cells, smaller slot widths, or simpler geometry." }; if (/produced no STL|geometry empty/i.test(msg)) return { hint: "OpenSCAD produced an empty model — check parameter combinations (e.g. cell_top_overlap too large)." }; return { hint: "" }; } async function _doRender() { if (state.rendering) return; state.rendering = true; state.abortCtrl = new AbortController(); $("render-status").textContent = "rendering…"; $("warning").textContent = ""; $("render-time").textContent = ""; _showProgress(true); const t0 = performance.now(); state.elapsedTimer = setInterval(() => { const dt = ((performance.now() - t0) / 1000).toFixed(1); $("render-elapsed").textContent = `${dt}s`; }, 100); try { const useCustomSource = state.scadVisible && state.scadEditor && state.scadEditor.getValue() !== state.bundledSource; const url = useCustomSource ? "/api/scad/render" : "/api/holder/render"; const body = useCustomSource ? { source: state.scadEditor.getValue(), params: state.params } : { params: state.params }; const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: state.abortCtrl.signal, }); if (!res.ok) { let msg = res.statusText; 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"; } else { $("render-status").textContent = `✗ ${e.message}`; const { hint } = _classifyError(e.message); if (hint) $("warning").textContent = hint; } } finally { state.rendering = false; state.abortCtrl = null; clearInterval(state.elapsedTimer); _showProgress(false); } } function _updateStatus() { // Quick cell count without hitting the server — uses the same formulas // server-side but we cheap-out here for instant feedback. const rows = +state.params.num_rows || 0; const cols = +state.params.num_cols || 0; const style = state.params.pack_style; let n; if (style === "tria") n = rows * (rows + 1) / 2; else n = rows * cols; const status = $("status"); const overLimit = n > state.maxCells; status.textContent = overLimit ? `${n} cells · over limit (${state.maxCells})` : `${n} cells`; status.classList.toggle("over-limit", overLimit); // Block render when over the cap. Also blocks "Design busbars →" // since it'd hit the same limit on the server. $("btn-render").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 = ""; } } // ----- SCAD editor ---------------------------------------------------------- async function _ensureBundledSource() { if (state.bundledSource !== null) return; const r = await fetch("/api/holder/source"); if (!r.ok) throw new Error(`source fetch failed: ${r.statusText}`); state.bundledSource = await r.text(); } function _updateScadDirtyTag() { if (!state.scadEditor) return; const dirty = state.scadEditor.getValue() !== state.bundledSource; $("scad-dirty-tag").hidden = !dirty; } async function _showScadEditor() { await _ensureBundledSource(); $("scad-panel").hidden = false; state.scadVisible = true; if (!state.scadEditor) { state.scadEditor = window.CodeMirror($("scad-editor"), { value: state.bundledSource, mode: "text/x-csrc", // closest built-in mode for OpenSCAD theme: "dracula", lineNumbers: true, indentUnit: 4, tabSize: 4, lineWrapping: false, viewportMargin: Infinity, // render whole document so CM fills the panel }); state.scadEditor.on("change", _updateScadDirtyTag); } else { state.scadEditor.refresh(); } _updateScadDirtyTag(); } function _hideScadEditor() { $("scad-panel").hidden = true; state.scadVisible = false; } $("btn-scad-toggle").addEventListener("click", () => { if (state.scadVisible) _hideScadEditor(); else _showScadEditor().catch((e) => alert("Couldn't load SCAD: " + e.message)); }); $("btn-scad-close").addEventListener("click", _hideScadEditor); $("btn-scad-reset").addEventListener("click", () => { if (!state.scadEditor || state.bundledSource == null) return; state.scadEditor.setValue(state.bundledSource); _updateScadDirtyTag(); }); // ----- 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(); }); $("btn-cancel").addEventListener("click", () => { if (state.abortCtrl) state.abortCtrl.abort(); }); $("btn-download-stl").addEventListener("click", () => { if (!state.lastBlob) { alert("Nothing rendered yet."); return; } const url = URL.createObjectURL(state.lastBlob); const a = document.createElement("a"); a.href = url; a.download = `hex_holder_${state.params.pack_style}_${state.params.num_rows}x${state.params.num_cols}.stl`; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); }); $("btn-download-scad").addEventListener("click", async () => { // Reuse the bundled SCAD plus a header that pins current values, so the // downloaded file reproduces the same model when opened in OpenSCAD. const overrides = Object.entries(state.params) .map(([k, v]) => `${k} = ${JSON.stringify(v)};`) .join("\n"); // We don't have a backend endpoint for the .scad source yet; fetch it // directly (deploy/install.sh puts it under /scad/hex_cell.scad — for the // local Flask app it's not exposed under /static. So inline a banner that // points the user to copy the params manually for now.) const banner = `// Generated by Hex Holder Designer\n// Drop these overrides into hex_cell.scad (or pass via OpenSCAD -D):\n//\n${ overrides.split("\n").map((l) => "// " + l).join("\n") }\n//\n// Or run: openscad -o out.stl ${ Object.entries(state.params).map(([k,v]) => `-D ${k}=${JSON.stringify(v)}`).join(" ") } hex_cell.scad\n`; const blob = new Blob([banner + "\n" + overrides + "\n"], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "hex_holder_params.scad"; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); }); $("btn-to-busbar").addEventListener("click", async () => { $("render-status").textContent = "exporting cells…"; try { const r = await fetch("/api/holder/cells", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ params: state.params }), }); if (!r.ok) throw new Error((await r.json()).error || r.statusText); const { cells } = await r.json(); if (!cells || !cells.length) throw new Error("no cells produced"); const partLabel = `${state.params.pack_style} ${state.params.num_rows}×${state.params.num_cols} (${state.params.cell_dia}mm)`; const proj = await Api.createProject(`Holder · ${partLabel}`, { params: { cellDia: state.params.cell_dia, openingDia: state.params.cell_dia - 2 * state.params.cell_top_overlap, }, cells, busbars: [], activeBusbarId: null, }); location.href = `/?p=${proj.id}`; } catch (e) { $("render-status").textContent = `✗ ${e.message}`; } }); // ----- bootstrap ------------------------------------------------------------ async function init() { try { const r = await fetch("/api/holder/params"); if (!r.ok) throw new Error(r.statusText); const { params: schema, defaults, max_cells } = await r.json(); state.schema = schema; state.params = { ...defaults }; if (typeof max_cells === "number") state.maxCells = max_cells; _renderForm(schema, defaults); _updateStatus(); } catch (e) { $("param-form").innerHTML = `
Couldn't load param schema: ${e.message}
`; return; } _whenViewerReady(() => { window.HolderViewer.init($("viewer3d")); state.rendererReady = true; // Auto-render once on load so the user sees something immediately. // After that, rendering is explicit (Render button). _doRender(); }); } init();