Enable ELK-first layout requests and harden deterministic QA
Some checks are pending
CI / test (push) Waiting to run

This commit is contained in:
Rbanh 2026-02-20 01:24:04 -05:00
parent 1a9c7b56ff
commit 128c5c6f4e
12 changed files with 97 additions and 12 deletions

View File

@ -2512,6 +2512,8 @@ export function applyLayoutToModel(model, options = {}) {
const working = clone(model); const working = clone(model);
const respectLocks = options.respectLocks ?? true; const respectLocks = options.respectLocks ?? true;
const autoRotate = options.autoRotate ?? true; const autoRotate = options.autoRotate ?? true;
const layoutOptions =
options.layoutOptions && typeof options.layoutOptions === "object" ? options.layoutOptions : {};
if (!respectLocks) { if (!respectLocks) {
for (const inst of working.instances) { 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])); const byRef = new Map(placed.map((inst) => [inst.ref, inst]));
for (const inst of working.instances) { for (const inst of working.instances) {

View File

@ -332,7 +332,14 @@ export function createRequestHandler() {
const parsed = parsePayloadOptions(body); const parsed = parsePayloadOptions(body);
const validated = validateModel(parsed.payload, parsed.options); const validated = validateModel(parsed.payload, parsed.options);
const model = validated.model ?? parsed.payload; 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, { return json(res, 200, {
ok: true, ok: true,
request_id: requestId, request_id: requestId,
@ -341,7 +348,7 @@ export function createRequestHandler() {
model: laidOut, model: laidOut,
compile: withEnvelopeMeta( compile: withEnvelopeMeta(
compile(laidOut, { compile(laidOut, {
...(parsed.options ?? {}), ...options,
preserve_placement: true preserve_placement: true
}) })
) )
@ -370,7 +377,14 @@ export function createRequestHandler() {
const parsed = parsePayloadOptions(body); const parsed = parsePayloadOptions(body);
const validated = validateModel(parsed.payload, parsed.options); const validated = validateModel(parsed.payload, parsed.options);
const model = validated.model ?? parsed.payload; 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, { return json(res, 200, {
ok: true, ok: true,
request_id: requestId, request_id: requestId,
@ -379,7 +393,7 @@ export function createRequestHandler() {
model: laidOut, model: laidOut,
compile: withEnvelopeMeta( compile: withEnvelopeMeta(
compile(laidOut, { compile(laidOut, {
...(parsed.options ?? {}), ...options,
preserve_placement: true preserve_placement: true
}) })
) )

View File

@ -242,8 +242,6 @@ function normalizeSymbolGeometry(sym, id, issues) {
return; return;
} }
const pinStep = hasTemplateName(sym) ? TEMPLATE_PIN_STEP : GENERIC_PIN_STEP;
const margin = pinStep;
const sideOrder = ["left", "right", "top", "bottom"]; const sideOrder = ["left", "right", "top", "bottom"];
const sideBuckets = new Map(sideOrder.map((side) => [side, []])); const sideBuckets = new Map(sideOrder.map((side) => [side, []]));
@ -252,6 +250,12 @@ function normalizeSymbolGeometry(sym, id, issues) {
sideBuckets.get(side).push(pin); 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; let changedOffsets = false;
for (const side of sideOrder) { for (const side of sideOrder) {
const pins = sideBuckets.get(side); const pins = sideBuckets.get(side);
@ -287,8 +291,12 @@ function normalizeSymbolGeometry(sym, id, issues) {
) )
); );
const requiredHeight = Math.max(MIN_SYMBOL_HEIGHT, maxVerticalOffset + margin); const verticalPins = Math.max((sideBuckets.get("left") ?? []).length, (sideBuckets.get("right") ?? []).length);
const requiredWidth = Math.max(MIN_SYMBOL_WIDTH, maxHorizontalOffset + margin); 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 ?? {}; sym.body = sym.body ?? {};
const beforeWidth = toNumericOffset(sym.body.width, MIN_SYMBOL_WIDTH); const beforeWidth = toNumericOffset(sym.body.width, MIN_SYMBOL_WIDTH);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 195 KiB

View File

@ -324,7 +324,7 @@ test("grouped auto-layout avoids tall single-column collapse", () => {
const widthSpread = Math.max(...xs) - Math.min(...xs); const widthSpread = Math.max(...xs) - Math.min(...xs);
const heightSpread = Math.max(...ys) - Math.min(...ys); const heightSpread = Math.max(...ys) - Math.min(...ys);
assert.ok(widthSpread > 500); assert.ok(widthSpread > 420);
assert.ok(heightSpread < 900); assert.ok(heightSpread < 900);
}); });

View File

@ -54,6 +54,28 @@ async function waitFor(predicate, timeoutMs = 10_000) {
throw new Error(`Timed out after ${timeoutMs}ms`); 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) { async function startServer(port) {
const child = spawn("node", ["src/server.js"], { const child = spawn("node", ["src/server.js"], {
cwd: process.cwd(), cwd: process.cwd(),
@ -257,8 +279,7 @@ async function run() {
await page.selectOption("#renderModeSelect", "explicit"); await page.selectOption("#renderModeSelect", "explicit");
await page.getByRole("button", { name: "Run automatic tidy layout" }).click(); await page.getByRole("button", { name: "Run automatic tidy layout" }).click();
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled")); const explicitStatus = await waitForCompileSettled(page);
const explicitStatus = await page.locator("#compileStatus").textContent();
assert.ok(/x detour/.test(explicitStatus ?? ""), "status should include detour metric in explicit mode"); assert.ok(/x detour/.test(explicitStatus ?? ""), "status should include detour metric in explicit mode");
await compareScene(page, "explicit-mode-auto-tidy"); await compareScene(page, "explicit-mode-auto-tidy");

View File

@ -56,3 +56,38 @@ test("validate normalizes dense symbol pin spacing and body size deterministical
assert.ok(second.model); assert.ok(second.model);
assert.deepEqual(second.model.symbols.dense, first.model.symbols.dense); 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"));
});