From 27ab9a0f08c22559e9330fffd226923cb8887896 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:51:14 +0000 Subject: [PATCH 1/5] feat(vscode): lay unplaced nodes out with ELK and route edges around boxes Co-Authored-By: jason.han --- .../unreleased/vscode-auto-layout.changed.md | 1 + docs/guide/08-editors.md | 4 +- editors/vscode/README.md | 2 +- editors/vscode/package-lock.json | 7 + editors/vscode/package.json | 6 +- editors/vscode/src/webview/autolayout.test.ts | 121 ++++++++++++++ editors/vscode/src/webview/autolayout.ts | 151 ++++++++++++++++++ editors/vscode/src/webview/diagram.ts | 24 ++- editors/vscode/src/webview/layout.test.ts | 39 +++++ editors/vscode/src/webview/layout.ts | 65 ++++++-- editors/vscode/src/webview/tsconfig.json | 1 + editors/vscode/tsconfig.json | 1 + 12 files changed, 402 insertions(+), 20 deletions(-) create mode 100644 changes/unreleased/vscode-auto-layout.changed.md create mode 100644 editors/vscode/src/webview/autolayout.test.ts create mode 100644 editors/vscode/src/webview/autolayout.ts diff --git a/changes/unreleased/vscode-auto-layout.changed.md b/changes/unreleased/vscode-auto-layout.changed.md new file mode 100644 index 0000000000..1e6c8fd29c --- /dev/null +++ b/changes/unreleased/vscode-auto-layout.changed.md @@ -0,0 +1 @@ +- **The diagram panel lays unplaced nodes out in layers and routes edges around boxes.** Nodes the model does not place took slots in a square grid and every edge ran straight between centres, across whatever lay between; they are now laid out by the ELK layered algorithm, edges orthogonal and routed around the boxes. A node the model places, or one dragged, is drawn where stated as before. Renderings of more than 600 nodes keep the grid. diff --git a/docs/guide/08-editors.md b/docs/guide/08-editors.md index b00d96b085..4c7533aaad 100644 --- a/docs/guide/08-editors.md +++ b/docs/guide/08-editors.md @@ -91,7 +91,9 @@ redraws from what the file now says. Where a diagram's boxes go is the model's decision when it states one: a view whose body places its elements with the bundled `DiagramLayout` library (`metadata Layout about engine { x = 120; y = 80; }`, and `Route` for an edge's waypoints) is drawn exactly so, and a node the model does -not place takes a slot in a grid under its owner. Dragging a node writes that annotation — into +not place is laid out in layers under its owner by the ELK layered algorithm, its edges +running orthogonally around the boxes (a rendering of more than 600 nodes falls back to a +square grid). Dragging a node writes that annotation — into the view's body when a view is drawn, into the element's own when the document is drawn directly — as one edit when the pointer is released; dragging the handle on an edge bends it through a `Route` waypoint. The geometry is on every node and edge the server sends (`x`, `y`, diff --git a/editors/vscode/README.md b/editors/vscode/README.md index a2ce9f9277..a387baadec 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -96,7 +96,7 @@ own just waits for the server. | --- | --- | | **What it draws** | The view the document declares. A document declaring several drawable views opens on the one whose declaration holds the editor's cursor, else the one last chosen for that document in this workspace, else the one picked from a list — the drawable views by name and kind, **All views** to open each in its own panel, and the pseudo-views last; views the server cannot draw are left out of that list (the panel's own picker still shows them, disabled, with the reason), and cancelling opens nothing. A document declaring none is drawn directly, as a model tree, interconnection diagram, state diagram, action flow, sequence diagram or element table — a table is written as Markdown rather than drawn, and is shown as that. A view whose rendering is not supported (`geometry`, `textual`) is listed but not drawable, and the reason is written under the diagram. | | **Several panels** | A document may have one panel per view open at once; they are titled `Diagram: — ` while there are several, each redraws when the model changes, and each highlights the cursor's node. Open Diagram reveals the panel already showing the chosen view, or opens another beside the source for a different one. Picking a view in a panel's picker retargets that panel — unless another panel already draws it, which is revealed instead. Panels come back with their views when the window reloads. | -| **Where things go** | A node the model places — a `DiagramLayout::Layout` annotation in the view's body or the element's own — is drawn exactly there, at the size it states; every other node takes a slot in a grid under its owner, in the order rendered, so the same model draws the same way every time. An edge follows the waypoints its `DiagramLayout::Route` gives it, else runs straight. | +| **Where things go** | A node the model places — a `DiagramLayout::Layout` annotation in the view's body or the element's own — is drawn exactly there, at the size it states; every other node is laid out in layers under its owner by the ELK layered algorithm, and an edge without waypoints of its own runs orthogonally around the boxes between two nodes neither the model nor a drag placed, else straight. An edge follows the waypoints its `DiagramLayout::Route` gives it. A rendering of more than 600 nodes keeps the earlier square grid, so a migrated model does not hang the panel. | | **Style** | The panel's **Style** list, or the `opensysml.diagram.style` setting, picks the look of every diagram. `theme` (the default) follows the VS Code colour theme. `pilot` is the pilot visualizer's Standard B&W, the look the DOT and PlantUML forms are written in: white canvas, black sans-serif text, thin dark borders, square definitions and rounded usages, a heavier border on a package and a dashed one on a region, bold names over a small italic `«kind»`, thick arrowless connections and dashed flows, filled black pseudo-states. The eight palettes (`okabe-ito`, `tol-bright`, `tol-muted`, `tol-light`, `brewer-set2`, `brewer-dark2`, `viridis`, `cividis`, [described here](../../docs/project/view-rendering-forms.md#palettes)) are that look filled by keyword family — parts one colour, ports another, a usage a lighter tint of its definition's — in the very colours a DOT or PlantUML export of the view takes, since the server names them; text stays black. Changing the list keeps the choice in your settings and redraws every open diagram. A `sysml-lsp` too old to name colours draws a palette as `pilot` and says so under the diagram. | | **Navigation** | Click a node to open the declaration it was built from; moving the cursor in the editor highlights the node whose declaration contains it. A node built from a standard library declaration opens the bundled library file, read-only. | | **While typing** | A rendering that fails mid-keystroke leaves the last good diagram on screen, dimmed, with the error in the status line: the panel never blanks. What a rendering could not represent is listed under it. | diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json index c9d0f42ae3..167b3bde23 100644 --- a/editors/vscode/package-lock.json +++ b/editors/vscode/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { + "elkjs": "0.12.0", "vscode-languageclient": "9.0.1" }, "devDependencies": { @@ -1577,6 +1578,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/elkjs": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.12.0.tgz", + "integrity": "sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ==", + "license": "EPL-2.0 OR GPL-3.0-or-later" + }, "node_modules/encoding-sniffer": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", diff --git a/editors/vscode/package.json b/editors/vscode/package.json index c186de97cf..659b9ba590 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -26,7 +26,10 @@ "untrustedWorkspaces": { "supported": "limited", "description": "In Restricted Mode the language server is only started from `opensysml.server.path` or `PATH`, never from the workspace's own `bin/` directory, and the server settings are read from user settings only.", - "restrictedConfigurations": ["opensysml.server.path", "opensysml.server.args"] + "restrictedConfigurations": [ + "opensysml.server.path", + "opensysml.server.args" + ] } }, "contributes": { @@ -213,6 +216,7 @@ "package": "npm run typecheck && npm test && npm run build -- --production && vsce package --no-dependencies --out opensysml-sysml.vsix" }, "dependencies": { + "elkjs": "0.12.0", "vscode-languageclient": "9.0.1" }, "devDependencies": { diff --git a/editors/vscode/src/webview/autolayout.test.ts b/editors/vscode/src/webview/autolayout.test.ts new file mode 100644 index 0000000000..a86cba3ad0 --- /dev/null +++ b/editors/vscode/src/webview/autolayout.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { RenderEdge, RenderNode, RenderPoint, RenderResult } from "../protocol"; +import { AUTO_LAYOUT_LIMIT, autoLayout, type AutoLayout } from "./autolayout"; +import type { Box } from "./layout"; + +const origin = { uri: "file:///m.sysml", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 4 } }, digest: "d0" }; + +function node(id: string, name: string, extra: Partial = {}): RenderNode { + return { id, kind: "part", name, type: "", detail: "", fqn: `M::${name}`, origin, ...extra }; +} + +function edge(from: string, to: string, extra: Partial = {}): RenderEdge { + return { from, to, label: "", kind: "connection", fqn: `M::${from}${to}`, ...extra }; +} + +function rendering(nodes: RenderNode[], edges: RenderEdge[] = [], extra: Partial = {}): RenderResult { + return { + view: "M::V", + kind: "tree", + stated: "", + form: "mermaid", + artifact: "", + nodes, + edges, + notices: [], + version: 7, + ...extra, + }; +} + +// boxOf is a node's placed geometry as a box; ELK always states a size. +function boxOf(laid: AutoLayout, id: string): Box { + const geometry = laid.nodes.get(id); + assert.ok(geometry?.width !== undefined && geometry.height !== undefined); + return { x: geometry.x, y: geometry.y, width: geometry.width, height: geometry.height }; +} + +function disjoint(a: Box, b: Box): boolean { + return a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y; +} + +// onBorder is the point lying on the box's edge, give or take a pixel of snapping. +function onBorder(point: RenderPoint, box: Box): boolean { + const withinX = point.x >= box.x - 1 && point.x <= box.x + box.width + 1; + const withinY = point.y >= box.y - 1 && point.y <= box.y + box.height + 1; + const onX = Math.abs(point.x - box.x) <= 1 || Math.abs(point.x - box.x - box.width) <= 1; + const onY = Math.abs(point.y - box.y) <= 1 || Math.abs(point.y - box.y - box.height) <= 1; + return (withinX && onY) || (withinY && onX); +} + +function orthogonal(points: RenderPoint[]): boolean { + return points.every((point, i) => i === 0 || point.x === points[i - 1].x || point.y === points[i - 1].y); +} + +test("autoLayout lays a chain out in layers downward and routes its edges orthogonally", async () => { + const result = rendering([node("a", "a"), node("b", "b"), node("c", "c")], [edge("a", "b"), edge("b", "c")]); + const laid = await autoLayout(result); + assert.ok(laid); + const a = boxOf(laid, "a"); + const b = boxOf(laid, "b"); + const c = boxOf(laid, "c"); + assert.ok(a.y < b.y && b.y < c.y); + for (const box of [a, b, c]) { + assert.ok(box.width > 0 && box.height > 0); + } + assert.ok(disjoint(a, b) && disjoint(b, c) && disjoint(a, c)); + const routes = [laid.routes.get(0)!, laid.routes.get(1)!]; + assert.ok(routes.every((route) => route.length >= 2)); + assert.ok(routes.every(orthogonal)); + assert.ok(onBorder(routes[0][0], a) && onBorder(routes[0].at(-1)!, b)); + assert.ok(onBorder(routes[1][0], b) && onBorder(routes[1].at(-1)!, c)); +}); + +test("autoLayout holds a container's children inside it and reports absolute edge points", async () => { + const result = rendering( + [node("p", "p"), node("a", "a", { parent: "p" }), node("b", "b", { parent: "p" }), node("c", "c")], + [edge("a", "c"), edge("b", "c")], + ); + const laid = await autoLayout(result); + assert.ok(laid); + const p = boxOf(laid, "p"); + const a = boxOf(laid, "a"); + const b = boxOf(laid, "b"); + const c = boxOf(laid, "c"); + for (const child of [a, b]) { + assert.ok(child.x >= p.x && child.x + child.width <= p.x + p.width); + assert.ok(child.y >= p.y && child.y + child.height <= p.y + p.height); + } + // The routes are absolute canvas coordinates: they end on c's border directly. + for (const route of [laid.routes.get(0)!, laid.routes.get(1)!]) { + assert.ok(onBorder(route.at(-1)!, c)); + } +}); + +test("autoLayout leaves a routed edge and a self-loop alone", async () => { + const route = [{ x: 40, y: 40 }]; + const result = rendering( + [node("a", "a"), node("b", "b"), node("c", "c")], + [edge("a", "b", { route }), edge("b", "c"), edge("c", "c")], + ); + const laid = await autoLayout(result); + assert.ok(laid); + assert.equal(laid.routes.has(0), false); + assert.ok(laid.routes.has(1)); + assert.equal(laid.routes.has(2), false); +}); + +test("autoLayout declines a kind with no canvas and a rendering over the limit", async () => { + assert.equal(await autoLayout(rendering([node("a", "a")], [], { kind: "sequence" })), undefined); + const many = Array.from({ length: AUTO_LAYOUT_LIMIT + 1 }, (_, i) => node(`n${i}`, `n${i}`)); + assert.equal(await autoLayout(rendering(many)), undefined); +}); + +test("autoLayout lays an interconnection out left to right", async () => { + const result = rendering([node("a", "a"), node("b", "b")], [edge("a", "b")], { kind: "interconnection" }); + const laid = await autoLayout(result); + assert.ok(laid); + assert.ok(boxOf(laid, "a").x < boxOf(laid, "b").x); +}); diff --git a/editors/vscode/src/webview/autolayout.ts b/editors/vscode/src/webview/autolayout.ts new file mode 100644 index 0000000000..aee90ea476 --- /dev/null +++ b/editors/vscode/src/webview/autolayout.ts @@ -0,0 +1,151 @@ +// Places the nodes a model does not place, and routes the edges between them, +// with the ELK layered algorithm: boxes land in layers, edges run orthogonally +// around them, and containers grow to hold their children. +import ELK from "elkjs/lib/elk.bundled.js"; +import type { ElkExtendedEdge, ElkNode } from "elkjs/lib/elk-api"; + +import type { LayoutGeometry, RenderNode, RenderPoint, RenderResult } from "../protocol"; +import { + CONTAINER_PAD, + GAP, + labelLines, + labelSize, + MARGIN, + PLACEABLE_KINDS, + shapeOf, + snap, + symbolSize, +} from "./layout"; + +export interface AutoLayout { + /** Absolute canvas geometry (x, y, width, height) for every node ELK placed. */ + nodes: Map; + /** By edge index: ELK's orthogonal polyline in absolute canvas coordinates, source anchor first, target anchor last. */ + routes: Map; +} + +/** Above this many nodes the grid stays: laying out a migrated model must not hang the panel. */ +export const AUTO_LAYOUT_LIMIT = 600; + +const elk = new ELK(); + +/** + * autoLayout lays the rendering out with ELK, or returns undefined when the kind + * has no editable canvas, the rendering is too large, or ELK fails. + */ +export async function autoLayout(result: RenderResult): Promise { + if (!PLACEABLE_KINDS.has(result.kind) || (result.nodes?.length ?? 0) === 0 || result.nodes!.length > AUTO_LAYOUT_LIMIT) { + return undefined; + } + try { + return await layOut(result); + } catch (err) { + console.warn(`auto layout failed: ${err instanceof Error ? err.message : String(err)}`); + return undefined; + } +} + +// layOut hands ELK the nodes — nested as the model owns them — and the edges, and +// reads back absolute canvas geometry: node coordinates are relative to their +// parent in ELK's JSON, and edge section coordinates relative to the root. +async function layOut(result: RenderResult): Promise { + const nodes = result.nodes ?? []; + const byId = new Map(nodes.map((node) => [node.id, node])); + const children = new Map(); + for (const node of nodes) { + const parent = node.parent !== undefined && node.parent !== node.id && byId.has(node.parent) ? node.parent : undefined; + const siblings = children.get(parent) ?? []; + siblings.push(node); + children.set(parent, siblings); + } + // A node whose owner is collapsed is not in the graph, so no edge routes to it. + const inGraph = (id: string): boolean => { + let node = byId.get(id); + while (node) { + const parent = node.parent; + if (parent === undefined || parent === node.id || !byId.has(parent)) { + return true; + } + node = byId.get(parent); + if (node?.collapsed) { + return false; + } + } + return false; + }; + const elkNode = (node: RenderNode): ElkNode => { + const size = symbolSize(shapeOf(node.kind)) ?? labelSize(labelLines(node)); + const kids = node.collapsed ? [] : (children.get(node.id) ?? []); + const out: ElkNode = { id: node.id, width: size.width, height: size.height }; + if (kids.length > 0) { + out.children = kids.map(elkNode); + // A container's label sits above its children, so the top padding is its height. + out.layoutOptions = { + "elk.padding": `[top=${size.height},left=${CONTAINER_PAD},bottom=${CONTAINER_PAD},right=${CONTAINER_PAD}]`, + "elk.nodeSize.constraints": "MINIMUM_SIZE", + "elk.nodeSize.minimum": `(${size.width},${size.height})`, + }; + } + return out; + }; + const edges: ElkExtendedEdge[] = []; + (result.edges ?? []).forEach((edge, index) => { + if (edge.from === edge.to || !inGraph(edge.from) || !inGraph(edge.to)) { + return; + } + edges.push({ id: `e${index}`, sources: [edge.from], targets: [edge.to] }); + }); + const laid = await elk.layout({ + id: "__root__", + layoutOptions: { + "elk.algorithm": "layered", + "elk.hierarchyHandling": "INCLUDE_CHILDREN", + "elk.edgeRouting": "ORTHOGONAL", + "elk.direction": result.kind === "tree" || result.kind === "action" ? "DOWN" : "RIGHT", + "elk.spacing.nodeNode": `${GAP}`, + "elk.layered.spacing.nodeNodeBetweenLayers": `${GAP * 1.5}`, + "elk.spacing.edgeNode": `${GAP / 2}`, + "elk.layered.spacing.edgeNodeBetweenLayers": `${GAP / 2}`, + "elk.spacing.componentComponent": `${GAP}`, + // Edge section coordinates come back relative to the root, like a node's + // geometry relative to its parent, so they read as canvas coordinates. + "org.eclipse.elk.json.edgeCoords": "ROOT", + }, + children: (children.get(undefined) ?? []).map(elkNode), + edges, + }); + const placed = new Map(); + const walk = (node: ElkNode, ox: number, oy: number): void => { + for (const child of node.children ?? []) { + const x = ox + (child.x ?? 0); + const y = oy + (child.y ?? 0); + const geometry: LayoutGeometry = { + x: snap(x + MARGIN), + y: snap(y + MARGIN), + width: snap(child.width ?? 0), + height: snap(child.height ?? 0), + }; + if (byId.get(child.id)?.collapsed) { + geometry.collapsed = true; + } + placed.set(child.id, geometry); + walk(child, x, y); + } + }; + walk(laid, 0, 0); + const routes = new Map(); + for (const edge of laid.edges ?? []) { + const index = Number(edge.id.slice(1)); + if ((result.edges?.[index]?.route?.length ?? 0) > 0) { + continue; + } + const points: RenderPoint[] = []; + for (const section of edge.sections ?? []) { + points.push(section.startPoint, ...(section.bendPoints ?? []), section.endPoint); + } + if (points.length >= 2) { + routes.set(index, points.map((point) => ({ x: snap(point.x + MARGIN), y: snap(point.y + MARGIN) }))); + } + } + return { nodes: placed, routes }; +} diff --git a/editors/vscode/src/webview/diagram.ts b/editors/vscode/src/webview/diagram.ts index 6bd22e6439..bf448d6389 100644 --- a/editors/vscode/src/webview/diagram.ts +++ b/editors/vscode/src/webview/diagram.ts @@ -12,6 +12,7 @@ import { } from "../protocol"; import { type DiagramStyle, pilotLook, STYLE_LABELS, STYLES, styleOf } from "../style"; import { MenuCommand, MenuItem, nodeMenu, paletteItems } from "./actions"; +import { autoLayout, type AutoLayout } from "./autolayout"; import { cssEscape, drawCanvas, liftNode } from "./canvas"; import { dragHint, Drop, dropOn } from "./drop"; import { @@ -59,6 +60,8 @@ let style: DiagramStyle = styleOf(saved.style); let selectedNode: string | undefined; /** The layout on screen, which gestures act on; undefined while a table or nothing is shown. */ let layout: CanvasLayout | undefined; +/** What ELK placed for the rendering on screen; undefined until it answers, and for kinds it does not lay out. */ +let auto: AutoLayout | undefined; let gesture: Gesture | undefined; /** How far the pointer moves before a press becomes a drag rather than a click. */ const DRAG_THRESHOLD = 3; @@ -227,6 +230,7 @@ function draw(result: RenderResult): boolean { if (result.form === "mermaid") { // Mermaid is the machine form a diagram is exported in; the panel draws // the same nodes and edges itself, so their geometry is its own to edit. + auto = undefined; layout = layoutCanvas(result); show(layout); } else { @@ -240,6 +244,22 @@ function draw(result: RenderResult): boolean { diagram.classList.remove("stale"); showStatus(""); last = result; + if (result.form === "mermaid") { + // The grid answers at once; ELK's layered layout replaces it when it resolves. + showStatus("Laying out…"); + void autoLayout(result).then((laid) => { + if (last !== result) { + return; + } + showStatus(""); + if (!laid) { + return; + } + auto = laid; + layout = layoutCanvas(result, {}, auto); + show(layout); + }); + } remember(); showNotices(result); // An open menu names nodes of the drawing just replaced. @@ -357,7 +377,7 @@ function moveGesture(event: PointerEvent): void { drawDrag(event.shiftKey); return; } - showDragged(layoutCanvas(result, overridesOf(gesture.placements))); + showDragged(layoutCanvas(result, overridesOf(gesture.placements), auto)); } // showDragged puts the canvas a gesture has changed on screen. The pointer is captured by the @@ -381,7 +401,7 @@ function drawDrag(shift: boolean): void { const svg = showDragged(layout); liftNode(svg, layout, gesture.id, gesture.at.x - gesture.start.x, gesture.at.y - gesture.start.y); } else { - showDragged(layoutCanvas(last, overridesOf(gesture.placements))); + showDragged(layoutCanvas(last, overridesOf(gesture.placements), auto)); } previewDrop(shift); } diff --git a/editors/vscode/src/webview/layout.test.ts b/editors/vscode/src/webview/layout.test.ts index 0515661d4e..f3dfecba68 100644 --- a/editors/vscode/src/webview/layout.test.ts +++ b/editors/vscode/src/webview/layout.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import type { RenderEdge, RenderNode, RenderResult } from "../protocol"; +import type { AutoLayout } from "./autolayout"; import { anchor, GAP, @@ -447,3 +448,41 @@ test("overridesOf previews a gesture: the moved node and route show where the dr assert.deepEqual(preview.edges[0].route, []); assert.equal(preview.edges[0].points.length, 2); }); + +test("layoutCanvas takes an auto layout's geometry for nodes the model does not place", () => { + const auto: AutoLayout = { + nodes: new Map([ + ["a", { x: 100, y: 50, width: 140, height: 60 }], + ["b", { x: 300, y: 200, width: 140, height: 60 }], + ["c", { x: 500, y: 50, width: 140, height: 60 }], + ]), + routes: new Map([[0, [{ x: 240, y: 80 }, { x: 300, y: 80 }, { x: 300, y: 200 }, { x: 300, y: 230 }]]]), + }; + const result = rendering( + [node("a", "a"), node("b", "b"), node("c", "c", { x: 50, y: 400, width: 90, height: 50 })], + [{ from: "a", to: "b", label: "", kind: "connection", fqn: "M::ab" }, { from: "a", to: "c", label: "", kind: "connection", fqn: "M::ac" }], + ); + const layout = layoutCanvas(result, {}, auto); + const a = layout.nodes.get("a")!; + const b = layout.nodes.get("b")!; + const c = layout.nodes.get("c")!; + // The auto geometry is honored exactly, but does not pin the node. + assert.deepEqual(a.box, { x: 100, y: 50, width: 140, height: 60 }); + assert.equal(a.pinned, false); + assert.equal(b.pinned, false); + // The model's geometry wins over the auto layout and stays pinned. + assert.deepEqual(c.box, { x: 50, y: 400, width: 90, height: 50 }); + assert.equal(c.pinned, true); + // An edge between two auto-placed nodes follows the auto route verbatim: its + // anchors first and last, its inner points the edge's route. + assert.deepEqual(layout.edges[0].points, auto.routes.get(0)); + assert.deepEqual(layout.edges[0].route, auto.routes.get(0)!.slice(1, -1)); + // An edge at a node the model places is straight, the auto route void at it. + assert.equal(layout.edges[1].points.length, 2); + assert.deepEqual(layout.edges[1].route, []); + // A gesture wins over both, and the pinned end straightens the edge to it. + const preview = layoutCanvas(result, overridesOf({ nodes: [{ id: "a", layout: { x: 10, y: 10 } }], edges: [] }), auto); + assert.deepEqual([preview.nodes.get("a")!.box.x, preview.nodes.get("a")!.box.y], [10, 10]); + assert.equal(preview.nodes.get("a")!.pinned, true); + assert.equal(preview.edges[0].points.length, 2); +}); diff --git a/editors/vscode/src/webview/layout.ts b/editors/vscode/src/webview/layout.ts index f434753f8d..6ef4eaabbb 100644 --- a/editors/vscode/src/webview/layout.ts +++ b/editors/vscode/src/webview/layout.ts @@ -12,6 +12,7 @@ import { type RenderPoint, type RenderResult, } from "../protocol"; +import type { AutoLayout } from "./autolayout"; export interface Box { x: number; @@ -69,7 +70,7 @@ export interface CanvasLayout { } /** The rendering kinds whose renderers position nodes and route edges from DiagramLayout. */ -const PLACEABLE_KINDS = new Set(["tree", "interconnection", "state", "action"]); +export const PLACEABLE_KINDS = new Set(["tree", "interconnection", "state", "action"]); /** Geometry a gesture in progress shows in place of the model's, before the model says so. */ export interface Overrides { @@ -95,7 +96,7 @@ const MIN_WIDTH = 96; const MIN_HEIGHT = 40; /** Between slots of one grid, and between a container's border and its slots. */ export const GAP = 32; -const CONTAINER_PAD = 16; +export const CONTAINER_PAD = 16; /** The canvas's margin around the outermost boxes. */ export const MARGIN = 24; const POINT_SIZE = 12; @@ -114,8 +115,12 @@ export function snap(value: number): number { return Math.round(value); } -/** layoutCanvas places every node and routes every edge of the rendering. */ -export function layoutCanvas(result: RenderResult, overrides: Overrides = {}): CanvasLayout { +/** + * layoutCanvas places every node and routes every edge of the rendering. `auto` + * is what ELK laid out for the same rendering: it places a node the model does + * not, without pinning it, and routes an edge whose ends are not placed. + */ +export function layoutCanvas(result: RenderResult, overrides: Overrides = {}, auto?: AutoLayout): CanvasLayout { const placed = new Map(); const roots: PlacedNode[] = []; for (const node of result.nodes ?? []) { @@ -143,13 +148,19 @@ export function layoutCanvas(result: RenderResult, overrides: Overrides = {}): C if (result.kind === "sequence") { return layoutSequence(result, roots, placed); } - const geometry = (entry: PlacedNode): LayoutGeometry | undefined => { + const geometry = (entry: PlacedNode): NodeGeometry => { const override = overrides.nodes?.get(entry.node.id); if (override) { - return override; + return { stated: override, pinned: true }; } const { x, y, width, height, collapsed } = entry.node; - return x !== undefined && y !== undefined ? { x, y, width, height, collapsed } : undefined; + if (x !== undefined && y !== undefined) { + return { stated: { x, y, width, height, collapsed }, pinned: true }; + } + // The auto layout's geometry is stated but not pinned: it is a guess, so + // the node keeps a grid slot for a layout that runs without it. + const laid = auto?.nodes.get(entry.node.id); + return laid !== undefined ? { stated: { ...laid, collapsed }, pinned: false } : { pinned: false }; }; placeGrid(roots, { x: MARGIN, y: MARGIN }, geometry); @@ -169,7 +180,7 @@ export function layoutCanvas(result: RenderResult, overrides: Overrides = {}): C reach(entry.box.x, entry.box.y); reach(entry.box.x + entry.box.width, entry.box.y + entry.box.height); } - const edges = (result.edges ?? []).map((edge, index) => routeEdge(edge, index, placed, overrides.routes)); + const edges = (result.edges ?? []).map((edge, index) => routeEdge(edge, index, placed, overrides.routes, auto)); for (const edge of edges) { if (edge.hidden) { continue; @@ -233,7 +244,13 @@ function headHeight(roots: PlacedNode[]): number { return roots.reduce((max, root) => Math.max(max, labelSize(root.lines).height), 0); } -type Geometry = (entry: PlacedNode) => LayoutGeometry | undefined; +/** Where a node goes and whether the model or a gesture, rather than a guess, states it. */ +interface NodeGeometry { + stated?: LayoutGeometry; + pinned: boolean; +} + +type Geometry = (entry: PlacedNode) => NodeGeometry; // placeGrid puts entries in a near-square grid from origin, in order, each column // as wide and each row as tall as its widest and tallest entry. An unsized box's @@ -275,8 +292,8 @@ function spread(lanes: PlacedNode[][], start: number, placeAt: (entry: PlacedNod // model's width, else its label's, widened to hold every child shown. The columns // of its children are settled first, from inside its padding. function placeAcross(entry: PlacedNode, slot: number, geometry: Geometry): number { - const stated = geometry(entry); - entry.pinned = stated !== undefined; + const { stated, pinned } = geometry(entry); + entry.pinned = pinned; entry.collapsed = stated?.collapsed === true; if (entry.collapsed) { hide(entry.children); @@ -300,7 +317,7 @@ function placeAcross(entry: PlacedNode, slot: number, geometry: Geometry): numbe // placeDown settles a node's y and height as placeAcross does its x and width; // the rows of its children start below its label. function placeDown(entry: PlacedNode, slot: number, geometry: Geometry): number { - const stated = geometry(entry); + const { stated } = geometry(entry); entry.box.y = stated?.y ?? slot; const shown = shownChildren(entry, stated); const columns = Math.max(1, Math.ceil(Math.sqrt(shown.length))); @@ -356,7 +373,7 @@ function labelHead(node: RenderNode): string { } // labelSize is the box a label needs, its head in bold glyphs. -function labelSize(lines: string[]): { width: number; height: number } { +export function labelSize(lines: string[]): { width: number; height: number } { let width = 0; lines.forEach((line, i) => { width = Math.max(width, [...line].length * (i === 0 ? BOLD_GLYPH_WIDTH : GLYPH_WIDTH)); @@ -393,7 +410,7 @@ export function shapeOf(kind: string): Shape { } // symbolSize is a symbol's fixed size; undefined for a label box. -function symbolSize(shape: Shape): { width: number; height: number } | undefined { +export function symbolSize(shape: Shape): { width: number; height: number } | undefined { switch (shape) { case "point": return { width: POINT_SIZE, height: POINT_SIZE }; @@ -411,15 +428,33 @@ function symbolSize(shape: Shape): { width: number; height: number } | undefined // routeEdge is an edge's polyline: from the border of its source, through the // route's waypoints, to the border of its target; a self-loop swings out to the right. +// The auto layout's route applies only where neither end is placed, since an end +// the model or a gesture moved is where the route was computed around it. function routeEdge( edge: RenderEdge, index: number, placed: Map, routes: Map | undefined, + auto?: AutoLayout, ): PlacedEdge { - const route = (routes?.has(index) ? routes.get(index) : edge.route) ?? []; + const stated = routes?.has(index) ? routes.get(index) : edge.route; const source = placed.get(edge.from); const target = placed.get(edge.to); + const routed = + stated === undefined && source?.pinned === false && target?.pinned === false ? auto?.routes.get(index) : undefined; + if (routed !== undefined && routed.length >= 2) { + // ELK's anchors already lie on the boxes' borders, so its polyline is drawn + // verbatim and a drag edits only the inner waypoints. + return { + edge, + index, + points: routed, + route: routed.slice(1, -1), + label: midpoint(routed), + hidden: source?.hidden === true || target?.hidden === true, + }; + } + const route = stated ?? []; const from = source?.box ?? { x: 0, y: 0, width: 0, height: 0 }; const to = target?.box ?? { x: 0, y: 0, width: 0, height: 0 }; let inner = route; diff --git a/editors/vscode/src/webview/tsconfig.json b/editors/vscode/src/webview/tsconfig.json index f521932842..f3c9debf62 100644 --- a/editors/vscode/src/webview/tsconfig.json +++ b/editors/vscode/src/webview/tsconfig.json @@ -4,6 +4,7 @@ "module": "ESNext", "moduleResolution": "Bundler", "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, "outDir": "../../out/webview" }, // The extension project excludes this directory; that exclusion is inherited diff --git a/editors/vscode/tsconfig.json b/editors/vscode/tsconfig.json index 3100176237..e0700b10a0 100644 --- a/editors/vscode/tsconfig.json +++ b/editors/vscode/tsconfig.json @@ -11,6 +11,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, + "skipLibCheck": true, "sourceMap": true }, "include": ["src"], From 785fbd01cc966c8301994ae57da112e369dae506 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:58:11 +0000 Subject: [PATCH 2/5] fix(vscode): space ELK layers inside containers Co-Authored-By: jason.han --- editors/vscode/src/webview/autolayout.test.ts | 5 +++- editors/vscode/src/webview/autolayout.ts | 24 +++++++++++++------ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/editors/vscode/src/webview/autolayout.test.ts b/editors/vscode/src/webview/autolayout.test.ts index a86cba3ad0..dfdcae53ad 100644 --- a/editors/vscode/src/webview/autolayout.test.ts +++ b/editors/vscode/src/webview/autolayout.test.ts @@ -3,7 +3,7 @@ import { test } from "node:test"; import type { RenderEdge, RenderNode, RenderPoint, RenderResult } from "../protocol"; import { AUTO_LAYOUT_LIMIT, autoLayout, type AutoLayout } from "./autolayout"; -import type { Box } from "./layout"; +import { GAP, type Box } from "./layout"; const origin = { uri: "file:///m.sysml", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 4 } }, digest: "d0" }; @@ -88,6 +88,9 @@ test("autoLayout holds a container's children inside it and reports absolute edg assert.ok(child.x >= p.x && child.x + child.width <= p.x + p.width); assert.ok(child.y >= p.y && child.y + child.height <= p.y + p.height); } + const horizontalGap = Math.max(b.x - (a.x + a.width), a.x - (b.x + b.width)); + const verticalGap = Math.max(b.y - (a.y + a.height), a.y - (b.y + b.height)); + assert.ok(horizontalGap >= GAP || verticalGap >= GAP); // The routes are absolute canvas coordinates: they end on c's border directly. for (const route of [laid.routes.get(0)!, laid.routes.get(1)!]) { assert.ok(onBorder(route.at(-1)!, c)); diff --git a/editors/vscode/src/webview/autolayout.ts b/editors/vscode/src/webview/autolayout.ts index aee90ea476..82aae95c1b 100644 --- a/editors/vscode/src/webview/autolayout.ts +++ b/editors/vscode/src/webview/autolayout.ts @@ -73,6 +73,7 @@ async function layOut(result: RenderResult): Promise { } return false; }; + const options = spacingOptions(result.kind); const elkNode = (node: RenderNode): ElkNode => { const size = symbolSize(shapeOf(node.kind)) ?? labelSize(labelLines(node)); const kids = node.collapsed ? [] : (children.get(node.id) ?? []); @@ -81,6 +82,7 @@ async function layOut(result: RenderResult): Promise { out.children = kids.map(elkNode); // A container's label sits above its children, so the top padding is its height. out.layoutOptions = { + ...options, "elk.padding": `[top=${size.height},left=${CONTAINER_PAD},bottom=${CONTAINER_PAD},right=${CONTAINER_PAD}]`, "elk.nodeSize.constraints": "MINIMUM_SIZE", "elk.nodeSize.minimum": `(${size.width},${size.height})`, @@ -98,15 +100,9 @@ async function layOut(result: RenderResult): Promise { const laid = await elk.layout({ id: "__root__", layoutOptions: { + ...options, "elk.algorithm": "layered", "elk.hierarchyHandling": "INCLUDE_CHILDREN", - "elk.edgeRouting": "ORTHOGONAL", - "elk.direction": result.kind === "tree" || result.kind === "action" ? "DOWN" : "RIGHT", - "elk.spacing.nodeNode": `${GAP}`, - "elk.layered.spacing.nodeNodeBetweenLayers": `${GAP * 1.5}`, - "elk.spacing.edgeNode": `${GAP / 2}`, - "elk.layered.spacing.edgeNodeBetweenLayers": `${GAP / 2}`, - "elk.spacing.componentComponent": `${GAP}`, // Edge section coordinates come back relative to the root, like a node's // geometry relative to its parent, so they read as canvas coordinates. "org.eclipse.elk.json.edgeCoords": "ROOT", @@ -149,3 +145,17 @@ async function layOut(result: RenderResult): Promise { } return { nodes: placed, routes }; } + +// spacingOptions is shared by the root and every compound node so nested +// layers have the same direction, edge routing, and label-friendly gaps. +function spacingOptions(kind: string): Record { + return { + "elk.edgeRouting": "ORTHOGONAL", + "elk.direction": kind === "tree" || kind === "action" ? "DOWN" : "RIGHT", + "elk.spacing.nodeNode": `${GAP}`, + "elk.layered.spacing.nodeNodeBetweenLayers": `${GAP * 2}`, + "elk.spacing.edgeNode": `${GAP / 2}`, + "elk.layered.spacing.edgeNodeBetweenLayers": `${GAP / 2}`, + "elk.spacing.componentComponent": `${GAP}`, + }; +} From 5ab97512d17509101e3018f726bfa3c64cadd5f1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:11:29 +0000 Subject: [PATCH 3/5] fix(vscode): keep the auto layout consistent with placed nodes Co-Authored-By: jason.han --- editors/vscode/src/webview/autolayout.test.ts | 40 ++++++++ editors/vscode/src/webview/autolayout.ts | 99 +++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/editors/vscode/src/webview/autolayout.test.ts b/editors/vscode/src/webview/autolayout.test.ts index dfdcae53ad..6187a47cad 100644 --- a/editors/vscode/src/webview/autolayout.test.ts +++ b/editors/vscode/src/webview/autolayout.test.ts @@ -122,3 +122,43 @@ test("autoLayout lays an interconnection out left to right", async () => { assert.ok(laid); assert.ok(boxOf(laid, "a").x < boxOf(laid, "b").x); }); + +test("autoLayout moves an unplaced subtree with the container the model places", async () => { + const result = rendering( + [node("p", "p", { x: 500, y: 400, width: 300, height: 200 }), node("a", "a", { parent: "p" }), node("b", "b", { parent: "p" })], + [edge("a", "b")], + { kind: "interconnection" }, + ); + const laid = await autoLayout(result); + assert.ok(laid); + const p = boxOf(laid, "p"); + assert.deepEqual(p, { x: 500, y: 400, width: 300, height: 200 }); + for (const child of [boxOf(laid, "a"), boxOf(laid, "b")]) { + assert.ok(child.x >= p.x && child.x + child.width <= p.x + p.width); + assert.ok(child.y >= p.y && child.y + child.height <= p.y + p.height); + } + for (const point of laid.routes.get(0)!) { + assert.ok(point.x >= p.x && point.x <= p.x + p.width && point.y >= p.y && point.y <= p.y + p.height); + } +}); + +test("autoLayout grows an unplaced container around a child the model places elsewhere", async () => { + const result = rendering([node("q", "q"), node("a", "a", { parent: "q", x: 700, y: 50 })]); + const laid = await autoLayout(result); + assert.ok(laid); + const q = boxOf(laid, "q"); + const a = boxOf(laid, "a"); + assert.deepEqual([a.x, a.y], [700, 50]); + assert.ok(a.x >= q.x && a.x + a.width <= q.x + q.width); + assert.ok(a.y >= q.y && a.y + a.height <= q.y + q.height); +}); + +test("autoLayout drops the route of an edge crossing a placed container's border", async () => { + const result = rendering( + [node("p", "p", { x: 500, y: 400, width: 300, height: 200 }), node("a", "a", { parent: "p" }), node("c", "c")], + [edge("a", "c")], + ); + const laid = await autoLayout(result); + assert.ok(laid); + assert.equal(laid.routes.has(0), false); +}); diff --git a/editors/vscode/src/webview/autolayout.ts b/editors/vscode/src/webview/autolayout.ts index 82aae95c1b..115bb5e0a6 100644 --- a/editors/vscode/src/webview/autolayout.ts +++ b/editors/vscode/src/webview/autolayout.ts @@ -143,9 +143,108 @@ async function layOut(result: RenderResult): Promise { routes.set(index, points.map((point) => ({ x: snap(point.x + MARGIN), y: snap(point.y + MARGIN) }))); } } + reconcile(result, children, placed, routes); return { nodes: placed, routes }; } +// reconcile puts ELK's picture and the model's geometry together: the subtree +// under a node the model places shifts to the model's place (a stated +// descendant keeps its own stated place on its turn, outermost first), an edge +// within the subtree moves with it, and one crossing its border loses the route +// so it is drawn straight. Then an unplaced container grows — never shrinks — +// to cover shown children a placed sibling carried away; a route at a grown +// border may sit slightly off the edge it hugged, which reads fine. +function reconcile( + result: RenderResult, + children: Map, + placed: Map, + routes: Map, +): void { + const nodes = result.nodes ?? []; + const byId = new Map(nodes.map((node) => [node.id, node])); + const depth = (node: RenderNode): number => { + let d = 0; + let at = node; + while (at.parent !== undefined && at.parent !== at.id && byId.has(at.parent)) { + d++; + at = byId.get(at.parent)!; + } + return d; + }; + const subtree = (id: string): Set => { + const inside = new Set([id]); + const stack = [id]; + while (stack.length > 0) { + for (const child of children.get(stack.pop()!) ?? []) { + if (placed.has(child.id) && !inside.has(child.id)) { + inside.add(child.id); + stack.push(child.id); + } + } + } + return inside; + }; + for (const node of nodes + .filter((n) => n.x !== undefined && n.y !== undefined && placed.has(n.id)) + .sort((a, b) => depth(a) - depth(b))) { + const geometry = placed.get(node.id)!; + const dx = node.x! - geometry.x; + const dy = node.y! - geometry.y; + const inside = subtree(node.id); + for (const id of inside) { + if (id === node.id) { + continue; + } + const moved = placed.get(id)!; + moved.x += dx; + moved.y += dy; + } + geometry.x = node.x!; + geometry.y = node.y!; + if (node.width !== undefined) { + geometry.width = node.width; + } + if (node.height !== undefined) { + geometry.height = node.height; + } + for (const [index, points] of [...routes]) { + const edge = result.edges![index]; + const from = inside.has(edge.from); + const to = inside.has(edge.to); + if (from && to) { + routes.set(index, points.map((point) => ({ x: snap(point.x + dx), y: snap(point.y + dy) }))); + } else if (from || to) { + routes.delete(index); + } + } + } + for (const node of nodes + .filter((n) => placed.has(n.id) && (n.x === undefined || n.y === undefined) && !n.collapsed) + .sort((a, b) => depth(b) - depth(a))) { + const kids = (children.get(node.id) ?? []).filter((child) => placed.has(child.id)); + if (kids.length === 0) { + continue; + } + const geometry = placed.get(node.id)!; + const header = symbolSize(shapeOf(node.kind)) ?? labelSize(labelLines(node)); + let left = geometry.x; + let top = geometry.y; + let right = geometry.x + (geometry.width ?? 0); + let bottom = geometry.y + (geometry.height ?? 0); + for (const child of kids) { + const box = placed.get(child.id)!; + left = Math.min(left, box.x - CONTAINER_PAD); + top = Math.min(top, box.y - header.height); + right = Math.max(right, box.x + (box.width ?? 0) + CONTAINER_PAD); + bottom = Math.max(bottom, box.y + (box.height ?? 0) + CONTAINER_PAD); + } + geometry.x = left; + geometry.y = top; + geometry.width = right - left; + geometry.height = bottom - top; + } +} + // spacingOptions is shared by the root and every compound node so nested // layers have the same direction, edge routing, and label-friendly gaps. function spacingOptions(kind: string): Record { From 71efd9c01e57693cc0a9beb691138757a5031469 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:13:00 +0000 Subject: [PATCH 4/5] refactor(vscode): shorten the reconcile comment Co-Authored-By: jason.han --- editors/vscode/src/webview/autolayout.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/editors/vscode/src/webview/autolayout.ts b/editors/vscode/src/webview/autolayout.ts index 115bb5e0a6..ce952cb780 100644 --- a/editors/vscode/src/webview/autolayout.ts +++ b/editors/vscode/src/webview/autolayout.ts @@ -147,13 +147,8 @@ async function layOut(result: RenderResult): Promise { return { nodes: placed, routes }; } -// reconcile puts ELK's picture and the model's geometry together: the subtree -// under a node the model places shifts to the model's place (a stated -// descendant keeps its own stated place on its turn, outermost first), an edge -// within the subtree moves with it, and one crossing its border loses the route -// so it is drawn straight. Then an unplaced container grows — never shrinks — -// to cover shown children a placed sibling carried away; a route at a grown -// border may sit slightly off the edge it hugged, which reads fine. +// reconcile moves each placed node's subtree to the model's place (outermost first), keeps routes +// inside it and drops those crossing its border; then unplaced containers grow to cover their children. function reconcile( result: RenderResult, children: Map, From d759d84325336be4598c9ad95b7744127d6a6340 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:20:37 +0000 Subject: [PATCH 5/5] fix(vscode): drop routes at a container the auto layout grew Co-Authored-By: jason.han --- editors/vscode/src/webview/autolayout.test.ts | 10 +++++++++- editors/vscode/src/webview/autolayout.ts | 13 ++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/editors/vscode/src/webview/autolayout.test.ts b/editors/vscode/src/webview/autolayout.test.ts index 6187a47cad..c0f5d6fb5e 100644 --- a/editors/vscode/src/webview/autolayout.test.ts +++ b/editors/vscode/src/webview/autolayout.test.ts @@ -143,7 +143,10 @@ test("autoLayout moves an unplaced subtree with the container the model places", }); test("autoLayout grows an unplaced container around a child the model places elsewhere", async () => { - const result = rendering([node("q", "q"), node("a", "a", { parent: "q", x: 700, y: 50 })]); + const result = rendering( + [node("q", "q"), node("a", "a", { parent: "q", x: 700, y: 50 }), node("b", "b"), node("c", "c")], + [edge("q", "b"), edge("a", "b"), edge("c", "b")], + ); const laid = await autoLayout(result); assert.ok(laid); const q = boxOf(laid, "q"); @@ -151,6 +154,11 @@ test("autoLayout grows an unplaced container around a child the model places els assert.deepEqual([a.x, a.y], [700, 50]); assert.ok(a.x >= q.x && a.x + a.width <= q.x + q.width); assert.ok(a.y >= q.y && a.y + a.height <= q.y + q.height); + // The grown box's own route is dropped so the edge anchors on its new border. + assert.equal(laid.routes.has(0), false); + // So is a route at the placed child, which the model positions. + assert.equal(laid.routes.has(1), false); + assert.ok(laid.routes.has(2)); }); test("autoLayout drops the route of an edge crossing a placed container's border", async () => { diff --git a/editors/vscode/src/webview/autolayout.ts b/editors/vscode/src/webview/autolayout.ts index ce952cb780..6a13b6ca25 100644 --- a/editors/vscode/src/webview/autolayout.ts +++ b/editors/vscode/src/webview/autolayout.ts @@ -148,7 +148,8 @@ async function layOut(result: RenderResult): Promise { } // reconcile moves each placed node's subtree to the model's place (outermost first), keeps routes -// inside it and drops those crossing its border; then unplaced containers grow to cover their children. +// inside it and drops those crossing its border; then unplaced containers grow to cover their +// children, their own routes dropped. function reconcile( result: RenderResult, children: Map, @@ -233,10 +234,20 @@ function reconcile( right = Math.max(right, box.x + (box.width ?? 0) + CONTAINER_PAD); bottom = Math.max(bottom, box.y + (box.height ?? 0) + CONTAINER_PAD); } + if (left === geometry.x && top === geometry.y && right - left === geometry.width && bottom - top === geometry.height) { + continue; + } geometry.x = left; geometry.y = top; geometry.width = right - left; geometry.height = bottom - top; + // The box moved, so routes anchored on its old border are dropped; they are drawn straight. + for (const [index] of [...routes]) { + const edge = result.edges![index]; + if (edge.from === node.id || edge.to === node.id) { + routes.delete(index); + } + } } }