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).
7.4 KiB
CLAUDE.md — Busbar Designer
Notes for future sessions and any agent picking up this project.
What this is
A small web tool to design busbars (nickel/copper strips) for cylindrical-cell battery packs built with hex-shaped cell holders. The user imports cell-center coordinates (originating from Addy's Hex-Cell-Holder OpenSCAD generator), groups cells into parallel busbars and series chains, and exports the resulting 2D geometry to STEP / DXF / SVG.
The hard requirement is a working STEP export. STEP is generated by OpenCASCADE via build123d's OCP bindings — never write STEP by hand.
Architecture
Browser (static/) ──fetch──> Flask (app.py)
├── busbar_export.py → build123d → STEP/DXF/SVG
└── storage.py → SQLite (projects/presets/snapshots)
- Frontend is plain HTML + JS (no build step). State lives in memory but is auto-synced to the server every 1.5 s; the active project ID is in the URL (
?p=42) so refresh and other devices resume the same state. - Backend is a Flask app with three groups of endpoints:
- CAD export:
POST /api/export/{step,dxf,svg}— stateless; takes a busbar payload and returns bytes via build123d. - Persistence:
/api/projects/*,/api/presets/*,/api/snapshots/*— CRUD over SQLite (storage.py). - Health:
GET /api/health.
- CAD export:
- The SQLite DB lives at
data/busbar.db(override viaBUSBAR_DBenv var). In Docker it's mounted as a volume so it survives container restarts.
Payload contract (frontend → backend)
{
"units": "mm",
"extrude": false, // false = flat 2D face; true = solid extrusion
"thickness": 0.2, // used only when extrude=true
"busbars": [
{
"name": "P1",
"color": "#ff8800",
"shape": "panel", // "panel" (default) | "wire"
"strip_width": 8.0, // mm — only used by shape="wire"
"pad_radius": 9.0, // mm — pad disc radius / panel inflation radius
"hole_radius": 5.0, // mm — welding window radius
"cells": [ // ordered list of cell centers in mm
{"id": 1, "x": 0.0, "y": 0.0},
{"id": 2, "x": 22.8, "y": 0.0},
{"id": 3, "x": 45.6, "y": 0.0}
]
}
]
}
panel shape (production-style plate — hugs ONLY selected cells, never bridges across non-selected):
union( pad_disc(c, pad_radius) for c in cells ) ∪ union( stadium(c_i, c_j, 2*pad_radius) for (i,j) in neighbor_edges ) − holes.
neighbor_edges(cells, factor) = pairs whose distance ≤ factor × min_pair_distance (default factor=1.15). This is what gives concave outlines (L, U, T...) instead of convex hulls. Critical: convex hull / offset() Minkowski-sum approaches fill in inner concavities (bridge across non-selected cells), which is wrong for production busbars — the user-visible bug was a fat diagonal stripe across an L-selection. The neighbor-edge stadium chain solves this.
holes per cell are either:
cross(default): two perpendicular slits, arm length2*hole_radius, arm widthslit_width.circle: disc of radiushole_radius.
wire shape (thin jumper / series link between panels):
union( pad_disc(c) ) ∪ union( strip_segment(c[i], c[i+1], width=strip_width) ) − holes. Cells are connected in array-order as a polyline.
Geometry source of truth
All math comes from hex_cell.scad (sibling folder Hex-Cell-Holder-master/). Key formulas live in static/js/importer.js's generateCenters() and must stay in sync with the SCAD script:
hex_w = cell_dia + 2*wallhex_pt = hex_w / 2 / cos(30°)opening_dia = cell_dia - 2*cell_top_overlap(welding window default)- Row pitch Y:
1.5 * hex_pt - Rect col X (even row):
hex_w * col; (odd row):0.5*hex_w + hex_w*col - Para col X:
row*0.5*hex_w + hex_w*col - Tria col X:
row*0.5*hex_w - hex_w*col,col ∈ [0..row]
If the user reports coordinates "off by half a hex," check rect-vs-para and even/odd row offset first.
File map
app.py— Flask app. Servesstatic/, dispatches export endpoints, exposes REST CRUD for projects/presets/snapshots. Single-file; no blueprints.busbar_export.py— pure functions. Input: payload dict. Output: bytes. No Flask import here.storage.py— SQLite layer for projects, presets, snapshots. Connection-per-call inside a context manager; no Flask import.static/index.html— single page; left sidebar (import / params / busbars) + right pane (canvas + live SVG preview) + top bar (project switcher + history + exports) + history modal.static/js/app.js— top-level controller; owns in-memory state, hooks auto-save + URL routing.static/js/api.js— thin fetch-based REST client (Api.listProjects, getProject, ...).static/js/importer.js— paste-text parser + generator.static/js/viewport.js— canvas with pan/zoom; renders cells, busbars, selection.static/js/groups.js— busbar CRUD (create, rename, recolor, delete, reorder cells).static/js/geometry.js— frontend-side polygon preview.static/js/exporter.js— POSTs export payload, triggers file download.scad_snippet.scad— drop-in forhex_cell.scadthat echoes per-cell coordinates.
How to add a new export format
- Add a writer in
busbar_export.pythat accepts the same payload dict and returns bytes + mimetype + extension. - Add a route in
app.py:@app.post("/api/export/<fmt>")already dispatches byfmt; just register the writer in theWRITERSdict. - Add a button in
static/index.htmland hook it inexporter.js.
Deployment
- Dev:
python app.py→ Flask debug server on127.0.0.1:5000. - Docker:
docker compose up -d --build. Uses Python 3.12 base (avoids the 3.13 OCP wheel risk), 2 gunicorn workers, ~600 MB image. SeeDockerfile/docker-compose.yml. - Proxmox LXC without Docker: clone to
/opt/busbar-designer, create venv withgunicorninstalled, installdeploy/busbar-designer.serviceas a systemd unit. See README for the full sequence.
The app honours HOST, PORT, and FLASK_DEBUG env vars. Bind to 0.0.0.0 for LAN access; otherwise it stays on localhost.
No auth. Put behind a reverse proxy (Caddy/nginx/Traefik) with basic_auth or Authelia for anything beyond the LAN.
How to swap backend for opencascade.js (WASM)
If the user ever wants zero-backend, static/js/exporter.js is the only file that needs to change — the payload format is identical. Drop in opencascade.js, build the same shapes (Wire → Face → STEPControl_Writer), and download via Blob.
Tests
tests/test_export.py exercises busbar_export.py with a minimal 2-cell busbar and checks the STEP file is non-empty and contains ISO-10303-21. Run:
python -m pytest tests/
Known cautions
- Python 3.13 + OCP wheels: the official OCP wheel may not be published for the very latest CPython. If install fails, recommend Python 3.12.
- Unit handling: backend always treats coordinates as mm. Never mix units. STEP writer sets
STEPControl_Writer.WriteHeader().SetUnits("MM")(via build123d default). - DXF:
build123d.export_dxfemits 2D entities (lines + arcs). The pad-with-hole becomes a closed boundary + an inner circle — laser CAM treats it correctly as a cut. - Topology: when two pad discs overlap (cells closer than
2 * pad_radius), use Boolean union before exporting — otherwise STEP has duplicate edges.