Files
busbar-designer/holder.py
T
wenil 676d976937 holder + scad: SCAD source editor on /holder + new /scad universal renderer
Phase 2 — SCAD editor on /holder
================================
Some adjustments need more control than the parameter form gives
(e.g. tweaking the .scad logic itself). A collapsible CodeMirror-based
editor now slides up from the bottom of the viewport when you click
the "</> Source" button at the bottom-right.

- holder.html / holder.css: editor panel, toggle button, Reset and
  Close buttons. CodeMirror 5 loaded from cdnjs (single CSS + JS
  pair, clike mode for syntax highlighting since OpenSCAD is C-like).
- holder-app.js: lazy-initialises the editor on first show, fetches
  bundled source from /api/holder/source, tracks whether the editor
  content has been modified ("modified" tag in the panel title).
  When the editor is visible AND content differs from bundled,
  _doRender() switches from /api/holder/render -> /api/scad/render
  with {source, params}. Cell-count cap doesn't apply in that mode
  (the source may not even use the holder schema).

Phase 3 — Universal OpenSCAD playground at /scad
================================================
A standalone page for rendering arbitrary OpenSCAD. Paste code, click
Render (or Ctrl+Enter), see STL in the same Three.js viewer the
holder uses. Useful for prototyping new generators before wiring
them into a parameter form.

- New page: static/scad.html + scad.css + js/scad-app.js. Reuses
  holder-viewer.js (the Three.js scene module is generic enough).
- "Load example" populates a 6-hole rounded bracket so new users
  can verify the renderer works in two clicks.
- Same progress UI (elapsed timer + indeterminate bar + Cancel
  via AbortController) as /holder.
- Top nav now has Holder / Busbars / SCAD on every page.

Backend (shared by phase 2 & 3)
================================
- holder.py: split _run_openscad() out of render_stl(). New
  render_source(source, params=None) writes the source to a temp
  file and renders it; enforces MAX_SOURCE_BYTES (default 512 KB)
  and RENDER_TIMEOUT but skips the cell-count cap. New bundled_source()
  returns the hex_cell.scad text for editor pre-population.
- app.py: GET /api/holder/source returns the bundled .scad text.
  POST /api/scad/render takes {source, params?} and returns STL +
  X-Holder-Dimensions header. ValueError -> 400, RuntimeError -> 500.
  _stl_response() factored out so both render endpoints emit the same
  headers consistently.
2026-05-25 12:52:18 +03:00

410 lines
16 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
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")