Sprint 5: enforce readable geometry for generic/template symbols
Some checks are pending
CI / test (push) Waiting to run

This commit is contained in:
Rbanh 2026-02-19 23:09:53 -05:00
parent e69f2db44c
commit b8d894f243
2 changed files with 150 additions and 0 deletions

View File

@ -24,6 +24,8 @@ const GENERIC_DEFAULT_WIDTH = 160;
const GENERIC_MIN_HEIGHT = 120; const GENERIC_MIN_HEIGHT = 120;
const GENERIC_PIN_STEP = 18; const GENERIC_PIN_STEP = 18;
const TEMPLATE_PIN_STEP = 24; const TEMPLATE_PIN_STEP = 24;
const MIN_SYMBOL_WIDTH = 96;
const MIN_SYMBOL_HEIGHT = 64;
const TEMPLATE_DEFS = { const TEMPLATE_DEFS = {
resistor: { resistor: {
@ -230,6 +232,90 @@ function pinCountToHeight(pinCount) {
return Math.max(GENERIC_MIN_HEIGHT, rows * GENERIC_PIN_STEP); return Math.max(GENERIC_MIN_HEIGHT, rows * GENERIC_PIN_STEP);
} }
function toNumericOffset(value, fallback) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function normalizeSymbolGeometry(sym, id, issues) {
if (!sym || typeof sym !== "object" || !Array.isArray(sym.pins) || sym.pins.length === 0) {
return;
}
const pinStep = hasTemplateName(sym) ? TEMPLATE_PIN_STEP : GENERIC_PIN_STEP;
const margin = pinStep;
const sideOrder = ["left", "right", "top", "bottom"];
const sideBuckets = new Map(sideOrder.map((side) => [side, []]));
for (const pin of sym.pins) {
const side = sideBuckets.has(pin.side) ? pin.side : "left";
sideBuckets.get(side).push(pin);
}
let changedOffsets = false;
for (const side of sideOrder) {
const pins = sideBuckets.get(side);
pins.sort((a, b) => {
const ao = toNumericOffset(a.offset, margin);
const bo = toNumericOffset(b.offset, margin);
if (ao !== bo) return ao - bo;
return String(a.name ?? "").localeCompare(String(b.name ?? ""));
});
let next = margin;
for (const pin of pins) {
const current = toNumericOffset(pin.offset, next);
const normalized = Math.max(next, current);
if (pin.offset !== normalized) {
pin.offset = normalized;
changedOffsets = true;
}
next = normalized + pinStep;
}
}
const maxVerticalOffset = Math.max(
margin,
...[...(sideBuckets.get("left") ?? []), ...(sideBuckets.get("right") ?? [])].map((pin) =>
toNumericOffset(pin.offset, margin)
)
);
const maxHorizontalOffset = Math.max(
margin,
...[...(sideBuckets.get("top") ?? []), ...(sideBuckets.get("bottom") ?? [])].map((pin) =>
toNumericOffset(pin.offset, margin)
)
);
const requiredHeight = Math.max(MIN_SYMBOL_HEIGHT, maxVerticalOffset + margin);
const requiredWidth = Math.max(MIN_SYMBOL_WIDTH, maxHorizontalOffset + margin);
sym.body = sym.body ?? {};
const beforeWidth = toNumericOffset(sym.body.width, MIN_SYMBOL_WIDTH);
const beforeHeight = toNumericOffset(sym.body.height, MIN_SYMBOL_HEIGHT);
const nextWidth = Math.max(beforeWidth, requiredWidth);
const nextHeight = Math.max(beforeHeight, requiredHeight);
sym.body.width = nextWidth;
sym.body.height = nextHeight;
const desiredPinOrder = sideOrder.flatMap((side) => sideBuckets.get(side) ?? []);
const changedOrder =
desiredPinOrder.length === sym.pins.length &&
desiredPinOrder.some((pin, index) => pin !== sym.pins[index]);
if (changedOrder) {
sym.pins = desiredPinOrder;
}
if (changedOffsets || nextWidth !== beforeWidth || nextHeight !== beforeHeight || changedOrder) {
issues.push({
code: "symbol_geometry_adjusted",
message: `Adjusted symbol '${id}' geometry/pin spacing for readability.`,
severity: "warning",
path: `symbols.${id}`
});
}
}
function buildPinsFromUsage(ref, pinUsage) { function buildPinsFromUsage(ref, pinUsage) {
const names = [...pinUsage.values()].map((x) => x.pin).sort((a, b) => a.localeCompare(b)); const names = [...pinUsage.values()].map((x) => x.pin).sort((a, b) => a.localeCompare(b));
if (!names.length) { if (!names.length) {
@ -467,6 +553,12 @@ function ensureGenericSymbols(model, issues, enableGenericSymbols) {
} }
} }
for (const [id, sym] of Object.entries(next.symbols)) {
if (isGenericSymbol(sym) || hasTemplateName(sym)) {
normalizeSymbolGeometry(sym, id, issues);
}
}
for (const net of next.nets ?? []) { for (const net of next.nets ?? []) {
for (const node of net.nodes ?? []) { for (const node of net.nodes ?? []) {
const inst = (next.instances ?? []).find((x) => x.ref === node.ref); const inst = (next.instances ?? []).find((x) => x.ref === node.ref);

58
tests/validate.test.js Normal file
View File

@ -0,0 +1,58 @@
import test from "node:test";
import assert from "node:assert/strict";
import { validateModel } from "../src/validate.js";
test("validate normalizes dense symbol pin spacing and body size deterministically", () => {
const model = {
meta: { title: "Dense symbol test" },
symbols: {
dense: {
symbol_id: "dense",
category: "generic",
body: { width: 80, height: 40 },
pins: [
{ name: "A", number: "1", side: "left", offset: 4, type: "input" },
{ name: "B", number: "2", side: "left", offset: 6, type: "input" },
{ name: "Y", number: "3", side: "right", offset: 5, type: "output" },
{ name: "Z", number: "4", side: "right", offset: 7, type: "output" }
]
}
},
instances: [{ ref: "U1", symbol: "dense", placement: { x: 100, y: 100, rotation: 0, locked: false } }],
nets: [
{
name: "N1",
class: "signal",
nodes: [
{ ref: "U1", pin: "A" },
{ ref: "U1", pin: "Y" }
]
},
{
name: "N2",
class: "signal",
nodes: [
{ ref: "U1", pin: "B" },
{ ref: "U1", pin: "Z" }
]
}
]
};
const first = validateModel(model);
assert.ok(first.model);
const dense = first.model.symbols.dense;
assert.ok(dense.body.width >= 96);
assert.ok(dense.body.height >= 64);
const leftPins = dense.pins.filter((pin) => pin.side === "left");
const rightPins = dense.pins.filter((pin) => pin.side === "right");
assert.ok(leftPins[1].offset - leftPins[0].offset >= 18);
assert.ok(rightPins[1].offset - rightPins[0].offset >= 18);
assert.ok(first.issues.some((issue) => issue.code === "symbol_geometry_adjusted"));
const second = validateModel(first.model);
assert.ok(second.model);
assert.deepEqual(second.model.symbols.dense, first.model.symbols.dense);
});