Files
Wiring-Designer/backend/app/routers/formboard.py
T
Kyle 82d8570767 Add formboard harness layout designer
Separate read-only canvas for physically laying out wire harnesses.
Syncs connectors from the main diagram (one-way), no changes flow back.

Backend:
- FormboardLayout, FormboardNode, FormboardSegment models
- /api/formboard/ router: get/create layout, sync connectors from diagram,
  full CRUD for nodes and segments

Frontend (formboard.js):
- Konva canvas: drag nodes, click-to-connect segments, zoom/pan
- Node types: Connector (blue rect, synced from diagram), Branch (green
  circle, manual), Splice/joint (orange diamond, manual)
- Segments scale thickness by wire count; fitting overlays for open,
  split loom, conduit, heat shrink, tape wrap
- Right-click context menu for delete on nodes/segments

App integration:
- "⊞ Formboard" tab in view-tab-bar (distinct color, independent of views)
- Sub-toolbar: Select / Branch / Splice / Connect / Sync / Fit / Delete
- Right panel swaps to formboard props when active:
  - Node: label, notes, linked device info
  - Segment: fitting type, fitting color, label, length, wire checklist
    (checkboxes for every wire in the main diagram)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 07:18:43 -04:00

216 lines
7.6 KiB
Python

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()