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:
@@ -0,0 +1,225 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user