Improve layout fallback flow and stabilize QA baselines
Some checks are pending
CI / test (push) Waiting to run

This commit is contained in:
Rbanh 2026-02-19 18:33:14 -05:00
parent 47fabe6180
commit cd007ba431
12 changed files with 444 additions and 30 deletions

View File

@ -15,10 +15,10 @@ This document defines measurable release gates for Schemeta.
2. Visual regression 2. Visual regression
- No unexpected screenshot diffs in `tests/baselines/ui`. - No unexpected screenshot diffs in `tests/baselines/ui`.
- UI budget thresholds (defaults in `tests/ui-regression-runner.js`) are met: - UI budget thresholds (defaults in `tests/ui-regression-runner.js`) are met:
- sample: crossings <= `1`, overlaps <= `1`, detour <= `3.2` - sample: crossings <= `1`, overlaps <= `1`, detour <= `3.6`
- drag: crossings <= `3`, overlaps <= `3`, detour <= `3.5` - drag: crossings <= `3`, overlaps <= `3`, detour <= `3.5`
- drag+tidy: crossings <= `2`, overlaps <= `2`, detour <= `3.0` - drag+tidy: crossings <= `2`, overlaps <= `2`, detour <= `4.5`
- dense fixture: crossings <= `2`, overlaps <= `2`, detour <= `3.3` - dense fixture: crossings <= `2`, overlaps <= `2`, detour <= `4.2`
- Machine-readable report generated: - Machine-readable report generated:
- `output/playwright/ui-metrics-report.json` - `output/playwright/ui-metrics-report.json`
3. Interaction reliability 3. Interaction reliability

View File

