6bc922cabf
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
226 lines
8.4 KiB
Python
226 lines
8.4 KiB
Python
"""Generate hex cell holder STL via OpenSCAD subprocess.
|
||
|
||
We embed Addy's hex_cell.scad (in scad/) and shell out to `openscad` with
|
||
parameter overrides via `-D var=value`. STL bytes come back from a temp file.
|
||
|
||
Cell-center coordinates are derived in *Python* using the same formulas
|
||
the .scad file uses (see static/js/importer.js for the JS mirror). That avoids
|
||
a second OpenSCAD invocation just to echo coordinates and matches the math
|
||
exactly — see `hex_holder_geometry` memory for the formulas.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import math
|
||
import os
|
||
import shutil
|
||
import subprocess
|
||
import tempfile
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Locations / config
|
||
# ---------------------------------------------------------------------------
|
||
|
||
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"))
|
||
|
||
|
||
def openscad_available() -> bool:
|
||
return shutil.which(OPENSCAD_BIN) is not None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Parameter schema — exposed to the frontend for auto-form generation.
|
||
# Only the subset users typically touch; the .scad itself has many more knobs
|
||
# that fall back to script defaults.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@dataclass
|
||
class Param:
|
||
name: str # SCAD variable name
|
||
label: str # UI label
|
||
kind: str # "number" | "select" | "bool"
|
||
default: Any
|
||
group: str = "main"
|
||
options: list[str] | None = None # for kind="select"
|
||
min: float | None = None # for kind="number"
|
||
max: float | None = None
|
||
step: float | None = None
|
||
help: str | None = None
|
||
|
||
|
||
PARAMS: list[Param] = [
|
||
# Pack geometry
|
||
Param("part", "Part", "select", "holder", group="part",
|
||
options=["holder", "cap", "box lid", "box bottom", "insulator"],
|
||
help="Which piece to generate."),
|
||
Param("pack_style", "Pack style", "select", "rect", group="part",
|
||
options=["rect", "para", "tria"],
|
||
help="Cell arrangement."),
|
||
Param("wire_style", "Wire style", "select", "strip", group="part",
|
||
options=["strip", "bus"],
|
||
help="strip = nickel strips between cells; bus = bus wires between rows."),
|
||
|
||
# Cell
|
||
Param("cell_dia", "Cell diameter (mm)", "number", 21.2, group="cell",
|
||
min=10, max=40, step=0.1, help="21.2 for 21700, 18.4 for 18650, 26.5 for 26650."),
|
||
Param("cell_height", "Cell height (mm)", "number", 70.0, group="cell",
|
||
min=30, max=200, step=1),
|
||
Param("wall", "Wall thickness (mm)", "number", 0.8, group="cell",
|
||
min=0.4, max=3.0, step=0.1,
|
||
help="Wall around one cell; spacing between cells is 2× this."),
|
||
|
||
# Pack size
|
||
Param("num_rows", "Rows", "number", 6, group="size", min=1, max=40, step=1),
|
||
Param("num_cols", "Columns", "number", 12, group="size", min=1, max=40, step=1),
|
||
|
||
# Holder
|
||
Param("holder_height", "Holder height (mm)", "number", 10.0, group="holder",
|
||
min=4, max=30, step=0.5),
|
||
Param("slot_height", "Slot height (mm)", "number", 3.0, group="holder",
|
||
min=0, max=10, step=0.5,
|
||
help="Height of all wire slots (set 0 for no slots)."),
|
||
Param("col_slot_width", "Column slot width (mm)", "number", 8.0, group="holder",
|
||
min=0, max=20, step=0.5),
|
||
Param("row_slot_width", "Row slot width (mm)", "number", 8.0, group="holder",
|
||
min=0, max=20, step=0.5),
|
||
Param("cell_top_overlap", "Cell top overlap (mm)","number", 3.0, group="holder",
|
||
min=0, max=10, step=0.5,
|
||
help="Opening dia = cell_dia − 2 × this. Welding-window size."),
|
||
]
|
||
|
||
|
||
def default_params() -> dict[str, Any]:
|
||
return {p.name: p.default for p in PARAMS}
|
||
|
||
|
||
def schema_dict() -> list[dict]:
|
||
out = []
|
||
for p in PARAMS:
|
||
d = {"name": p.name, "label": p.label, "kind": p.kind,
|
||
"default": p.default, "group": p.group}
|
||
if p.options is not None: d["options"] = p.options
|
||
if p.min is not None: d["min"] = p.min
|
||
if p.max is not None: d["max"] = p.max
|
||
if p.step is not None: d["step"] = p.step
|
||
if p.help is not None: d["help"] = p.help
|
||
out.append(d)
|
||
return out
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Cell-center computation (Python mirror of get_hex_center_points_*).
|
||
# Returns list of {id, x, y} in mm.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
_COS30 = math.cos(math.radians(30))
|
||
|
||
|
||
def compute_cells(params: dict) -> list[dict]:
|
||
cell_dia = float(params.get("cell_dia", 21.2))
|
||
wall = float(params.get("wall", 0.8))
|
||
rows = int(params.get("num_rows", 6))
|
||
cols = int(params.get("num_cols", 12))
|
||
style = str(params.get("pack_style", "rect"))
|
||
|
||
hex_w = cell_dia + 2 * wall
|
||
hex_pt = (hex_w / 2) / _COS30
|
||
row_y = lambda r: r * 1.5 * hex_pt
|
||
|
||
out: list[dict] = []
|
||
cid = 1
|
||
if style == "rect":
|
||
for r in range(rows):
|
||
for c in range(cols):
|
||
x = (0 if r % 2 == 0 else 0.5 * hex_w) + hex_w * c
|
||
out.append({"id": cid, "x": x, "y": row_y(r)})
|
||
cid += 1
|
||
elif style == "para":
|
||
for r in range(rows):
|
||
for c in range(cols):
|
||
out.append({"id": cid, "x": r * 0.5 * hex_w + hex_w * c, "y": row_y(r)})
|
||
cid += 1
|
||
elif style == "tria":
|
||
for r in range(rows):
|
||
for c in range(r + 1):
|
||
out.append({"id": cid, "x": r * 0.5 * hex_w - hex_w * c, "y": row_y(r)})
|
||
cid += 1
|
||
else:
|
||
raise ValueError(f"unknown pack_style: {style!r}")
|
||
return out
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# OpenSCAD render
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _to_scad_literal(v: Any) -> str:
|
||
"""Convert a Python value to the literal text OpenSCAD expects on -D."""
|
||
if isinstance(v, bool):
|
||
return "true" if v else "false"
|
||
if isinstance(v, (int, float)):
|
||
return repr(v)
|
||
if isinstance(v, str):
|
||
# OpenSCAD strings are double-quoted; escape via json.
|
||
return json.dumps(v)
|
||
raise TypeError(f"unsupported parameter type: {type(v).__name__}")
|
||
|
||
|
||
def _filter_params(params: dict) -> dict:
|
||
"""Drop None and unknown-name params; coerce types where obvious."""
|
||
known = {p.name: p for p in PARAMS}
|
||
out = {}
|
||
for k, v in (params or {}).items():
|
||
if k not in known or v is None:
|
||
continue
|
||
p = known[k]
|
||
if p.kind == "number":
|
||
v = float(v) if p.step and p.step != int(p.step) else int(float(v))
|
||
# keep ints for integer-step params
|
||
if isinstance(p.default, int) and float(v).is_integer():
|
||
v = int(v)
|
||
elif p.kind == "bool":
|
||
v = bool(v)
|
||
elif p.kind == "select":
|
||
v = str(v)
|
||
out[k] = v
|
||
return out
|
||
|
||
|
||
def render_stl(params: dict) -> bytes:
|
||
"""Render the .scad with given parameter overrides; return STL bytes."""
|
||
if not openscad_available():
|
||
raise RuntimeError(
|
||
f"`{OPENSCAD_BIN}` not found on PATH. Install OpenSCAD "
|
||
"(e.g. `apt install openscad` on Debian/Ubuntu)."
|
||
)
|
||
if not SCAD_FILE.is_file():
|
||
raise FileNotFoundError(f"SCAD source not found at {SCAD_FILE}")
|
||
|
||
clean = _filter_params(params)
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
out = Path(tmp) / "out.stl"
|
||
cmd = [OPENSCAD_BIN, "-o", str(out)]
|
||
for k, v in clean.items():
|
||
cmd += ["-D", f"{k}={_to_scad_literal(v)}"]
|
||
cmd.append(str(SCAD_FILE))
|
||
try:
|
||
r = subprocess.run(cmd, capture_output=True, timeout=RENDER_TIMEOUT)
|
||
except subprocess.TimeoutExpired:
|
||
raise RuntimeError(f"openscad timed out after {RENDER_TIMEOUT}s")
|
||
if r.returncode != 0:
|
||
err = (r.stderr or b"").decode(errors="replace").strip()
|
||
raise RuntimeError(f"openscad failed (exit {r.returncode}):\n{err[-800:]}")
|
||
if not out.exists() or out.stat().st_size == 0:
|
||
raise RuntimeError("openscad produced no STL (geometry empty?)")
|
||
return out.read_bytes()
|