Files
busbar-designer/static/js/holder-app.js
T
wenil dfef1453aa holder: fit-check — translucent max-box + manual enclosure overlay
The hex_cell.scad echoes its computed dimensions (pack_height_holder,
total_*_holder, box_total_*) to stderr. Capture those and surface them
in the viewer so the user can sanity-check whether the assembled
battery will fit in a target case.

Backend:
- holder.py: render_stl() now returns (stl_bytes, dims) where dims is
  a {name: float} dict parsed from openscad ECHO lines.
- app.py: /api/holder/render emits the dims dict as an
  X-Holder-Dimensions response header (JSON) with the matching
  Access-Control-Expose-Headers entry so fetch() can read it under
  any proxy / future CORS setup.

Viewer (holder-viewer.js):
- New setGhostBox(name, {w,d,h}, {visible,color}) and clearGhostBox(name)
  helpers. Each ghost is a Group of a translucent BoxGeometry mesh +
  matching EdgesGeometry wireframe, positioned to match how the STL
  mesh is placed (centred XY, bottom on Z=0).

UI (holder.html / holder.css):
- New "Fit check" panel under Status with two sections:
    • Show max bounding box (auto, from ECHO — defaults to box_total_*
      dims, falls back to total_*_holder + pack_height_holder).
    • Show enclosure (manual W × D × H inputs in mm).
- Verdict line under the enclosure inputs: "✓ fits" green or
  "✗ too small — battery won't fit" red.

Controller (holder-app.js):
- Reads X-Holder-Dimensions after each render, updates the max-box
  ghost in blue, prints the dimensions label.
- Watches enclosure inputs + toggles, drives the enclosure ghost
  (green if it fits, red if smaller than the max box on any axis).
- Fit comparison is orientation-independent in the XY plane (sorted
  W,D pair) but strict on Z (height).
2026-05-25 12:19:31 +03:00

436 lines
15 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)
};
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"];
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);
}
// 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 = "";
}
}
// ----- 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();