Files
busbar-designer/static/js/holder-app.js
T
wenil 5bba0e3c4a holder: expose all 59 scad parameters auto-extracted from the .scad source
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.
2026-05-25 12:44:45 +03:00

461 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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();