Initial commit: Busbar Designer
Web tool for designing nickel/copper busbars over cylindrical-cell battery packs (21700, 18650) in hex holders. Flask + build123d backend exports STEP/DXF/SVG; vanilla JS frontend with live preview, multi-project SQLite persistence, snapshot history. Deploy scripts in deploy/ (proxmox-lxc.sh, install.sh, update.sh).
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
tests/
|
||||
*.md
|
||||
.git/
|
||||
.gitignore
|
||||
data/
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,22 @@
|
||||
# Force LF for shell scripts and systemd units so they work after checkout on Linux,
|
||||
# regardless of the developer's core.autocrlf setting.
|
||||
*.sh text eol=lf
|
||||
*.service text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
docker-compose.yml text eol=lf
|
||||
|
||||
# Normalise line endings on commit, but keep CRLF on Windows checkout for everything else.
|
||||
*.py text
|
||||
*.js text
|
||||
*.html text
|
||||
*.css text
|
||||
*.md text
|
||||
*.scad text
|
||||
*.txt text
|
||||
|
||||
# Binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.stl binary
|
||||
*.step binary
|
||||
*.dxf binary
|
||||
+41
@@ -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
|
||||
@@ -0,0 +1,125 @@
|
||||
# CLAUDE.md — Busbar Designer
|
||||
|
||||
Notes for future sessions and any agent picking up this project.
|
||||
|
||||
## What this is
|
||||
|
||||
A small web tool to design **busbars** (nickel/copper strips) for cylindrical-cell battery packs built with hex-shaped cell holders. The user imports cell-center coordinates (originating from Addy's `Hex-Cell-Holder` OpenSCAD generator), groups cells into parallel busbars and series chains, and exports the resulting 2D geometry to **STEP / DXF / SVG**.
|
||||
|
||||
The hard requirement is a working **STEP export**. STEP is generated by OpenCASCADE via `build123d`'s `OCP` bindings — never write STEP by hand.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser (static/) ──fetch──> Flask (app.py)
|
||||
├── busbar_export.py → build123d → STEP/DXF/SVG
|
||||
└── storage.py → SQLite (projects/presets/snapshots)
|
||||
```
|
||||
|
||||
- **Frontend** is plain HTML + JS (no build step). State lives in memory but is auto-synced to the server every 1.5 s; the active project ID is in the URL (`?p=42`) so refresh and other devices resume the same state.
|
||||
- **Backend** is a Flask app with three groups of endpoints:
|
||||
- **CAD export**: `POST /api/export/{step,dxf,svg}` — stateless; takes a busbar payload and returns bytes via build123d.
|
||||
- **Persistence**: `/api/projects/*`, `/api/presets/*`, `/api/snapshots/*` — CRUD over SQLite (`storage.py`).
|
||||
- **Health**: `GET /api/health`.
|
||||
- The SQLite DB lives at `data/busbar.db` (override via `BUSBAR_DB` env var). In Docker it's mounted as a volume so it survives container restarts.
|
||||
|
||||
## Payload contract (frontend → backend)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"units": "mm",
|
||||
"extrude": false, // false = flat 2D face; true = solid extrusion
|
||||
"thickness": 0.2, // used only when extrude=true
|
||||
"busbars": [
|
||||
{
|
||||
"name": "P1",
|
||||
"color": "#ff8800",
|
||||
"shape": "panel", // "panel" (default) | "wire"
|
||||
"strip_width": 8.0, // mm — only used by shape="wire"
|
||||
"pad_radius": 9.0, // mm — pad disc radius / panel inflation radius
|
||||
"hole_radius": 5.0, // mm — welding window radius
|
||||
"cells": [ // ordered list of cell centers in mm
|
||||
{"id": 1, "x": 0.0, "y": 0.0},
|
||||
{"id": 2, "x": 22.8, "y": 0.0},
|
||||
{"id": 3, "x": 45.6, "y": 0.0}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**panel shape** (production-style plate — hugs ONLY selected cells, never bridges across non-selected):
|
||||
`union( pad_disc(c, pad_radius) for c in cells ) ∪ union( stadium(c_i, c_j, 2*pad_radius) for (i,j) in neighbor_edges ) − holes`.
|
||||
|
||||
`neighbor_edges(cells, factor)` = pairs whose distance ≤ `factor × min_pair_distance` (default factor=1.15). This is what gives concave outlines (L, U, T...) instead of convex hulls. Critical: convex hull / `offset()` Minkowski-sum approaches fill in inner concavities (bridge across non-selected cells), which is **wrong** for production busbars — the user-visible bug was a fat diagonal stripe across an L-selection. The neighbor-edge stadium chain solves this.
|
||||
|
||||
`holes` per cell are either:
|
||||
- `cross` (default): two perpendicular slits, arm length `2*hole_radius`, arm width `slit_width`.
|
||||
- `circle`: disc of radius `hole_radius`.
|
||||
|
||||
**wire shape** (thin jumper / series link between panels):
|
||||
`union( pad_disc(c) ) ∪ union( strip_segment(c[i], c[i+1], width=strip_width) ) − holes`. Cells are connected in array-order as a polyline.
|
||||
|
||||
## Geometry source of truth
|
||||
|
||||
All math comes from `hex_cell.scad` (sibling folder `Hex-Cell-Holder-master/`). Key formulas live in `static/js/importer.js`'s `generateCenters()` and **must stay in sync** with the SCAD script:
|
||||
|
||||
- `hex_w = cell_dia + 2*wall`
|
||||
- `hex_pt = hex_w / 2 / cos(30°)`
|
||||
- `opening_dia = cell_dia - 2*cell_top_overlap` (welding window default)
|
||||
- Row pitch Y: `1.5 * hex_pt`
|
||||
- Rect col X (even row): `hex_w * col`; (odd row): `0.5*hex_w + hex_w*col`
|
||||
- Para col X: `row*0.5*hex_w + hex_w*col`
|
||||
- Tria col X: `row*0.5*hex_w - hex_w*col`, `col ∈ [0..row]`
|
||||
|
||||
If the user reports coordinates "off by half a hex," check `rect`-vs-`para` and even/odd row offset first.
|
||||
|
||||
## File map
|
||||
|
||||
- `app.py` — Flask app. Serves `static/`, dispatches export endpoints, exposes REST CRUD for projects/presets/snapshots. Single-file; no blueprints.
|
||||
- `busbar_export.py` — pure functions. Input: payload dict. Output: bytes. No Flask import here.
|
||||
- `storage.py` — SQLite layer for projects, presets, snapshots. Connection-per-call inside a context manager; no Flask import.
|
||||
- `static/index.html` — single page; left sidebar (import / params / busbars) + right pane (canvas + live SVG preview) + top bar (project switcher + history + exports) + history modal.
|
||||
- `static/js/app.js` — top-level controller; owns in-memory state, hooks auto-save + URL routing.
|
||||
- `static/js/api.js` — thin fetch-based REST client (Api.listProjects, getProject, ...).
|
||||
- `static/js/importer.js` — paste-text parser + generator.
|
||||
- `static/js/viewport.js` — canvas with pan/zoom; renders cells, busbars, selection.
|
||||
- `static/js/groups.js` — busbar CRUD (create, rename, recolor, delete, reorder cells).
|
||||
- `static/js/geometry.js` — frontend-side polygon preview.
|
||||
- `static/js/exporter.js` — POSTs export payload, triggers file download.
|
||||
- `scad_snippet.scad` — drop-in for `hex_cell.scad` that echoes per-cell coordinates.
|
||||
|
||||
## How to add a new export format
|
||||
|
||||
1. Add a writer in `busbar_export.py` that accepts the same payload dict and returns bytes + mimetype + extension.
|
||||
2. Add a route in `app.py`: `@app.post("/api/export/<fmt>")` already dispatches by `fmt`; just register the writer in the `WRITERS` dict.
|
||||
3. Add a button in `static/index.html` and hook it in `exporter.js`.
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Dev**: `python app.py` → Flask debug server on `127.0.0.1:5000`.
|
||||
- **Docker**: `docker compose up -d --build`. Uses Python 3.12 base (avoids the 3.13 OCP wheel risk), 2 gunicorn workers, ~600 MB image. See `Dockerfile` / `docker-compose.yml`.
|
||||
- **Proxmox LXC without Docker**: clone to `/opt/busbar-designer`, create venv with `gunicorn` installed, install `deploy/busbar-designer.service` as a systemd unit. See README for the full sequence.
|
||||
|
||||
The app honours `HOST`, `PORT`, and `FLASK_DEBUG` env vars. Bind to `0.0.0.0` for LAN access; otherwise it stays on localhost.
|
||||
|
||||
No auth. Put behind a reverse proxy (Caddy/nginx/Traefik) with basic_auth or Authelia for anything beyond the LAN.
|
||||
|
||||
## How to swap backend for opencascade.js (WASM)
|
||||
|
||||
If the user ever wants zero-backend, `static/js/exporter.js` is the only file that needs to change — the payload format is identical. Drop in `opencascade.js`, build the same shapes (`Wire → Face → STEPControl_Writer`), and download via Blob.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_export.py` exercises `busbar_export.py` with a minimal 2-cell busbar and checks the STEP file is non-empty and contains `ISO-10303-21`. Run:
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/
|
||||
```
|
||||
|
||||
## Known cautions
|
||||
|
||||
- **Python 3.13 + OCP wheels**: the official OCP wheel may not be published for the very latest CPython. If install fails, recommend Python 3.12.
|
||||
- **Unit handling**: backend always treats coordinates as **mm**. Never mix units. STEP writer sets `STEPControl_Writer.WriteHeader().SetUnits("MM")` (via build123d default).
|
||||
- **DXF**: `build123d.export_dxf` emits 2D entities (lines + arcs). The pad-with-hole becomes a closed boundary + an inner circle — laser CAM treats it correctly as a cut.
|
||||
- **Topology**: when two pad discs overlap (cells closer than `2 * pad_radius`), use Boolean union before exporting — otherwise STEP has duplicate edges.
|
||||
+50
@@ -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"]
|
||||
@@ -0,0 +1,196 @@
|
||||
# Busbar Designer
|
||||
|
||||
Web tool for designing nickel/copper **busbars** over cylindrical-cell battery packs (21700, 18650, ...) built with hex-shaped cell holders.
|
||||
|
||||
Workflow:
|
||||
1. Import cell-center coordinates (paste from OpenSCAD console, CSV, JSON — or generate from `cell_dia / wall / rows / cols`).
|
||||
2. View cells on a 2D canvas at real mm scale.
|
||||
3. Click cells to select; group selected cells into named **busbars** (parallel groups), then chain busbars in series.
|
||||
4. Each busbar is drawn as a strip of configurable width with circular pads + welding-window holes over each cell center.
|
||||
5. Export busbar geometry to **STEP** (mandatory), plus **DXF** and **SVG** for laser/waterjet cutting.
|
||||
|
||||
## Quick start
|
||||
|
||||
Requires **Python 3.10+**. On Windows / PowerShell:
|
||||
|
||||
```powershell
|
||||
cd busbar-designer
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
pip install -r requirements.txt
|
||||
python app.py
|
||||
```
|
||||
|
||||
Then open <http://localhost:5000> in your browser.
|
||||
|
||||
> First-time install pulls **build123d** (~ 200 MB; bundles OpenCASCADE via OCP) and may take several minutes.
|
||||
|
||||
### Linux / macOS
|
||||
|
||||
```bash
|
||||
cd busbar-designer
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python app.py
|
||||
```
|
||||
|
||||
## Geometry source of truth
|
||||
|
||||
All cell-center math matches Addy's `Hex-Cell-Holder/hex_cell.scad`:
|
||||
|
||||
| Variable | Formula | 21700 default |
|
||||
|-----------------|------------------------------------------|---------------|
|
||||
| `hex_w` | `cell_dia + 2*wall` | `22.8 mm` |
|
||||
| `hex_pt` | `hex_w / 2 / cos(30°)` | `13.164 mm` |
|
||||
| `opening_dia` | `cell_dia − 2*cell_top_overlap` | `15.2 mm` |
|
||||
| row pitch (Y) | `1.5 * hex_pt` | `19.746 mm` |
|
||||
| col pitch (X) | `hex_w` | `22.8 mm` |
|
||||
| `rect` row shift | `0` (even) / `0.5*hex_w` (odd) | `11.4 mm` |
|
||||
|
||||
## Import formats
|
||||
|
||||
The importer auto-detects:
|
||||
|
||||
- **OpenSCAD ECHO**: `ECHO: "Cell 1: x = 0.0, y = 0.0"` (paste straight from console).
|
||||
- If your `hex_cell.scad` doesn't print these, drop `scad_snippet.scad` (in this repo) at the bottom of the file.
|
||||
- **CSV**: `index,x,y` (header optional).
|
||||
- **JSON**: `[{"id":1,"x":0,"y":0}, ...]` or `[[x,y], ...]`.
|
||||
- **Generator** tab: enter `cell_dia / wall / rows / cols / pack_style` — coordinates are computed with the exact OpenSCAD formulas.
|
||||
|
||||
## Persistence
|
||||
|
||||
State is stored in a SQLite file (`data/busbar.db` by default; override with `BUSBAR_DB` env var):
|
||||
|
||||
| Table | What |
|
||||
|-------------|------------------------------------------------------------------------|
|
||||
| `projects` | Full editor state (cells + busbars + params) per project |
|
||||
| `presets` | Named param bundles you save from the params panel |
|
||||
| `snapshots` | Auto-history per project; max `SNAPSHOT_RETENTION` (default 20) kept |
|
||||
|
||||
**REST API** (all JSON):
|
||||
|
||||
| Method | Path | What |
|
||||
|--------|-----------------------------------------------|-------------------------------|
|
||||
| GET | `/api/projects` | list projects |
|
||||
| POST | `/api/projects` | create `{name, data}` |
|
||||
| GET | `/api/projects/{id}` | full project |
|
||||
| PUT | `/api/projects/{id}` | update `{name?, data?, snapshot?, note?}` |
|
||||
| DELETE | `/api/projects/{id}` | delete (cascades to snapshots) |
|
||||
| GET | `/api/projects/{id}/snapshots` | list snapshots |
|
||||
| GET | `/api/snapshots/{sid}` | full snapshot |
|
||||
| POST | `/api/snapshots/{sid}/restore` | roll project back |
|
||||
| GET | `/api/presets` | list presets |
|
||||
| POST | `/api/presets` | create `{name, params}` |
|
||||
| PUT | `/api/presets/{id}` | update |
|
||||
| DELETE | `/api/presets/{id}` | delete |
|
||||
|
||||
**UI behaviour**:
|
||||
- Active project ID is in the URL: `?p=42`. Bookmark or open from another device → same state.
|
||||
- Auto-save fires 1.5 s after the last change. The first save in any 60 s window also creates a history snapshot.
|
||||
- "History" button shows the snapshot list with a Restore action.
|
||||
- Presets are saved server-side and apply to any project with one click.
|
||||
|
||||
**Backup**: the entire DB is one file. Copy `data/busbar.db` (or the `data/` folder in Docker) for offline backup.
|
||||
|
||||
## Export
|
||||
|
||||
| Format | Use | Backend |
|
||||
|--------|---------------------------------------------|--------------------------|
|
||||
| STEP | CAD (FreeCAD, Fusion, SolidWorks) | `build123d` → OpenCASCADE |
|
||||
| DXF | Laser cutter / CNC (2D) | `build123d` → ezdxf |
|
||||
| SVG | Quick preview, illustrator | `build123d` |
|
||||
|
||||
STEP files are written as **flat 3D faces** (not solids) so a fab shop can lay them out before cutting. If you need a thick solid (e.g., 0.2 mm nickel) toggle "Extrude busbars" — the backend will extrude each strip to the configured thickness.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
busbar-designer/
|
||||
├── app.py # Flask server: static files + /api/export/{step,dxf,svg}
|
||||
├── busbar_export.py # build123d → STEP / DXF / SVG
|
||||
├── requirements.txt
|
||||
├── scad_snippet.scad # paste into hex_cell.scad to echo per-cell coordinates
|
||||
├── CLAUDE.md # architecture notes for Claude / future contributors
|
||||
├── tests/
|
||||
│ └── test_export.py
|
||||
└── static/
|
||||
├── index.html
|
||||
├── styles.css
|
||||
└── js/
|
||||
├── app.js
|
||||
├── importer.js
|
||||
├── viewport.js
|
||||
├── groups.js
|
||||
├── geometry.js
|
||||
└── exporter.js
|
||||
```
|
||||
|
||||
## Deploy to a home server
|
||||
|
||||
Three supported paths — pick one. Full docs in [`deploy/README.md`](deploy/README.md).
|
||||
|
||||
### Option A — Proxmox VE (one-liner from the PVE host)
|
||||
|
||||
```bash
|
||||
bash -c "$(curl -fsSL https://gitea.local/me/busbar-designer/raw/branch/main/deploy/proxmox-lxc.sh)"
|
||||
```
|
||||
|
||||
Creates a Debian 12 LXC and installs the service inside. Whiptail prompts for container ID, hostname, disk/CPU/RAM, network, repo URL, branch. Defaults to 4 GB disk, 2 cores, 1 GB RAM, dhcp.
|
||||
|
||||
### Option B — Inside an existing Debian/Ubuntu (LXC, VM, bare)
|
||||
|
||||
```bash
|
||||
REPO_URL=https://gitea.local/me/busbar-designer.git \
|
||||
bash -c "$(curl -fsSL https://gitea.local/me/busbar-designer/raw/branch/main/deploy/install.sh)"
|
||||
```
|
||||
|
||||
Sets up the venv, installs deps, drops a systemd unit, starts the service. Update with `bash /opt/busbar-designer/deploy/update.sh`.
|
||||
|
||||
### Option C — Docker
|
||||
|
||||
```bash
|
||||
git clone <repo> busbar-designer && cd busbar-designer
|
||||
mkdir -p data
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The `data/` folder is mounted into the container at `/app/data` so the SQLite DB survives `docker compose down`.
|
||||
|
||||
### Reverse proxy (optional)
|
||||
|
||||
If you want it behind your existing nginx / Caddy / Traefik:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_read_timeout 120s; # STEP export can take a few seconds
|
||||
client_max_body_size 5m;
|
||||
}
|
||||
```
|
||||
|
||||
For Caddy:
|
||||
|
||||
```
|
||||
busbar.example.com {
|
||||
reverse_proxy 127.0.0.1:5000
|
||||
}
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
The app has no authentication. Either:
|
||||
- expose only on your LAN (default), or
|
||||
- put it behind an authenticating reverse proxy (Caddy `basic_auth`, Authelia, Pocket-ID, etc.).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`pip install build123d` fails on Windows / Python 3.13** — the prebuilt OCP wheels lag the latest Python release. If your `python --version` is 3.13, install Python 3.12 from python.org and use it for the venv: `py -3.12 -m venv .venv`.
|
||||
- **STEP opens empty in FreeCAD** — make sure at least one busbar exists and contains ≥ 1 cell. The exporter writes only assigned busbars.
|
||||
- **Port 5000 in use** — set `PORT=5050 python app.py`.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Flask entrypoint for Busbar Designer.
|
||||
|
||||
Serves the static frontend, the /api/export/<fmt> CAD endpoints (build123d →
|
||||
STEP/DXF/SVG), and the /api/projects, /api/presets, /api/snapshots persistence
|
||||
endpoints (SQLite via storage.py).
|
||||
|
||||
Run:
|
||||
python app.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from flask import Flask, Response, jsonify, request, send_from_directory
|
||||
|
||||
from busbar_export import WRITERS
|
||||
import storage
|
||||
|
||||
APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STATIC_DIR = os.path.join(APP_DIR, "static")
|
||||
|
||||
app = Flask(__name__, static_folder=STATIC_DIR, static_url_path="")
|
||||
|
||||
storage.init_db()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static + health
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return send_from_directory(STATIC_DIR, "index.html")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CAD export (unchanged contract)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.post("/api/export/<fmt>")
|
||||
def export(fmt: str):
|
||||
fmt = fmt.lower()
|
||||
if fmt not in WRITERS:
|
||||
return jsonify({"error": f"unsupported format: {fmt}"}), 400
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
writer, mimetype, ext = WRITERS[fmt]
|
||||
try:
|
||||
data = writer(payload)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"error": f"{type(e).__name__}: {e}"}), 500
|
||||
|
||||
filename = f"busbars.{ext}"
|
||||
return Response(
|
||||
data,
|
||||
mimetype=mimetype,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Projects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/projects")
|
||||
def projects_index():
|
||||
return jsonify(storage.list_projects())
|
||||
|
||||
|
||||
@app.post("/api/projects")
|
||||
def projects_create():
|
||||
body = request.get_json(silent=True) or {}
|
||||
name = (body.get("name") or "Untitled").strip() or "Untitled"
|
||||
data = body.get("data") or {}
|
||||
pid = storage.create_project(name, data)
|
||||
return jsonify({"id": pid, "name": name})
|
||||
|
||||
|
||||
@app.get("/api/projects/<int:pid>")
|
||||
def projects_show(pid: int):
|
||||
p = storage.get_project(pid)
|
||||
if p is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify(p)
|
||||
|
||||
|
||||
@app.put("/api/projects/<int:pid>")
|
||||
def projects_update(pid: int):
|
||||
body = request.get_json(silent=True) or {}
|
||||
ok = storage.update_project(
|
||||
pid,
|
||||
name=body.get("name"),
|
||||
data=body.get("data"),
|
||||
snapshot=bool(body.get("snapshot", False)),
|
||||
note=body.get("note"),
|
||||
)
|
||||
if not ok:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.delete("/api/projects/<int:pid>")
|
||||
def projects_delete(pid: int):
|
||||
storage.delete_project(pid)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snapshots
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/projects/<int:pid>/snapshots")
|
||||
def snapshots_index(pid: int):
|
||||
return jsonify(storage.list_snapshots(pid))
|
||||
|
||||
|
||||
@app.get("/api/snapshots/<int:sid>")
|
||||
def snapshot_show(sid: int):
|
||||
snap = storage.get_snapshot(sid)
|
||||
if snap is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify(snap)
|
||||
|
||||
|
||||
@app.post("/api/snapshots/<int:sid>/restore")
|
||||
def snapshot_restore(sid: int):
|
||||
if not storage.restore_snapshot(sid):
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Presets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/presets")
|
||||
def presets_index():
|
||||
return jsonify(storage.list_presets())
|
||||
|
||||
|
||||
@app.post("/api/presets")
|
||||
def presets_create():
|
||||
body = request.get_json(silent=True) or {}
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"error": "name required"}), 400
|
||||
pid = storage.create_preset(name, body.get("params") or {})
|
||||
if pid is None:
|
||||
return jsonify({"error": "name already in use"}), 409
|
||||
return jsonify({"id": pid, "name": name})
|
||||
|
||||
|
||||
@app.put("/api/presets/<int:pid>")
|
||||
def presets_update(pid: int):
|
||||
body = request.get_json(silent=True) or {}
|
||||
ok = storage.update_preset(pid, name=body.get("name"), params=body.get("params"))
|
||||
if not ok:
|
||||
return jsonify({"error": "not found or name conflict"}), 404
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.delete("/api/presets/<int:pid>")
|
||||
def presets_delete(pid: int):
|
||||
storage.delete_preset(pid)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.environ.get("PORT", 5000))
|
||||
host = os.environ.get("HOST", "127.0.0.1")
|
||||
debug = os.environ.get("FLASK_DEBUG", "1") == "1"
|
||||
print(f" * Busbar Designer running on http://{host}:{port}", file=sys.stderr)
|
||||
app.run(host=host, port=port, debug=debug)
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
# Deploy
|
||||
|
||||
Three scripts here, plus the systemd unit.
|
||||
|
||||
| File | Where it runs | What it does |
|
||||
|-------------------------------|--------------------------------|---------------------------------------------------------------------------|
|
||||
| `proxmox-lxc.sh` | **Proxmox VE host** as root | Creates an unprivileged Debian 12 LXC, then runs `install.sh` inside it. |
|
||||
| `install.sh` | **inside** an LXC/VM/server | Clones the repo, sets up Python venv, installs deps, starts systemd unit. |
|
||||
| `update.sh` | **inside** the LXC/VM/server | `git pull` + refresh Python deps + `systemctl restart`. |
|
||||
| `busbar-designer.service` | systemd | Unit file template; install.sh substitutes paths/user/port. |
|
||||
|
||||
All scripts are idempotent (safe to re-run) and use only stdlib + Debian-shipped tools (`pct`, `pveam`, `whiptail`, `git`, `python3`). No external dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Step 0 — push to your Gitea (one time)
|
||||
|
||||
```bash
|
||||
# on your laptop, in this repo
|
||||
git remote add gitea https://gitea.local/me/busbar-designer.git
|
||||
git push gitea main
|
||||
```
|
||||
|
||||
If your Gitea uses a self-signed cert, set `GIT_SSL_NO_VERIFY=1` everywhere (or install the cert into the system trust store).
|
||||
|
||||
---
|
||||
|
||||
## Path A — Proxmox VE host (recommended)
|
||||
|
||||
One-liner from the Proxmox shell:
|
||||
|
||||
```bash
|
||||
bash -c "$(curl -fsSL https://gitea.local/me/busbar-designer/raw/branch/main/deploy/proxmox-lxc.sh)"
|
||||
```
|
||||
|
||||
(Substitute `https://gitea.local/me/busbar-designer/raw/branch/main/deploy/proxmox-lxc.sh` with your repo's raw URL. Gitea's raw URL format is `https://<host>/<user>/<repo>/raw/branch/<branch>/<path>`.)
|
||||
|
||||
You'll get whiptail prompts for:
|
||||
|
||||
- Container ID (defaults to next available)
|
||||
- Hostname (`busbar-designer`)
|
||||
- Disk size (4 GB), cores (2), RAM (1024 MB)
|
||||
- Storage pool, network bridge, IP (`dhcp` or `1.2.3.4/24,gw=1.2.3.1`)
|
||||
- Repo URL & branch
|
||||
- Skip TLS verify? (yes if your Gitea uses self-signed certs)
|
||||
|
||||
The script will:
|
||||
|
||||
1. Download Debian 12 template if missing.
|
||||
2. Create the LXC, start it.
|
||||
3. Inside the LXC: clone the repo, install everything, enable systemd.
|
||||
4. Print the URL + root password + management commands.
|
||||
|
||||
To skip prompts (CI / scripted re-deploys):
|
||||
|
||||
```bash
|
||||
CTID=210 HOSTNAME=busbar DISK_SIZE=4 CORES=2 RAM=1024 \
|
||||
BRIDGE=vmbr0 IP=dhcp STORAGE=local-lvm \
|
||||
REPO_URL=https://gitea.local/me/busbar-designer.git BRANCH=main \
|
||||
bash deploy/proxmox-lxc.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Path B — inside an existing LXC / VM / bare server
|
||||
|
||||
Already have a Debian 12 / Ubuntu 22.04 / 24.04 host? Just run the installer:
|
||||
|
||||
```bash
|
||||
REPO_URL=https://gitea.local/me/busbar-designer.git \
|
||||
bash -c "$(curl -fsSL https://gitea.local/me/busbar-designer/raw/branch/main/deploy/install.sh)"
|
||||
```
|
||||
|
||||
Defaults to `/opt/busbar-designer`, user `busbar`, port `5000`. Override with `INSTALL_DIR`, `SVC_USER`, `PORT`.
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
From the Proxmox host:
|
||||
|
||||
```bash
|
||||
pct exec 210 -- bash /opt/busbar-designer/deploy/update.sh
|
||||
```
|
||||
|
||||
From inside the LXC:
|
||||
|
||||
```bash
|
||||
sudo bash /opt/busbar-designer/deploy/update.sh
|
||||
```
|
||||
|
||||
The updater does `git fetch + reset --hard` on the tracked branch, refreshes Python deps, restarts the service.
|
||||
|
||||
### Optional: auto-deploy via Gitea webhook
|
||||
|
||||
In your Gitea repo → **Settings → Webhooks → Add Webhook (Gitea)**:
|
||||
|
||||
- URL: `http://<lxc-ip>:5050/hook` (you'd need to add a tiny webhook listener; not built-in)
|
||||
- Trigger: `Push`
|
||||
- Branch filter: `main`
|
||||
|
||||
Out of the box there's no webhook endpoint — the simplest path is a cron `*/5 * * * * root bash /opt/busbar-designer/deploy/update.sh` if you want polling, or just SSH and re-run `update.sh` after each push.
|
||||
|
||||
---
|
||||
|
||||
## Backup & restore
|
||||
|
||||
Everything user-generated lives in `/opt/busbar-designer/data/busbar.db` (SQLite, single file).
|
||||
|
||||
```bash
|
||||
# Backup (from Proxmox host)
|
||||
pct exec 210 -- cat /opt/busbar-designer/data/busbar.db > busbar-backup-$(date +%F).db
|
||||
|
||||
# Restore
|
||||
cat busbar-backup-2026-05-24.db | pct exec 210 -- bash -c \
|
||||
'systemctl stop busbar-designer && cat > /opt/busbar-designer/data/busbar.db && chown busbar:busbar /opt/busbar-designer/data/busbar.db && systemctl start busbar-designer'
|
||||
```
|
||||
|
||||
Or via Proxmox's own LXC backup (`vzdump`) which captures the whole rootfs.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
|--------------------------------------------------|---------------------------------------------------------------------------------------|
|
||||
| `pveversion: command not found` | The Proxmox script is not running on a PVE host. Use `install.sh` directly inside the LXC. |
|
||||
| Git clone fails with TLS error | Set `GIT_SSL_NO_VERIFY=1` env var, or install your Gitea CA cert into `/usr/local/share/ca-certificates`. |
|
||||
| Service starts, then dies | `pct exec <CTID> -- journalctl -u busbar-designer -n 100`. Usually the build123d wheel didn't install — Debian 12 ships Python 3.11 which is fine. |
|
||||
| Browser shows 502 / can't connect | Check the LXC's IP with `pct exec <CTID> -- ip a`. If using DHCP, the IP may have changed. |
|
||||
| `pveam download` fails | Run `pveam update` on the host first. |
|
||||
@@ -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
|
||||
@@ -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 "================================================================"
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bash
|
||||
# proxmox-lxc.sh — create an unprivileged Debian 12 LXC on a Proxmox VE host
|
||||
# and install Busbar Designer into it. Inspired by the community-scripts style.
|
||||
#
|
||||
# Run on the Proxmox host as root, e.g.:
|
||||
#
|
||||
# bash -c "$(curl -fsSL https://gitea.local/me/busbar-designer/raw/branch/main/deploy/proxmox-lxc.sh)"
|
||||
#
|
||||
# Interactive whiptail prompts; or pre-set everything via env to skip prompts:
|
||||
#
|
||||
# REPO_URL=https://gitea.local/me/busbar-designer.git \
|
||||
# CTID=210 HOSTNAME=busbar DISK_SIZE=4 CORES=2 RAM=1024 \
|
||||
# BRIDGE=vmbr0 IP=dhcp STORAGE=local-lvm \
|
||||
# bash proxmox-lxc.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---- colors ----------------------------------------------------------------
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'
|
||||
BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m'
|
||||
log() { echo -e "${GREEN}▸${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}!${NC} $*"; }
|
||||
die() { echo -e "${RED}✗ $*${NC}" >&2; exit 1; }
|
||||
|
||||
banner() {
|
||||
cat <<'EOF'
|
||||
|
||||
____ _ ____ _
|
||||
| __ ) _ _ ___| |__ __ _ _ __ | _ \ ___ ___(_) __ _ _ __ ___ _ __
|
||||
| _ \| | | / __| '_ \ / _` | '__| | | | |/ _ \/ __| |/ _` | '_ \ / _ \ '__|
|
||||
| |_) | |_| \__ \ |_) | (_| | | | |_| | __/\__ \ | (_| | | | | __/ |
|
||||
|____/ \__,_|___/_.__/ \__,_|_| |____/ \___||___/_|\__, |_| |_|\___|_|
|
||||
|___/
|
||||
Proxmox VE LXC installer
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---- preflight -------------------------------------------------------------
|
||||
[[ $EUID -eq 0 ]] || die "Run as root on the Proxmox host."
|
||||
command -v pveversion >/dev/null 2>&1 || die "pveversion not found — is this a Proxmox VE host?"
|
||||
command -v pct >/dev/null 2>&1 || die "pct not found — Proxmox VE tools missing?"
|
||||
|
||||
banner
|
||||
|
||||
# ---- defaults --------------------------------------------------------------
|
||||
CTID_DEFAULT=$(pvesh get /cluster/nextid 2>/dev/null || echo "200")
|
||||
CTID="${CTID:-$CTID_DEFAULT}"
|
||||
HOSTNAME="${HOSTNAME:-busbar-designer}"
|
||||
DISK_SIZE="${DISK_SIZE:-4}"
|
||||
CORES="${CORES:-2}"
|
||||
RAM="${RAM:-1024}"
|
||||
SWAP="${SWAP:-512}"
|
||||
BRIDGE="${BRIDGE:-vmbr0}"
|
||||
IP="${IP:-dhcp}"
|
||||
STORAGE="${STORAGE:-local-lvm}"
|
||||
TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local}"
|
||||
REPO_URL="${REPO_URL:-}"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
GIT_SSL_NO_VERIFY="${GIT_SSL_NO_VERIFY:-0}"
|
||||
|
||||
# ---- interactive prompts (whiptail) ----------------------------------------
|
||||
ask() {
|
||||
local var="$1" prompt="$2" default="$3" h="${4:-8}" w="${5:-60}"
|
||||
local val
|
||||
if [[ -t 0 ]] && command -v whiptail >/dev/null 2>&1; then
|
||||
val=$(whiptail --title "Busbar Designer" --inputbox "$prompt" "$h" "$w" "$default" \
|
||||
3>&1 1>&2 2>&3) || die "Cancelled."
|
||||
else
|
||||
read -rp "$prompt [$default]: " val
|
||||
val="${val:-$default}"
|
||||
fi
|
||||
printf -v "$var" '%s' "$val"
|
||||
}
|
||||
|
||||
yesno() {
|
||||
local prompt="$1" default="${2:-no}"
|
||||
if [[ -t 0 ]] && command -v whiptail >/dev/null 2>&1; then
|
||||
if [[ "$default" == "yes" ]]; then
|
||||
whiptail --title "Busbar Designer" --yesno "$prompt" 8 60
|
||||
else
|
||||
whiptail --title "Busbar Designer" --yesno "$prompt" --defaultno 8 60
|
||||
fi
|
||||
else
|
||||
read -rp "$prompt [$default]: " val
|
||||
[[ "${val:-$default}" =~ ^(y|yes)$ ]]
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ -z "$REPO_URL" ]] || [[ -t 0 ]]; then
|
||||
ask CTID "Container ID" "$CTID"
|
||||
ask HOSTNAME "Hostname" "$HOSTNAME"
|
||||
ask DISK_SIZE "Root disk size (GB)" "$DISK_SIZE"
|
||||
ask CORES "CPU cores" "$CORES"
|
||||
ask RAM "RAM (MB)" "$RAM"
|
||||
ask STORAGE "Storage pool for rootfs (e.g. local-lvm, local-zfs)" "$STORAGE" 8 70
|
||||
ask BRIDGE "Network bridge" "$BRIDGE"
|
||||
ask IP "IP config: 'dhcp' or 'a.b.c.d/24,gw=a.b.c.1'" "$IP" 8 70
|
||||
ask REPO_URL "Git URL of busbar-designer repo (your Gitea / GitHub)" \
|
||||
"${REPO_URL:-https://gitea.local/me/busbar-designer.git}" 10 70
|
||||
ask BRANCH "Branch" "$BRANCH"
|
||||
if yesno "Skip TLS verification for git? (only if your Gitea uses a self-signed cert)"; then
|
||||
GIT_SSL_NO_VERIFY=1
|
||||
fi
|
||||
fi
|
||||
|
||||
[[ -n "$REPO_URL" ]] || die "REPO_URL is required."
|
||||
|
||||
# ---- template --------------------------------------------------------------
|
||||
log "Looking for a Debian 12 template..."
|
||||
TEMPLATE=$(pveam available --section system 2>/dev/null \
|
||||
| awk '/debian-12-standard/ {print $2}' | sort -r | head -n 1)
|
||||
[[ -n "$TEMPLATE" ]] || die "Couldn't find debian-12-standard in 'pveam available'. Run 'pveam update' first."
|
||||
|
||||
LOCAL_TEMPLATE="/var/lib/vz/template/cache/$TEMPLATE"
|
||||
if [[ ! -f "$LOCAL_TEMPLATE" ]]; then
|
||||
log "Downloading template $TEMPLATE..."
|
||||
pveam download "$TEMPLATE_STORAGE" "$TEMPLATE"
|
||||
fi
|
||||
|
||||
# ---- create LXC ------------------------------------------------------------
|
||||
if pct status "$CTID" >/dev/null 2>&1; then
|
||||
die "Container $CTID already exists. Pick a different CTID."
|
||||
fi
|
||||
|
||||
PASSWORD="$(openssl rand -base64 12 | tr -d '/+=' | cut -c1-16)"
|
||||
|
||||
if [[ "$IP" == "dhcp" ]]; then
|
||||
NET="name=eth0,bridge=$BRIDGE,ip=dhcp"
|
||||
else
|
||||
NET="name=eth0,bridge=$BRIDGE,ip=$IP"
|
||||
fi
|
||||
|
||||
log "Creating LXC $CTID ($HOSTNAME)..."
|
||||
pct create "$CTID" "$TEMPLATE_STORAGE:vztmpl/$TEMPLATE" \
|
||||
--hostname "$HOSTNAME" \
|
||||
--cores "$CORES" \
|
||||
--memory "$RAM" \
|
||||
--swap "$SWAP" \
|
||||
--rootfs "$STORAGE:$DISK_SIZE" \
|
||||
--net0 "$NET" \
|
||||
--password "$PASSWORD" \
|
||||
--features nesting=1 \
|
||||
--unprivileged 1 \
|
||||
--onboot 1 \
|
||||
--start 1 \
|
||||
--description "Busbar Designer · $REPO_URL ($BRANCH)" >/dev/null
|
||||
|
||||
# ---- wait for network ------------------------------------------------------
|
||||
log "Waiting for network in CT $CTID..."
|
||||
for i in {1..30}; do
|
||||
if pct exec "$CTID" -- bash -c "getent hosts deb.debian.org >/dev/null" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# ---- run installer inside the LXC ------------------------------------------
|
||||
log "Bootstrapping git + curl in the container..."
|
||||
pct exec "$CTID" -- bash -c "
|
||||
set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq git ca-certificates curl
|
||||
" || die "Failed to install bootstrap packages in CT."
|
||||
|
||||
log "Cloning repo and running deploy/install.sh inside CT $CTID..."
|
||||
pct exec "$CTID" -- bash -c "
|
||||
set -e
|
||||
${GIT_SSL_NO_VERIFY:+export GIT_SSL_NO_VERIFY=true}
|
||||
rm -rf /opt/busbar-designer
|
||||
git clone --depth 1 -b '$BRANCH' '$REPO_URL' /opt/busbar-designer
|
||||
REPO_URL='$REPO_URL' BRANCH='$BRANCH' \
|
||||
${GIT_SSL_NO_VERIFY:+GIT_SSL_NO_VERIFY=1} \
|
||||
bash /opt/busbar-designer/deploy/install.sh
|
||||
" || die "Installer failed. Inspect with: pct enter $CTID"
|
||||
|
||||
# ---- report ---------------------------------------------------------------
|
||||
IP_ADDR=$(pct exec "$CTID" -- bash -c "hostname -I | awk '{print \$1}'" 2>/dev/null || true)
|
||||
[[ -n "$IP_ADDR" ]] || IP_ADDR="<unknown — check 'pct exec $CTID -- ip a'>"
|
||||
|
||||
echo
|
||||
log "================================================================"
|
||||
log " ${BOLD}✓ Busbar Designer LXC ready${NC}"
|
||||
log ""
|
||||
log " Container ID: $CTID"
|
||||
log " Hostname: $HOSTNAME"
|
||||
log " Root password: $PASSWORD"
|
||||
log " URL: http://$IP_ADDR:5000"
|
||||
log ""
|
||||
log " Update: pct exec $CTID -- bash /opt/busbar-designer/deploy/update.sh"
|
||||
log " Logs: pct exec $CTID -- journalctl -u busbar-designer -f"
|
||||
log " Enter: pct enter $CTID"
|
||||
log " Backup: pct exec $CTID -- cat /opt/busbar-designer/data/busbar.db > backup.db"
|
||||
log "================================================================"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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));
|
||||
@@ -0,0 +1,172 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Busbar Designer</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<h1>Busbar Designer</h1>
|
||||
|
||||
<div class="project-bar">
|
||||
<select id="project-select" title="Open project">
|
||||
<option value="">— select project —</option>
|
||||
</select>
|
||||
<input id="project-name" type="text" placeholder="Project name" />
|
||||
<button id="btn-project-new" title="Create a new empty project">+ New</button>
|
||||
<button id="btn-project-del" class="danger" title="Delete current project">×</button>
|
||||
<button id="btn-save-now" class="primary" title="Save current state to server now (no waiting for auto-save)">Save</button>
|
||||
<button id="btn-history" title="View snapshot history">History</button>
|
||||
<span id="save-status" class="save-status">—</span>
|
||||
<span id="status" class="topbar-status">No cells loaded</span>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="btn-save" title="Download project as JSON file">Save .json</button>
|
||||
<button id="btn-load" title="Import project from JSON file">Load .json</button>
|
||||
<input type="file" id="file-load" accept=".json" hidden />
|
||||
<span class="sep"></span>
|
||||
<button id="btn-export-step" class="primary">Export STEP</button>
|
||||
<button id="btn-export-dxf">Export DXF</button>
|
||||
<button id="btn-export-svg">Export SVG</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<aside class="left">
|
||||
<section class="panel">
|
||||
<h2>1. Cell import</h2>
|
||||
|
||||
<div class="tabs" id="import-tabs">
|
||||
<button class="tab active" data-tab="paste">Paste</button>
|
||||
<button class="tab" data-tab="csv">CSV</button>
|
||||
<button class="tab" data-tab="json">JSON</button>
|
||||
<button class="tab" data-tab="gen">Generator</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-body" data-tab-body="paste">
|
||||
<p class="hint">Paste OpenSCAD ECHO lines (<code>ECHO: "Cell 1: x = …, y = …"</code>) or plain <code>id x y</code> lines.</p>
|
||||
<textarea id="paste-text" rows="8" placeholder='ECHO: "Cell 1: x = 0, y = 0" ECHO: "Cell 2: x = 22.8, y = 0"'></textarea>
|
||||
<button id="btn-import-paste">Import</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-body hidden" data-tab-body="csv">
|
||||
<p class="hint">CSV with optional header: <code>index,x,y</code></p>
|
||||
<textarea id="csv-text" rows="8" placeholder="1,0,0 2,22.8,0"></textarea>
|
||||
<button id="btn-import-csv">Import</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-body hidden" data-tab-body="json">
|
||||
<p class="hint">JSON: <code>[{"id":1,"x":0,"y":0}, …]</code> or <code>[[x,y], …]</code></p>
|
||||
<textarea id="json-text" rows="8" placeholder='[[0,0],[22.8,0]]'></textarea>
|
||||
<button id="btn-import-json">Import</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-body hidden" data-tab-body="gen">
|
||||
<p class="hint">Exact formulas from <code>hex_cell.scad</code>.</p>
|
||||
<div class="grid">
|
||||
<label>Cell dia (mm) <input type="number" id="gen-cell-dia" value="21.2" step="0.1"></label>
|
||||
<label>Wall (mm) <input type="number" id="gen-wall" value="0.8" step="0.1"></label>
|
||||
<label>Rows <input type="number" id="gen-rows" value="4" min="1"></label>
|
||||
<label>Cols <input type="number" id="gen-cols" value="6" min="1"></label>
|
||||
<label>Style
|
||||
<select id="gen-style">
|
||||
<option value="rect">rect</option>
|
||||
<option value="para">para</option>
|
||||
<option value="tria">tria</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button id="btn-import-gen">Generate</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>2. Cell & busbar params</h2>
|
||||
<div class="preset-row">
|
||||
<select id="preset-select">
|
||||
<option value="">— saved preset —</option>
|
||||
</select>
|
||||
<button id="btn-preset-apply" title="Apply selected preset">Apply</button>
|
||||
<button id="btn-preset-save" class="primary" title="Save current params as new preset">Save as preset</button>
|
||||
<button id="btn-preset-del" class="danger" title="Delete selected preset">×</button>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<label>Cell dia (mm) <input type="number" id="p-cell-dia" value="21.2" step="0.1"></label>
|
||||
<label>Opening dia (mm) <input type="number" id="p-opening-dia" value="15.2" step="0.1"></label>
|
||||
<label>Pad radius (mm) <input type="number" id="p-pad-radius" value="9.0" step="0.1" title="Disc radius over each cell (panel + wire)"></label>
|
||||
<label>Strip width (mm) <input type="number" id="p-strip-width" value="6.0" step="0.1" title="Connector width between cells"></label>
|
||||
<label>Hole shape
|
||||
<select id="p-hole-shape">
|
||||
<option value="cross" selected>cross (slit)</option>
|
||||
<option value="circle">circle</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Hole radius (mm) <input type="number" id="p-hole-radius" value="6.0" step="0.1" title="For cross: half-length of each arm; for circle: radius"></label>
|
||||
<label>Slit width (mm) <input type="number" id="p-slit-width" value="1.0" step="0.1" title="Width of each cross arm"></label>
|
||||
<label>Neighbor factor <input type="number" id="p-neighbor-factor" value="1.15" step="0.05" min="1.0" title="Bridge two cells if distance ≤ factor × shortest pair distance"></label>
|
||||
<label class="checkbox"><input type="checkbox" id="p-extrude"> Extrude solid</label>
|
||||
<label>Thickness (mm) <input type="number" id="p-thickness" value="0.2" step="0.05"></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>3. Busbars</h2>
|
||||
<div class="busbar-toolbar">
|
||||
<button id="btn-new-busbar" class="primary">+ New busbar from selection</button>
|
||||
<span class="sel-info" id="sel-info">0 selected</span>
|
||||
</div>
|
||||
<ul id="busbar-list" class="busbar-list"></ul>
|
||||
<p class="hint">Click cell to select. Shift+click extend; Alt+click deselect. Right-mouse drag to pan; wheel to zoom.</p>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="right">
|
||||
<div class="viewport-wrap">
|
||||
<canvas id="viewport"></canvas>
|
||||
<div class="viewport-overlay">
|
||||
<div id="cursor-pos">x: — , y: —</div>
|
||||
<div id="zoom-info">1.0 px/mm</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-preview-wrap collapsed" id="step-preview-wrap">
|
||||
<div class="step-preview-header">
|
||||
<button id="step-preview-collapse" class="step-preview-collapse" title="Show / hide preview">▴</button>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="step-preview-toggle" checked>
|
||||
Live STEP preview
|
||||
</label>
|
||||
<span id="step-preview-status" class="hint">idle</span>
|
||||
</div>
|
||||
<div class="step-preview" id="step-preview-content">
|
||||
<div class="step-preview-empty">Create a busbar to see the exported geometry here.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- History modal -->
|
||||
<div id="history-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Snapshot history</h3>
|
||||
<button id="btn-history-close" class="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="hint">Each auto-save creates a snapshot of the prior state (max 20 per project). Restore rolls back; the current state is auto-snapshotted first.</p>
|
||||
<ul id="history-list" class="history-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/importer.js"></script>
|
||||
<script src="js/groups.js"></script>
|
||||
<script src="js/geometry.js"></script>
|
||||
<script src="js/viewport.js"></script>
|
||||
<script src="js/exporter.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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}`),
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,666 @@
|
||||
/* app.js — top-level controller.
|
||||
*
|
||||
* Persistence model:
|
||||
* - Each "project" is a row in SQLite holding the full editor state
|
||||
* (cells + busbars + params + activeBusbarId).
|
||||
* - Active project id lives in the URL as ?p=<id>; bookmarking / opening
|
||||
* in another browser reloads the same state.
|
||||
* - Any onStateChanged() fires Viewport.render(), schedulePreview() and
|
||||
* scheduleAutoSave(). Auto-save is debounced 1500 ms; the first save in
|
||||
* each 60 s window also writes a snapshot (history).
|
||||
* - If the user edits before naming a project, one is auto-created with a
|
||||
* timestamp name; URL is updated.
|
||||
*
|
||||
* Presets are separate from projects: just a named blob of params.
|
||||
*/
|
||||
|
||||
(() => {
|
||||
|
||||
// ---- state ---------------------------------------------------------------
|
||||
const state = {
|
||||
cells: [],
|
||||
busbars: [],
|
||||
cellToBusbar: new Map(),
|
||||
selection: new Set(),
|
||||
activeBusbarId: null,
|
||||
};
|
||||
|
||||
const params = {
|
||||
cellDia: 21.2,
|
||||
openingDia: 15.2,
|
||||
stripWidth: 6.0,
|
||||
padRadius: 9.0,
|
||||
holeRadius: 6.0,
|
||||
holeShape: "cross",
|
||||
slitWidth: 1.0,
|
||||
neighborFactor: 1.15,
|
||||
extrude: false,
|
||||
thickness: 0.2,
|
||||
};
|
||||
|
||||
let currentProject = null; // { id, name } when one is open
|
||||
let isSaving = false;
|
||||
let pendingSave = false;
|
||||
let isDirty = false; // unsaved changes since the last successful save
|
||||
let autoSaveTimer = null;
|
||||
let lastSnapshotAt = 0;
|
||||
const SNAPSHOT_INTERVAL_MS = 60_000;
|
||||
|
||||
let stepPreviewEnabled = true;
|
||||
let stepPreviewTimer = null;
|
||||
let stepPreviewInFlight = null;
|
||||
|
||||
// ---- helpers -------------------------------------------------------------
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const fmtMm = (v) => (Math.round(v * 100) / 100).toFixed(2);
|
||||
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
|
||||
function setSaveStatus(cls, text) {
|
||||
const el = $("save-status");
|
||||
if (!el) return;
|
||||
el.className = "save-status " + (cls || "");
|
||||
el.textContent = text || "—";
|
||||
}
|
||||
|
||||
// ---- tabs ---------------------------------------------------------------
|
||||
document.querySelectorAll(".tab").forEach((t) => {
|
||||
t.addEventListener("click", () => {
|
||||
document.querySelectorAll(".tab").forEach((x) => x.classList.remove("active"));
|
||||
t.classList.add("active");
|
||||
const target = t.dataset.tab;
|
||||
document.querySelectorAll(".tab-body").forEach((b) => {
|
||||
b.classList.toggle("hidden", b.dataset.tabBody !== target);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- cell import --------------------------------------------------------
|
||||
$("btn-import-paste").addEventListener("click", () => loadCells(Importer.parsePaste($("paste-text").value)));
|
||||
$("btn-import-csv" ).addEventListener("click", () => loadCells(Importer.parseCSV ($("csv-text" ).value)));
|
||||
$("btn-import-json" ).addEventListener("click", () => {
|
||||
try { loadCells(Importer.parseJSON($("json-text").value)); }
|
||||
catch (e) { alert(`JSON parse error: ${e.message}`); }
|
||||
});
|
||||
$("btn-import-gen").addEventListener("click", () => {
|
||||
loadCells(Importer.generate({
|
||||
cellDia: +$("gen-cell-dia").value,
|
||||
wall: +$("gen-wall").value,
|
||||
rows: +$("gen-rows").value,
|
||||
cols: +$("gen-cols").value,
|
||||
style: $("gen-style").value,
|
||||
}));
|
||||
});
|
||||
|
||||
// ---- param inputs -------------------------------------------------------
|
||||
const paramInputs = [
|
||||
["p-cell-dia", "cellDia"],
|
||||
["p-opening-dia", "openingDia"],
|
||||
["p-strip-width", "stripWidth"],
|
||||
["p-pad-radius", "padRadius"],
|
||||
["p-hole-radius", "holeRadius"],
|
||||
["p-slit-width", "slitWidth"],
|
||||
["p-neighbor-factor", "neighborFactor"],
|
||||
["p-thickness", "thickness"],
|
||||
];
|
||||
for (const [id, key] of paramInputs) {
|
||||
$(id).addEventListener("input", () => {
|
||||
params[key] = +$(id).value;
|
||||
onStateChanged();
|
||||
});
|
||||
}
|
||||
$("p-hole-shape").addEventListener("change", () => {
|
||||
params.holeShape = $("p-hole-shape").value;
|
||||
onStateChanged();
|
||||
});
|
||||
$("p-extrude").addEventListener("change", () => {
|
||||
params.extrude = $("p-extrude").checked;
|
||||
schedulePreview();
|
||||
scheduleAutoSave();
|
||||
});
|
||||
|
||||
function _syncParamInputs() {
|
||||
for (const [id, key] of paramInputs) $(id).value = params[key];
|
||||
$("p-hole-shape").value = params.holeShape || "cross";
|
||||
$("p-extrude").checked = !!params.extrude;
|
||||
}
|
||||
|
||||
// ---- busbar create button -----------------------------------------------
|
||||
$("btn-new-busbar").addEventListener("click", () => {
|
||||
const ids = [...state.selection];
|
||||
if (!ids.length) { alert("Select cells first."); return; }
|
||||
const bb = Groups.create(state, ids);
|
||||
state.activeBusbarId = bb.id;
|
||||
state.selection.clear();
|
||||
renderBusbarList();
|
||||
updateSelInfo();
|
||||
onStateChanged();
|
||||
});
|
||||
|
||||
// ---- .json export / import (file-based; complements server storage) -----
|
||||
$("btn-save").addEventListener("click", saveProjectAsFile);
|
||||
$("btn-load").addEventListener("click", () => $("file-load").click());
|
||||
$("file-load").addEventListener("change", (e) => {
|
||||
const f = e.target.files[0];
|
||||
if (!f) return;
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
try { applyState(JSON.parse(r.result)); onStateChanged(); }
|
||||
catch (err) { alert(err.message); }
|
||||
};
|
||||
r.readAsText(f);
|
||||
e.target.value = "";
|
||||
});
|
||||
|
||||
// ---- export buttons -----------------------------------------------------
|
||||
$("btn-export-step").addEventListener("click", () => Exporter.exportFormat("step", state, params));
|
||||
$("btn-export-dxf" ).addEventListener("click", () => Exporter.exportFormat("dxf", state, params));
|
||||
$("btn-export-svg" ).addEventListener("click", () => Exporter.exportFormat("svg", state, params));
|
||||
|
||||
// ---- viewport init ------------------------------------------------------
|
||||
Viewport.init($("viewport"), state, params, {
|
||||
onCellClick: (cellId, mods) => {
|
||||
if (mods.alt) state.selection.delete(cellId);
|
||||
else if (mods.shift) state.selection.add(cellId);
|
||||
else { state.selection.clear(); state.selection.add(cellId); }
|
||||
updateSelInfo();
|
||||
Viewport.render();
|
||||
},
|
||||
onCursorMove: (x, y) => {
|
||||
$("cursor-pos").textContent = `x: ${fmtMm(x)} , y: ${fmtMm(y)}`;
|
||||
},
|
||||
onZoomChange: (s) => {
|
||||
$("zoom-info").textContent = `${fmtMm(s)} px/mm`;
|
||||
},
|
||||
});
|
||||
|
||||
// ---- project bar --------------------------------------------------------
|
||||
$("project-select").addEventListener("change", (e) => {
|
||||
const id = +e.target.value;
|
||||
if (!id) return;
|
||||
openProject(id);
|
||||
});
|
||||
|
||||
$("project-name").addEventListener("change", async (e) => {
|
||||
if (!currentProject) return;
|
||||
const name = e.target.value.trim();
|
||||
if (!name) { e.target.value = currentProject.name; return; }
|
||||
try {
|
||||
await Api.updateProject(currentProject.id, { name });
|
||||
currentProject.name = name;
|
||||
await refreshProjectList();
|
||||
setSaveStatus("saved", "renamed");
|
||||
setTimeout(() => setSaveStatus("", "—"), 1500);
|
||||
} catch (err) { alert(err.message); }
|
||||
});
|
||||
|
||||
$("btn-project-new").addEventListener("click", async () => {
|
||||
const name = prompt(
|
||||
"New project name:",
|
||||
`Project ${new Date().toLocaleDateString()}`
|
||||
);
|
||||
if (name === null) return;
|
||||
try {
|
||||
const r = await Api.createProject(name.trim() || "Untitled", _emptyProjectData());
|
||||
await refreshProjectList();
|
||||
openProject(r.id);
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
|
||||
$("btn-project-del").addEventListener("click", async () => {
|
||||
if (!currentProject) return;
|
||||
if (!confirm(`Delete project "${currentProject.name}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await Api.deleteProject(currentProject.id);
|
||||
currentProject = null;
|
||||
$("project-name").value = "";
|
||||
history.replaceState({}, "", location.pathname);
|
||||
await refreshProjectList();
|
||||
applyState(_emptyProjectData());
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
|
||||
// ---- history modal ------------------------------------------------------
|
||||
$("btn-history").addEventListener("click", showHistory);
|
||||
$("btn-history-close").addEventListener("click", () => $("history-modal").classList.add("hidden"));
|
||||
$("history-modal").addEventListener("click", (e) => {
|
||||
if (e.target.id === "history-modal") $("history-modal").classList.add("hidden");
|
||||
});
|
||||
|
||||
async function showHistory() {
|
||||
if (!currentProject) { alert("Open a project first."); return; }
|
||||
let snaps;
|
||||
try { snaps = await Api.listSnapshots(currentProject.id); }
|
||||
catch (e) { alert(e.message); return; }
|
||||
const ul = $("history-list");
|
||||
if (!snaps.length) {
|
||||
ul.innerHTML = '<li class="hint">No snapshots yet.</li>';
|
||||
} else {
|
||||
ul.innerHTML = snaps.map((s) => `
|
||||
<li class="history-item">
|
||||
<span class="time">${escapeHtml(s.created_at)}</span>
|
||||
<span class="note">${escapeHtml(s.note || "(auto)")}</span>
|
||||
<button data-id="${s.id}" class="restore">Restore</button>
|
||||
</li>`).join("");
|
||||
ul.querySelectorAll(".restore").forEach((b) => {
|
||||
b.addEventListener("click", async () => {
|
||||
if (!confirm("Restore this snapshot? The current state will be saved to history first.")) return;
|
||||
try {
|
||||
await Api.restoreSnapshot(+b.dataset.id);
|
||||
$("history-modal").classList.add("hidden");
|
||||
await openProject(currentProject.id);
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
});
|
||||
}
|
||||
$("history-modal").classList.remove("hidden");
|
||||
}
|
||||
|
||||
// ---- presets ------------------------------------------------------------
|
||||
$("btn-preset-apply").addEventListener("click", async () => {
|
||||
const id = +$("preset-select").value;
|
||||
if (!id) return;
|
||||
try {
|
||||
const p = await Api.getPreset?.(id) || (await Api.listPresets()).find((x) => x.id === id);
|
||||
if (!p) return;
|
||||
Object.assign(params, p.params);
|
||||
_syncParamInputs();
|
||||
onStateChanged();
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
|
||||
$("btn-preset-save").addEventListener("click", async () => {
|
||||
const name = prompt("Preset name (e.g. '21700 0.2mm Ni'):");
|
||||
if (!name) return;
|
||||
try {
|
||||
await Api.createPreset(name.trim(), { ...params });
|
||||
await refreshPresetList();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
});
|
||||
|
||||
$("btn-preset-del").addEventListener("click", async () => {
|
||||
const id = +$("preset-select").value;
|
||||
if (!id) return;
|
||||
const name = $("preset-select").selectedOptions[0]?.text || "";
|
||||
if (!confirm(`Delete preset "${name}"?`)) return;
|
||||
try {
|
||||
await Api.deletePreset(id);
|
||||
await refreshPresetList();
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
|
||||
// ---- top-level orchestration --------------------------------------------
|
||||
function _emptyProjectData() {
|
||||
return { params: { ...params }, cells: [], busbars: [], activeBusbarId: null };
|
||||
}
|
||||
|
||||
function loadCells(cells) {
|
||||
if (!cells || !cells.length) { alert("No cells parsed."); return; }
|
||||
state.cells = cells;
|
||||
state.busbars = [];
|
||||
state.cellToBusbar = new Map();
|
||||
state.selection = new Set();
|
||||
Groups.reset();
|
||||
updateStatus();
|
||||
renderBusbarList();
|
||||
updateSelInfo();
|
||||
Viewport.fitToContent();
|
||||
Viewport.render();
|
||||
schedulePreview();
|
||||
// Save NOW (skip debounce) — most "I refreshed and lost everything"
|
||||
// reports happen when the user refreshes within the 1.5 s window.
|
||||
clearTimeout(autoSaveTimer);
|
||||
performAutoSave();
|
||||
}
|
||||
|
||||
function applyState(data) {
|
||||
if (data.params) Object.assign(params, data.params);
|
||||
state.cells = (data.cells || []).map((c, i) => ({ id: c.id ?? i + 1, x: +c.x, y: +c.y }));
|
||||
Groups.reset();
|
||||
state.busbars = (data.busbars || []).map((b) => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
color: b.color,
|
||||
shape: b.shape || "panel",
|
||||
cells: [...(b.cells || [])],
|
||||
}));
|
||||
state.cellToBusbar = new Map();
|
||||
for (const b of state.busbars) for (const cid of b.cells) state.cellToBusbar.set(cid, b.id);
|
||||
state.selection = new Set();
|
||||
state.activeBusbarId = data.activeBusbarId ?? null;
|
||||
_syncParamInputs();
|
||||
updateStatus();
|
||||
renderBusbarList();
|
||||
updateSelInfo();
|
||||
Viewport.fitToContent();
|
||||
schedulePreview();
|
||||
}
|
||||
|
||||
function onStateChanged() {
|
||||
Viewport.render();
|
||||
schedulePreview();
|
||||
scheduleAutoSave();
|
||||
}
|
||||
|
||||
function updateStatus() {
|
||||
const el = $("status");
|
||||
if (!el) return;
|
||||
el.textContent = state.cells.length
|
||||
? `${state.cells.length} cells loaded`
|
||||
: "No cells loaded";
|
||||
}
|
||||
|
||||
function updateSelInfo() {
|
||||
const el = $("sel-info");
|
||||
if (!el) return;
|
||||
el.textContent = `${state.selection.size} selected`;
|
||||
}
|
||||
|
||||
// ---- busbar list --------------------------------------------------------
|
||||
function renderBusbarList() {
|
||||
const ul = $("busbar-list");
|
||||
ul.innerHTML = "";
|
||||
for (const bb of state.busbars) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "busbar-item" + (bb.id === state.activeBusbarId ? " active" : "");
|
||||
|
||||
const swatch = document.createElement("input");
|
||||
swatch.type = "color";
|
||||
swatch.className = "busbar-color";
|
||||
swatch.value = bb.color;
|
||||
swatch.addEventListener("input", (e) => {
|
||||
Groups.recolor(state, bb.id, e.target.value);
|
||||
onStateChanged();
|
||||
});
|
||||
|
||||
const name = document.createElement("input");
|
||||
name.className = "busbar-name";
|
||||
name.value = bb.name;
|
||||
name.addEventListener("change", (e) => {
|
||||
Groups.rename(state, bb.id, e.target.value);
|
||||
scheduleAutoSave();
|
||||
});
|
||||
|
||||
const count = document.createElement("span");
|
||||
count.className = "busbar-count";
|
||||
count.textContent = `${bb.cells.length}p`;
|
||||
|
||||
const shapeSel = document.createElement("select");
|
||||
shapeSel.className = "busbar-shape";
|
||||
shapeSel.title = "Shape: panel = production plate; wire = thin strip";
|
||||
for (const opt of ["panel", "wire"]) {
|
||||
const o = document.createElement("option");
|
||||
o.value = opt; o.textContent = opt;
|
||||
if (bb.shape === opt) o.selected = true;
|
||||
shapeSel.appendChild(o);
|
||||
}
|
||||
shapeSel.addEventListener("change", (e) => {
|
||||
bb.shape = e.target.value;
|
||||
onStateChanged();
|
||||
});
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "busbar-actions";
|
||||
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.textContent = "+ sel";
|
||||
addBtn.addEventListener("click", () => {
|
||||
Groups.addCells(state, bb.id, [...state.selection]);
|
||||
state.selection.clear();
|
||||
renderBusbarList(); updateSelInfo(); onStateChanged();
|
||||
});
|
||||
|
||||
const remBtn = document.createElement("button");
|
||||
remBtn.textContent = "− sel";
|
||||
remBtn.addEventListener("click", () => {
|
||||
Groups.removeCells(state, bb.id, [...state.selection]);
|
||||
state.selection.clear();
|
||||
renderBusbarList(); updateSelInfo(); onStateChanged();
|
||||
});
|
||||
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "del";
|
||||
delBtn.textContent = "×";
|
||||
delBtn.addEventListener("click", () => {
|
||||
if (!confirm(`Delete busbar "${bb.name}"?`)) return;
|
||||
Groups.remove(state, bb.id);
|
||||
renderBusbarList(); onStateChanged();
|
||||
});
|
||||
|
||||
actions.appendChild(addBtn); actions.appendChild(remBtn); actions.appendChild(delBtn);
|
||||
|
||||
li.appendChild(swatch); li.appendChild(name); li.appendChild(count);
|
||||
li.appendChild(shapeSel); li.appendChild(actions);
|
||||
li.addEventListener("click", (e) => {
|
||||
if (e.target !== li) return;
|
||||
state.activeBusbarId = bb.id;
|
||||
renderBusbarList();
|
||||
});
|
||||
ul.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- .json file save (download) -----------------------------------------
|
||||
function saveProjectAsFile() {
|
||||
const blob = new Blob([JSON.stringify(_serialize(), null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = (currentProject ? currentProject.name : "busbar-project") + ".json";
|
||||
document.body.appendChild(a); a.click();
|
||||
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
|
||||
}
|
||||
|
||||
function _serialize() {
|
||||
return {
|
||||
params,
|
||||
cells: state.cells,
|
||||
busbars: state.busbars,
|
||||
activeBusbarId: state.activeBusbarId,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- server: projects ---------------------------------------------------
|
||||
async function refreshProjectList() {
|
||||
try {
|
||||
const list = await Api.listProjects();
|
||||
const sel = $("project-select");
|
||||
const keep = sel.value;
|
||||
sel.innerHTML = '<option value="">— select project —</option>' +
|
||||
list.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join("");
|
||||
if (currentProject) sel.value = String(currentProject.id);
|
||||
else sel.value = keep;
|
||||
} catch (e) {
|
||||
console.error("refreshProjectList:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function openProject(id) {
|
||||
try {
|
||||
const p = await Api.getProject(id);
|
||||
currentProject = { id: p.id, name: p.name };
|
||||
lastSnapshotAt = 0;
|
||||
$("project-name").value = p.name;
|
||||
$("project-select").value = String(id);
|
||||
history.replaceState({}, "", `?p=${id}`);
|
||||
applyState(p.data || {});
|
||||
setSaveStatus("saved", "loaded");
|
||||
setTimeout(() => setSaveStatus("", "—"), 1500);
|
||||
} catch (e) {
|
||||
alert(`Open project failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureProject() {
|
||||
if (currentProject) return;
|
||||
const name = `Untitled ${new Date().toLocaleString()}`;
|
||||
try {
|
||||
const r = await Api.createProject(name, _serialize());
|
||||
currentProject = { id: r.id, name: r.name };
|
||||
$("project-name").value = r.name;
|
||||
history.replaceState({}, "", `?p=${r.id}`);
|
||||
await refreshProjectList();
|
||||
} catch (e) {
|
||||
console.error("ensureProject:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- auto-save ----------------------------------------------------------
|
||||
function scheduleAutoSave() {
|
||||
isDirty = true;
|
||||
setSaveStatus("dirty", "● unsaved");
|
||||
clearTimeout(autoSaveTimer);
|
||||
autoSaveTimer = setTimeout(performAutoSave, 1500);
|
||||
}
|
||||
|
||||
async function performAutoSave() {
|
||||
await ensureProject();
|
||||
if (!currentProject) return;
|
||||
if (isSaving) { pendingSave = true; return; }
|
||||
isSaving = true;
|
||||
setSaveStatus("saving", "saving…");
|
||||
try {
|
||||
const now = Date.now();
|
||||
const shouldSnapshot = now - lastSnapshotAt > SNAPSHOT_INTERVAL_MS;
|
||||
await Api.updateProject(currentProject.id, {
|
||||
data: _serialize(),
|
||||
snapshot: shouldSnapshot,
|
||||
note: shouldSnapshot ? "auto-save" : null,
|
||||
});
|
||||
if (shouldSnapshot) lastSnapshotAt = now;
|
||||
isDirty = false;
|
||||
setSaveStatus("saved", "✓ saved");
|
||||
// Don't clear the indicator — keep "saved" visible until the next change.
|
||||
} catch (e) {
|
||||
setSaveStatus("error", `error: ${e.message}`);
|
||||
} finally {
|
||||
isSaving = false;
|
||||
if (pendingSave) {
|
||||
pendingSave = false;
|
||||
scheduleAutoSave();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- manual save + unload guard ----------------------------------------
|
||||
$("btn-save-now").addEventListener("click", async () => {
|
||||
clearTimeout(autoSaveTimer);
|
||||
await performAutoSave();
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", (e) => {
|
||||
if (isDirty) {
|
||||
// Some browsers ignore custom messages but show a generic warning.
|
||||
e.preventDefault();
|
||||
e.returnValue = "You have unsaved changes. Leave anyway?";
|
||||
return e.returnValue;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- server: presets ----------------------------------------------------
|
||||
async function refreshPresetList() {
|
||||
try {
|
||||
const list = await Api.listPresets();
|
||||
const sel = $("preset-select");
|
||||
const keep = sel.value;
|
||||
sel.innerHTML = '<option value="">— saved preset —</option>' +
|
||||
list.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join("");
|
||||
sel.value = keep;
|
||||
} catch (e) {
|
||||
console.error("refreshPresetList:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- live STEP preview --------------------------------------------------
|
||||
$("step-preview-collapse").addEventListener("click", () => {
|
||||
$("step-preview-wrap").classList.toggle("collapsed");
|
||||
// After the pane resizes, canvas may need to refit — the ResizeObserver
|
||||
// in viewport.js handles the backing-store, but tx/ty/scale are cached.
|
||||
requestAnimationFrame(() => Viewport.fitToContent());
|
||||
});
|
||||
|
||||
$("step-preview-toggle").addEventListener("change", (e) => {
|
||||
stepPreviewEnabled = e.target.checked;
|
||||
if (stepPreviewEnabled) updateStepPreview();
|
||||
else {
|
||||
$("step-preview-content").innerHTML =
|
||||
'<div class="step-preview-empty">Live preview disabled.</div>';
|
||||
$("step-preview-status").textContent = "off";
|
||||
}
|
||||
});
|
||||
|
||||
function schedulePreview() {
|
||||
if (!stepPreviewEnabled) return;
|
||||
clearTimeout(stepPreviewTimer);
|
||||
stepPreviewTimer = setTimeout(updateStepPreview, 400);
|
||||
}
|
||||
|
||||
async function updateStepPreview() {
|
||||
const content = $("step-preview-content");
|
||||
const status = $("step-preview-status");
|
||||
if (!state.busbars.length) {
|
||||
content.innerHTML =
|
||||
'<div class="step-preview-empty">Create a busbar to see the exported geometry here.</div>';
|
||||
status.textContent = "—";
|
||||
return;
|
||||
}
|
||||
status.textContent = "loading…";
|
||||
const reqId = Symbol("preview");
|
||||
stepPreviewInFlight = reqId;
|
||||
try {
|
||||
const payload = Exporter.buildPayload(state, params);
|
||||
const res = await fetch("/api/export/svg", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (stepPreviewInFlight !== reqId) return;
|
||||
if (!res.ok) {
|
||||
let err = res.statusText;
|
||||
try { err = (await res.json()).error || err; } catch {}
|
||||
content.innerHTML = `<div class="step-preview-err">${escapeHtml(err)}</div>`;
|
||||
status.textContent = "error";
|
||||
return;
|
||||
}
|
||||
const svgText = await res.text();
|
||||
if (stepPreviewInFlight !== reqId) return;
|
||||
content.innerHTML = svgText;
|
||||
const svgEl = content.querySelector("svg");
|
||||
if (svgEl) {
|
||||
svgEl.removeAttribute("width");
|
||||
svgEl.removeAttribute("height");
|
||||
}
|
||||
status.textContent = `ok (${Math.round(svgText.length / 1024)} kB)`;
|
||||
} catch (e) {
|
||||
content.innerHTML = `<div class="step-preview-err">${escapeHtml(e.message)}</div>`;
|
||||
status.textContent = "error";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- init ---------------------------------------------------------------
|
||||
async function init() {
|
||||
await Promise.all([refreshProjectList(), refreshPresetList()]);
|
||||
|
||||
// URL routing: ?p=<id> opens that project.
|
||||
const urlPid = new URLSearchParams(location.search).get("p");
|
||||
if (urlPid && /^\d+$/.test(urlPid)) {
|
||||
try { await openProject(+urlPid); }
|
||||
catch (e) {
|
||||
// Project doesn't exist anymore — strip query and show empty.
|
||||
history.replaceState({}, "", location.pathname);
|
||||
currentProject = null;
|
||||
updateStatus();
|
||||
renderBusbarList();
|
||||
updateSelInfo();
|
||||
}
|
||||
} else {
|
||||
updateStatus();
|
||||
renderBusbarList();
|
||||
updateSelInfo();
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -0,0 +1,532 @@
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--panel: #161a22;
|
||||
--panel-2: #1e242f;
|
||||
--border: #2a313d;
|
||||
--text: #e4e7ec;
|
||||
--muted: #8a93a3;
|
||||
--accent: #f08a24;
|
||||
--accent-2: #4fa3ff;
|
||||
--danger: #d94a4a;
|
||||
--grid: #1f2530;
|
||||
--grid-major: #2a3140;
|
||||
--cell: #4a5365;
|
||||
--cell-sel: #f08a24;
|
||||
--cell-busbar: #4fa3ff;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden; /* never let the page itself scroll — flex children handle their own overflow */
|
||||
}
|
||||
|
||||
/* ---- topbar ---- */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 10px 16px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.topbar .status {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.topbar .actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.project-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
border-left: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-bar select,
|
||||
.project-bar input[type=text] {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
width: auto;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.project-bar select { min-width: 140px; }
|
||||
|
||||
.save-status {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-family: "SF Mono", Consolas, monospace;
|
||||
margin-left: 4px;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.topbar-status {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
padding-left: 8px;
|
||||
margin-left: 4px;
|
||||
border-left: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.save-status.dirty { color: var(--accent); }
|
||||
.save-status.saving { color: var(--accent-2); }
|
||||
.save-status.saved { color: #3ecf8e; }
|
||||
.save-status.error { color: var(--danger); }
|
||||
|
||||
button.danger {
|
||||
background: var(--panel);
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
button.danger:hover {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ---- modal ---- */
|
||||
.modal {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal.hidden { display: none; }
|
||||
.modal-content {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
max-width: 560px;
|
||||
width: 90vw;
|
||||
max-height: 80vh;
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: var(--panel-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-header h3 { margin: 0; font-size: 14px; }
|
||||
.modal-close {
|
||||
background: transparent; border: 0;
|
||||
color: var(--muted); font-size: 20px; line-height: 1;
|
||||
cursor: pointer; padding: 0 4px;
|
||||
}
|
||||
.modal-close:hover { color: var(--text); }
|
||||
.modal-body {
|
||||
padding: 12px 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
list-style: none; margin: 0; padding: 0;
|
||||
}
|
||||
.history-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.history-item .time {
|
||||
font-family: "SF Mono", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
min-width: 140px;
|
||||
}
|
||||
.history-item .note { flex: 1; color: var(--text); font-size: 12px; }
|
||||
.history-item button { padding: 2px 8px; font-size: 11px; }
|
||||
|
||||
.topbar .sep {
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: var(--border);
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
/* ---- main grid ---- */
|
||||
main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.left {
|
||||
width: 380px;
|
||||
flex-shrink: 0;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.viewport-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.step-preview-wrap {
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.step-preview-wrap.collapsed {
|
||||
height: auto;
|
||||
}
|
||||
.step-preview-wrap.collapsed .step-preview {
|
||||
display: none;
|
||||
}
|
||||
.step-preview-wrap.collapsed .step-preview-collapse {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.step-preview-collapse {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 0 6px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.step-preview-collapse:hover { color: var(--text); }
|
||||
|
||||
.step-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.step-preview-header .hint { margin: 0; font-size: 11px; }
|
||||
|
||||
.step-preview {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.step-preview svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.step-preview-empty,
|
||||
.step-preview-err {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.step-preview-err { color: var(--danger); }
|
||||
|
||||
.preset-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.preset-row select { flex: 1; min-width: 140px; }
|
||||
.preset-row button { padding: 4px 8px; font-size: 11px; }
|
||||
.preset-row .hint { margin: 0; font-size: 11px; flex-basis: 100%; }
|
||||
|
||||
#viewport {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.viewport-overlay {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 12px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
pointer-events: none;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-family: "SF Mono", Consolas, monospace;
|
||||
background: rgba(15, 17, 21, 0.6);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ---- panels ---- */
|
||||
.panel {
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.hint code {
|
||||
background: rgba(255,255,255,0.05);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ---- tabs ---- */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab-body { display: block; }
|
||||
.tab-body.hidden { display: none; }
|
||||
|
||||
/* ---- forms ---- */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
label.checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
input[type=number], input[type=text], select, textarea {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
font-family: "SF Mono", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-2);
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button:hover { background: var(--panel-2); }
|
||||
button:active { transform: translateY(1px); }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #1a1a1a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button.primary:hover { background: #ff9933; }
|
||||
|
||||
.tab-body button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ---- busbars ---- */
|
||||
.busbar-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sel-info {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.busbar-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.busbar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.busbar-item.active {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.busbar-color {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.busbar-name {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text);
|
||||
padding: 2px 4px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.busbar-name:focus { background: var(--panel); border-radius: 3px; }
|
||||
|
||||
.busbar-count {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-family: "SF Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
.busbar-shape {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
padding: 1px 4px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.busbar-actions { display: flex; gap: 4px; }
|
||||
|
||||
.busbar-actions button {
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.busbar-actions .del { color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.left { width: 320px; }
|
||||
}
|
||||
+260
@@ -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
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Sanity tests for busbar_export — exercise STEP/DXF/SVG writers end-to-end."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from busbar_export import to_step, to_dxf, to_svg
|
||||
|
||||
|
||||
def _payload():
|
||||
return {
|
||||
"extrude": False,
|
||||
"busbars": [
|
||||
{
|
||||
"name": "P1",
|
||||
"color": "#ff8800",
|
||||
"strip_width": 8.0,
|
||||
"pad_radius": 7.0,
|
||||
"hole_radius": 5.0,
|
||||
"cells": [
|
||||
{"id": 1, "x": 0.0, "y": 0.0},
|
||||
{"id": 2, "x": 22.8, "y": 0.0},
|
||||
{"id": 3, "x": 45.6, "y": 0.0},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_step_export_is_iso_10303():
|
||||
data = to_step(_payload())
|
||||
text = data[:200].decode("ascii", errors="ignore")
|
||||
assert "ISO-10303-21" in text, f"STEP header missing, got: {text!r}"
|
||||
assert len(data) > 500
|
||||
|
||||
|
||||
def test_dxf_export_is_nonempty():
|
||||
data = to_dxf(_payload())
|
||||
assert b"SECTION" in data[:2000]
|
||||
|
||||
|
||||
def test_svg_export_is_svg():
|
||||
data = to_svg(_payload())
|
||||
assert b"<svg" in data[:2000]
|
||||
|
||||
|
||||
def test_extruded_step_includes_solid():
|
||||
payload = _payload()
|
||||
payload["extrude"] = True
|
||||
payload["thickness"] = 0.2
|
||||
data = to_step(payload)
|
||||
assert b"MANIFOLD_SOLID_BREP" in data or b"CLOSED_SHELL" in data
|
||||
|
||||
|
||||
def test_panel_5p_row_is_stadium():
|
||||
"""5 cells in a row → collinear hull → stadium-shaped panel."""
|
||||
payload = {
|
||||
"busbars": [{
|
||||
"name": "5P",
|
||||
"shape": "panel",
|
||||
"pad_radius": 10.0,
|
||||
"hole_radius": 5.0,
|
||||
"cells": [{"id": i + 1, "x": i * 22.8, "y": 0.0} for i in range(5)],
|
||||
}]
|
||||
}
|
||||
data = to_step(payload)
|
||||
assert b"ISO-10303-21" in data[:200]
|
||||
assert len(data) > 500
|
||||
|
||||
|
||||
def test_panel_grid_uses_convex_hull():
|
||||
"""4-cell square → 4-vertex hull → rounded rectangle."""
|
||||
payload = {
|
||||
"busbars": [{
|
||||
"name": "4P",
|
||||
"shape": "panel",
|
||||
"pad_radius": 10.0,
|
||||
"hole_radius": 5.0,
|
||||
"cells": [
|
||||
{"id": 1, "x": 0.0, "y": 0.0},
|
||||
{"id": 2, "x": 22.8, "y": 0.0},
|
||||
{"id": 3, "x": 22.8, "y": 19.746},
|
||||
{"id": 4, "x": 0.0, "y": 19.746},
|
||||
],
|
||||
}]
|
||||
}
|
||||
data = to_step(payload)
|
||||
assert b"ISO-10303-21" in data[:200]
|
||||
|
||||
|
||||
def test_panel_single_cell_is_disc_with_hole():
|
||||
payload = {
|
||||
"busbars": [{
|
||||
"name": "1P",
|
||||
"shape": "panel",
|
||||
"pad_radius": 10.0,
|
||||
"hole_radius": 5.0,
|
||||
"cells": [{"id": 1, "x": 0.0, "y": 0.0}],
|
||||
}]
|
||||
}
|
||||
data = to_step(payload)
|
||||
assert b"ISO-10303-21" in data[:200]
|
||||
|
||||
|
||||
def test_wire_shape_still_works():
|
||||
payload = {
|
||||
"busbars": [{
|
||||
"name": "wire",
|
||||
"shape": "wire",
|
||||
"strip_width": 6.0,
|
||||
"pad_radius": 7.0,
|
||||
"hole_radius": 5.0,
|
||||
"cells": [{"x": 0, "y": 0}, {"x": 22.8, "y": 0}],
|
||||
}]
|
||||
}
|
||||
data = to_step(payload)
|
||||
assert b"ISO-10303-21" in data[:200]
|
||||
|
||||
|
||||
def test_panel_L_shape_has_no_diagonal_bridge():
|
||||
"""L-shape (7 cells in column + 2 cells across top) must not bridge across
|
||||
non-selected cells. The convex-hull approach would have, but neighbor-edge
|
||||
must not — verify by checking that the area of the resulting face is close
|
||||
to the sum of stadium-chain segments, not the area of the L's bounding box.
|
||||
"""
|
||||
from busbar_export import parse_payload, busbar_sketch
|
||||
cells = [{"x": 0, "y": i * 19.75} for i in range(7)]
|
||||
cells += [{"x": 22.8, "y": 6 * 19.75}, {"x": 45.6, "y": 6 * 19.75}]
|
||||
payload = {"busbars": [{
|
||||
"name": "L", "shape": "panel",
|
||||
"pad_radius": 10.0, "hole_radius": 6.0,
|
||||
"hole_shape": "cross", "slit_width": 1.8,
|
||||
"cells": cells,
|
||||
}]}
|
||||
busbars, *_ = parse_payload(payload)
|
||||
face = busbar_sketch(busbars[0])
|
||||
area = face.area
|
||||
|
||||
# Pad disc area = π·10² ≈ 314 per cell, 9 cells → 2826 max if all disjoint.
|
||||
# Bridges add some, holes subtract some. The L's bounding box is
|
||||
# 65.6 × 138.25 ≈ 9070 — convex hull would be at least ~4500. Neighbor
|
||||
# chain should be well under 3500.
|
||||
assert 2000 < area < 3500, f"area {area} suggests convex-hull bridging"
|
||||
|
||||
|
||||
def test_cross_slit_punches_through():
|
||||
"""Cross slit must remove area from the panel (vs. no holes baseline)."""
|
||||
from busbar_export import parse_payload, busbar_sketch
|
||||
base = {
|
||||
"busbars": [{
|
||||
"name": "P", "shape": "panel", "pad_radius": 10.0,
|
||||
"hole_radius": 6.0, "hole_shape": "cross", "slit_width": 1.8,
|
||||
"cells": [{"x": 0, "y": 0}, {"x": 22.8, "y": 0}],
|
||||
}]
|
||||
}
|
||||
with_cross, *_ = parse_payload(base)
|
||||
area_cross = busbar_sketch(with_cross[0]).area
|
||||
|
||||
# Same payload, tiny hole — should be nearly the same area as un-punched.
|
||||
base["busbars"][0]["hole_radius"] = 0.1
|
||||
base["busbars"][0]["slit_width"] = 0.1
|
||||
tiny, *_ = parse_payload(base)
|
||||
area_tiny = busbar_sketch(tiny[0]).area
|
||||
|
||||
assert area_tiny > area_cross + 50, \
|
||||
f"cross didn't punch much: tiny={area_tiny:.1f}, cross={area_cross:.1f}"
|
||||
@@ -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) == []
|
||||
Reference in New Issue
Block a user