"""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()