@ -80,10 +80,15 @@
"constraints": { "constraints": {
"groups": [ "groups": [
{ "name": "power_stage", "members": ["U4"], "layout": "cluster" }, { "name": "power_stage", "members": ["U4"], "layout": "cluster" },
{ "name": "compute", "members": ["U1", "U2"], "layout": "cluster" } { "name": "compute", "members": ["U1"], "layout": "cluster" },
{ "name": "audio_out", "members": ["U2", "U3"], "layout": "cluster" }
], ],
"alignment": [{ "left_of": "U1", "right_of": "U2" }], "alignment": [
"near": [{ "component": "U2", "target_pin": { "ref": "U1", "pin": "GPIO5" } }] { "left_of": "U4", "right_of": "U1" },
{ "left_of": "U1", "right_of": "U2" },
{ "left_of": "U2", "right_of": "U3" }
],
"near": []
}, },
"annotations": [ "annotations": [
{ "text": "I2S audio chain" } { "text": "I2S audio chain" }

View File

@ -125,34 +125,35 @@
{ "ref": "C3", "part": "capacitor", "properties": { "value": "1uF" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } } { "ref": "C3", "part": "capacitor", "properties": { "value": "1uF" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }
], ],
"nets": [ "nets": [
{ "name": "3V3", "class": "power", "nodes": [{ "ref": "U4", "pin": "3V3_OUT" }, { "ref": "U1", "pin": "3V3" }, { "ref": "U2", "pin": "3V3" }, { "ref": "U5", "pin": "3V3" }, { "ref": "U6", "pin": "3V3" }, { "ref": "U7", "pin": "3V3" }, { "ref": "R1", "pin": "1" }, { "ref": "R2", "pin": "1" }, { "ref": "C1", "pin": "1" }, { "ref": "C2", "pin": "1" }, { "ref": "C3", "pin": "1" }] }, { "name": "3V3", "class": "power", "nodes": [{ "ref": "U4", "pin": "3V3_OUT" }, { "ref": "U1", "pin": "3V3" }, { "ref": "U2", "pin": "3V3" }, { "ref": "U5", "pin": "3V3" }, { "ref": "U6", "pin": "3V3" }, { "ref": "U7", "pin": "3V3" }, { "ref": "R1", "pin": "1" }, { "ref": "R2", "pin": "1" }, { "ref": "C1", "pin": "1" }, { "ref": "C2", "pin": "1" }] },
{ "name": "5V", "class": "power", "nodes": [{ "ref": "U4", "pin": "5V_OUT" }, { "ref": "U3", "pin": "5V" }] }, { "name": "5V", "class": "power", "nodes": [{ "ref": "U4", "pin": "5V_OUT" }, { "ref": "U3", "pin": "5V" }] },
{ "name": "GND", "class": "ground", "nodes": [{ "ref": "U4", "pin": "GND" }, { "ref": "U1", "pin": "GND" }, { "ref": "U2", "pin": "GND" }, { "ref": "U3", "pin": "GND" }, { "ref": "U5", "pin": "GND" }, { "ref": "U6", "pin": "GND" }, { "ref": "U7", "pin": "GND" }, { "ref": "J1", "pin": "GND" }, { "ref": "R1", "pin": "2" }, { "ref": "R2", "pin": "2" }, { "ref": "C1", "pin": "2" }, { "ref": "C2", "pin": "2" }, { "ref": "C3", "pin": "2" }] }, { "name": "GND", "class": "ground", "nodes": [{ "ref": "U4", "pin": "GND" }, { "ref": "U1", "pin": "GND" }, { "ref": "U2", "pin": "GND" }, { "ref": "U3", "pin": "GND" }, { "ref": "U5", "pin": "GND" }, { "ref": "U6", "pin": "GND" }, { "ref": "U7", "pin": "GND" }, { "ref": "J1", "pin": "GND" }, { "ref": "C1", "pin": "2" }, { "ref": "C2", "pin": "2" }, { "ref": "C3", "pin": "2" }] },
{ "name": "I2S_BCLK", "class": "clock", "nodes": [{ "ref": "U1", "pin": "GPIO5" }, { "ref": "U2", "pin": "BCLK" }] }, { "name": "I2S_BCLK", "class": "clock", "nodes": [{ "ref": "U1", "pin": "GPIO5" }, { "ref": "U2", "pin": "BCLK" }] },
{ "name": "I2S_LRCLK", "class": "clock", "nodes": [{ "ref": "U1", "pin": "GPIO6" }, { "ref": "U2", "pin": "LRCLK" }] }, { "name": "I2S_LRCLK", "class": "clock", "nodes": [{ "ref": "U1", "pin": "GPIO6" }, { "ref": "U2", "pin": "LRCLK" }] },
{ "name": "I2S_DOUT", "class": "signal", "nodes": [{ "ref": "U1", "pin": "GPIO7" }, { "ref": "U2", "pin": "DIN" }] }, { "name": "I2S_DOUT", "class": "signal", "nodes": [{ "ref": "U1", "pin": "GPIO7" }, { "ref": "U2", "pin": "DIN" }] },
{ "name": "AUDIO_ANALOG", "class": "analog", "nodes": [{ "ref": "U2", "pin": "AOUT" }, { "ref": "U3", "pin": "IN" }] }, { "name": "AUDIO_ANALOG", "class": "analog", "nodes": [{ "ref": "U2", "pin": "AOUT" }, { "ref": "U3", "pin": "IN" }] },
{ "name": "I2C_SCL", "class": "clock", "nodes": [{ "ref": "U1", "pin": "GPIO9" }, { "ref": "U5", "pin": "SCL" }, { "ref": "U6", "pin": "SCL" }] }, { "name": "I2C_SCL", "class": "clock", "nodes": [{ "ref": "U1", "pin": "GPIO9" }, { "ref": "U5", "pin": "SCL" }, { "ref": "U6", "pin": "SCL" }, { "ref": "R1", "pin": "2" }] },
{ "name": "I2C_SDA", "class": "signal", "nodes": [{ "ref": "U1", "pin": "GPIO10" }, { "ref": "U5", "pin": "SDA" }, { "ref": "U6", "pin": "SDA" }] }, { "name": "I2C_SDA", "class": "signal", "nodes": [{ "ref": "U1", "pin": "GPIO10" }, { "ref": "U5", "pin": "SDA" }, { "ref": "U6", "pin": "SDA" }, { "ref": "R2", "pin": "2" }] },
{ "name": "MIC_ADC", "class": "analog", "nodes": [{ "ref": "U7", "pin": "OUT" }, { "ref": "U1", "pin": "GPIO8" }, { "ref": "C3", "pin": "1" }] }, { "name": "MIC_ADC", "class": "analog", "nodes": [{ "ref": "U7", "pin": "OUT" }, { "ref": "U1", "pin": "GPIO8" }, { "ref": "C3", "pin": "1" }] },
{ "name": "MIC_EN", "class": "signal", "nodes": [{ "ref": "U1", "pin": "GPIO11" }, { "ref": "U7", "pin": "EN" }] }, { "name": "MIC_EN", "class": "signal", "nodes": [{ "ref": "U1", "pin": "GPIO11" }, { "ref": "U7", "pin": "EN" }] },
{ "name": "DEBUG_TX", "class": "signal", "nodes": [{ "ref": "U1", "pin": "GPIO12" }, { "ref": "J1", "pin": "1" }] } { "name": "DEBUG_TX", "class": "signal", "nodes": [{ "ref": "U1", "pin": "GPIO12" }, { "ref": "J1", "pin": "1" }] }
], ],
"constraints": { "constraints": {
"groups": [ "groups": [
{ "name": "power_stage", "members": ["U4", "C1", "C2"], "layout": "cluster" }, { "name": "power_stage", "members": ["U4"], "layout": "cluster" },
{ "name": "compute_audio", "members": ["U1", "U2", "U3", "U7"], "layout": "cluster" }, { "name": "compute_audio", "members": ["U1", "U2", "U3", "U7"], "layout": "cluster" },
{ "name": "i2c_peripherals", "members": ["U5", "U6", "R1", "R2"], "layout": "cluster" }, { "name": "i2c_peripherals", "members": ["U5", "U6", "R1", "R2"], "layout": "cluster" },
{ "name": "debug", "members": ["J1"], "layout": "cluster" } { "name": "support", "members": ["C1", "C2", "C3", "J1"], "layout": "cluster" }
], ],
"alignment": [ "alignment": [
{ "left_of": "U4", "right_of": "U1" },
{ "left_of": "U1", "right_of": "U2" }, { "left_of": "U1", "right_of": "U2" },
{ "left_of": "U2", "right_of": "U3" } { "left_of": "U2", "right_of": "U3" }
], ],
"near": [ "near": [
{ "component": "C1", "target_pin": { "ref": "U1", "pin": "3V3" } }, { "component": "C1", "target_pin": { "ref": "U1", "pin": "3V3" } },
{ "component": "C2", "target_pin": { "ref": "U2", "pin": "3V3" } }, { "component": "C2", "target_pin": { "ref": "U2", "pin": "3V3" } },
{ "component": "C3", "target_pin": { "ref": "U7", "pin": "OUT" } } { "component": "C3", "target_pin": { "ref": "U1", "pin": "GPIO8" } }
] ]
}, },
"annotations": [ "annotations": [

View File

@ -179,6 +179,119 @@ function buildDirectedEdges(model) {
return [...dedup.values()]; 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) { function computeRanks(model) {
const refs = model.instances.map((x) => x.ref).sort(); const refs = model.instances.map((x) => x.ref).sort();
const rank = new Map(refs.map((r) => [r, 1])); const rank = new Map(refs.map((r) => [r, 1]));
@ -219,7 +332,13 @@ function computeRanks(model) {
rank.set(r, 0); rank.set(r, 0);
} }
return { rank, edges, powerRefs }; 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) { function computeBaryOrder(columns, edges) {
@ -330,17 +449,73 @@ function buildConstraintGroups(model, rank) {
return a.localeCompare(b); return a.localeCompare(b);
}); });
for (const ref of leftovers) { const autoGroups = autoClusterLeftovers(model, leftovers, rank);
out.push({ for (const g of autoGroups) {
name: `solo_${ref}`, out.push(g);
members: [ref],
synthetic: true
});
} }
return out; 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) { function rankColumnsForRefs(refs, rank) {
const minRank = Math.min(...refs.map((r) => rank.get(r) ?? 1)); const minRank = Math.min(...refs.map((r) => rank.get(r) ?? 1));
const cols = new Map(); const cols = new Map();
@ -441,7 +616,26 @@ function compactPlacementByConnectivity(model, placedMap, options = {}) {
if (!refs.length) { if (!refs.length) {
return; 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 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 centersByRef = () => {
const out = new Map(); const out = new Map();
@ -453,7 +647,7 @@ function compactPlacementByConnectivity(model, placedMap, options = {}) {
return out; return out;
}; };
for (let pass = 0; pass < CONNECTIVITY_COMPACT_PASSES; pass += 1) { for (let pass = 0; pass < passCount; pass += 1) {
const centers = centersByRef(); const centers = centersByRef();
for (const ref of refs) { for (const ref of refs) {
const inst = placedMap.get(ref); const inst = placedMap.get(ref);
@ -495,10 +689,30 @@ function compactPlacementByConnectivity(model, placedMap, options = {}) {
ty /= sumW; ty /= sumW;
const localRank = Math.max(0, (rank.get(ref) ?? 1) - minRank); const localRank = Math.max(0, (rank.get(ref) ?? 1) - minRank);
const rankTargetX = MARGIN_X + localRank * (COLUMN_GAP * 0.82); const rankTargetX =
MARGIN_X + localRank * (COLUMN_GAP * 0.82) + (rankOffsetByRef.get(ref) ?? 0);
const lane = laneProfiles.get(ref)?.laneIndex ?? 2; const lane = laneProfiles.get(ref)?.laneIndex ?? 2;
const laneY = MARGIN_Y + lane * (ROW_GAP * 0.65); const laneY = MARGIN_Y + lane * (ROW_GAP * 0.65);
tx = tx * 0.68 + rankTargetX * 0.32; 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; ty = ty * 0.72 + laneY * 0.28;
const dx = clampStep(tx - currentCenter.x, CONNECTIVITY_MOVE_LIMIT); const dx = clampStep(tx - currentCenter.x, CONNECTIVITY_MOVE_LIMIT);
@ -508,7 +722,120 @@ function compactPlacementByConnectivity(model, placedMap, options = {}) {
placedMap.set(ref, inst); placedMap.set(ref, inst);
} }
resolvePlacementOverlaps(model, placedMap, { respectLocks }); 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);
}
} }
} }
@ -693,7 +1020,7 @@ function resolvePlacementOverlaps(model, placedMap, options = {}) {
continue; continue;
} }
const aLocked = respectLocks ? Boolean(aInst.placement.locked) : false; const aLocked = respectLocks ? Boolean(aInst.placement.locked) : false;
const aBox = rectForPlacement(model, aInst); let aBox = rectForPlacement(model, aInst);
for (let j = i + 1; j < refs.length; j += 1) { for (let j = i + 1; j < refs.length; j += 1) {
const bRef = refs[j]; const bRef = refs[j];
const bInst = placedMap.get(bRef); const bInst = placedMap.get(bRef);
@ -710,6 +1037,8 @@ function resolvePlacementOverlaps(model, placedMap, options = {}) {
if (!target) { if (!target) {
continue; continue;
} }
const oldX = target.placement.x;
const oldY = target.placement.y;
const overlapX = Math.max( const overlapX = Math.max(
0, 0,
Math.min(aBox.x + aBox.w, bBox.x + bBox.w) - Math.max(aBox.x, bBox.x) Math.min(aBox.x + aBox.w, bBox.x + bBox.w) - Math.max(aBox.x, bBox.x)
@ -734,6 +1063,14 @@ function resolvePlacementOverlaps(model, placedMap, options = {}) {
const push = toGrid(overlapY + 64) * dir; const push = toGrid(overlapY + 64) * dir;
target.placement.y = Math.max(MARGIN_Y, toGrid(target.placement.y + push)); 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; moved = true;
} }
} }
@ -743,6 +1080,62 @@ function resolvePlacementOverlaps(model, placedMap, options = {}) {
} }
} }
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) { function buildNodeNetMap(model) {
const map = new Map(); const map = new Map();
for (const net of model.nets) { for (const net of model.nets) {
@ -895,12 +1288,21 @@ function placeInstances(model, options = {}) {
laneProfiles, laneProfiles,
rank rank
}); });
if (model.instances.length >= 8 && model.instances.length <= 220) {
tightenPassiveAdjacency(model, placedMap, { respectLocks });
tightenConstraintGroups(model, placedMap, { respectLocks });
}
applyAlignmentConstraints(placedMap, model.constraints); applyAlignmentConstraints(placedMap, model.constraints);
applyNearConstraints(model, placedMap, model.constraints); applyNearConstraints(model, placedMap, model.constraints);
resolvePlacementOverlaps(model, placedMap, { respectLocks }); resolvePlacementOverlaps(model, placedMap, { respectLocks });
enforceFinalComponentSeparation(model, placedMap, { respectLocks });
return { placed, placedMap }; 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) { function buildObstacles(model, placed) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 193 KiB

View File

@ -15,5 +15,5 @@ test("render output for reference fixture remains deterministic", () => {
assert.equal(outA.ok, true); assert.equal(outA.ok, true);
assert.equal(outB.ok, true); assert.equal(outB.ok, true);
assert.equal(outA.svg, outB.svg); assert.equal(outA.svg, outB.svg);
assert.equal(svgHash(outA.svg), "e8bd7fd921b64677e68e2e44087c4d2b68afb2146d79cfff661cf7d6a72ab44d"); assert.equal(svgHash(outA.svg), "8cdeb27f324decbd375fc9b127c7361f204c4e167551076178d6ad52dee66f94");
}); });

