/* tracks-app.js — controller for the /tracks (train tracks generator) page. * * - GET /api/tracks/params — schema for all parts. * - Switching the part selector rebuilds the param form for that part. * - POST /api/tracks/render with {part, params} → STL → Three.js viewer. * - Re-uses holder-viewer.js (Three.js scene) and the same status / progress * markup the holder uses, styled via holder.css. */ const $ = (id) => document.getElementById(id); const state = { schema: null, // {parts: [{key, label, module, params, defaults}, ...]} partsByKey: {}, // shortcut: key -> spec currentPart: null, // key params: {}, // per-part current values (replaced on part change) rendering: false, abortCtrl: null, elapsedTimer: null, lastBlob: null, rendererReady: false, }; function _whenViewerReady(cb) { if (window.HolderViewer) { cb(); return; } let tries = 0; const t = setInterval(() => { if (window.HolderViewer) { clearInterval(t); cb(); } else if (++tries > 50) { clearInterval(t); console.error("HolderViewer never loaded"); } }, 60); } // ----- form rendering ------------------------------------------------------- function _renderForm() { const spec = state.partsByKey[state.currentPart]; const root = $("param-form"); root.innerHTML = ""; if (!spec) return; for (const p of spec.params) { const row = document.createElement("div"); row.className = "param-row"; const label = document.createElement("label"); label.textContent = p.label || p.name; row.appendChild(label); let inp; if (p.kind === "select") { inp = document.createElement("select"); for (const opt of p.options || []) { const o = document.createElement("option"); o.value = opt; o.textContent = opt; if (opt === state.params[p.name]) o.selected = true; inp.appendChild(o); } } else if (p.kind === "bool") { inp = document.createElement("input"); inp.type = "checkbox"; inp.checked = !!state.params[p.name]; } else { inp = document.createElement("input"); inp.type = "number"; if (p.min != null) inp.min = p.min; if (p.max != null) inp.max = p.max; if (p.step != null) inp.step = p.step; inp.value = state.params[p.name] ?? p.default; } inp.name = p.name; inp.dataset.kind = p.kind; inp.addEventListener("change", _onParamChange); inp.addEventListener("input", _onParamChange); row.appendChild(inp); if (p.help) { const h = document.createElement("div"); h.className = "param-help"; h.textContent = p.help; row.appendChild(h); } root.appendChild(row); } $("status").textContent = spec.label; } function _onParamChange(e) { const el = e.target; const k = el.name; let v; if (el.dataset.kind === "bool") v = el.checked; else if (el.dataset.kind === "number") v = el.value === "" ? null : Number(el.value); else v = el.value; state.params[k] = v; } function _switchPart(key) { const spec = state.partsByKey[key]; if (!spec) return; state.currentPart = key; state.params = { ...spec.defaults }; $("part-help").textContent = spec.module ? `module: ${spec.module}()` : ""; _renderForm(); } // ----- render orchestration ------------------------------------------------- function _showProgress(show) { $("render-progress").hidden = !show; $("btn-render").hidden = show; $("btn-cancel").hidden = !show; } async function _doRender() { if (state.rendering || !state.currentPart) return; state.rendering = true; state.abortCtrl = new AbortController(); $("render-status").textContent = "rendering…"; $("warning").textContent = ""; _showProgress(true); const t0 = performance.now(); state.elapsedTimer = setInterval(() => { $("render-elapsed").textContent = ((performance.now() - t0) / 1000).toFixed(1) + "s"; }, 100); try { const res = await fetch("/api/tracks/render", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ part: state.currentPart, params: state.params }), signal: state.abortCtrl.signal, }); if (!res.ok) { let msg = res.statusText; try { msg = (await res.json()).error || msg; } catch {} throw new Error(msg); } state.lastBlob = await res.blob(); const buf = await state.lastBlob.arrayBuffer(); 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`; } catch (e) { if (e.name === "AbortError") $("render-status").textContent = "✗ cancelled"; else $("render-status").textContent = `✗ ${e.message}`; } finally { state.rendering = false; state.abortCtrl = null; clearInterval(state.elapsedTimer); _showProgress(false); } } // ----- wiring --------------------------------------------------------------- $("btn-render").addEventListener("click", _doRender); $("btn-cancel").addEventListener("click", () => state.abortCtrl?.abort()); $("part-select").addEventListener("change", (e) => _switchPart(e.target.value)); $("btn-download-stl").addEventListener("click", () => { if (!state.lastBlob) { alert("Nothing rendered yet."); return; } const url = URL.createObjectURL(state.lastBlob); const a = document.createElement("a"); a.href = url; a.download = `track_${state.currentPart}.stl`; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); }); // Ctrl+Enter triggers Render. document.addEventListener("keydown", (e) => { if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.preventDefault(); _doRender(); } }); // ----- bootstrap ------------------------------------------------------------ async function init() { try { const r = await fetch("/api/tracks/params"); if (!r.ok) throw new Error(r.statusText); state.schema = await r.json(); state.partsByKey = Object.fromEntries(state.schema.parts.map((p) => [p.key, p])); // Populate the part selector. const sel = $("part-select"); sel.innerHTML = ""; for (const p of state.schema.parts) { const o = document.createElement("option"); o.value = p.key; o.textContent = p.label; sel.appendChild(o); } // Pick "track" (straight) by default — most useful first impression. const defaultKey = state.partsByKey.track ? "track" : state.schema.parts[0].key; sel.value = defaultKey; _switchPart(defaultKey); } catch (e) { $("param-form").innerHTML = `
Couldn't load schema: ${e.message}
`; return; } _whenViewerReady(() => { window.HolderViewer.init($("viewer3d")); state.rendererReady = true; _doRender(); // initial render so the user sees something }); } init();