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:
@@ -28,7 +28,7 @@ Environment=PATH=/opt/busbar-designer/.venv/bin:/usr/local/sbin:/usr/local/bin:/
|
||||
Environment=HOME=/opt/busbar-designer/data
|
||||
Environment=XDG_CONFIG_HOME=/opt/busbar-designer/data/.config
|
||||
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
|
||||
RestartSec=5
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ from typing import Any
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
SCAD_FILE = APP_DIR / "scad" / "hex_cell.scad"
|
||||
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:
|
||||
|
||||
@@ -65,3 +65,43 @@
|
||||
flex-basis: 100%;
|
||||
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
@@ -28,7 +28,9 @@
|
||||
<span id="status" class="topbar-status">— cells</span>
|
||||
|
||||
<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>
|
||||
<span class="sep"></span>
|
||||
<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">
|
||||
<h2>Status</h2>
|
||||
<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="warning"></p>
|
||||
</section>
|
||||
|
||||
+60
-18
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user