diff --git a/src/validate.js b/src/validate.js index 1f945e6..579b898 100644 --- a/src/validate.js +++ b/src/validate.js @@ -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); diff --git a/tests/validate.test.js b/tests/validate.test.js new file mode 100644 index 0000000..465a2e8 --- /dev/null +++ b/tests/validate.test.js @@ -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); +});