f32203b630
- 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>
317 lines
13 KiB
Python
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}"},
|
|
)
|