Sprint 4: deterministic label collision pass and refreshed UI baselines
Some checks are pending
CI / test (push) Waiting to run
158
src/render.js
@ -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,29 +231,55 @@ function pickLabelPoints(points, maxCount, used, minSpacing, avoidPoints = []) {
|
||||
break;
|
||||
}
|
||||
|
||||
let blocked = false;
|
||||
for (const prev of used) {
|
||||
if (distance(p, prev) < minSpacing) {
|
||||
blocked = true;
|
||||
break;
|
||||
const candidates = withCandidateOffsets(p);
|
||||
for (const candidate of candidates) {
|
||||
let blocked = false;
|
||||
for (const prev of used) {
|
||||
if (distance(candidate, prev) < minSpacing) {
|
||||
blocked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (blocked) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const pin of avoidPoints) {
|
||||
if (distance(p, pin) < minSpacing * 0.9) {
|
||||
blocked = true;
|
||||
break;
|
||||
if (blocked) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (blocked) {
|
||||
continue;
|
||||
}
|
||||
|
||||
accepted.push(p);
|
||||
used.push(p);
|
||||
for (const pin of avoidPoints) {
|
||||
if (distance(candidate, pin) < minSpacing * 0.9) {
|
||||
blocked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (blocked) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 195 KiB |
@ -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");
|
||||
});
|
||||
|
||||