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
+1 -1
View File
@@ -28,7 +28,7 @@ Environment=PATH=/opt/busbar-designer/.venv/bin:/usr/local/sbin:/usr/local/bin:/
Environment=HOME=/opt/busbar-designer/data Environment=HOME=/opt/busbar-designer/data
Environment=XDG_CONFIG_HOME=/opt/busbar-designer/data/.config Environment=XDG_CONFIG_HOME=/opt/busbar-designer/data/.config
Environment=XDG_CACHE_HOME=/opt/busbar-designer/data/.cache Environment=XDG_CACHE_HOME=/opt/busbar-designer/data/.cache
ExecStart=/opt/busbar-designer/.venv/bin/gunicorn --bind=0.0.0.0:5000 --workers=2 --threads=2 --timeout=120 app:app ExecStart=/opt/busbar-designer/.venv/bin/gunicorn --bind=0.0.0.0:5000 --workers=2 --threads=2 --timeout=300 app:app
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
+1 -1
View File
@@ -28,7 +28,7 @@ from typing import Any
APP_DIR = Path(__file__).resolve().parent APP_DIR = Path(__file__).resolve().parent
SCAD_FILE = APP_DIR / "scad" / "hex_cell.scad" SCAD_FILE = APP_DIR / "scad" / "hex_cell.scad"
OPENSCAD_BIN = os.environ.get("OPENSCAD_BIN", "openscad") OPENSCAD_BIN = os.environ.get("OPENSCAD_BIN", "openscad")
RENDER_TIMEOUT = int(os.environ.get("OPENSCAD_TIMEOUT", "60")) RENDER_TIMEOUT = int(os.environ.get("OPENSCAD_TIMEOUT", "300"))
def openscad_available() -> bool: def openscad_available() -> bool:
+40
View File
@@ -65,3 +65,43 @@
flex-basis: 100%; flex-basis: 100%;
margin-top: -2px; margin-top: -2px;
} }
/* Render progress (indeterminate bar + elapsed-time counter) */
.render-progress {
display: flex;
align-items: center;
gap: 8px;
margin: 6px 0;
}
.render-progress-bar {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--border, #2a2f3a);
overflow: hidden;
position: relative;
}
.render-progress-fill {
position: absolute;
inset: 0;
width: 35%;
border-radius: 3px;
background: linear-gradient(90deg, transparent, var(--accent, #4a9eff), transparent);
animation: render-progress-slide 1.2s linear infinite;
}
@keyframes render-progress-slide {
0% { left: -35%; }
100% { left: 100%; }
}
.render-elapsed {
font-size: 11px;
color: var(--muted);
font-variant-numeric: tabular-nums;
min-width: 42px;
text-align: right;
}
/* Dirty indicator on Render button when params changed since last render */
#btn-render.dirty {
box-shadow: 0 0 0 2px var(--accent, #4a9eff) inset;
}
+7 -1
View File
@@ -28,7 +28,9 @@
<span id="status" class="topbar-status">— cells</span> <span id="status" class="topbar-status">— cells</span>
<div class="actions"> <div class="actions">
<button id="btn-download-stl" class="primary" title="Download STL of the current configuration">Download STL</button> <button id="btn-render" class="primary" title="Render STL with current parameters">Render</button>
<button id="btn-cancel" title="Cancel running render" hidden>Cancel</button>
<button id="btn-download-stl" title="Download STL of the current configuration">Download STL</button>
<button id="btn-download-scad" title="Download the OpenSCAD source with current parameters baked in">Download .scad</button> <button id="btn-download-scad" title="Download the OpenSCAD source with current parameters baked in">Download .scad</button>
<span class="sep"></span> <span class="sep"></span>
<button id="btn-to-busbar" class="primary" title="Open Busbar Designer with these cell coordinates pre-loaded">Design busbars →</button> <button id="btn-to-busbar" class="primary" title="Open Busbar Designer with these cell coordinates pre-loaded">Design busbars →</button>
@@ -47,6 +49,10 @@
<section class="panel"> <section class="panel">
<h2>Status</h2> <h2>Status</h2>
<p class="hint" id="render-status">idle</p> <p class="hint" id="render-status">idle</p>
<div id="render-progress" class="render-progress" hidden>
<div class="render-progress-bar"><div class="render-progress-fill"></div></div>
<span id="render-elapsed" class="render-elapsed">0.0s</span>
</div>
<p class="hint" id="render-time"></p> <p class="hint" id="render-time"></p>
<p class="hint" id="warning"></p> <p class="hint" id="warning"></p>
</section> </section>
+59 -17
View File
@@ -1,8 +1,8 @@
/* holder-app.js — controller for the Hex Holder Designer page. /* holder-app.js — controller for the Hex Holder Designer page.
* *
* - Loads /api/holder/params for the form schema and builds inputs. * - Loads /api/holder/params for the form schema and builds inputs.
* - On any param change, debounces 400 ms then POSTs /api/holder/render and * - Changing params marks the Render button "dirty"; user must click Render
* pushes the returned STL ArrayBuffer into the Three.js viewer. * (or Cancel) to drive POST /api/holder/render → Three.js viewer.
* - "Download STL" re-uses the last rendered blob (no double trip). * - "Download STL" re-uses the last rendered blob (no double trip).
* - "Design busbars →" computes cells via /api/holder/cells, creates a new * - "Design busbars →" computes cells via /api/holder/cells, creates a new
* busbar-designer project via /api/projects, redirects to /?p=<id>. * busbar-designer project via /api/projects, redirects to /?p=<id>.
@@ -14,8 +14,10 @@ const state = {
params: {}, // current values, name → value params: {}, // current values, name → value
schema: [], // [{name, label, kind, ...}] schema: [], // [{name, label, kind, ...}]
lastBlob: null, // last rendered STL blob (for download) lastBlob: null, // last rendered STL blob (for download)
renderTimer: null,
rendering: false, rendering: false,
dirty: false, // params changed since last successful render
abortCtrl: null, // for cancelling in-flight render
elapsedTimer: null,
rendererReady: false, rendererReady: false,
}; };
@@ -129,31 +131,56 @@ function _onParamChange(e) {
else v = el.value; else v = el.value;
state.params[k] = v; state.params[k] = v;
_updateStatus(); _updateStatus();
_scheduleRender(); _markDirty();
} }
// ----- render orchestration ------------------------------------------------- // ----- render orchestration -------------------------------------------------
function _scheduleRender() { function _markDirty() {
clearTimeout(state.renderTimer); state.dirty = true;
state.renderTimer = setTimeout(_doRender, 400); $("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() { async function _doRender() {
if (state.rendering) { if (state.rendering) return;
// Re-schedule once current finishes.
state.renderTimer = setTimeout(_doRender, 200);
return;
}
state.rendering = true; state.rendering = true;
state.abortCtrl = new AbortController();
$("render-status").textContent = "rendering…"; $("render-status").textContent = "rendering…";
$("warning").textContent = ""; $("warning").textContent = "";
$("render-time").textContent = "";
_showProgress(true);
const t0 = performance.now(); const t0 = performance.now();
state.elapsedTimer = setInterval(() => {
const dt = ((performance.now() - t0) / 1000).toFixed(1);
$("render-elapsed").textContent = `${dt}s`;
}, 100);
try { try {
const res = await fetch("/api/holder/render", { const res = await fetch("/api/holder/render", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ params: state.params }), body: JSON.stringify({ params: state.params }),
signal: state.abortCtrl.signal,
}); });
if (!res.ok) { if (!res.ok) {
let msg = res.statusText; let msg = res.statusText;
@@ -165,15 +192,20 @@ async function _doRender() {
window.HolderViewer.loadSTL(buf); window.HolderViewer.loadSTL(buf);
const dt = ((performance.now() - t0) / 1000).toFixed(1); const dt = ((performance.now() - t0) / 1000).toFixed(1);
$("render-status").textContent = `✓ rendered in ${dt}s · ${(state.lastBlob.size/1024).toFixed(0)} kB`; $("render-status").textContent = `✓ rendered in ${dt}s · ${(state.lastBlob.size/1024).toFixed(0)} kB`;
$("render-time").textContent = ""; _clearDirty();
} catch (e) { } catch (e) {
if (e.name === "AbortError") {
$("render-status").textContent = "✗ cancelled";
} else {
$("render-status").textContent = `${e.message}`; $("render-status").textContent = `${e.message}`;
if (/openscad/i.test(e.message)) { const { hint } = _classifyError(e.message);
$("warning").textContent = if (hint) $("warning").textContent = hint;
"Make sure OpenSCAD is installed on the server (apt install openscad).";
} }
} finally { } finally {
state.rendering = false; state.rendering = false;
state.abortCtrl = null;
clearInterval(state.elapsedTimer);
_showProgress(false);
} }
} }
@@ -191,6 +223,14 @@ function _updateStatus() {
// ----- buttons -------------------------------------------------------------- // ----- buttons --------------------------------------------------------------
$("btn-render").addEventListener("click", () => {
if (state.rendererReady) _doRender();
});
$("btn-cancel").addEventListener("click", () => {
if (state.abortCtrl) state.abortCtrl.abort();
});
$("btn-download-stl").addEventListener("click", () => { $("btn-download-stl").addEventListener("click", () => {
if (!state.lastBlob) { alert("Nothing rendered yet."); return; } if (!state.lastBlob) { alert("Nothing rendered yet."); return; }
const url = URL.createObjectURL(state.lastBlob); const url = URL.createObjectURL(state.lastBlob);
@@ -273,7 +313,9 @@ async function init() {
_whenViewerReady(() => { _whenViewerReady(() => {
window.HolderViewer.init($("viewer3d")); window.HolderViewer.init($("viewer3d"));
state.rendererReady = true; 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();
}); });
} }