6bc922cabf
Server-side OpenSCAD renders STL from bundled hex_cell.scad with parameter
overrides via -D. Frontend is a Three.js viewer with auto-form generated
from /api/holder/params. 'Design busbars →' button posts the computed
cell coordinates to /api/projects and redirects to the busbar editor with
the holder cells pre-loaded.
- holder.py: openscad subprocess wrapper + compute_cells()
(Python mirror of get_hex_center_points_*)
- scad/hex_cell.scad: verbatim copy of Addy/Hex-Cell-Holder source
- app.py: /holder route + /api/holder/{params,render,cells}
- static/holder.html etc: parameter form + Three.js STL viewer
- Dockerfile / install.sh: apt install openscad
- static/index.html: nav link Holder ↔ Busbars in topbar
281 lines
9.3 KiB
JavaScript
281 lines
9.3 KiB
JavaScript
/* holder-app.js — controller for the Hex Holder Designer page.
|
||
*
|
||
* - Loads /api/holder/params for the form schema and builds inputs.
|
||
* - On any param change, debounces 400 ms then POSTs /api/holder/render and
|
||
* pushes the returned STL ArrayBuffer into the 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)
|
||
renderTimer: null,
|
||
rendering: false,
|
||
rendererReady: false,
|
||
};
|
||
|
||
// ----- 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();
|
||
_scheduleRender();
|
||
}
|
||
|
||
// ----- render orchestration -------------------------------------------------
|
||
|
||
function _scheduleRender() {
|
||
clearTimeout(state.renderTimer);
|
||
state.renderTimer = setTimeout(_doRender, 400);
|
||
}
|
||
|
||
async function _doRender() {
|
||
if (state.rendering) {
|
||
// Re-schedule once current finishes.
|
||
state.renderTimer = setTimeout(_doRender, 200);
|
||
return;
|
||
}
|
||
state.rendering = true;
|
||
$("render-status").textContent = "rendering…";
|
||
$("warning").textContent = "";
|
||
const t0 = performance.now();
|
||
try {
|
||
const res = await fetch("/api/holder/render", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ params: state.params }),
|
||
});
|
||
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`;
|
||
$("render-time").textContent = "";
|
||
} catch (e) {
|
||
$("render-status").textContent = `✗ ${e.message}`;
|
||
if (/openscad/i.test(e.message)) {
|
||
$("warning").textContent =
|
||
"Make sure OpenSCAD is installed on the server (apt install openscad).";
|
||
}
|
||
} finally {
|
||
state.rendering = 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;
|
||
$("status").textContent = `${n} cells`;
|
||
}
|
||
|
||
// ----- buttons --------------------------------------------------------------
|
||
|
||
$("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 } = await r.json();
|
||
state.schema = schema;
|
||
state.params = { ...defaults };
|
||
_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;
|
||
_doRender(); // initial render with defaults
|
||
});
|
||
}
|
||
|
||
init();
|