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 @@ + @@ -96,6 +100,11 @@