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:
@@ -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"),
|
||||
}
|
||||
Reference in New Issue
Block a user