import { layoutAndRoute, netAnchorPoint } from "./layout.js"; const GRID = 20; const NET_COLORS = { power: "#b54708", ground: "#344054", signal: "#1d4ed8", analog: "#0f766e", differential: "#c11574", clock: "#b93815", bus: "#155eef" }; function esc(text) { return String(text) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function normalizeRotation(value) { const n = Number(value ?? 0); if (!Number.isFinite(n)) { return 0; } const snapped = Math.round(n / 90) * 90; let rot = snapped % 360; if (rot < 0) { rot += 360; } return rot; } function rotatePoint(point, center, rotation) { if (!rotation) { return point; } const rad = (rotation * Math.PI) / 180; const cos = Math.round(Math.cos(rad)); const sin = Math.round(Math.sin(rad)); const dx = point.x - center.x; const dy = point.y - center.y; return { x: Math.round(center.x + dx * cos - dy * sin), y: Math.round(center.y + dx * sin + dy * cos) }; } function rotateSide(side, rotation) { const steps = normalizeRotation(rotation) / 90; const order = ["top", "right", "bottom", "left"]; const idx = order.indexOf(side); if (idx < 0) { return side; } return order[(idx + steps) % 4]; } function truncate(text, max) { const s = String(text ?? ""); if (s.length <= max) { return s; } return `${s.slice(0, Math.max(1, max - 1))}...`; } function wrapTextLine(text, maxChars) { const words = String(text ?? "").trim().split(/\s+/).filter(Boolean); if (!words.length) { return []; } const lines = []; let current = ""; for (const word of words) { if (!current) { current = word; continue; } if (`${current} ${word}`.length <= maxChars) { current = `${current} ${word}`; continue; } lines.push(current); current = word; } if (current) { lines.push(current); } return lines; } function netColor(netClass) { return NET_COLORS[netClass] ?? NET_COLORS.signal; } function symbolTemplateKind(sym) { const t = String(sym?.template_name ?? "").toLowerCase(); if (["resistor", "capacitor", "inductor", "diode", "led", "connector"].includes(t)) { return t; } const c = String(sym?.category ?? "").toLowerCase(); if (c.includes("resistor")) return "resistor"; if (c.includes("capacitor")) return "capacitor"; if (c.includes("inductor")) return "inductor"; if (c.includes("diode")) return "diode"; if (c.includes("led")) return "led"; if (c.includes("connector")) return "connector"; return null; } function renderSymbolBody(sym, x, y, width, height) { const kind = symbolTemplateKind(sym); if (!kind) { return ``; } const midX = x + width / 2; const midY = y + height / 2; const left = x + 16; const right = x + width - 16; const top = y + 14; const bottom = y + height - 14; const body = []; body.push(``); if (kind === "resistor") { const y0 = midY; const pts = [ [left, y0], [left + 16, y0 - 10], [left + 28, y0 + 10], [left + 40, y0 - 10], [left + 52, y0 + 10], [left + 64, y0 - 10], [right, y0] ]; body.push(``); } else if (kind === "capacitor") { body.push(``); body.push(``); body.push(``); body.push(``); } else if (kind === "inductor") { body.push(``); for (let i = 0; i < 4; i += 1) { const cx = left + 18 + i * 16; body.push(``); } body.push(``); } else if (kind === "diode" || kind === "led") { const triLeft = left + 12; const triRight = midX + 6; body.push(``); body.push(``); body.push(``); body.push(``); if (kind === "led") { body.push(``); body.push(``); } } else if (kind === "connector") { body.push(``); body.push(``); body.push(``); } return body.join(""); } function pinNetMap(model) { const map = new Map(); for (const net of model.nets) { for (const node of net.nodes) { const key = `${node.ref}.${node.pin}`; const list = map.get(key) ?? []; list.push(net.name); map.set(key, list); } } return map; } function renderWirePath(pathD, netName, netClass) { const color = netColor(netClass); return [ ``, `` ].join(""); } function renderNetLabel(x, y, netName, netClass, bold = false) { const color = netColor(netClass); const weight = bold ? "700" : "600"; return `${esc(netName)}`; } function isGroundLikeNet(net) { const cls = String(net?.class ?? "").trim().toLowerCase(); if (cls === "ground") { return true; } const name = String(net?.name ?? "").trim().toLowerCase(); return name === "gnd" || name === "ground" || name.endsWith("_gnd"); } function tieLabelPoint(point, netClass) { if (netClass === "power") { return { x: point.x + 8, y: point.y - 10 }; } return { x: point.x + 8, y: point.y - 8 }; } function distance(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); } function rectFromLabelPoint(point, width = 78, height = 14) { return { x: point.x - 2, y: point.y - height + 2, width, height }; } function rectsOverlap(a, b) { return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; } function pointInRect(point, rect) { return point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height; } function withCandidateOffsets(point) { const offsets = [ [0, 0], [0, -10], [0, 10], [10, 0], [-10, 0], [14, -10], [-14, -10], [14, 10], [-14, 10] ]; return offsets.map(([dx, dy]) => ({ x: point.x + dx, y: point.y + dy })); } function pickLabelPoints(points, maxCount, used, usedRects, minSpacing, avoidPoints = [], blockedRects = []) { const accepted = []; for (const p of points) { if (accepted.length >= maxCount) { break; } const candidates = withCandidateOffsets(p); for (const candidate of candidates) { let blocked = false; for (const prev of used) { if (distance(candidate, prev) < minSpacing) { blocked = true; break; } } if (blocked) { continue; } for (const pin of avoidPoints) { if (distance(candidate, pin) < minSpacing * 0.9) { blocked = true; break; } } if (blocked) { continue; } const labelRect = rectFromLabelPoint(candidate); for (const usedRect of usedRects) { if (rectsOverlap(labelRect, usedRect)) { blocked = true; break; } } if (blocked) { continue; } for (const blockedRect of blockedRects) { if (rectsOverlap(labelRect, blockedRect) || pointInRect(candidate, blockedRect)) { blocked = true; break; } } if (blocked) { continue; } accepted.push(candidate); used.push(candidate); usedRects.push(labelRect); break; } } return accepted; } function placePinText(side, desired, occupiedBySide, minStep) { const occupied = occupiedBySide[side] ?? []; if (side === "left" || side === "right") { let y = desired.y; while (occupied.some((value) => Math.abs(value - y) < minStep)) { y += minStep; } occupied.push(y); occupiedBySide[side] = occupied; return { x: desired.x, y }; } let x = desired.x; while (occupied.some((value) => Math.abs(value - x) < minStep)) { x += minStep; } occupied.push(x); occupiedBySide[side] = occupied; return { x, y: desired.y }; } function renderGroundSymbol(x, y, netName) { const y0 = y + 3; const y1 = y + 7; const y2 = y + 10; const y3 = y + 13; return ` `; } function renderPowerSymbol(x, y, netName) { return ` `; } function renderGenericTie(x, y, netName, netClass) { const color = netColor(netClass); return ``; } function renderTieSymbol(x, y, netName, netClass) { if (netClass === "ground") { return renderGroundSymbol(x, y, netName); } if (netClass === "power") { return renderPowerSymbol(x, y, netName); } return renderGenericTie(x, y, netName, netClass); } function representativePoint(routeInfo, netAnchor) { if (routeInfo.labelPoints?.length) { return routeInfo.labelPoints[0]; } if (routeInfo.tiePoints?.length) { return routeInfo.tiePoints[0]; } if (routeInfo.routes?.length && routeInfo.routes[0].length) { const seg = routeInfo.routes[0][0]; return { x: (seg.a.x + seg.b.x) / 2, y: (seg.a.y + seg.b.y) / 2 }; } return netAnchor; } function renderLegend(offsetY = 14) { const entries = [ ["power", "Power"], ["ground", "Ground"], ["clock", "Clock"], ["signal", "Signal"], ["analog", "Analog"] ]; const rows = entries .map( ([cls, label], idx) => `${label}` ) .join(""); return ` ${rows} `; } function renderAnnotationPanel(entries, maxWidth = 380) { if (!entries.length) { return { svg: "", width: 0, height: 0 }; } const panelX = 14; const panelY = 14; const rowHeight = 14; const panelWidth = Math.max(260, Math.min(420, Math.round(maxWidth))); const charsPerRow = Math.max(28, Math.floor((panelWidth - 16) / 6.2)); const wrapped = entries .flatMap((line) => wrapTextLine(line, charsPerRow).map((seg) => truncate(seg, charsPerRow))) .slice(0, 8); const panelHeight = 12 + wrapped.length * rowHeight; const textRows = wrapped .map( (line, idx) => `${esc(line)}` ) .join("\n"); const svg = ` ${textRows} `; return { svg, x: panelX, y: panelY, width: panelWidth, height: panelHeight }; } export function renderSvgFromLayout(model, layout, options = {}) { const showLabels = options.show_labels !== false; const showAnnotations = options.show_annotations !== false; const showLegend = options.show_legend !== false; const pinNets = pinNetMap(model); const netClassByName = new Map((model.nets ?? []).map((n) => [n.name, n.class])); const allPinPoints = []; const componentRects = []; const components = layout.placed .map((inst) => { const sym = model.symbols[inst.symbol]; const x = inst.placement.x; const y = inst.placement.y; const rotation = normalizeRotation(inst.placement.rotation ?? 0); const cx = x + sym.body.width / 2; const cy = y + sym.body.height / 2; componentRects.push({ x: x - 6, y: y - 6, width: sym.body.width + 12, height: sym.body.height + 12 }); const templateKind = symbolTemplateKind(sym); const compactLabel = templateKind || sym.body.width <= 140 || sym.body.height <= 90; const legacyShowInstanceNetLabels = Boolean(inst.properties?.show_net_labels); const pinUi = inst.properties?.pin_ui && typeof inst.properties.pin_ui === "object" && !Array.isArray(inst.properties.pin_ui) ? inst.properties.pin_ui : {}; const pinCircles = []; const pinLabels = []; const instanceNetLabels = []; const pinLabelOccupied = { left: [], right: [], top: [], bottom: [] }; const netLabelOccupied = { left: [], right: [], top: [], bottom: [] }; for (const pin of sym.pins) { let px = x; let py = y; if (pin.side === "left") { px = x; py = y + pin.offset; } else if (pin.side === "right") { px = x + sym.body.width; py = y + pin.offset; } else if (pin.side === "top") { px = x + pin.offset; py = y; } else { px = x + pin.offset; py = y + sym.body.height; } pinCircles.push( `` ); const rotated = rotatePoint({ x: px, y: py }, { x: cx, y: cy }, rotation); const rx = rotated.x; const ry = rotated.y; const rotatedSide = rotateSide(pin.side, rotation); allPinPoints.push({ x: rx, y: ry }); let labelX = rx + 6; let labelY = ry - 4; let textAnchor = "start"; if (rotatedSide === "right") { labelX = rx - 6; labelY = ry - 4; textAnchor = "end"; } else if (rotatedSide === "top") { labelX = rx + 4; labelY = ry + 12; textAnchor = "start"; } else if (rotatedSide === "bottom") { labelX = rx + 4; labelY = ry - 8; textAnchor = "start"; } const pinTextPos = placePinText(rotatedSide, { x: labelX, y: labelY }, pinLabelOccupied, 11); labelX = pinTextPos.x; labelY = pinTextPos.y; const showPinLabel = !templateKind || !/^\d+$/.test(pin.name); if (showPinLabel) { pinLabels.push( `${esc(pin.name)}` ); } const pinUiEntry = pinUi[pin.name]; const showPinNetLabel = pinUiEntry && typeof pinUiEntry === "object" && Object.prototype.hasOwnProperty.call(pinUiEntry, "show_net_label") ? Boolean(pinUiEntry.show_net_label) : legacyShowInstanceNetLabels; if (showPinNetLabel && showLabels) { const nets = pinNets.get(`${inst.ref}.${pin.name}`) ?? []; const displayNet = nets.find((n) => !isGroundLikeNet({ name: n, class: "" })) ?? nets[0]; if (displayNet) { let netX = labelX; let netY = labelY; let netAnchor = textAnchor; if (rotatedSide === "left") { netX = rx - 12; netY = ry - 10; netAnchor = "end"; } else if (rotatedSide === "right") { netX = rx + 12; netY = ry - 10; netAnchor = "start"; } else if (rotatedSide === "top") { netX = rx + 8; netY = ry - 10; netAnchor = "start"; } else { netX = rx + 8; netY = ry + 14; netAnchor = "start"; } const netTextPos = placePinText(rotatedSide, { x: netX, y: netY }, netLabelOccupied, 13); netX = netTextPos.x; netY = netTextPos.y; instanceNetLabels.push( `${esc(displayNet)}` ); } } } const pinLabelsSvg = [...pinLabels, ...instanceNetLabels].join(""); const pinCoreSvg = pinCircles.join(""); const rotationTransform = rotation ? ` transform="rotate(${rotation} ${cx} ${cy})"` : ""; const refLabel = truncate(inst.ref, compactLabel ? 6 : 10); const valueLabel = truncate(inst.properties?.value ?? inst.symbol, compactLabel ? 18 : 28); const refY = compactLabel ? y - 6 : y + 18; const valueY = compactLabel ? y + sym.body.height + 14 : y + 34; return ` ${renderSymbolBody(sym, x, y, sym.body.width, sym.body.height)} ${pinCoreSvg} ${pinLabelsSvg} ${esc(refLabel)} ${esc(valueLabel)} `; }) .join("\n"); const wires = layout.routed .flatMap((rn) => rn.routes.map((route) => { const path = route .map((seg, idx) => `${idx === 0 ? "M" : "L"} ${seg.a.x} ${seg.a.y} L ${seg.b.x} ${seg.b.y}`) .join(" "); return renderWirePath(path, rn.net.name, rn.net.class); }) ) .join("\n"); const junctions = layout.routed .flatMap((rn) => (rn.junctionPoints ?? []).map((p) => { const color = netColor(rn.net.class); return ``; }) ) .join("\n"); const tiePoints = layout.routed .flatMap((rn) => (rn.tiePoints ?? []).map((p) => renderTieSymbol(p.x, p.y, rn.net.name, rn.net.class)) ) .join("\n"); const routedByName = new Map(layout.routed.map((r) => [r.net.name, r])); const usedLabelPoints = []; const usedLabelRects = []; const labels = []; const tieLabels = []; const annotationLines = showAnnotations ? (model.annotations ?? []).map((a) => truncate(String(a.text ?? ""), 140)).filter(Boolean).slice(0, 6) : []; const annotationPanel = renderAnnotationPanel(annotationLines, (layout.width ?? 1200) * 0.36); const legendY = annotationPanel.height > 0 ? annotationPanel.y + annotationPanel.height + 8 : 14; const blockedLabelRects = [ ...(annotationPanel.height > 0 ? [{ x: annotationPanel.x - 2, y: annotationPanel.y - 2, width: annotationPanel.width + 4, height: annotationPanel.height + 4 }] : []), ...(showLegend ? [{ x: 6, y: legendY - 8, width: 126, height: 86 }] : []), ...componentRects ]; for (const net of model.nets) { if (isGroundLikeNet(net)) { continue; } const routeInfo = routedByName.get(net.name); if (routeInfo?.isBusMember && routeInfo.mode === "label_tie") { continue; } const netAnchor = netAnchorPoint(net, model, layout.placed); const candidates = []; if (routeInfo?.mode === "label_tie") { candidates.push(...(routeInfo?.labelPoints ?? [])); const selected = pickLabelPoints( candidates, 1, usedLabelPoints, usedLabelRects, GRID * 2.4, allPinPoints, blockedLabelRects ); for (const p of selected) { labels.push(renderNetLabel(p.x, p.y, net.name, net.class, true)); } continue; } if (routeInfo?.labelPoints?.length) { candidates.push(...routeInfo.labelPoints); } const routeCenter = representativePoint(routeInfo, netAnchor); if (routeCenter) { candidates.push({ x: routeCenter.x + 8, y: routeCenter.y - 8 }); } if (netAnchor) { candidates.push({ x: netAnchor.x + 8, y: netAnchor.y - 8 }); } const selected = pickLabelPoints( candidates, 1, usedLabelPoints, usedLabelRects, GRID * 2.4, allPinPoints, blockedLabelRects ); for (const p of selected) { labels.push(renderNetLabel(p.x, p.y, net.name, net.class)); } } if (showLabels) { const usedTieLabels = []; const usedTieRects = []; for (const rn of layout.routed) { if (rn.mode !== "label_tie" || isGroundLikeNet(rn.net)) { continue; } const candidates = (rn.tiePoints ?? []).map((p) => tieLabelPoint(p, rn.net.class)); if (!candidates.length) { continue; } const maxPerNet = rn.net.class === "power" ? Math.min(6, candidates.length) : Math.min(2, candidates.length); const selected = pickLabelPoints( candidates, maxPerNet, usedTieLabels, usedTieRects, GRID * 1.5, allPinPoints, blockedLabelRects ); for (const p of selected) { tieLabels.push(renderNetLabel(p.x, p.y, rn.net.name, rn.net.class, true)); } } } const busLabels = (layout.bus_groups ?? []) .map((group) => { const reps = group.nets .map((netName) => { const net = model.nets.find((n) => n.name === netName); if (!net) { return null; } const anchor = netAnchorPoint(net, model, layout.placed); return representativePoint(routedByName.get(netName), anchor); }) .filter(Boolean); if (!reps.length) { return ""; } const x = reps.reduce((sum, p) => sum + p.x, 0) / reps.length; const y = reps.reduce((sum, p) => sum + p.y, 0) / reps.length; return `${esc(group.name)} bus`; }) .join("\n"); const labelLayer = showLabels ? [...labels, ...tieLabels].join("\n") : ""; return ` ${components} ${wires} ${junctions} ${tiePoints} ${labelLayer} ${busLabels} ${annotationPanel.svg} ${showLegend ? renderLegend(legendY) : ""} `; } export function renderSvg(model, options = {}) { const layout = layoutAndRoute(model, options); return renderSvgFromLayout(model, layout, options); }