3418e01689
User-triggered 120x120 = 14400 cells, which produces huge STL/long renders. Total cells is the right metric (CSG cost scales with count, not max axis), so cap by N = rows*cols (or rows*(rows+1)/2 for tria style). 1000 covers any realistic pack (e.g. 20x50) while blocking accidental misuse. Backend: - holder.py: MAX_CELLS env-tunable (default 1000); expected_cell_count and _check_cell_limit raise ValueError on exceed; both compute_cells() and render_stl() call it up-front. - app.py: /api/holder/render now returns 400 on ValueError (not 500) so the frontend can distinguish bad input from server failure. /api/holder/params now publishes max_cells alongside the schema. Frontend: - holder-app.js: reads max_cells from the params endpoint; status shows "N cells / over limit (1000)" in red and disables the Render and "Design busbars" buttons when exceeded. - holder.css: .topbar-status.over-limit style (red, bold).
334 lines
11 KiB
JavaScript
334 lines
11 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
|
||
};
|
||
|
||
// ----- 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();
|
||
_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);
|
||
}
|
||
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();
|
||
} 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;
|
||
}
|
||
|
||
// ----- buttons --------------------------------------------------------------
|
||
|
||
$("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();
|