Files
busbar-designer/holder.py
T
wenil 5bba0e3c4a holder: expose all 59 scad parameters auto-extracted from the .scad source
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.
2026-05-25 12:44:45 +03:00

375 lines
14 KiB
Python
Raw 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 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"")