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
+11
View File
@@ -0,0 +1,11 @@
.venv/
__pycache__/
*.pyc
.pytest_cache/
tests/
*.md
.git/
.gitignore
data/
.vscode/
.idea/
+22
View File
@@ -0,0 +1,22 @@
# Force LF for shell scripts and systemd units so they work after checkout on Linux,
# regardless of the developer's core.autocrlf setting.
*.sh text eol=lf
*.service text eol=lf
Dockerfile text eol=lf
docker-compose.yml text eol=lf
# Normalise line endings on commit, but keep CRLF on Windows checkout for everything else.
*.py text
*.js text
*.html text
*.css text
*.md text
*.scad text
*.txt text
# Binary
*.png binary
*.jpg binary
*.stl binary
*.step binary
*.dxf binary
+41
View File
@@ -0,0 +1,41 @@
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
.pytest_cache/
*.egg-info/
.coverage
# Virtual env
.venv/
venv/
env/
# Local SQLite DB (lives in deploy target; do not commit project data)
data/
tests/_tmp_storage.db
*.db
# IDE
.vscode/
.idea/
*.swp
*~
# OS
.DS_Store
Thumbs.db
# Build / export artifacts
*.step
*.STEP
*.dxf
*.DXF
build/
dist/
# Local secrets (just in case)
.env
.gitea-token
+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.
+50
View File
@@ -0,0 +1,50 @@
# Multi-stage to keep the runtime image small.
# build123d pulls scipy + matplotlib + OCP wheel — a few hundred MB.
FROM python:3.12-slim AS builder
ENV PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libgl1 libglu1-mesa libxrender1 libxext6 libsm6 \
libgomp1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --user -r requirements.txt && \
pip install --user gunicorn
# ---------------------------------------------------------------------------
FROM python:3.12-slim AS runtime
# OCP needs these at runtime.
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 libglu1-mesa libxrender1 libxext6 libsm6 libgomp1 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --shell /usr/sbin/nologin app
COPY --from=builder /root/.local /home/app/.local
ENV PATH=/home/app/.local/bin:$PATH \
PYTHONUNBUFFERED=1 \
HOST=0.0.0.0 \
PORT=5000 \
FLASK_DEBUG=0
WORKDIR /app
COPY --chown=app:app app.py busbar_export.py ./
COPY --chown=app:app static/ ./static/
USER app
EXPOSE 5000
# SQLite DB lives here; mount as a volume to persist across container restarts.
ENV BUSBAR_DB=/app/data/busbar.db
VOLUME /app/data
# 2 workers is plenty — each export is CPU-bound on OpenCASCADE; more workers
# don't help on a single-socket VM and just balloon RAM.
CMD ["gunicorn", "--bind=0.0.0.0:5000", "--workers=2", "--threads=2", \
"--timeout=120", "app:app"]
+196
View File
@@ -0,0 +1,196 @@
# Busbar Designer
Web tool for designing nickel/copper **busbars** over cylindrical-cell battery packs (21700, 18650, ...) built with hex-shaped cell holders.
Workflow:
1. Import cell-center coordinates (paste from OpenSCAD console, CSV, JSON — or generate from `cell_dia / wall / rows / cols`).
2. View cells on a 2D canvas at real mm scale.
3. Click cells to select; group selected cells into named **busbars** (parallel groups), then chain busbars in series.
4. Each busbar is drawn as a strip of configurable width with circular pads + welding-window holes over each cell center.
5. Export busbar geometry to **STEP** (mandatory), plus **DXF** and **SVG** for laser/waterjet cutting.
## Quick start
Requires **Python 3.10+**. On Windows / PowerShell:
```powershell
cd busbar-designer
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
python app.py
```
Then open <http://localhost:5000> in your browser.
> First-time install pulls **build123d** (~ 200 MB; bundles OpenCASCADE via OCP) and may take several minutes.
### Linux / macOS
```bash
cd busbar-designer
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python app.py
```
## Geometry source of truth
All cell-center math matches Addy's `Hex-Cell-Holder/hex_cell.scad`:
| Variable | Formula | 21700 default |
|-----------------|------------------------------------------|---------------|
| `hex_w` | `cell_dia + 2*wall` | `22.8 mm` |
| `hex_pt` | `hex_w / 2 / cos(30°)` | `13.164 mm` |
| `opening_dia` | `cell_dia 2*cell_top_overlap` | `15.2 mm` |
| row pitch (Y) | `1.5 * hex_pt` | `19.746 mm` |
| col pitch (X) | `hex_w` | `22.8 mm` |
| `rect` row shift | `0` (even) / `0.5*hex_w` (odd) | `11.4 mm` |
## Import formats
The importer auto-detects:
- **OpenSCAD ECHO**: `ECHO: "Cell 1: x = 0.0, y = 0.0"` (paste straight from console).
- If your `hex_cell.scad` doesn't print these, drop `scad_snippet.scad` (in this repo) at the bottom of the file.
- **CSV**: `index,x,y` (header optional).
- **JSON**: `[{"id":1,"x":0,"y":0}, ...]` or `[[x,y], ...]`.
- **Generator** tab: enter `cell_dia / wall / rows / cols / pack_style` — coordinates are computed with the exact OpenSCAD formulas.
## Persistence
State is stored in a SQLite file (`data/busbar.db` by default; override with `BUSBAR_DB` env var):
| Table | What |
|-------------|------------------------------------------------------------------------|
| `projects` | Full editor state (cells + busbars + params) per project |
| `presets` | Named param bundles you save from the params panel |
| `snapshots` | Auto-history per project; max `SNAPSHOT_RETENTION` (default 20) kept |
**REST API** (all JSON):
| Method | Path | What |
|--------|-----------------------------------------------|-------------------------------|
| GET | `/api/projects` | list projects |
| POST | `/api/projects` | create `{name, data}` |
| GET | `/api/projects/{id}` | full project |
| PUT | `/api/projects/{id}` | update `{name?, data?, snapshot?, note?}` |
| DELETE | `/api/projects/{id}` | delete (cascades to snapshots) |
| GET | `/api/projects/{id}/snapshots` | list snapshots |
| GET | `/api/snapshots/{sid}` | full snapshot |
| POST | `/api/snapshots/{sid}/restore` | roll project back |
| GET | `/api/presets` | list presets |
| POST | `/api/presets` | create `{name, params}` |
| PUT | `/api/presets/{id}` | update |
| DELETE | `/api/presets/{id}` | delete |
**UI behaviour**:
- Active project ID is in the URL: `?p=42`. Bookmark or open from another device → same state.
- Auto-save fires 1.5 s after the last change. The first save in any 60 s window also creates a history snapshot.
- "History" button shows the snapshot list with a Restore action.
- Presets are saved server-side and apply to any project with one click.
**Backup**: the entire DB is one file. Copy `data/busbar.db` (or the `data/` folder in Docker) for offline backup.
## Export
| Format | Use | Backend |
|--------|---------------------------------------------|--------------------------|
| STEP | CAD (FreeCAD, Fusion, SolidWorks) | `build123d` → OpenCASCADE |
| DXF | Laser cutter / CNC (2D) | `build123d` → ezdxf |
| SVG | Quick preview, illustrator | `build123d` |
STEP files are written as **flat 3D faces** (not solids) so a fab shop can lay them out before cutting. If you need a thick solid (e.g., 0.2 mm nickel) toggle "Extrude busbars" — the backend will extrude each strip to the configured thickness.
## Project layout
```
busbar-designer/
├── app.py # Flask server: static files + /api/export/{step,dxf,svg}
├── busbar_export.py # build123d → STEP / DXF / SVG
├── requirements.txt
├── scad_snippet.scad # paste into hex_cell.scad to echo per-cell coordinates
├── CLAUDE.md # architecture notes for Claude / future contributors
├── tests/
│ └── test_export.py
└── static/
├── index.html
├── styles.css
└── js/
├── app.js
├── importer.js
├── viewport.js
├── groups.js
├── geometry.js
└── exporter.js
```
## Deploy to a home server
Three supported paths — pick one. Full docs in [`deploy/README.md`](deploy/README.md).
### Option A — Proxmox VE (one-liner from the PVE host)
```bash
bash -c "$(curl -fsSL https://gitea.local/me/busbar-designer/raw/branch/main/deploy/proxmox-lxc.sh)"
```
Creates a Debian 12 LXC and installs the service inside. Whiptail prompts for container ID, hostname, disk/CPU/RAM, network, repo URL, branch. Defaults to 4 GB disk, 2 cores, 1 GB RAM, dhcp.
### Option B — Inside an existing Debian/Ubuntu (LXC, VM, bare)
```bash
REPO_URL=https://gitea.local/me/busbar-designer.git \
bash -c "$(curl -fsSL https://gitea.local/me/busbar-designer/raw/branch/main/deploy/install.sh)"
```
Sets up the venv, installs deps, drops a systemd unit, starts the service. Update with `bash /opt/busbar-designer/deploy/update.sh`.
### Option C — Docker
```bash
git clone <repo> busbar-designer && cd busbar-designer
mkdir -p data
docker compose up -d --build
```
The `data/` folder is mounted into the container at `/app/data` so the SQLite DB survives `docker compose down`.
### Reverse proxy (optional)
If you want it behind your existing nginx / Caddy / Traefik:
```nginx
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_read_timeout 120s; # STEP export can take a few seconds
client_max_body_size 5m;
}
```
For Caddy:
```
busbar.example.com {
reverse_proxy 127.0.0.1:5000
}
```
### Security
The app has no authentication. Either:
- expose only on your LAN (default), or
- put it behind an authenticating reverse proxy (Caddy `basic_auth`, Authelia, Pocket-ID, etc.).
## Troubleshooting
- **`pip install build123d` fails on Windows / Python 3.13** — the prebuilt OCP wheels lag the latest Python release. If your `python --version` is 3.13, install Python 3.12 from python.org and use it for the venv: `py -3.12 -m venv .venv`.
- **STEP opens empty in FreeCAD** — make sure at least one busbar exists and contains ≥ 1 cell. The exporter writes only assigned busbars.
- **Port 5000 in use** — set `PORT=5050 python app.py`.
## License
MIT.
+194
View File
@@ -0,0 +1,194 @@
"""Flask entrypoint for Busbar Designer.
Serves the static frontend, the /api/export/<fmt> CAD endpoints (build123d →
STEP/DXF/SVG), and the /api/projects, /api/presets, /api/snapshots persistence
endpoints (SQLite via storage.py).
Run:
python app.py
"""
from __future__ import annotations
import os
import sys
import traceback
from flask import Flask, Response, jsonify, request, send_from_directory
from busbar_export import WRITERS
import storage
APP_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(APP_DIR, "static")
app = Flask(__name__, static_folder=STATIC_DIR, static_url_path="")
storage.init_db()
# ---------------------------------------------------------------------------
# Static + health
# ---------------------------------------------------------------------------
@app.get("/")
def index():
return send_from_directory(STATIC_DIR, "index.html")
@app.get("/api/health")
def health():
return jsonify({"status": "ok"})
# ---------------------------------------------------------------------------
# CAD export (unchanged contract)
# ---------------------------------------------------------------------------
@app.post("/api/export/<fmt>")
def export(fmt: str):
fmt = fmt.lower()
if fmt not in WRITERS:
return jsonify({"error": f"unsupported format: {fmt}"}), 400
payload = request.get_json(silent=True) or {}
writer, mimetype, ext = WRITERS[fmt]
try:
data = writer(payload)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({"error": f"{type(e).__name__}: {e}"}), 500
filename = f"busbars.{ext}"
return Response(
data,
mimetype=mimetype,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ---------------------------------------------------------------------------
# Projects
# ---------------------------------------------------------------------------
@app.get("/api/projects")
def projects_index():
return jsonify(storage.list_projects())
@app.post("/api/projects")
def projects_create():
body = request.get_json(silent=True) or {}
name = (body.get("name") or "Untitled").strip() or "Untitled"
data = body.get("data") or {}
pid = storage.create_project(name, data)
return jsonify({"id": pid, "name": name})
@app.get("/api/projects/<int:pid>")
def projects_show(pid: int):
p = storage.get_project(pid)
if p is None:
return jsonify({"error": "not found"}), 404
return jsonify(p)
@app.put("/api/projects/<int:pid>")
def projects_update(pid: int):
body = request.get_json(silent=True) or {}
ok = storage.update_project(
pid,
name=body.get("name"),
data=body.get("data"),
snapshot=bool(body.get("snapshot", False)),
note=body.get("note"),
)
if not ok:
return jsonify({"error": "not found"}), 404
return jsonify({"ok": True})
@app.delete("/api/projects/<int:pid>")
def projects_delete(pid: int):
storage.delete_project(pid)
return jsonify({"ok": True})
# ---------------------------------------------------------------------------
# Snapshots
# ---------------------------------------------------------------------------
@app.get("/api/projects/<int:pid>/snapshots")
def snapshots_index(pid: int):
return jsonify(storage.list_snapshots(pid))
@app.get("/api/snapshots/<int:sid>")
def snapshot_show(sid: int):
snap = storage.get_snapshot(sid)
if snap is None:
return jsonify({"error": "not found"}), 404
return jsonify(snap)
@app.post("/api/snapshots/<int:sid>/restore")
def snapshot_restore(sid: int):
if not storage.restore_snapshot(sid):
return jsonify({"error": "not found"}), 404
return jsonify({"ok": True})
# ---------------------------------------------------------------------------
# Presets
# ---------------------------------------------------------------------------
@app.get("/api/presets")
def presets_index():
return jsonify(storage.list_presets())
@app.post("/api/presets")
def presets_create():
body = request.get_json(silent=True) or {}
name = (body.get("name") or "").strip()
if not name:
return jsonify({"error": "name required"}), 400
pid = storage.create_preset(name, body.get("params") or {})
if pid is None:
return jsonify({"error": "name already in use"}), 409
return jsonify({"id": pid, "name": name})
@app.put("/api/presets/<int:pid>")
def presets_update(pid: int):
body = request.get_json(silent=True) or {}
ok = storage.update_preset(pid, name=body.get("name"), params=body.get("params"))
if not ok:
return jsonify({"error": "not found or name conflict"}), 404
return jsonify({"ok": True})
@app.delete("/api/presets/<int:pid>")
def presets_delete(pid: int):
storage.delete_preset(pid)
return jsonify({"ok": True})
# ---------------------------------------------------------------------------
# Boot
# ---------------------------------------------------------------------------
if __name__ == "__main__":
port = int(os.environ.get("PORT", 5000))
host = os.environ.get("HOST", "127.0.0.1")
debug = os.environ.get("FLASK_DEBUG", "1") == "1"
print(f" * Busbar Designer running on http://{host}:{port}", file=sys.stderr)
app.run(host=host, port=port, debug=debug)
+306
View File
@@ -0,0 +1,306 @@
"""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"),
}
+131
View File
@@ -0,0 +1,131 @@
# Deploy
Three scripts here, plus the systemd unit.
| File | Where it runs | What it does |
|-------------------------------|--------------------------------|---------------------------------------------------------------------------|
| `proxmox-lxc.sh` | **Proxmox VE host** as root | Creates an unprivileged Debian 12 LXC, then runs `install.sh` inside it. |
| `install.sh` | **inside** an LXC/VM/server | Clones the repo, sets up Python venv, installs deps, starts systemd unit. |
| `update.sh` | **inside** the LXC/VM/server | `git pull` + refresh Python deps + `systemctl restart`. |
| `busbar-designer.service` | systemd | Unit file template; install.sh substitutes paths/user/port. |
All scripts are idempotent (safe to re-run) and use only stdlib + Debian-shipped tools (`pct`, `pveam`, `whiptail`, `git`, `python3`). No external dependencies.
---
## Step 0 — push to your Gitea (one time)
```bash
# on your laptop, in this repo
git remote add gitea https://gitea.local/me/busbar-designer.git
git push gitea main
```
If your Gitea uses a self-signed cert, set `GIT_SSL_NO_VERIFY=1` everywhere (or install the cert into the system trust store).
---
## Path A — Proxmox VE host (recommended)
One-liner from the Proxmox shell:
```bash
bash -c "$(curl -fsSL https://gitea.local/me/busbar-designer/raw/branch/main/deploy/proxmox-lxc.sh)"
```
(Substitute `https://gitea.local/me/busbar-designer/raw/branch/main/deploy/proxmox-lxc.sh` with your repo's raw URL. Gitea's raw URL format is `https://<host>/<user>/<repo>/raw/branch/<branch>/<path>`.)
You'll get whiptail prompts for:
- Container ID (defaults to next available)
- Hostname (`busbar-designer`)
- Disk size (4 GB), cores (2), RAM (1024 MB)
- Storage pool, network bridge, IP (`dhcp` or `1.2.3.4/24,gw=1.2.3.1`)
- Repo URL & branch
- Skip TLS verify? (yes if your Gitea uses self-signed certs)
The script will:
1. Download Debian 12 template if missing.
2. Create the LXC, start it.
3. Inside the LXC: clone the repo, install everything, enable systemd.
4. Print the URL + root password + management commands.
To skip prompts (CI / scripted re-deploys):
```bash
CTID=210 HOSTNAME=busbar DISK_SIZE=4 CORES=2 RAM=1024 \
BRIDGE=vmbr0 IP=dhcp STORAGE=local-lvm \
REPO_URL=https://gitea.local/me/busbar-designer.git BRANCH=main \
bash deploy/proxmox-lxc.sh
```
---
## Path B — inside an existing LXC / VM / bare server
Already have a Debian 12 / Ubuntu 22.04 / 24.04 host? Just run the installer:
```bash
REPO_URL=https://gitea.local/me/busbar-designer.git \
bash -c "$(curl -fsSL https://gitea.local/me/busbar-designer/raw/branch/main/deploy/install.sh)"
```
Defaults to `/opt/busbar-designer`, user `busbar`, port `5000`. Override with `INSTALL_DIR`, `SVC_USER`, `PORT`.
---
## Updating
From the Proxmox host:
```bash
pct exec 210 -- bash /opt/busbar-designer/deploy/update.sh
```
From inside the LXC:
```bash
sudo bash /opt/busbar-designer/deploy/update.sh
```
The updater does `git fetch + reset --hard` on the tracked branch, refreshes Python deps, restarts the service.
### Optional: auto-deploy via Gitea webhook
In your Gitea repo → **Settings → Webhooks → Add Webhook (Gitea)**:
- URL: `http://<lxc-ip>:5050/hook` (you'd need to add a tiny webhook listener; not built-in)
- Trigger: `Push`
- Branch filter: `main`
Out of the box there's no webhook endpoint — the simplest path is a cron `*/5 * * * * root bash /opt/busbar-designer/deploy/update.sh` if you want polling, or just SSH and re-run `update.sh` after each push.
---
## Backup & restore
Everything user-generated lives in `/opt/busbar-designer/data/busbar.db` (SQLite, single file).
```bash
# Backup (from Proxmox host)
pct exec 210 -- cat /opt/busbar-designer/data/busbar.db > busbar-backup-$(date +%F).db
# Restore
cat busbar-backup-2026-05-24.db | pct exec 210 -- bash -c \
'systemctl stop busbar-designer && cat > /opt/busbar-designer/data/busbar.db && chown busbar:busbar /opt/busbar-designer/data/busbar.db && systemctl start busbar-designer'
```
Or via Proxmox's own LXC backup (`vzdump`) which captures the whole rootfs.
---
## Troubleshooting
| Symptom | Fix |
|--------------------------------------------------|---------------------------------------------------------------------------------------|
| `pveversion: command not found` | The Proxmox script is not running on a PVE host. Use `install.sh` directly inside the LXC. |
| Git clone fails with TLS error | Set `GIT_SSL_NO_VERIFY=1` env var, or install your Gitea CA cert into `/usr/local/share/ca-certificates`. |
| Service starts, then dies | `pct exec <CTID> -- journalctl -u busbar-designer -n 100`. Usually the build123d wheel didn't install — Debian 12 ships Python 3.11 which is fine. |
| Browser shows 502 / can't connect | Check the LXC's IP with `pct exec <CTID> -- ip a`. If using DHCP, the IP may have changed. |
| `pveam download` fails | Run `pveam update` on the host first. |
+38
View File
@@ -0,0 +1,38 @@
; systemd unit for running busbar-designer in a Proxmox LXC (or any Linux VM)
; without Docker. Assumes the project lives at /opt/busbar-designer and you've
; created a venv there with `python3 -m venv .venv && .venv/bin/pip install -r
; requirements.txt gunicorn`.
;
; Install:
; sudo cp deploy/busbar-designer.service /etc/systemd/system/
; sudo systemctl daemon-reload
; sudo systemctl enable --now busbar-designer
;
; Logs: journalctl -u busbar-designer -f
[Unit]
Description=Busbar Designer (Flask + build123d)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=busbar
Group=busbar
WorkingDirectory=/opt/busbar-designer
Environment=HOST=0.0.0.0
Environment=PORT=5000
Environment=FLASK_DEBUG=0
Environment=PATH=/opt/busbar-designer/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=/opt/busbar-designer/.venv/bin/gunicorn --bind=0.0.0.0:5000 --workers=2 --threads=2 --timeout=120 app:app
Restart=on-failure
RestartSec=5
; Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/busbar-designer
[Install]
WantedBy=multi-user.target
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# install.sh — install Busbar Designer on Debian / Ubuntu.
#
# Runs inside an LXC, VM, or bare-metal host. Idempotent (safe to re-run).
#
# Required env:
# REPO_URL Git URL of the busbar-designer repo
# (e.g. https://gitea.local/me/busbar-designer.git)
#
# Optional env:
# BRANCH git branch to track (default: main)
# INSTALL_DIR install location (default: /opt/busbar-designer)
# SVC_USER systemd service user (default: busbar)
# PORT HTTP port (default: 5000)
# GIT_SSL_NO_VERIFY=1 skip TLS verify for self-signed Gitea certs
set -euo pipefail
REPO_URL="${REPO_URL:-}"
BRANCH="${BRANCH:-main}"
INSTALL_DIR="${INSTALL_DIR:-/opt/busbar-designer}"
SVC_USER="${SVC_USER:-busbar}"
PORT="${PORT:-5000}"
# ---- helpers ---------------------------------------------------------------
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
log() { echo -e "${GREEN}${NC} $*"; }
warn() { echo -e "${YELLOW}!${NC} $*"; }
die() { echo -e "${RED}$*${NC}" >&2; exit 1; }
[[ $EUID -eq 0 ]] || die "Run as root (or via sudo)."
[[ -n "$REPO_URL" ]] || die "REPO_URL is required (export REPO_URL=https://gitea.local/me/busbar-designer.git)"
if [[ "${GIT_SSL_NO_VERIFY:-0}" == "1" ]]; then
warn "GIT_SSL_NO_VERIFY=1 — skipping TLS verification for git."
export GIT_SSL_NO_VERIFY=true
fi
# ---- system packages -------------------------------------------------------
log "Installing system packages..."
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq \
git ca-certificates curl \
python3 python3-venv python3-pip \
libgl1 libglu1-mesa libxrender1 libxext6 libsm6 libgomp1
# ---- service user ----------------------------------------------------------
if ! id "$SVC_USER" >/dev/null 2>&1; then
log "Creating service user '$SVC_USER'..."
useradd --system --create-home --shell /usr/sbin/nologin "$SVC_USER"
else
log "Service user '$SVC_USER' already exists."
fi
# ---- source tree -----------------------------------------------------------
mkdir -p "$(dirname "$INSTALL_DIR")"
if [[ -d "$INSTALL_DIR/.git" ]]; then
log "Updating existing checkout at $INSTALL_DIR..."
chown -R "$SVC_USER:$SVC_USER" "$INSTALL_DIR"
sudo -u "$SVC_USER" git -C "$INSTALL_DIR" remote set-url origin "$REPO_URL"
sudo -u "$SVC_USER" git -C "$INSTALL_DIR" fetch --depth 1 origin "$BRANCH"
sudo -u "$SVC_USER" git -C "$INSTALL_DIR" reset --hard "origin/$BRANCH"
else
log "Cloning $REPO_URL (branch $BRANCH) into $INSTALL_DIR..."
rm -rf "$INSTALL_DIR"
git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR"
chown -R "$SVC_USER:$SVC_USER" "$INSTALL_DIR"
fi
# ---- virtualenv + python deps ---------------------------------------------
if [[ ! -x "$INSTALL_DIR/.venv/bin/python" ]]; then
log "Creating Python venv..."
sudo -u "$SVC_USER" python3 -m venv "$INSTALL_DIR/.venv"
fi
log "Installing Python dependencies (build123d pulls OpenCASCADE — may take a few minutes)..."
sudo -u "$SVC_USER" "$INSTALL_DIR/.venv/bin/pip" install --quiet --upgrade pip
sudo -u "$SVC_USER" "$INSTALL_DIR/.venv/bin/pip" install --quiet \
-r "$INSTALL_DIR/requirements.txt" gunicorn
# ---- data dir (for SQLite) -------------------------------------------------
sudo -u "$SVC_USER" mkdir -p "$INSTALL_DIR/data"
# ---- systemd unit ----------------------------------------------------------
log "Installing systemd unit..."
UNIT_SRC="$INSTALL_DIR/deploy/busbar-designer.service"
UNIT_DST="/etc/systemd/system/busbar-designer.service"
[[ -f "$UNIT_SRC" ]] || die "deploy/busbar-designer.service missing in the repo."
# Substitute paths / user / port into the unit.
sed -e "s|/opt/busbar-designer|$INSTALL_DIR|g" \
-e "s|User=busbar|User=$SVC_USER|g" \
-e "s|Group=busbar|Group=$SVC_USER|g" \
-e "s|--bind=0.0.0.0:5000|--bind=0.0.0.0:$PORT|g" \
-e "s|Environment=PORT=5000|Environment=PORT=$PORT|g" \
"$UNIT_SRC" > "$UNIT_DST"
systemctl daemon-reload
systemctl enable --now busbar-designer
sleep 2
if ! systemctl is-active --quiet busbar-designer; then
warn "Service is not active. Last 30 log lines:"
journalctl -u busbar-designer -n 30 --no-pager
die "Service failed to start."
fi
IP_ADDR=$(hostname -I 2>/dev/null | awk '{print $1}')
[[ -n "$IP_ADDR" ]] || IP_ADDR="$(hostname)"
echo
log "================================================================"
log " ✓ Busbar Designer installed."
log ""
log " URL: http://$IP_ADDR:$PORT"
log " Logs: journalctl -u busbar-designer -f"
log " Update: sudo bash $INSTALL_DIR/deploy/update.sh"
log " Backup: cp $INSTALL_DIR/data/busbar.db ..."
log "================================================================"
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env bash
# proxmox-lxc.sh — create an unprivileged Debian 12 LXC on a Proxmox VE host
# and install Busbar Designer into it. Inspired by the community-scripts style.
#
# Run on the Proxmox host as root, e.g.:
#
# bash -c "$(curl -fsSL https://gitea.local/me/busbar-designer/raw/branch/main/deploy/proxmox-lxc.sh)"
#
# Interactive whiptail prompts; or pre-set everything via env to skip prompts:
#
# REPO_URL=https://gitea.local/me/busbar-designer.git \
# CTID=210 HOSTNAME=busbar DISK_SIZE=4 CORES=2 RAM=1024 \
# BRIDGE=vmbr0 IP=dhcp STORAGE=local-lvm \
# bash proxmox-lxc.sh
set -euo pipefail
# ---- colors ----------------------------------------------------------------
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'
BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m'
log() { echo -e "${GREEN}${NC} $*"; }
warn() { echo -e "${YELLOW}!${NC} $*"; }
die() { echo -e "${RED}$*${NC}" >&2; exit 1; }
banner() {
cat <<'EOF'
____ _ ____ _
| __ ) _ _ ___| |__ __ _ _ __ | _ \ ___ ___(_) __ _ _ __ ___ _ __
| _ \| | | / __| '_ \ / _` | '__| | | | |/ _ \/ __| |/ _` | '_ \ / _ \ '__|
| |_) | |_| \__ \ |_) | (_| | | | |_| | __/\__ \ | (_| | | | | __/ |
|____/ \__,_|___/_.__/ \__,_|_| |____/ \___||___/_|\__, |_| |_|\___|_|
|___/
Proxmox VE LXC installer
EOF
}
# ---- preflight -------------------------------------------------------------
[[ $EUID -eq 0 ]] || die "Run as root on the Proxmox host."
command -v pveversion >/dev/null 2>&1 || die "pveversion not found — is this a Proxmox VE host?"
command -v pct >/dev/null 2>&1 || die "pct not found — Proxmox VE tools missing?"
banner
# ---- defaults --------------------------------------------------------------
CTID_DEFAULT=$(pvesh get /cluster/nextid 2>/dev/null || echo "200")
CTID="${CTID:-$CTID_DEFAULT}"
HOSTNAME="${HOSTNAME:-busbar-designer}"
DISK_SIZE="${DISK_SIZE:-4}"
CORES="${CORES:-2}"
RAM="${RAM:-1024}"
SWAP="${SWAP:-512}"
BRIDGE="${BRIDGE:-vmbr0}"
IP="${IP:-dhcp}"
STORAGE="${STORAGE:-local-lvm}"
TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local}"
REPO_URL="${REPO_URL:-}"
BRANCH="${BRANCH:-main}"
GIT_SSL_NO_VERIFY="${GIT_SSL_NO_VERIFY:-0}"
# ---- interactive prompts (whiptail) ----------------------------------------
ask() {
local var="$1" prompt="$2" default="$3" h="${4:-8}" w="${5:-60}"
local val
if [[ -t 0 ]] && command -v whiptail >/dev/null 2>&1; then
val=$(whiptail --title "Busbar Designer" --inputbox "$prompt" "$h" "$w" "$default" \
3>&1 1>&2 2>&3) || die "Cancelled."
else
read -rp "$prompt [$default]: " val
val="${val:-$default}"
fi
printf -v "$var" '%s' "$val"
}
yesno() {
local prompt="$1" default="${2:-no}"
if [[ -t 0 ]] && command -v whiptail >/dev/null 2>&1; then
if [[ "$default" == "yes" ]]; then
whiptail --title "Busbar Designer" --yesno "$prompt" 8 60
else
whiptail --title "Busbar Designer" --yesno "$prompt" --defaultno 8 60
fi
else
read -rp "$prompt [$default]: " val
[[ "${val:-$default}" =~ ^(y|yes)$ ]]
fi
}
if [[ -z "$REPO_URL" ]] || [[ -t 0 ]]; then
ask CTID "Container ID" "$CTID"
ask HOSTNAME "Hostname" "$HOSTNAME"
ask DISK_SIZE "Root disk size (GB)" "$DISK_SIZE"
ask CORES "CPU cores" "$CORES"
ask RAM "RAM (MB)" "$RAM"
ask STORAGE "Storage pool for rootfs (e.g. local-lvm, local-zfs)" "$STORAGE" 8 70
ask BRIDGE "Network bridge" "$BRIDGE"
ask IP "IP config: 'dhcp' or 'a.b.c.d/24,gw=a.b.c.1'" "$IP" 8 70
ask REPO_URL "Git URL of busbar-designer repo (your Gitea / GitHub)" \
"${REPO_URL:-https://gitea.local/me/busbar-designer.git}" 10 70
ask BRANCH "Branch" "$BRANCH"
if yesno "Skip TLS verification for git? (only if your Gitea uses a self-signed cert)"; then
GIT_SSL_NO_VERIFY=1
fi
fi
[[ -n "$REPO_URL" ]] || die "REPO_URL is required."
# ---- template --------------------------------------------------------------
log "Looking for a Debian 12 template..."
TEMPLATE=$(pveam available --section system 2>/dev/null \
| awk '/debian-12-standard/ {print $2}' | sort -r | head -n 1)
[[ -n "$TEMPLATE" ]] || die "Couldn't find debian-12-standard in 'pveam available'. Run 'pveam update' first."
LOCAL_TEMPLATE="/var/lib/vz/template/cache/$TEMPLATE"
if [[ ! -f "$LOCAL_TEMPLATE" ]]; then
log "Downloading template $TEMPLATE..."
pveam download "$TEMPLATE_STORAGE" "$TEMPLATE"
fi
# ---- create LXC ------------------------------------------------------------
if pct status "$CTID" >/dev/null 2>&1; then
die "Container $CTID already exists. Pick a different CTID."
fi
PASSWORD="$(openssl rand -base64 12 | tr -d '/+=' | cut -c1-16)"
if [[ "$IP" == "dhcp" ]]; then
NET="name=eth0,bridge=$BRIDGE,ip=dhcp"
else
NET="name=eth0,bridge=$BRIDGE,ip=$IP"
fi
log "Creating LXC $CTID ($HOSTNAME)..."
pct create "$CTID" "$TEMPLATE_STORAGE:vztmpl/$TEMPLATE" \
--hostname "$HOSTNAME" \
--cores "$CORES" \
--memory "$RAM" \
--swap "$SWAP" \
--rootfs "$STORAGE:$DISK_SIZE" \
--net0 "$NET" \
--password "$PASSWORD" \
--features nesting=1 \
--unprivileged 1 \
--onboot 1 \
--start 1 \
--description "Busbar Designer · $REPO_URL ($BRANCH)" >/dev/null
# ---- wait for network ------------------------------------------------------
log "Waiting for network in CT $CTID..."
for i in {1..30}; do
if pct exec "$CTID" -- bash -c "getent hosts deb.debian.org >/dev/null" 2>/dev/null; then
break
fi
sleep 2
done
# ---- run installer inside the LXC ------------------------------------------
log "Bootstrapping git + curl in the container..."
pct exec "$CTID" -- bash -c "
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq git ca-certificates curl
" || die "Failed to install bootstrap packages in CT."
log "Cloning repo and running deploy/install.sh inside CT $CTID..."
pct exec "$CTID" -- bash -c "
set -e
${GIT_SSL_NO_VERIFY:+export GIT_SSL_NO_VERIFY=true}
rm -rf /opt/busbar-designer
git clone --depth 1 -b '$BRANCH' '$REPO_URL' /opt/busbar-designer
REPO_URL='$REPO_URL' BRANCH='$BRANCH' \
${GIT_SSL_NO_VERIFY:+GIT_SSL_NO_VERIFY=1} \
bash /opt/busbar-designer/deploy/install.sh
" || die "Installer failed. Inspect with: pct enter $CTID"
# ---- report ---------------------------------------------------------------
IP_ADDR=$(pct exec "$CTID" -- bash -c "hostname -I | awk '{print \$1}'" 2>/dev/null || true)
[[ -n "$IP_ADDR" ]] || IP_ADDR="<unknown — check 'pct exec $CTID -- ip a'>"
echo
log "================================================================"
log " ${BOLD}✓ Busbar Designer LXC ready${NC}"
log ""
log " Container ID: $CTID"
log " Hostname: $HOSTNAME"
log " Root password: $PASSWORD"
log " URL: http://$IP_ADDR:5000"
log ""
log " Update: pct exec $CTID -- bash /opt/busbar-designer/deploy/update.sh"
log " Logs: pct exec $CTID -- journalctl -u busbar-designer -f"
log " Enter: pct enter $CTID"
log " Backup: pct exec $CTID -- cat /opt/busbar-designer/data/busbar.db > backup.db"
log "================================================================"
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# update.sh — pull the latest from the configured branch, refresh deps, restart.
#
# Optional env (override defaults set at install time):
# INSTALL_DIR default /opt/busbar-designer
# SVC_USER default busbar
# BRANCH default main (or whatever the local checkout tracks)
set -euo pipefail
INSTALL_DIR="${INSTALL_DIR:-/opt/busbar-designer}"
SVC_USER="${SVC_USER:-busbar}"
BRANCH="${BRANCH:-}"
GREEN='\033[0;32m'; RED='\033[0;31m'; NC='\033[0m'
log() { echo -e "${GREEN}${NC} $*"; }
die() { echo -e "${RED}$*${NC}" >&2; exit 1; }
[[ $EUID -eq 0 ]] || die "Run as root."
[[ -d "$INSTALL_DIR/.git" ]] || die "$INSTALL_DIR is not a git checkout."
cd "$INSTALL_DIR"
# Use the branch the working copy tracks if not overridden.
if [[ -z "$BRANCH" ]]; then
BRANCH=$(sudo -u "$SVC_USER" git -C "$INSTALL_DIR" rev-parse --abbrev-ref HEAD)
fi
log "Pulling origin/$BRANCH..."
sudo -u "$SVC_USER" git -C "$INSTALL_DIR" fetch --depth 1 origin "$BRANCH"
sudo -u "$SVC_USER" git -C "$INSTALL_DIR" reset --hard "origin/$BRANCH"
log "Refreshing Python deps..."
sudo -u "$SVC_USER" "$INSTALL_DIR/.venv/bin/pip" install --quiet --upgrade \
-r "$INSTALL_DIR/requirements.txt" gunicorn
log "Restarting busbar-designer..."
systemctl restart busbar-designer
sleep 2
if systemctl is-active --quiet busbar-designer; then
log "✓ Updated and restarted."
else
die "Service failed after restart. journalctl -u busbar-designer -n 50"
fi
+23
View File
@@ -0,0 +1,23 @@
services:
busbar-designer:
build: .
image: busbar-designer:latest
container_name: busbar-designer
restart: unless-stopped
ports:
- "5000:5000" # change to "8000:5000" if 5000 is taken
environment:
- HOST=0.0.0.0
- PORT=5000
- FLASK_DEBUG=0
- BUSBAR_DB=/app/data/busbar.db
- SNAPSHOT_RETENTION=20 # max history versions kept per project
volumes:
- ./data:/app/data # SQLite DB lives here; back it up by copying this folder
healthcheck:
test: ["CMD", "python", "-c",
"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:5000/api/health', timeout=3).status==200 else 1)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
+5
View File
@@ -0,0 +1,5 @@
Flask>=3.0
build123d>=0.10.0
ezdxf>=1.2.0
# gunicorn is for production (Docker / systemd); not needed for `python app.py`
gunicorn>=21.0; sys_platform != "win32"
+19
View File
@@ -0,0 +1,19 @@
// Drop this at the END of hex_cell.scad to echo per-cell coordinates
// in a format the Busbar Designer importer recognises:
//
// ECHO: "Cell 1: x = 0.0, y = 0.0"
// ECHO: "Cell 2: x = 22.8, y = 0.0"
// ...
//
// Origin (0,0) = center of cell at row=0, col=0.
// Indexing matches the OpenSCAD loop order: row major, then col.
module _echo_cells(centers) {
for (i = [0 : len(centers) - 1]) {
echo(str("Cell ", i + 1, ": x = ", centers[i].x, ", y = ", centers[i].y));
}
}
if (pack_style == "rect") _echo_cells(get_hex_center_points_rect(num_rows, num_cols));
else if (pack_style == "para") _echo_cells(get_hex_center_points_para(num_rows, num_cols));
else if (pack_style == "tria") _echo_cells(get_hex_center_points_tria(num_rows, num_cols));
+172
View File
@@ -0,0 +1,172 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Busbar Designer</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="topbar">
<h1>Busbar Designer</h1>
<div class="project-bar">
<select id="project-select" title="Open project">
<option value="">— select project —</option>
</select>
<input id="project-name" type="text" placeholder="Project name" />
<button id="btn-project-new" title="Create a new empty project">+ New</button>
<button id="btn-project-del" class="danger" title="Delete current project">×</button>
<button id="btn-save-now" class="primary" title="Save current state to server now (no waiting for auto-save)">Save</button>
<button id="btn-history" title="View snapshot history">History</button>
<span id="save-status" class="save-status"></span>
<span id="status" class="topbar-status">No cells loaded</span>
</div>
<div class="actions">
<button id="btn-save" title="Download project as JSON file">Save .json</button>
<button id="btn-load" title="Import project from JSON file">Load .json</button>
<input type="file" id="file-load" accept=".json" hidden />
<span class="sep"></span>
<button id="btn-export-step" class="primary">Export STEP</button>
<button id="btn-export-dxf">Export DXF</button>
<button id="btn-export-svg">Export SVG</button>
</div>
</header>
<main>
<aside class="left">
<section class="panel">
<h2>1. Cell import</h2>
<div class="tabs" id="import-tabs">
<button class="tab active" data-tab="paste">Paste</button>
<button class="tab" data-tab="csv">CSV</button>
<button class="tab" data-tab="json">JSON</button>
<button class="tab" data-tab="gen">Generator</button>
</div>
<div class="tab-body" data-tab-body="paste">
<p class="hint">Paste OpenSCAD ECHO lines (<code>ECHO: "Cell 1: x = …, y = …"</code>) or plain <code>id x y</code> lines.</p>
<textarea id="paste-text" rows="8" placeholder='ECHO: "Cell 1: x = 0, y = 0"&#10;ECHO: "Cell 2: x = 22.8, y = 0"'></textarea>
<button id="btn-import-paste">Import</button>
</div>
<div class="tab-body hidden" data-tab-body="csv">
<p class="hint">CSV with optional header: <code>index,x,y</code></p>
<textarea id="csv-text" rows="8" placeholder="1,0,0&#10;2,22.8,0"></textarea>
<button id="btn-import-csv">Import</button>
</div>
<div class="tab-body hidden" data-tab-body="json">
<p class="hint">JSON: <code>[{"id":1,"x":0,"y":0}, …]</code> or <code>[[x,y], …]</code></p>
<textarea id="json-text" rows="8" placeholder='[[0,0],[22.8,0]]'></textarea>
<button id="btn-import-json">Import</button>
</div>
<div class="tab-body hidden" data-tab-body="gen">
<p class="hint">Exact formulas from <code>hex_cell.scad</code>.</p>
<div class="grid">
<label>Cell dia (mm) <input type="number" id="gen-cell-dia" value="21.2" step="0.1"></label>
<label>Wall (mm) <input type="number" id="gen-wall" value="0.8" step="0.1"></label>
<label>Rows <input type="number" id="gen-rows" value="4" min="1"></label>
<label>Cols <input type="number" id="gen-cols" value="6" min="1"></label>
<label>Style
<select id="gen-style">
<option value="rect">rect</option>
<option value="para">para</option>
<option value="tria">tria</option>
</select>
</label>
</div>
<button id="btn-import-gen">Generate</button>
</div>
</section>
<section class="panel">
<h2>2. Cell &amp; busbar params</h2>
<div class="preset-row">
<select id="preset-select">
<option value="">— saved preset —</option>
</select>
<button id="btn-preset-apply" title="Apply selected preset">Apply</button>
<button id="btn-preset-save" class="primary" title="Save current params as new preset">Save as preset</button>
<button id="btn-preset-del" class="danger" title="Delete selected preset">×</button>
</div>
<div class="grid">
<label>Cell dia (mm) <input type="number" id="p-cell-dia" value="21.2" step="0.1"></label>
<label>Opening dia (mm) <input type="number" id="p-opening-dia" value="15.2" step="0.1"></label>
<label>Pad radius (mm) <input type="number" id="p-pad-radius" value="9.0" step="0.1" title="Disc radius over each cell (panel + wire)"></label>
<label>Strip width (mm) <input type="number" id="p-strip-width" value="6.0" step="0.1" title="Connector width between cells"></label>
<label>Hole shape
<select id="p-hole-shape">
<option value="cross" selected>cross (slit)</option>
<option value="circle">circle</option>
</select>
</label>
<label>Hole radius (mm) <input type="number" id="p-hole-radius" value="6.0" step="0.1" title="For cross: half-length of each arm; for circle: radius"></label>
<label>Slit width (mm) <input type="number" id="p-slit-width" value="1.0" step="0.1" title="Width of each cross arm"></label>
<label>Neighbor factor <input type="number" id="p-neighbor-factor" value="1.15" step="0.05" min="1.0" title="Bridge two cells if distance ≤ factor × shortest pair distance"></label>
<label class="checkbox"><input type="checkbox" id="p-extrude"> Extrude solid</label>
<label>Thickness (mm) <input type="number" id="p-thickness" value="0.2" step="0.05"></label>
</div>
</section>
<section class="panel">
<h2>3. Busbars</h2>
<div class="busbar-toolbar">
<button id="btn-new-busbar" class="primary">+ New busbar from selection</button>
<span class="sel-info" id="sel-info">0 selected</span>
</div>
<ul id="busbar-list" class="busbar-list"></ul>
<p class="hint">Click cell to select. Shift+click extend; Alt+click deselect. Right-mouse drag to pan; wheel to zoom.</p>
</section>
</aside>
<section class="right">
<div class="viewport-wrap">
<canvas id="viewport"></canvas>
<div class="viewport-overlay">
<div id="cursor-pos">x: — , y: —</div>
<div id="zoom-info">1.0 px/mm</div>
</div>
</div>
<div class="step-preview-wrap collapsed" id="step-preview-wrap">
<div class="step-preview-header">
<button id="step-preview-collapse" class="step-preview-collapse" title="Show / hide preview"></button>
<label class="checkbox">
<input type="checkbox" id="step-preview-toggle" checked>
Live STEP preview
</label>
<span id="step-preview-status" class="hint">idle</span>
</div>
<div class="step-preview" id="step-preview-content">
<div class="step-preview-empty">Create a busbar to see the exported geometry here.</div>
</div>
</div>
</section>
</main>
<!-- History modal -->
<div id="history-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>Snapshot history</h3>
<button id="btn-history-close" class="modal-close">×</button>
</div>
<div class="modal-body">
<p class="hint">Each auto-save creates a snapshot of the prior state (max 20 per project). Restore rolls back; the current state is auto-snapshotted first.</p>
<ul id="history-list" class="history-list"></ul>
</div>
</div>
</div>
<script src="js/importer.js"></script>
<script src="js/groups.js"></script>
<script src="js/geometry.js"></script>
<script src="js/viewport.js"></script>
<script src="js/exporter.js"></script>
<script src="js/api.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
/* api.js thin REST client over fetch(). Throws on non-2xx so callers can
* try/catch. Returns parsed JSON.
*/
const Api = (() => {
async function _req(method, url, body) {
const opts = { method, headers: {} };
if (body !== undefined) {
opts.headers["content-type"] = "application/json";
opts.body = JSON.stringify(body);
}
const res = await fetch(url, opts);
if (!res.ok) {
let msg = res.statusText;
try { msg = (await res.json()).error || msg; } catch {}
const err = new Error(`${res.status} ${msg}`);
err.status = res.status;
throw err;
}
if (res.status === 204) return null;
const ct = res.headers.get("content-type") || "";
return ct.includes("application/json") ? res.json() : res.text();
}
return {
// Projects
listProjects: () => _req("GET", "/api/projects"),
getProject: (id) => _req("GET", `/api/projects/${id}`),
createProject: (name, data) => _req("POST", "/api/projects", { name, data }),
updateProject: (id, payload) => _req("PUT", `/api/projects/${id}`, payload),
deleteProject: (id) => _req("DELETE", `/api/projects/${id}`),
// Snapshots
listSnapshots: (projectId) => _req("GET", `/api/projects/${projectId}/snapshots`),
getSnapshot: (sid) => _req("GET", `/api/snapshots/${sid}`),
restoreSnapshot: (sid) => _req("POST", `/api/snapshots/${sid}/restore`),
// Presets
listPresets: () => _req("GET", "/api/presets"),
createPreset: (name, params) => _req("POST", "/api/presets", { name, params }),
updatePreset: (id, payload) => _req("PUT", `/api/presets/${id}`, payload),
deletePreset: (id) => _req("DELETE", `/api/presets/${id}`),
};
})();
+666
View File
@@ -0,0 +1,666 @@
/* app.js top-level controller.
*
* Persistence model:
* - Each "project" is a row in SQLite holding the full editor state
* (cells + busbars + params + activeBusbarId).
* - Active project id lives in the URL as ?p=<id>; bookmarking / opening
* in another browser reloads the same state.
* - Any onStateChanged() fires Viewport.render(), schedulePreview() and
* scheduleAutoSave(). Auto-save is debounced 1500 ms; the first save in
* each 60 s window also writes a snapshot (history).
* - If the user edits before naming a project, one is auto-created with a
* timestamp name; URL is updated.
*
* Presets are separate from projects: just a named blob of params.
*/
(() => {
// ---- state ---------------------------------------------------------------
const state = {
cells: [],
busbars: [],
cellToBusbar: new Map(),
selection: new Set(),
activeBusbarId: null,
};
const params = {
cellDia: 21.2,
openingDia: 15.2,
stripWidth: 6.0,
padRadius: 9.0,
holeRadius: 6.0,
holeShape: "cross",
slitWidth: 1.0,
neighborFactor: 1.15,
extrude: false,
thickness: 0.2,
};
let currentProject = null; // { id, name } when one is open
let isSaving = false;
let pendingSave = false;
let isDirty = false; // unsaved changes since the last successful save
let autoSaveTimer = null;
let lastSnapshotAt = 0;
const SNAPSHOT_INTERVAL_MS = 60_000;
let stepPreviewEnabled = true;
let stepPreviewTimer = null;
let stepPreviewInFlight = null;
// ---- helpers -------------------------------------------------------------
const $ = (id) => document.getElementById(id);
const fmtMm = (v) => (Math.round(v * 100) / 100).toFixed(2);
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
function setSaveStatus(cls, text) {
const el = $("save-status");
if (!el) return;
el.className = "save-status " + (cls || "");
el.textContent = text || "—";
}
// ---- tabs ---------------------------------------------------------------
document.querySelectorAll(".tab").forEach((t) => {
t.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((x) => x.classList.remove("active"));
t.classList.add("active");
const target = t.dataset.tab;
document.querySelectorAll(".tab-body").forEach((b) => {
b.classList.toggle("hidden", b.dataset.tabBody !== target);
});
});
});
// ---- cell import --------------------------------------------------------
$("btn-import-paste").addEventListener("click", () => loadCells(Importer.parsePaste($("paste-text").value)));
$("btn-import-csv" ).addEventListener("click", () => loadCells(Importer.parseCSV ($("csv-text" ).value)));
$("btn-import-json" ).addEventListener("click", () => {
try { loadCells(Importer.parseJSON($("json-text").value)); }
catch (e) { alert(`JSON parse error: ${e.message}`); }
});
$("btn-import-gen").addEventListener("click", () => {
loadCells(Importer.generate({
cellDia: +$("gen-cell-dia").value,
wall: +$("gen-wall").value,
rows: +$("gen-rows").value,
cols: +$("gen-cols").value,
style: $("gen-style").value,
}));
});
// ---- param inputs -------------------------------------------------------
const paramInputs = [
["p-cell-dia", "cellDia"],
["p-opening-dia", "openingDia"],
["p-strip-width", "stripWidth"],
["p-pad-radius", "padRadius"],
["p-hole-radius", "holeRadius"],
["p-slit-width", "slitWidth"],
["p-neighbor-factor", "neighborFactor"],
["p-thickness", "thickness"],
];
for (const [id, key] of paramInputs) {
$(id).addEventListener("input", () => {
params[key] = +$(id).value;
onStateChanged();
});
}
$("p-hole-shape").addEventListener("change", () => {
params.holeShape = $("p-hole-shape").value;
onStateChanged();
});
$("p-extrude").addEventListener("change", () => {
params.extrude = $("p-extrude").checked;
schedulePreview();
scheduleAutoSave();
});
function _syncParamInputs() {
for (const [id, key] of paramInputs) $(id).value = params[key];
$("p-hole-shape").value = params.holeShape || "cross";
$("p-extrude").checked = !!params.extrude;
}
// ---- busbar create button -----------------------------------------------
$("btn-new-busbar").addEventListener("click", () => {
const ids = [...state.selection];
if (!ids.length) { alert("Select cells first."); return; }
const bb = Groups.create(state, ids);
state.activeBusbarId = bb.id;
state.selection.clear();
renderBusbarList();
updateSelInfo();
onStateChanged();
});
// ---- .json export / import (file-based; complements server storage) -----
$("btn-save").addEventListener("click", saveProjectAsFile);
$("btn-load").addEventListener("click", () => $("file-load").click());
$("file-load").addEventListener("change", (e) => {
const f = e.target.files[0];
if (!f) return;
const r = new FileReader();
r.onload = () => {
try { applyState(JSON.parse(r.result)); onStateChanged(); }
catch (err) { alert(err.message); }
};
r.readAsText(f);
e.target.value = "";
});
// ---- export buttons -----------------------------------------------------
$("btn-export-step").addEventListener("click", () => Exporter.exportFormat("step", state, params));
$("btn-export-dxf" ).addEventListener("click", () => Exporter.exportFormat("dxf", state, params));
$("btn-export-svg" ).addEventListener("click", () => Exporter.exportFormat("svg", state, params));
// ---- viewport init ------------------------------------------------------
Viewport.init($("viewport"), state, params, {
onCellClick: (cellId, mods) => {
if (mods.alt) state.selection.delete(cellId);
else if (mods.shift) state.selection.add(cellId);
else { state.selection.clear(); state.selection.add(cellId); }
updateSelInfo();
Viewport.render();
},
onCursorMove: (x, y) => {
$("cursor-pos").textContent = `x: ${fmtMm(x)} , y: ${fmtMm(y)}`;
},
onZoomChange: (s) => {
$("zoom-info").textContent = `${fmtMm(s)} px/mm`;
},
});
// ---- project bar --------------------------------------------------------
$("project-select").addEventListener("change", (e) => {
const id = +e.target.value;
if (!id) return;
openProject(id);
});
$("project-name").addEventListener("change", async (e) => {
if (!currentProject) return;
const name = e.target.value.trim();
if (!name) { e.target.value = currentProject.name; return; }
try {
await Api.updateProject(currentProject.id, { name });
currentProject.name = name;
await refreshProjectList();
setSaveStatus("saved", "renamed");
setTimeout(() => setSaveStatus("", "—"), 1500);
} catch (err) { alert(err.message); }
});
$("btn-project-new").addEventListener("click", async () => {
const name = prompt(
"New project name:",
`Project ${new Date().toLocaleDateString()}`
);
if (name === null) return;
try {
const r = await Api.createProject(name.trim() || "Untitled", _emptyProjectData());
await refreshProjectList();
openProject(r.id);
} catch (e) { alert(e.message); }
});
$("btn-project-del").addEventListener("click", async () => {
if (!currentProject) return;
if (!confirm(`Delete project "${currentProject.name}"? This cannot be undone.`)) return;
try {
await Api.deleteProject(currentProject.id);
currentProject = null;
$("project-name").value = "";
history.replaceState({}, "", location.pathname);
await refreshProjectList();
applyState(_emptyProjectData());
} catch (e) { alert(e.message); }
});
// ---- history modal ------------------------------------------------------
$("btn-history").addEventListener("click", showHistory);
$("btn-history-close").addEventListener("click", () => $("history-modal").classList.add("hidden"));
$("history-modal").addEventListener("click", (e) => {
if (e.target.id === "history-modal") $("history-modal").classList.add("hidden");
});
async function showHistory() {
if (!currentProject) { alert("Open a project first."); return; }
let snaps;
try { snaps = await Api.listSnapshots(currentProject.id); }
catch (e) { alert(e.message); return; }
const ul = $("history-list");
if (!snaps.length) {
ul.innerHTML = '<li class="hint">No snapshots yet.</li>';
} else {
ul.innerHTML = snaps.map((s) => `
<li class="history-item">
<span class="time">${escapeHtml(s.created_at)}</span>
<span class="note">${escapeHtml(s.note || "(auto)")}</span>
<button data-id="${s.id}" class="restore">Restore</button>
</li>`).join("");
ul.querySelectorAll(".restore").forEach((b) => {
b.addEventListener("click", async () => {
if (!confirm("Restore this snapshot? The current state will be saved to history first.")) return;
try {
await Api.restoreSnapshot(+b.dataset.id);
$("history-modal").classList.add("hidden");
await openProject(currentProject.id);
} catch (e) { alert(e.message); }
});
});
}
$("history-modal").classList.remove("hidden");
}
// ---- presets ------------------------------------------------------------
$("btn-preset-apply").addEventListener("click", async () => {
const id = +$("preset-select").value;
if (!id) return;
try {
const p = await Api.getPreset?.(id) || (await Api.listPresets()).find((x) => x.id === id);
if (!p) return;
Object.assign(params, p.params);
_syncParamInputs();
onStateChanged();
} catch (e) { alert(e.message); }
});
$("btn-preset-save").addEventListener("click", async () => {
const name = prompt("Preset name (e.g. '21700 0.2mm Ni'):");
if (!name) return;
try {
await Api.createPreset(name.trim(), { ...params });
await refreshPresetList();
} catch (e) {
alert(e.message);
}
});
$("btn-preset-del").addEventListener("click", async () => {
const id = +$("preset-select").value;
if (!id) return;
const name = $("preset-select").selectedOptions[0]?.text || "";
if (!confirm(`Delete preset "${name}"?`)) return;
try {
await Api.deletePreset(id);
await refreshPresetList();
} catch (e) { alert(e.message); }
});
// ---- top-level orchestration --------------------------------------------
function _emptyProjectData() {
return { params: { ...params }, cells: [], busbars: [], activeBusbarId: null };
}
function loadCells(cells) {
if (!cells || !cells.length) { alert("No cells parsed."); return; }
state.cells = cells;
state.busbars = [];
state.cellToBusbar = new Map();
state.selection = new Set();
Groups.reset();
updateStatus();
renderBusbarList();
updateSelInfo();
Viewport.fitToContent();
Viewport.render();
schedulePreview();
// Save NOW (skip debounce) — most "I refreshed and lost everything"
// reports happen when the user refreshes within the 1.5 s window.
clearTimeout(autoSaveTimer);
performAutoSave();
}
function applyState(data) {
if (data.params) Object.assign(params, data.params);
state.cells = (data.cells || []).map((c, i) => ({ id: c.id ?? i + 1, x: +c.x, y: +c.y }));
Groups.reset();
state.busbars = (data.busbars || []).map((b) => ({
id: b.id,
name: b.name,
color: b.color,
shape: b.shape || "panel",
cells: [...(b.cells || [])],
}));
state.cellToBusbar = new Map();
for (const b of state.busbars) for (const cid of b.cells) state.cellToBusbar.set(cid, b.id);
state.selection = new Set();
state.activeBusbarId = data.activeBusbarId ?? null;
_syncParamInputs();
updateStatus();
renderBusbarList();
updateSelInfo();
Viewport.fitToContent();
schedulePreview();
}
function onStateChanged() {
Viewport.render();
schedulePreview();
scheduleAutoSave();
}
function updateStatus() {
const el = $("status");
if (!el) return;
el.textContent = state.cells.length
? `${state.cells.length} cells loaded`
: "No cells loaded";
}
function updateSelInfo() {
const el = $("sel-info");
if (!el) return;
el.textContent = `${state.selection.size} selected`;
}
// ---- busbar list --------------------------------------------------------
function renderBusbarList() {
const ul = $("busbar-list");
ul.innerHTML = "";
for (const bb of state.busbars) {
const li = document.createElement("li");
li.className = "busbar-item" + (bb.id === state.activeBusbarId ? " active" : "");
const swatch = document.createElement("input");
swatch.type = "color";
swatch.className = "busbar-color";
swatch.value = bb.color;
swatch.addEventListener("input", (e) => {
Groups.recolor(state, bb.id, e.target.value);
onStateChanged();
});
const name = document.createElement("input");
name.className = "busbar-name";
name.value = bb.name;
name.addEventListener("change", (e) => {
Groups.rename(state, bb.id, e.target.value);
scheduleAutoSave();
});
const count = document.createElement("span");
count.className = "busbar-count";
count.textContent = `${bb.cells.length}p`;
const shapeSel = document.createElement("select");
shapeSel.className = "busbar-shape";
shapeSel.title = "Shape: panel = production plate; wire = thin strip";
for (const opt of ["panel", "wire"]) {
const o = document.createElement("option");
o.value = opt; o.textContent = opt;
if (bb.shape === opt) o.selected = true;
shapeSel.appendChild(o);
}
shapeSel.addEventListener("change", (e) => {
bb.shape = e.target.value;
onStateChanged();
});
const actions = document.createElement("div");
actions.className = "busbar-actions";
const addBtn = document.createElement("button");
addBtn.textContent = "+ sel";
addBtn.addEventListener("click", () => {
Groups.addCells(state, bb.id, [...state.selection]);
state.selection.clear();
renderBusbarList(); updateSelInfo(); onStateChanged();
});
const remBtn = document.createElement("button");
remBtn.textContent = " sel";
remBtn.addEventListener("click", () => {
Groups.removeCells(state, bb.id, [...state.selection]);
state.selection.clear();
renderBusbarList(); updateSelInfo(); onStateChanged();
});
const delBtn = document.createElement("button");
delBtn.className = "del";
delBtn.textContent = "×";
delBtn.addEventListener("click", () => {
if (!confirm(`Delete busbar "${bb.name}"?`)) return;
Groups.remove(state, bb.id);
renderBusbarList(); onStateChanged();
});
actions.appendChild(addBtn); actions.appendChild(remBtn); actions.appendChild(delBtn);
li.appendChild(swatch); li.appendChild(name); li.appendChild(count);
li.appendChild(shapeSel); li.appendChild(actions);
li.addEventListener("click", (e) => {
if (e.target !== li) return;
state.activeBusbarId = bb.id;
renderBusbarList();
});
ul.appendChild(li);
}
}
// ---- .json file save (download) -----------------------------------------
function saveProjectAsFile() {
const blob = new Blob([JSON.stringify(_serialize(), null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = (currentProject ? currentProject.name : "busbar-project") + ".json";
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
}
function _serialize() {
return {
params,
cells: state.cells,
busbars: state.busbars,
activeBusbarId: state.activeBusbarId,
};
}
// ---- server: projects ---------------------------------------------------
async function refreshProjectList() {
try {
const list = await Api.listProjects();
const sel = $("project-select");
const keep = sel.value;
sel.innerHTML = '<option value="">— select project —</option>' +
list.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join("");
if (currentProject) sel.value = String(currentProject.id);
else sel.value = keep;
} catch (e) {
console.error("refreshProjectList:", e);
}
}
async function openProject(id) {
try {
const p = await Api.getProject(id);
currentProject = { id: p.id, name: p.name };
lastSnapshotAt = 0;
$("project-name").value = p.name;
$("project-select").value = String(id);
history.replaceState({}, "", `?p=${id}`);
applyState(p.data || {});
setSaveStatus("saved", "loaded");
setTimeout(() => setSaveStatus("", "—"), 1500);
} catch (e) {
alert(`Open project failed: ${e.message}`);
}
}
async function ensureProject() {
if (currentProject) return;
const name = `Untitled ${new Date().toLocaleString()}`;
try {
const r = await Api.createProject(name, _serialize());
currentProject = { id: r.id, name: r.name };
$("project-name").value = r.name;
history.replaceState({}, "", `?p=${r.id}`);
await refreshProjectList();
} catch (e) {
console.error("ensureProject:", e);
}
}
// ---- auto-save ----------------------------------------------------------
function scheduleAutoSave() {
isDirty = true;
setSaveStatus("dirty", "● unsaved");
clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(performAutoSave, 1500);
}
async function performAutoSave() {
await ensureProject();
if (!currentProject) return;
if (isSaving) { pendingSave = true; return; }
isSaving = true;
setSaveStatus("saving", "saving…");
try {
const now = Date.now();
const shouldSnapshot = now - lastSnapshotAt > SNAPSHOT_INTERVAL_MS;
await Api.updateProject(currentProject.id, {
data: _serialize(),
snapshot: shouldSnapshot,
note: shouldSnapshot ? "auto-save" : null,
});
if (shouldSnapshot) lastSnapshotAt = now;
isDirty = false;
setSaveStatus("saved", "✓ saved");
// Don't clear the indicator — keep "saved" visible until the next change.
} catch (e) {
setSaveStatus("error", `error: ${e.message}`);
} finally {
isSaving = false;
if (pendingSave) {
pendingSave = false;
scheduleAutoSave();
}
}
}
// ---- manual save + unload guard ----------------------------------------
$("btn-save-now").addEventListener("click", async () => {
clearTimeout(autoSaveTimer);
await performAutoSave();
});
window.addEventListener("beforeunload", (e) => {
if (isDirty) {
// Some browsers ignore custom messages but show a generic warning.
e.preventDefault();
e.returnValue = "You have unsaved changes. Leave anyway?";
return e.returnValue;
}
});
// ---- server: presets ----------------------------------------------------
async function refreshPresetList() {
try {
const list = await Api.listPresets();
const sel = $("preset-select");
const keep = sel.value;
sel.innerHTML = '<option value="">— saved preset —</option>' +
list.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join("");
sel.value = keep;
} catch (e) {
console.error("refreshPresetList:", e);
}
}
// ---- live STEP preview --------------------------------------------------
$("step-preview-collapse").addEventListener("click", () => {
$("step-preview-wrap").classList.toggle("collapsed");
// After the pane resizes, canvas may need to refit — the ResizeObserver
// in viewport.js handles the backing-store, but tx/ty/scale are cached.
requestAnimationFrame(() => Viewport.fitToContent());
});
$("step-preview-toggle").addEventListener("change", (e) => {
stepPreviewEnabled = e.target.checked;
if (stepPreviewEnabled) updateStepPreview();
else {
$("step-preview-content").innerHTML =
'<div class="step-preview-empty">Live preview disabled.</div>';
$("step-preview-status").textContent = "off";
}
});
function schedulePreview() {
if (!stepPreviewEnabled) return;
clearTimeout(stepPreviewTimer);
stepPreviewTimer = setTimeout(updateStepPreview, 400);
}
async function updateStepPreview() {
const content = $("step-preview-content");
const status = $("step-preview-status");
if (!state.busbars.length) {
content.innerHTML =
'<div class="step-preview-empty">Create a busbar to see the exported geometry here.</div>';
status.textContent = "—";
return;
}
status.textContent = "loading…";
const reqId = Symbol("preview");
stepPreviewInFlight = reqId;
try {
const payload = Exporter.buildPayload(state, params);
const res = await fetch("/api/export/svg", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (stepPreviewInFlight !== reqId) return;
if (!res.ok) {
let err = res.statusText;
try { err = (await res.json()).error || err; } catch {}
content.innerHTML = `<div class="step-preview-err">${escapeHtml(err)}</div>`;
status.textContent = "error";
return;
}
const svgText = await res.text();
if (stepPreviewInFlight !== reqId) return;
content.innerHTML = svgText;
const svgEl = content.querySelector("svg");
if (svgEl) {
svgEl.removeAttribute("width");
svgEl.removeAttribute("height");
}
status.textContent = `ok (${Math.round(svgText.length / 1024)} kB)`;
} catch (e) {
content.innerHTML = `<div class="step-preview-err">${escapeHtml(e.message)}</div>`;
status.textContent = "error";
}
}
// ---- init ---------------------------------------------------------------
async function init() {
await Promise.all([refreshProjectList(), refreshPresetList()]);
// URL routing: ?p=<id> opens that project.
const urlPid = new URLSearchParams(location.search).get("p");
if (urlPid && /^\d+$/.test(urlPid)) {
try { await openProject(+urlPid); }
catch (e) {
// Project doesn't exist anymore — strip query and show empty.
history.replaceState({}, "", location.pathname);
currentProject = null;
updateStatus();
renderBusbarList();
updateSelInfo();
}
} else {
updateStatus();
renderBusbarList();
updateSelInfo();
}
}
init();
})();
+60
View File
@@ -0,0 +1,60 @@
/* exporter.js — collect busbars into the backend payload and download file. */
const Exporter = (() => {
function buildPayload(state, params) {
const cellsById = new Map(state.cells.map((c) => [c.id, c]));
const busbars = state.busbars.map((bb) => ({
name: bb.name,
color: bb.color,
shape: bb.shape || "panel",
strip_width: params.stripWidth,
pad_radius: params.padRadius,
hole_radius: params.holeRadius,
hole_shape: params.holeShape || "cross",
slit_width: params.slitWidth,
neighbor_factor: params.neighborFactor,
cells: bb.cells
.map((cid) => {
const c = cellsById.get(cid);
return c ? { id: c.id, x: c.x, y: c.y } : null;
})
.filter(Boolean),
}));
return {
units: "mm",
extrude: !!params.extrude,
thickness: params.thickness,
busbars,
};
}
async function exportFormat(fmt, state, params) {
if (!state.busbars.length) {
alert("Create at least one busbar before exporting.");
return;
}
const payload = buildPayload(state, params);
const res = await fetch(`/api/export/${fmt}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
let msg;
try { msg = (await res.json()).error; } catch { msg = await res.text(); }
alert(`Export failed: ${msg}`);
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `busbars.${fmt}`;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
}
return { exportFormat, buildPayload };
})();
+144
View File
@@ -0,0 +1,144 @@
/* geometry.js frontend-side preview of busbar shapes.
*
* Mirrors busbar_export.py exactly so the canvas preview is what the STEP
* will contain.
*
* panel = union of pad discs at every cell + stadium bridges between every
* pair of cells that are neighbors (distance neighborFactor ×
* min_pair_distance). Concave selections (L/U/T/...) hug their cells.
*
* wire = polyline strip of strip_width with pad discs at each cell.
*
* Welding windows (busbarHolesPath) are either:
* - cross = two perpendicular slits of length 2·hole_radius, width slit_width
* - circle = single disc of radius hole_radius
*/
const Geometry = (() => {
function neighborEdges(pts, factor) {
const n = pts.length;
if (n < 2) return [];
let minD = Infinity;
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const dx = pts[j][0] - pts[i][0], dy = pts[j][1] - pts[i][1];
const d = Math.hypot(dx, dy);
if (d > 1e-9 && d < minD) minD = d;
}
}
if (!isFinite(minD)) return [];
const thr = minD * factor;
const out = [];
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const dx = pts[j][0] - pts[i][0], dy = pts[j][1] - pts[i][1];
const d = Math.hypot(dx, dy);
if (d > 1e-9 && d <= thr) out.push([i, j]);
}
}
return out;
}
function busbarPath(busbar, cellsById, params) {
return (busbar.shape === "wire")
? wirePath(busbar, cellsById, params)
: panelPath(busbar, cellsById, params);
}
/** Welding windows path. Caller fills with destination-out to punch. */
function busbarHolesPath(busbar, cellsById, params) {
const path = new Path2D();
const cells = busbar.cells.map((cid) => cellsById.get(cid)).filter(Boolean);
if (params.holeShape === "circle") {
for (const c of cells) {
path.moveTo(c.x + params.holeRadius, c.y);
path.arc(c.x, c.y, params.holeRadius, 0, Math.PI * 2);
}
} else {
// cross
const halfW = Math.max(0.05, params.slitWidth / 2);
const halfL = params.holeRadius;
for (const c of cells) {
_addRectXY(path, c.x, c.y, 2 * halfL, 2 * halfW); // horizontal arm
_addRectXY(path, c.x, c.y, 2 * halfW, 2 * halfL); // vertical arm
}
}
return path;
}
function wirePath(busbar, cellsById, params) {
const path = new Path2D();
const cells = busbar.cells.map((cid) => cellsById.get(cid)).filter(Boolean);
for (const c of cells) {
path.moveTo(c.x + params.padRadius, c.y);
path.arc(c.x, c.y, params.padRadius, 0, Math.PI * 2);
}
for (let i = 0; i < cells.length - 1; i++) {
_addRect(path, cells[i], cells[i + 1], params.stripWidth);
}
return path;
}
/** Panel = disc at every cell + stadium between every neighbor pair. */
function panelPath(busbar, cellsById, params) {
const path = new Path2D();
const cells = busbar.cells.map((cid) => cellsById.get(cid)).filter(Boolean);
if (!cells.length) return path;
// Discs.
for (const c of cells) {
path.moveTo(c.x + params.padRadius, c.y);
path.arc(c.x, c.y, params.padRadius, 0, Math.PI * 2);
}
if (cells.length < 2) return path;
// Neighbor bridges — narrow connector (strip_width), not 2*pad_radius —
// this gives the dog-bone shape and a real gap to neighboring busbars.
const pts = cells.map((c) => [c.x, c.y]);
const edges = neighborEdges(pts, params.neighborFactor || 1.15);
for (const [i, j] of edges) {
_addRect(path,
{ x: pts[i][0], y: pts[i][1] },
{ x: pts[j][0], y: pts[j][1] },
params.stripWidth);
}
return path;
}
/** Rectangle from a to b of given width, as a closed Path2D sub-path.
*
* Vertex order matches Path2D.arc() winding so all sub-paths in the busbar
* body wind the SAME direction. With ctx.fill(path, "nonzero") same-winding
* subpaths union (no fill cancellation in overlap regions). If the winding
* differs from arc(), overlap regions sum to 0 and appear as holes the
* 'tangled black gaps inside the busbar' bug.
*/
function _addRect(path, a, b, width) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < 1e-9) return;
const ux = dx / len, uy = dy / len;
const px = -uy, py = ux;
const hw = width / 2;
// Order: above-A → below-A → below-B → above-B → close.
path.moveTo(a.x + px * hw, a.y + py * hw);
path.lineTo(a.x - px * hw, a.y - py * hw);
path.lineTo(b.x - px * hw, b.y - py * hw);
path.lineTo(b.x + px * hw, b.y + py * hw);
path.closePath();
}
/** Axis-aligned rectangle centered at (cx, cy). */
function _addRectXY(path, cx, cy, w, h) {
const hw = w / 2, hh = h / 2;
path.moveTo(cx - hw, cy - hh);
path.lineTo(cx + hw, cy - hh);
path.lineTo(cx + hw, cy + hh);
path.lineTo(cx - hw, cy + hh);
path.closePath();
}
return { busbarPath, busbarHolesPath, neighborEdges, panelPath, wirePath };
})();
+83
View File
@@ -0,0 +1,83 @@
/* groups.js busbar (parallel cell group) CRUD.
*
* A Busbar is { id, name, color, cells: [cellId, ] }.
* Cell `assigned` lookup is maintained so the canvas can render colors.
*/
const Groups = (() => {
// Friendly hue palette — rotates as new busbars are added.
const COLORS = [
"#f08a24", "#4fa3ff", "#3ecf8e", "#e85aad",
"#a78bfa", "#f5d142", "#52d6c6", "#ff6b6b",
"#7cb342", "#ba68c8", "#26c6da", "#ffa726",
];
let nextId = 1;
let nextNameIdx = 1;
function create(state, cellIds) {
if (!cellIds || cellIds.length === 0) return null;
const id = nextId++;
const bb = {
id,
name: `P${nextNameIdx++}`,
color: COLORS[(id - 1) % COLORS.length],
shape: "panel", // "panel" (default, production plate) | "wire"
cells: [...cellIds],
};
state.busbars.push(bb);
_reassign(state);
return bb;
}
function remove(state, busbarId) {
state.busbars = state.busbars.filter((b) => b.id !== busbarId);
_reassign(state);
}
function rename(state, busbarId, name) {
const bb = state.busbars.find((b) => b.id === busbarId);
if (bb) bb.name = name;
}
function recolor(state, busbarId, color) {
const bb = state.busbars.find((b) => b.id === busbarId);
if (bb) bb.color = color;
}
function addCells(state, busbarId, cellIds) {
const bb = state.busbars.find((b) => b.id === busbarId);
if (!bb) return;
const set = new Set(bb.cells);
for (const id of cellIds) {
if (!set.has(id)) { bb.cells.push(id); set.add(id); }
}
_reassign(state);
}
function removeCells(state, busbarId, cellIds) {
const bb = state.busbars.find((b) => b.id === busbarId);
if (!bb) return;
const rm = new Set(cellIds);
bb.cells = bb.cells.filter((id) => !rm.has(id));
_reassign(state);
}
function _reassign(state) {
state.cellToBusbar = new Map();
for (const bb of state.busbars) {
for (const cid of bb.cells) state.cellToBusbar.set(cid, bb.id);
}
}
function findByCell(state, cellId) {
return state.cellToBusbar.get(cellId);
}
function reset() {
nextId = 1;
nextNameIdx = 1;
}
return { create, remove, rename, recolor, addCells, removeCells, findByCell, reset };
})();
+111
View File
@@ -0,0 +1,111 @@
/* importer.js — parse OpenSCAD ECHO / CSV / JSON / parametric generator.
*
* All parsers return an array of {id, x, y} in millimetres.
* Generator formulas are 1:1 mirrors of `get_hex_center_points_*` from
* Addy's hex_cell.scad (see CLAUDE.md for the cheat sheet).
*/
const Importer = (() => {
const COS30 = Math.cos(Math.PI / 6);
/** Parse OpenSCAD ECHO lines or loose `Cell N: x = …, y = …`. */
function parsePaste(text) {
const out = [];
const re = /Cell\s*(\d+)\s*:\s*x\s*=\s*(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s*,\s*y\s*=\s*(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/g;
let m;
while ((m = re.exec(text)) !== null) {
out.push({ id: parseInt(m[1], 10), x: parseFloat(m[2]), y: parseFloat(m[3]) });
}
if (out.length) return out;
// Fallback: lines of form `index x y` or `index, x, y` (any separator).
const lines = text.split(/\r?\n/);
let auto = 1;
for (const line of lines) {
const nums = line.match(/-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?/g);
if (!nums) continue;
if (nums.length >= 3) {
out.push({ id: parseInt(nums[0], 10), x: parseFloat(nums[1]), y: parseFloat(nums[2]) });
} else if (nums.length === 2) {
out.push({ id: auto++, x: parseFloat(nums[0]), y: parseFloat(nums[1]) });
}
}
return out;
}
/** CSV with optional header. Columns: index,x,y OR x,y. */
function parseCSV(text) {
const out = [];
const lines = text.trim().split(/\r?\n/).filter(Boolean);
let auto = 1;
for (const raw of lines) {
const cols = raw.split(/[,;\t]/).map((s) => s.trim());
// Skip header rows that don't parse as numbers.
if (cols.length >= 2 && isNaN(parseFloat(cols[0])) && isNaN(parseFloat(cols[1]))) continue;
if (cols.length >= 3) {
const id = parseInt(cols[0], 10);
const x = parseFloat(cols[1]);
const y = parseFloat(cols[2]);
if (!isNaN(x) && !isNaN(y)) out.push({ id: isNaN(id) ? auto++ : id, x, y });
} else if (cols.length === 2) {
const x = parseFloat(cols[0]);
const y = parseFloat(cols[1]);
if (!isNaN(x) && !isNaN(y)) out.push({ id: auto++, x, y });
}
}
return out;
}
/** JSON: array of objects {id,x,y} OR array of [x,y]. */
function parseJSON(text) {
const j = JSON.parse(text);
if (!Array.isArray(j)) throw new Error("JSON must be a top-level array");
return j.map((item, i) => {
if (Array.isArray(item)) {
return { id: i + 1, x: +item[0], y: +item[1] };
}
return {
id: item.id != null ? +item.id : i + 1,
x: +item.x,
y: +item.y,
};
});
}
/** Generator — mirrors get_hex_center_points_{rect,para,tria} from hex_cell.scad. */
function generate({ cellDia, wall, rows, cols, style }) {
const hex_w = cellDia + 2 * wall;
const hex_pt = (hex_w / 2) / COS30;
const rowY = (r) => r * 1.5 * hex_pt;
const out = [];
let id = 1;
if (style === "rect") {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = (r % 2 === 0) ? hex_w * c : 0.5 * hex_w + hex_w * c;
out.push({ id: id++, x, y: rowY(r) });
}
}
} else if (style === "para") {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = r * 0.5 * hex_w + hex_w * c;
out.push({ id: id++, x, y: rowY(r) });
}
}
} else if (style === "tria") {
for (let r = 0; r < rows; r++) {
for (let c = 0; c <= r; c++) {
const x = r * 0.5 * hex_w - hex_w * c;
out.push({ id: id++, x, y: rowY(r) });
}
}
} else {
throw new Error(`unknown style: ${style}`);
}
return out;
}
return { parsePaste, parseCSV, parseJSON, generate };
})();
+320
View File
@@ -0,0 +1,320 @@
/* viewport.js 2D canvas viewer with real mm scale, pan, zoom, cell picking.
*
* Coordinates in app state are millimetres; we keep a pan (tx, ty in px) and a
* scale (px-per-mm). Y axis is flipped on render so +Y points up like CAD.
*/
const Viewport = (() => {
let canvas, ctx, state, params;
let scale = 4; // px per mm
let tx = 0, ty = 0; // pan offset, in pixels (added after scaling)
let isPanning = false;
let panStart = null;
let cellHoverId = null;
// Callbacks set by app.js.
let onCellClick = () => {};
let onCursorMove = () => {};
let onZoomChange = () => {};
function init(canvasEl, stateRef, paramsRef, handlers) {
canvas = canvasEl;
ctx = canvas.getContext("2d");
state = stateRef;
params = paramsRef;
onCellClick = handlers.onCellClick || onCellClick;
onCursorMove = handlers.onCursorMove || onCursorMove;
onZoomChange = handlers.onZoomChange || onZoomChange;
window.addEventListener("resize", _resize);
// Catch any layout change (flex re-flow, sidebar toggle, etc.) — the
// canvas backing-store must match its CSS size or content draws blurry /
// mis-positioned ('cells off-screen' bug).
if (typeof ResizeObserver !== "undefined") {
new ResizeObserver(_resize).observe(canvas);
}
canvas.addEventListener("mousedown", _onMouseDown);
canvas.addEventListener("mousemove", _onMouseMove);
canvas.addEventListener("mouseup", _onMouseUp);
canvas.addEventListener("mouseleave", _onMouseUp);
canvas.addEventListener("wheel", _onWheel, { passive: false });
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
_resize();
}
function _resize() {
const dpr = window.devicePixelRatio || 1;
const r = canvas.getBoundingClientRect();
canvas.width = Math.max(1, Math.round(r.width * dpr));
canvas.height = Math.max(1, Math.round(r.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // user space = CSS pixels
render();
}
function fitToContent() {
// Defer one frame so any pending layout (flex re-flow when busbar list
// grows, preview pane appears, etc.) is computed before we measure
// canvas dimensions. Without this, clientWidth/Height can be 0 → cells
// end up off-screen.
requestAnimationFrame(_fitToContentNow);
}
function _fitToContentNow() {
_resize();
if (!state.cells || state.cells.length === 0) {
tx = canvas.clientWidth / 2;
ty = canvas.clientHeight / 2;
scale = 4;
onZoomChange(scale);
render();
return;
}
const cellRadius = (params.cellDia || 21.2) / 2;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const c of state.cells) {
if (c.x < minX) minX = c.x; if (c.x > maxX) maxX = c.x;
if (c.y < minY) minY = c.y; if (c.y > maxY) maxY = c.y;
}
minX -= cellRadius; maxX += cellRadius;
minY -= cellRadius; maxY += cellRadius;
const w = maxX - minX, h = maxY - minY;
const margin = 40;
const sx = (canvas.clientWidth - 2 * margin) / w;
const sy = (canvas.clientHeight - 2 * margin) / h;
scale = Math.max(0.5, Math.min(40, Math.min(sx, sy)));
// World point at canvas center should be (minX+w/2, minY+h/2).
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
tx = canvas.clientWidth / 2 - cx * scale;
ty = canvas.clientHeight / 2 + cy * scale; // +cy*scale because Y is flipped
onZoomChange(scale);
render();
}
// World (mm) → screen (px)
function w2s(x, y) {
return { x: x * scale + tx, y: -y * scale + ty };
}
// Screen → world
function s2w(px, py) {
return { x: (px - tx) / scale, y: -(py - ty) / scale };
}
function render() {
if (!ctx) return;
const W = canvas.clientWidth, H = canvas.clientHeight;
ctx.clearRect(0, 0, W, H);
_drawGrid(W, H);
_drawCells();
_drawBusbars();
_drawCellLabels(); // labels on top so the cross-slit punch doesn't eat them
_drawSelection();
}
function _drawGrid(W, H) {
// Minor grid every 5 mm, major every 50 mm.
const minor = 5, major = 50;
const tl = s2w(0, 0), br = s2w(W, H);
const xMin = Math.floor(Math.min(tl.x, br.x) / minor) * minor;
const xMax = Math.ceil (Math.max(tl.x, br.x) / minor) * minor;
const yMin = Math.floor(Math.min(tl.y, br.y) / minor) * minor;
const yMax = Math.ceil (Math.max(tl.y, br.y) / minor) * minor;
ctx.lineWidth = 1;
for (let x = xMin; x <= xMax; x += minor) {
const p = w2s(x, 0);
ctx.strokeStyle = (Math.abs(x % major) < 1e-6) ? "#2a3140" : "#1f2530";
ctx.beginPath();
ctx.moveTo(p.x, 0); ctx.lineTo(p.x, H); ctx.stroke();
}
for (let y = yMin; y <= yMax; y += minor) {
const p = w2s(0, y);
ctx.strokeStyle = (Math.abs(y % major) < 1e-6) ? "#2a3140" : "#1f2530";
ctx.beginPath();
ctx.moveTo(0, p.y); ctx.lineTo(W, p.y); ctx.stroke();
}
// Origin marker.
const o = w2s(0, 0);
ctx.strokeStyle = "#ff6b6b";
ctx.beginPath();
ctx.moveTo(o.x - 8, o.y); ctx.lineTo(o.x + 8, o.y);
ctx.moveTo(o.x, o.y - 8); ctx.lineTo(o.x, o.y + 8);
ctx.stroke();
}
function _drawCells() {
const r = (params.cellDia || 21.2) / 2;
const o = (params.openingDia || 15.2) / 2;
const hexW = (params.cellDia || 21.2) + 2 * 0.8;
const hexPt = hexW / 2 / Math.cos(Math.PI / 6);
ctx.lineWidth = 1;
// For cells assigned to a busbar we draw NOTHING here — the busbar layer
// owns those pixels entirely (fill + cross slit + label). Drawing hex /
// circle outlines under the busbar muddies the preview.
for (const c of state.cells) {
if (Groups.findByCell(state, c.id) != null) continue;
const p = w2s(c.x, c.y);
const rPx = r * scale;
const oPx = o * scale;
// Hex outline.
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const a = Math.PI / 2 + i * Math.PI / 3;
const hx = p.x + hexPt * scale * Math.cos(a);
const hy = p.y - hexPt * scale * Math.sin(a);
if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy);
}
ctx.closePath();
ctx.strokeStyle = "#262d3a";
ctx.stroke();
// Cell circle + opening hint (so the user sees the welding window).
ctx.beginPath();
ctx.arc(p.x, p.y, rPx, 0, Math.PI * 2);
ctx.fillStyle = _withAlpha("#4a5365", 0.18);
ctx.fill();
ctx.strokeStyle = "#4a5365";
ctx.stroke();
ctx.fillStyle = _withAlpha("#4a5365", 0.35);
ctx.beginPath();
ctx.arc(p.x, p.y, oPx, 0, Math.PI * 2);
ctx.fill();
}
}
function _drawCellLabels() {
if (scale <= 2) return;
ctx.font = `${Math.max(9, Math.min(14, scale * 1.5))}px -apple-system, system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (const c of state.cells) {
const p = w2s(c.x, c.y);
const inBusbar = Groups.findByCell(state, c.id) != null;
ctx.fillStyle = inBusbar ? "#1a1a1a" : "#cdd5e0";
ctx.fillText(String(c.id), p.x, p.y);
}
}
function _drawBusbars() {
if (!state.busbars.length) return;
const cellsById = new Map(state.cells.map((c) => [c.id, c]));
const dpr = window.devicePixelRatio || 1;
ctx.save();
// Set transform so we can draw busbar Path2D in world (mm) coords.
// screen.x = scale*world.x + tx ; screen.y = -scale*world.y + ty
ctx.setTransform(dpr * scale, 0, 0, -dpr * scale, dpr * tx, dpr * ty);
for (const bb of state.busbars) {
const params2 = {
padRadius: params.padRadius,
holeRadius: params.holeRadius,
stripWidth: params.stripWidth,
};
const body = Geometry.busbarPath(bb, cellsById, params2);
const holes = Geometry.busbarHolesPath(bb, cellsById, params2);
// Body: union semantics — fill nonzero so overlapping rectangles/discs
// don't carve evenodd holes. Higher opacity than v1 so the panel reads
// as a single object, not a translucent veil.
ctx.fillStyle = _withAlpha(bb.color, 0.75);
ctx.fill(body, "nonzero");
// Holes: punch via destination-out so the dark canvas shows through.
ctx.save();
ctx.globalCompositeOperation = "destination-out";
ctx.fill(holes, "nonzero");
ctx.restore();
}
ctx.restore();
}
function _drawSelection() {
if (!state.selection.size) return;
const r = ((params.cellDia || 21.2) / 2 + 1.5);
ctx.lineWidth = 2;
ctx.strokeStyle = "#f08a24";
for (const id of state.selection) {
const c = state.cells.find((cc) => cc.id === id);
if (!c) continue;
const p = w2s(c.x, c.y);
ctx.beginPath();
ctx.arc(p.x, p.y, r * scale, 0, Math.PI * 2);
ctx.stroke();
}
}
function _withAlpha(hex, a) {
const s = hex.replace("#", "");
const r = parseInt(s.slice(0, 2), 16);
const g = parseInt(s.slice(2, 4), 16);
const b = parseInt(s.slice(4, 6), 16);
return `rgba(${r},${g},${b},${a})`;
}
function _pickCell(px, py) {
const r = (params.cellDia || 21.2) / 2;
let best = null, bestDist = Infinity;
for (const c of state.cells) {
const p = w2s(c.x, c.y);
const d = Math.hypot(p.x - px, p.y - py);
if (d < r * scale && d < bestDist) { best = c; bestDist = d; }
}
return best;
}
function _onMouseDown(e) {
const rect = canvas.getBoundingClientRect();
const px = e.clientX - rect.left, py = e.clientY - rect.top;
if (e.button === 2 || e.button === 1) {
isPanning = true;
panStart = { px, py, tx, ty };
canvas.style.cursor = "grabbing";
return;
}
if (e.button === 0) {
const c = _pickCell(px, py);
if (c) onCellClick(c.id, { shift: e.shiftKey, alt: e.altKey, ctrl: e.ctrlKey });
}
}
function _onMouseMove(e) {
const rect = canvas.getBoundingClientRect();
const px = e.clientX - rect.left, py = e.clientY - rect.top;
if (isPanning && panStart) {
tx = panStart.tx + (px - panStart.px);
ty = panStart.ty + (py - panStart.py);
render();
return;
}
const w = s2w(px, py);
onCursorMove(w.x, w.y);
}
function _onMouseUp() {
isPanning = false;
panStart = null;
canvas.style.cursor = "crosshair";
}
function _onWheel(e) {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const px = e.clientX - rect.left, py = e.clientY - rect.top;
const pre = s2w(px, py);
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
scale = Math.max(0.5, Math.min(80, scale * factor));
// Keep cursor mm-point stationary.
tx = px - pre.x * scale;
ty = py + pre.y * scale;
onZoomChange(scale);
render();
}
return { init, render, fitToContent };
})();
+532
View File
@@ -0,0 +1,532 @@
:root {
--bg: #0f1115;
--panel: #161a22;
--panel-2: #1e242f;
--border: #2a313d;
--text: #e4e7ec;
--muted: #8a93a3;
--accent: #f08a24;
--accent-2: #4fa3ff;
--danger: #d94a4a;
--grid: #1f2530;
--grid-major: #2a3140;
--cell: #4a5365;
--cell-sel: #f08a24;
--cell-busbar: #4fa3ff;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
body {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden; /* never let the page itself scroll — flex children handle their own overflow */
}
/* ---- topbar ---- */
.topbar {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 16px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.topbar h1 {
margin: 0;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.02em;
}
.topbar .status {
color: var(--muted);
font-size: 13px;
}
.topbar .actions {
margin-left: auto;
display: flex;
gap: 8px;
align-items: center;
}
.project-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 0 12px;
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
}
.project-bar select,
.project-bar input[type=text] {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 8px;
font: inherit;
font-size: 13px;
width: auto;
min-width: 120px;
}
.project-bar select { min-width: 140px; }
.save-status {
color: var(--muted);
font-size: 11px;
font-family: "SF Mono", Consolas, monospace;
margin-left: 4px;
min-width: 50px;
}
.topbar-status {
color: var(--muted);
font-size: 12px;
padding-left: 8px;
margin-left: 4px;
border-left: 1px solid var(--border);
white-space: nowrap;
}
.save-status.dirty { color: var(--accent); }
.save-status.saving { color: var(--accent-2); }
.save-status.saved { color: #3ecf8e; }
.save-status.error { color: var(--danger); }
button.danger {
background: var(--panel);
color: var(--danger);
border-color: var(--danger);
}
button.danger:hover {
background: var(--danger);
color: #fff;
}
/* ---- modal ---- */
.modal {
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
}
.modal.hidden { display: none; }
.modal-content {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
max-width: 560px;
width: 90vw;
max-height: 80vh;
display: flex; flex-direction: column;
overflow: hidden;
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px;
background: var(--panel-2);
border-bottom: 1px solid var(--border);
}
.modal-header h3 { margin: 0; font-size: 14px; }
.modal-close {
background: transparent; border: 0;
color: var(--muted); font-size: 20px; line-height: 1;
cursor: pointer; padding: 0 4px;
}
.modal-close:hover { color: var(--text); }
.modal-body {
padding: 12px 14px;
overflow-y: auto;
}
.history-list {
list-style: none; margin: 0; padding: 0;
}
.history-item {
display: flex; align-items: center; gap: 10px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 4px;
background: var(--bg);
}
.history-item .time {
font-family: "SF Mono", Consolas, monospace;
font-size: 12px;
color: var(--muted);
min-width: 140px;
}
.history-item .note { flex: 1; color: var(--text); font-size: 12px; }
.history-item button { padding: 2px 8px; font-size: 11px; }
.topbar .sep {
width: 1px;
height: 22px;
background: var(--border);
margin: 0 6px;
}
/* ---- main grid ---- */
main {
display: flex;
flex: 1;
min-height: 0;
}
.left {
width: 380px;
flex-shrink: 0;
background: var(--panel);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 12px;
}
.right {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--bg);
}
.viewport-wrap {
flex: 1 1 auto;
min-height: 0;
position: relative;
overflow: hidden;
}
.step-preview-wrap {
flex: 0 0 auto;
border-top: 1px solid var(--border);
background: var(--panel);
display: flex;
flex-direction: column;
overflow: hidden;
height: 280px;
}
.step-preview-wrap.collapsed {
height: auto;
}
.step-preview-wrap.collapsed .step-preview {
display: none;
}
.step-preview-wrap.collapsed .step-preview-collapse {
transform: rotate(180deg);
}
.step-preview-collapse {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0 6px;
font-size: 10px;
line-height: 1;
cursor: pointer;
margin-right: 4px;
}
.step-preview-collapse:hover { color: var(--text); }
.step-preview-header {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 12px;
border-bottom: 1px solid var(--border);
background: var(--panel-2);
font-size: 12px;
}
.step-preview-header .hint { margin: 0; font-size: 11px; }
.step-preview {
flex: 1;
overflow: auto;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
}
.step-preview svg {
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
}
.step-preview-empty,
.step-preview-err {
color: var(--muted);
font-size: 12px;
padding: 16px;
text-align: center;
}
.step-preview-err { color: var(--danger); }
.preset-row {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.preset-row select { flex: 1; min-width: 140px; }
.preset-row button { padding: 4px 8px; font-size: 11px; }
.preset-row .hint { margin: 0; font-size: 11px; flex-basis: 100%; }
#viewport {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.viewport-overlay {
position: absolute;
bottom: 8px;
left: 12px;
display: flex;
gap: 16px;
pointer-events: none;
color: var(--muted);
font-size: 12px;
font-family: "SF Mono", Consolas, monospace;
background: rgba(15, 17, 21, 0.6);
padding: 4px 8px;
border-radius: 4px;
}
/* ---- panels ---- */
.panel {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
}
.panel h2 {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
.hint {
color: var(--muted);
font-size: 12px;
margin: 0 0 8px;
}
.hint code {
background: rgba(255,255,255,0.05);
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
}
/* ---- tabs ---- */
.tabs {
display: flex;
gap: 2px;
margin-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.tab {
flex: 1;
background: transparent;
color: var(--muted);
border: 0;
border-bottom: 2px solid transparent;
padding: 6px 8px;
cursor: pointer;
font-size: 12px;
}
.tab.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.tab-body { display: block; }
.tab-body.hidden { display: none; }
/* ---- forms ---- */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
label {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
label.checkbox {
flex-direction: row;
align-items: center;
gap: 6px;
text-transform: none;
letter-spacing: 0;
font-size: 12px;
color: var(--text);
}
input[type=number], input[type=text], select, textarea {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 8px;
font: inherit;
width: 100%;
}
textarea {
resize: vertical;
font-family: "SF Mono", Consolas, monospace;
font-size: 12px;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--accent-2);
}
button {
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
font: inherit;
}
button:hover { background: var(--panel-2); }
button:active { transform: translateY(1px); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.primary {
background: var(--accent);
border-color: var(--accent);
color: #1a1a1a;
font-weight: 600;
}
button.primary:hover { background: #ff9933; }
.tab-body button {
margin-top: 8px;
}
/* ---- busbars ---- */
.busbar-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.sel-info {
color: var(--muted);
font-size: 12px;
margin-left: auto;
}
.busbar-list {
list-style: none;
margin: 0;
padding: 0;
}
.busbar-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 6px;
}
.busbar-item.active {
border-color: var(--accent);
}
.busbar-color {
width: 16px;
height: 16px;
border-radius: 4px;
cursor: pointer;
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.2);
}
.busbar-name {
flex: 1;
background: transparent;
border: 0;
color: var(--text);
padding: 2px 4px;
font: inherit;
}
.busbar-name:focus { background: var(--panel); border-radius: 3px; }
.busbar-count {
color: var(--muted);
font-size: 11px;
font-family: "SF Mono", Consolas, monospace;
}
.busbar-shape {
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
border-radius: 3px;
font-size: 11px;
padding: 1px 4px;
width: auto;
}
.busbar-actions { display: flex; gap: 4px; }
.busbar-actions button {
padding: 2px 6px;
font-size: 11px;
}
.busbar-actions .del { color: var(--danger); border-color: var(--danger); }
@media (max-width: 1000px) {
.left { width: 320px; }
}
+260
View File
@@ -0,0 +1,260 @@
"""SQLite storage for Busbar Designer.
One file (`data/busbar.db` by default; override with `BUSBAR_DB` env var).
Three tables:
projects full editor state (cells + busbars + params) per project.
presets named param sets the user can apply to any project.
snapshots per-project history; auto-pruned to the last N (env
SNAPSHOT_RETENTION, default 20).
Connection model: opens a fresh sqlite3 connection per call inside a context
manager. Cheap (< 1ms) and avoids worker-shared state issues with gunicorn.
"""
from __future__ import annotations
import json
import os
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterator
DB_PATH = Path(os.environ.get("BUSBAR_DB", "data/busbar.db"))
SNAPSHOT_RETENTION = int(os.environ.get("SNAPSHOT_RETENTION", "20"))
@contextmanager
def _conn() -> Iterator[sqlite3.Connection]:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
c = sqlite3.connect(str(DB_PATH))
c.row_factory = sqlite3.Row
c.execute("PRAGMA foreign_keys = ON")
try:
yield c
c.commit()
finally:
c.close()
def init_db() -> None:
with _conn() as c:
c.executescript("""
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
params TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
data TEXT NOT NULL,
note TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_snap_proj
ON snapshots(project_id, created_at DESC);
""")
# ---------------------------------------------------------------------------
# Projects
# ---------------------------------------------------------------------------
def list_projects() -> list[dict]:
with _conn() as c:
rows = c.execute(
"SELECT id, name, created_at, updated_at FROM projects "
"ORDER BY updated_at DESC"
).fetchall()
return [dict(r) for r in rows]
def get_project(pid: int) -> dict | None:
with _conn() as c:
row = c.execute(
"SELECT id, name, data, created_at, updated_at FROM projects WHERE id=?",
(pid,),
).fetchone()
if row is None:
return None
d = dict(row)
d["data"] = json.loads(d["data"])
return d
def create_project(name: str, data: dict) -> int:
with _conn() as c:
cur = c.execute(
"INSERT INTO projects(name, data) VALUES(?,?)",
(name, json.dumps(data)),
)
return cur.lastrowid
def update_project(
pid: int,
name: str | None = None,
data: dict | None = None,
snapshot: bool = False,
note: str | None = None,
) -> bool:
"""Update name and/or data. If `snapshot=True`, save the prior state to history first."""
with _conn() as c:
# snapshot of the CURRENT (pre-update) state — useful for auto-save checkpoints
if snapshot:
old = c.execute("SELECT data FROM projects WHERE id=?", (pid,)).fetchone()
if old is None:
return False
c.execute(
"INSERT INTO snapshots(project_id, data, note) VALUES(?,?,?)",
(pid, old["data"], note),
)
_purge_old_snapshots(c, pid)
if name is None and data is None:
return False
fields, values = [], []
if name is not None:
fields.append("name=?")
values.append(name)
if data is not None:
fields.append("data=?")
values.append(json.dumps(data))
fields.append("updated_at=CURRENT_TIMESTAMP")
values.append(pid)
r = c.execute(
f"UPDATE projects SET {', '.join(fields)} WHERE id=?", values
)
return r.rowcount > 0
def delete_project(pid: int) -> bool:
with _conn() as c:
r = c.execute("DELETE FROM projects WHERE id=?", (pid,))
return r.rowcount > 0
# ---------------------------------------------------------------------------
# Snapshots
# ---------------------------------------------------------------------------
def list_snapshots(pid: int) -> list[dict]:
with _conn() as c:
rows = c.execute(
"SELECT id, note, created_at FROM snapshots "
"WHERE project_id=? ORDER BY created_at DESC",
(pid,),
).fetchall()
return [dict(r) for r in rows]
def get_snapshot(sid: int) -> dict | None:
with _conn() as c:
row = c.execute(
"SELECT id, project_id, data, note, created_at FROM snapshots WHERE id=?",
(sid,),
).fetchone()
if row is None:
return None
d = dict(row)
d["data"] = json.loads(d["data"])
return d
def restore_snapshot(sid: int) -> bool:
"""Copy a snapshot's data back into its parent project (preserving history)."""
snap = get_snapshot(sid)
if not snap:
return False
return update_project(
snap["project_id"],
data=snap["data"],
snapshot=True,
note="auto: before restore",
)
def _purge_old_snapshots(c: sqlite3.Connection, pid: int) -> None:
c.execute(
"""
DELETE FROM snapshots WHERE id IN (
SELECT id FROM snapshots WHERE project_id=?
ORDER BY created_at DESC
LIMIT -1 OFFSET ?
)
""",
(pid, SNAPSHOT_RETENTION),
)
# ---------------------------------------------------------------------------
# Presets
# ---------------------------------------------------------------------------
def list_presets() -> list[dict]:
with _conn() as c:
rows = c.execute(
"SELECT id, name, params, created_at FROM presets ORDER BY name"
).fetchall()
return [{**dict(r), "params": json.loads(r["params"])} for r in rows]
def get_preset(pid: int) -> dict | None:
with _conn() as c:
row = c.execute(
"SELECT id, name, params, created_at FROM presets WHERE id=?", (pid,)
).fetchone()
if row is None:
return None
d = dict(row)
d["params"] = json.loads(d["params"])
return d
def create_preset(name: str, params: dict) -> int | None:
with _conn() as c:
try:
cur = c.execute(
"INSERT INTO presets(name, params) VALUES(?,?)",
(name, json.dumps(params)),
)
return cur.lastrowid
except sqlite3.IntegrityError:
return None # name UNIQUE collision
def update_preset(pid: int, name: str | None = None, params: dict | None = None) -> bool:
with _conn() as c:
sets, vals = [], []
if name is not None:
sets.append("name=?")
vals.append(name)
if params is not None:
sets.append("params=?")
vals.append(json.dumps(params))
if not sets:
return False
vals.append(pid)
try:
r = c.execute(f"UPDATE presets SET {', '.join(sets)} WHERE id=?", vals)
except sqlite3.IntegrityError:
return False
return r.rowcount > 0
def delete_preset(pid: int) -> bool:
with _conn() as c:
r = c.execute("DELETE FROM presets WHERE id=?", (pid,))
return r.rowcount > 0
+167
View File
@@ -0,0 +1,167 @@
"""Sanity tests for busbar_export — exercise STEP/DXF/SVG writers end-to-end."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from busbar_export import to_step, to_dxf, to_svg
def _payload():
return {
"extrude": False,
"busbars": [
{
"name": "P1",
"color": "#ff8800",
"strip_width": 8.0,
"pad_radius": 7.0,
"hole_radius": 5.0,
"cells": [
{"id": 1, "x": 0.0, "y": 0.0},
{"id": 2, "x": 22.8, "y": 0.0},
{"id": 3, "x": 45.6, "y": 0.0},
],
}
],
}
def test_step_export_is_iso_10303():
data = to_step(_payload())
text = data[:200].decode("ascii", errors="ignore")
assert "ISO-10303-21" in text, f"STEP header missing, got: {text!r}"
assert len(data) > 500
def test_dxf_export_is_nonempty():
data = to_dxf(_payload())
assert b"SECTION" in data[:2000]
def test_svg_export_is_svg():
data = to_svg(_payload())
assert b"<svg" in data[:2000]
def test_extruded_step_includes_solid():
payload = _payload()
payload["extrude"] = True
payload["thickness"] = 0.2
data = to_step(payload)
assert b"MANIFOLD_SOLID_BREP" in data or b"CLOSED_SHELL" in data
def test_panel_5p_row_is_stadium():
"""5 cells in a row → collinear hull → stadium-shaped panel."""
payload = {
"busbars": [{
"name": "5P",
"shape": "panel",
"pad_radius": 10.0,
"hole_radius": 5.0,
"cells": [{"id": i + 1, "x": i * 22.8, "y": 0.0} for i in range(5)],
}]
}
data = to_step(payload)
assert b"ISO-10303-21" in data[:200]
assert len(data) > 500
def test_panel_grid_uses_convex_hull():
"""4-cell square → 4-vertex hull → rounded rectangle."""
payload = {
"busbars": [{
"name": "4P",
"shape": "panel",
"pad_radius": 10.0,
"hole_radius": 5.0,
"cells": [
{"id": 1, "x": 0.0, "y": 0.0},
{"id": 2, "x": 22.8, "y": 0.0},
{"id": 3, "x": 22.8, "y": 19.746},
{"id": 4, "x": 0.0, "y": 19.746},
],
}]
}
data = to_step(payload)
assert b"ISO-10303-21" in data[:200]
def test_panel_single_cell_is_disc_with_hole():
payload = {
"busbars": [{
"name": "1P",
"shape": "panel",
"pad_radius": 10.0,
"hole_radius": 5.0,
"cells": [{"id": 1, "x": 0.0, "y": 0.0}],
}]
}
data = to_step(payload)
assert b"ISO-10303-21" in data[:200]
def test_wire_shape_still_works():
payload = {
"busbars": [{
"name": "wire",
"shape": "wire",
"strip_width": 6.0,
"pad_radius": 7.0,
"hole_radius": 5.0,
"cells": [{"x": 0, "y": 0}, {"x": 22.8, "y": 0}],
}]
}
data = to_step(payload)
assert b"ISO-10303-21" in data[:200]
def test_panel_L_shape_has_no_diagonal_bridge():
"""L-shape (7 cells in column + 2 cells across top) must not bridge across
non-selected cells. The convex-hull approach would have, but neighbor-edge
must not verify by checking that the area of the resulting face is close
to the sum of stadium-chain segments, not the area of the L's bounding box.
"""
from busbar_export import parse_payload, busbar_sketch
cells = [{"x": 0, "y": i * 19.75} for i in range(7)]
cells += [{"x": 22.8, "y": 6 * 19.75}, {"x": 45.6, "y": 6 * 19.75}]
payload = {"busbars": [{
"name": "L", "shape": "panel",
"pad_radius": 10.0, "hole_radius": 6.0,
"hole_shape": "cross", "slit_width": 1.8,
"cells": cells,
}]}
busbars, *_ = parse_payload(payload)
face = busbar_sketch(busbars[0])
area = face.area
# Pad disc area = π·10² ≈ 314 per cell, 9 cells → 2826 max if all disjoint.
# Bridges add some, holes subtract some. The L's bounding box is
# 65.6 × 138.25 ≈ 9070 — convex hull would be at least ~4500. Neighbor
# chain should be well under 3500.
assert 2000 < area < 3500, f"area {area} suggests convex-hull bridging"
def test_cross_slit_punches_through():
"""Cross slit must remove area from the panel (vs. no holes baseline)."""
from busbar_export import parse_payload, busbar_sketch
base = {
"busbars": [{
"name": "P", "shape": "panel", "pad_radius": 10.0,
"hole_radius": 6.0, "hole_shape": "cross", "slit_width": 1.8,
"cells": [{"x": 0, "y": 0}, {"x": 22.8, "y": 0}],
}]
}
with_cross, *_ = parse_payload(base)
area_cross = busbar_sketch(with_cross[0]).area
# Same payload, tiny hole — should be nearly the same area as un-punched.
base["busbars"][0]["hole_radius"] = 0.1
base["busbars"][0]["slit_width"] = 0.1
tiny, *_ = parse_payload(base)
area_tiny = busbar_sketch(tiny[0]).area
assert area_tiny > area_cross + 50, \
f"cross didn't punch much: tiny={area_tiny:.1f}, cross={area_cross:.1f}"
+90
View File
@@ -0,0 +1,90 @@
"""Sanity tests for storage.py — uses a fresh in-memory-ish DB per test."""
import os
import sys
from pathlib import Path
# Point the storage module at a tmp DB before importing it.
TMP_DB = Path(__file__).parent / "_tmp_storage.db"
os.environ["BUSBAR_DB"] = str(TMP_DB)
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import storage # noqa: E402
def setup_function(_):
if TMP_DB.exists():
TMP_DB.unlink()
storage.init_db()
def teardown_module(_):
if TMP_DB.exists():
TMP_DB.unlink()
def test_project_crud():
pid = storage.create_project("test", {"cells": [{"x": 0, "y": 0}]})
assert pid > 0
p = storage.get_project(pid)
assert p["name"] == "test"
assert p["data"]["cells"][0]["x"] == 0
assert storage.update_project(pid, name="renamed")
assert storage.get_project(pid)["name"] == "renamed"
assert storage.update_project(pid, data={"cells": [], "params": {"k": 1}})
assert storage.get_project(pid)["data"]["params"]["k"] == 1
assert storage.delete_project(pid)
assert storage.get_project(pid) is None
def test_snapshot_created_and_restore():
pid = storage.create_project("p", {"v": 1})
# snapshot=True means: snapshot the CURRENT (v=1) state before applying v=2
assert storage.update_project(pid, data={"v": 2}, snapshot=True, note="step")
snaps = storage.list_snapshots(pid)
assert len(snaps) == 1
assert snaps[0]["note"] == "step"
sid = snaps[0]["id"]
snap = storage.get_snapshot(sid)
assert snap["data"]["v"] == 1 # the *prior* state
# Restore: rolls project back to v=1 (and creates another snapshot of v=2).
assert storage.restore_snapshot(sid)
assert storage.get_project(pid)["data"]["v"] == 1
assert len(storage.list_snapshots(pid)) == 2
def test_snapshot_retention_caps_at_N():
pid = storage.create_project("p", {"v": 0})
for i in range(1, storage.SNAPSHOT_RETENTION + 10):
storage.update_project(pid, data={"v": i}, snapshot=True)
snaps = storage.list_snapshots(pid)
assert len(snaps) == storage.SNAPSHOT_RETENTION
def test_preset_crud_and_name_unique():
pid = storage.create_preset("21700", {"cellDia": 21.2})
assert pid > 0
assert storage.create_preset("21700", {"x": 1}) is None # name collision
assert storage.list_presets()[0]["params"]["cellDia"] == 21.2
assert storage.update_preset(pid, params={"cellDia": 21.4})
assert storage.get_preset(pid)["params"]["cellDia"] == 21.4
assert storage.delete_preset(pid)
assert storage.list_presets() == []
def test_delete_project_cascades_snapshots():
pid = storage.create_project("p", {"v": 1})
storage.update_project(pid, data={"v": 2}, snapshot=True)
storage.update_project(pid, data={"v": 3}, snapshot=True)
assert len(storage.list_snapshots(pid)) == 2
storage.delete_project(pid)
assert storage.list_snapshots(pid) == []