Add views, import/duplicate, DRC, jumps toggle, multi-wire select, auto-route, delete view tabs
- Roadmap #22: File→Import JSON to restore a diagram (drag-drop or file picker) - Roadmap #23: Duplicate diagram from diagram list - Roadmap #24: DRC panel with collapsible categories (unconnected pins, dup refs, no length, no P/N) - Multi-view tabs: named views per diagram with independent device/wire layouts; snapshot-on-entry prevents bleed - Keyboard arrow nudge for selected device(s) - Undo for move, duplicate, label/property changes - Wire jump arcs toggle (⌒ Jumps button in toolbar) - Shift+click multi-wire selection; right-click bulk clear waypoints or auto-route - Auto-route: obstacle-avoiding ortho routing for single or bulk selected wires - Delete view tab with × button; right-click context menu for rename/delete Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+11
-7
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user