Initial commit: Busbar Designer

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).
This commit is contained in:
wenil
2026-05-24 18:59:50 +03:00
commit d8cb0dc06d
28 changed files with 4172 additions and 0 deletions
+306
View File
@@ -0,0 +1,306 @@
"""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"),
}