diff --git a/src/layout.js b/src/layout.js index 273e9f5..5f9ec79 100644 --- a/src/layout.js +++ b/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: [] }; } diff --git a/tests/compile.test.js b/tests/compile.test.js index 8fbfb1a..50172f4 100644 --- a/tests/compile.test.js +++ b/tests/compile.test.js @@ -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", () => { diff --git a/tests/fixtures/mock-elk-runtime.cjs b/tests/fixtures/mock-elk-runtime.cjs new file mode 100644 index 0000000..14b0142 --- /dev/null +++ b/tests/fixtures/mock-elk-runtime.cjs @@ -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;