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 `
`;
}
export function renderSvg(model, options = {}) {
const layout = layoutAndRoute(model, options);
return renderSvgFromLayout(model, layout, options);
}