/* holder-app.js — controller for the Hex Holder Designer page. * * - Loads /api/holder/params for the form schema and builds inputs. * - On any param change, debounces 400 ms then POSTs /api/holder/render and * pushes the returned STL ArrayBuffer into the 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) renderTimer: null, rendering: false, rendererReady: false, }; // ----- 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"]; const GROUP_LABELS = { part: "Part / pack", cell: "Cell", size: "Pack size", holder: "Holder", }; 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"; const title = document.createElement("h3"); title.className = "param-group-title"; title.textContent = GROUP_LABELS[g] || g; wrap.appendChild(title); 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); } wrap.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(); _scheduleRender(); } // ----- render orchestration ------------------------------------------------- function _scheduleRender() { clearTimeout(state.renderTimer); state.renderTimer = setTimeout(_doRender, 400); } async function _doRender() { if (state.rendering) { // Re-schedule once current finishes. state.renderTimer = setTimeout(_doRender, 200); return; } state.rendering = true; $("render-status").textContent = "rendering…"; $("warning").textContent = ""; const t0 = performance.now(); try { const res = await fetch("/api/holder/render", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ params: state.params }), }); if (!res.ok) { let msg = res.statusText; try { msg = (await res.json()).error || msg; } catch {} throw new Error(msg); } 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`; $("render-time").textContent = ""; } catch (e) { $("render-status").textContent = `✗ ${e.message}`; if (/openscad/i.test(e.message)) { $("warning").textContent = "Make sure OpenSCAD is installed on the server (apt install openscad)."; } } finally { state.rendering = 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; $("status").textContent = `${n} cells`; } // ----- buttons -------------------------------------------------------------- $("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 } = await r.json(); state.schema = schema; state.params = { ...defaults }; _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; _doRender(); // initial render with defaults }); } init();