Files
Wiring-Designer/backend/app/routers/formboard.py
T
Kyle f3564cbbb0 Update wiring diagram maker: formboard router + frontend
Backend formboard router and frontend (canvas, formboard, connector library,
app/api, index/css) updates. Ignore *.log.
2026-07-14 07:09:10 -04:00

262 lines
9.4 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)):
"""Sync devices as connector nodes and build segments from wire connections.
Layout strategy:
- Connectors alternate above/below a central trunk line (y=100 / y=300)
- Segments are auto-created for each unique device-pair connected by wires,
with wire_ids populated so thickness reflects the bundle size.
Existing nodes are label-synced but not moved; existing segments are not duplicated.
"""
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}
dev_to_node: dict = {n.device_id: n for n in layout.nodes if n.device_id is not None}
added = 0
new_x = 80 + len([n for n in layout.nodes if n.device_id is not None]) * 200
for i, dev in enumerate(diagram.devices):
label = dev.reference or dev.label or f"Dev {dev.id}"
if dev.id in existing_dev_ids:
node = dev_to_node[dev.id]
if not node.label or node.label == f"Dev {dev.id}":
node.label = label
else:
# Alternate connectors above (y=80) and below (y=320) the trunk
row_y = 80 if added % 2 == 0 else 320
node = models.FormboardNode(
layout_id=layout.id,
node_type="connector",
device_id=dev.id,
x=new_x, y=row_y,
label=label,
)
db.add(node)
db.flush() # get node.id
dev_to_node[dev.id] = node
new_x += 200
added += 1
# Build segments from wire connections
# Group wires by (min_dev_id, max_dev_id) pair to avoid direction duplicates
from collections import defaultdict
pair_wires: dict = defaultdict(list)
for wire in diagram.wires:
a, b = wire.from_device_id, wire.to_device_id
if a and b and a != b:
pair_wires[(min(a, b), max(a, b))].append(wire.id)
# Find existing segments by node-pair to avoid duplicates
existing_seg_pairs = set()
for seg in layout.segments:
existing_seg_pairs.add((min(seg.from_node_id, seg.to_node_id),
max(seg.from_node_id, seg.to_node_id)))
segs_added = 0
for (dev_a, dev_b), wire_ids in pair_wires.items():
node_a = dev_to_node.get(dev_a)
node_b = dev_to_node.get(dev_b)
if not node_a or not node_b:
continue
pair = (min(node_a.id, node_b.id), max(node_a.id, node_b.id))
if pair in existing_seg_pairs:
continue
seg = models.FormboardSegment(
layout_id=layout.id,
from_node_id=node_a.id,
to_node_id=node_b.id,
wire_ids=wire_ids,
fitting_type="open",
)
db.add(seg)
existing_seg_pairs.add(pair)
segs_added += 1
db.commit()
db.refresh(layout)
return {
"added": added,
"segs_added": segs_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()