Files
busbar-designer/holder.py
T
wenil 0aa38809b4 holder: switch to OpenSCAD Manifold backend (10-50x faster renders)
OpenSCAD 2021.01 (Debian 13 default) is single-threaded CGAL. The Manifold
backend, available in OpenSCAD 2024+, parallelises CSG operations and
gives massive speedups on multi-cell holders.

Bench on this LXC (6 cores), realistic params:
  6x12  (72 cells):  CGAL  3.14s -> Manifold 0.08s   (37x)
  10x20 (200 cells): -                       1.41s
  15x25 (375 cells): -                       1.97s

Installation steps performed on the server (manual, not in this repo):
  wget https://files.openscad.org/snapshots/OpenSCAD-2026.04.26-x86_64.AppImage
  ./AppImage --appimage-extract  (no FUSE required)
  ln -s squashfs-root/AppRun /usr/local/bin/openscad-nightly

Code changes:
- holder.py: new OPENSCAD_BACKEND env var (default "Manifold");
  appends "--backend Manifold" to the openscad call when set.
- deploy/busbar-designer.service: points OPENSCAD_BIN at the
  extracted nightly and sets OPENSCAD_BACKEND=Manifold.

Real-time progress percentage was investigated but not implemented:
OpenSCAD has no headless --progress flag, so live % from the CLI is
not feasible. With Manifold making renders sub-second to a few seconds,
the existing indeterminate progress bar + elapsed timer is sufficient.
2026-05-25 11:35:20 +03:00

230 lines
8.6 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"))
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 compute_cells(params: dict) -> list[dict]:
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."""
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()