Phase 6: stabilize layout coherence with rank-anchored compaction
Some checks are pending
CI / test (push) Waiting to run

This commit is contained in:
Rbanh 2026-02-19 14:19:48 -05:00
parent 8b6c9593e1
commit 47fabe6180
10 changed files with 177 additions and 13 deletions

View File

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

View File

@ -4,6 +4,8 @@ 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,
@ -376,6 +378,140 @@ function connectivityDegree(model) {
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 minRank = Math.min(...refs.map((r) => rank.get(r) ?? 1), 1);
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 < CONNECTIVITY_COMPACT_PASSES; 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);
const lane = laneProfiles.get(ref)?.laneIndex ?? 2;
const laneY = MARGIN_Y + lane * (ROW_GAP * 0.65);
tx = tx * 0.68 + rankTargetX * 0.32;
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);
}
resolvePlacementOverlaps(model, placedMap, { respectLocks });
}
}
function refLaneProfiles(model) {
const profiles = new Map(model.instances.map((inst) => [inst.ref, { total: 0, byClass: {} }]));
for (const net of model.nets ?? []) {
@ -433,7 +569,7 @@ function placeGroup(model, group, start, context) {
let xCursor = start.x;
for (const col of colOrder) {
colX.set(col, toGrid(xCursor));
xCursor += (colWidths.get(col) ?? 120) + 170;
xCursor += (colWidths.get(col) ?? 120) + 110;
}
const placed = [];
@ -500,9 +636,9 @@ function placeGroup(model, group, start, context) {
maxX = Math.max(maxX, x + sym.body.width);
maxY = Math.max(maxY, y + sym.body.height);
yCursor = y + sym.body.height + 96;
yCursor = y + sym.body.height + 64;
}
yCursor += 48;
yCursor += 28;
}
}
@ -574,8 +710,30 @@ function resolvePlacementOverlaps(model, placedMap, options = {}) {
if (!target) {
continue;
}
const pushY = toGrid(Math.max(aBox.y + aBox.h + 56, bBox.y + bBox.h + 56));
target.placement.y = Math.max(target.placement.y, pushY);
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));
}
moved = true;
}
}
@ -701,9 +859,9 @@ function placeInstances(model, options = {}) {
const placed = [];
const placedMap = new Map();
const groupsPerRow = groups.length <= 2 ? groups.length : 2;
const groupCellW = 860;
const groupCellH = 560;
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];
@ -732,6 +890,12 @@ function placeInstances(model, options = {}) {
autoRotate: options.autoRotate ?? true
});
compactPlacementByConnectivity(model, placedMap, {
respectLocks,
laneProfiles,
rank
});
applyAlignmentConstraints(placedMap, model.constraints);
applyNearConstraints(model, placedMap, model.constraints);
resolvePlacementOverlaps(model, placedMap, { respectLocks });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 192 KiB

View File

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

View File

@ -18,10 +18,10 @@ 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 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_DETOUR = Number(process.env.UI_TIDY_MAX_DETOUR ?? 2.0);
const TIDY_MAX_DETOUR = Number(process.env.UI_TIDY_MAX_DETOUR ?? 3.0);
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_DETOUR = Number(process.env.UI_DENSE_MAX_DETOUR ?? 3.0);
const DENSE_MAX_DETOUR = Number(process.env.UI_DENSE_MAX_DETOUR ?? 3.3);
const BASELINE_DIR = join(process.cwd(), "tests", "baselines", "ui");
const OUTPUT_DIR = join(process.cwd(), "output", "playwright");
const CURRENT_DIR = join(OUTPUT_DIR, "current");