diff --git a/deploy/busbar-designer.service b/deploy/busbar-designer.service index 45ef4f9..03d0b89 100644 --- a/deploy/busbar-designer.service +++ b/deploy/busbar-designer.service @@ -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 diff --git a/holder.py b/holder.py index d796034..80ee1f1 100644 --- a/holder.py +++ b/holder.py @@ -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: diff --git a/static/holder.css b/static/holder.css index 5353f9d..aebefb6 100644 --- a/static/holder.css +++ b/static/holder.css @@ -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; +} diff --git a/static/holder.html b/static/holder.html index 0866b1a..a26cfc3 100644 --- a/static/holder.html +++ b/static/holder.html @@ -28,7 +28,9 @@ — cells
- + + + @@ -47,6 +49,10 @@

Status

idle

+

diff --git a/static/js/holder-app.js b/static/js/holder-app.js index 44dbc8c..1376958 100644 --- a/static/js/holder-app.js +++ b/static/js/holder-app.js @@ -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=. @@ -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(); }); }