Add hex holder designer page (/holder)

Server-side OpenSCAD renders STL from bundled hex_cell.scad with parameter
overrides via -D. Frontend is a Three.js viewer with auto-form generated
from /api/holder/params. 'Design busbars →' button posts the computed
cell coordinates to /api/projects and redirects to the busbar editor with
the holder cells pre-loaded.

  - holder.py:                openscad subprocess wrapper + compute_cells()
                              (Python mirror of get_hex_center_points_*)
  - scad/hex_cell.scad:       verbatim copy of Addy/Hex-Cell-Holder source
  - app.py:                   /holder route + /api/holder/{params,render,cells}
  - static/holder.html etc:   parameter form + Three.js STL viewer
  - Dockerfile / install.sh:  apt install openscad
  - static/index.html:        nav link Holder ↔ Busbars in topbar
This commit is contained in:
wenil
2026-05-24 19:27:50 +03:00
parent d8cb0dc06d
commit 6bc922cabf
13 changed files with 2371 additions and 9 deletions
+280
View File
@@ -0,0 +1,280 @@
/* 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.
* - "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>.
*/
const $ = (id) => document.getElementById(id);
const state = {
params: {}, // current values, name → value
schema: [], // [{name, label, kind, ...}]
lastBlob: null, // last rendered STL blob (for download)
renderTimer: null,
rendering: false,
rendererReady: false,
};
// ----- viewer init ----------------------------------------------------------
// HolderViewer is set up by holder-viewer.js (loaded as ES module) and
// attached to window. We wait briefly for it to be ready.
function _whenViewerReady(cb) {
if (window.HolderViewer) {
cb();
return;
}
let tries = 0;
const t = setInterval(() => {
tries++;
if (window.HolderViewer) {
clearInterval(t);
cb();
} else if (tries > 50) {
clearInterval(t);
console.error("HolderViewer never loaded");
}
}, 60);
}
// ----- form generation ------------------------------------------------------
const GROUP_ORDER = ["part", "cell", "size", "holder"];
const GROUP_LABELS = {
part: "Part / pack",
cell: "Cell",
size: "Pack size",
holder: "Holder",
};
function _renderForm(schema, defaults) {
const root = $("param-form");
root.innerHTML = "";
// Group params.
const byGroup = new Map();
for (const p of schema) {
if (!byGroup.has(p.group)) byGroup.set(p.group, []);
byGroup.get(p.group).push(p);
}
const groups = GROUP_ORDER.filter((g) => byGroup.has(g))
.concat([...byGroup.keys()].filter((g) => !GROUP_ORDER.includes(g)));
for (const g of groups) {
const wrap = document.createElement("div");
wrap.className = "param-group";
const title = document.createElement("h3");
title.className = "param-group-title";
title.textContent = GROUP_LABELS[g] || g;
wrap.appendChild(title);
for (const p of byGroup.get(g)) {
const row = document.createElement("div");
row.className = "param-row";
const label = document.createElement("label");
label.textContent = p.label;
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);
}
wrap.appendChild(row);
}
root.appendChild(wrap);
}
}
function _onParamChange(e) {
const el = e.target;
const k = el.name;
const kind = el.dataset.kind;
let v;
if (kind === "bool") v = el.checked;
else if (kind === "number") v = el.value === "" ? null : Number(el.value);
else v = el.value;
state.params[k] = v;
_updateStatus();
_scheduleRender();
}
// ----- render orchestration -------------------------------------------------
function _scheduleRender() {
clearTimeout(state.renderTimer);
state.renderTimer = setTimeout(_doRender, 400);
}
async function _doRender() {
if (state.rendering) {
// Re-schedule once current finishes.
state.renderTimer = setTimeout(_doRender, 200);
return;
}
state.rendering = true;
$("render-status").textContent = "rendering…";
$("warning").textContent = "";
const t0 = performance.now();
try {
const res = await fetch("/api/holder/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ params: state.params }),
});
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`;
$("render-time").textContent = "";
} 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).";
}
} finally {
state.rendering = false;
}
}
function _updateStatus() {
// Quick cell count without hitting the server — uses the same formulas
// server-side but we cheap-out here for instant feedback.
const rows = +state.params.num_rows || 0;
const cols = +state.params.num_cols || 0;
const style = state.params.pack_style;
let n;
if (style === "tria") n = rows * (rows + 1) / 2;
else n = rows * cols;
$("status").textContent = `${n} cells`;
}
// ----- buttons --------------------------------------------------------------
$("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 = `hex_holder_${state.params.pack_style}_${state.params.num_rows}x${state.params.num_cols}.stl`;
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
});
$("btn-download-scad").addEventListener("click", async () => {
// Reuse the bundled SCAD plus a header that pins current values, so the
// downloaded file reproduces the same model when opened in OpenSCAD.
const overrides = Object.entries(state.params)
.map(([k, v]) => `${k} = ${JSON.stringify(v)};`)
.join("\n");
// We don't have a backend endpoint for the .scad source yet; fetch it
// directly (deploy/install.sh puts it under /scad/hex_cell.scad — for the
// local Flask app it's not exposed under /static. So inline a banner that
// points the user to copy the params manually for now.)
const banner = `// Generated by Hex Holder Designer\n// Drop these overrides into hex_cell.scad (or pass via OpenSCAD -D):\n//\n${
overrides.split("\n").map((l) => "// " + l).join("\n")
}\n//\n// Or run: openscad -o out.stl ${
Object.entries(state.params).map(([k,v]) => `-D ${k}=${JSON.stringify(v)}`).join(" ")
} hex_cell.scad\n`;
const blob = new Blob([banner + "\n" + overrides + "\n"], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "hex_holder_params.scad";
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
});
$("btn-to-busbar").addEventListener("click", async () => {
$("render-status").textContent = "exporting cells…";
try {
const r = await fetch("/api/holder/cells", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ params: state.params }),
});
if (!r.ok) throw new Error((await r.json()).error || r.statusText);
const { cells } = await r.json();
if (!cells || !cells.length) throw new Error("no cells produced");
const partLabel = `${state.params.pack_style} ${state.params.num_rows}×${state.params.num_cols} (${state.params.cell_dia}mm)`;
const proj = await Api.createProject(`Holder · ${partLabel}`, {
params: {
cellDia: state.params.cell_dia,
openingDia: state.params.cell_dia - 2 * state.params.cell_top_overlap,
},
cells,
busbars: [],
activeBusbarId: null,
});
location.href = `/?p=${proj.id}`;
} catch (e) {
$("render-status").textContent = `${e.message}`;
}
});
// ----- bootstrap ------------------------------------------------------------
async function init() {
try {
const r = await fetch("/api/holder/params");
if (!r.ok) throw new Error(r.statusText);
const { params: schema, defaults } = await r.json();
state.schema = schema;
state.params = { ...defaults };
_renderForm(schema, defaults);
_updateStatus();
} catch (e) {
$("param-form").innerHTML =
`<div class="step-preview-err">Couldn't load param schema: ${e.message}</div>`;
return;
}
_whenViewerReady(() => {
window.HolderViewer.init($("viewer3d"));
state.rendererReady = true;
_doRender(); // initial render with defaults
});
}
init();
+131
View File
@@ -0,0 +1,131 @@
/* holder-viewer.js — Three.js scene for STL preview.
*
* Exports a single global `HolderViewer` (UMD-ish, attached to window) so the
* non-module app script can use it. Set up via HolderViewer.init(canvasEl);
* load STL bytes via HolderViewer.loadSTL(arrayBuffer).
*/
import * as THREE from "three";
import { STLLoader } from "three/addons/loaders/STLLoader.js";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
const HolderViewer = (() => {
let scene, camera, renderer, controls;
let mesh = null;
let host = null;
function init(hostEl) {
host = hostEl;
const w = host.clientWidth || 800;
const h = host.clientHeight || 600;
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0d12);
camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 5000);
camera.position.set(120, 120, 120);
camera.up.set(0, 0, 1); // Z-up (CAD convention)
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(w, h);
host.innerHTML = "";
host.appendChild(renderer.domElement);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.screenSpacePanning = true;
// Lights
scene.add(new THREE.AmbientLight(0xffffff, 0.45));
const key = new THREE.DirectionalLight(0xffffff, 0.7);
key.position.set(1, 1, 2).normalize();
scene.add(key);
const fill = new THREE.DirectionalLight(0xaccfff, 0.35);
fill.position.set(-1, -1, 0.5).normalize();
scene.add(fill);
// Build plate grid (10 mm minor, 50 mm major) at Z=0
const grid = new THREE.GridHelper(400, 40, 0x2a3140, 0x1f2530);
grid.rotateX(Math.PI / 2); // make it the XY plane (camera up is Z)
scene.add(grid);
// Axes (small)
const axes = new THREE.AxesHelper(20);
scene.add(axes);
// Resize observer
new ResizeObserver(_onResize).observe(host);
_animate();
}
function _onResize() {
if (!host) return;
const w = host.clientWidth, h = host.clientHeight;
if (w === 0 || h === 0) return;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}
function _animate() {
requestAnimationFrame(_animate);
controls.update();
renderer.render(scene, camera);
}
/** Load STL bytes (ArrayBuffer) and replace any existing mesh. */
function loadSTL(buf) {
const loader = new STLLoader();
const geom = loader.parse(buf);
geom.computeVertexNormals();
// Centre the geometry on its bounding box and align bottom to Z=0.
geom.computeBoundingBox();
const bb = geom.boundingBox;
const cx = (bb.min.x + bb.max.x) / 2;
const cy = (bb.min.y + bb.max.y) / 2;
geom.translate(-cx, -cy, -bb.min.z);
if (mesh) {
scene.remove(mesh);
mesh.geometry.dispose();
mesh.material.dispose();
}
const mat = new THREE.MeshStandardMaterial({
color: 0xf08a24,
metalness: 0.15,
roughness: 0.65,
flatShading: false,
});
mesh = new THREE.Mesh(geom, mat);
scene.add(mesh);
_fitCameraToMesh(geom);
}
function _fitCameraToMesh(geom) {
const bb = geom.boundingBox;
const size = bb.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const dist = maxDim * 1.6;
camera.position.set(dist * 0.8, -dist * 0.8, dist * 0.8);
controls.target.set(0, 0, size.z / 2);
camera.lookAt(controls.target);
camera.updateProjectionMatrix();
}
function clear() {
if (mesh) {
scene.remove(mesh);
mesh.geometry.dispose();
mesh.material.dispose();
mesh = null;
}
}
return { init, loadSTL, clear };
})();
window.HolderViewer = HolderViewer;