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>
This commit is contained in:
+2
-1
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user