dfef1453aa
The hex_cell.scad echoes its computed dimensions (pack_height_holder,
total_*_holder, box_total_*) to stderr. Capture those and surface them
in the viewer so the user can sanity-check whether the assembled
battery will fit in a target case.
Backend:
- holder.py: render_stl() now returns (stl_bytes, dims) where dims is
a {name: float} dict parsed from openscad ECHO lines.
- app.py: /api/holder/render emits the dims dict as an
X-Holder-Dimensions response header (JSON) with the matching
Access-Control-Expose-Headers entry so fetch() can read it under
any proxy / future CORS setup.
Viewer (holder-viewer.js):
- New setGhostBox(name, {w,d,h}, {visible,color}) and clearGhostBox(name)
helpers. Each ghost is a Group of a translucent BoxGeometry mesh +
matching EdgesGeometry wireframe, positioned to match how the STL
mesh is placed (centred XY, bottom on Z=0).
UI (holder.html / holder.css):
- New "Fit check" panel under Status with two sections:
• Show max bounding box (auto, from ECHO — defaults to box_total_*
dims, falls back to total_*_holder + pack_height_holder).
• Show enclosure (manual W × D × H inputs in mm).
- Verdict line under the enclosure inputs: "✓ fits" green or
"✗ too small — battery won't fit" red.
Controller (holder-app.js):
- Reads X-Holder-Dimensions after each render, updates the max-box
ghost in blue, prints the dimensions label.
- Watches enclosure inputs + toggles, drives the enclosure ghost
(green if it fits, red if smaller than the max box on any axis).
- Fit comparison is orientation-independent in the XY plane (sorted
W,D pair) but strict on Z (height).
279 lines
10 KiB
Python
279 lines
10 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
|
||
|
||
|
||
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
|
||
|
||
|
||
_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"")
|