From d8cb0dc06d94f6649b812a461948585a2af2500e Mon Sep 17 00:00:00 2001 From: wenil Date: Sun, 24 May 2026 18:59:50 +0300 Subject: [PATCH] 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). --- .dockerignore | 11 + .gitattributes | 22 ++ .gitignore | 41 ++ CLAUDE.md | 125 +++++++ Dockerfile | 50 +++ README.md | 196 ++++++++++ app.py | 194 ++++++++++ busbar_export.py | 306 +++++++++++++++ deploy/README.md | 131 +++++++ deploy/busbar-designer.service | 38 ++ deploy/install.sh | 121 ++++++ deploy/proxmox-lxc.sh | 195 ++++++++++ deploy/update.sh | 45 +++ docker-compose.yml | 23 ++ requirements.txt | 5 + scad_snippet.scad | 19 + static/index.html | 172 +++++++++ static/js/api.js | 45 +++ static/js/app.js | 666 +++++++++++++++++++++++++++++++++ static/js/exporter.js | 60 +++ static/js/geometry.js | 144 +++++++ static/js/groups.js | 83 ++++ static/js/importer.js | 111 ++++++ static/js/viewport.js | 320 ++++++++++++++++ static/styles.css | 532 ++++++++++++++++++++++++++ storage.py | 260 +++++++++++++ tests/test_export.py | 167 +++++++++ tests/test_storage.py | 90 +++++ 28 files changed, 4172 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 busbar_export.py create mode 100644 deploy/README.md create mode 100644 deploy/busbar-designer.service create mode 100644 deploy/install.sh create mode 100644 deploy/proxmox-lxc.sh create mode 100644 deploy/update.sh create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 scad_snippet.scad create mode 100644 static/index.html create mode 100644 static/js/api.js create mode 100644 static/js/app.js create mode 100644 static/js/exporter.js create mode 100644 static/js/geometry.js create mode 100644 static/js/groups.js create mode 100644 static/js/importer.js create mode 100644 static/js/viewport.js create mode 100644 static/styles.css create mode 100644 storage.py create mode 100644 tests/test_export.py create mode 100644 tests/test_storage.py 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) == []