Files
busbar-designer/static/js/holder-app.js
T
wenil 676d976937 holder + scad: SCAD source editor on /holder + new /scad universal renderer
Phase 2 — SCAD editor on /holder
================================
Some adjustments need more control than the parameter form gives
(e.g. tweaking the .scad logic itself). A collapsible CodeMirror-based
editor now slides up from the bottom of the viewport when you click
the "</> Source" button at the bottom-right.

- holder.html / holder.css: editor panel, toggle button, Reset and
  Close buttons. CodeMirror 5 loaded from cdnjs (single CSS + JS
  pair, clike mode for syntax highlighting since OpenSCAD is C-like).
- holder-app.js: lazy-initialises the editor on first show, fetches
  bundled source from /api/holder/source, tracks whether the editor
  content has been modified ("modified" tag in the panel title).
  When the editor is visible AND content differs from bundled,
  _doRender() switches from /api/holder/render -> /api/scad/render
  with {source, params}. Cell-count cap doesn't apply in that mode
  (the source may not even use the holder schema).

Phase 3 — Universal OpenSCAD playground at /scad
================================================
A standalone page for rendering arbitrary OpenSCAD. Paste code, click
Render (or Ctrl+Enter), see STL in the same Three.js viewer the
holder uses. Useful for prototyping new generators before wiring
them into a parameter form.

- New page: static/scad.html + scad.css + js/scad-app.js. Reuses
  holder-viewer.js (the Three.js scene module is generic enough).
- "Load example" populates a 6-hole rounded bracket so new users
  can verify the renderer works in two clicks.
- Same progress UI (elapsed timer + indeterminate bar + Cancel
  via AbortController) as /holder.
- Top nav now has Holder / Busbars / SCAD on every page.

Backend (shared by phase 2 & 3)
================================
- holder.py: split _run_openscad() out of render_stl(). New
  render_source(source, params=None) writes the source to a temp
  file and renders it; enforces MAX_SOURCE_BYTES (default 512 KB)
  and RENDER_TIMEOUT but skips the cell-count cap. New bundled_source()
  returns the hex_cell.scad text for editor pre-population.
- app.py: GET /api/holder/source returns the bundled .scad text.
  POST /api/scad/render takes {source, params?} and returns STL +
  X-Holder-Dimensions header. ValueError -> 400, RuntimeError -> 500.
  _stl_response() factored out so both render endpoints emit the same
  headers consistently.
2026-05-25 12:52:18 +03:00

523 lines
18 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)
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 =
`<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();