5bba0e3c4a
The bundled hex_cell.scad has many more knobs than the dozen we used to expose (box clearances, cap/box wall, vertical stacking, busbar template, etc). Maintaining a hand-curated PARAMS list in Python next to a SCAD file that already documents every variable inline was always going to drift. Sync + auto-extract approach: - scad/hex_cell.scad: replace with the upstream master file from Albert Phan's Hex-Cell-Holder fork (adds the BUSBAR TEMPLATE section and the "busbar template" part option). - holder.py: PARAMS now built at import time by _scan_scad(), which regexes top-level `name = literal; // help` lines into Param entries up until the // END OF CONFIGURATION marker. Scan handles bool / number / string literals; derived expressions and helper variables are skipped automatically. - Manual maps stay small and explicit: _SELECT_OPTIONS for the few string-enum params (part, pack_style, box_style, template_outline, template_hole_style, etc.), _GROUP_RULES for UI sectioning, and _NUMBER_HINTS for sensible min/max/step on the most-tweaked numbers. UI: - holder-app.js: extra GROUP_ORDER / GROUP_LABELS for the new sections (cap, box, insulator, bolts, wires, stacking, template, advanced). Group titles are now click-to-collapse; the advanced groups start collapsed so the form isn't a wall of inputs on load. - holder.css: caret marker on the group title, smooth rotate on collapse, hides the body via .collapsed class. Net effect: every variable in the .scad — including the new busbar template knobs — is editable from the page, with helpful comments copied straight from the .scad source.
461 lines
16 KiB
JavaScript
461 lines
16 KiB
JavaScript
/* 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=<id>.
|
||
*/
|
||
|
||
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)
|
||
};
|
||
|
||
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 res = await fetch("/api/holder/render", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ params: state.params }),
|
||
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 = "";
|
||
}
|
||
}
|
||
|
||
// ----- 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 =
|
||
`<div class="step-preview-err">Couldn't load param schema: ${e.message}</div>`;
|
||
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();
|