"""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"), }