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:
wenil
2026-05-24 18:59:50 +03:00
commit d8cb0dc06d
28 changed files with 4172 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
# 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`.
- The SQLite DB lives at `data/busbar.db` (override via `BUSBAR_DB` env var). In Docker it's mounted as a volume so it survives container restarts.
## Payload contract (frontend → backend)
```jsonc
{
"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 length `2*hole_radius`, arm width `slit_width`.
- `circle`: disc of radius `hole_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*wall`
- `hex_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. Serves `static/`, 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 for `hex_cell.scad` that echoes per-cell coordinates.
## How to add a new export format
1. Add a writer in `busbar_export.py` that accepts the same payload dict and returns bytes + mimetype + extension.
2. Add a route in `app.py`: `@app.post("/api/export/<fmt>")` already dispatches by `fmt`; just register the writer in the `WRITERS` dict.
3. Add a button in `static/index.html` and hook it in `exporter.js`.
## Deployment
- **Dev**: `python app.py` → Flask debug server on `127.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. See `Dockerfile` / `docker-compose.yml`.
- **Proxmox LXC without Docker**: clone to `/opt/busbar-designer`, create venv with `gunicorn` installed, install `deploy/busbar-designer.service` as 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:
```powershell
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_dxf` emits 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.