diff --git a/.gitignore b/.gitignore index 76e2610..1918beb 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ Thumbs.db # Editor .vscode/ *.swp + +# Logs +*.log diff --git a/backend/app/routers/formboard.py b/backend/app/routers/formboard.py index 9398945..db42978 100644 --- a/backend/app/routers/formboard.py +++ b/backend/app/routers/formboard.py @@ -54,41 +54,87 @@ def get_layout(diagram_id: int, db: Session = Depends(get_db)): @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.""" + """Sync devices as connector nodes and build segments from wire connections. + + Layout strategy: + - Connectors alternate above/below a central trunk line (y=100 / y=300) + - Segments are auto-created for each unique device-pair connected by wires, + with wire_ids populated so thickness reflects the bundle size. + Existing nodes are label-synced but not moved; existing segments are not duplicated. + """ 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} + dev_to_node: dict = {n.device_id: n for n in layout.nodes if n.device_id is not None} added = 0 - x = 80 - for dev in diagram.devices: + new_x = 80 + len([n for n in layout.nodes if n.device_id is not None]) * 200 + + for i, dev in enumerate(diagram.devices): + label = dev.reference or dev.label or f"Dev {dev.id}" 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 + node = dev_to_node[dev.id] + if not node.label or node.label == f"Dev {dev.id}": + node.label = label + else: + # Alternate connectors above (y=80) and below (y=320) the trunk + row_y = 80 if added % 2 == 0 else 320 + node = models.FormboardNode( + layout_id=layout.id, + node_type="connector", + device_id=dev.id, + x=new_x, y=row_y, + label=label, + ) + db.add(node) + db.flush() # get node.id + dev_to_node[dev.id] = node + new_x += 200 + added += 1 + + # Build segments from wire connections + # Group wires by (min_dev_id, max_dev_id) pair to avoid direction duplicates + from collections import defaultdict + pair_wires: dict = defaultdict(list) + for wire in diagram.wires: + a, b = wire.from_device_id, wire.to_device_id + if a and b and a != b: + pair_wires[(min(a, b), max(a, b))].append(wire.id) + + # Find existing segments by node-pair to avoid duplicates + existing_seg_pairs = set() + for seg in layout.segments: + existing_seg_pairs.add((min(seg.from_node_id, seg.to_node_id), + max(seg.from_node_id, seg.to_node_id))) + + segs_added = 0 + for (dev_a, dev_b), wire_ids in pair_wires.items(): + node_a = dev_to_node.get(dev_a) + node_b = dev_to_node.get(dev_b) + if not node_a or not node_b: continue - node = models.FormboardNode( + pair = (min(node_a.id, node_b.id), max(node_a.id, node_b.id)) + if pair in existing_seg_pairs: + continue + seg = models.FormboardSegment( 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}", + from_node_id=node_a.id, + to_node_id=node_b.id, + wire_ids=wire_ids, + fitting_type="open", ) - db.add(node) - x += 200 - added += 1 + db.add(seg) + existing_seg_pairs.add(pair) + segs_added += 1 db.commit() db.refresh(layout) return { "added": added, + "segs_added": segs_added, "nodes": [_nd(n) for n in layout.nodes], "segments": [_sd(s) for s in layout.segments], } diff --git a/frontend/css/main.css b/frontend/css/main.css index 3757172..f96c9de 100644 --- a/frontend/css/main.css +++ b/frontend/css/main.css @@ -280,7 +280,7 @@ button:active { background: #1a1a3a; } .conn-edit-btn:hover { background: #2a2a5a; border-color: #6666aa; } /* ── Modal ───────────────────────────────────────────────────────────────── */ -#connector-modal, #drc-modal, #git-modal { +#connector-modal, #drc-modal, #git-modal, #pinout-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 1000; align-items: center; justify-content: center; } diff --git a/frontend/index.html b/frontend/index.html index f6823eb..ee586c3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -230,6 +230,9 @@ +
+ +
@@ -459,6 +462,39 @@ + + +
diff --git a/frontend/js/api.js b/frontend/js/api.js index 27c575e..547f27e 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -6,6 +6,7 @@ async function apiFetch(path, options = {}) { const text = await res.text(); throw new Error(`API ${res.status}: ${text}`); } + if (res.status === 204) return null; return res.json(); } diff --git a/frontend/js/app.js b/frontend/js/app.js index 428239f..4583f51 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -30,10 +30,11 @@ class WiringApp { onDeviceContextMenu: (id, x, y) => this._showCtxDevice(id, x, y), onWireContextMenu: (id, x, y) => this._showCtxWire(id, x, y), onMultiWireSelected: (ids) => this._onMultiWireSelected(ids), + onPinoutRequested: (d) => this._showPinoutPopup(d), onDeviceResized: async (id, x, y, w, h) => { const device = this.canvas.deviceData.get(id); if (!device) return; - this._recalcPinOffsets(device.pins || [], w, h); + this._recalcPinOffsets(device.pins || [], w, h, device); await api.devices.update(id, { x, y, width: w, height: h, pins: device.pins }).catch(console.error); this.canvas.updateDevice(device); this.canvas.selectDevice(id); @@ -50,6 +51,7 @@ class WiringApp { this._bindProps(); this._bindTabs(); this._bindConnectorModal(); + this._bindPinoutModal(); this._bindLibrarySearch(); this._bindFormboard(); @@ -252,9 +254,12 @@ class WiringApp { } _filterConnectors() { + const savedCat = document.getElementById("lib-category")?.value || ""; this._buildConnectorLibrary(); // refresh category list + const catSelect = document.getElementById("lib-category"); + if (catSelect && savedCat) catSelect.value = savedCat; // restore selection after innerHTML rebuild const query = (document.getElementById("lib-search")?.value || "").toLowerCase(); - const catFilter = document.getElementById("lib-category")?.value || ""; + const catFilter = catSelect?.value || ""; const list = document.getElementById("lib-list"); list.innerHTML = ""; @@ -342,6 +347,9 @@ class WiringApp { if (e.key === "Escape" && document.getElementById("connector-modal")?.style.display !== "none") { this.closeConnectorModal(); } + if (e.key === "Escape" && document.getElementById("pinout-modal")?.style.display !== "none") { + this._closePinoutPopup(); + } }); // Update pin labels when pin count changes @@ -429,6 +437,291 @@ class WiringApp { } catch (e) { alert("Failed to delete: " + e.message); } } + // ── Pinout popup ───────────────────────────────────────────────────────────── + + _bindPinoutModal() { + document.getElementById("pinout-close")?.addEventListener("click", () => this._closePinoutPopup()); + document.getElementById("pinout-modal")?.addEventListener("click", (e) => { + if (e.target.id === "pinout-modal") this._closePinoutPopup(); + }); + document.getElementById("prop-pinout-btn")?.addEventListener("click", () => { + if (this._propDevice) this._showPinoutPopup(this._propDevice); + }); + document.getElementById("pinout-print-btn")?.addEventListener("click", () => { + if (this._propDevice) this._printPinout(this._propDevice); + }); + document.getElementById("pinout-copy-btn")?.addEventListener("click", () => { + if (this._propDevice) this._copyPinoutTable(this._propDevice); + }); + } + + _showPinoutPopup(device) { + if (!device) return; + const connId = device.properties?.connectorLibraryId; + const conn = connId ? getConnectorById(connId) : null; + const pins = device.pins || []; + + // ── SVG face view ──────────────────────────────────────────────────────── + const svg = device.properties?.shape === "circular" + ? this._buildCircularSVG(device, pins, false) + : this._buildSchematicSVG(device, pins, false); + + // ── Pin table ──────────────────────────────────────────────────────────── + const pinConnections = this._buildPinConnections(device); + + const tbody = document.getElementById("pinout-table-body"); + tbody.innerHTML = ""; + pins.forEach((pin, i) => { + const num = i + 1; + const isDefault = !pin.name || pin.name === String(num); + const customNote = isDefault ? null : pin.name; + const conn = pinConnections.get(pin.id) || null; + const note = customNote || conn?.label || null; + const noteColor = customNote ? "#dde0f5" : conn ? "#8899cc" : "#333355"; + + // Wire color swatch — stripe drawn as a diagonal slash over the primary color + let swatchHTML = ""; + if (conn?.colorPrimary) { + const cp = conn.colorPrimary; + const cs = conn.colorStripe; + if (cs) { + swatchHTML = ` + + + `; + } else { + swatchHTML = ``; + } + } + + const tr = document.createElement("tr"); + tr.style.borderBottom = "1px solid #1a1a2e"; + tr.innerHTML = ` + ${num} + ${swatchHTML}${note ?? "—"}`; + tbody.appendChild(tr); + }); + + document.getElementById("pinout-modal-title").textContent = device.label; + document.getElementById("pinout-svg-container").innerHTML = svg; + document.getElementById("pinout-info").textContent = + [conn?.partNumber || device.properties?.partNumber, `${pins.length} pins`].filter(Boolean).join(" · "); + document.getElementById("pinout-modal").style.display = "flex"; + } + + _buildPinConnections(device) { + const map = new Map(); + this.canvas.wireData.forEach(wire => { + const check = (thisDevId, thisPinId, otherDevId, otherPinId) => { + if (thisDevId !== device.id || map.has(thisPinId)) return; + const otherDev = this.canvas.deviceData.get(otherDevId); + if (!otherDev) return; + const otherPin = (otherDev.pins || []).find(p => p.id === otherPinId); + map.set(thisPinId, { + label: `${otherDev.label || otherDev.reference || `Device ${otherDevId}`}, ${otherPin?.name || otherPinId}`, + colorPrimary: wire.color_primary || null, + colorStripe: wire.color_stripe || null, + }); + }; + check(wire.from_device_id, wire.from_pin, wire.to_device_id, wire.to_pin); + check(wire.to_device_id, wire.to_pin, wire.from_device_id, wire.from_pin); + }); + return map; + } + + _printPinout(device) { + const pins = device.pins || []; + const connId = device.properties?.connectorLibraryId; + const conn = connId ? getConnectorById(connId) : null; + const conns = this._buildPinConnections(device); + const svg = device.properties?.shape === "circular" + ? this._buildCircularSVG(device, pins, true) + : this._buildSchematicSVG(device, pins, true); + + const subtitle = [conn?.partNumber || device.properties?.partNumber, `${pins.length} pins`] + .filter(Boolean).join(" · "); + + const rows = pins.map((pin, i) => { + const num = i + 1; + const isDefault = !pin.name || pin.name === String(num); + const customNote = isDefault ? null : pin.name; + const c = conns.get(pin.id); + const note = customNote || c?.label || "—"; + + let swatch = ""; + if (c?.colorPrimary) { + if (c.colorStripe) { + swatch = ` + + + `; + } else { + swatch = ``; + } + } + return `${num}${swatch}${note}`; + }).join(""); + + const html = ` + +Pinout — ${device.label} + + +

${device.label}

+
${subtitle}
+
+
${svg}
+
+ + + ${rows} +
#ColorNote / Connection
+
+
+