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 @@
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();
});
}