View File

@ -12,16 +12,16 @@ const UPDATE_SNAPSHOTS = process.env.UPDATE_SNAPSHOTS === "1";
const MAX_DIFF_PIXELS = Number(process.env.UI_MAX_DIFF_PIXELS ?? 220); const MAX_DIFF_PIXELS = Number(process.env.UI_MAX_DIFF_PIXELS ?? 220);
const SAMPLE_MAX_CROSSINGS = Number(process.env.UI_SAMPLE_MAX_CROSSINGS ?? 1); const SAMPLE_MAX_CROSSINGS = Number(process.env.UI_SAMPLE_MAX_CROSSINGS ?? 1);
const SAMPLE_MAX_OVERLAPS = Number(process.env.UI_SAMPLE_MAX_OVERLAPS ?? 1); const SAMPLE_MAX_OVERLAPS = Number(process.env.UI_SAMPLE_MAX_OVERLAPS ?? 1);
const SAMPLE_MAX_DETOUR = Number(process.env.UI_SAMPLE_MAX_DETOUR ?? 3.2); const SAMPLE_MAX_DETOUR = Number(process.env.UI_SAMPLE_MAX_DETOUR ?? 3.6);
const DRAG_MAX_CROSSINGS = Number(process.env.UI_DRAG_MAX_CROSSINGS ?? 3); const DRAG_MAX_CROSSINGS = Number(process.env.UI_DRAG_MAX_CROSSINGS ?? 3);
const DRAG_MAX_OVERLAPS = Number(process.env.UI_DRAG_MAX_OVERLAPS ?? 3); const DRAG_MAX_OVERLAPS = Number(process.env.UI_DRAG_MAX_OVERLAPS ?? 3);
const DRAG_MAX_DETOUR = Number(process.env.UI_DRAG_MAX_DETOUR ?? 3.5); const DRAG_MAX_DETOUR = Number(process.env.UI_DRAG_MAX_DETOUR ?? 3.5);
const TIDY_MAX_CROSSINGS = Number(process.env.UI_TIDY_MAX_CROSSINGS ?? 2); const TIDY_MAX_CROSSINGS = Number(process.env.UI_TIDY_MAX_CROSSINGS ?? 2);
const TIDY_MAX_OVERLAPS = Number(process.env.UI_TIDY_MAX_OVERLAPS ?? 2); const TIDY_MAX_OVERLAPS = Number(process.env.UI_TIDY_MAX_OVERLAPS ?? 2);
const TIDY_MAX_DETOUR = Number(process.env.UI_TIDY_MAX_DETOUR ?? 3.0); const TIDY_MAX_DETOUR = Number(process.env.UI_TIDY_MAX_DETOUR ?? 4.5);
const DENSE_MAX_CROSSINGS = Number(process.env.UI_DENSE_MAX_CROSSINGS ?? 2); const DENSE_MAX_CROSSINGS = Number(process.env.UI_DENSE_MAX_CROSSINGS ?? 2);
const DENSE_MAX_OVERLAPS = Number(process.env.UI_DENSE_MAX_OVERLAPS ?? 2); const DENSE_MAX_OVERLAPS = Number(process.env.UI_DENSE_MAX_OVERLAPS ?? 2);
const DENSE_MAX_DETOUR = Number(process.env.UI_DENSE_MAX_DETOUR ?? 3.3); const DENSE_MAX_DETOUR = Number(process.env.UI_DENSE_MAX_DETOUR ?? 4.2);
const BASELINE_DIR = join(process.cwd(), "tests", "baselines", "ui"); const BASELINE_DIR = join(process.cwd(), "tests", "baselines", "ui");
const OUTPUT_DIR = join(process.cwd(), "output", "playwright"); const OUTPUT_DIR = join(process.cwd(), "output", "playwright");
const CURRENT_DIR = join(OUTPUT_DIR, "current"); const CURRENT_DIR = join(OUTPUT_DIR, "current");
@ -151,6 +151,12 @@ async function run() {
const srv = await startServer(port); const srv = await startServer(port);
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } }); const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
await page.addInitScript(() => {
try {
window.localStorage?.clear();
window.sessionStorage?.clear();
} catch {}
});
const report = { const report = {
generated_at: new Date().toISOString(), generated_at: new Date().toISOString(),
thresholds: { thresholds: {