5bba0e3c4a
The bundled hex_cell.scad has many more knobs than the dozen we used to expose (box clearances, cap/box wall, vertical stacking, busbar template, etc). Maintaining a hand-curated PARAMS list in Python next to a SCAD file that already documents every variable inline was always going to drift. Sync + auto-extract approach: - scad/hex_cell.scad: replace with the upstream master file from Albert Phan's Hex-Cell-Holder fork (adds the BUSBAR TEMPLATE section and the "busbar template" part option). - holder.py: PARAMS now built at import time by _scan_scad(), which regexes top-level `name = literal; // help` lines into Param entries up until the // END OF CONFIGURATION marker. Scan handles bool / number / string literals; derived expressions and helper variables are skipped automatically. - Manual maps stay small and explicit: _SELECT_OPTIONS for the few string-enum params (part, pack_style, box_style, template_outline, template_hole_style, etc.), _GROUP_RULES for UI sectioning, and _NUMBER_HINTS for sensible min/max/step on the most-tweaked numbers. UI: - holder-app.js: extra GROUP_ORDER / GROUP_LABELS for the new sections (cap, box, insulator, bolts, wires, stacking, template, advanced). Group titles are now click-to-collapse; the advanced groups start collapsed so the form isn't a wall of inputs on load. - holder.css: caret marker on the group title, smooth rotate on collapse, hides the body via .collapsed class. Net effect: every variable in the .scad — including the new busbar template knobs — is editable from the page, with helpful comments copied straight from the .scad source.
375 lines
14 KiB
Python
375 lines
14 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 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"(?P<rhs>true|false|\"[^\"]*\"|[-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s*;"
|
||
r"(?:\s*//\s*(?P<comment>.*))?\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"")
|