Sprint 5: enforce readable geometry for generic/template symbols
Some checks are pending
CI / test (push) Waiting to run
Some checks are pending
CI / test (push) Waiting to run
This commit is contained in:
parent
e69f2db44c
commit
b8d894f243
@ -24,6 +24,8 @@ const GENERIC_DEFAULT_WIDTH = 160;
|
||||
const GENERIC_MIN_HEIGHT = 120;
|
||||
const GENERIC_PIN_STEP = 18;
|
||||
const TEMPLATE_PIN_STEP = 24;
|
||||
const MIN_SYMBOL_WIDTH = 96;
|
||||
const MIN_SYMBOL_HEIGHT = 64;
|
||||
|
||||
const TEMPLATE_DEFS = {
|
||||
resistor: {
|
||||
@ -230,6 +232,90 @@ function pinCountToHeight(pinCount) {
|
||||
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) {
|
||||
const names = [...pinUsage.values()].map((x) => x.pin).sort((a, b) => a.localeCompare(b));
|
||||
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 node of net.nodes ?? []) {
|
||||
const inst = (next.instances ?? []).find((x) => x.ref === node.ref);
|
||||
|
||||
58
tests/validate.test.js
Normal file
58
tests/validate.test.js
Normal 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user