Files
wenil 6bc922cabf 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
2026-05-24 19:27:50 +03:00

226 lines
8.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()