Sprint 4: deterministic label collision pass and refreshed UI baselines
Some checks are pending
CI / test (push) Waiting to run
126
src/render.js
@ -191,7 +191,39 @@ function distance(a, b) {
|
|||||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
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 = [];
|
const accepted = [];
|
||||||
|
|
||||||
for (const p of points) {
|
for (const p of points) {
|
||||||
@ -199,9 +231,11 @@ function pickLabelPoints(points, maxCount, used, minSpacing, avoidPoints = []) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const candidates = withCandidateOffsets(p);
|
||||||
|
for (const candidate of candidates) {
|
||||||
let blocked = false;
|
let blocked = false;
|
||||||
for (const prev of used) {
|
for (const prev of used) {
|
||||||
if (distance(p, prev) < minSpacing) {
|
if (distance(candidate, prev) < minSpacing) {
|
||||||
blocked = true;
|
blocked = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -211,7 +245,7 @@ function pickLabelPoints(points, maxCount, used, minSpacing, avoidPoints = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const pin of avoidPoints) {
|
for (const pin of avoidPoints) {
|
||||||
if (distance(p, pin) < minSpacing * 0.9) {
|
if (distance(candidate, pin) < minSpacing * 0.9) {
|
||||||
blocked = true;
|
blocked = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -220,8 +254,32 @@ function pickLabelPoints(points, maxCount, used, minSpacing, avoidPoints = []) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
accepted.push(p);
|
const labelRect = rectFromLabelPoint(candidate);
|
||||||
used.push(p);
|
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;
|
return accepted;
|
||||||
@ -309,6 +367,7 @@ export function renderSvgFromLayout(model, layout, options = {}) {
|
|||||||
const pinNets = pinNetMap(model);
|
const pinNets = pinNetMap(model);
|
||||||
const netClassByName = new Map((model.nets ?? []).map((n) => [n.name, n.class]));
|
const netClassByName = new Map((model.nets ?? []).map((n) => [n.name, n.class]));
|
||||||
const allPinPoints = [];
|
const allPinPoints = [];
|
||||||
|
const componentRects = [];
|
||||||
|
|
||||||
const components = layout.placed
|
const components = layout.placed
|
||||||
.map((inst) => {
|
.map((inst) => {
|
||||||
@ -318,6 +377,12 @@ export function renderSvgFromLayout(model, layout, options = {}) {
|
|||||||
const rotation = normalizeRotation(inst.placement.rotation ?? 0);
|
const rotation = normalizeRotation(inst.placement.rotation ?? 0);
|
||||||
const cx = x + sym.body.width / 2;
|
const cx = x + sym.body.width / 2;
|
||||||
const cy = y + sym.body.height / 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 templateKind = symbolTemplateKind(sym);
|
||||||
const compactLabel = templateKind || sym.body.width <= 140 || sym.body.height <= 90;
|
const compactLabel = templateKind || sym.body.width <= 140 || sym.body.height <= 90;
|
||||||
const legacyShowInstanceNetLabels = Boolean(inst.properties?.show_net_labels);
|
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 routedByName = new Map(layout.routed.map((r) => [r.net.name, r]));
|
||||||
const usedLabelPoints = [];
|
const usedLabelPoints = [];
|
||||||
|
const usedLabelRects = [];
|
||||||
const labels = [];
|
const labels = [];
|
||||||
const tieLabels = [];
|
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) {
|
for (const net of model.nets) {
|
||||||
if (isGroundLikeNet(net)) {
|
if (isGroundLikeNet(net)) {
|
||||||
@ -489,7 +566,15 @@ export function renderSvgFromLayout(model, layout, options = {}) {
|
|||||||
|
|
||||||
if (routeInfo?.mode === "label_tie") {
|
if (routeInfo?.mode === "label_tie") {
|
||||||
candidates.push(...(routeInfo?.labelPoints ?? []));
|
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) {
|
for (const p of selected) {
|
||||||
labels.push(renderNetLabel(p.x, p.y, net.name, net.class, true));
|
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 });
|
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) {
|
for (const p of selected) {
|
||||||
labels.push(renderNetLabel(p.x, p.y, net.name, net.class));
|
labels.push(renderNetLabel(p.x, p.y, net.name, net.class));
|
||||||
}
|
}
|
||||||
@ -511,6 +604,7 @@ export function renderSvgFromLayout(model, layout, options = {}) {
|
|||||||
|
|
||||||
if (showLabels) {
|
if (showLabels) {
|
||||||
const usedTieLabels = [];
|
const usedTieLabels = [];
|
||||||
|
const usedTieRects = [];
|
||||||
for (const rn of layout.routed) {
|
for (const rn of layout.routed) {
|
||||||
if (rn.mode !== "label_tie" || isGroundLikeNet(rn.net)) {
|
if (rn.mode !== "label_tie" || isGroundLikeNet(rn.net)) {
|
||||||
continue;
|
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 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) {
|
for (const p of selected) {
|
||||||
tieLabels.push(renderNetLabel(p.x, p.y, rn.net.name, rn.net.class, true));
|
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");
|
.join("\n");
|
||||||
|
|
||||||
const annotations = (model.annotations ?? [])
|
const annotations = annotationEntries
|
||||||
.map((a, idx) => {
|
.map((a) => {
|
||||||
const x = a.x ?? 16;
|
return `<text x="${a.x}" y="${a.y}" font-size="11" fill="#6b7280">${esc(a.text)}</text>`;
|
||||||
const y = a.y ?? 24 + idx * 16;
|
|
||||||
return `<text x="${x}" y="${y}" font-size="11" fill="#6b7280">${esc(a.text)}</text>`;
|
|
||||||
})
|
})
|
||||||
.join("\n");
|
.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(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), "8cdeb27f324decbd375fc9b127c7361f204c4e167551076178d6ad52dee66f94");
|
assert.equal(svgHash(outA.svg), "8dc4f0722829a68136cb237373a8d3e26669c693ec7f4287c92d22772488b99f");
|
||||||
});
|
});
|
||||||
|
|||||||