diff --git a/backend/app/main.py b/backend/app/main.py index eff35bc..d9ecf6b 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, git_mgr, octopart, views, wires +from .routers import bundles, connectors, diagrams, devices, export, formboard, git_mgr, octopart, views, wires Base.metadata.create_all(bind=engine) @@ -70,6 +70,7 @@ 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") +app.include_router(formboard.router, prefix="/api/formboard") app.include_router(git_mgr.router, prefix="/api/git") FRONTEND_DIR = os.path.normpath( diff --git a/backend/app/models.py b/backend/app/models.py index a85855b..b3c0ede 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -124,6 +124,45 @@ class ViewWireWaypoints(Base): view = relationship("DiagramView", back_populates="wire_waypoints") +class FormboardLayout(Base): + __tablename__ = "formboard_layouts" + id = Column(Integer, primary_key=True, index=True) + diagram_id = Column(Integer, ForeignKey("diagrams.id", ondelete="CASCADE"), nullable=False, unique=True) + + nodes = relationship("FormboardNode", back_populates="layout", cascade="all, delete-orphan") + segments = relationship("FormboardSegment", back_populates="layout", cascade="all, delete-orphan") + + +class FormboardNode(Base): + __tablename__ = "formboard_nodes" + id = Column(Integer, primary_key=True, index=True) + layout_id = Column(Integer, ForeignKey("formboard_layouts.id", ondelete="CASCADE"), nullable=False) + node_type = Column(String, default="branch") # connector | branch | splice + device_id = Column(Integer, ForeignKey("devices.id", ondelete="SET NULL"), nullable=True) + x = Column(Float, default=100) + y = Column(Float, default=100) + label = Column(String, default="") + notes = Column(Text, default="") + + layout = relationship("FormboardLayout", back_populates="nodes") + + +class FormboardSegment(Base): + __tablename__ = "formboard_segments" + id = Column(Integer, primary_key=True, index=True) + layout_id = Column(Integer, ForeignKey("formboard_layouts.id", ondelete="CASCADE"), nullable=False) + from_node_id = Column(Integer, ForeignKey("formboard_nodes.id", ondelete="CASCADE"), nullable=False) + to_node_id = Column(Integer, ForeignKey("formboard_nodes.id", ondelete="CASCADE"), nullable=False) + waypoints = Column(JSON, default=[]) + wire_ids = Column(JSON, default=[]) # wire IDs from main diagram + fitting_type = Column(String, default="open") # open | loom | conduit | heat_shrink | tape + fitting_color = Column(String, default="#888888") + label = Column(String, default="") + length_mm = Column(Float, nullable=True) + + layout = relationship("FormboardLayout", back_populates="segments") + + class CustomConnector(Base): __tablename__ = "custom_connectors" id = Column(Integer, primary_key=True, index=True) diff --git a/backend/app/routers/formboard.py b/backend/app/routers/formboard.py new file mode 100644 index 0000000..9398945 --- /dev/null +++ b/backend/app/routers/formboard.py @@ -0,0 +1,215 @@ +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from .. import models +from ..database import get_db + +router = APIRouter(tags=["formboard"]) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _get_or_create(diagram_id: int, db: Session) -> models.FormboardLayout: + layout = db.query(models.FormboardLayout).filter_by(diagram_id=diagram_id).first() + if not layout: + layout = models.FormboardLayout(diagram_id=diagram_id) + db.add(layout) + db.commit() + db.refresh(layout) + return layout + + +def _nd(n: models.FormboardNode) -> dict: + return { + "id": n.id, "layout_id": n.layout_id, "node_type": n.node_type, + "device_id": n.device_id, "x": n.x, "y": n.y, + "label": n.label, "notes": n.notes, + } + + +def _sd(s: models.FormboardSegment) -> dict: + return { + "id": s.id, "layout_id": s.layout_id, + "from_node_id": s.from_node_id, "to_node_id": s.to_node_id, + "waypoints": s.waypoints or [], "wire_ids": s.wire_ids or [], + "fitting_type": s.fitting_type, "fitting_color": s.fitting_color, + "label": s.label, "length_mm": s.length_mm, + } + + +# ── Endpoints ───────────────────────────────────────────────────────────────── + +@router.get("/{diagram_id}") +def get_layout(diagram_id: int, db: Session = Depends(get_db)): + layout = _get_or_create(diagram_id, db) + return { + "id": layout.id, "diagram_id": layout.diagram_id, + "nodes": [_nd(n) for n in layout.nodes], + "segments": [_sd(s) for s in layout.segments], + } + + +@router.post("/{diagram_id}/sync") +def sync_from_diagram(diagram_id: int, db: Session = Depends(get_db)): + """Import all devices from the main diagram as connector nodes. + Existing nodes (matched by device_id) are label-synced but not moved.""" + diagram = db.query(models.Diagram).filter_by(id=diagram_id).first() + if not diagram: + raise HTTPException(404, "Diagram not found") + + layout = _get_or_create(diagram_id, db) + existing_dev_ids = {n.device_id for n in layout.nodes if n.device_id is not None} + + added = 0 + x = 80 + for dev in diagram.devices: + if dev.id in existing_dev_ids: + # Keep label in sync if the node label is still the default + node = next((n for n in layout.nodes if n.device_id == dev.id), None) + if node: + new_label = dev.reference or dev.label or f"Dev {dev.id}" + if not node.label or node.label == f"Dev {dev.id}": + node.label = new_label + continue + node = models.FormboardNode( + layout_id=layout.id, + node_type="connector", + device_id=dev.id, + x=x, y=180, + label=dev.reference or dev.label or f"Dev {dev.id}", + ) + db.add(node) + x += 200 + added += 1 + + db.commit() + db.refresh(layout) + return { + "added": added, + "nodes": [_nd(n) for n in layout.nodes], + "segments": [_sd(s) for s in layout.segments], + } + + +# ── Nodes ───────────────────────────────────────────────────────────────────── + +class NodeCreate(BaseModel): + node_type: str = "branch" + x: float = 100 + y: float = 100 + label: str = "" + notes: str = "" + device_id: Optional[int] = None + + +class NodeUpdate(BaseModel): + x: Optional[float] = None + y: Optional[float] = None + label: Optional[str] = None + notes: Optional[str] = None + + +@router.post("/{diagram_id}/nodes", status_code=201) +def create_node(diagram_id: int, body: NodeCreate, db: Session = Depends(get_db)): + layout = _get_or_create(diagram_id, db) + node = models.FormboardNode( + layout_id=layout.id, node_type=body.node_type, + device_id=body.device_id, + x=body.x, y=body.y, label=body.label, notes=body.notes, + ) + db.add(node) + db.commit() + db.refresh(node) + return _nd(node) + + +@router.patch("/nodes/{node_id}") +def update_node(node_id: int, body: NodeUpdate, db: Session = Depends(get_db)): + node = db.query(models.FormboardNode).filter_by(id=node_id).first() + if not node: + raise HTTPException(404) + if body.x is not None: node.x = body.x + if body.y is not None: node.y = body.y + if body.label is not None: node.label = body.label + if body.notes is not None: node.notes = body.notes + db.commit() + db.refresh(node) + return _nd(node) + + +@router.delete("/nodes/{node_id}", status_code=204) +def delete_node(node_id: int, db: Session = Depends(get_db)): + node = db.query(models.FormboardNode).filter_by(id=node_id).first() + if not node: + raise HTTPException(404) + # Cascade-delete connected segments + db.query(models.FormboardSegment).filter( + (models.FormboardSegment.from_node_id == node_id) | + (models.FormboardSegment.to_node_id == node_id) + ).delete(synchronize_session=False) + db.delete(node) + db.commit() + + +# ── Segments ────────────────────────────────────────────────────────────────── + +class SegCreate(BaseModel): + from_node_id: int + to_node_id: int + wire_ids: List[int] = [] + fitting_type: str = "open" + fitting_color: str = "#888888" + label: str = "" + length_mm: Optional[float] = None + + +class SegUpdate(BaseModel): + waypoints: Optional[List] = None + wire_ids: Optional[List[int]] = None + fitting_type: Optional[str] = None + fitting_color: Optional[str] = None + label: Optional[str] = None + length_mm: Optional[float] = None + + +@router.post("/{diagram_id}/segments", status_code=201) +def create_segment(diagram_id: int, body: SegCreate, db: Session = Depends(get_db)): + layout = _get_or_create(diagram_id, db) + seg = models.FormboardSegment( + layout_id=layout.id, + from_node_id=body.from_node_id, to_node_id=body.to_node_id, + wire_ids=body.wire_ids, fitting_type=body.fitting_type, + fitting_color=body.fitting_color, label=body.label, length_mm=body.length_mm, + ) + db.add(seg) + db.commit() + db.refresh(seg) + return _sd(seg) + + +@router.patch("/segments/{seg_id}") +def update_segment(seg_id: int, body: SegUpdate, db: Session = Depends(get_db)): + seg = db.query(models.FormboardSegment).filter_by(id=seg_id).first() + if not seg: + raise HTTPException(404) + if body.waypoints is not None: seg.waypoints = body.waypoints + if body.wire_ids is not None: seg.wire_ids = body.wire_ids + if body.fitting_type is not None: seg.fitting_type = body.fitting_type + if body.fitting_color is not None: seg.fitting_color = body.fitting_color + if body.label is not None: seg.label = body.label + if body.length_mm is not None: seg.length_mm = body.length_mm + db.commit() + db.refresh(seg) + return _sd(seg) + + +@router.delete("/segments/{seg_id}", status_code=204) +def delete_segment(seg_id: int, db: Session = Depends(get_db)): + seg = db.query(models.FormboardSegment).filter_by(id=seg_id).first() + if not seg: + raise HTTPException(404) + db.delete(seg) + db.commit() diff --git a/frontend/css/main.css b/frontend/css/main.css index 7c58792..3757172 100644 --- a/frontend/css/main.css +++ b/frontend/css/main.css @@ -101,6 +101,26 @@ body { background: #12121c; color: #c8ccee; } border-radius: 3px; } .view-tab-close:hover { opacity: 1; background: rgba(255,80,80,0.25); color: #ff8888; } +.view-tab-sep { width: 1px; background: #2a2a44; height: 18px; margin: 0 4px; } +.fb-tab { color: #6688aa; border-color: #1e3355; } +.fb-tab.active { background: #0d2235; border-color: #336699; color: #88bbdd; } +.fb-tab:hover:not(.active) { background: #0d1a28; color: #aaccee; } + +#fb-toolbar { + display: flex; align-items: center; gap: 4px; flex-wrap: wrap; + padding: 4px 8px; background: #090914; border-bottom: 1px solid #1e3355; + flex-shrink: 0; +} +.fb-mode-btn { + padding: 3px 10px; font-size: 11px; border-radius: 4px; + background: #0d1a28; border: 1px solid #1e3355; color: #5577aa; cursor: pointer; +} +.fb-mode-btn.active { background: #0d2235; border-color: #336699; color: #88bbdd; } +.fb-mode-btn:hover:not(.active) { background: #111e30; color: #aaccee; } + +#fb-container { flex: 1; overflow: hidden; } +#fb-canvas { width: 100%; height: 100%; } + #btn-add-view { padding: 2px 8px; font-size: 14px; background: transparent; border: 1px dashed #333355; border-radius: 4px; color: #556; cursor: pointer; diff --git a/frontend/index.html b/frontend/index.html index 2d25f91..f6823eb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -105,8 +105,24 @@
+ + +