holder: explicit Render button + progress UI + longer render timeout

- Replace debounced auto-render-on-param-change with an explicit Render
  button. Param changes mark the button "dirty" (accent ring); user clicks
  Render to drive a render. A Cancel button (AbortController) appears
  while a render is in flight.
- Add indeterminate progress bar with elapsed-time counter in the status
  panel. Real OpenSCAD --progress streaming can come later.
- Bump OPENSCAD_TIMEOUT default 60s -> 300s and gunicorn --timeout
  120s -> 300s. The 60s cap was misclassified by the frontend as
  "OpenSCAD not installed" because the error string contained the word
  "openscad" -- which the JS matched too greedily.
- Frontend error classifier now distinguishes "binary not found",
  "timed out", and "geometry empty" cases and only shows the
  install-OpenSCAD hint for the real not-found case.
This commit is contained in:
wenil
2026-05-25 11:22:04 +03:00
parent 1fadef0b3f
commit af3ed092dc
5 changed files with 109 additions and 21 deletions
+60 -18
View File
@@ -1,8 +1,8 @@
/* 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.
* - 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>.
@@ -14,8 +14,10 @@ const state = {
params: {}, // current values, name → value
schema: [], // [{name, label, kind, ...}]
lastBlob: null, // last rendered STL blob (for download)
renderTimer: null,
rendering: false,
dirty: false, // params changed since last successful render
abortCtrl: null, // for cancelling in-flight render
elapsedTimer: null,
rendererReady: false,
};
@@ -129,31 +131,56 @@ function _onParamChange(e) {
else v = el.value;
state.params[k] = v;
_updateStatus();
_scheduleRender();
_markDirty();
}
// ----- render orchestration -------------------------------------------------
function _scheduleRender() {
clearTimeout(state.renderTimer);
state.renderTimer = setTimeout(_doRender, 400);
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) {
// Re-schedule once current finishes.
state.renderTimer = setTimeout(_doRender, 200);
return;
}
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;
@@ -165,15 +192,20 @@ async function _doRender() {
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 = "";
_clearDirty();
} 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).";
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);
}
}
@@ -191,6 +223,14 @@ function _updateStatus() {
// ----- 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);
@@ -273,7 +313,9 @@ async function init() {
_whenViewerReady(() => {
window.HolderViewer.init($("viewer3d"));
state.rendererReady = true;
_doRender(); // initial render with defaults
// Auto-render once on load so the user sees something immediately.
// After that, rendering is explicit (Render button).
_doRender();
});
}