Sprint 4: deterministic label collision pass and refreshed UI baselines
Some checks are pending
CI / test (push) Waiting to run

This commit is contained in:
Rbanh 2026-02-19 23:01:56 -05:00
parent dc9c2773de
commit e69f2db44c
8 changed files with 130 additions and 30 deletions

View File

@ -191,7 +191,39 @@ function distance(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function pickLabelPoints(points, maxCount, used, minSpacing, avoidPoints = []) {
function rectFromLabelPoint(point, width = 78, height = 14) {
return {
x: point.x - 2,
y: point.y - height + 2,
width,
height
};
}
function rectsOverlap(a, b) {
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
}
function pointInRect(point, rect) {
return point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height;
}
function withCandidateOffsets(point) {
const offsets = [
[0, 0],
[0, -10],
[0, 10],
[10, 0],
[-10, 0],
[14, -10],
[-14, -10],
[14, 10],
[-14, 10]
];
return offsets.map(([dx, dy]) => ({ x: point.x + dx, y: point.y + dy }));
}
function pickLabelPoints(points, maxCount, used, usedRects, minSpacing, avoidPoints = [], blockedRects = []) {
const accepted = [];
for (const p of points) {
@ -199,9 +231,11 @@ function pickLabelPoints(points, maxCount, used, minSpacing, avoidPoints = []) {
break;
}
const candidates = withCandidateOffsets(p);
for (const candidate of candidates) {
let blocked = false;
for (const prev of used) {
if (distance(p, prev) < minSpacing) {
if (distance(candidate, prev) < minSpacing) {
blocked = true;
break;
}
@ -211,7 +245,7 @@ function pickLabelPoints(points, maxCount, used, minSpacing, avoidPoints = []) {
}
for (const pin of avoidPoints) {
if (distance(p, pin) < minSpacing * 0.9) {
if (distance(candidate, pin) < minSpacing * 0.9) {
blocked = true;
break;
}
@ -220,8 +254,32 @@ function pickLabelPoints(points, maxCount, used, minSpacing, avoidPoints = []) {
continue;
}
accepted.push(p);
used.push(p);
const labelRect = rectFromLabelPoint(candidate);
for (const usedRect of usedRects) {
if (rectsOverlap(labelRect, usedRect)) {
blocked = true;
break;
}
}
if (blocked) {
continue;
}
for (const blockedRect of blockedRects) {
if (rectsOverlap(labelRect, blockedRect) || pointInRect(candidate, blockedRect)) {
blocked = true;
break;
}
}
if (blocked) {
continue;
}
accepted.push(candidate);
used.push(candidate);
usedRects.push(labelRect);
break;
}
}
return accepted;
@ -309,6 +367,7 @@ export function renderSvgFromLayout(model, layout, options = {}) {
const pinNets = pinNetMap(model);
const netClassByName = new Map((model.nets ?? []).map((n) => [n.name, n.class]));
const allPinPoints = [];
const componentRects = [];
const components = layout.placed
.map((inst) => {
@ -318,6 +377,12 @@ export function renderSvgFromLayout(model, layout, options = {}) {
const rotation = normalizeRotation(inst.placement.rotation ?? 0);
const cx = x + sym.body.width / 2;
const cy = y + sym.body.height / 2;
componentRects.push({
x: x - 6,
y: y - 6,
width: sym.body.width + 12,
height: sym.body.height + 12
});
const templateKind = symbolTemplateKind(sym);
const compactLabel = templateKind || sym.body.width <= 140 || sym.body.height <= 90;
const legacyShowInstanceNetLabels = Boolean(inst.properties?.show_net_labels);
@ -471,8 +536,20 @@ export function renderSvgFromLayout(model, layout, options = {}) {
const routedByName = new Map(layout.routed.map((r) => [r.net.name, r]));
const usedLabelPoints = [];
const usedLabelRects = [];
const labels = [];
const tieLabels = [];
const blockedLabelRects = [{ x: 6, y: 6, width: 126, height: 86 }, ...componentRects];
const annotationEntries = (model.annotations ?? []).map((a, idx) => {
const x = a.x ?? 16;
const y = a.y ?? 24 + idx * 16;
const yAdjusted = x < 170 && y < 110 ? 110 + idx * 16 : y;
const text = String(a.text ?? "");
return { x, y: yAdjusted, text, width: Math.max(90, Math.min(640, text.length * 6.2)), height: 14 };
});
for (const ann of annotationEntries) {
blockedLabelRects.push({ x: ann.x - 4, y: ann.y - 12, width: ann.width + 8, height: ann.height + 2 });
}
for (const net of model.nets) {
if (isGroundLikeNet(net)) {
@ -489,7 +566,15 @@ export function renderSvgFromLayout(model, layout, options = {}) {
if (routeInfo?.mode === "label_tie") {
candidates.push(...(routeInfo?.labelPoints ?? []));
const selected = pickLabelPoints(candidates, 1, usedLabelPoints, GRID * 2.4, allPinPoints);
const selected = pickLabelPoints(
candidates,
1,
usedLabelPoints,
usedLabelRects,
GRID * 2.4,
allPinPoints,
blockedLabelRects
);
for (const p of selected) {
labels.push(renderNetLabel(p.x, p.y, net.name, net.class, true));
}
@ -503,7 +588,15 @@ export function renderSvgFromLayout(model, layout, options = {}) {
candidates.push({ x: netAnchor.x + 8, y: netAnchor.y - 8 });
}
const selected = pickLabelPoints(candidates, 1, usedLabelPoints, GRID * 2.4, allPinPoints);
const selected = pickLabelPoints(
candidates,
1,
usedLabelPoints,
usedLabelRects,
GRID * 2.4,
allPinPoints,
blockedLabelRects
);
for (const p of selected) {
labels.push(renderNetLabel(p.x, p.y, net.name, net.class));
}
@ -511,6 +604,7 @@ export function renderSvgFromLayout(model, layout, options = {}) {
if (showLabels) {
const usedTieLabels = [];
const usedTieRects = [];
for (const rn of layout.routed) {
if (rn.mode !== "label_tie" || isGroundLikeNet(rn.net)) {
continue;
@ -522,7 +616,15 @@ export function renderSvgFromLayout(model, layout, options = {}) {
}
const maxPerNet = rn.net.class === "power" ? Math.min(6, candidates.length) : Math.min(2, candidates.length);
const selected = pickLabelPoints(candidates, maxPerNet, usedTieLabels, GRID * 1.5, allPinPoints);
const selected = pickLabelPoints(
candidates,
maxPerNet,
usedTieLabels,
usedTieRects,
GRID * 1.5,
allPinPoints,
blockedLabelRects
);
for (const p of selected) {
tieLabels.push(renderNetLabel(p.x, p.y, rn.net.name, rn.net.class, true));
}
@ -553,11 +655,9 @@ export function renderSvgFromLayout(model, layout, options = {}) {
})
.join("\n");
const annotations = (model.annotations ?? [])
.map((a, idx) => {
const x = a.x ?? 16;
const y = a.y ?? 24 + idx * 16;
return `<text x="${x}" y="${y}" font-size="11" fill="#6b7280">${esc(a.text)}</text>`;
const annotations = annotationEntries
.map((a) => {
return `<text x="${a.x}" y="${a.y}" font-size="11" fill="#6b7280">${esc(a.text)}</text>`;
})
.join("\n");

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 195 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), "8cdeb27f324decbd375fc9b127c7361f204c4e167551076178d6ad52dee66f94");
assert.equal(svgHash(outA.svg), "8dc4f0722829a68136cb237373a8d3e26669c693ec7f4287c92d22772488b99f");
});