From e4a80dc0e68caca468f2c9536b20112fe0472c1e Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 24 Apr 2026 19:18:39 -0400 Subject: [PATCH] Add views, import/duplicate, DRC, jumps toggle, multi-wire select, auto-route, delete view tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Roadmap #22: File→Import JSON to restore a diagram (drag-drop or file picker) - Roadmap #23: Duplicate diagram from diagram list - Roadmap #24: DRC panel with collapsible categories (unconnected pins, dup refs, no length, no P/N) - Multi-view tabs: named views per diagram with independent device/wire layouts; snapshot-on-entry prevents bleed - Keyboard arrow nudge for selected device(s) - Undo for move, duplicate, label/property changes - Wire jump arcs toggle (⌒ Jumps button in toolbar) - Shift+click multi-wire selection; right-click bulk clear waypoints or auto-route - Auto-route: obstacle-avoiding ortho routing for single or bulk selected wires - Delete view tab with × button; right-click context menu for rename/delete Co-Authored-By: Claude Sonnet 4.6 --- ROADMAP.md | 6 +- backend/app/main.py | 18 +- backend/app/models.py | 35 +++ backend/app/routers/diagrams.py | 114 ++++++++- backend/app/routers/views.py | 77 +++++++ backend/app/schemas.py | 38 +++ frontend/css/main.css | 60 ++++- frontend/index.html | 24 ++ frontend/js/api.js | 11 + frontend/js/app.js | 393 +++++++++++++++++++++++++++++++- frontend/js/canvas.js | 72 +++++- 11 files changed, 823 insertions(+), 25 deletions(-) create mode 100644 backend/app/routers/views.py diff --git a/ROADMAP.md b/ROADMAP.md index f1a9bd5..94215fe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,9 +18,9 @@ ### Productivity - [ ] 21. **Find / search** — toolbar search box filters devices by reference/label and highlights matches on canvas; Ctrl+F shortcut -- [ ] 22. **Import JSON** — drag-and-drop or File→Import a previously exported JSON to restore a diagram -- [ ] 23. **Duplicate diagram** — diagram list context menu → "Duplicate" clones all devices and wires under a new name -- [ ] 24. **DRC / design-rule check** — one-click check that flags: unconnected pins, duplicate references, wires with no length set, missing part numbers; results shown in a panel +- [x] 22. **Import JSON** — drag-and-drop or File→Import a previously exported JSON to restore a diagram +- [x] 23. **Duplicate diagram** — diagram list context menu → "Duplicate" clones all devices and wires under a new name +- [x] 24. **DRC / design-rule check** — one-click check that flags: unconnected pins, duplicate references, wires with no length set, missing part numbers; results shown in a panel - [ ] 25. **Pin part numbers** — each pin row in the properties panel has a "Contact P/N" field; shows up correctly in BOM export as a separate line item - [x] 1. **Right-click context menu** — quick actions on devices (duplicate, delete, add pin, save to library) and wires (delete, clear waypoints) diff --git a/backend/app/main.py b/backend/app/main.py index 3c1ef65..06f5f97 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles from sqlalchemy import text, inspect as sa_inspect from .database import Base, engine, SessionLocal -from .routers import bundles, connectors, diagrams, devices, export, octopart, wires +from .routers import bundles, connectors, diagrams, devices, export, octopart, views, wires Base.metadata.create_all(bind=engine) @@ -45,6 +45,9 @@ def _migrate(): if "show_size_label" not in _cols("wires"): conn.execute(text("ALTER TABLE wires ADD COLUMN show_size_label INTEGER DEFAULT 0")) conn.commit() + + # diagram_views / view_device_positions / view_wire_waypoints are created + # via Base.metadata.create_all — no ALTER TABLE needed for new tables except Exception as e: print(f"[migrate] column error: {e}", file=sys.stderr) @@ -59,12 +62,13 @@ app.add_middleware( allow_headers=["*"], ) -app.include_router(bundles.router, prefix="/api/bundles") -app.include_router(diagrams.router, prefix="/api/diagrams") -app.include_router(devices.router, prefix="/api/devices") -app.include_router(wires.router, prefix="/api/wires") -app.include_router(octopart.router, prefix="/api/octopart") -app.include_router(export.router, prefix="/api/export") +app.include_router(bundles.router, prefix="/api/bundles") +app.include_router(diagrams.router, prefix="/api/diagrams") +app.include_router(devices.router, prefix="/api/devices") +app.include_router(wires.router, prefix="/api/wires") +app.include_router(views.router, prefix="/api/views") +app.include_router(octopart.router, prefix="/api/octopart") +app.include_router(export.router, prefix="/api/export") app.include_router(connectors.router, prefix="/api/connectors") FRONTEND_DIR = os.path.normpath( diff --git a/backend/app/models.py b/backend/app/models.py index 559bdf8..a85855b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -16,6 +16,7 @@ class Diagram(Base): devices = relationship("Device", back_populates="diagram", cascade="all, delete-orphan") wires = relationship("Wire", back_populates="diagram", cascade="all, delete-orphan") wire_bundles = relationship("WireBundle", back_populates="diagram", cascade="all, delete-orphan") + views = relationship("DiagramView", back_populates="diagram", cascade="all, delete-orphan") class WireBundle(Base): @@ -89,6 +90,40 @@ class Wire(Base): bundle = relationship("WireBundle", back_populates="wires", foreign_keys=[bundle_id]) +class DiagramView(Base): + __tablename__ = "diagram_views" + id = Column(Integer, primary_key=True, index=True) + diagram_id = Column(Integer, ForeignKey("diagrams.id", ondelete="CASCADE"), nullable=False) + name = Column(String, nullable=False, default="View") + route_mode = Column(String, default="direct") + created_at = Column(DateTime, default=datetime.datetime.utcnow) + + diagram = relationship("Diagram", back_populates="views") + device_positions = relationship("ViewDevicePosition", back_populates="view", cascade="all, delete-orphan") + wire_waypoints = relationship("ViewWireWaypoints", back_populates="view", cascade="all, delete-orphan") + + +class ViewDevicePosition(Base): + __tablename__ = "view_device_positions" + id = Column(Integer, primary_key=True, index=True) + view_id = Column(Integer, ForeignKey("diagram_views.id", ondelete="CASCADE"), nullable=False) + device_id = Column(Integer, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False) + x = Column(Float, nullable=False, default=100) + y = Column(Float, nullable=False, default=100) + + view = relationship("DiagramView", back_populates="device_positions") + + +class ViewWireWaypoints(Base): + __tablename__ = "view_wire_waypoints" + id = Column(Integer, primary_key=True, index=True) + view_id = Column(Integer, ForeignKey("diagram_views.id", ondelete="CASCADE"), nullable=False) + wire_id = Column(Integer, ForeignKey("wires.id", ondelete="CASCADE"), nullable=False) + waypoints = Column(JSON, default=[]) + + view = relationship("DiagramView", back_populates="wire_waypoints") + + class CustomConnector(Base): __tablename__ = "custom_connectors" id = Column(Integer, primary_key=True, index=True) diff --git a/backend/app/routers/diagrams.py b/backend/app/routers/diagrams.py index c781d9d..3172353 100644 --- a/backend/app/routers/diagrams.py +++ b/backend/app/routers/diagrams.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.orm import Session from typing import List from .. import models, schemas @@ -51,3 +51,115 @@ def delete_diagram(diagram_id: int, db: Session = Depends(get_db)): db.delete(diagram) db.commit() return {"ok": True} + + +@router.post("/{diagram_id}/duplicate", response_model=schemas.DiagramSummary, status_code=201) +def duplicate_diagram(diagram_id: int, db: Session = Depends(get_db)): + src = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first() + if not src: + raise HTTPException(status_code=404, detail="Diagram not found") + + new_diag = models.Diagram(name=f"{src.name} (copy)", description=src.description or "") + db.add(new_diag) + db.flush() + + id_map = {} + for dev in src.devices: + new_dev = models.Device( + diagram_id=new_diag.id, + device_type=dev.device_type, + label=dev.label, + reference=dev.reference, + x=dev.x + 30, + y=dev.y + 30, + width=dev.width, + height=dev.height, + properties=dev.properties, + pins=dev.pins, + ) + db.add(new_dev) + db.flush() + id_map[dev.id] = new_dev.id + + for wire in src.wires: + new_wire = models.Wire( + diagram_id=new_diag.id, + label=wire.label, + from_device_id=id_map.get(wire.from_device_id), + from_pin=wire.from_pin, + to_device_id=id_map.get(wire.to_device_id), + to_pin=wire.to_pin, + color_primary=wire.color_primary, + color_stripe=wire.color_stripe, + gauge=wire.gauge, + length=wire.length, + length_unit=wire.length_unit, + notes=wire.notes or "", + waypoints=wire.waypoints or [], + twisted_pair=wire.twisted_pair, + twist_pitch=wire.twist_pitch, + shielded=wire.shielded, + show_size_label=wire.show_size_label, + ) + db.add(new_wire) + + db.commit() + db.refresh(new_diag) + return new_diag + + +@router.post("/import", response_model=schemas.DiagramSummary, status_code=201) +async def import_diagram(request: Request, db: Session = Depends(get_db)): + data = await request.json() + + new_diag = models.Diagram( + name=data.get("name", "Imported Diagram"), + description=data.get("description", ""), + ) + db.add(new_diag) + db.flush() + + id_map = {} + for dev_data in data.get("devices", []): + new_dev = models.Device( + diagram_id=new_diag.id, + device_type=dev_data.get("device_type", "generic"), + label=dev_data.get("label", ""), + reference=dev_data.get("reference", ""), + x=dev_data.get("x", 100), + y=dev_data.get("y", 100), + width=dev_data.get("width", 120), + height=dev_data.get("height", 60), + properties=dev_data.get("properties", {}), + pins=dev_data.get("pins", []), + ) + db.add(new_dev) + db.flush() + if "id" in dev_data: + id_map[dev_data["id"]] = new_dev.id + + for wire_data in data.get("wires", []): + new_wire = models.Wire( + diagram_id=new_diag.id, + label=wire_data.get("label", ""), + from_device_id=id_map.get(wire_data.get("from_device_id")), + from_pin=wire_data.get("from_pin", ""), + to_device_id=id_map.get(wire_data.get("to_device_id")), + to_pin=wire_data.get("to_pin", ""), + color_primary=wire_data.get("color_primary", "#FF0000"), + color_stripe=wire_data.get("color_stripe"), + gauge=wire_data.get("gauge", "18 AWG"), + length=wire_data.get("length"), + length_unit=wire_data.get("length_unit", "in"), + notes=wire_data.get("notes", ""), + waypoints=wire_data.get("waypoints", []), + twisted_pair=wire_data.get("twisted_pair", False), + twist_pitch=wire_data.get("twist_pitch", 16.0), + shielded=wire_data.get("shielded", False), + show_size_label=wire_data.get("show_size_label", False), + ) + db.add(new_wire) + + db.commit() + db.refresh(new_diag) + return new_diag diff --git a/backend/app/routers/views.py b/backend/app/routers/views.py new file mode 100644 index 0000000..16cac5f --- /dev/null +++ b/backend/app/routers/views.py @@ -0,0 +1,77 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from .. import models, schemas +from ..database import get_db + +router = APIRouter(tags=["views"]) + + +@router.get("/diagram/{diagram_id}", response_model=List[schemas.DiagramViewOut]) +def list_views(diagram_id: int, db: Session = Depends(get_db)): + return db.query(models.DiagramView).filter(models.DiagramView.diagram_id == diagram_id).order_by(models.DiagramView.created_at).all() + + +@router.post("/", response_model=schemas.DiagramViewOut, status_code=201) +def create_view(payload: schemas.DiagramViewCreate, db: Session = Depends(get_db)): + view = models.DiagramView(**payload.model_dump()) + db.add(view) + db.commit() + db.refresh(view) + return view + + +@router.patch("/{view_id}", response_model=schemas.DiagramViewOut) +def update_view(view_id: int, payload: schemas.DiagramViewUpdate, db: Session = Depends(get_db)): + view = db.get(models.DiagramView, view_id) + if not view: + raise HTTPException(404, "View not found") + for k, v in payload.model_dump(exclude_unset=True).items(): + setattr(view, k, v) + db.commit() + db.refresh(view) + return view + + +@router.delete("/{view_id}", status_code=204) +def delete_view(view_id: int, db: Session = Depends(get_db)): + view = db.get(models.DiagramView, view_id) + if not view: + raise HTTPException(404, "View not found") + db.delete(view) + db.commit() + + +@router.get("/{view_id}/layout", response_model=schemas.ViewLayout) +def get_view_layout(view_id: int, db: Session = Depends(get_db)): + view = db.get(models.DiagramView, view_id) + if not view: + raise HTTPException(404, "View not found") + pos = {p.device_id: {"x": p.x, "y": p.y} for p in view.device_positions} + waypts = {w.wire_id: (w.waypoints or []) for w in view.wire_waypoints} + return {"device_positions": pos, "wire_waypoints": waypts} + + +@router.patch("/{view_id}/devices/{device_id}") +def update_view_device_position(view_id: int, device_id: int, payload: schemas.ViewDevicePositionUpdate, db: Session = Depends(get_db)): + pos = db.query(models.ViewDevicePosition).filter_by(view_id=view_id, device_id=device_id).first() + if pos: + pos.x = payload.x + pos.y = payload.y + else: + pos = models.ViewDevicePosition(view_id=view_id, device_id=device_id, x=payload.x, y=payload.y) + db.add(pos) + db.commit() + return {"ok": True} + + +@router.patch("/{view_id}/wires/{wire_id}") +def update_view_wire_waypoints(view_id: int, wire_id: int, payload: schemas.ViewWireWaypointsUpdate, db: Session = Depends(get_db)): + ww = db.query(models.ViewWireWaypoints).filter_by(view_id=view_id, wire_id=wire_id).first() + if ww: + ww.waypoints = payload.waypoints + else: + ww = models.ViewWireWaypoints(view_id=view_id, wire_id=wire_id, waypoints=payload.waypoints) + db.add(ww) + db.commit() + return {"ok": True} diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 6214989..2d2f75b 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -146,6 +146,44 @@ class DiagramFull(DiagramSummary): wire_bundles: List[WireBundleOut] = [] +# ── DiagramView ─────────────────────────────────────────────────────────────── + +class DiagramViewBase(BaseModel): + name: str + route_mode: str = "direct" + + +class DiagramViewCreate(DiagramViewBase): + diagram_id: int + + +class DiagramViewUpdate(BaseModel): + name: Optional[str] = None + route_mode: Optional[str] = None + + +class DiagramViewOut(DiagramViewBase): + id: int + diagram_id: int + created_at: datetime + + model_config = {"from_attributes": True} + + +class ViewDevicePositionUpdate(BaseModel): + x: float + y: float + + +class ViewWireWaypointsUpdate(BaseModel): + waypoints: List[Dict[str, Any]] = [] + + +class ViewLayout(BaseModel): + device_positions: Dict[int, Dict[str, float]] = {} + wire_waypoints: Dict[int, List[Dict[str, Any]]] = {} + + # ── CustomConnector ─────────────────────────────────────────────────────────── class CustomConnectorBase(BaseModel): diff --git a/frontend/css/main.css b/frontend/css/main.css index 44213cb..624f349 100644 --- a/frontend/css/main.css +++ b/frontend/css/main.css @@ -74,6 +74,38 @@ body { background: #12121c; color: #c8ccee; } } .diagram-item:hover .di-del-btn { opacity: 1; } .di-del-btn:hover { background: #3a1414; border-color: #aa3333; } +.di-dup-btn { + flex-shrink: 0; opacity: 0; padding: 1px 5px; font-size: 13px; line-height: 1; + background: transparent; border-color: transparent; color: #88aaff; + transition: opacity .15s; margin-left: 4px; +} +.diagram-item:hover .di-dup-btn { opacity: 1; } +.di-dup-btn:hover { background: #1a1a40; border-color: #4466aa; } + +/* ── View tab bar ────────────────────────────────────────────────────────── */ +#view-tab-bar { + display: flex; align-items: center; gap: 2px; + padding: 3px 8px; background: #0c0c1c; border-bottom: 1px solid #2a2a44; + flex-shrink: 0; +} +.view-tab { + padding: 3px 12px; font-size: 11px; border-radius: 4px 4px 0 0; + background: #1a1a30; border: 1px solid #2a2a44; border-bottom: none; + color: #8888aa; cursor: pointer; +} +.view-tab { display: inline-flex; align-items: center; gap: 4px; } +.view-tab.active { background: #1e2a4a; border-color: #4466aa; color: #c0c8f0; } +.view-tab:hover:not(.active) { background: #1e1e38; color: #aaa; } +.view-tab-close { + font-size: 13px; line-height: 1; opacity: 0.4; padding: 0 2px; + border-radius: 3px; +} +.view-tab-close:hover { opacity: 1; background: rgba(255,80,80,0.25); color: #ff8888; } +#btn-add-view { + padding: 2px 8px; font-size: 14px; background: transparent; + border: 1px dashed #333355; border-radius: 4px; color: #556; cursor: pointer; +} +#btn-add-view:hover { border-color: #5566aa; color: #99aadd; } /* ── Device library ───────────────────────────────────────────────────────── */ #device-library { padding: 6px; display: flex; flex-direction: column; gap: 3px; } @@ -98,9 +130,9 @@ body { background: #12121c; color: #c8ccee; } .conn-meta { font-size: 10px; color: #556688; margin-top: 1px; } /* ── Canvas area ──────────────────────────────────────────────────────────── */ -#canvas-area { flex: 1; position: relative; overflow: hidden; } +#canvas-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; } #canvas-container { - width: 100%; height: 100%; + flex: 1; width: 100%; background-color: #131320; background-image: radial-gradient(circle, #2e2e50 1px, transparent 1px); background-size: 20px 20px; @@ -228,7 +260,7 @@ button:active { background: #1a1a3a; } .conn-edit-btn:hover { background: #2a2a5a; border-color: #6666aa; } /* ── Modal ───────────────────────────────────────────────────────────────── */ -#connector-modal { +#connector-modal, #drc-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 1000; align-items: center; justify-content: center; } @@ -250,6 +282,28 @@ button:active { background: #1a1a3a; } display: flex; align-items: center; gap: 8px; flex-shrink: 0; } +/* ── DRC ────────────────────────────────────────────────────────────────── */ +.drc-category { border: 1px solid #2a2a44; border-radius: 5px; overflow: hidden; } +.drc-cat-header { + display: flex; align-items: center; gap: 8px; + padding: 8px 12px; background: #1a1a30; cursor: pointer; + user-select: none; +} +.drc-cat-header:hover { background: #1e1e3a; } +.drc-cat-icon { font-size: 13px; } +.drc-cat-label { flex: 1; font-size: 12px; font-weight: 600; color: #c0c4e8; } +.drc-cat-count { + font-size: 11px; font-weight: 700; background: #2a2a50; + padding: 1px 7px; border-radius: 10px; color: #9090cc; +} +.drc-cat-body { padding: 4px 0; background: #0e0e1e; } +.drc-row { + padding: 5px 14px; font-size: 11px; color: #a0a4c8; + border-bottom: 1px solid #151528; +} +.drc-row:last-child { border-bottom: none; } +.drc-none { color: #444466; font-style: italic; } + .cm-label { display: block; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: #555577; margin-bottom: 4px; diff --git a/frontend/index.html b/frontend/index.html index e35f00d..7bfd66d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -28,6 +28,7 @@
+ @@ -37,10 +38,12 @@
+
✓ Saved
+ @@ -49,6 +52,7 @@
+ @@ -96,6 +100,11 @@
+ +
@@ -395,6 +404,21 @@
+ + + diff --git a/frontend/js/api.js b/frontend/js/api.js index 0ff166b..cd399bd 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -16,6 +16,17 @@ const api = { get: (id) => apiFetch(`/diagrams/${id}`), update: (id, data) => apiFetch(`/diagrams/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), delete: (id) => apiFetch(`/diagrams/${id}`, { method: "DELETE" }), + duplicate: (id) => apiFetch(`/diagrams/${id}/duplicate`, { method: "POST" }), + import: (data) => apiFetch("/diagrams/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), + }, + views: { + listForDiagram: (diagramId) => apiFetch(`/views/diagram/${diagramId}`), + create: (data) => apiFetch("/views/", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), + update: (id, data) => apiFetch(`/views/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), + delete: (id) => apiFetch(`/views/${id}`, { method: "DELETE" }), + getLayout: (id) => apiFetch(`/views/${id}/layout`), + updateDevicePos: (viewId, deviceId, data) => apiFetch(`/views/${viewId}/devices/${deviceId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), + updateWireWaypoints: (viewId, wireId, data) => apiFetch(`/views/${viewId}/wires/${wireId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), }, devices: { create: (data) => apiFetch("/devices/", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), diff --git a/frontend/js/app.js b/frontend/js/app.js index 9d6f017..0c770e5 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -1,6 +1,8 @@ class WiringApp { constructor() { this.diagramId = null; + this.currentViewId = null; // null = main layout, number = view id + this._views = []; // view objects for current diagram this._savedTimer = null; this._customConnectors = []; // loaded from backend this._editingConnId = null; // id of custom connector being edited in modal @@ -18,15 +20,16 @@ class WiringApp { onWireSelected: (w) => this._showWireProps(w), onSelectionCleared: () => this._clearProps(), onMultipleSelected: (count) => this._showMultiProps(count), - onDeviceMoved: (id, x, y) => { api.devices.update(id, { x, y }).catch(console.error); this._flashSaved(); }, + onDeviceMoved: (id, x, y) => { this._savePosForCurrentView(id, x, y); this._flashSaved(); }, onDeviceDropped: (type, wx, wy) => this.addDevice(type, wx, wy), onConnectorDropped: (connId, wx, wy) => this.addConnector(connId, wx, wy), onWireCreated: (fDev, fPin, tDev, tPin) => this.createWire(fDev, fPin, tDev, tPin), - onWaypointsChanged: (id, wps) => { api.wires.update(id, { waypoints: wps }).catch(console.error); this._flashSaved(); }, + onWaypointsChanged: (id, wps) => { this._saveWaypointsForCurrentView(id, wps); this._flashSaved(); }, onDragStarted: (snap) => { this._preDrag = snap; }, onDragEnded: (ids) => { this._pushMoveUndo(ids); }, onDeviceContextMenu: (id, x, y) => this._showCtxDevice(id, x, y), onWireContextMenu: (id, x, y) => this._showCtxWire(id, x, y), + onMultiWireSelected: (ids) => this._onMultiWireSelected(ids), onDeviceResized: async (id, x, y, w, h) => { const device = this.canvas.deviceData.get(id); if (!device) return; @@ -52,6 +55,24 @@ class WiringApp { .then(() => this._checkOctopartStatus()); } + // ── View-aware position/waypoint saving ─────────────────────────────────────── + + _savePosForCurrentView(id, x, y) { + if (this.currentViewId) { + api.views.updateDevicePos(this.currentViewId, id, { x, y }).catch(console.error); + } else { + api.devices.update(id, { x, y }).catch(console.error); + } + } + + _saveWaypointsForCurrentView(wireId, waypoints) { + if (this.currentViewId) { + api.views.updateWireWaypoints(this.currentViewId, wireId, { waypoints }).catch(console.error); + } else { + api.wires.update(wireId, { waypoints }).catch(console.error); + } + } + // ── Auto-reopen & saved indicator ──────────────────────────────────────────── _autoOpenLast() { @@ -167,14 +188,14 @@ class WiringApp { const d = this.canvas.deviceData.get(id); if (g) { g.x(x); g.y(y); } if (d) { d.x = x; d.y = y; } - await api.devices.update(id, { x, y }).catch(console.error); + this._savePosForCurrentView(id, x, y); this.canvas._redrawWiresFor(id); } for (const [wId, wps] of waypoints) { const wire = this.canvas.wireData.get(wId); if (wire) { wire.waypoints = wps.map(wp => ({ ...wp })); - await api.wires.update(wId, { waypoints: wire.waypoints }).catch(console.error); + this._saveWaypointsForCurrentView(wId, wire.waypoints); } } this.canvas.deviceLayer.batchDraw(); @@ -434,6 +455,30 @@ class WiringApp { on("btn-json", () => this._export("json")); on("btn-img", () => { const n = document.getElementById("diagram-name")?.value || "diagram"; this.canvas.exportImage(n); }); on("btn-formboard", () => this._export("formboard")); + on("btn-drc", () => this._runDrc()); + on("btn-import", () => document.getElementById("import-file-input")?.click()); + document.getElementById("import-file-input")?.addEventListener("change", (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = async (ev) => { + try { + const data = JSON.parse(ev.target.result); + const diag = await api.diagrams.import(data); + await this.loadDiagramList(); + await this.openDiagram(diag.id); + } catch (err) { alert("Import failed: " + err.message); } + e.target.value = ""; + }; + reader.readAsText(file); + }); + + on("btn-jumps", () => { + const btn = document.getElementById("btn-jumps"); + const enabled = !this.canvas.jumpsEnabled; + this.canvas.setJumpsEnabled(enabled); + btn?.classList.toggle("active", enabled); + }); on("btn-snap", () => { const btn = document.getElementById("btn-snap"); const enabled = !this.canvas.snapEnabled; @@ -552,6 +597,41 @@ class WiringApp { } _showCtxWire(id, x, y) { + const multiIds = this.canvas.selectedWireIds; + if (multiIds.size > 1 && multiIds.has(id)) { + // Multi-wire context menu + const items = [ + { action: "multi-clearwp", icon: "⌀", label: `Clear waypoints (${multiIds.size} wires)` }, + { action: "multi-autoroute", icon: "⤢", label: `Auto-route (${multiIds.size} wires)` }, + ]; + this._openCtx(x, y, items); + this._ctxMenu().onclick = async (e) => { + const action = e.target.closest(".ctx-item")?.dataset.action; + this._closeCtx(); + if (!action) return; + if (action === "multi-clearwp") { + for (const wid of multiIds) { + const w = this.canvas.wireData.get(wid); + if (!w) continue; + w.waypoints = []; + this.canvas.updateWire(w); + api.wires.update(wid, { waypoints: [] }).catch(console.error); + } + this._flashSaved(); + } + if (action === "multi-autoroute") { + for (const wid of multiIds) { + const newWps = this.canvas.autoRouteWire(wid); + if (newWps !== null) { + api.wires.update(wid, { waypoints: newWps }).catch(console.error); + } + } + this._flashSaved(); + } + }; + return; + } + const wire = this.canvas.wireData.get(id); const hasWaypoints = (wire?.waypoints?.length ?? 0) > 0; const items = [ @@ -559,7 +639,8 @@ class WiringApp { ...(this._copiedWireStyle ? [{ action: "pastestyle", icon: "⎗", label: "Paste Style" }] : []), ...(wire?.label ? [{ action: "selectnet", icon: "⋈", label: `Select net "${wire.label}"` }] : []), "---", - { action: "straighten", icon: "⤢", label: "Straighten wire" }, + { action: "autoroute", icon: "⤢", label: "Auto-route" }, + { action: "straighten", icon: "⤡", label: "Straighten wire" }, ...(hasWaypoints ? [{ action: "clearwp", icon: "⌀", label: "Clear waypoints" }] : []), "---", { action: "del", icon: "✕", label: "Delete wire", danger: true }, @@ -570,6 +651,13 @@ class WiringApp { this._closeCtx(); if (!action) return; if (action === "del") this.deleteSelected(); + if (action === "autoroute" && wire) { + const newWps = this.canvas.autoRouteWire(id); + if (newWps !== null) { + api.wires.update(id, { waypoints: newWps }).catch(console.error); + this._flashSaved(); + } + } if ((action === "clearwp" || action === "straighten") && wire) { wire.waypoints = []; this.canvas.updateWire(wire); @@ -600,6 +688,10 @@ class WiringApp { }; } + _onMultiWireSelected(ids) { + // No panel update needed for multi-wire; the count could be shown in status bar + } + _setRouteMode(mode) { this.canvas.setRouteMode(mode); document.querySelectorAll(".route-btn").forEach(b => b.classList.remove("active")); @@ -621,6 +713,33 @@ class WiringApp { if ((e.ctrlKey || e.metaKey) && e.key === "v") { e.preventDefault(); this.pasteClipboard(); } if ((e.ctrlKey || e.metaKey) && e.key === "d") { e.preventDefault(); this.duplicateSelected(); } if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); this._undo(); } + + // Arrow key nudge + if (["ArrowLeft","ArrowRight","ArrowUp","ArrowDown"].includes(e.key)) { + const ids = [...this.canvas.selectedDeviceIds]; + if (!ids.length) return; + e.preventDefault(); + const step = e.shiftKey ? 10 : 1; + const dx = e.key === "ArrowLeft" ? -step : e.key === "ArrowRight" ? step : 0; + const dy = e.key === "ArrowUp" ? -step : e.key === "ArrowDown" ? step : 0; + ids.forEach(id => { + const g = this.canvas.deviceNodes.get(id); + const d = this.canvas.deviceData.get(id); + if (!g || !d) return; + d.x += dx; d.y += dy; + g.x(d.x); g.y(d.y); + this.canvas._redrawWiresFor(id); + }); + this.canvas.deviceLayer.batchDraw(); + clearTimeout(this._nudgeSaveTimer); + this._nudgeSaveTimer = setTimeout(() => { + ids.forEach(id => { + const d = this.canvas.deviceData.get(id); + if (d) this._savePosForCurrentView(id, d.x, d.y); + }); + this._flashSaved(); + }, 300); + } }); } @@ -890,9 +1009,18 @@ class WiringApp { el.dataset.id = d.id; el.innerHTML = `${d.name} ${new Date(d.updated_at + "Z").toLocaleDateString()} + `; el.querySelector(".di-name").addEventListener("click", () => this.openDiagram(d.id)); el.querySelector(".di-date").addEventListener("click", () => this.openDiagram(d.id)); + el.querySelector(".di-dup-btn").addEventListener("click", async (e) => { + e.stopPropagation(); + try { + const copy = await api.diagrams.duplicate(d.id); + await this.loadDiagramList(); + await this.openDiagram(copy.id); + } catch (err) { alert("Duplicate failed: " + err.message); } + }); el.querySelector(".di-del-btn").addEventListener("click", (e) => { e.stopPropagation(); this.deleteDiagram(d.id, d.name); @@ -908,10 +1036,13 @@ class WiringApp { await api.diagrams.delete(id); if (this.diagramId === id) { this.diagramId = null; + this.currentViewId = null; + this._views = []; localStorage.removeItem("lastDiagramId"); this.canvas.loadDiagram({ devices: [], wires: [] }); document.getElementById("diagram-name").value = ""; document.getElementById("canvas-placeholder").style.display = ""; + document.getElementById("view-tab-bar").style.display = "none"; this._clearProps(); } await this.loadDiagramList(); @@ -932,6 +1063,7 @@ class WiringApp { try { const diagram = await api.diagrams.get(id); this.diagramId = id; + this.currentViewId = null; localStorage.setItem("lastDiagramId", id); document.getElementById("diagram-name").value = diagram.name; document.getElementById("canvas-placeholder").style.display = "none"; @@ -942,12 +1074,263 @@ class WiringApp { this.setMode("select"); this._clearProps(); await this.loadDiagramList(); + await this._loadViews(); } catch (e) { localStorage.removeItem("lastDiagramId"); console.error("Failed to open diagram:", e); } } + // ── Views ───────────────────────────────────────────────────────────────────── + + async _loadViews() { + if (!this.diagramId) return; + try { this._views = await api.views.listForDiagram(this.diagramId); } + catch { this._views = []; } + this._renderViewTabs(); + } + + _renderViewTabs() { + const bar = document.getElementById("view-tab-bar"); + if (!bar) return; + // Remove all view tabs (keep add button) + bar.querySelectorAll(".view-tab").forEach(t => t.remove()); + const addBtn = document.getElementById("btn-add-view"); + + const mainTab = document.createElement("button"); + mainTab.className = "view-tab" + (this.currentViewId === null ? " active" : ""); + mainTab.dataset.viewId = "main"; + mainTab.textContent = "Main"; + mainTab.addEventListener("click", () => this._switchToView(null)); + bar.insertBefore(mainTab, addBtn); + + this._views.forEach(v => { + const tab = document.createElement("button"); + tab.className = "view-tab" + (this.currentViewId === v.id ? " active" : ""); + tab.dataset.viewId = v.id; + + const label = document.createElement("span"); + label.textContent = v.name; + tab.appendChild(label); + + const closeBtn = document.createElement("span"); + closeBtn.textContent = "×"; + closeBtn.className = "view-tab-close"; + closeBtn.title = "Delete view"; + closeBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + if (!confirm(`Delete view "${v.name}"?`)) return; + try { + await api.views.delete(v.id); + this._views = this._views.filter(x => x.id !== v.id); + if (this.currentViewId === v.id) await this._switchToView(null); + else this._renderViewTabs(); + } catch (err) { alert("Failed to delete view: " + err.message); } + }); + tab.appendChild(closeBtn); + + tab.addEventListener("dblclick", () => this._renameView(v)); + tab.addEventListener("click", () => this._switchToView(v.id)); + tab.addEventListener("contextmenu", (e) => { e.preventDefault(); this._viewTabCtx(v, e.clientX, e.clientY); }); + bar.insertBefore(tab, addBtn); + }); + + bar.style.display = this.diagramId ? "" : "none"; + + if (!addBtn._bound) { + addBtn._bound = true; + addBtn.addEventListener("click", () => this._addView()); + } + } + + async _addView() { + const name = prompt("View name (e.g. \"Curved\"):", "View " + (this._views.length + 1)); + if (!name || !this.diagramId) return; + const routeMode = prompt("Default routing (direct / curved / ortho):", "direct") || "direct"; + try { + const v = await api.views.create({ diagram_id: this.diagramId, name, route_mode: routeMode }); + this._views.push(v); + this._renderViewTabs(); + await this._switchToView(v.id); + } catch (e) { alert("Failed to create view: " + e.message); } + } + + async _renameView(v) { + const name = prompt("Rename view:", v.name); + if (!name) return; + try { + const updated = await api.views.update(v.id, { name }); + const idx = this._views.findIndex(x => x.id === v.id); + if (idx >= 0) this._views[idx] = updated; + this._renderViewTabs(); + } catch (e) { alert("Failed to rename: " + e.message); } + } + + _viewTabCtx(v, x, y) { + this._openCtx(x, y, [ + { action: "rename", icon: "✏", label: "Rename…" }, + { action: "del", icon: "✕", label: "Delete view", danger: true }, + ]); + this._ctxMenu().onclick = async (e) => { + const action = e.target.closest(".ctx-item")?.dataset.action; + this._closeCtx(); + if (action === "rename") this._renameView(v); + if (action === "del") { + if (!confirm(`Delete view "${v.name}"?`)) return; + try { + await api.views.delete(v.id); + this._views = this._views.filter(x => x.id !== v.id); + if (this.currentViewId === v.id) await this._switchToView(null); + else this._renderViewTabs(); + } catch (err) { alert("Failed to delete view: " + err.message); } + } + }; + } + + async _switchToView(viewId) { + this.currentViewId = viewId; + const diagram = await api.diagrams.get(this.diagramId).catch(() => null); + if (!diagram) return; + + if (viewId === null) { + // Restore main layout — reload the diagram as-is + this.canvas.loadDiagram(diagram); + this.canvas.loadBundles(this._bundles); + const routeBtn = document.querySelector(".route-btn.active"); + // Keep current route mode + } else { + const view = this._views.find(v => v.id === viewId); + const layout = await api.views.getLayout(viewId).catch(() => ({ device_positions: {}, wire_waypoints: {} })); + + this.canvas.loadDiagram(diagram); + this.canvas.loadBundles(this._bundles); + + // Apply stored view positions + const storedIds = new Set(Object.keys(layout.device_positions).map(Number)); + Object.entries(layout.device_positions).forEach(([devIdStr, pos]) => { + const devId = parseInt(devIdStr); + const d = this.canvas.deviceData.get(devId); + const g = this.canvas.deviceNodes.get(devId); + if (d && g) { d.x = pos.x; d.y = pos.y; g.x(pos.x); g.y(pos.y); } + }); + + // Snapshot any device NOT yet in this view's layout so main-tab moves + // can never bleed through after this point. + const toSnapshot = []; + this.canvas.deviceData.forEach((d, devId) => { + if (!storedIds.has(devId)) toSnapshot.push({ devId, x: d.x, y: d.y }); + }); + toSnapshot.forEach(({ devId, x, y }) => + api.views.updateDevicePos(viewId, devId, { x, y }).catch(console.error) + ); + + // Apply stored wire waypoints + Object.entries(layout.wire_waypoints).forEach(([wireIdStr, wps]) => { + const wireId = parseInt(wireIdStr); + const w = this.canvas.wireData.get(wireId); + if (w) { w.waypoints = wps; } + }); + this.canvas.deviceLayer.batchDraw(); + this.canvas.wireLayer.batchDraw(); + + if (view?.route_mode) this._setRouteMode(view.route_mode); + } + + this._renderViewTabs(); + this.setMode("select"); + this._clearProps(); + } + + // ── DRC ─────────────────────────────────────────────────────────────────────── + + _runDrc() { + if (!this.diagramId) return this._needDiagram(); + const devices = [...this.canvas.deviceData.values()]; + const wires = [...this.canvas.wireData.values()]; + + const categories = [ + { key: "unconnected", label: "Unconnected Pins", icon: "🔴", items: [] }, + { key: "dupref", label: "Duplicate References",icon: "🟠", items: [] }, + { key: "nolength", label: "Missing Wire Length", icon: "🔵", items: [] }, + { key: "nopn", label: "Missing Part Numbers", icon: "🟡", items: [] }, + ]; + const cat = Object.fromEntries(categories.map(c => [c.key, c.items])); + + // Unconnected pins + const connectedPins = new Set(); + wires.forEach(w => { + if (w.from_device_id) connectedPins.add(`${w.from_device_id}::${w.from_pin}`); + if (w.to_device_id) connectedPins.add(`${w.to_device_id}::${w.to_pin}`); + }); + devices.forEach(d => { + if (d.device_type === "group") return; + (d.pins || []).forEach(pin => { + if (!connectedPins.has(`${d.id}::${pin.id}`)) + cat.unconnected.push(`${d.reference || d.label || "Device"} — pin "${pin.name || pin.id}"`); + }); + }); + + // Duplicate references + const refCount = new Map(); + devices.forEach(d => { if (d.reference) refCount.set(d.reference, (refCount.get(d.reference) || 0) + 1); }); + refCount.forEach((count, ref) => { + if (count > 1) cat.dupref.push(`"${ref}" used on ${count} devices`); + }); + + // Missing wire length + wires.forEach(w => { + if (!w.length) cat.nolength.push(`Wire ${w.label ? `"${w.label}"` : `ID ${w.id}`}`); + }); + + // Missing part numbers + devices.forEach(d => { + if (d.device_type === "group") return; + if (!d.properties?.partNumber) cat.nopn.push(`${d.reference || d.label || "Device"}`); + }); + + // Render + const modal = document.getElementById("drc-modal"); + const results = document.getElementById("drc-results"); + if (!modal || !results) return; + + const totalIssues = categories.reduce((s, c) => s + c.items.length, 0); + + if (!totalIssues) { + results.innerHTML = `
✓ No issues found
`; + } else { + results.innerHTML = categories.map(c => { + const count = c.items.length; + const catId = `drc-cat-${c.key}`; + const rows = c.items.map(msg => + `
${msg}
` + ).join(""); + return ` +
+ +
+ ${count ? rows : '
None
'} +
+
`; + }).join(""); + + results.querySelectorAll(".drc-toggle").forEach(cb => { + cb.addEventListener("change", () => { + const body = document.getElementById(cb.dataset.cat); + if (body) body.style.display = cb.checked ? "" : "none"; + }); + }); + } + + modal.style.display = "flex"; + document.getElementById("drc-close")?.addEventListener("click", () => { modal.style.display = "none"; }, { once: true }); + modal.addEventListener("click", (e) => { if (e.target === modal) modal.style.display = "none"; }, { once: true }); + } + // ── Actions ─────────────────────────────────────────────────────────────────── setMode(mode) { diff --git a/frontend/js/canvas.js b/frontend/js/canvas.js index 6991c59..2efe7d3 100644 --- a/frontend/js/canvas.js +++ b/frontend/js/canvas.js @@ -33,6 +33,8 @@ class DiagramCanvas { this._bundleData = []; // array of bundle objects this._bundleNodes = []; // Konva shapes for bundle overlays (on groupLayer) this._netHighlightIds = new Set(); // wire IDs currently net-highlighted + this.selectedWireIds = new Set(); // wire IDs in multi-wire selection + this.jumpsEnabled = true; // wire jump arcs at crossings this._initStage(containerId); this._initZoomPan(); @@ -639,6 +641,8 @@ class DiagramCanvas { clearSelection() { this._netHighlightIds.forEach(id => this._wireHighlight(id, false)); this._netHighlightIds.clear(); + this.selectedWireIds.forEach(id => this._wireHighlight(id, false)); + this.selectedWireIds.clear(); this.selectedDeviceIds.forEach(id => this._highlightDevice(id, false)); this.selectedDeviceIds.clear(); this._clearResizeHandles(); @@ -966,7 +970,38 @@ class DiagramCanvas { _makeSvgPath(pts, crossings) { if (this.routeMode === "curved") return this._ptsToSvgPathCurved(pts); - return this._ptsToSvgPath(pts, crossings); + return this._ptsToSvgPath(pts, this.jumpsEnabled ? crossings : null); + } + + setJumpsEnabled(enabled) { + this.jumpsEnabled = enabled; + this._recomputeJumps(); + } + + autoRouteWire(wireId) { + const wire = this.wireData.get(wireId); + if (!wire) return null; + const fd = this.deviceData.get(wire.from_device_id); + const td = this.deviceData.get(wire.to_device_id); + if (!fd || !td) return null; + const fp = (fd.pins || []).find(p => p.id === wire.from_pin); + const tp = (td.pins || []).find(p => p.id === wire.to_pin); + if (!fp || !tp) return null; + const S = 24; + const ex1 = fd.x + fp.x_offset + (fp.side === "right" ? S : fp.side === "left" ? -S : 0); + const ey1 = fd.y + fp.y_offset + (fp.side === "bottom" ? S : fp.side === "top" ? -S : 0); + const ex2 = td.x + tp.x_offset + (tp.side === "right" ? S : tp.side === "left" ? -S : 0); + const ey2 = td.y + tp.y_offset + (tp.side === "bottom" ? S : tp.side === "top" ? -S : 0); + const excludeIds = new Set([wire.from_device_id, wire.to_device_id]); + const inner = this._autoOrtho(ex1, ey1, ex2, ey2, excludeIds); + // Strip the two exit points (_autoOrtho includes ex1,ey1 and ex2,ey2); store inner pairs as waypoints + const mid = inner.slice(2, inner.length - 2); + wire.waypoints = []; + for (let i = 0; i < mid.length; i += 2) wire.waypoints.push({ x: mid[i], y: mid[i + 1] }); + this._destroyWireVisuals(wireId); + this._renderWire(wire); + this._recomputeJumps(); + return wire.waypoints; } _recomputeJumps() { @@ -1630,24 +1665,49 @@ class DiagramCanvas { hit.on("click", (e) => { if (this.mode !== "select") return; e.cancelBubble = true; - this.selectWire(wire.id); + if (e.evt.shiftKey) { + // Shift+click: toggle this wire in multi-wire selection + if (this.selectedWireIds.has(wire.id)) { + this.selectedWireIds.delete(wire.id); + this._wireHighlight(wire.id, false); + } else { + // Add current single-selected wire to set first + if (this.selectedId && this.selectedType === "wire" && !this.selectedWireIds.has(this.selectedId)) { + this.selectedWireIds.add(this.selectedId); + } + this.selectedWireIds.add(wire.id); + this._wireHighlight(wire.id, true); + this.selectedId = wire.id; + this.selectedType = "wire"; + } + this.cb.onMultiWireSelected?.(this.selectedWireIds); + } else { + this.selectWire(wire.id); + } }); hit.on("mouseenter", () => { - if (this.mode === "select" && this.selectedId !== wire.id) { + if (this.mode === "select" && this.selectedId !== wire.id && !this.selectedWireIds.has(wire.id)) { this._wireHighlight(wire.id, true); this.stage.container().style.cursor = "pointer"; } }); hit.on("mouseleave", () => { - if (this.selectedId !== wire.id) this._wireHighlight(wire.id, false); + if (this.selectedId !== wire.id && !this.selectedWireIds.has(wire.id)) { + this._wireHighlight(wire.id, false); + } this.stage.container().style.cursor = ""; }); hit.on("contextmenu", (e) => { e.evt.preventDefault(); e.cancelBubble = true; - if (this.selectedId !== wire.id) this.selectWire(wire.id); - this.cb.onWireContextMenu?.(wire.id, e.evt.clientX, e.evt.clientY); + if (this.selectedWireIds.size > 1 && this.selectedWireIds.has(wire.id)) { + // Multi-wire context menu + this.cb.onWireContextMenu?.(wire.id, e.evt.clientX, e.evt.clientY); + } else { + if (this.selectedId !== wire.id) this.selectWire(wire.id); + this.cb.onWireContextMenu?.(wire.id, e.evt.clientX, e.evt.clientY); + } }); // Wire body drag (selected wire only) → inserts a waypoint at drag start