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=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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user