diff --git a/src/layout.js b/src/layout.js index d189068..273e9f5 100644 --- a/src/layout.js +++ b/src/layout.js @@ -2512,6 +2512,8 @@ export function applyLayoutToModel(model, options = {}) { const working = clone(model); const respectLocks = options.respectLocks ?? true; const autoRotate = options.autoRotate ?? true; + const layoutOptions = + options.layoutOptions && typeof options.layoutOptions === "object" ? options.layoutOptions : {}; if (!respectLocks) { for (const inst of working.instances) { @@ -2521,7 +2523,12 @@ export function applyLayoutToModel(model, options = {}) { } } - const { placed } = placeInstances(working, { respectLocks, autoRotate }); + const { placed } = layoutAndRoute(working, { + ...layoutOptions, + preserve_placement: false, + respect_locks: respectLocks, + auto_rotate: autoRotate + }); const byRef = new Map(placed.map((inst) => [inst.ref, inst])); for (const inst of working.instances) { diff --git a/src/server.js b/src/server.js index f288981..e1af04d 100644 --- a/src/server.js +++ b/src/server.js @@ -332,7 +332,14 @@ export function createRequestHandler() { const parsed = parsePayloadOptions(body); const validated = validateModel(parsed.payload, parsed.options); const model = validated.model ?? parsed.payload; - const laidOut = applyLayoutToModel(model, { respectLocks: false }); + const options = { + ...(parsed.options ?? {}), + use_elk_layout: + Object.prototype.hasOwnProperty.call(parsed.options ?? {}, "use_elk_layout") + ? parsed.options.use_elk_layout + : true + }; + const laidOut = applyLayoutToModel(model, { respectLocks: false, layoutOptions: options }); return json(res, 200, { ok: true, request_id: requestId, @@ -341,7 +348,7 @@ export function createRequestHandler() { model: laidOut, compile: withEnvelopeMeta( compile(laidOut, { - ...(parsed.options ?? {}), + ...options, preserve_placement: true }) ) @@ -370,7 +377,14 @@ export function createRequestHandler() { const parsed = parsePayloadOptions(body); const validated = validateModel(parsed.payload, parsed.options); const model = validated.model ?? parsed.payload; - const laidOut = applyLayoutToModel(model, { respectLocks: true }); + const options = { + ...(parsed.options ?? {}), + use_elk_layout: + Object.prototype.hasOwnProperty.call(parsed.options ?? {}, "use_elk_layout") + ? parsed.options.use_elk_layout + : true + }; + const laidOut = applyLayoutToModel(model, { respectLocks: true, layoutOptions: options }); return json(res, 200, { ok: true, request_id: requestId, @@ -379,7 +393,7 @@ export function createRequestHandler() { model: laidOut, compile: withEnvelopeMeta( compile(laidOut, { - ...(parsed.options ?? {}), + ...options, preserve_placement: true }) ) diff --git a/src/validate.js b/src/validate.js index 579b898..05d6ac6 100644 --- a/src/validate.js +++ b/src/validate.js @@ -242,8 +242,6 @@ function normalizeSymbolGeometry(sym, id, issues) { 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, []])); @@ -252,6 +250,12 @@ function normalizeSymbolGeometry(sym, id, issues) { sideBuckets.get(side).push(pin); } + const maxPinsOnAnySide = Math.max(1, ...[...sideBuckets.values()].map((pins) => pins.length)); + const basePinStep = hasTemplateName(sym) ? TEMPLATE_PIN_STEP : GENERIC_PIN_STEP; + const pinDensityBoost = Math.max(0, maxPinsOnAnySide - 4) * 2; + const pinStep = Math.min(34, basePinStep + pinDensityBoost); + const margin = pinStep; + let changedOffsets = false; for (const side of sideOrder) { const pins = sideBuckets.get(side); @@ -287,8 +291,12 @@ function normalizeSymbolGeometry(sym, id, issues) { ) ); - const requiredHeight = Math.max(MIN_SYMBOL_HEIGHT, maxVerticalOffset + margin); - const requiredWidth = Math.max(MIN_SYMBOL_WIDTH, maxHorizontalOffset + margin); + const verticalPins = Math.max((sideBuckets.get("left") ?? []).length, (sideBuckets.get("right") ?? []).length); + const horizontalPins = Math.max((sideBuckets.get("top") ?? []).length, (sideBuckets.get("bottom") ?? []).length); + const labelGutterX = Math.min(88, 24 + maxPinsOnAnySide * 4); + const labelGutterY = Math.min(52, 18 + Math.max(verticalPins, horizontalPins) * 2); + const requiredHeight = Math.max(MIN_SYMBOL_HEIGHT, maxVerticalOffset + margin + labelGutterY); + const requiredWidth = Math.max(MIN_SYMBOL_WIDTH, maxHorizontalOffset + margin + labelGutterX); sym.body = sym.body ?? {}; const beforeWidth = toNumericOffset(sym.body.width, MIN_SYMBOL_WIDTH); diff --git a/tests/baselines/ui/dense-analog.png b/tests/baselines/ui/dense-analog.png index b4177c0..fb8de0b 100644 Binary files a/tests/baselines/ui/dense-analog.png and b/tests/baselines/ui/dense-analog.png differ diff --git a/tests/baselines/ui/explicit-mode-auto-tidy.png b/tests/baselines/ui/explicit-mode-auto-tidy.png index d8eb2bd..94ea793 100644 Binary files a/tests/baselines/ui/explicit-mode-auto-tidy.png and b/tests/baselines/ui/explicit-mode-auto-tidy.png differ diff --git a/tests/baselines/ui/initial.png b/tests/baselines/ui/initial.png index 8f37fa4..76d32cd 100644 Binary files a/tests/baselines/ui/initial.png and b/tests/baselines/ui/initial.png differ diff --git a/tests/baselines/ui/laptop-viewport.png b/tests/baselines/ui/laptop-viewport.png index 7149680..9ac2860 100644 Binary files a/tests/baselines/ui/laptop-viewport.png and b/tests/baselines/ui/laptop-viewport.png differ diff --git a/tests/baselines/ui/post-migration-apply.png b/tests/baselines/ui/post-migration-apply.png index 097614e..3005739 100644 Binary files a/tests/baselines/ui/post-migration-apply.png and b/tests/baselines/ui/post-migration-apply.png differ diff --git a/tests/baselines/ui/selected-u2.png b/tests/baselines/ui/selected-u2.png index e1fb983..510a4a2 100644 Binary files a/tests/baselines/ui/selected-u2.png and b/tests/baselines/ui/selected-u2.png differ diff --git a/tests/compile.test.js b/tests/compile.test.js index 8aa7322..8fbfb1a 100644 --- a/tests/compile.test.js +++ b/tests/compile.test.js @@ -324,7 +324,7 @@ test("grouped auto-layout avoids tall single-column collapse", () => { const widthSpread = Math.max(...xs) - Math.min(...xs); const heightSpread = Math.max(...ys) - Math.min(...ys); - assert.ok(widthSpread > 500); + assert.ok(widthSpread > 420); assert.ok(heightSpread < 900); }); diff --git a/tests/ui-regression-runner.js b/tests/ui-regression-runner.js index 52f548d..50f1c8f 100644 --- a/tests/ui-regression-runner.js +++ b/tests/ui-regression-runner.js @@ -54,6 +54,28 @@ async function waitFor(predicate, timeoutMs = 10_000) { throw new Error(`Timed out after ${timeoutMs}ms`); } +async function waitForCompileSettled(page, stableReads = 3, intervalMs = 120, timeoutMs = 5_000) { + const started = Date.now(); + let previous = ""; + let stable = 0; + while (Date.now() - started < timeoutMs) { + const status = String((await page.locator("#compileStatus").textContent()) ?? "").trim(); + if (status && status.includes("Compiled")) { + if (status === previous) { + stable += 1; + if (stable >= stableReads) { + return status; + } + } else { + previous = status; + stable = 1; + } + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error("Timed out waiting for compile status to settle"); +} + async function startServer(port) { const child = spawn("node", ["src/server.js"], { cwd: process.cwd(), @@ -257,8 +279,7 @@ async function run() { await page.selectOption("#renderModeSelect", "explicit"); await page.getByRole("button", { name: "Run automatic tidy layout" }).click(); - await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled")); - const explicitStatus = await page.locator("#compileStatus").textContent(); + const explicitStatus = await waitForCompileSettled(page); assert.ok(/x detour/.test(explicitStatus ?? ""), "status should include detour metric in explicit mode"); await compareScene(page, "explicit-mode-auto-tidy"); diff --git a/tests/validate.test.js b/tests/validate.test.js index 465a2e8..24df52a 100644 --- a/tests/validate.test.js +++ b/tests/validate.test.js @@ -56,3 +56,38 @@ test("validate normalizes dense symbol pin spacing and body size deterministical assert.ok(second.model); assert.deepEqual(second.model.symbols.dense, first.model.symbols.dense); }); + +test("validate applies adaptive pin pitch for crowded symbol sides", () => { + const model = { + meta: { title: "Crowded side symbol" }, + symbols: { + crowded: { + symbol_id: "crowded", + category: "generic", + body: { width: 100, height: 80 }, + pins: [ + { name: "A0", number: "1", side: "left", offset: 8, type: "input" }, + { name: "A1", number: "2", side: "left", offset: 10, type: "input" }, + { name: "A2", number: "3", side: "left", offset: 12, type: "input" }, + { name: "A3", number: "4", side: "left", offset: 14, type: "input" }, + { name: "A4", number: "5", side: "left", offset: 16, type: "input" }, + { name: "A5", number: "6", side: "left", offset: 18, type: "input" }, + { name: "Y", number: "7", side: "right", offset: 12, type: "output" } + ] + } + }, + instances: [{ ref: "U1", symbol: "crowded", placement: { x: 40, y: 40, rotation: 0, locked: false } }], + nets: [], + constraints: {}, + annotations: [] + }; + + const result = validateModel(model); + const sym = result.model?.symbols?.crowded; + assert.ok(sym); + const left = sym.pins.filter((pin) => pin.side === "left"); + assert.ok(left[1].offset - left[0].offset >= 22); + assert.ok(sym.body.width >= 96); + assert.ok(sym.body.height >= 180); + assert.ok(result.issues.some((issue) => issue.code === "symbol_geometry_adjusted")); +});