Files
wenil 7512393ef4 busbars: FDM test-print export (extruded STL + STEP)
Adds a way to print a busbar on a 3D printer to physically verify
sizing before laser-cutting nickel/copper. The existing "Extrude
solid + 0.2mm" path stays for thin-strip STEP — slicers can't print
0.2mm sheet — so the FDM path has its own thickness input (default
2mm, min 0.5mm).

Backend:
- busbar_export.py: new to_stl() writer. Forces extrude_flag = True
  (STL is inherently 3D) and bumps thickness up to 2mm if the
  incoming value is <0.5mm. Registers under WRITERS["stl"] so the
  existing /api/export/<fmt> route serves it for free.

UI:
- index.html: new "FDM test print" block under the Params panel
  with its own thickness input and two buttons (STL, STEP). The
  existing 'Extrude solid' checkbox + 0.2mm thickness keep
  driving plain "Export STEP".
- styles.css: .fdm-block / .fdm-row / .fdm-buttons styles
  matching the existing panel typography.
- app.js: _exportFdm(fmt) reuses Exporter.exportFormat with a
  shallow-merged params override ({extrude: true, thickness: fdmT}),
  so the on-the-fly request gets the FDM settings without
  mutating the live params state.

Verified: STL render of a 3-cell strip @ 2mm => 73KB binary STL
(opens cleanly in slicers); STEP @ 2mm => 160KB ISO-10303-21
solid; existing flat STEP path unchanged at 15KB.
2026-05-25 14:04:19 +03:00

329 lines
11 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,
export_stl,
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()
def to_stl(payload: dict) -> bytes:
"""STL with the busbars extruded to a printable plate.
STL is inherently 3D, so extrusion is forced regardless of the payload
flag. Thickness defaults to 2 mm when missing or <0.5 mm (slicers can't
do 0.2 mm sheet — that figure is for the 'thin nickel strip' STEP case).
"""
busbars, extrude_flag, thickness = parse_payload(payload)
if thickness < 0.5:
thickness = 2.0
shapes = build_shapes(busbars, extrude_flag=True, thickness=thickness)
compound = _as_compound(shapes)
with TemporaryDirectory() as tmp:
path = Path(tmp) / "busbars.stl"
# build123d's export_stl takes a single shape; the Compound carries
# all busbars together so the slicer sees them as one job.
export_stl(compound, 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"),
"stl": (to_stl, "model/stl", "stl"),
}