Files
Wiring-Designer/backend/app/routers/export.py
T
Kyle f32203b630 Initial commit — wiring diagram maker with full feature set
- FastAPI + SQLite backend with routers for diagrams, devices, wires,
  bundles, connectors, export, and Octopart/Nexar search
- Konva.js canvas: ortho/direct/curved routing, wire crossings, harness
  mode, snap-to-grid, multi-select, drag, resize, undo
- Wire features: color/stripe, twisted pair helix, shielded glow,
  gauge size label, multi-core bundle grouping, length/unit tracking
- Device library: connector, terminal block, cable, splice, label, group,
  relay, fuse, switch, component with custom connector support
- Exports: BOM CSV, assembly TXT, JSON, formboard layout, PNG image
- Auto-migration for schema changes via ALTER TABLE at startup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 12:51:21 -04:00

317 lines
13 KiB
Python

import csv
import io
import json
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse
from sqlalchemy.orm import Session
from .. import models
from ..database import get_db
router = APIRouter(tags=["export"])
@router.get("/{diagram_id}/bom/csv")
def export_bom_csv(diagram_id: int, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["=== DEVICES ==="])
writer.writerow(["Reference", "Type", "Label", "Part Number", "Manufacturer", "Description", "Datasheet"])
for device in diagram.devices:
props = device.properties or {}
desc = props.get("description", "")
if device.device_type == "cable" and not desc:
desc = f"{props.get('conductorCount', len(props.get('conductors', [])))} conductors"
writer.writerow([
device.reference, device.device_type, device.label,
props.get("partNumber", ""), props.get("manufacturer", ""),
desc, props.get("datasheetUrl", ""),
])
writer.writerow([])
writer.writerow(["=== WIRES ==="])
writer.writerow(["ID", "Label", "From Device", "From Pin", "To Device", "To Pin",
"Primary Color", "Stripe Color", "Gauge", "Length", "Unit", "Shielded", "Notes"])
device_map = {d.id: d for d in diagram.devices}
for wire in diagram.wires:
fd = device_map.get(wire.from_device_id)
td = device_map.get(wire.to_device_id)
writer.writerow([
wire.id, wire.label,
f"{fd.reference} ({fd.label})" if fd else "",
wire.from_pin,
f"{td.reference} ({td.label})" if td else "",
wire.to_pin,
wire.color_primary, wire.color_stripe or "",
wire.gauge, wire.length or "", wire.length_unit if wire.length else "",
"YES" if wire.shielded else "", wire.notes,
])
output.seek(0)
filename = f"{diagram.name.replace(' ', '_')}_BOM.csv"
return StreamingResponse(
io.BytesIO(output.getvalue().encode()),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@router.get("/{diagram_id}/assembly/txt")
def export_assembly_txt(diagram_id: int, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
device_map = {d.id: d for d in diagram.devices}
lines = [
f"ASSEMBLY INSTRUCTIONS: {diagram.name}",
"=" * 60, "",
]
if diagram.description:
lines += [f"Description: {diagram.description}", ""]
lines += ["STEP 1: PREPARE DEVICES", "-" * 40]
for i, d in enumerate(diagram.devices, 1):
props = d.properties or {}
lines.append(f" {i}. {d.reference or '?'}{d.label} "
f"(Type: {d.device_type}, P/N: {props.get('partNumber', 'N/A')})")
lines.append("")
# Group wires by which cable device (if any) they pass through
cable_devices = {d.id: d for d in diagram.devices if d.device_type == "cable"}
wire_cable_map = {} # wire_id -> cable_device_id
for wire in diagram.wires:
if wire.from_device_id in cable_devices or wire.to_device_id in cable_devices:
cable_id = wire.from_device_id if wire.from_device_id in cable_devices else wire.to_device_id
wire_cable_map[wire.id] = cable_id
lines += ["STEP 2: WIRE CONNECTIONS", "-" * 40]
step = 1
for wire in diagram.wires:
if wire.id in wire_cable_map:
continue # handled in cables step
fd = device_map.get(wire.from_device_id)
td = device_map.get(wire.to_device_id)
from_str = f"{fd.reference or fd.label} Pin {wire.from_pin}" if fd else "unconnected"
to_str = f"{td.reference or td.label} Pin {wire.to_pin}" if td else "unconnected"
color_str = wire.color_primary
if wire.color_stripe:
color_str += f" / {wire.color_stripe} stripe"
length_str = f"{wire.length} {wire.length_unit}" if wire.length else "TBD"
twisted = " [TWISTED PAIR]" if wire.twisted_pair else ""
shielded = " [SHIELDED]" if wire.shielded else ""
lines += [
f" {step}. {color_str} {wire.gauge} {length_str}{twisted}{shielded}",
f" From: {from_str}",
f" To: {to_str}",
]
if wire.label:
lines.append(f" Label: {wire.label}")
if wire.notes:
lines.append(f" Notes: {wire.notes}")
lines.append("")
step += 1
if cable_devices:
lines += ["", "STEP 3: CABLE ASSEMBLIES", "-" * 40]
for cable_dev in cable_devices.values():
props = cable_dev.properties or {}
conductors = props.get("conductors", [])
pn = props.get("partNumber", "N/A")
lines += [
f" Cable: {cable_dev.reference or '?'}{cable_dev.label} (P/N: {pn}, {len(conductors)} conductors)",
]
cable_wires = [w for w in diagram.wires if wire_cable_map.get(w.id) == cable_dev.id]
for wire in cable_wires:
fd = device_map.get(wire.from_device_id)
td = device_map.get(wire.to_device_id)
other_dev = td if wire.from_device_id == cable_dev.id else fd
other_pin = wire.to_pin if wire.from_device_id == cable_dev.id else wire.from_pin
cable_pin = wire.from_pin if wire.from_device_id == cable_dev.id else wire.to_pin
length_str = f"{wire.length} {wire.length_unit}" if wire.length else "TBD"
lines.append(
f" Pin {cable_pin} ({wire.color_primary}) → "
f"{other_dev.reference or other_dev.label if other_dev else '?'} Pin {other_pin} {length_str}"
)
lines.append("")
content = "\n".join(lines)
filename = f"{diagram.name.replace(' ', '_')}_Assembly.txt"
return StreamingResponse(
io.BytesIO(content.encode()),
media_type="text/plain",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@router.get("/{diagram_id}/formboard", response_class=HTMLResponse)
def export_formboard(diagram_id: int, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
device_map = {d.id: d for d in diagram.devices}
def to_mm(length, unit):
factors = {"mm": 1.0, "cm": 10.0, "in": 25.4, "ft": 304.8}
return length * factors.get(unit or "in", 25.4)
wires_with_length = []
wires_no_length = []
for wire in diagram.wires:
if wire.length and wire.length > 0:
mm = to_mm(wire.length, wire.length_unit)
wires_with_length.append((wire, mm))
else:
wires_no_length.append(wire)
wires_with_length.sort(key=lambda t: t[1], reverse=True)
def wire_label(wire):
fd = device_map.get(wire.from_device_id)
td = device_map.get(wire.to_device_id)
frm = f"{fd.reference or fd.label}/{wire.from_pin}" if fd else "?"
to = f"{td.reference or td.label}/{wire.to_pin}" if td else "?"
return f"{frm}{to}" + (f" [{wire.label}]" if wire.label else "")
def stripe_style(wire):
if wire.color_stripe:
c1, c2 = wire.color_primary or "#cc0000", wire.color_stripe
return f"background: repeating-linear-gradient(45deg, {c1}, {c1} 4mm, {c2} 4mm, {c2} 8mm);"
return f"background: {wire.color_primary or '#cc0000'};"
rows_html = ""
for wire, mm in wires_with_length:
badges = ""
if wire.twisted_pair:
badges += '<span class="badge tw">TWISTED</span>'
if wire.shielded:
badges += '<span class="badge sh">SHIELDED</span>'
txt_color = "#000" if (wire.color_primary or "#000") in ("#FFD700","#DDDDDD","#FF8C00","#dddddd") else "#fff"
rows_html += f"""
<tr>
<td class="lbl">{wire_label(wire)}</td>
<td class="gauge">{wire.gauge or ""}</td>
<td class="len-val">{wire.length:.1f} {wire.length_unit or "in"} ({mm:.0f} mm)</td>
<td class="bar-cell">
<div class="wire-bar" style="width:{mm:.1f}mm;{stripe_style(wire)}color:{txt_color}">
<span class="bar-text">{mm:.0f} mm</span>
</div>
</td>
<td>{badges}</td>
</tr>"""
no_len_rows = ""
for wire in wires_no_length:
no_len_rows += f"<li>{wire_label(wire)}{wire.gauge or 'gauge?'}</li>"
no_len_section = ""
if wires_no_length:
no_len_section = f"""
<h2>Wires without length</h2>
<ul class="no-len">{no_len_rows}</ul>"""
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Formboard — {diagram.name}</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: sans-serif; font-size: 11pt; padding: 10mm; background: #fff; color: #111; }}
h1 {{ font-size: 16pt; margin-bottom: 4mm; }}
.meta {{ font-size: 9pt; color: #555; margin-bottom: 6mm; }}
.scale-bar-wrap {{ margin-bottom: 8mm; }}
.scale-bar {{ display: inline-block; width: 100mm; height: 5mm; background: #333; }}
.scale-bar-lbl {{ font-size: 9pt; color: #555; margin-left: 3mm; }}
table {{ border-collapse: collapse; width: 100%; margin-bottom: 8mm; }}
th {{ text-align: left; font-size: 9pt; color: #555; border-bottom: 0.5mm solid #ccc; padding: 1mm 2mm; }}
td {{ padding: 1.5mm 2mm; vertical-align: middle; border-bottom: 0.2mm solid #e8e8e8; }}
.lbl {{ font-size: 9pt; max-width: 60mm; word-break: break-all; }}
.gauge {{ font-size: 9pt; white-space: nowrap; }}
.len-val {{ font-size: 9pt; white-space: nowrap; }}
.bar-cell {{ width: 99%; }}
.wire-bar {{
height: 5mm; min-width: 5mm; border-radius: 1mm;
display: flex; align-items: center; padding: 0 2mm;
print-color-adjust: exact; -webkit-print-color-adjust: exact;
}}
.bar-text {{ font-size: 7pt; white-space: nowrap; }}
.badge {{ font-size: 7pt; padding: 0.5mm 1.5mm; border-radius: 1mm; margin-left: 1mm; white-space: nowrap; }}
.tw {{ background: #d0e8ff; color: #003; }}
.sh {{ background: #e8e8e8; color: #333; }}
.no-len {{ font-size: 9pt; padding-left: 5mm; margin-bottom: 6mm; }}
.no-len li {{ margin-bottom: 1mm; }}
h2 {{ font-size: 12pt; margin: 6mm 0 3mm; }}
@media print {{
body {{ padding: 5mm; }}
.no-print {{ display: none; }}
}}
</style>
</head>
<body>
<h1>📐 Formboard — {diagram.name}</h1>
<p class="meta">Wire bars are printed at true 1:1 scale. Print at 100% (no scaling) for accurate lengths.</p>
<div class="scale-bar-wrap no-print">
<strong>100 mm scale reference:</strong><br>
<div class="scale-bar"></div><span class="scale-bar-lbl">← this bar is exactly 100 mm when printed at 100%</span>
</div>
<table>
<thead><tr>
<th>Wire</th><th>Gauge</th><th>Length</th><th>Scale (1:1)</th><th>Flags</th>
</tr></thead>
<tbody>{rows_html}</tbody>
</table>
{no_len_section}
<p class="meta no-print" style="margin-top:8mm">Print → More settings → Scale: 100% → No headers/footers</p>
</body>
</html>"""
return HTMLResponse(content=html)
@router.get("/{diagram_id}/json")
def export_json(diagram_id: int, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
data = {
"id": diagram.id,
"name": diagram.name,
"description": diagram.description,
"devices": [
{
"id": d.id, "device_type": d.device_type, "label": d.label,
"reference": d.reference, "x": d.x, "y": d.y,
"width": d.width, "height": d.height,
"properties": d.properties, "pins": d.pins,
}
for d in diagram.devices
],
"wires": [
{
"id": w.id, "label": w.label,
"from_device_id": w.from_device_id, "from_pin": w.from_pin,
"to_device_id": w.to_device_id, "to_pin": w.to_pin,
"color_primary": w.color_primary, "color_stripe": w.color_stripe,
"gauge": w.gauge, "length": w.length, "length_unit": w.length_unit,
"notes": w.notes,
}
for w in diagram.wires
],
}
filename = f"{diagram.name.replace(' ', '_')}.json"
return StreamingResponse(
io.BytesIO(json.dumps(data, indent=2).encode()),
media_type="application/json",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)