d8cb0dc06d
Web tool for designing nickel/copper busbars over cylindrical-cell battery packs (21700, 18650) in hex holders. Flask + build123d backend exports STEP/DXF/SVG; vanilla JS frontend with live preview, multi-project SQLite persistence, snapshot history. Deploy scripts in deploy/ (proxmox-lxc.sh, install.sh, update.sh).
307 lines
9.9 KiB
Python
307 lines
9.9 KiB
Python
"""Convert a Busbar Designer payload into STEP / DXF / SVG bytes.
|
||
|
||
Two busbar shapes (selectable per busbar):
|
||
|
||
* **panel** (default) — production-style cohesive plate that hugs the selected
|
||
cells and only the selected cells. Built as a Minkowski sum: a disc of
|
||
`pad_radius` at every cell center, plus a stadium-shaped bridge of width
|
||
`2*pad_radius` between every pair of cells that are *direct neighbors*
|
||
(distance within `neighbor_factor × min_pair_distance`). Concave layouts
|
||
(L, U, T, ...) follow the selection without bridging across non-selected
|
||
cells, matching how real laser-cut nickel/copper busbars look.
|
||
|
||
* **wire** — polyline strip of `strip_width` with pad discs at each cell;
|
||
useful for thin series jumpers between panels.
|
||
|
||
Welding windows are punched through the result. Two hole shapes:
|
||
|
||
* **cross** (default) — two perpendicular rectangular slits forming a plus,
|
||
arms of length `2*hole_radius` and width `slit_width`. Standard for spot
|
||
welding cylindrical cells.
|
||
* **circle** — a circular hole of radius `hole_radius`.
|
||
|
||
Coordinates are millimetres. build123d's STEP/DXF/SVG writers default to MM.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from tempfile import TemporaryDirectory
|
||
from typing import Iterable, List, Sequence, Tuple
|
||
|
||
from build123d import (
|
||
BuildPart,
|
||
BuildSketch,
|
||
Circle,
|
||
Color,
|
||
Compound,
|
||
ExportDXF,
|
||
ExportSVG,
|
||
Locations,
|
||
Mode,
|
||
Pos,
|
||
Rectangle,
|
||
Sketch,
|
||
add,
|
||
export_step,
|
||
extrude,
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Cell:
|
||
id: int
|
||
x: float
|
||
y: float
|
||
|
||
|
||
@dataclass
|
||
class Busbar:
|
||
name: str
|
||
color: str
|
||
shape: str # "panel" | "wire"
|
||
strip_width: float
|
||
pad_radius: float
|
||
hole_radius: float
|
||
hole_shape: str # "cross" | "circle"
|
||
slit_width: float # cross arm width (mm)
|
||
neighbor_factor: float # neighbor-edge threshold = factor * min_pair_distance
|
||
cells: List[Cell]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Parsing
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def parse_payload(payload: dict) -> tuple[List[Busbar], bool, float]:
|
||
extrude_flag = bool(payload.get("extrude", False))
|
||
thickness = float(payload.get("thickness", 0.2) or 0.2)
|
||
busbars: List[Busbar] = []
|
||
for raw in payload.get("busbars", []):
|
||
cells = [
|
||
Cell(id=int(c.get("id", i + 1)), x=float(c["x"]), y=float(c["y"]))
|
||
for i, c in enumerate(raw.get("cells", []))
|
||
]
|
||
if not cells:
|
||
continue
|
||
shape = str(raw.get("shape") or "panel").lower()
|
||
if shape not in ("panel", "wire"):
|
||
shape = "panel"
|
||
hole_shape = str(raw.get("hole_shape") or "cross").lower()
|
||
if hole_shape not in ("cross", "circle"):
|
||
hole_shape = "cross"
|
||
busbars.append(
|
||
Busbar(
|
||
name=str(raw.get("name") or f"Busbar {len(busbars) + 1}"),
|
||
color=str(raw.get("color") or "#888888"),
|
||
shape=shape,
|
||
strip_width=float(raw.get("strip_width", 8.0)),
|
||
pad_radius=float(raw.get("pad_radius", 9.0)),
|
||
hole_radius=float(raw.get("hole_radius", 5.0)),
|
||
hole_shape=hole_shape,
|
||
slit_width=float(raw.get("slit_width", 1.0)),
|
||
neighbor_factor=float(raw.get("neighbor_factor", 1.15)),
|
||
cells=cells,
|
||
)
|
||
)
|
||
if not busbars:
|
||
raise ValueError("Payload contains no busbars with cells.")
|
||
return busbars, extrude_flag, thickness
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Neighbor edges — connect a pair of cells only if their distance is within
|
||
# `factor` times the smallest pair distance in the busbar. This yields a
|
||
# planar graph that captures direct hex neighbors but never diagonals across
|
||
# non-selected cells.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _neighbor_edges(pts: Sequence[Tuple[float, float]], factor: float) -> List[Tuple[int, int]]:
|
||
n = len(pts)
|
||
if n < 2:
|
||
return []
|
||
min_d = math.inf
|
||
for i in range(n):
|
||
xi, yi = pts[i]
|
||
for j in range(i + 1, n):
|
||
d = math.hypot(pts[j][0] - xi, pts[j][1] - yi)
|
||
if 1e-9 < d < min_d:
|
||
min_d = d
|
||
if min_d is math.inf:
|
||
return []
|
||
threshold = min_d * factor
|
||
out: List[Tuple[int, int]] = []
|
||
for i in range(n):
|
||
xi, yi = pts[i]
|
||
for j in range(i + 1, n):
|
||
d = math.hypot(pts[j][0] - xi, pts[j][1] - yi)
|
||
if 1e-9 < d <= threshold:
|
||
out.append((i, j))
|
||
return out
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Welding-window punching (must be called from inside an active BuildSketch).
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _punch_holes(busbar: Busbar) -> None:
|
||
if busbar.hole_shape == "cross":
|
||
slit_w = max(0.1, busbar.slit_width)
|
||
slit_l = 2 * busbar.hole_radius
|
||
for c in busbar.cells:
|
||
with Locations(Pos(c.x, c.y)):
|
||
Rectangle(slit_l, slit_w, mode=Mode.SUBTRACT)
|
||
Rectangle(slit_w, slit_l, mode=Mode.SUBTRACT)
|
||
else:
|
||
for c in busbar.cells:
|
||
with Locations(Pos(c.x, c.y)):
|
||
Circle(busbar.hole_radius, mode=Mode.SUBTRACT)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Per-busbar sketch builders
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _panel_sketch(busbar: Busbar) -> Sketch:
|
||
"""Dog-bone chain: wide pad disc at each cell + narrow connector between
|
||
neighbors, with welding holes punched. Connector width = strip_width
|
||
(independent from pad_radius), so the panel narrows between cells
|
||
('waist') and leaves clearance to adjacent busbars.
|
||
"""
|
||
pts = [(c.x, c.y) for c in busbar.cells]
|
||
pad = busbar.pad_radius
|
||
connector_w = busbar.strip_width
|
||
edges = _neighbor_edges(pts, busbar.neighbor_factor)
|
||
|
||
with BuildSketch() as sk:
|
||
for x, y in pts:
|
||
with Locations(Pos(x, y)):
|
||
Circle(pad)
|
||
for i, j in edges:
|
||
ax, ay = pts[i]
|
||
bx, by = pts[j]
|
||
dx, dy = bx - ax, by - ay
|
||
length = math.hypot(dx, dy)
|
||
if length < 1e-9:
|
||
continue
|
||
angle_deg = math.degrees(math.atan2(dy, dx))
|
||
cx, cy = (ax + bx) / 2.0, (ay + by) / 2.0
|
||
with Locations(Pos(cx, cy)):
|
||
Rectangle(length, connector_w, rotation=angle_deg)
|
||
_punch_holes(busbar)
|
||
return sk.sketch
|
||
|
||
|
||
def _wire_sketch(busbar: Busbar) -> Sketch:
|
||
"""Polyline strip with pad discs at each cell, holes punched."""
|
||
with BuildSketch() as sk:
|
||
for c in busbar.cells:
|
||
with Locations(Pos(c.x, c.y)):
|
||
Circle(busbar.pad_radius)
|
||
for a, b in zip(busbar.cells, busbar.cells[1:]):
|
||
dx, dy = b.x - a.x, b.y - a.y
|
||
length = math.hypot(dx, dy)
|
||
if length < 1e-9:
|
||
continue
|
||
angle_deg = math.degrees(math.atan2(dy, dx))
|
||
cx, cy = (a.x + b.x) / 2.0, (a.y + b.y) / 2.0
|
||
with Locations(Pos(cx, cy)):
|
||
Rectangle(length, busbar.strip_width, rotation=angle_deg)
|
||
_punch_holes(busbar)
|
||
return sk.sketch
|
||
|
||
|
||
def busbar_sketch(busbar: Busbar) -> Sketch:
|
||
if busbar.shape == "wire":
|
||
return _wire_sketch(busbar)
|
||
return _panel_sketch(busbar)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Compose & export
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _hex_color(hex_str: str) -> Color | None:
|
||
try:
|
||
s = hex_str.lstrip("#")
|
||
r, g, b = int(s[0:2], 16) / 255, int(s[2:4], 16) / 255, int(s[4:6], 16) / 255
|
||
return Color(r, g, b)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def build_shapes(
|
||
busbars: Iterable[Busbar], extrude_flag: bool, thickness: float
|
||
) -> list:
|
||
out = []
|
||
for b in busbars:
|
||
sk = busbar_sketch(b)
|
||
if extrude_flag:
|
||
with BuildPart() as bp:
|
||
add(sk)
|
||
extrude(amount=thickness)
|
||
shape = bp.part
|
||
else:
|
||
shape = sk
|
||
shape.label = b.name
|
||
color = _hex_color(b.color)
|
||
if color is not None:
|
||
try:
|
||
shape.color = color
|
||
except Exception:
|
||
pass
|
||
out.append(shape)
|
||
return out
|
||
|
||
|
||
def _as_compound(shapes: list) -> Compound:
|
||
return Compound(label="busbars", children=shapes)
|
||
|
||
|
||
def to_step(payload: dict) -> bytes:
|
||
busbars, extrude_flag, thickness = parse_payload(payload)
|
||
shapes = build_shapes(busbars, extrude_flag, thickness)
|
||
compound = _as_compound(shapes)
|
||
with TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "busbars.step"
|
||
export_step(compound, str(path))
|
||
return path.read_bytes()
|
||
|
||
|
||
def to_dxf(payload: dict) -> bytes:
|
||
busbars, *_ = parse_payload(payload)
|
||
shapes = build_shapes(busbars, extrude_flag=False, thickness=0)
|
||
with TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "busbars.dxf"
|
||
exporter = ExportDXF()
|
||
for sh in shapes:
|
||
exporter.add_shape(sh)
|
||
exporter.write(str(path))
|
||
return path.read_bytes()
|
||
|
||
|
||
def to_svg(payload: dict) -> bytes:
|
||
busbars, *_ = parse_payload(payload)
|
||
shapes = build_shapes(busbars, extrude_flag=False, thickness=0)
|
||
with TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "busbars.svg"
|
||
exporter = ExportSVG(scale=1.0, margin=5.0)
|
||
for sh in shapes:
|
||
exporter.add_shape(sh)
|
||
exporter.write(str(path))
|
||
return path.read_bytes()
|
||
|
||
|
||
WRITERS = {
|
||
"step": (to_step, "application/step", "step"),
|
||
"dxf": (to_dxf, "image/vnd.dxf", "dxf"),
|
||
"svg": (to_svg, "image/svg+xml", "svg"),
|
||
}
|