Files
Wiring-Designer/backend/app/routers/export.py
T
Kyle 072e620d56 Scope git commit to current diagram + fix complete JSON export/import
- Git commit: "Current diagram only" checkbox stages only that diagram's
  JSON file instead of all diagrams
- JSON export now includes: waypoints, twisted_pair, twist_pitch, shielded,
  show_size_label, bundle_id, bundle_label, sheet_id, sheets, wire_bundles
- JSON import reconstructs sheets and bundles with remapped IDs so restored
  diagrams are exact copies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 20:02:19 -04:00

336 lines
14 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")
# Build bundle id→label map for wire export
bundle_label = {b.id: b.label for b in diagram.wire_bundles}
data = {
"id": diagram.id,
"name": diagram.name,
"description": diagram.description,
"sheets": [
{"id": s.id, "name": s.name, "position": s.position}
for s in diagram.sheets
],
"wire_bundles": [
{"id": b.id, "label": b.label, "jacket_color": b.jacket_color}
for b in diagram.wire_bundles
],
"devices": [
{
"id": d.id, "sheet_id": d.sheet_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, "sheet_id": w.sheet_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,
"waypoints": w.waypoints or [],
"twisted_pair": bool(w.twisted_pair),
"twist_pitch": w.twist_pitch,
"shielded": bool(w.shielded),
"show_size_label": bool(w.show_size_label),
"bundle_id": w.bundle_id,
"bundle_label": bundle_label.get(w.bundle_id),
}
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}"},
)