schemeta/src/layout.js

2489 lines
68 KiB
JavaScript

import { resolveElkRuntime } from "./layout-elk.js";
const GRID = 20;
const MARGIN_X = 140;
const MARGIN_Y = 140;
const COLUMN_GAP = 320;
const ROW_GAP = 190;
const OBSTACLE_PADDING = 14;
const CONNECTIVITY_COMPACT_PASSES = 8;
const CONNECTIVITY_MOVE_LIMIT = GRID * 6;
const NET_CLASS_PRIORITY = {
power: 0,
ground: 1,
clock: 2,
signal: 3,
analog: 4,
bus: 5,
differential: 6
};
const LABEL_TIE_CLASSES = new Set(["power", "ground", "bus"]);
const DEFAULT_RENDER_MODE = "schematic_stub";
export const DEFAULT_LAYOUT_ENGINE = "schemeta-v2";
const ROTATION_STEPS = [0, 90, 180, 270];
const MIN_CHANNEL_SPACING_STEPS = 3;
const LANE_ORDER = ["power", "clock", "signal", "analog", "ground", "bus", "differential"];
function toGrid(value) {
return Math.round(value / GRID) * GRID;
}
function pointKey(p) {
return `${p.x},${p.y}`;
}
function edgeKey(a, b) {
const ka = pointKey(a);
const kb = pointKey(b);
return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
}
function clone(obj) {
return JSON.parse(JSON.stringify(obj));
}
function pinTypeFor(model, ref, pinName) {
const inst = model.instances.find((x) => x.ref === ref);
if (!inst) {
return "passive";
}
const sym = model.symbols[inst.symbol];
const pin = sym?.pins.find((p) => p.name === pinName);
return pin?.type ?? "passive";
}
function pinPoint(inst, pin, width, height) {
const x0 = inst.placement.x;
const y0 = inst.placement.y;
const rotation = normalizeRotation(inst.placement.rotation ?? 0);
let base = { x: x0, y: y0 };
switch (pin.side) {
case "left":
base = { x: x0, y: y0 + pin.offset };
break;
case "right":
base = { x: x0 + width, y: y0 + pin.offset };
break;
case "top":
base = { x: x0 + pin.offset, y: y0 };
break;
case "bottom":
base = { x: x0 + pin.offset, y: y0 + height };
break;
default:
base = { x: x0, y: y0 };
}
if (!rotation) {
return base;
}
const cx = x0 + width / 2;
const cy = y0 + height / 2;
return rotatePoint(base, { x: cx, y: cy }, rotation);
}
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) {
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 hasValidPlacement(inst) {
return Number.isFinite(inst?.placement?.x) && Number.isFinite(inst?.placement?.y);
}
function preservePlacedInstances(model, options = {}) {
const respectLocks = options.respectLocks ?? true;
const autoRotate = options.autoRotate ?? false;
const fallback = placeInstances(model, { respectLocks, autoRotate });
const fallbackByRef = new Map((fallback?.placed ?? []).map((inst) => [inst.ref, inst]));
const instances = [...model.instances].sort((a, b) => a.ref.localeCompare(b.ref));
return instances.map((inst) => {
if (hasValidPlacement(inst)) {
return {
...inst,
placement: {
x: toGrid(Number(inst.placement.x)),
y: toGrid(Number(inst.placement.y)),
rotation: normalizeRotation(inst.placement.rotation ?? 0),
locked: respectLocks ? Boolean(inst.placement.locked) : false
}
};
}
return fallbackByRef.get(inst.ref) ?? {
...inst,
placement: {
x: toGrid(MARGIN_X),
y: toGrid(MARGIN_Y),
rotation: normalizeRotation(inst.placement?.rotation ?? 0),
locked: respectLocks ? Boolean(inst.placement?.locked) : false
}
};
});
}
function getNodePin(model, placedMap, node) {
const inst = placedMap.get(node.ref);
if (!inst) {
return null;
}
const sym = model.symbols[inst.symbol];
const pin = sym.pins.find((x) => x.name === node.pin);
if (!pin) {
return null;
}
const point = pinPoint(inst, pin, sym.body.width, sym.body.height);
const exit = {
left: { x: point.x - GRID, y: point.y },
right: { x: point.x + GRID, y: point.y },
top: { x: point.x, y: point.y - GRID },
bottom: { x: point.x, y: point.y + GRID }
}[rotateSide(pin.side, inst.placement.rotation ?? 0)];
return {
ref: node.ref,
pin: node.pin,
pinType: pin.type,
side: pin.side,
point,
exit: { x: toGrid(exit.x), y: toGrid(exit.y) }
};
}
function buildDirectedEdges(model) {
const edges = [];
for (const net of model.nets) {
const sources = net.nodes.filter((n) => {
const t = pinTypeFor(model, n.ref, n.pin);
return t === "output" || t === "power_out";
});
const sinks = net.nodes.filter((n) => {
const t = pinTypeFor(model, n.ref, n.pin);
return t === "input" || t === "power_in" || t === "analog" || t === "bidirectional";
});
for (const s of sources) {
for (const d of sinks) {
if (s.ref !== d.ref) {
edges.push([s.ref, d.ref]);
}
}
}
}
const dedup = new Map();
for (const [a, b] of edges) {
dedup.set(`${a}->${b}`, [a, b]);
}
return [...dedup.values()];
}
function isLikelySourceSymbol(sym) {
const category = String(sym?.category ?? "").toLowerCase();
return (
category.includes("power") ||
category.includes("mcu") ||
category.includes("microcontroller") ||
category.includes("processor") ||
category.includes("clock")
);
}
function fallbackUndirectedRanks(model, directedRank) {
const refs = model.instances.map((x) => x.ref).sort();
if (!refs.length) {
return directedRank;
}
const graph = connectivityGraph(model);
const degree = new Map(refs.map((ref) => [ref, 0]));
for (const ref of refs) {
let sum = 0;
for (const w of graph.get(ref)?.values() ?? []) {
sum += w;
}
degree.set(ref, sum);
}
const seeds = [];
for (const inst of model.instances) {
const sym = model.symbols[inst.symbol];
if (!sym) {
continue;
}
if (sym.pins.some((p) => p.type === "power_out" || p.type === "output")) {
seeds.push(inst.ref);
continue;
}
if (isLikelySourceSymbol(sym)) {
seeds.push(inst.ref);
}
}
if (!seeds.length) {
const strongest = refs
.slice()
.sort((a, b) => (degree.get(b) ?? 0) - (degree.get(a) ?? 0) || a.localeCompare(b))[0];
if (strongest) {
seeds.push(strongest);
}
}
const dist = new Map(refs.map((ref) => [ref, Number.POSITIVE_INFINITY]));
const queue = [];
for (const s of seeds) {
dist.set(s, 0);
queue.push(s);
}
while (queue.length) {
const ref = queue.shift();
const base = dist.get(ref) ?? Number.POSITIVE_INFINITY;
for (const [nbr, weight] of graph.get(ref)?.entries() ?? []) {
const step = Math.max(0.35, 1.4 - Math.min(1.1, weight * 0.55));
const next = base + step;
if (next + 1e-6 < (dist.get(nbr) ?? Number.POSITIVE_INFINITY)) {
dist.set(nbr, next);
queue.push(nbr);
}
}
}
let minFinite = Number.POSITIVE_INFINITY;
let maxFinite = 0;
for (const d of dist.values()) {
if (!Number.isFinite(d)) {
continue;
}
minFinite = Math.min(minFinite, d);
maxFinite = Math.max(maxFinite, d);
}
const normalized = new Map(directedRank);
for (const ref of refs) {
const d = dist.get(ref);
if (!Number.isFinite(d)) {
continue;
}
const shifted = d - (Number.isFinite(minFinite) ? minFinite : 0);
const fallbackRank = Math.max(0, Math.round(shifted * 1.6));
const directed = directedRank.get(ref) ?? 1;
normalized.set(ref, Math.max(directed, fallbackRank));
}
if (maxFinite - minFinite < 0.6) {
refs
.slice()
.sort((a, b) => {
const da = degree.get(a) ?? 0;
const db = degree.get(b) ?? 0;
if (da !== db) {
return db - da;
}
return a.localeCompare(b);
})
.forEach((ref, idx) => {
const directed = normalized.get(ref) ?? 1;
normalized.set(ref, Math.max(directed, Math.floor(idx / 3)));
});
}
return normalized;
}
function computeRanks(model) {
const refs = model.instances.map((x) => x.ref).sort();
const rank = new Map(refs.map((r) => [r, 1]));
const powerRefs = new Set(
model.instances
.filter((inst) => {
const sym = model.symbols[inst.symbol];
return (
sym.category.toLowerCase().includes("power") ||
sym.pins.some((p) => p.type === "power_out")
);
})
.map((x) => x.ref)
);
for (const r of powerRefs) {
rank.set(r, 0);
}
const edges = buildDirectedEdges(model);
for (let i = 0; i < refs.length; i += 1) {
let changed = false;
for (const [from, to] of edges) {
const next = (rank.get(from) ?? 1) + 1;
if (!powerRefs.has(to) && next > (rank.get(to) ?? 1)) {
rank.set(to, next);
changed = true;
}
}
if (!changed) {
break;
}
}
for (const r of powerRefs) {
rank.set(r, 0);
}
const uniqueDirectedRanks = new Set(refs.map((ref) => rank.get(ref) ?? 1));
const needsFallback =
edges.length < Math.max(2, Math.floor(refs.length * 0.25)) ||
uniqueDirectedRanks.size <= 2;
const ranked = needsFallback ? fallbackUndirectedRanks(model, rank) : rank;
return { rank: ranked, edges, powerRefs };
}
function computeBaryOrder(columns, edges) {
const predecessors = new Map();
for (const [a, b] of edges) {
const list = predecessors.get(b) ?? [];
list.push(a);
predecessors.set(b, list);
}
const orderByRef = new Map();
for (const refs of columns.values()) {
refs.forEach((ref, idx) => orderByRef.set(ref, idx));
}
const sortedColumns = [...columns.entries()].sort((a, b) => Number(a[0]) - Number(b[0]));
for (const [, refs] of sortedColumns) {
refs.sort((a, b) => {
const pa = predecessors.get(a) ?? [];
const pb = predecessors.get(b) ?? [];
const ba = pa.length
? pa.reduce((sum, r) => sum + (orderByRef.get(r) ?? 0), 0) / pa.length
: Number.MAX_SAFE_INTEGER;
const bb = pb.length
? pb.reduce((sum, r) => sum + (orderByRef.get(r) ?? 0), 0) / pb.length
: Number.MAX_SAFE_INTEGER;
if (ba !== bb) {
return ba - bb;
}
return a.localeCompare(b);
});
refs.forEach((ref, idx) => orderByRef.set(ref, idx));
}
}
function applyAlignmentConstraints(placedMap, constraints) {
const alignments = constraints?.alignment ?? [];
for (const rule of alignments) {
const left = placedMap.get(rule.left_of);
const right = placedMap.get(rule.right_of);
if (!left || !right) {
continue;
}
const targetRightX = left.placement.x + COLUMN_GAP;
if (right.placement.x < targetRightX) {
right.placement.x = toGrid(targetRightX);
}
}
}
function applyNearConstraints(model, placedMap, constraints) {
const rules = constraints?.near ?? [];
for (const rule of rules) {
const comp = placedMap.get(rule.component);
const target = placedMap.get(rule.target_pin?.ref);
if (!comp || !target || comp.placement.locked) {
continue;
}
const targetSym = model.symbols[target.symbol];
const targetPin = targetSym.pins.find((p) => p.name === rule.target_pin.pin);
if (!targetPin) {
continue;
}
const tp = pinPoint(target, targetPin, targetSym.body.width, targetSym.body.height);
comp.placement.x = toGrid(tp.x + GRID * 4);
comp.placement.y = toGrid(tp.y - GRID * 3);
}
}
function buildInstanceMap(instances) {
return new Map(instances.map((inst) => [inst.ref, inst]));
}
function buildConstraintGroups(model, rank) {
const allRefs = new Set(model.instances.map((i) => i.ref));
const consumed = new Set();
const out = [];
for (const g of model.constraints?.groups ?? []) {
const members = (g.members ?? []).filter((ref) => allRefs.has(ref) && !consumed.has(ref));
if (!members.length) {
continue;
}
for (const ref of members) {
consumed.add(ref);
}
out.push({
name: g.name ?? `group_${out.length + 1}`,
members,
synthetic: false
});
}
const leftovers = model.instances
.map((i) => i.ref)
.filter((ref) => !consumed.has(ref))
.sort((a, b) => {
const ra = rank.get(a) ?? 1;
const rb = rank.get(b) ?? 1;
if (ra !== rb) {
return ra - rb;
}
return a.localeCompare(b);
});
const autoGroups = autoClusterLeftovers(model, leftovers, rank);
for (const g of autoGroups) {
out.push(g);
}
return out;
}
function autoClusterLeftovers(model, leftovers, rank) {
if (!leftovers.length) {
return [];
}
const graph = connectivityGraph(model);
const leftoverSet = new Set(leftovers);
const visited = new Set();
const clusters = [];
const EDGE_THRESHOLD = 0.35;
for (const start of leftovers) {
if (visited.has(start)) {
continue;
}
const queue = [start];
visited.add(start);
const comp = [];
while (queue.length) {
const ref = queue.shift();
comp.push(ref);
for (const [nbr, w] of graph.get(ref)?.entries() ?? []) {
if (!leftoverSet.has(nbr) || visited.has(nbr)) {
continue;
}
if (w < EDGE_THRESHOLD) {
continue;
}
visited.add(nbr);
queue.push(nbr);
}
}
clusters.push(comp);
}
clusters.sort((a, b) => {
const ar = Math.min(...a.map((r) => rank.get(r) ?? 1));
const br = Math.min(...b.map((r) => rank.get(r) ?? 1));
if (ar !== br) {
return ar - br;
}
return a[0].localeCompare(b[0]);
});
return clusters.map((members, idx) => {
const sorted = [...members].sort((a, b) => {
const ra = rank.get(a) ?? 1;
const rb = rank.get(b) ?? 1;
if (ra !== rb) {
return ra - rb;
}
return a.localeCompare(b);
});
if (sorted.length === 1) {
return { name: `solo_${sorted[0]}`, members: sorted, synthetic: true };
}
return { name: `auto_cluster_${idx + 1}`, members: sorted, synthetic: true };
});
}
function rankColumnsForRefs(refs, rank) {
const minRank = Math.min(...refs.map((r) => rank.get(r) ?? 1));
const cols = new Map();
for (const ref of refs) {
const localRank = Math.max(0, (rank.get(ref) ?? 1) - minRank);
const list = cols.get(localRank) ?? [];
list.push(ref);
cols.set(localRank, list);
}
const uniqueCols = [...cols.keys()].sort((a, b) => a - b);
if (uniqueCols.length === 1 && refs.length >= 4) {
cols.clear();
const targetCols = Math.min(3, Math.max(2, Math.ceil(refs.length / 3)));
refs.forEach((ref, idx) => {
const col = idx % targetCols;
const list = cols.get(col) ?? [];
list.push(ref);
cols.set(col, list);
});
}
return cols;
}
function connectivityDegree(model) {
const deg = new Map(model.instances.map((i) => [i.ref, 0]));
for (const net of model.nets) {
const refs = [...new Set((net.nodes ?? []).map((n) => n.ref))];
for (const ref of refs) {
deg.set(ref, (deg.get(ref) ?? 0) + Math.max(1, refs.length - 1));
}
}
return deg;
}
function connectivityGraph(model) {
const graph = new Map(model.instances.map((i) => [i.ref, new Map()]));
const classWeight = (netClass) => {
if (netClass === "clock") return 4.6;
if (netClass === "signal") return 4.2;
if (netClass === "analog") return 3.8;
if (netClass === "bus") return 3.4;
if (netClass === "differential") return 3.2;
if (netClass === "power") return 1.6;
if (netClass === "ground") return 1.4;
return 2.4;
};
for (const net of model.nets ?? []) {
const refs = [...new Set((net.nodes ?? []).map((n) => n.ref))].filter((ref) => graph.has(ref));
if (refs.length < 2) {
continue;
}
const weight = classWeight(String(net.class ?? "signal")) / Math.max(1, refs.length - 1);
for (let i = 0; i < refs.length; i += 1) {
for (let j = i + 1; j < refs.length; j += 1) {
const a = refs[i];
const b = refs[j];
const aAdj = graph.get(a);
const bAdj = graph.get(b);
aAdj.set(b, (aAdj.get(b) ?? 0) + weight);
bAdj.set(a, (bAdj.get(a) ?? 0) + weight);
}
}
}
return graph;
}
function centerForPlacement(model, inst) {
const sym = model.symbols[inst.symbol];
const w = sym?.body?.width ?? 120;
const h = sym?.body?.height ?? 80;
return {
x: inst.placement.x + w / 2,
y: inst.placement.y + h / 2
};
}
function clampStep(delta, maxStep) {
if (delta > maxStep) {
return maxStep;
}
if (delta < -maxStep) {
return -maxStep;
}
return delta;
}
function compactPlacementByConnectivity(model, placedMap, options = {}) {
const respectLocks = options.respectLocks ?? true;
const laneProfiles = options.laneProfiles ?? new Map();
const rank = options.rank ?? new Map();
const graph = connectivityGraph(model);
const refs = [...placedMap.keys()].sort();
if (!refs.length) {
return;
}
const passCount = refs.length > 140 ? 2 : refs.length > 80 ? 4 : CONNECTIVITY_COMPACT_PASSES;
const minRank = Math.min(...refs.map((r) => rank.get(r) ?? 1), 1);
const rankBuckets = new Map();
for (const ref of refs) {
const r = rank.get(ref) ?? 1;
const list = rankBuckets.get(r) ?? [];
list.push(ref);
rankBuckets.set(r, list);
}
const rankOffsetByRef = new Map();
for (const [, bucketRefs] of rankBuckets.entries()) {
bucketRefs.sort();
const cols = Math.min(4, Math.max(1, Math.ceil(bucketRefs.length / 2)));
const stride = GRID * 6;
for (let i = 0; i < bucketRefs.length; i += 1) {
const slot = i % cols;
const centered = slot - (cols - 1) / 2;
rankOffsetByRef.set(bucketRefs[i], centered * stride);
}
}
const centersByRef = () => {
const out = new Map();
for (const ref of refs) {
const inst = placedMap.get(ref);
if (!inst) continue;
out.set(ref, centerForPlacement(model, inst));
}
return out;
};
for (let pass = 0; pass < passCount; pass += 1) {
const centers = centersByRef();
for (const ref of refs) {
const inst = placedMap.get(ref);
if (!inst) {
continue;
}
if (respectLocks && inst.placement.locked) {
continue;
}
const sym = model.symbols[inst.symbol];
const w = sym?.body?.width ?? 120;
const h = sym?.body?.height ?? 80;
const currentCenter = centers.get(ref);
if (!currentCenter) {
continue;
}
const neighbors = [...(graph.get(ref)?.entries() ?? [])];
if (!neighbors.length) {
continue;
}
let sumW = 0;
let tx = 0;
let ty = 0;
for (const [nbr, weight] of neighbors) {
const c = centers.get(nbr);
if (!c) {
continue;
}
sumW += weight;
tx += c.x * weight;
ty += c.y * weight;
}
if (!sumW) {
continue;
}
tx /= sumW;
ty /= sumW;
const localRank = Math.max(0, (rank.get(ref) ?? 1) - minRank);
const rankTargetX =
MARGIN_X + localRank * (COLUMN_GAP * 0.82) + (rankOffsetByRef.get(ref) ?? 0);
const lane = laneProfiles.get(ref)?.laneIndex ?? 2;
const laneY = MARGIN_Y + lane * (ROW_GAP * 0.65);
let repelX = 0;
let repelY = 0;
for (const [otherRef, otherCenter] of centers.entries()) {
if (otherRef === ref) {
continue;
}
const ddx = currentCenter.x - otherCenter.x;
const ddy = currentCenter.y - otherCenter.y;
const ax = Math.abs(ddx);
const ay = Math.abs(ddy);
if (ax > GRID * 10 || ay > GRID * 10) {
continue;
}
const px = (GRID * 10 - ax) * 0.035;
const py = (GRID * 10 - ay) * 0.03;
repelX += ddx >= 0 ? px : -px;
repelY += ddy >= 0 ? py : -py;
}
tx = tx * 0.62 + rankTargetX * 0.24 + currentCenter.x * 0.14 + repelX;
ty = ty * 0.72 + laneY * 0.28;
const dx = clampStep(tx - currentCenter.x, CONNECTIVITY_MOVE_LIMIT);
const dy = clampStep(ty - currentCenter.y, CONNECTIVITY_MOVE_LIMIT);
inst.placement.x = toGrid(Math.max(MARGIN_X, inst.placement.x + dx));
inst.placement.y = toGrid(Math.max(MARGIN_Y, inst.placement.y + dy));
placedMap.set(ref, inst);
}
const shouldResolve =
pass === passCount - 1 || (refs.length <= 80 && pass % 2 === 1);
if (shouldResolve) {
resolvePlacementOverlaps(model, placedMap, { respectLocks });
}
}
}
function isRailNetName(name) {
const n = String(name ?? "").toUpperCase();
return n === "GND" || n === "GROUND" || n === "3V3" || n === "5V" || n === "VCC" || n === "VIN";
}
function symbolKind(sym) {
const t = String(sym?.template_name ?? "").toLowerCase();
if (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";
return c;
}
function tightenPassiveAdjacency(model, placedMap, options = {}) {
const respectLocks = options.respectLocks ?? true;
const refToNets = new Map();
for (const net of model.nets ?? []) {
for (const node of net.nodes ?? []) {
const list = refToNets.get(node.ref) ?? [];
list.push(net);
refToNets.set(node.ref, list);
}
}
const refs = [...placedMap.keys()].sort();
for (const ref of refs) {
const inst = placedMap.get(ref);
if (!inst) continue;
if (respectLocks && inst.placement.locked) continue;
const sym = model.symbols[inst.symbol];
const kind = symbolKind(sym);
if (!["resistor", "capacitor", "inductor", "diode", "led"].includes(kind)) {
continue;
}
const nets = refToNets.get(ref) ?? [];
const preferred = nets.find((n) => !["power", "ground"].includes(String(n.class ?? "")) && !isRailNetName(n.name));
if (!preferred) {
continue;
}
const anchors = [];
for (const node of preferred.nodes ?? []) {
if (node.ref === ref) continue;
const other = placedMap.get(node.ref);
if (!other) continue;
anchors.push(centerForPlacement(model, other));
}
if (!anchors.length) {
continue;
}
const cx = anchors.reduce((s, p) => s + p.x, 0) / anchors.length;
const cy = anchors.reduce((s, p) => s + p.y, 0) / anchors.length;
const current = centerForPlacement(model, inst);
const tx = cx * 0.86 + current.x * 0.14;
const ty = cy * 0.86 + current.y * 0.14;
const nx = toGrid(Math.max(MARGIN_X, tx - (sym?.body?.width ?? 120) / 2));
const ny = toGrid(Math.max(MARGIN_Y, ty - (sym?.body?.height ?? 80) / 2));
inst.placement.x = nx;
inst.placement.y = ny;
placedMap.set(ref, inst);
}
}
function tightenConstraintGroups(model, placedMap, options = {}) {
const respectLocks = options.respectLocks ?? true;
for (const g of model.constraints?.groups ?? []) {
const members = (g.members ?? []).map((ref) => placedMap.get(ref)).filter(Boolean);
if (members.length < 2) {
continue;
}
const centers = members.map((inst) => centerForPlacement(model, inst));
const cx = centers.reduce((s, p) => s + p.x, 0) / centers.length;
const cy = centers.reduce((s, p) => s + p.y, 0) / centers.length;
const maxRadius = 320;
for (const inst of members) {
if (respectLocks && inst.placement.locked) {
continue;
}
const sym = model.symbols[inst.symbol];
const c = centerForPlacement(model, inst);
const dx = c.x - cx;
const dy = c.y - cy;
const dist = Math.hypot(dx, dy);
if (dist <= maxRadius) {
continue;
}
const pull = Math.min(CONNECTIVITY_MOVE_LIMIT, (dist - maxRadius) * 0.55);
const ux = dist > 0 ? dx / dist : 0;
const uy = dist > 0 ? dy / dist : 0;
const tx = c.x - ux * pull;
const ty = c.y - uy * pull;
inst.placement.x = toGrid(Math.max(MARGIN_X, tx - (sym?.body?.width ?? 120) / 2));
inst.placement.y = toGrid(Math.max(MARGIN_Y, ty - (sym?.body?.height ?? 80) / 2));
placedMap.set(inst.ref, inst);
}
}
}
function refLaneProfiles(model) {
const profiles = new Map(model.instances.map((inst) => [inst.ref, { total: 0, byClass: {} }]));
for (const net of model.nets ?? []) {
const netClass = String(net.class ?? "signal");
const refs = [...new Set((net.nodes ?? []).map((n) => n.ref))];
for (const ref of refs) {
const p = profiles.get(ref);
if (!p) {
continue;
}
p.total += 1;
p.byClass[netClass] = (p.byClass[netClass] ?? 0) + 1;
}
}
const out = new Map();
for (const [ref, profile] of profiles.entries()) {
const ranked = Object.entries(profile.byClass).sort((a, b) => {
if (a[1] !== b[1]) {
return b[1] - a[1];
}
return (NET_CLASS_PRIORITY[a[0]] ?? 99) - (NET_CLASS_PRIORITY[b[0]] ?? 99);
});
const dominantClass = ranked[0]?.[0] ?? "signal";
const laneIndex = Math.max(0, LANE_ORDER.indexOf(dominantClass));
out.set(ref, {
dominantClass,
laneIndex,
profile
});
}
return out;
}
function placeGroup(model, group, start, context) {
const { rank, degree, instanceByRef, respectLocks, laneProfiles } = context;
const refs = [...group.members].sort((a, b) => a.localeCompare(b));
const cols = rankColumnsForRefs(refs, rank);
const colOrder = [...cols.keys()].sort((a, b) => a - b);
const colWidths = new Map();
for (const col of colOrder) {
const w = Math.max(
...cols.get(col).map((ref) => {
const inst = instanceByRef.get(ref);
const sym = inst ? model.symbols[inst.symbol] : null;
return sym?.body?.width ?? 120;
}),
120
);
colWidths.set(col, w);
}
const colX = new Map();
let xCursor = start.x;
for (const col of colOrder) {
colX.set(col, toGrid(xCursor));
xCursor += (colWidths.get(col) ?? 120) + 110;
}
const placed = [];
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const col of colOrder) {
const refsInCol = [...(cols.get(col) ?? [])].sort((a, b) => {
const la = laneProfiles.get(a)?.laneIndex ?? 2;
const lb = laneProfiles.get(b)?.laneIndex ?? 2;
if (la !== lb) {
return la - lb;
}
const da = degree.get(a) ?? 0;
const db = degree.get(b) ?? 0;
if (da !== db) {
return db - da;
}
return a.localeCompare(b);
});
const byLane = new Map();
for (const ref of refsInCol) {
const lane = laneProfiles.get(ref)?.laneIndex ?? 2;
const list = byLane.get(lane) ?? [];
list.push(ref);
byLane.set(lane, list);
}
let yCursor = start.y;
const laneSequence = [...byLane.keys()].sort((a, b) => a - b);
for (const lane of laneSequence) {
const laneRefs = byLane.get(lane) ?? [];
for (const ref of laneRefs) {
const inst = instanceByRef.get(ref);
if (!inst) {
continue;
}
const sym = model.symbols[inst.symbol];
const locked = respectLocks ? (inst.placement.locked ?? false) : false;
let x = inst.placement.x;
let y = inst.placement.y;
if (x == null || y == null || !locked) {
x = toGrid(colX.get(col) ?? start.x);
y = toGrid(yCursor);
}
const next = {
...inst,
placement: {
x,
y,
rotation: normalizeRotation(inst.placement.rotation ?? 0),
locked
}
};
placed.push(next);
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + sym.body.width);
maxY = Math.max(maxY, y + sym.body.height);
yCursor = y + sym.body.height + 64;
}
yCursor += 28;
}
}
if (!placed.length) {
return {
placed,
bbox: { x: start.x, y: start.y, w: 320, h: 240 }
};
}
return {
placed,
bbox: {
x: minX,
y: minY,
w: maxX - minX,
h: maxY - minY
}
};
}
function rectForPlacement(model, inst) {
const sym = model.symbols[inst.symbol];
return {
x: inst.placement.x,
y: inst.placement.y,
w: sym?.body?.width ?? 120,
h: sym?.body?.height ?? 80
};
}
function boxesOverlap(a, b, pad = 12) {
return !(
a.x + a.w + pad <= b.x ||
b.x + b.w + pad <= a.x ||
a.y + a.h + pad <= b.y ||
b.y + b.h + pad <= a.y
);
}
function resolvePlacementOverlaps(model, placedMap, options = {}) {
const respectLocks = options.respectLocks ?? true;
const refs = [...placedMap.keys()].sort();
const iterations = Math.max(1, refs.length * 3);
for (let pass = 0; pass < iterations; pass += 1) {
let moved = false;
for (let i = 0; i < refs.length; i += 1) {
const aRef = refs[i];
const aInst = placedMap.get(aRef);
if (!aInst) {
continue;
}
const aLocked = respectLocks ? Boolean(aInst.placement.locked) : false;
let aBox = rectForPlacement(model, aInst);
for (let j = i + 1; j < refs.length; j += 1) {
const bRef = refs[j];
const bInst = placedMap.get(bRef);
if (!bInst) {
continue;
}
const bLocked = respectLocks ? Boolean(bInst.placement.locked) : false;
const bBox = rectForPlacement(model, bInst);
if (!boxesOverlap(aBox, bBox, 14)) {
continue;
}
const target = !aLocked ? aInst : !bLocked ? bInst : null;
if (!target) {
continue;
}
const oldX = target.placement.x;
const oldY = target.placement.y;
const overlapX = Math.max(
0,
Math.min(aBox.x + aBox.w, bBox.x + bBox.w) - Math.max(aBox.x, bBox.x)
);
const overlapY = Math.max(
0,
Math.min(aBox.y + aBox.h, bBox.y + bBox.h) - Math.max(aBox.y, bBox.y)
);
const targetBox = rectForPlacement(model, target);
const otherBox = target === aInst ? bBox : aBox;
const targetCx = targetBox.x + targetBox.w / 2;
const targetCy = targetBox.y + targetBox.h / 2;
const otherCx = otherBox.x + otherBox.w / 2;
const otherCy = otherBox.y + otherBox.h / 2;
if (overlapX <= overlapY) {
const dir = targetCx >= otherCx ? 1 : -1;
const push = toGrid(overlapX + 64) * dir;
target.placement.x = Math.max(MARGIN_X, toGrid(target.placement.x + push));
} else {
const dir = targetCy >= otherCy ? 1 : -1;
const push = toGrid(overlapY + 64) * dir;
target.placement.y = Math.max(MARGIN_Y, toGrid(target.placement.y + push));
}
if (target.placement.x === oldX && target.placement.y === oldY) {
const fallbackDir = targetCy >= otherCy ? 1 : -1;
const fallbackPush = toGrid(Math.max(overlapY, overlapX) + 84) * fallbackDir;
target.placement.y = Math.max(MARGIN_Y, toGrid(target.placement.y + fallbackPush));
}
if (target === aInst) {
aBox = rectForPlacement(model, aInst);
}
moved = true;
}
}
if (!moved) {
break;
}
}
}
function enforceFinalComponentSeparation(model, placedMap, options = {}) {
const respectLocks = options.respectLocks ?? true;
const refs = [...placedMap.keys()].sort();
const maxPasses = Math.max(1, refs.length * 2);
for (let pass = 0; pass < maxPasses; pass += 1) {
let changed = false;
for (let i = 0; i < refs.length; i += 1) {
const aRef = refs[i];
const aInst = placedMap.get(aRef);
if (!aInst) continue;
const aLocked = respectLocks ? Boolean(aInst.placement.locked) : false;
const aBox = rectForPlacement(model, aInst);
for (let j = i + 1; j < refs.length; j += 1) {
const bRef = refs[j];
const bInst = placedMap.get(bRef);
if (!bInst) continue;
const bLocked = respectLocks ? Boolean(bInst.placement.locked) : false;
const bBox = rectForPlacement(model, bInst);
if (!boxesOverlap(aBox, bBox, 8)) {
continue;
}
let mover = null;
let anchor = null;
if (!aLocked && bLocked) {
mover = aInst;
anchor = bInst;
} else if (aLocked && !bLocked) {
mover = bInst;
anchor = aInst;
} else if (!aLocked && !bLocked) {
mover = bRef.localeCompare(aRef) >= 0 ? bInst : aInst;
anchor = mover === bInst ? aInst : bInst;
} else {
continue;
}
const mBox = rectForPlacement(model, mover);
const kBox = rectForPlacement(model, anchor);
const moveRight = mBox.x >= kBox.x;
const stepX = toGrid(Math.max(mBox.w, kBox.w) + 70) * (moveRight ? 1 : -1);
const oldX = mover.placement.x;
mover.placement.x = Math.max(MARGIN_X, toGrid(mover.placement.x + stepX));
if (mover.placement.x === oldX) {
mover.placement.y = Math.max(MARGIN_Y, toGrid(mover.placement.y + toGrid(Math.max(mBox.h, kBox.h) + 70)));
}
changed = true;
}
}
if (!changed) {
break;
}
}
}
function buildNodeNetMap(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);
map.set(key, list);
}
}
return map;
}
function scoreInstanceRotation(model, placedMap, inst, rotation, nodeNetMap) {
const sym = model.symbols[inst.symbol];
if (!sym || !Array.isArray(sym.pins)) {
return Number.POSITIVE_INFINITY;
}
const temp = {
...inst,
placement: {
...inst.placement,
rotation
}
};
let score = 0;
for (const pin of sym.pins) {
const key = `${inst.ref}.${pin.name}`;
const nets = nodeNetMap.get(key) ?? [];
if (!nets.length) {
continue;
}
const p = pinPoint(temp, pin, sym.body.width, sym.body.height);
for (const net of nets) {
const others = (net.nodes ?? []).filter((n) => !(n.ref === inst.ref && n.pin === pin.name));
if (!others.length) {
continue;
}
let cx = 0;
let cy = 0;
let count = 0;
for (const n of others) {
const oi = placedMap.get(n.ref);
if (!oi) {
continue;
}
const os = model.symbols[oi.symbol];
const op = os?.pins?.find((pp) => pp.name === n.pin);
if (!os || !op) {
continue;
}
const q = pinPoint(oi, op, os.body.width, os.body.height);
cx += q.x;
cy += q.y;
count += 1;
}
if (!count) {
continue;
}
const tx = cx / count;
const ty = cy / count;
score += Math.abs(p.x - tx) + Math.abs(p.y - ty);
}
}
return score;
}
function applyAutoRotation(model, placedMap, options = {}) {
const allow = options.autoRotate !== false;
if (!allow) {
return;
}
const nodeNetMap = buildNodeNetMap(model);
const refs = [...placedMap.keys()].sort();
for (const ref of refs) {
const inst = placedMap.get(ref);
if (!inst || inst.placement.locked) {
continue;
}
let bestRot = normalizeRotation(inst.placement.rotation ?? 0);
let bestScore = scoreInstanceRotation(model, placedMap, inst, bestRot, nodeNetMap);
for (const rot of ROTATION_STEPS) {
const s = scoreInstanceRotation(model, placedMap, inst, rot, nodeNetMap);
if (s < bestScore - 0.001) {
bestScore = s;
bestRot = rot;
}
}
inst.placement.rotation = bestRot;
placedMap.set(ref, inst);
}
}
function placeInstances(model, options = {}) {
const respectLocks = options.respectLocks ?? true;
const instances = [...model.instances].sort((a, b) => a.ref.localeCompare(b.ref));
const { rank } = computeRanks(model);
const degree = connectivityDegree(model);
const laneProfiles = refLaneProfiles(model);
const instanceByRef = buildInstanceMap(instances);
const groups = buildConstraintGroups(model, rank);
const placed = [];
const placedMap = new Map();
const groupsPerRow = groups.length <= 3 ? groups.length : groups.length >= 6 ? 3 : 2;
const groupCellW = 620;
const groupCellH = 420;
for (let i = 0; i < groups.length; i += 1) {
const group = groups[i];
const row = groupsPerRow ? Math.floor(i / groupsPerRow) : 0;
const col = groupsPerRow ? i % groupsPerRow : 0;
const origin = {
x: toGrid(MARGIN_X + col * groupCellW),
y: toGrid(MARGIN_Y + row * groupCellH)
};
const out = placeGroup(model, group, origin, {
rank,
degree,
laneProfiles,
instanceByRef,
respectLocks
});
for (const inst of out.placed) {
placed.push(inst);
placedMap.set(inst.ref, inst);
}
}
applyAutoRotation(model, placedMap, {
autoRotate: options.autoRotate ?? true
});
compactPlacementByConnectivity(model, placedMap, {
respectLocks,
laneProfiles,
rank
});
if (model.instances.length >= 8 && model.instances.length <= 220) {
tightenPassiveAdjacency(model, placedMap, { respectLocks });
tightenConstraintGroups(model, placedMap, { respectLocks });
}
applyAlignmentConstraints(placedMap, model.constraints);
applyNearConstraints(model, placedMap, model.constraints);
resolvePlacementOverlaps(model, placedMap, { respectLocks });
enforceFinalComponentSeparation(model, placedMap, { respectLocks });
const normalizedPlaced = instances
.map((inst) => placedMap.get(inst.ref) ?? inst)
.sort((a, b) => a.ref.localeCompare(b.ref));
return { placed: normalizedPlaced, placedMap };
}
function buildObstacles(model, placed) {
return placed.map((inst) => {
const sym = model.symbols[inst.symbol];
return {
ref: inst.ref,
x: inst.placement.x - OBSTACLE_PADDING,
y: inst.placement.y - OBSTACLE_PADDING,
w: sym.body.width + OBSTACLE_PADDING * 2,
h: sym.body.height + OBSTACLE_PADDING * 2
};
});
}
function between(value, min, max) {
return value >= min && value <= max;
}
function segmentIntersectsBox(a, b, box) {
if (a.x === b.x) {
if (!between(a.x, box.x, box.x + box.w)) {
return false;
}
const minY = Math.min(a.y, b.y);
const maxY = Math.max(a.y, b.y);
return !(maxY < box.y || minY > box.y + box.h);
}
if (a.y === b.y) {
if (!between(a.y, box.y, box.y + box.h)) {
return false;
}
const minX = Math.min(a.x, b.x);
const maxX = Math.max(a.x, b.x);
return !(maxX < box.x || minX > box.x + box.w);
}
return false;
}
function buildBounds(model, placed) {
const maxX = Math.max(...placed.map((p) => p.placement.x + model.symbols[p.symbol].body.width), MARGIN_X * 3);
const maxY = Math.max(...placed.map((p) => p.placement.y + model.symbols[p.symbol].body.height), MARGIN_Y * 3);
return {
minX: 0,
minY: 0,
maxX: toGrid(maxX + MARGIN_X * 2),
maxY: toGrid(maxY + MARGIN_Y * 2)
};
}
function segmentBlocked(a, b, obstacles, allowedRefs) {
for (const box of obstacles) {
if (allowedRefs.has(box.ref)) {
continue;
}
if (segmentIntersectsBox(a, b, box)) {
return true;
}
}
return false;
}
function heuristic(a, b) {
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
}
function reconstructPath(cameFrom, currentKey) {
const out = [];
let key = currentKey;
while (key) {
const node = cameFrom.get(key)?.node;
if (!node) {
const [x, y] = key.split(",").map(Number);
out.push({ x, y });
break;
}
out.push(node);
key = cameFrom.get(key)?.prev;
}
out.reverse();
return out;
}
function hasForeignEdgeUsage(edgeUsage, netName, a, b) {
const usage = edgeUsage.get(edgeKey(a, b));
if (!usage) {
return false;
}
for (const existingNet of usage.byNet.keys()) {
if (existingNet !== netName) {
return true;
}
}
return false;
}
function hasForeignPointUsage(pointUsage, netName, point) {
const usage = pointUsage.get(pointKey(point));
if (!usage) {
return false;
}
for (const existingNet of usage.keys()) {
if (existingNet !== netName) {
return true;
}
}
return false;
}
function foreignUsageCount(usageMap, key, ownNet) {
const usage = usageMap.get(key);
if (!usage) {
return 0;
}
let count = 0;
for (const [net, n] of usage.entries()) {
if (net !== ownNet) {
count += n;
}
}
return count;
}
function channelCrowdingPenalty(current, next, context) {
const { netName, hLineUsage, vLineUsage } = context;
if (current.x !== next.x && current.y !== next.y) {
return 0;
}
const isHorizontal = current.y === next.y;
const lineUsage = isHorizontal ? hLineUsage : vLineUsage;
const lineCoord = isHorizontal ? current.y : current.x;
let penalty = 0;
for (let step = -MIN_CHANNEL_SPACING_STEPS; step <= MIN_CHANNEL_SPACING_STEPS; step += 1) {
const coord = lineCoord + step * GRID;
const foreign = foreignUsageCount(lineUsage, coord, netName);
if (!foreign) {
continue;
}
const distance = Math.abs(step);
const weight = distance === 0 ? 34 : distance === 1 ? 17 : 7;
penalty += foreign * weight;
}
return penalty;
}
function pointCongestionPenalty(point, context) {
const { pointUsage, netName } = context;
let penalty = 0;
for (const nb of [
{ x: point.x, y: point.y },
{ x: point.x + GRID, y: point.y },
{ x: point.x - GRID, y: point.y },
{ x: point.x, y: point.y + GRID },
{ x: point.x, y: point.y - GRID }
]) {
const foreign = foreignUsageCount(pointUsage, pointKey(nb), netName);
if (foreign) {
penalty += foreign * 10;
}
}
return penalty;
}
function proximityPenalty(point, obstacles, allowedRefs) {
let penalty = 0;
for (const box of obstacles) {
if (allowedRefs.has(box.ref)) {
continue;
}
const dx = Math.max(box.x - point.x, 0, point.x - (box.x + box.w));
const dy = Math.max(box.y - point.y, 0, point.y - (box.y + box.h));
const dist = dx + dy;
if (dist < GRID * 4) {
penalty += (GRID * 4 - dist) * 0.7;
}
}
return penalty;
}
function aStar(start, goal, context) {
const { bounds, obstacles, allowedRefs, edgeUsage, pointUsage, netName } = context;
const startNode = { x: toGrid(start.x), y: toGrid(start.y) };
const goalNode = { x: toGrid(goal.x), y: toGrid(goal.y) };
const open = new Map();
const gScore = new Map();
const cameFrom = new Map();
const startKey = pointKey(startNode);
open.set(startKey, { ...startNode, dir: null, f: heuristic(startNode, goalNode) });
gScore.set(startKey, 0);
cameFrom.set(startKey, { prev: null, node: startNode, dir: null });
const maxIterations = 30000;
let iterations = 0;
while (open.size > 0 && iterations < maxIterations) {
iterations += 1;
let current = null;
for (const cand of open.values()) {
if (!current || cand.f < current.f) {
current = cand;
}
}
if (!current) {
break;
}
const currentKey = pointKey(current);
if (current.x === goalNode.x && current.y === goalNode.y) {
return reconstructPath(cameFrom, currentKey);
}
open.delete(currentKey);
const neighbors = [
{ x: current.x + GRID, y: current.y, dir: "h" },
{ x: current.x - GRID, y: current.y, dir: "h" },
{ x: current.x, y: current.y + GRID, dir: "v" },
{ x: current.x, y: current.y - GRID, dir: "v" }
];
for (const nb of neighbors) {
if (nb.x < bounds.minX || nb.x > bounds.maxX || nb.y < bounds.minY || nb.y > bounds.maxY) {
continue;
}
if (segmentBlocked(current, nb, obstacles, allowedRefs)) {
continue;
}
if (hasForeignEdgeUsage(edgeUsage, netName, current, nb)) {
continue;
}
const isGoal = nb.x === goalNode.x && nb.y === goalNode.y;
if (!isGoal && hasForeignPointUsage(pointUsage, netName, nb)) {
continue;
}
const nbKey = pointKey(nb);
const prevCost = gScore.get(currentKey) ?? Number.POSITIVE_INFINITY;
const turnPenalty = current.dir && current.dir !== nb.dir ? 24 : 0;
const obstaclePenalty = proximityPenalty(nb, obstacles, allowedRefs);
const channelPenalty = channelCrowdingPenalty(current, nb, context);
const congestionPenalty = pointCongestionPenalty(nb, context);
const tentative = prevCost + GRID + turnPenalty + obstaclePenalty + channelPenalty + congestionPenalty;
if (tentative >= (gScore.get(nbKey) ?? Number.POSITIVE_INFINITY)) {
continue;
}
gScore.set(nbKey, tentative);
open.set(nbKey, {
...nb,
f: tentative + heuristic(nb, goalNode)
});
cameFrom.set(nbKey, {
prev: currentKey,
node: { x: nb.x, y: nb.y },
dir: nb.dir
});
}
}
return null;
}
function pointsToSegments(points) {
const segments = [];
for (let i = 1; i < points.length; i += 1) {
const a = points[i - 1];
const b = points[i];
if (a.x === b.x && a.y === b.y) {
continue;
}
segments.push({ a: { ...a }, b: { ...b } });
}
return simplifySegments(segments);
}
function simplifySegments(segments) {
const out = [];
for (const seg of segments) {
const prev = out[out.length - 1];
if (!prev) {
out.push(seg);
continue;
}
const prevVertical = prev.a.x === prev.b.x;
const currVertical = seg.a.x === seg.b.x;
if (prevVertical === currVertical && prev.b.x === seg.a.x && prev.b.y === seg.a.y) {
prev.b = { ...seg.b };
continue;
}
out.push(seg);
}
return out;
}
function segmentStepPoints(a, b) {
const points = [];
if (a.x === b.x) {
const step = a.y < b.y ? GRID : -GRID;
for (let y = a.y; step > 0 ? y <= b.y : y >= b.y; y += step) {
points.push({ x: a.x, y });
}
return points;
}
if (a.y === b.y) {
const step = a.x < b.x ? GRID : -GRID;
for (let x = a.x; step > 0 ? x <= b.x : x >= b.x; x += step) {
points.push({ x, y: a.y });
}
return points;
}
return [a, b];
}
function addLineUsage(lineUsage, coord, netName) {
const usage = lineUsage.get(coord) ?? new Map();
usage.set(netName, (usage.get(netName) ?? 0) + 1);
lineUsage.set(coord, usage);
}
function addUsageForSegments(edgeUsage, pointUsage, hLineUsage, vLineUsage, netName, segments) {
for (const seg of segments) {
const stepPoints = segmentStepPoints(seg.a, seg.b);
for (let i = 1; i < stepPoints.length; i += 1) {
const p0 = stepPoints[i - 1];
const p1 = stepPoints[i];
const eKey = edgeKey(p0, p1);
const edge = edgeUsage.get(eKey) ?? { total: 0, byNet: new Map() };
edge.total += 1;
edge.byNet.set(netName, (edge.byNet.get(netName) ?? 0) + 1);
edgeUsage.set(eKey, edge);
}
if (seg.a.y === seg.b.y) {
addLineUsage(hLineUsage, seg.a.y, netName);
} else if (seg.a.x === seg.b.x) {
addLineUsage(vLineUsage, seg.a.x, netName);
}
for (const p of stepPoints) {
const pKey = pointKey(p);
const usage = pointUsage.get(pKey) ?? new Map();
usage.set(netName, (usage.get(netName) ?? 0) + 1);
pointUsage.set(pKey, usage);
}
}
}
function computeJunctionPoints(routes) {
const degree = new Map();
for (const route of routes) {
for (const seg of route) {
const aKey = pointKey(seg.a);
const bKey = pointKey(seg.b);
degree.set(aKey, (degree.get(aKey) ?? 0) + 1);
degree.set(bKey, (degree.get(bKey) ?? 0) + 1);
}
}
const out = [];
for (const [key, deg] of degree.entries()) {
if (deg >= 3) {
const [x, y] = key.split(",").map(Number);
out.push({ x, y });
}
}
out.sort((a, b) => a.x - b.x || a.y - b.y);
return out;
}
function labelPointForPin(pin) {
if (pin.side === "left") {
return { x: pin.exit.x - GRID * 2.2, y: pin.exit.y - GRID * 0.4 };
}
if (pin.side === "right") {
return { x: pin.exit.x + GRID * 0.6, y: pin.exit.y - GRID * 0.4 };
}
if (pin.side === "top") {
return { x: pin.exit.x + GRID * 0.3, y: pin.exit.y - GRID * 0.8 };
}
return { x: pin.exit.x + GRID * 0.3, y: pin.exit.y + GRID * 0.9 };
}
function chooseEndpoints(pinNodes) {
if (pinNodes.length < 2) {
return null;
}
const priority = (pinType) => {
if (pinType === "power_out") return 0;
if (pinType === "output") return 1;
if (pinType === "bidirectional") return 2;
if (pinType === "analog") return 3;
return 4;
};
const sorted = [...pinNodes].sort((a, b) => {
const pa = priority(a.pinType);
const pb = priority(b.pinType);
if (pa !== pb) {
return pa - pb;
}
return a.exit.x - b.exit.x || a.exit.y - b.exit.y;
});
return { source: sorted[0], target: sorted[1] };
}
function pinRoutePriority(pin) {
if (pin.pinType === "power_out") return 0;
if (pin.pinType === "output") return 1;
if (pin.pinType === "bidirectional") return 2;
if (pin.pinType === "analog") return 3;
return 4;
}
function uniquePoints(points) {
const map = new Map();
for (const p of points) {
map.set(pointKey(p), p);
}
return [...map.values()];
}
function routeLabelTieNet(net, pinNodes, context, fallbackReason = null) {
const routes = [];
const tiePoints = [];
for (const pin of pinNodes) {
const stub = pointsToSegments([pin.point, pin.exit]);
if (stub.length) {
routes.push(stub);
addUsageForSegments(
context.edgeUsage,
context.pointUsage,
context.hLineUsage,
context.vLineUsage,
net.name,
stub
);
}
tiePoints.push({ x: pin.exit.x, y: pin.exit.y });
}
const labelPoints = [];
if (tiePoints.length) {
const centroid = {
x: tiePoints.reduce((sum, p) => sum + p.x, 0) / tiePoints.length,
y: tiePoints.reduce((sum, p) => sum + p.y, 0) / tiePoints.length
};
labelPoints.push({
x: toGrid(centroid.x + GRID * 0.6),
y: toGrid(centroid.y - GRID * 0.8)
});
const anchorPin = [...pinNodes].sort((a, b) => a.exit.x - b.exit.x || a.exit.y - b.exit.y)[0];
if (anchorPin) {
labelPoints.push(labelPointForPin(anchorPin));
}
}
return {
mode: "label_tie",
routes,
labelPoints,
tiePoints,
junctionPoints: [],
route_stats: {
total_length: routeLengthFromSegments(routes),
direct_length: 0,
total_bends: 0,
detour_ratio: 1,
used_label_tie: true,
fallback_reason: fallbackReason
}
};
}
function pathLength(points) {
let length = 0;
for (let i = 1; i < points.length; i += 1) {
length += Math.abs(points[i].x - points[i - 1].x) + Math.abs(points[i].y - points[i - 1].y);
}
return length;
}
function routeLengthFromSegments(routes) {
let length = 0;
for (const route of routes) {
for (const seg of route) {
length += Math.abs(seg.a.x - seg.b.x) + Math.abs(seg.a.y - seg.b.y);
}
}
return length;
}
function countBendsInRoute(routes) {
let bends = 0;
for (const route of routes) {
for (let i = 1; i < route.length; i += 1) {
const prev = route[i - 1];
const curr = route[i];
const prevH = prev.a.y === prev.b.y;
const currH = curr.a.y === curr.b.y;
if (prevH !== currH) {
bends += 1;
}
}
}
return bends;
}
function routePointToPointNet(net, pinNodes, context) {
if (pinNodes.length < 2) {
return {
mode: "routed",
routes: [],
labelPoints: [],
tiePoints: [],
junctionPoints: [],
route_stats: {
total_length: 0,
direct_length: 0,
total_bends: 0,
detour_ratio: 1,
used_label_tie: false,
fallback_reason: null
}
};
}
const sorted = [...pinNodes].sort((a, b) => {
const pa = pinRoutePriority(a);
const pb = pinRoutePriority(b);
if (pa !== pb) {
return pa - pb;
}
return a.exit.x - b.exit.x || a.exit.y - b.exit.y;
});
const source = sorted[0];
const routes = [];
const sourceStub = pointsToSegments([source.point, source.exit]);
if (sourceStub.length) {
routes.push(sourceStub);
addUsageForSegments(
context.edgeUsage,
context.pointUsage,
context.hLineUsage,
context.vLineUsage,
net.name,
sourceStub
);
}
const treePoints = new Map();
treePoints.set(pointKey(source.exit), source.exit);
const allowedRefs = new Set(sorted.map((p) => p.ref));
const remaining = sorted.slice(1);
let accumulatedPath = 0;
let accumulatedDirect = 0;
for (const target of remaining) {
const candidates = uniquePoints([...treePoints.values()])
.sort((a, b) => heuristic(target.exit, a) - heuristic(target.exit, b))
.slice(0, 12);
let best = null;
for (const attach of candidates) {
const path = aStar(target.exit, attach, {
...context,
allowedRefs,
netName: net.name
});
if (!path) {
continue;
}
const cost = pathLength(path);
if (!best || cost < best.cost) {
best = { path, attach, cost };
}
}
if (!best) {
return {
...routeLabelTieNet(net, sorted, context),
route_stats: {
total_length: 0,
direct_length: 0,
total_bends: 0,
detour_ratio: 1,
used_label_tie: true,
fallback_reason: "no_path"
}
};
}
const direct = heuristic(target.exit, best.attach);
accumulatedPath += best.cost;
accumulatedDirect += Math.max(GRID, direct);
if (context.renderMode === "schematic_stub" && best.cost > direct * 1.6) {
return {
...routeLabelTieNet(net, sorted, context),
route_stats: {
total_length: accumulatedPath,
direct_length: accumulatedDirect,
total_bends: 0,
detour_ratio: accumulatedPath / Math.max(GRID, accumulatedDirect),
used_label_tie: true,
fallback_reason: "branch_detour"
}
};
}
const branchPoints = [target.point, target.exit, ...best.path.slice(1)];
const branchSegments = pointsToSegments(branchPoints);
if (branchSegments.length) {
routes.push(branchSegments);
addUsageForSegments(
context.edgeUsage,
context.pointUsage,
context.hLineUsage,
context.vLineUsage,
net.name,
branchSegments
);
for (const seg of branchSegments) {
for (const p of segmentStepPoints(seg.a, seg.b)) {
treePoints.set(pointKey(p), p);
}
}
}
}
const allPathPoints = routes.flatMap((route) => route.flatMap((seg) => [seg.a, seg.b]));
const centroid = allPathPoints.length
? {
x: allPathPoints.reduce((sum, p) => sum + p.x, 0) / allPathPoints.length,
y: allPathPoints.reduce((sum, p) => sum + p.y, 0) / allPathPoints.length
}
: labelPointForPin(source);
const labelPoints = [labelPointForPin(source)];
if (sorted.length >= 3) {
labelPoints.push({ x: toGrid(centroid.x + GRID * 0.4), y: toGrid(centroid.y - GRID * 0.6) });
}
const junctionPoints = computeJunctionPoints(routes);
if (sorted.length >= 3 && junctionPoints.length === 0) {
junctionPoints.push({ x: source.exit.x, y: source.exit.y });
}
const totalLength = routeLengthFromSegments(routes);
const totalBends = countBendsInRoute(routes);
const directLength = Math.max(GRID, accumulatedDirect || heuristic(source.exit, sorted[sorted.length - 1].exit));
const detourRatio = totalLength / directLength;
const maxAllowedBends = Math.max(6, sorted.length * 3);
if (
context.renderMode === "schematic_stub" &&
(detourRatio > 1.9 || totalBends > maxAllowedBends || totalLength > GRID * 180)
) {
return {
...routeLabelTieNet(net, sorted, context),
route_stats: {
total_length: totalLength,
direct_length: directLength,
total_bends: totalBends,
detour_ratio: detourRatio,
used_label_tie: true,
fallback_reason: "global_quality"
}
};
}
return {
mode: "routed",
routes,
labelPoints,
tiePoints: [],
junctionPoints,
route_stats: {
total_length: totalLength,
direct_length: directLength,
total_bends: totalBends,
detour_ratio: detourRatio,
used_label_tie: false,
fallback_reason: null
}
};
}
function detectBusGroups(nets) {
const groups = new Map();
for (const net of nets) {
const match = /^([A-Za-z0-9]+)_/.exec(net.name);
if (!match) {
continue;
}
const key = match[1].toUpperCase();
const list = groups.get(key) ?? [];
list.push(net.name);
groups.set(key, list);
}
const out = [];
for (const [name, members] of groups.entries()) {
if (members.length >= 2) {
out.push({ name, nets: members.sort() });
}
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
function shouldUseLabelTie(net, pinNodes, context) {
if (!pinNodes.length) {
return false;
}
const minX = Math.min(...pinNodes.map((p) => p.exit.x), 0);
const maxX = Math.max(...pinNodes.map((p) => p.exit.x), 0);
const minY = Math.min(...pinNodes.map((p) => p.exit.y), 0);
const maxY = Math.max(...pinNodes.map((p) => p.exit.y), 0);
const span = Math.abs(maxX - minX) + Math.abs(maxY - minY);
if (context.renderMode === "explicit") {
if (context.busNetNames.has(net.name) && (pinNodes.length >= 3 || span > GRID * 36)) {
return true;
}
return LABEL_TIE_CLASSES.has(net.class) && pinNodes.length > 2;
}
if (LABEL_TIE_CLASSES.has(net.class)) {
return true;
}
if (context.busNetNames.has(net.name)) {
return true;
}
// Prefer readable schematic stubs for dense multi-node nets.
if (pinNodes.length >= 5) {
return true;
}
// Long-distributed analog/signal nets become noisy when fully routed.
if ((net.class === "analog" || net.class === "signal") && pinNodes.length >= 3 && span > GRID * 56) {
return true;
}
return false;
}
function shouldFallbackToTieByQuality(net, pinNodes, routed) {
if (!routed || routed.mode !== "routed") {
return false;
}
const stats = routed.route_stats ?? {};
const detour = Number(stats.detour_ratio ?? 1);
const bends = Number(stats.total_bends ?? 0);
const totalLength = Number(stats.total_length ?? 0);
const directLength = Number(stats.direct_length ?? totalLength);
const spanRatio = directLength > 0 ? totalLength / directLength : 1;
if (pinNodes.length >= 4 && (detour > 2.25 || bends >= 7)) {
return true;
}
if ((net.class === "analog" || net.class === "signal") && pinNodes.length >= 3 && spanRatio > 2.7) {
return true;
}
return false;
}
function routeAllNets(model, placed, placedMap, bounds, options) {
const obstacles = buildObstacles(model, placed);
const edgeUsage = new Map();
const pointUsage = new Map();
const hLineUsage = new Map();
const vLineUsage = new Map();
const busGroups = detectBusGroups(model.nets);
const busNetNames = new Set(busGroups.flatMap((g) => g.nets));
const nets = [...model.nets].sort((a, b) => {
const pa = NET_CLASS_PRIORITY[a.class] ?? 99;
const pb = NET_CLASS_PRIORITY[b.class] ?? 99;
if (pa !== pb) {
return pa - pb;
}
return a.name.localeCompare(b.name);
});
const routedByName = new Map();
for (const net of nets) {
const pinNodes = net.nodes.map((node) => getNodePin(model, placedMap, node)).filter(Boolean);
const routeContext = {
bounds,
obstacles,
edgeUsage,
pointUsage,
hLineUsage,
vLineUsage,
renderMode: options.renderMode,
busNetNames
};
let routed = shouldUseLabelTie(net, pinNodes, routeContext)
? routeLabelTieNet(net, pinNodes, routeContext)
: routePointToPointNet(net, pinNodes, routeContext);
if (shouldFallbackToTieByQuality(net, pinNodes, routed)) {
routed = routeLabelTieNet(net, pinNodes, routeContext, "quality_policy");
}
routedByName.set(net.name, {
net,
isBusMember: busNetNames.has(net.name),
...routed
});
}
const routed = model.nets.map(
(net) =>
routedByName.get(net.name) ?? {
net,
mode: "routed",
routes: [],
labelPoints: [],
tiePoints: [],
junctionPoints: [],
route_stats: {
total_length: 0,
direct_length: 0,
total_bends: 0,
detour_ratio: 1,
used_label_tie: false,
fallback_reason: null
}
}
);
return { routed, busGroups };
}
function lineIntersection(a1, a2, b1, b2) {
const aVertical = a1.x === a2.x;
const bVertical = b1.x === b2.x;
if (aVertical === bVertical) {
return null;
}
const v = aVertical ? [a1, a2] : [b1, b2];
const h = aVertical ? [b1, b2] : [a1, a2];
const vx = v[0].x;
const hy = h[0].y;
const vMinY = Math.min(v[0].y, v[1].y);
const vMaxY = Math.max(v[0].y, v[1].y);
const hMinX = Math.min(h[0].x, h[1].x);
const hMaxX = Math.max(h[0].x, h[1].x);
if (vx >= hMinX && vx <= hMaxX && hy >= vMinY && hy <= vMaxY) {
return { x: vx, y: hy };
}
return null;
}
function collectSegmentsByNet(routed) {
const out = [];
for (const rn of routed) {
for (const route of rn.routes) {
for (const seg of route) {
out.push({ net: rn.net.name, seg });
}
}
}
return out;
}
function countOverlapEdges(routed) {
const edgeNets = new Map();
for (const rn of routed) {
for (const route of rn.routes) {
for (const seg of route) {
const steps = segmentStepPoints(seg.a, seg.b);
for (let i = 1; i < steps.length; i += 1) {
const k = edgeKey(steps[i - 1], steps[i]);
const set = edgeNets.get(k) ?? new Set();
set.add(rn.net.name);
edgeNets.set(k, set);
}
}
}
}
let overlaps = 0;
for (const nets of edgeNets.values()) {
if (nets.size > 1) {
overlaps += 1;
}
}
return overlaps;
}
function countCrossings(routed) {
const segments = collectSegmentsByNet(routed);
let crossings = 0;
for (let i = 0; i < segments.length; i += 1) {
const a = segments[i];
for (let j = i + 1; j < segments.length; j += 1) {
const b = segments[j];
if (a.net === b.net) {
continue;
}
const hit = lineIntersection(a.seg.a, a.seg.b, b.seg.a, b.seg.b);
if (!hit) {
continue;
}
const atEndpoint =
pointKey(hit) === pointKey(a.seg.a) ||
pointKey(hit) === pointKey(a.seg.b) ||
pointKey(hit) === pointKey(b.seg.a) ||
pointKey(hit) === pointKey(b.seg.b);
if (!atEndpoint) {
crossings += 1;
}
}
}
return crossings;
}
function countLabelCollisions(routed) {
const labels = routed.flatMap((rn) => {
if (rn.isBusMember && rn.mode === "label_tie") {
return [];
}
const points = rn.labelPoints?.length ? [rn.labelPoints[0]] : [];
return points.map((p) => ({ ...p, net: rn.net.name }));
});
let collisions = 0;
for (let i = 0; i < labels.length; i += 1) {
for (let j = i + 1; j < labels.length; j += 1) {
const a = labels[i];
const b = labels[j];
if (Math.abs(a.x - b.x) < GRID * 2 && Math.abs(a.y - b.y) < GRID * 1.2) {
collisions += 1;
}
}
}
return collisions;
}
function computeLayoutMetrics(routed, busGroups) {
const segmentCount = routed.reduce(
(total, rn) => total + rn.routes.reduce((sum, route) => sum + route.length, 0),
0
);
const tiePoints = routed.reduce((total, rn) => total + rn.tiePoints.length, 0);
const totalBends = routed.reduce((total, rn) => total + Number(rn.route_stats?.total_bends ?? 0), 0);
const totalLength = routed.reduce((total, rn) => total + Number(rn.route_stats?.total_length ?? 0), 0);
const totalDirect = routed.reduce((total, rn) => total + Number(rn.route_stats?.direct_length ?? 0), 0);
const labelTieFallbacks = routed.reduce(
(total, rn) => total + (rn.route_stats?.used_label_tie && rn.route_stats?.fallback_reason ? 1 : 0),
0
);
const labelTieRoutes = routed.reduce((total, rn) => total + (rn.route_stats?.used_label_tie ? 1 : 0), 0);
return {
segment_count: segmentCount,
overlap_edges: countOverlapEdges(routed),
crossings: countCrossings(routed),
label_collisions: countLabelCollisions(routed),
tie_points_used: tiePoints,
bus_groups: busGroups.length,
total_bends: totalBends,
total_length: totalLength,
direct_length: totalDirect,
detour_ratio: totalDirect > 0 ? totalLength / totalDirect : 1,
label_tie_fallbacks: labelTieFallbacks,
label_tie_routes: labelTieRoutes
};
}
export function applyLayoutToModel(model, options = {}) {
const working = clone(model);
const respectLocks = options.respectLocks ?? true;
const autoRotate = options.autoRotate ?? true;
if (!respectLocks) {
for (const inst of working.instances) {
inst.placement.x = null;
inst.placement.y = null;
inst.placement.locked = false;
}
}
const { placed } = placeInstances(working, { respectLocks, autoRotate });
const byRef = new Map(placed.map((inst) => [inst.ref, inst]));
for (const inst of working.instances) {
const p = byRef.get(inst.ref);
if (!p) {
continue;
}
inst.placement.x = p.placement.x;
inst.placement.y = p.placement.y;
inst.placement.rotation = p.placement.rotation;
inst.placement.locked = p.placement.locked;
}
return working;
}
export function requestedLayoutEngine(options = {}) {
const explicitEngine = typeof options.layout_engine === "string" ? options.layout_engine.trim().toLowerCase() : "";
if (explicitEngine === "elk" || options.use_elk_layout === true) {
return "elk";
}
return DEFAULT_LAYOUT_ENGINE;
}
function layoutAndRouteNative(model, options = {}) {
const renderMode = options.render_mode === "explicit" ? "explicit" : DEFAULT_RENDER_MODE;
const respectLocks = options.respect_locks ?? true;
const autoRotate = options.auto_rotate ?? true;
const preservePlacement = options.preserve_placement === true;
const placed = preservePlacement
? preservePlacedInstances(model, { respectLocks, autoRotate: false })
: placeInstances(model, { respectLocks, autoRotate }).placed;
const placedMap = new Map(placed.map((inst) => [inst.ref, inst]));
const bounds = buildBounds(model, placed);
const { routed, busGroups } = routeAllNets(model, placed, placedMap, bounds, {
renderMode
});
return {
placed,
routed,
width: bounds.maxX,
height: bounds.maxY,
bus_groups: busGroups,
metrics: computeLayoutMetrics(routed, busGroups),
render_mode_used: renderMode
};
}
export function layoutAndRoute(model, options = {}) {
const requestedEngine = requestedLayoutEngine(options);
const nativeLayout = layoutAndRouteNative(model, options);
if (requestedEngine !== "elk") {
return {
...nativeLayout,
layout_engine_requested: requestedEngine,
layout_engine_used: DEFAULT_LAYOUT_ENGINE,
layout_warnings: []
};
}
const elkRuntime = resolveElkRuntime(options.elk_runtime_module);
if (!elkRuntime.ok) {
return {
...nativeLayout,
layout_engine_requested: "elk",
layout_engine_used: DEFAULT_LAYOUT_ENGINE,
layout_warnings: [
{
code: "elk_layout_unavailable_fallback",
message: elkRuntime.message
}
]
};
}
return {
...nativeLayout,
layout_engine_requested: "elk",
layout_engine_used: DEFAULT_LAYOUT_ENGINE,
layout_warnings: [
{
code: "elk_layout_boundary_fallback",
message:
"ELK runtime resolved, but backend ELK placement is not yet enabled. Using default layout engine."
}
]
};
}
export function netAnchorPoint(net, model, placed) {
const first = net.nodes[0];
if (!first) {
return null;
}
const inst = placed.find((x) => x.ref === first.ref);
if (!inst) {
return null;
}
const sym = model.symbols[inst.symbol];
const p = sym.pins.find((x) => x.name === first.pin);
if (!p) {
return null;
}
return pinPoint(inst, p, sym.body.width, sym.body.height);
}