Files
busbar-designer/holder.py
T
wenil 3418e01689 holder: cap total cells at 1000 to prevent runaway renders
User-triggered 120x120 = 14400 cells, which produces huge STL/long
renders. Total cells is the right metric (CSG cost scales with count,
not max axis), so cap by N = rows*cols (or rows*(rows+1)/2 for tria
style). 1000 covers any realistic pack (e.g. 20x50) while blocking
accidental misuse.

Backend:
- holder.py: MAX_CELLS env-tunable (default 1000); expected_cell_count
  and _check_cell_limit raise ValueError on exceed; both
  compute_cells() and render_stl() call it up-front.
- app.py: /api/holder/render now returns 400 on ValueError (not 500)
  so the frontend can distinguish bad input from server failure.
  /api/holder/params now publishes max_cells alongside the schema.

Frontend:
- holder-app.js: reads max_cells from the params endpoint; status
  shows "N cells / over limit (1000)" in red and disables the
  Render and "Design busbars" buttons when exceeded.
- holder.css: .topbar-status.over-limit style (red, bold).
2026-05-25 11:49:28 +03:00

253 lines
9.4 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 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
PARAMS: list[Param] = [
# Pack geometry
Param("part", "Part", "select", "holder", group="part",
options=["holder", "cap", "box lid", "box bottom", "insulator"],
help="Which piece to generate."),
Param("pack_style", "Pack style", "select", "rect", group="part",
options=["rect", "para", "tria"],
help="Cell arrangement."),
Param("wire_style", "Wire style", "select", "strip", group="part",
options=["strip", "bus"],
help="strip = nickel strips between cells; bus = bus wires between rows."),
# Cell
Param("cell_dia", "Cell diameter (mm)", "number", 21.2, group="cell",
min=10, max=40, step=0.1, help="21.2 for 21700, 18.4 for 18650, 26.5 for 26650."),
Param("cell_height", "Cell height (mm)", "number", 70.0, group="cell",
min=30, max=200, step=1),
Param("wall", "Wall thickness (mm)", "number", 0.8, group="cell",
min=0.4, max=3.0, step=0.1,
help="Wall around one cell; spacing between cells is 2× this."),
# Pack size
Param("num_rows", "Rows", "number", 6, group="size", min=1, max=40, step=1),
Param("num_cols", "Columns", "number", 12, group="size", min=1, max=40, step=1),
# Holder
Param("holder_height", "Holder height (mm)", "number", 10.0, group="holder",
min=4, max=30, step=0.5),
Param("slot_height", "Slot height (mm)", "number", 3.0, group="holder",
min=0, max=10, step=0.5,
help="Height of all wire slots (set 0 for no slots)."),
Param("col_slot_width", "Column slot width (mm)", "number", 8.0, group="holder",
min=0, max=20, step=0.5),
Param("row_slot_width", "Row slot width (mm)", "number", 8.0, group="holder",
min=0, max=20, step=0.5),
Param("cell_top_overlap", "Cell top overlap (mm)","number", 3.0, group="holder",
min=0, max=10, step=0.5,
help="Opening dia = cell_dia 2 × this. Welding-window size."),
]
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
def render_stl(params: dict) -> bytes:
"""Render the .scad with given parameter overrides; return STL bytes."""
_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()