Enable ELK-first layout requests and harden deterministic QA
Some checks are pending
CI / test (push) Waiting to run
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 195 KiB |
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
||||||
|
|
||||||
|
|||||||
@ -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"));
|
||||||
|
});
|
||||||
|
|||||||