Activate ELK backend placement path with deterministic fallback
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
128c5c6f4e
commit
46175efe1b
245
src/layout.js
245
src/layout.js
@ -2578,6 +2578,228 @@ function layoutAndRouteNative(model, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildElkEdges(model) {
|
||||
const directed = buildDirectedEdges(model);
|
||||
if (directed.length) {
|
||||
return directed;
|
||||
}
|
||||
const undirected = new Map();
|
||||
for (const net of model.nets ?? []) {
|
||||
const refs = [...new Set((net.nodes ?? []).map((n) => n.ref))].sort();
|
||||
if (refs.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const source = refs[0];
|
||||
for (let i = 1; i < refs.length; i += 1) {
|
||||
const target = refs[i];
|
||||
if (source === target) continue;
|
||||
undirected.set(`${source}->${target}`, [source, target]);
|
||||
}
|
||||
}
|
||||
return [...undirected.values()];
|
||||
}
|
||||
|
||||
function buildElkGraph(model) {
|
||||
const instances = [...(model.instances ?? [])].sort((a, b) => a.ref.localeCompare(b.ref));
|
||||
const edges = buildElkEdges(model);
|
||||
return {
|
||||
id: "schemeta-root",
|
||||
layoutOptions: {
|
||||
"elk.algorithm": "layered",
|
||||
"elk.direction": "RIGHT",
|
||||
"elk.spacing.nodeNode": "90",
|
||||
"elk.layered.spacing.nodeNodeBetweenLayers": "170",
|
||||
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
|
||||
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
|
||||
"elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES"
|
||||
},
|
||||
children: instances.map((inst) => {
|
||||
const sym = model.symbols[inst.symbol];
|
||||
return {
|
||||
id: inst.ref,
|
||||
width: sym?.body?.width ?? 120,
|
||||
height: sym?.body?.height ?? 80
|
||||
};
|
||||
}),
|
||||
edges: edges.map(([source, target], idx) => ({
|
||||
id: `e${idx}_${source}_${target}`,
|
||||
sources: [source],
|
||||
targets: [target]
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function resolveElkLayoutResult(elkRuntime, graph) {
|
||||
const ElkCtor = elkRuntime.ElkCtor;
|
||||
if (typeof ElkCtor !== "function") {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "invalid_runtime",
|
||||
message: "ELK runtime did not provide a constructor."
|
||||
};
|
||||
}
|
||||
let instance;
|
||||
try {
|
||||
instance = new ElkCtor();
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "constructor_failed",
|
||||
message: `ELK constructor failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
};
|
||||
}
|
||||
|
||||
let laidOut;
|
||||
if (typeof instance.layoutSync === "function") {
|
||||
laidOut = instance.layoutSync(graph);
|
||||
} else if (typeof instance.layout === "function") {
|
||||
laidOut = instance.layout(graph);
|
||||
if (laidOut && typeof laidOut.then === "function") {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "async_runtime",
|
||||
message:
|
||||
"ELK runtime returned an asynchronous layout promise; synchronous backend placement is required. Using default engine."
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "invalid_runtime",
|
||||
message: "ELK runtime did not expose layout/layoutSync."
|
||||
};
|
||||
}
|
||||
|
||||
if (!laidOut || !Array.isArray(laidOut.children)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "invalid_result",
|
||||
message: "ELK runtime returned an invalid graph result."
|
||||
};
|
||||
}
|
||||
return { ok: true, graph: laidOut };
|
||||
}
|
||||
|
||||
function layoutAndRouteElk(model, options = {}, nativeLayout) {
|
||||
const elkRuntime = resolveElkRuntime(options.elk_runtime_module);
|
||||
if (!elkRuntime.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
warning: {
|
||||
code: "elk_layout_unavailable_fallback",
|
||||
message: elkRuntime.message
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const graph = buildElkGraph(model);
|
||||
const elkResult = resolveElkLayoutResult(elkRuntime, graph);
|
||||
if (!elkResult.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
warning: {
|
||||
code:
|
||||
elkResult.reason === "async_runtime"
|
||||
? "elk_layout_async_runtime_fallback"
|
||||
: "elk_layout_invalid_result_fallback",
|
||||
message: elkResult.message
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const renderMode = options.render_mode === "explicit" ? "explicit" : DEFAULT_RENDER_MODE;
|
||||
const respectLocks = options.respect_locks ?? true;
|
||||
const autoRotate = options.auto_rotate ?? true;
|
||||
const preservePlacement = options.preserve_placement !== false;
|
||||
const rank = computeRanks(model).rank;
|
||||
const laneProfiles = refLaneProfiles(model);
|
||||
const minRank = Math.min(...[...rank.values(), 1]);
|
||||
const nativeByRef = new Map((nativeLayout?.placed ?? []).map((inst) => [inst.ref, inst]));
|
||||
const elkByRef = new Map(
|
||||
elkResult.graph.children
|
||||
.filter((child) => child && typeof child.id === "string")
|
||||
.map((child) => [child.id, child])
|
||||
);
|
||||
|
||||
const placed = [...model.instances]
|
||||
.sort((a, b) => a.ref.localeCompare(b.ref))
|
||||
.map((inst) => {
|
||||
const sym = model.symbols[inst.symbol];
|
||||
const locked = respectLocks ? Boolean(inst.placement?.locked) : false;
|
||||
const keepExisting = preservePlacement && hasValidPlacement(inst);
|
||||
const keepLocked = locked && hasValidPlacement(inst);
|
||||
const native = nativeByRef.get(inst.ref);
|
||||
const elkNode = elkByRef.get(inst.ref);
|
||||
let x = hasValidPlacement(inst) ? Number(inst.placement.x) : Number.NaN;
|
||||
let y = hasValidPlacement(inst) ? Number(inst.placement.y) : Number.NaN;
|
||||
|
||||
if (!keepExisting && !keepLocked) {
|
||||
if (elkNode && Number.isFinite(elkNode.x) && Number.isFinite(elkNode.y)) {
|
||||
const localRank = Math.max(0, (rank.get(inst.ref) ?? 1) - minRank);
|
||||
const lane = laneProfiles.get(inst.ref)?.laneIndex ?? 2;
|
||||
const rankTargetX = MARGIN_X + localRank * (COLUMN_GAP * 0.84);
|
||||
const laneTargetY = MARGIN_Y + lane * (ROW_GAP * 0.66);
|
||||
const rawX = toGrid(MARGIN_X + Number(elkNode.x));
|
||||
const rawY = toGrid(MARGIN_Y + Number(elkNode.y));
|
||||
x = toGrid(rawX * 0.7 + rankTargetX * 0.3);
|
||||
y = toGrid(rawY * 0.62 + laneTargetY * 0.38);
|
||||
} else if (native?.placement && Number.isFinite(native.placement.x) && Number.isFinite(native.placement.y)) {
|
||||
x = native.placement.x;
|
||||
y = native.placement.y;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||
if (native?.placement && Number.isFinite(native.placement.x) && Number.isFinite(native.placement.y)) {
|
||||
x = native.placement.x;
|
||||
y = native.placement.y;
|
||||
} else {
|
||||
x = toGrid(MARGIN_X);
|
||||
y = toGrid(MARGIN_Y);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...inst,
|
||||
placement: {
|
||||
x: toGrid(Math.max(MARGIN_X, x)),
|
||||
y: toGrid(Math.max(MARGIN_Y, y)),
|
||||
rotation: normalizeRotation(inst.placement?.rotation ?? 0),
|
||||
locked
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const placedMap = new Map(placed.map((inst) => [inst.ref, inst]));
|
||||
applyAutoRotation(model, placedMap, { autoRotate });
|
||||
applyAlignmentConstraints(placedMap, model.constraints);
|
||||
applyNearConstraints(model, placedMap, model.constraints);
|
||||
resolvePlacementOverlaps(model, placedMap, { respectLocks });
|
||||
enforceFinalComponentSeparation(model, placedMap, { respectLocks });
|
||||
const normalizedPlaced = [...model.instances]
|
||||
.sort((a, b) => a.ref.localeCompare(b.ref))
|
||||
.map((inst) => placedMap.get(inst.ref) ?? inst);
|
||||
|
||||
const bounds = buildBounds(model, normalizedPlaced);
|
||||
const normalizedMap = new Map(normalizedPlaced.map((inst) => [inst.ref, inst]));
|
||||
const { routed, busGroups } = routeAllNets(model, normalizedPlaced, normalizedMap, bounds, {
|
||||
renderMode
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
layout: {
|
||||
placed: normalizedPlaced,
|
||||
routed,
|
||||
width: bounds.maxX,
|
||||
height: bounds.maxY,
|
||||
bus_groups: busGroups,
|
||||
metrics: computeLayoutMetrics(routed, busGroups),
|
||||
render_mode_used: renderMode
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function layoutAndRoute(model, options = {}) {
|
||||
const requestedEngine = requestedLayoutEngine(options);
|
||||
const nativeLayout = layoutAndRouteNative(model, options);
|
||||
@ -2591,32 +2813,21 @@ export function layoutAndRoute(model, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
const elkRuntime = resolveElkRuntime(options.elk_runtime_module);
|
||||
if (!elkRuntime.ok) {
|
||||
const elkLayout = layoutAndRouteElk(model, options, nativeLayout);
|
||||
if (!elkLayout.ok) {
|
||||
return {
|
||||
...nativeLayout,
|
||||
layout_engine_requested: "elk",
|
||||
layout_engine_used: DEFAULT_LAYOUT_ENGINE,
|
||||
layout_warnings: [
|
||||
{
|
||||
code: "elk_layout_unavailable_fallback",
|
||||
message: elkRuntime.message
|
||||
}
|
||||
]
|
||||
layout_warnings: [elkLayout.warning]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...nativeLayout,
|
||||
...elkLayout.layout,
|
||||
layout_engine_requested: "elk",
|
||||
layout_engine_used: DEFAULT_LAYOUT_ENGINE,
|
||||
layout_warnings: [
|
||||
{
|
||||
code: "elk_layout_boundary_fallback",
|
||||
message:
|
||||
"ELK runtime resolved, but backend ELK placement is not yet enabled. Using default layout engine."
|
||||
}
|
||||
]
|
||||
layout_engine_used: "elk",
|
||||
layout_warnings: []
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { compile } from "../src/compile.js";
|
||||
import fixture from "../examples/esp32-audio.json" with { type: "json" };
|
||||
|
||||
@ -51,12 +52,13 @@ test("compile default layout engine path remains stable", () => {
|
||||
test("compile accepts ELK layout flag option", () => {
|
||||
const result = compile(fixture, {
|
||||
use_elk_layout: true,
|
||||
elk_runtime_module: "__missing_elk_runtime_for_test__"
|
||||
elk_runtime_module: path.resolve(process.cwd(), "tests/fixtures/mock-elk-runtime.cjs")
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.layout_engine_requested, "elk");
|
||||
assert.equal(result.layout_engine_used, "schemeta-v2");
|
||||
assert.equal(result.layout_engine_used, "elk");
|
||||
assert.deepEqual(result.layout_warnings, []);
|
||||
});
|
||||
|
||||
test("compile ELK fallback is deterministic when runtime is unavailable", () => {
|
||||
|
||||
21
tests/fixtures/mock-elk-runtime.cjs
vendored
Normal file
21
tests/fixtures/mock-elk-runtime.cjs
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
class MockElkRuntime {
|
||||
layoutSync(graph) {
|
||||
const children = Array.isArray(graph?.children) ? [...graph.children] : [];
|
||||
const positioned = children.map((node, idx) => {
|
||||
const col = idx % 4;
|
||||
const row = Math.floor(idx / 4);
|
||||
return {
|
||||
...node,
|
||||
x: 120 + col * 260,
|
||||
y: 100 + row * 200
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: graph?.id ?? "root",
|
||||
children: positioned,
|
||||
edges: Array.isArray(graph?.edges) ? graph.edges : []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MockElkRuntime;
|
||||
Loading…
Reference in New Issue
Block a user