commit d8cb0dc06d94f6649b812a461948585a2af2500e Author: wenil Date: Sun May 24 18:59:50 2026 +0300 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). diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..11a42b1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +tests/ +*.md +.git/ +.gitignore +data/ +.vscode/ +.idea/ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4c28d91 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de855d3 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d290bd1 --- /dev/null +++ b/CLAUDE.md @@ -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/")` 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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e42eb7d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..598c16a --- /dev/null +++ b/README.md @@ -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 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 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. diff --git a/app.py b/app.py new file mode 100644 index 0000000..2e62b42 --- /dev/null +++ b/app.py @@ -0,0 +1,194 @@ +"""Flask entrypoint for Busbar Designer. + +Serves the static frontend, the /api/export/ 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/") +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/") +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/") +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/") +def projects_delete(pid: int): + storage.delete_project(pid) + return jsonify({"ok": True}) + + +# --------------------------------------------------------------------------- +# Snapshots +# --------------------------------------------------------------------------- + + +@app.get("/api/projects//snapshots") +def snapshots_index(pid: int): + return jsonify(storage.list_snapshots(pid)) + + +@app.get("/api/snapshots/") +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//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/") +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/") +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) diff --git a/busbar_export.py b/busbar_export.py new file mode 100644 index 0000000..602e4a7 --- /dev/null +++ b/busbar_export.py @@ -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"), +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..4364e07 --- /dev/null +++ b/deploy/README.md @@ -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://///raw/branch//`.) + +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://: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 -- 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 -- ip a`. If using DHCP, the IP may have changed. | +| `pveam download` fails | Run `pveam update` on the host first. | diff --git a/deploy/busbar-designer.service b/deploy/busbar-designer.service new file mode 100644 index 0000000..96a59f1 --- /dev/null +++ b/deploy/busbar-designer.service @@ -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 diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100644 index 0000000..683155b --- /dev/null +++ b/deploy/install.sh @@ -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 "================================================================" diff --git a/deploy/proxmox-lxc.sh b/deploy/proxmox-lxc.sh new file mode 100644 index 0000000..5d4e39f --- /dev/null +++ b/deploy/proxmox-lxc.sh @@ -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="" + +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 "================================================================" diff --git a/deploy/update.sh b/deploy/update.sh new file mode 100644 index 0000000..afb2283 --- /dev/null +++ b/deploy/update.sh @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..87a3bdb --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5a98d9d --- /dev/null +++ b/requirements.txt @@ -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" diff --git a/scad_snippet.scad b/scad_snippet.scad new file mode 100644 index 0000000..8353016 --- /dev/null +++ b/scad_snippet.scad @@ -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)); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..50d8ce0 --- /dev/null +++ b/static/index.html @@ -0,0 +1,172 @@ + + + + + +Busbar Designer + + + +
+

Busbar Designer

+ +
+ + + + + + + + No cells loaded +
+ +
+ + + + + + + +
+
+ +
+ + +
+
+ +
+
x: — , y: —
+
1.0 px/mm
+
+
+ +
+
+ + + + + + + + + + + + + diff --git a/static/js/api.js b/static/js/api.js new file mode 100644 index 0000000..3edff91 --- /dev/null +++ b/static/js/api.js @@ -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}`), + }; +})(); diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..f03b378 --- /dev/null +++ b/static/js/app.js @@ -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=; 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) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 = '
  • No snapshots yet.
  • '; + } else { + ul.innerHTML = snaps.map((s) => ` +
  • + ${escapeHtml(s.created_at)} + ${escapeHtml(s.note || "(auto)")} + +
  • `).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 = '' + + list.map((p) => ``).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 = '' + + list.map((p) => ``).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 = + '
    Live preview disabled.
    '; + $("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 = + '
    Create a busbar to see the exported geometry here.
    '; + 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 = `
    ${escapeHtml(err)}
    `; + 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 = `
    ${escapeHtml(e.message)}
    `; + status.textContent = "error"; + } + } + + // ---- init --------------------------------------------------------------- + async function init() { + await Promise.all([refreshProjectList(), refreshPresetList()]); + + // URL routing: ?p= 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(); +})(); diff --git a/static/js/exporter.js b/static/js/exporter.js new file mode 100644 index 0000000..e4ed8b6 --- /dev/null +++ b/static/js/exporter.js @@ -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 }; +})(); diff --git a/static/js/geometry.js b/static/js/geometry.js new file mode 100644 index 0000000..184a2fe --- /dev/null +++ b/static/js/geometry.js @@ -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 }; +})(); diff --git a/static/js/groups.js b/static/js/groups.js new file mode 100644 index 0000000..1e0729b --- /dev/null +++ b/static/js/groups.js @@ -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 }; +})(); diff --git a/static/js/importer.js b/static/js/importer.js new file mode 100644 index 0000000..b2e1e21 --- /dev/null +++ b/static/js/importer.js @@ -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 }; +})(); diff --git a/static/js/viewport.js b/static/js/viewport.js new file mode 100644 index 0000000..65e62f5 --- /dev/null +++ b/static/js/viewport.js @@ -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 }; +})(); diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..c81f4c2 --- /dev/null +++ b/static/styles.css @@ -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; } +} diff --git a/storage.py b/storage.py new file mode 100644 index 0000000..d7383a5 --- /dev/null +++ b/storage.py @@ -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 diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..4dd74be --- /dev/null +++ b/tests/test_export.py @@ -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" 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}" diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..5f7921e --- /dev/null +++ b/tests/test_storage.py @@ -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) == []