Files
busbar-designer/static/js/holder-app.js
T
wenil 6bc922cabf Add hex holder designer page (/holder)
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
2026-05-24 19:27:50 +03:00

281 lines
9.3 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.
* - 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();