Improve layout fallback flow and stabilize QA baselines
Some checks are pending
CI / test (push) Waiting to run
@ -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
|
||||||
|
|||||||
@ -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" }
|
||||||
|
|||||||
@ -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": [
|
||||||
|
|||||||
426
src/layout.js
@ -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,9 +722,122 @@ function compactPlacementByConnectivity(model, placedMap, options = {}) {
|
|||||||
placedMap.set(ref, inst);
|
placedMap.set(ref, inst);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldResolve =
|
||||||
|
pass === passCount - 1 || (refs.length <= 80 && pass % 2 === 1);
|
||||||
|
if (shouldResolve) {
|
||||||
resolvePlacementOverlaps(model, placedMap, { respectLocks });
|
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) {
|
function refLaneProfiles(model) {
|
||||||
const profiles = new Map(model.instances.map((inst) => [inst.ref, { total: 0, byClass: {} }]));
|
const profiles = new Map(model.instances.map((inst) => [inst.ref, { total: 0, byClass: {} }]));
|
||||||
@ -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) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 265 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 193 KiB |
@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||