676d976937
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.
523 lines
18 KiB
JavaScript
523 lines
18 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)
|
||
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();
|