5d65b0d8a1
_filter_params() had a stale short-circuit:
v = float(v) if p.step and p.step != int(p.step) else int(float(v))
For auto-extracted params (the new majority of PARAMS), `p.step is None`,
which is falsy. The conditional took the `int(float(v))` branch and
truncated values like 0.2 -> 0, 0.7 -> 0, 1.5 -> 1. The /holder render
silently dropped every clearance, tolerance, and offset to zero, so
the holder pieces in "both" mode (and a handful of other configurations)
came out merged at their edges. /scad never hit the bug because it
doesn't pass -D overrides — the SCAD defaults applied unchanged.
Fix: always coerce through float, then narrow back to int only when
the result has no fractional part. Verified /holder render with all
defaults now produces the exact same STL bytes as /scad with the
bundled source (md5 match).
411 lines
16 KiB
Python
411 lines
16 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":
|
||
# Always coerce through float — earlier logic short-circuited to
|
||
# int(float(v)) when step was None (most auto-extracted params),
|
||
# truncating decimals like box_clearance=0.2 → 0.
|
||
fv = float(v)
|
||
v = int(fv) if fv.is_integer() else fv
|
||
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
|
||
|
||
|
||
MAX_SOURCE_BYTES = int(os.environ.get("OPENSCAD_MAX_SOURCE_BYTES", str(512 * 1024)))
|
||
|
||
|
||
def _run_openscad(scad_path: Path, params: dict | None) -> tuple[bytes, dict]:
|
||
if not openscad_available():
|
||
raise RuntimeError(
|
||
f"`{OPENSCAD_BIN}` not found on PATH. Install OpenSCAD "
|
||
"(e.g. `apt install openscad` on Debian/Ubuntu)."
|
||
)
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
out = Path(tmp) / "out.stl"
|
||
cmd = [OPENSCAD_BIN, "-o", str(out)]
|
||
if OPENSCAD_BACKEND:
|
||
cmd += ["--backend", OPENSCAD_BACKEND]
|
||
# Only the bundled-schema render passes -D overrides; raw source
|
||
# may not know about those variable names.
|
||
if params is not None:
|
||
for k, v in _filter_params(params).items():
|
||
cmd += ["-D", f"{k}={_to_scad_literal(v)}"]
|
||
cmd.append(str(scad_path))
|
||
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"")
|
||
|
||
|
||
def render_stl(params: dict) -> tuple[bytes, dict]:
|
||
"""Render the bundled hex_cell.scad with parameter overrides.
|
||
|
||
Enforces the holder cell-count cap (PARAMS-schema is assumed). For
|
||
rendering arbitrary user-supplied OpenSCAD source, use render_source.
|
||
"""
|
||
_check_cell_limit(params)
|
||
if not SCAD_FILE.is_file():
|
||
raise FileNotFoundError(f"SCAD source not found at {SCAD_FILE}")
|
||
return _run_openscad(SCAD_FILE, params)
|
||
|
||
|
||
def render_source(source: str, params: dict | None = None) -> tuple[bytes, dict]:
|
||
"""Render arbitrary user-supplied OpenSCAD source.
|
||
|
||
Source size is capped (MAX_SOURCE_BYTES). Cell-count limit does NOT
|
||
apply — the source defines its own variables. RENDER_TIMEOUT still
|
||
bounds runaway computation.
|
||
"""
|
||
if not isinstance(source, str) or not source.strip():
|
||
raise ValueError("Empty SCAD source.")
|
||
src_bytes = source.encode("utf-8", errors="replace")
|
||
if len(src_bytes) > MAX_SOURCE_BYTES:
|
||
raise ValueError(
|
||
f"SCAD source too large ({len(src_bytes)} > {MAX_SOURCE_BYTES} bytes)."
|
||
)
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
scad = Path(tmp) / "user.scad"
|
||
scad.write_bytes(src_bytes)
|
||
return _run_openscad(scad, params)
|
||
|
||
|
||
def bundled_source() -> str:
|
||
"""Read the bundled hex_cell.scad for editor pre-population."""
|
||
if not SCAD_FILE.is_file():
|
||
raise FileNotFoundError(f"SCAD source not found at {SCAD_FILE}")
|
||
return SCAD_FILE.read_text(encoding="utf-8", errors="replace")
|