"""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 re 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") # Empty string disables; default Manifold gives ~10-50x speedup on OpenSCAD 2024+ OPENSCAD_BACKEND = os.environ.get("OPENSCAD_BACKEND", "Manifold") RENDER_TIMEOUT = int(os.environ.get("OPENSCAD_TIMEOUT", "300")) # Cap total cells to keep renders fast and STL sizes sane. 1000 covers # real packs (e.g. 20×50 = 1000) while blocking accidental 120×120 = 14400. MAX_CELLS = int(os.environ.get("HOLDER_MAX_CELLS", "1000")) 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 # ----- automated scan of the bundled .scad ---------------------------------- # We extract top-level `name = literal; // help` lines into Param entries. # Lines whose RHS isn't a constant number / bool / string (e.g. derived # expressions like `box_total_height = get_mock_pack_height() + ...`) are # skipped — those would still take their default value from the .scad itself. # Param categories — used both for grouping in the UI and for picking the # select-typed params (those with a fixed option set). Keep keys in scad # variable-name space; group order/labels are the rendering hint. _SELECT_OPTIONS: dict[str, list[str]] = { "part": ["holder", "cap", "box lid", "box bottom", "wire clamp", "insulator", "vertical box section", "busbar template"], "part_type": ["normal", "mirrored", "both", "assembled"], "pack_style": ["rect", "para", "tria"], "wire_style": ["strip", "bus"], "box_style": ["bolt", "ziptie", "both"], "template_outline": ["rect", "hull"], "template_hole_style": ["engrave", "through", "center"], } # How to group each scad var into UI sections. First match wins; "advanced" # catches the rest. _GROUP_RULES: list[tuple[str, list[str]]] = [ ("part", ["part", "part_type", "pack_style", "wire_style", "box_style"]), ("cell", ["cell_dia", "cell_height", "wall"]), ("size", ["num_rows", "num_cols"]), ("holder", ["holder_height", "slot_height", "col_slot_width", "row_slot_width", "cell_top_overlap"]), ("cap", ["cap_wall", "cap_clearance"]), ("box", ["box_lip", "wire_clamp_add", "box_wall", "box_clearance", "bms_clearance", "box_bottom_clearance", "box_wire_side_clearance", "box_nonwire_side_clearance"]), ("insulator",["insulator_as_support", "support_z_gap", "insulator_tolerance"]), ("bolts", ["bolt_dia", "bolt_head_dia", "bolt_head_thickness", "ziptie_width", "ziptie_thickness", "bolt_dia_clearance", "wire_clamp_bolt_dia"]), ("wires", ["wire_diameter", "clamp_factor", "wire_hole_width", "wire_hole_length", "wire_top_wall", "clamp_plate_height"]), ("stacking", ["stacking_pins", "stacking_pin_dia", "stacking_pin_alt_style", "stacking_bolts", "stacking_bolt_dia", "num_pack_stacks", "stacking_pins_tolerance"]), ("template", ["template_2d", "template_outline", "template_thickness", "template_margin", "template_mark_dia", "template_hole_style", "template_line_width", "template_engrave_depth", "template_center_mark", "template_center_mark_dia"]), ] # Tighter numeric input hints (min/max/step) for selected params. Anything # not listed here gets a free-form number input. _NUMBER_HINTS: dict[str, dict[str, float]] = { "cell_dia": {"min": 10, "max": 40, "step": 0.1}, "cell_height": {"min": 30, "max": 200, "step": 1}, "wall": {"min": 0.2, "max": 3, "step": 0.1}, "num_rows": {"min": 1, "max": 50, "step": 1}, "num_cols": {"min": 1, "max": 50, "step": 1}, "holder_height": {"min": 4, "max": 30, "step": 0.5}, "slot_height": {"min": 0, "max": 10, "step": 0.5}, "col_slot_width": {"min": 0, "max": 20, "step": 0.5}, "row_slot_width": {"min": 0, "max": 20, "step": 0.5}, "cell_top_overlap": {"min": 0, "max": 10, "step": 0.5}, "cap_wall": {"min": 0.4, "max": 5, "step": 0.1}, "box_wall": {"min": 0.4, "max": 10, "step": 0.1}, "num_pack_stacks": {"min": 1, "max": 10, "step": 1}, } _LITERAL_RE = re.compile( r"^\s*([a-z_][a-z0-9_]*)\s*=\s*" r"(?Ptrue|false|\"[^\"]*\"|[-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s*;" r"(?:\s*//\s*(?P.*))?\s*$" ) def _group_for(name: str) -> str: for grp, names in _GROUP_RULES: if name in names: return grp return "advanced" def _pretty_label(name: str) -> str: return name.replace("_", " ").capitalize() def _scan_scad(path: Path) -> list[Param]: out: list[Param] = [] seen: set[str] = set() if not path.is_file(): return out for raw in path.read_text(encoding="utf-8", errors="replace").splitlines(): # Stop at the explicit end-of-config marker so we don't pick up # internal helpers (hextra, spacing, etc.). if "END OF CONFIGURATION" in raw or "NON-Configurable" in raw: break m = _LITERAL_RE.match(raw) if not m: continue name = m.group(1) if name in seen: continue rhs = m.group("rhs") help_text = (m.group("comment") or "").strip() or None if rhs in ("true", "false"): kind, default = "bool", (rhs == "true") elif rhs.startswith('"'): literal = rhs[1:-1] if name in _SELECT_OPTIONS: kind, default = "select", literal else: # Plain free-text strings aren't worth a form field. continue else: try: num = float(rhs) default = int(num) if num.is_integer() else num kind = "number" except ValueError: continue p = Param( name=name, label=_pretty_label(name), kind=kind, default=default, group=_group_for(name), help=help_text, ) if kind == "select": p.options = _SELECT_OPTIONS[name] if kind == "number" and name in _NUMBER_HINTS: h = _NUMBER_HINTS[name] p.min, p.max, p.step = h.get("min"), h.get("max"), h.get("step") out.append(p) seen.add(name) return out PARAMS: list[Param] = _scan_scad(SCAD_FILE) 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 expected_cell_count(params: dict) -> int: """Cell count for a pack without computing coordinates.""" rows = int(params.get("num_rows", 6)) cols = int(params.get("num_cols", 12)) style = str(params.get("pack_style", "rect")) if style == "tria": return rows * (rows + 1) // 2 return rows * cols def _check_cell_limit(params: dict) -> None: n = expected_cell_count(params) if n > MAX_CELLS: raise ValueError( f"Too many cells ({n} > {MAX_CELLS}). Reduce rows × cols." ) def compute_cells(params: dict) -> list[dict]: _check_cell_limit(params) 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 _ECHO_RE = re.compile(r'^ECHO:\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?)\s*$') def _parse_echo_dims(stderr_bytes: bytes) -> dict: """Pick up `ECHO: name = N` numeric pairs from openscad stderr. The hex_cell.scad echoes derived measurements (pack_height_holder, total_length_holder, box_total_height, etc.) — useful for fit-check against a user enclosure without us mirroring all the SCAD math. """ out: dict = {} for line in (stderr_bytes or b"").decode(errors="replace").splitlines(): m = _ECHO_RE.match(line.strip()) if m: try: out[m.group(1)] = float(m.group(2)) except ValueError: pass return out def render_stl(params: dict) -> tuple[bytes, dict]: """Render the .scad with given parameter overrides. Returns (stl_bytes, dimensions) where dimensions is a {name: float} dict harvested from openscad ECHO output (pack/box/holder sizes). """ _check_cell_limit(params) 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)] if OPENSCAD_BACKEND: cmd += ["--backend", OPENSCAD_BACKEND] 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(), _parse_echo_dims(r.stderr or b"")