Files
wenil d8cb0dc06d 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).
2026-05-24 18:59:50 +03:00

307 lines
9.9 KiB
Python
Raw Permalink 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.
"""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"),
}