diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 00de5b4b45..5d10ff7c5a 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -45,6 +45,13 @@ jobs: go-version-file: go.mod cache: true + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: editors/vscode/package-lock.json + - name: Install documentation toolchain run: make docs-install @@ -56,6 +63,9 @@ jobs: - name: Build the in-browser engine run: make docs-engine-assets + - name: Build the landing-page diagram + run: make docs-landing-assets + - name: Build documentation site run: make docs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 20b5372009..0146caa2b0 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -961,6 +961,10 @@ jobs: - name: Package the VS Code extension run: make vscode-package + - name: Build landing-page diagram bundle + working-directory: editors/vscode + run: npm run build:landing + - name: Upload VS Code extension artifact uses: actions/upload-artifact@v4 with: @@ -1027,9 +1031,19 @@ jobs: go-version-file: go.mod cache: true + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: editors/vscode/package-lock.json + - name: Install documentation toolchain run: make docs-install + - name: Build the landing-page diagram + run: make docs-landing-assets + - name: Check documentation links run: python3 scripts/check-doc-links.py diff --git a/.gitignore b/.gitignore index a33f3f965b..6b024f6a6c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ /docs/assets/sysml-repl.wasm* /docs/assets/repl-examples/ /docs/assets/wasm_exec.js +/docs/assets/landing/ # Julia package environments resolve locally; the pinned deps are in Project.toml. client/julia/**/Manifest.toml diff --git a/Makefile b/Makefile index 0d36f5733d..b1744d978b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build build-sysml build-prod build-wasm-prod build-lsp build-grpc build-engine build-core build-syntax build-sysml-wasm build-release-wasm build-wasm build-wasm-wasip1 build-wasm-js wasm-check static-check windows-versioninfo-check man man-check install-tree pgo-profile conformance conformance-pkg conformance-rust conformance-julia conformance-matlab test test-shard coverage lint clean install help ontology-table ontology-table-check python-metamodel python-metamodel-check fuml-expected python-test python-coverage scripts-coverage node-coverage python-install proto proto-buf python-proto proto-ts proto-rust proto-lint proto-breaking vscode-grammar vscode-build vscode-package docs docs-install docs-serve docs-engine-assets docs-counts docs-check changelog-check changelog-render self-model +.PHONY: all build build-sysml build-prod build-wasm-prod build-lsp build-grpc build-engine build-core build-syntax build-sysml-wasm build-release-wasm build-wasm build-wasm-wasip1 build-wasm-js wasm-check static-check windows-versioninfo-check man man-check install-tree pgo-profile conformance conformance-pkg conformance-rust conformance-julia conformance-matlab test test-shard coverage lint clean install help ontology-table ontology-table-check python-metamodel python-metamodel-check fuml-expected python-test python-coverage scripts-coverage node-coverage python-install proto proto-buf python-proto proto-ts proto-rust proto-lint proto-breaking vscode-grammar vscode-build vscode-package docs docs-install docs-serve docs-engine-assets docs-landing-assets docs-counts docs-check changelog-check changelog-render self-model # Version information # Only release tags describe a build; the moving `nightly` tag is not a version. @@ -526,7 +526,7 @@ docs: ## Build the documentation site, failing on a broken link $(PYTHON) -m mkdocs build --strict --site-dir $(SITE_DIR) @echo "✓ Built $(SITE_DIR)/" -# The cli page's in-browser REPL fetches these two assets; they are built, never +# The cli page's in-browser REPL fetches these assets; they are built, never # vendored. The Pages job runs this target before `docs`; a local `make docs` or # `docs-serve` preview wants the same target first or the REPL cannot load. docs-engine-assets: ## Build the in-browser engine assets into docs/assets @@ -540,7 +540,12 @@ docs-engine-assets: ## Build the in-browser engine assets into docs/assets @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" docs/assets/ @echo "✓ Built docs/assets/sysml-engine.wasm.gz, sysml-repl.wasm.gz, repl-examples/ + wasm_exec.js" -docs-serve: docs-install ## Serve the documentation site with live reload +# The landing page's diagram and router assets are built from the extension's +# source rather than vendored; Pages and local previews need both bundles. +docs-landing-assets: ## Build the landing-page diagram assets into docs/assets + cd $(VSCODE_DIR) && npm ci && npm run build:landing + +docs-serve: docs-install ## Serve docs with live reload (build docs-engine-assets and docs-landing-assets first) $(PYTHON) -m mkdocs serve --strict help: ## Show this help message diff --git a/changes/unreleased/diagram-port-route-gap.fixed.md b/changes/unreleased/diagram-port-route-gap.fixed.md new file mode 100644 index 0000000000..a3cbc09ce8 --- /dev/null +++ b/changes/unreleased/diagram-port-route-gap.fixed.md @@ -0,0 +1,2 @@ +- **Wires laid out automatically meet their port squares.** They stopped half a port short of the square in the VS Code diagram panel and on the landing page. +- **Landing diagram boxes leave room for every shared wire lane, and rerouted wires stay inside the hero.** diff --git a/changes/unreleased/diagram-short-jogs.changed.md b/changes/unreleased/diagram-short-jogs.changed.md new file mode 100644 index 0000000000..4aec91219d --- /dev/null +++ b/changes/unreleased/diagram-short-jogs.changed.md @@ -0,0 +1 @@ +- **Rerouted wires no longer take bends shorter than one grid square** where a straighter route is free, in the VS Code diagram panel and on the landing page. diff --git a/changes/unreleased/landing-rendered-diagram.changed.md b/changes/unreleased/landing-rendered-diagram.changed.md new file mode 100644 index 0000000000..79898cd8fe --- /dev/null +++ b/changes/unreleased/landing-rendered-diagram.changed.md @@ -0,0 +1 @@ +- **Render the landing-page diagram from the engine-backed model.** The site build bundles the shared diagram code and its routing assets, with wires rerouting as boxes move and dropped boxes kept clear of each other. A dropped box whose port lands just out of line with a wired port lines up with it, so the wire runs straight. Wires keep a right-angled route while a box is dragged too close for libavoid to route around. diff --git a/docs/assets/opensysml-stack.sysml b/docs/assets/opensysml-stack.sysml index c1b56971de..453e5c9bdb 100644 --- a/docs/assets/opensysml-stack.sysml +++ b/docs/assets/opensysml-stack.sysml @@ -61,18 +61,23 @@ package OpenSysMLStack { // Where one model change travels: written and run in OpenSysML, committed // to Flexo, pulled into sysml-toolkit and pushed back, then checked // against the OMG reference. Each state is named for the part it is at. + item def Commit; + item def Pull; + item def Check; + item def Push; + state def ModelJourney { entry; then opensysml; state opensysml; - transition commit_change first opensysml accept commit then flexo; + transition commit_change first opensysml accept Commit then flexo; state flexo; - transition pull_change first flexo accept pull then toolkit; - transition check_change first flexo accept check then pilot; + transition pull_change first flexo accept Pull then toolkit; + transition check_change first flexo accept Check then pilot; state toolkit; - transition push_change first toolkit accept push then flexo; + transition push_change first toolkit accept Push then flexo; state pilot; } diff --git a/docs/project/third-party-notices.md b/docs/project/third-party-notices.md new file mode 100644 index 0000000000..7f89da9cd3 --- /dev/null +++ b/docs/project/third-party-notices.md @@ -0,0 +1,37 @@ +# Third-party notices + +The landing-page diagram uses these third-party packages: + +- **elkjs** 0.12.0 — licensed under EPL-2.0 OR GPL-3.0-or-later. Source: + https://github.com/kieler/elkjs +- **libavoid-js** 0.4.5 — licensed under LGPL-2.1-or-later. The full + [licence text](../assets/landing/libavoid-js.LICENSE.txt) ships with the + landing-page assets. The licence covers both: + - `assets/landing/libavoid.wasm`: libavoid compiled to WebAssembly; + - the libavoid-js JavaScript bindings compiled into + `assets/landing/diagram.js`. + + Copyright: + - libavoid: Copyright (C) 2004–2015 Monash University; authors Michael + Wybrow, Tim Dwyer and Vladyslav Hnatiuk. Its geometry code is partly based + on "Computational Geometry in C" (Second Edition), Copyright (C) 1998 + Joseph O'Rourke. + - libavoid-js: Vladyslav Hnatiuk. + + Source: + - libavoid-js 0.4.5: + https://github.com/Aksem/libavoid-js/tree/v0.4.5 (commit + `28a6fe20c2f1f809afdbeca8d789e65357614c4f`). + - libavoid as that release builds it (`ADAPTAGRAMS_VERSION = "1.0.4"` in + `tools/generate.py`): `cola/libavoid` in + https://github.com/Aksem/adaptagrams/tree/v1.0.4 (commit + `dd3236536399e5297b353d02c0b751eeaaf63aee`), a fork of + https://github.com/mjwybrow/adaptagrams. + Before compiling, libavoid-js's build (`tools/generate.py` at v0.4.5) adds + two forward declarations, `class Router;` and + `struct HyperedgeNewAndDeletedObjectLists;`, to + `cola/libavoid/hyperedgeimprover.h`; no other libavoid source is changed. + +To use modified libavoid sources, replace `assets/landing/libavoid.wasm` with +one built against the libavoid-js 0.4.5 bindings. To modify the JavaScript +bindings, rebuild `diagram.js` from source with `make docs-landing-assets`. diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 9c384c6372..766d8ecb87 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -212,8 +212,9 @@ "scripts": { "grammar": "go run ./tools/gengrammar -out syntaxes", "build": "node esbuild.mjs", + "build:landing": "node tools/build-landing.mjs", "watch": "node esbuild.mjs --watch", - "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p src/webview/tsconfig.json", + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p src/webview/tsconfig.json && tsc --noEmit -p src/landing/tsconfig.json", "test": "node tools/test.mjs", "package": "npm run typecheck && npm test && npm run build -- --production && vsce package --no-dependencies --out opensysml-sysml.vsix" }, diff --git a/editors/vscode/src/landing/main.ts b/editors/vscode/src/landing/main.ts new file mode 100644 index 0000000000..1fefd3d548 --- /dev/null +++ b/editors/vscode/src/landing/main.ts @@ -0,0 +1,1042 @@ +// The landing page's hero diagram: the stack model as the engine renders it, laid out +// by ELK, its wires rerouted by libavoid around the boxes a visitor moves, drawn by +// the same canvas code as the VS Code diagram panel. +import type { LayoutGeometry, RenderPoint, RenderResult } from "../protocol"; +import { autoLayout, type AutoLayout } from "../webview/autolayout"; +import { CLEARANCE, loadAvoid, portExitReach } from "../webview/avoid"; +import { cssEscape, drawCanvas } from "../webview/canvas"; +import { + alignedPlacement, + clampNodeToBounds, + freePlacement, + keepOrthogonalRoutes, + layoutCanvas, + type Box, + type CanvasLayout, + type Overrides, + type PlacedNode, + type PlacedPort, +} from "../webview/layout"; +import { + JOURNEY_EVENTS, + journey, + landingModel, + readModel, + type Diagnostic, + type EngineClient, + type EngineInstance, + type LandingModel, + type LandingPart, +} from "./model"; +import { presented } from "./present"; +import stack from "./stack.json"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +/** How far a box stays inside the hero's edges. */ +const HERO_PAD = 8; +/** How far a pointer travels before a press becomes a drag. */ +const DRAG_SLOP = 4; +const KEY_STEP = 10; +const LONG_PRESS = 550; +const EDIT_DELAY = 300; +const MAX_SCALE = 1.6; +const HOP = 1000; + +function insetBox(box: Box, distance: number): Box { + return { + x: box.x + distance, + y: box.y + distance, + width: Math.max(0, box.width - 2 * distance), + height: Math.max(0, box.height - 2 * distance), + }; +} + +interface Mounted { + dispose(): void; +} + +interface EngineUrls { + wasmExec: string; + engine: string; +} + +declare global { + interface Window { + osmlLoadEngine?(urls: EngineUrls): Promise; + osmlMountDiagram?(root: HTMLElement): void; + __osmlDiagram?: Mounted; + document$?: { subscribe(next: () => void): { unsubscribe(): void } }; + } +} + +interface Gesture { + id: string; + pointer: number; + start: RenderPoint; + from: RenderPoint; + at: RenderPoint; + moved: boolean; + longPressed: boolean; + frame?: number; + timer?: ReturnType; +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function diagnosticText(diagnostic: Diagnostic): string { + const at = diagnostic.line === undefined ? "" : `line ${diagnostic.line}:${diagnostic.column ?? 1}: `; + return `${at}${diagnostic.message || "error"}`; +} + +function webHref(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:" ? url.href : undefined; + } catch { + return undefined; + } +} + +function overlap(a: DOMRect | Box, b: DOMRect | Box): number { + const ax = "left" in a ? a.left : a.x; + const ay = "top" in a ? a.top : a.y; + const bx = "left" in b ? b.left : b.x; + const by = "top" in b ? b.top : b.y; + const w = Math.min(ax + a.width, bx + b.width) - Math.max(ax, bx); + const h = Math.min(ay + a.height, by + b.height) - Math.max(ay, by); + return w > 0 && h > 0 ? w * h : 0; +} + +function mount(root: HTMLElement): Mounted { + window.__osmlDiagram?.dispose(); + const hero = root.closest(".osml-hero") ?? root; + const stage = root.querySelector("[data-osml-stage]")!; + const card = hero.querySelector("[data-osml-card]")!; + const statusEl = root.querySelector("[data-osml-status]")!; + const runBtn = root.querySelector("[data-osml-run]")!; + const srcBtn = root.querySelector("[data-osml-source]")!; + const editor = root.querySelector("[data-osml-editor]")!; + const srcEl = root.querySelector("[data-osml-src]")!; + const resetBtn = root.querySelector("[data-osml-reset]")!; + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + const ac = new AbortController(); + const { signal } = ac; + const on = ( + target: HTMLElement | Document | Window | SVGElement, + type: K, + listener: (event: HTMLElementEventMap[K]) => void, + ): void => target.addEventListener(type, listener as EventListener, { signal }); + + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("class", "osml-diagram"); + svg.setAttribute("role", "group"); + svg.setAttribute("aria-label", "The OpenSysML stack, drawn from its SysML model"); + const content = document.createElementNS(SVG_NS, "g"); + svg.append(content); + hero.append(svg); + + let model = landingModel(stack.hash, stack.render as unknown as RenderResult, stack.instances as unknown as EngineInstance[]); + let result = presented(model); + let live = false; + let goodSource: string | undefined; + let auto: AutoLayout | undefined; + let view = { x: 0, y: 0, scale: 1 }; + let layout = layoutCanvas(result, { bounds: bounds() }); + let generation = 0; + let laidOut = false; + // Where the visitor has put a box, by part name, so a placement survives an edit. + const placed = new Map(); + let gesture: Gesture | undefined; + let cardFor: string | undefined; + let hot: string | undefined; + let liveNode: string | undefined; + let liveEdge: number | undefined; + let token: SVGCircleElement | undefined; + let running = false; + let editTimer: ReturnType | undefined; + let editSequence = 0; + let enginePromise: Promise | undefined; + let sourcePromise: Promise | undefined; + + const partOf = (id: string): LandingPart | undefined => [...model.parts.values()].find((part) => part.id === id); + const idOf = (feature: string): string | undefined => model.parts.get(feature)?.id; + + function status(text: string, error = false): void { + statusEl.textContent = text; + statusEl.classList.toggle("osml-schematic__status--err", error); + } + + function overrides(): Overrides { + const nodes = new Map(); + for (const [feature, at] of placed) { + const id = idOf(feature); + if (id) { + nodes.set(id, { x: at.x, y: at.y }); + } + } + return { nodes, bounds: bounds() }; + } + + // fit centres the unmoved diagram in the stage, scaled to the stage's width up to MAX_SCALE. + function fit(): void { + const home = layoutCanvas(result, { bounds: bounds() }, auto); + const heroRect = hero.getBoundingClientRect(); + const stageRect = stage.getBoundingClientRect(); + const scale = Math.min(MAX_SCALE, stageRect.width / home.width); + const height = Math.ceil(home.height * scale); + if (stage.style.height !== `${height}px`) { + stage.style.height = `${height}px`; + } + view = { + scale, + x: stageRect.left - heroRect.left + (stageRect.width - home.width * scale) / 2 - home.origin.x * scale, + y: stage.getBoundingClientRect().top - heroRect.top - home.origin.y * scale, + }; + } + + // bounds is the hero in layout coordinates, inset by HERO_PAD. + function bounds(): Box { + const pad = HERO_PAD / view.scale; + return { + x: -view.x / view.scale + pad, + y: -view.y / view.scale + pad, + width: hero.clientWidth / view.scale - 2 * pad, + height: hero.clientHeight / view.scale - 2 * pad, + }; + } + + function placementBounds(): Box { + return insetBox(bounds(), CLEARANCE); + } + + function clamped(node: PlacedNode, at: RenderPoint): RenderPoint { + return clampNodeToBounds(node, at, placementBounds(), (port) => exitReach(node, port)); + } + + function exitReach(node: PlacedNode, port: PlacedPort): number { + const sharing = layout.edges.filter( + ({ edge, hidden }) => + !hidden && + ((edge.from === node.node.id && edge.fromPort === port.port.id) || + (edge.to === node.node.id && edge.toPort === port.port.id)), + ).length; + return portExitReach(sharing); + } + + function otherNodes(id: string): PlacedNode[] { + return [...layout.nodes.values()].filter((entry) => entry.node.id !== id && !entry.hidden); + } + + // keepInHero keeps moved boxes clear while re-clamping in model order. + function keepInHero(): void { + let changed = false; + const settled = new Map(); + for (const entry of layout.nodes.values()) { + if (!entry.hidden) { + settled.set(entry.node.id, entry); + } + } + for (const part of model.parts.values()) { + const entry = layout.nodes.get(part.id); + if (!entry || entry.hidden) { + continue; + } + const moved = placed.has(part.feature); + const bounded = clamped(entry, placed.get(part.feature) ?? entry.box); + const at = + (moved && + freePlacement( + entry, + bounded, + [...settled.values()].filter((other) => other.node.id !== entry.node.id), + placementBounds(), + exitReach, + )) || + bounded; + if (at.x !== entry.box.x || at.y !== entry.box.y) { + placed.set(part.feature, at); + changed = true; + } + settled.set(entry.node.id, { ...entry, box: { ...entry.box, ...at } }); + } + if (changed) { + layout = layoutCanvas(result, overrides(), auto); + } + } + + function groupOf(id: string): SVGGElement | null { + return content.querySelector(`g.opensysml-node[data-opensysml-id="${cssEscape(id)}"]`); + } + + function edgesAt(id: string): number[] { + return result.edges.flatMap((edge, index) => (edge.from === id || edge.to === id ? [index] : [])); + } + + function syncClasses(): void { + const lit = cardFor ?? hot; + const litEdges = new Set(lit ? edgesAt(lit) : []); + hero.classList.toggle("osml-hero--focus", lit !== undefined); + for (const group of content.querySelectorAll("g.opensysml-node")) { + const id = group.dataset.opensysmlId; + group.classList.toggle("is-hot", id === lit); + group.classList.toggle("is-live", id === liveNode); + } + for (const group of content.querySelectorAll("g.opensysml-edge")) { + const index = Number(group.dataset.edge); + group.classList.toggle("is-hot", litEdges.has(index)); + group.classList.toggle("is-live", index === liveEdge); + } + } + + function decorate(): void { + for (const group of content.querySelectorAll("g.opensysml-node")) { + const part = partOf(group.dataset.opensysmlId ?? ""); + if (!part) { + continue; + } + group.classList.add("osml-part"); + group.setAttribute("tabindex", "0"); + group.setAttribute("role", "button"); + group.setAttribute( + "aria-label", + `${part.attrs.label}, ${part.attrs.kind ?? "part"}. Enter opens the project; ` + + "Shift+F10 shows its model; arrow keys move it.", + ); + } + } + + function draw(shown: CanvasLayout): void { + const focused = (document.activeElement as Element | null)?.closest?.("g.opensysml-node"); + const focusedId = focused && content.contains(focused) ? (focused as SVGGElement).dataset.opensysmlId : undefined; + const drawn = drawCanvas(shown); + content.replaceChildren(...Array.from(drawn.childNodes)); + content.setAttribute("transform", `translate(${view.x} ${view.y}) scale(${view.scale})`); + decorate(); + if (token) { + content.append(token); + } + syncClasses(); + if (focusedId) { + groupOf(focusedId)?.focus({ preventScroll: true }); + } + placeCard(); + } + + function settle(): void { + if (!laidOut) { + return; + } + fit(); + layout = layoutCanvas(result, overrides(), auto); + keepInHero(); + draw(layout); + } + + function relayOut(): void { + const mine = ++generation; + const shown = result; + void autoLayout(shown) + .catch((error: unknown) => { + console.warn("ELK could not lay out the landing diagram", error); + return undefined; + }) + .then((laid) => { + if (mine !== generation || signal.aborted) { + return; + } + auto = laid; + laidOut = true; + root.classList.add("osml-schematic--drawn"); + settle(); + }); + } + + // ---- the engine and the model text ---- + + function engine(): Promise { + if (!window.osmlLoadEngine) { + return Promise.reject(new Error("the engine loader is missing")); + } + enginePromise ??= window.osmlLoadEngine({ + wasmExec: root.dataset.osmlWasmExec ?? "", + engine: root.dataset.osmlEngine ?? "", + }).catch((error: unknown) => { + enginePromise = undefined; + throw error; + }); + return enginePromise; + } + + function publishedSource(): Promise { + sourcePromise ??= fetch(root.dataset.osmlModel ?? "") + .then((response) => { + if (!response.ok) { + throw new Error(`the model did not download (HTTP ${response.status})`); + } + return response.text(); + }) + .catch((error: unknown) => { + sourcePromise = undefined; + throw error; + }); + return sourcePromise; + } + + function currentSource(): Promise { + return !srcEl.readOnly && srcEl.value ? Promise.resolve(srcEl.value) : publishedSource(); + } + + function adopt(next: LandingModel, source: string): void { + model = next; + goodSource = source; + live = true; + for (const feature of [...placed.keys()]) { + if (!model.parts.has(feature)) { + placed.delete(feature); + } + } + result = presented(model); + if (cardFor && !partOf(cardFor)) { + closeCard(); + } + relayOut(); + if (cardFor) { + fillCard(cardFor); + } + } + + // read parses the source on the engine; it reports diagnostics and keeps the last good model. + async function read(source: string, verb: string): Promise { + const started = performance.now(); + const outcome = readModel(await engine(), source); + if (outcome.diagnostics.length > 0 || !outcome.model) { + const first = outcome.diagnostics[0]; + srcEl.setAttribute("aria-invalid", "true"); + status(`Not ${verb}: ${first ? diagnosticText(first) : "the model has nothing to draw"}. The diagram shows the last model that read cleanly.`, true); + return undefined; + } + srcEl.removeAttribute("aria-invalid"); + if (source !== goodSource) { + adopt(outcome.model, source); + } + const ms = Math.max(1, Math.round(performance.now() - started)); + status(`The engine read the model in ${ms} ms: ${outcome.model.parts.size} parts, ${outcome.model.render.edges.length} interfaces.`); + return outcome.model; + } + + function scheduleEdit(): void { + const mine = ++editSequence; + clearTimeout(editTimer); + editTimer = setTimeout(() => { + editTimer = undefined; + if (running) { + scheduleEdit(); + return; + } + const source = srcEl.value; + void read(source, "redrawn").catch((error: unknown) => { + if (mine === editSequence) { + status(`Not redrawn: ${message(error)}. The diagram shows the last model that read cleanly.`, true); + } + }); + }, EDIT_DELAY); + } + + // ---- the card ---- + + function row(into: HTMLElement, key: string, value: string): void { + const k = document.createElement("div"); + k.className = "osml-nodecard__k"; + k.textContent = key; + const v = document.createElement("div"); + v.className = "osml-nodecard__v"; + v.textContent = value; + into.append(k, v); + } + + function fillCard(id: string): void { + const part = partOf(id); + if (!part) { + closeCard(); + return; + } + const close = document.createElement("button"); + close.type = "button"; + close.className = "osml-nodecard__close"; + close.setAttribute("aria-label", "Close"); + close.textContent = "×"; + close.addEventListener("click", () => closeCard(true)); + const stereo = document.createElement("div"); + stereo.className = "osml-nodecard__stereo"; + stereo.textContent = `«${part.attrs.kind ?? "part"}»`; + const name = document.createElement("div"); + name.className = "osml-nodecard__name"; + name.id = "osml-nodecard-name"; + name.textContent = part.attrs.label; + const sub = document.createElement("div"); + sub.className = "osml-nodecard__sub"; + sub.textContent = `part ${part.feature} : ${part.symbol.slice(part.symbol.lastIndexOf("::") + 2)}`; + const attrs = document.createElement("div"); + attrs.className = "osml-nodecard__attrs"; + for (const key of Object.keys(part.attrs).sort()) { + row(attrs, key, part.attrs[key]); + } + const node = model.render.nodes.find((candidate) => candidate.id === id); + for (const port of node?.ports ?? []) { + row(attrs, `port ${port.name}`, port.type ?? ""); + } + const wires = document.createElement("div"); + wires.className = "osml-nodecard__attrs"; + for (const edge of model.render.edges) { + if (edge.from !== id && edge.to !== id) { + continue; + } + const peer = partOf(edge.from === id ? edge.to : edge.from); + row(wires, edge.from === id ? "→" : "←", `${peer?.attrs.label ?? "?"} (${edge.label || "interface"})`); + } + const note = document.createElement("div"); + note.className = "osml-nodecard__note"; + note.textContent = live + ? `Read by the engine from model ${model.hash.slice(0, 12)}` + : "The engine's saved reading of the published model. Run or edit it to read it live."; + card.replaceChildren(close, stereo, name, sub, attrs, wires, note); + const repo = webHref(part.attrs.repo); + if (repo) { + const link = document.createElement("a"); + link.className = "osml-nodecard__link"; + link.href = repo; + link.target = "_blank"; + link.rel = "noopener"; + link.textContent = "Open the project ↗"; + card.append(link); + } + card.setAttribute("aria-labelledby", name.id); + } + + function openCard(id: string, focus: boolean): void { + cardFor = id; + fillCard(id); + card.hidden = false; + syncClasses(); + placeCard(); + if (focus) { + card.querySelector(".osml-nodecard__close")?.focus(); + } + } + + function closeCard(refocus = false): void { + const was = cardFor; + cardFor = undefined; + card.hidden = true; + card.replaceChildren(); + syncClasses(); + if (refocus && was) { + groupOf(was)?.focus({ preventScroll: true }); + } + } + + // placeCard keeps the card visible while preferring a spot beside its box. + function placeCard(): void { + if (!cardFor || card.hidden) { + return; + } + const shape = groupOf(cardFor)?.querySelector(".shape"); + if (!shape) { + return; + } + const heroRect = hero.getBoundingClientRect(); + const headerBottom = document.querySelector(".md-header")?.getBoundingClientRect().bottom ?? 0; + const visibleLeft = Math.max(heroRect.left, 0); + const visibleTop = Math.max(heroRect.top, 0, headerBottom); + const visibleRight = Math.min(heroRect.right, window.innerWidth); + const visibleBottom = Math.min(heroRect.bottom, window.innerHeight); + const pad = 12; + const visibleWidth = visibleRight - visibleLeft; + const visibleHeight = visibleBottom - visibleTop; + const area = + visibleWidth > 2 * pad && visibleHeight > 2 * pad + ? { + x: visibleLeft - heroRect.left + pad, + y: visibleTop - heroRect.top + pad, + width: visibleWidth - 2 * pad, + height: visibleHeight - 2 * pad, + } + : { + x: pad, + y: pad, + width: Math.max(0, heroRect.width - 2 * pad), + height: Math.max(0, heroRect.height - 2 * pad), + }; + const at = shape.getBoundingClientRect(); + const box = new DOMRect(at.left - heroRect.left, at.top - heroRect.top, at.width, at.height); + const gap = 14; + const style = getComputedStyle(card); + const horizontalInsets = + style.boxSizing === "border-box" + ? 0 + : Number.parseFloat(style.paddingLeft) + + Number.parseFloat(style.paddingRight) + + Number.parseFloat(style.borderLeftWidth) + + Number.parseFloat(style.borderRightWidth); + const verticalInsets = + style.boxSizing === "border-box" + ? 0 + : Number.parseFloat(style.paddingTop) + + Number.parseFloat(style.paddingBottom) + + Number.parseFloat(style.borderTopWidth) + + Number.parseFloat(style.borderBottomWidth); + card.style.maxWidth = `${Math.max(0, area.width - horizontalInsets)}px`; + card.style.maxHeight = `${Math.max(0, area.height - verticalInsets)}px`; + const width = card.offsetWidth; + const height = card.offsetHeight; + const others = [...content.querySelectorAll("g.opensysml-node")] + .filter((group) => group.dataset.opensysmlId !== cardFor) + .map((group) => { + const r = (group.querySelector(".shape") ?? group).getBoundingClientRect(); + return new DOMRect(r.left - heroRect.left, r.top - heroRect.top, r.width, r.height); + }); + const middleY = box.y + box.height / 2 - height / 2; + const middleX = box.x + box.width / 2 - width / 2; + const candidates = [ + { side: "right", x: box.right + gap, y: middleY }, + { side: "left", x: box.left - gap - width, y: middleY }, + { side: "below", x: middleX, y: box.bottom + gap }, + { side: "above", x: middleX, y: box.top - gap - height }, + ]; + const placements = candidates.map((candidate) => { + const x = Math.min(Math.max(candidate.x, area.x), Math.max(area.x, area.x + area.width - width)); + const y = Math.min(Math.max(candidate.y, area.y), Math.max(area.y, area.y + area.height - height)); + const shift = Math.abs(x - candidate.x) + Math.abs(y - candidate.y); + return { ...candidate, x, y, shift, rect: new DOMRect(x, y, width, height) }; + }); + const uncovered = placements.filter((placement) => overlap(placement.rect, box) === 0); + const choices = uncovered.length > 0 ? uncovered : placements; + let best: { x: number; y: number; cost: number } | undefined; + for (const candidate of choices) { + // Keep clear candidates when available; otherwise the selected box weighs four times as much. + const cost = + overlap(candidate.rect, box) * 4 + + others.reduce((sum, other) => sum + overlap(candidate.rect, other), 0) + + candidate.shift; + if (!best || cost < best.cost) { + best = { x: candidate.x, y: candidate.y, cost }; + } + } + card.style.left = `${best!.x}px`; + card.style.top = `${best!.y}px`; + } + + // ---- moving boxes ---- + + function point(event: PointerEvent): RenderPoint { + const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(content.getScreenCTM()!.inverse()); + return { x: p.x, y: p.y }; + } + + function moveTo(id: string, at: RenderPoint): void { + const part = partOf(id); + const entry = layout.nodes.get(id); + if (!part || !entry) { + return; + } + placed.set(part.feature, clamped(entry, at)); + const previous = layout; + layout = keepOrthogonalRoutes(layoutCanvas(result, overrides(), auto), previous); + draw(layout); + } + + function openProject(id: string): void { + const repo = webHref(partOf(id)?.attrs.repo); + if (repo) { + window.open(repo, "_blank", "noopener"); + } + } + + function partGroup(target: EventTarget | null): SVGGElement | undefined { + const group = (target as Element | null)?.closest?.("g.opensysml-node.osml-part"); + return group && content.contains(group) ? group : undefined; + } + + function endGesture(cancelled: boolean): void { + const ended = gesture; + if (!ended) { + return; + } + clearTimeout(ended.timer); + if (ended.frame !== undefined) { + cancelAnimationFrame(ended.frame); + ended.frame = undefined; + } + gesture = undefined; + hero.classList.remove("osml-hero--dragging"); + if (ended.moved) { + const entry = layout.nodes.get(ended.id); + const at = entry && freePlacement(entry, ended.at, otherNodes(ended.id), placementBounds(), exitReach); + if (entry && at) { + moveTo(ended.id, alignedPlacement(entry, at, layout, placementBounds(), exitReach)); + } + } else if (!cancelled && !ended.longPressed) { + openProject(ended.id); + } + } + + on(svg, "pointerdown", (event) => { + const group = partGroup(event.target); + if (!group || event.button !== 0 || !laidOut) { + return; + } + const id = group.dataset.opensysmlId!; + const entry = layout.nodes.get(id)!; + const started: Gesture = { + id, + pointer: event.pointerId, + start: point(event), + from: { x: entry.box.x, y: entry.box.y }, + at: { x: entry.box.x, y: entry.box.y }, + moved: false, + longPressed: false, + }; + // Touch has no right button: a held, unmoved press opens the same card. + if (event.pointerType !== "mouse") { + started.timer = setTimeout(() => { + if (gesture === started && !started.moved) { + started.longPressed = true; + openCard(id, false); + } + }, LONG_PRESS); + } + gesture = started; + event.preventDefault(); + }); + on(window, "pointermove", (event) => { + const active = gesture; + if (!active || event.pointerId !== active.pointer) { + return; + } + const p = point(event); + const dx = p.x - active.start.x; + const dy = p.y - active.start.y; + if (!active.moved && Math.hypot(dx, dy) * view.scale < DRAG_SLOP) { + return; + } + if (!active.moved) { + active.moved = true; + clearTimeout(active.timer); + hero.classList.add("osml-hero--dragging"); + } + active.at = { x: active.from.x + dx, y: active.from.y + dy }; + if (active.frame === undefined) { + active.frame = requestAnimationFrame(() => { + active.frame = undefined; + if (!signal.aborted && gesture === active) { + moveTo(active.id, active.at); + } + }); + } + }); + on(window, "pointerup", (event) => { + if (gesture && event.pointerId === gesture.pointer) { + endGesture(false); + } + }); + on(window, "pointercancel", (event) => { + if (gesture && event.pointerId === gesture.pointer) { + endGesture(true); + } + }); + on(svg, "contextmenu", (event) => { + const group = partGroup(event.target); + if (!group) { + return; + } + event.preventDefault(); + openCard(group.dataset.opensysmlId!, false); + }); + on(svg, "keydown", (event) => { + const group = partGroup(event.target); + if (!group) { + return; + } + const id = group.dataset.opensysmlId!; + const steps: Record = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openProject(id); + } else if (event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey)) { + event.preventDefault(); + openCard(id, true); + } else if (steps[event.key] && laidOut) { + event.preventDefault(); + const entry = layout.nodes.get(id)!; + const box = entry.box; + const step = (event.shiftKey ? 4 : 1) * KEY_STEP; + const direction = steps[event.key]; + const at = freePlacement( + entry, + { x: box.x + direction[0] * step, y: box.y + direction[1] * step }, + otherNodes(id), + placementBounds(), + exitReach, + { x: direction[0], y: direction[1] }, + ); + if (at) { + moveTo(id, at); + } + } + }); + const light = (id: string | undefined): void => { + hot = id; + syncClasses(); + }; + on(svg, "pointerover", (event) => { + if (!gesture) { + light(partGroup(event.target)?.dataset.opensysmlId); + } + }); + on(svg, "pointerout", (event) => { + if (!gesture && !partGroup(event.relatedTarget)) { + light(undefined); + } + }); + on(svg, "focusin", (event) => light(partGroup(event.target)?.dataset.opensysmlId)); + on(svg, "focusout", () => light(undefined)); + on(document, "pointerdown", (event) => { + if (!card.hidden && !card.contains(event.target as Node) && !partGroup(event.target)) { + closeCard(); + } + }); + on(document, "keydown", (event) => { + if (event.key === "Escape" && !card.hidden) { + closeCard(true); + } + }); + + // ---- running the model ---- + + function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + function travel(index: number, forward: boolean): Promise { + return new Promise((resolve) => { + let began: number | undefined; + const step = (now: number): void => { + const line = content.querySelector(`g.opensysml-edge[data-edge="${index}"] .line`); + if (signal.aborted || !line || !token) { + resolve(); + return; + } + began ??= now; + const t = Math.min(1, (now - began) / HOP); + const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + const at = line.getPointAtLength(line.getTotalLength() * (forward ? eased : 1 - eased)); + token.setAttribute("cx", String(at.x)); + token.setAttribute("cy", String(at.y)); + if (t < 1) { + requestAnimationFrame(step); + } else { + resolve(); + } + }; + requestAnimationFrame(step); + }); + } + + async function animate(visited: string[]): Promise { + token = document.createElementNS(SVG_NS, "circle"); + token.setAttribute("class", "osml-token"); + token.setAttribute("r", "7"); + token.style.display = "none"; + content.append(token); + try { + for (let i = 0; i < visited.length && !signal.aborted; i++) { + liveNode = visited[i]; + liveEdge = undefined; + syncClasses(); + const next = visited[i + 1]; + if (next === undefined) { + await delay(1200); + break; + } + const index = result.edges.findIndex( + (edge) => (edge.from === liveNode && edge.to === next) || (edge.from === next && edge.to === liveNode), + ); + if (index < 0 || reduceMotion.matches) { + await delay(800); + continue; + } + liveEdge = index; + syncClasses(); + token.style.display = ""; + await travel(index, result.edges[index].from === liveNode); + token.style.display = "none"; + } + } finally { + token?.remove(); + token = undefined; + liveNode = undefined; + liveEdge = undefined; + syncClasses(); + } + } + + on(runBtn, "click", () => { + if (running) { + return; + } + running = true; + runBtn.disabled = true; + status("Loading the engine and reading the model…"); + void (async () => { + try { + const source = await currentSource(); + const current = source === goodSource ? model : await read(source, "run"); + if (!current) { + return; + } + const started = performance.now(); + const visited = journey(await engine(), current); + const ms = Math.max(1, Math.round(performance.now() - started)); + if (visited.length === 0) { + throw new Error("ExecuteState visited none of the diagram's parts"); + } + const names = visited.map((id) => partOf(id)?.attrs.label ?? id); + status(`ExecuteState ran ModelJourney on ${JOURNEY_EVENTS.join(", ")} in ${ms} ms: ${names.join(" → ")}`); + runBtn.textContent = "▶ Run it again"; + await animate(visited); + } catch (error) { + status(`The engine could not run the model (${message(error)}). The diagram still works.`, true); + } finally { + running = false; + runBtn.disabled = false; + } + })(); + }); + + // ---- editing the model ---- + + on(srcBtn, "click", () => { + const open = editor.hidden; + editor.hidden = !open; + srcBtn.setAttribute("aria-expanded", String(open)); + srcBtn.textContent = open ? "Hide the model" : "Edit the model"; + if (!open) { + return; + } + if (srcEl.value) { + srcEl.focus(); + return; + } + srcEl.value = "Loading the model…"; + srcEl.readOnly = true; + publishedSource().then( + (text) => { + srcEl.value = text; + srcEl.readOnly = false; + srcEl.focus(); + srcEl.setSelectionRange(0, 0); + srcEl.scrollTop = 0; + // The engine is fetched now, so the first edit redraws without a wait. + void engine().catch(() => undefined); + }, + (error: unknown) => { + srcEl.value = ""; + srcEl.readOnly = false; + status(`Could not load the model: ${message(error)}. Hide and reopen the editor to try again.`, true); + }, + ); + }); + on(srcEl, "input", () => { + if (!srcEl.readOnly) { + status("Loading the engine and reading the model…"); + scheduleEdit(); + } + }); + on(resetBtn, "click", () => { + publishedSource().then( + (text) => { + srcEl.value = text; + srcEl.removeAttribute("aria-invalid"); + placed.clear(); + settle(); + scheduleEdit(); + }, + (error: unknown) => status(`Could not load the model: ${message(error)}.`, true), + ); + }); + + // ---- size, routing, and leaving the page ---- + + const resize = new ResizeObserver(() => { + if (!root.isConnected) { + dispose(); + return; + } + settle(); + }); + resize.observe(hero); + resize.observe(stage); + window.addEventListener("resize", () => { + if (cardFor) { + placeCard(); + } + }, { signal }); + window.addEventListener("scroll", () => { + if (cardFor) { + placeCard(); + } + }, { signal }); + + void loadAvoid(root.dataset.osmlLibavoid ?? "").then( + () => { + if (!signal.aborted && placed.size > 0) { + settle(); + } + }, + (error: unknown) => console.warn("libavoid did not load; moved boxes keep straight wires", error), + ); + + // Instant navigation swaps the page out without unloading it; Material's document$ marks each swap. + const navigation = window.document$?.subscribe(() => { + if (!root.isConnected) { + dispose(); + } + }); + + function dispose(): void { + if (signal.aborted) { + return; + } + const active = gesture; + gesture = undefined; + if (active) { + clearTimeout(active.timer); + if (active.frame !== undefined) { + cancelAnimationFrame(active.frame); + active.frame = undefined; + } + } + ac.abort(); + resize.disconnect(); + navigation?.unsubscribe(); + clearTimeout(editTimer); + svg.remove(); + hero.classList.remove("osml-hero--focus", "osml-hero--dragging"); + if (window.__osmlDiagram === mounted) { + window.__osmlDiagram = undefined; + } + } + + const mounted: Mounted = { dispose }; + relayOut(); + return mounted; +} + +window.osmlMountDiagram = (root: HTMLElement): void => { + window.__osmlDiagram = mount(root); +}; diff --git a/editors/vscode/src/landing/model.test.ts b/editors/vscode/src/landing/model.test.ts new file mode 100644 index 0000000000..1c52c85da4 --- /dev/null +++ b/editors/vscode/src/landing/model.test.ts @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +import type { RenderResult } from "../protocol"; +import { + EngineClient, + EngineInstance, + JOURNEY_EVENTS, + JOURNEY_SYMBOL, + landingModel, + journey, + readModel, + rpc, +} from "./model"; + +interface LandingFixture { + hash: string; + render: Omit; + instances: EngineInstance[]; +} + +const fixture = JSON.parse(readFileSync("src/landing/stack.json", "utf8")) as LandingFixture; +const fixtureRender = fixture.render as RenderResult; + +test("landingModel keeps the four ported project parts and their interface edges", () => { + const model = landingModel(fixture.hash, fixtureRender, fixture.instances); + assert.equal(model.parts.size, 4); + assert.deepEqual( + [...model.parts.keys()].sort(), + ["flexo", "opensysml", "pilot", "toolkit"], + ); + assert.ok(model.render.nodes.every((node) => node.kind !== "attribute")); + assert.ok(model.render.nodes.every((node) => node.parent === undefined)); + + const ports = new Map(); + for (const part of model.parts.values()) { + const node = model.render.nodes.find(({ id }) => id === part.id)!; + const api = node.ports?.find(({ name }) => name === "api"); + assert.ok(api, `${part.feature} should keep its api port`); + ports.set(part.feature, api.id); + } + assert.equal(model.render.edges.length, 3); + assert.ok(model.render.edges.every((edge) => edge.toPort === ports.get("flexo"))); + assert.deepEqual( + model.render.edges.map((edge) => edge.label).sort(), + ["opensysml_flexo", "pilot_flexo", "toolkit_flexo"], + ); + assert.equal(model.parts.get("opensysml")?.attrs.label, "OpenSysML"); +}); + +test("readModel returns parse diagnostics without requesting a rendering", () => { + const calls: string[] = []; + const engine: EngineClient = { + call(method) { + calls.push(method); + return JSON.stringify({ + result: { + diagnostics: [{ + message: "unresolved name", + severity: "error", + span: { startLine: 3, startCol: 5 }, + }], + }, + }); + }, + }; + assert.deepEqual(readModel(engine, "bad model"), { + diagnostics: [{ message: "unresolved name", severity: "error", line: 3, column: 5 }], + }); + assert.deepEqual(calls, ["ParseSources"]); +}); + +test("rpc throws errors returned in the envelope or result", () => { + assert.throws( + () => rpc({ call: () => JSON.stringify({ error: { message: "engine unavailable" } }) }, "RenderView", {}), + /engine unavailable/, + ); + assert.throws( + () => rpc({ call: () => JSON.stringify({ result: { error: "symbol not found" } }) }, "Instantiate", {}), + /symbol not found/, + ); +}); + +test("readModel requests a minimal interconnection render before instantiating the stack", () => { + const calls: Array<{ method: string; params: Record }> = []; + const engine: EngineClient = { + call(method, params) { + calls.push({ method, params: JSON.parse(params) as Record }); + const result = method === "ParseSources" + ? { modelHash: fixture.hash, diagnostics: [] } + : method === "RenderView" + ? fixture.render + : { instances: fixture.instances }; + return JSON.stringify({ result }); + }, + }; + const read = readModel(engine, "model source"); + assert.deepEqual(read.diagnostics, []); + assert.ok(read.model); + assert.deepEqual(calls.map(({ method }) => method), ["ParseSources", "RenderView", "Instantiate"]); + assert.deepEqual(calls[1].params, { + modelHash: fixture.hash, + view: "#interconnection:OpenSysMLStack::stack", + ports: "minimal", + }); +}); + +test("journey maps visited feature names to node ids and drops unknown states", () => { + const model = landingModel(fixture.hash, fixtureRender, fixture.instances); + const engine: EngineClient = { + call(method, params) { + assert.equal(method, "ExecuteState"); + assert.deepEqual(JSON.parse(params), { + modelHash: fixture.hash, + stateMachineSymbolId: JOURNEY_SYMBOL, + events: JOURNEY_EVENTS, + }); + return JSON.stringify({ + result: { statesVisited: ["opensysml", "unknown", "flexo", "toolkit", "flexo", "pilot"] }, + }); + }, + }; + assert.deepEqual( + journey(engine, model), + ["opensysml", "flexo", "toolkit", "flexo", "pilot"].map((feature) => model.parts.get(feature)!.id), + ); +}); diff --git a/editors/vscode/src/landing/model.ts b/editors/vscode/src/landing/model.ts new file mode 100644 index 0000000000..07c2520be3 --- /dev/null +++ b/editors/vscode/src/landing/model.ts @@ -0,0 +1,198 @@ +import { normalizeRender } from "../protocol"; +import type { RenderResult } from "../protocol"; + +export interface EngineClient { + call(method: string, params: string): string; +} + +export interface Diagnostic { + message: string; + severity?: string; + line?: number; + column?: number; +} + +export interface LandingPart { + id: string; + feature: string; + symbol: string; + attrs: Record; +} + +export interface LandingModel { + hash: string; + render: RenderResult; + parts: Map; +} + +export type ModelRead = { diagnostics: Diagnostic[]; model?: LandingModel }; + +export interface EngineValue { + stringValue?: string; + instanceId?: string; +} + +export interface EngineFeatureValue { + value?: EngineValue; +} + +export interface EngineInstance { + id?: string; + typeSymbolId?: string; + featureValues?: Record; +} + +export const STACK_SYMBOL = "OpenSysMLStack::stack"; +export const JOURNEY_SYMBOL = "OpenSysMLStack::ModelJourney"; +export const JOURNEY_EVENTS = ["Commit", "Pull", "Push", "Check"] as const; + +type EngineRender = Omit; + +interface EngineDiagnostic extends Diagnostic { + span?: { + startLine?: number; + startCol?: number; + }; +} + +interface ParseSourcesResult { + modelHash?: string; + diagnostics?: EngineDiagnostic[]; +} + +interface InstantiateResult { + instances?: EngineInstance[]; +} + +interface ExecuteStateResult { + statesVisited?: string[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function errorMessage(error: unknown): string { + if (typeof error === "string") { + return error; + } + if (isRecord(error) && typeof error.message === "string") { + return error.message; + } + return JSON.stringify(error) ?? String(error); +} + +export function rpc(engine: EngineClient, method: string, params: object): T { + const envelope: unknown = JSON.parse(engine.call(method, JSON.stringify(params))); + if (!isRecord(envelope)) { + throw new Error(`${method} returned an invalid response`); + } + if (envelope.error !== undefined && envelope.error !== null) { + throw new Error(errorMessage(envelope.error)); + } + if (!("result" in envelope)) { + throw new Error(`${method} returned no result`); + } + const result = envelope.result; + if (isRecord(result) && result.error !== undefined && result.error !== null) { + throw new Error(errorMessage(result.error)); + } + return result as T; +} + +export function landingModel(hash: string, render: RenderResult, instances: EngineInstance[]): LandingModel { + const rootName = STACK_SYMBOL.slice(STACK_SYMBOL.lastIndexOf("::") + 2); + const root = render.nodes.find( + (node) => node.parent === undefined && (node.name === STACK_SYMBOL || node.name === rootName), + ); + if (!root) { + throw new Error(`rendered view has no ${STACK_SYMBOL} root`); + } + const rootInstance = instances.find((instance) => instance.typeSymbolId === STACK_SYMBOL); + const instancesById = new Map( + instances.flatMap((instance) => instance.id === undefined ? [] : [[instance.id, instance]]), + ); + const nodes = render.nodes + .filter((node) => node.id !== root.id && node.kind !== "attribute") + .map((node) => { + if (node.parent !== root.id) { + return node; + } + const { parent: _parent, ...lifted } = node; + return lifted; + }); + const retained = new Set(nodes.map((node) => node.id)); + const normalized = normalizeRender({ + ...render, + nodes, + edges: render.edges.filter((edge) => retained.has(edge.from) && retained.has(edge.to)), + }); + const parts = new Map(); + for (const node of nodes) { + if (node.kind !== "part") { + continue; + } + const instanceId = rootInstance?.featureValues?.[node.name]?.value?.instanceId; + const instance = instanceId === undefined ? undefined : instancesById.get(instanceId); + const attrs: Record = {}; + for (const [name, featureValue] of Object.entries(instance?.featureValues ?? {})) { + const value = featureValue.value?.stringValue; + if (value !== undefined) { + attrs[name] = value; + } + } + attrs.label ??= node.name; + const part: LandingPart = { + id: node.id, + feature: node.name, + symbol: instance?.typeSymbolId ?? node.type, + attrs, + }; + parts.set(part.feature, part); + } + return { hash, render: normalized, parts }; +} + +export function readModel(engine: EngineClient, source: string): ModelRead { + const parsed = rpc(engine, "ParseSources", { + documents: [{ name: "opensysml-stack.sysml", content: source }], + }); + const diagnostics = (parsed.diagnostics ?? []).map((diagnostic) => ({ + message: diagnostic.message, + ...(diagnostic.severity === undefined ? {} : { severity: diagnostic.severity }), + ...(diagnostic.span?.startLine === undefined ? {} : { line: diagnostic.span.startLine }), + ...(diagnostic.span?.startCol === undefined ? {} : { column: diagnostic.span.startCol }), + })); + if (diagnostics.length > 0) { + return { diagnostics }; + } + if (!parsed.modelHash) { + throw new Error("ParseSources returned no modelHash"); + } + const render = rpc(engine, "RenderView", { + modelHash: parsed.modelHash, + view: `#interconnection:${STACK_SYMBOL}`, + ports: "minimal", + }); + const built = rpc(engine, "Instantiate", { + modelHash: parsed.modelHash, + symbolId: STACK_SYMBOL, + }); + return { + diagnostics, + model: landingModel(parsed.modelHash, render as RenderResult, built.instances ?? []), + }; +} + +export function journey(engine: EngineClient, model: LandingModel): string[] { + const result = rpc(engine, "ExecuteState", { + modelHash: model.hash, + stateMachineSymbolId: JOURNEY_SYMBOL, + events: JOURNEY_EVENTS, + }); + const idsByFeature = new Map([...model.parts.values()].map((part) => [part.feature, part.id])); + return (result.statesVisited ?? []).flatMap((state) => { + const id = idsByFeature.get(state); + return id === undefined ? [] : [id]; + }); +} diff --git a/editors/vscode/src/landing/present.test.ts b/editors/vscode/src/landing/present.test.ts new file mode 100644 index 0000000000..88262d9d8c --- /dev/null +++ b/editors/vscode/src/landing/present.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +import type { RenderResult } from "../protocol"; +import { landingModel } from "./model"; +import type { EngineInstance, LandingModel } from "./model"; +import { presented } from "./present"; + +interface LandingFixture { + hash: string; + render: Omit; + instances: EngineInstance[]; +} + +const fixture = JSON.parse(readFileSync("src/landing/stack.json", "utf8")) as LandingFixture; +const fixtureRender = fixture.render as RenderResult; + +function fixtureModel(): LandingModel { + return landingModel(fixture.hash, fixtureRender, fixture.instances); +} + +function withPartAttrs(model: LandingModel, feature: string, attrs: Record): LandingModel { + const part = model.parts.get(feature); + assert.ok(part); + const parts = new Map(model.parts); + parts.set(feature, { ...part, attrs }); + return { ...model, parts }; +} + +test("presented uses fixture attributes and preserves edge and port data", () => { + const model = fixtureModel(); + const flexo = model.parts.get("flexo"); + assert.ok(flexo); + const result = presented(model); + const flexoNode = result.nodes.find(({ id }) => id === flexo.id); + assert.ok(flexoNode); + + assert.equal(flexo.id, "n19"); + assert.equal(flexoNode.ports?.[0]?.id, "n19.0"); + assert.deepEqual( + { + name: flexoNode.name, + kind: flexoNode.kind, + type: flexoNode.type, + detail: flexoNode.detail, + }, + { + name: "Flexo MMS", + kind: "model store", + type: "", + detail: "Kotlin · the versioned store", + }, + ); + + for (const node of model.render.nodes) { + assert.deepEqual( + result.nodes.find(({ id }) => id === node.id)?.ports, + node.ports, + ); + } + assert.deepEqual( + result.edges, + model.render.edges.map((edge) => ({ ...edge, label: "" })), + ); +}); + +test("presented falls back to the feature name and omits a missing language", () => { + const model = fixtureModel(); + const unlabeled = model.parts.get("opensysml"); + assert.ok(unlabeled); + const unlabeledAttrs = { ...unlabeled.attrs }; + delete unlabeledAttrs.label; + const unlabeledModel = withPartAttrs(model, "opensysml", unlabeledAttrs); + const unlabeledNode = presented(unlabeledModel).nodes.find(({ id }) => id === unlabeled.id); + assert.equal(unlabeledNode?.name, unlabeled.feature); + + const flexo = model.parts.get("flexo"); + assert.ok(flexo); + const roleOnlyAttrs = { ...flexo.attrs }; + delete roleOnlyAttrs.lang; + const roleOnlyModel = withPartAttrs(model, "flexo", roleOnlyAttrs); + const roleOnlyNode = presented(roleOnlyModel).nodes.find(({ id }) => id === flexo.id); + assert.equal(roleOnlyNode?.detail, roleOnlyAttrs.role); + assert.ok(!roleOnlyNode?.detail?.includes(" · ")); +}); + +test("presented returns non-part nodes unchanged without mutating the input render", () => { + const model = fixtureModel(); + const unmodeledNode = { ...model.render.nodes[0], id: "not-a-part" }; + const render = { ...model.render, nodes: [...model.render.nodes, unmodeledNode] }; + const input = { ...model, render }; + const originalRender = JSON.stringify(input.render); + + const result = presented(input); + + assert.strictEqual(result.nodes[result.nodes.length - 1], unmodeledNode); + assert.equal(JSON.stringify(input.render), originalRender); +}); diff --git a/editors/vscode/src/landing/present.ts b/editors/vscode/src/landing/present.ts new file mode 100644 index 0000000000..9263893835 --- /dev/null +++ b/editors/vscode/src/landing/present.ts @@ -0,0 +1,29 @@ +import type { RenderResult } from "../protocol"; +import type { LandingModel } from "./model"; + +/** + * presented is the landing model as the hero draws it: each project's box headed by its + * label, with its kind as the «keyword» and its language and role as the detail line. + * Connection names are left to the cards, so the wires carry no labels. + */ +export function presented(model: LandingModel): RenderResult { + const byId = new Map([...model.parts.values()].map((part) => [part.id, part])); + return { + ...model.render, + nodes: model.render.nodes.map((node) => { + const part = byId.get(node.id); + if (!part) { + return node; + } + const { label, kind, lang, role } = part.attrs; + return { + ...node, + name: label || part.feature, + kind: kind || node.kind, + type: "", + detail: [lang, role].filter((value) => value).join(" · "), + }; + }), + edges: model.render.edges.map((edge) => ({ ...edge, label: "" })), + }; +} diff --git a/editors/vscode/src/landing/stack.json b/editors/vscode/src/landing/stack.json new file mode 100644 index 0000000000..bcb3dbd586 --- /dev/null +++ b/editors/vscode/src/landing/stack.json @@ -0,0 +1,1190 @@ +{ + "hash": "fd64a637c5da6d1eff2685bd658b3f62b1d3ed2d689d3ee14ca60b1f47616f55", + "render": { + "view": "", + "kind": "interconnection", + "stated": "no view declared; rendering OpenSysMLStack::stack directly", + "notices": [], + "nodes": [ + { + "id": "n0", + "kind": "part", + "name": "OpenSysMLStack::stack", + "type": "", + "detail": "" + }, + { + "id": "n1", + "kind": "part", + "name": "opensysml", + "type": "Project", + "detail": "", + "parent": "n0", + "ports": [ + { + "id": "n1.0", + "name": "api", + "type": "ModelPort" + } + ] + }, + { + "id": "n2", + "kind": "attribute", + "name": "label", + "type": "", + "detail": "", + "parent": "n1" + }, + { + "id": "n3", + "kind": "attribute", + "name": "kind", + "type": "", + "detail": "", + "parent": "n1" + }, + { + "id": "n4", + "kind": "attribute", + "name": "lang", + "type": "", + "detail": "", + "parent": "n1" + }, + { + "id": "n5", + "kind": "attribute", + "name": "role", + "type": "", + "detail": "", + "parent": "n1" + }, + { + "id": "n6", + "kind": "attribute", + "name": "repo", + "type": "", + "detail": "", + "parent": "n1" + }, + { + "id": "n7", + "kind": "part", + "name": "toolkit", + "type": "Project", + "detail": "", + "parent": "n0", + "ports": [ + { + "id": "n7.0", + "name": "api", + "type": "ModelPort" + } + ] + }, + { + "id": "n8", + "kind": "attribute", + "name": "label", + "type": "", + "detail": "", + "parent": "n7" + }, + { + "id": "n9", + "kind": "attribute", + "name": "kind", + "type": "", + "detail": "", + "parent": "n7" + }, + { + "id": "n10", + "kind": "attribute", + "name": "lang", + "type": "", + "detail": "", + "parent": "n7" + }, + { + "id": "n11", + "kind": "attribute", + "name": "role", + "type": "", + "detail": "", + "parent": "n7" + }, + { + "id": "n12", + "kind": "attribute", + "name": "repo", + "type": "", + "detail": "", + "parent": "n7" + }, + { + "id": "n13", + "kind": "part", + "name": "pilot", + "type": "Project", + "detail": "", + "parent": "n0", + "ports": [ + { + "id": "n13.0", + "name": "api", + "type": "ModelPort" + } + ] + }, + { + "id": "n14", + "kind": "attribute", + "name": "label", + "type": "", + "detail": "", + "parent": "n13" + }, + { + "id": "n15", + "kind": "attribute", + "name": "kind", + "type": "", + "detail": "", + "parent": "n13" + }, + { + "id": "n16", + "kind": "attribute", + "name": "lang", + "type": "", + "detail": "", + "parent": "n13" + }, + { + "id": "n17", + "kind": "attribute", + "name": "role", + "type": "", + "detail": "", + "parent": "n13" + }, + { + "id": "n18", + "kind": "attribute", + "name": "repo", + "type": "", + "detail": "", + "parent": "n13" + }, + { + "id": "n19", + "kind": "part", + "name": "flexo", + "type": "Project", + "detail": "", + "parent": "n0", + "ports": [ + { + "id": "n19.0", + "name": "api", + "type": "ModelPort" + } + ] + }, + { + "id": "n20", + "kind": "attribute", + "name": "label", + "type": "", + "detail": "", + "parent": "n19" + }, + { + "id": "n21", + "kind": "attribute", + "name": "kind", + "type": "", + "detail": "", + "parent": "n19" + }, + { + "id": "n22", + "kind": "attribute", + "name": "lang", + "type": "", + "detail": "", + "parent": "n19" + }, + { + "id": "n23", + "kind": "attribute", + "name": "role", + "type": "", + "detail": "", + "parent": "n19" + }, + { + "id": "n24", + "kind": "attribute", + "name": "repo", + "type": "", + "detail": "", + "parent": "n19" + } + ], + "edges": [ + { + "from": "n1", + "to": "n19", + "fromPort": "n1.0", + "toPort": "n19.0", + "label": "opensysml_flexo", + "kind": "connection" + }, + { + "from": "n7", + "to": "n19", + "fromPort": "n7.0", + "toPort": "n19.0", + "label": "toolkit_flexo", + "kind": "connection" + }, + { + "from": "n13", + "to": "n19", + "fromPort": "n13.0", + "toPort": "n19.0", + "label": "pilot_flexo", + "kind": "connection" + } + ] + }, + "instances": [ + { + "id": "1", + "typeSymbolId": "OpenSysMLStack::stack", + "featureValues": { + "boundingShapes": { + "featureName": "boundingShapes", + "materialized": true + }, + "checkedConstraints": { + "featureName": "checkedConstraints", + "materialized": true + }, + "envelopingShapes": { + "featureName": "envelopingShapes", + "materialized": true + }, + "exhibitedStates": { + "featureName": "exhibitedStates" + }, + "flexo": { + "featureName": "flexo", + "value": { + "instanceId": "2" + }, + "materialized": true + }, + "isSolid": { + "featureName": "isSolid", + "value": { + "boolValue": true + }, + "materialized": true + }, + "opensysml": { + "featureName": "opensysml", + "value": { + "instanceId": "3" + }, + "materialized": true + }, + "opensysml_flexo": { + "featureName": "opensysml_flexo", + "value": { + "instanceId": "4" + }, + "materialized": true + }, + "ownedActions": { + "featureName": "ownedActions", + "materialized": true + }, + "ownedPorts": { + "featureName": "ownedPorts", + "materialized": true + }, + "ownedStates": { + "featureName": "ownedStates", + "materialized": true + }, + "performedActions": { + "featureName": "performedActions" + }, + "pilot": { + "featureName": "pilot", + "value": { + "instanceId": "7" + }, + "materialized": true + }, + "pilot_flexo": { + "featureName": "pilot_flexo", + "value": { + "instanceId": "8" + }, + "materialized": true + }, + "shape": { + "featureName": "shape", + "value": { + "unset": true + }, + "materialized": true + }, + "subitems": { + "featureName": "subitems", + "values": [ + { + "instanceId": "3" + }, + { + "instanceId": "10" + }, + { + "instanceId": "7" + }, + { + "instanceId": "2" + }, + { + "instanceId": "4" + }, + { + "instanceId": "11" + }, + { + "instanceId": "8" + } + ], + "materialized": true + }, + "subparts": { + "featureName": "subparts", + "values": [ + { + "instanceId": "3" + }, + { + "instanceId": "10" + }, + { + "instanceId": "7" + }, + { + "instanceId": "2" + }, + { + "instanceId": "4" + }, + { + "instanceId": "11" + }, + { + "instanceId": "8" + } + ], + "materialized": true + }, + "toolkit": { + "featureName": "toolkit", + "value": { + "instanceId": "10" + }, + "materialized": true + }, + "toolkit_flexo": { + "featureName": "toolkit_flexo", + "value": { + "instanceId": "11" + }, + "materialized": true + }, + "voids": { + "featureName": "voids", + "materialized": true + } + } + }, + { + "id": "2", + "typeSymbolId": "OpenSysMLStack::stack::flexo", + "featureValues": { + "api": { + "featureName": "api", + "value": { + "instanceId": "6" + }, + "materialized": true + }, + "boundingShapes": { + "featureName": "boundingShapes", + "materialized": true + }, + "checkedConstraints": { + "featureName": "checkedConstraints", + "materialized": true + }, + "envelopingShapes": { + "featureName": "envelopingShapes", + "materialized": true + }, + "exhibitedStates": { + "featureName": "exhibitedStates" + }, + "isSolid": { + "featureName": "isSolid", + "value": { + "boolValue": true + }, + "materialized": true + }, + "kind": { + "featureName": "kind", + "value": { + "stringValue": "model store" + }, + "materialized": true + }, + "label": { + "featureName": "label", + "value": { + "stringValue": "Flexo MMS" + }, + "materialized": true + }, + "lang": { + "featureName": "lang", + "value": { + "stringValue": "Kotlin" + }, + "materialized": true + }, + "ownedActions": { + "featureName": "ownedActions", + "materialized": true + }, + "ownedPorts": { + "featureName": "ownedPorts", + "values": [ + { + "instanceId": "6" + } + ], + "materialized": true + }, + "ownedStates": { + "featureName": "ownedStates", + "materialized": true + }, + "performedActions": { + "featureName": "performedActions" + }, + "repo": { + "featureName": "repo", + "value": { + "stringValue": "https://github.com/Open-MBEE/flexo-mms.openmbee.org" + }, + "materialized": true + }, + "role": { + "featureName": "role", + "value": { + "stringValue": "the versioned store" + }, + "materialized": true + }, + "shape": { + "featureName": "shape", + "value": { + "unset": true + }, + "materialized": true + }, + "subitems": { + "featureName": "subitems", + "materialized": true + }, + "subparts": { + "featureName": "subparts", + "materialized": true + }, + "voids": { + "featureName": "voids", + "materialized": true + } + } + }, + { + "id": "6", + "typeSymbolId": "OpenSysMLStack::ModelPort", + "featureValues": { + "interfacingPorts": { + "featureName": "interfacingPorts" + }, + "subports": { + "featureName": "subports", + "materialized": true + } + } + }, + { + "id": "3", + "typeSymbolId": "OpenSysMLStack::stack::opensysml", + "featureValues": { + "api": { + "featureName": "api", + "value": { + "instanceId": "5" + }, + "materialized": true + }, + "boundingShapes": { + "featureName": "boundingShapes", + "materialized": true + }, + "checkedConstraints": { + "featureName": "checkedConstraints", + "materialized": true + }, + "envelopingShapes": { + "featureName": "envelopingShapes", + "materialized": true + }, + "exhibitedStates": { + "featureName": "exhibitedStates" + }, + "isSolid": { + "featureName": "isSolid", + "value": { + "boolValue": true + }, + "materialized": true + }, + "kind": { + "featureName": "kind", + "value": { + "stringValue": "runtime" + }, + "materialized": true + }, + "label": { + "featureName": "label", + "value": { + "stringValue": "OpenSysML" + }, + "materialized": true + }, + "lang": { + "featureName": "lang", + "value": { + "stringValue": "Go" + }, + "materialized": true + }, + "ownedActions": { + "featureName": "ownedActions", + "materialized": true + }, + "ownedPorts": { + "featureName": "ownedPorts", + "values": [ + { + "instanceId": "5" + } + ], + "materialized": true + }, + "ownedStates": { + "featureName": "ownedStates", + "materialized": true + }, + "performedActions": { + "featureName": "performedActions" + }, + "repo": { + "featureName": "repo", + "value": { + "stringValue": "https://github.com/Open-MBEE/OpenSysML" + }, + "materialized": true + }, + "role": { + "featureName": "role", + "value": { + "stringValue": "runtime + LSP" + }, + "materialized": true + }, + "shape": { + "featureName": "shape", + "value": { + "unset": true + }, + "materialized": true + }, + "subitems": { + "featureName": "subitems", + "materialized": true + }, + "subparts": { + "featureName": "subparts", + "materialized": true + }, + "voids": { + "featureName": "voids", + "materialized": true + } + } + }, + { + "id": "5", + "typeSymbolId": "OpenSysMLStack::ModelPort", + "featureValues": { + "interfacingPorts": { + "featureName": "interfacingPorts" + }, + "subports": { + "featureName": "subports", + "materialized": true + } + } + }, + { + "id": "4", + "typeSymbolId": "OpenSysMLStack::Interchange", + "featureValues": { + "boundingShapes": { + "featureName": "boundingShapes", + "materialized": true + }, + "checkedConstraints": { + "featureName": "checkedConstraints", + "materialized": true + }, + "client": { + "featureName": "client", + "value": { + "instanceId": "5" + }, + "materialized": true + }, + "envelopingShapes": { + "featureName": "envelopingShapes", + "materialized": true + }, + "exhibitedStates": { + "featureName": "exhibitedStates" + }, + "isSolid": { + "featureName": "isSolid", + "value": { + "boolValue": true + }, + "materialized": true + }, + "ownedActions": { + "featureName": "ownedActions", + "materialized": true + }, + "ownedPorts": { + "featureName": "ownedPorts", + "materialized": true + }, + "ownedStates": { + "featureName": "ownedStates", + "materialized": true + }, + "participant": { + "featureName": "participant" + }, + "performedActions": { + "featureName": "performedActions" + }, + "shape": { + "featureName": "shape", + "value": { + "unset": true + }, + "materialized": true + }, + "source": { + "featureName": "source", + "value": { + "instanceId": "5" + }, + "materialized": true + }, + "store": { + "featureName": "store", + "value": { + "instanceId": "6" + }, + "materialized": true + }, + "subitems": { + "featureName": "subitems", + "materialized": true + }, + "subparts": { + "featureName": "subparts", + "materialized": true + }, + "target": { + "featureName": "target", + "value": { + "instanceId": "6" + }, + "materialized": true + }, + "voids": { + "featureName": "voids", + "materialized": true + } + } + }, + { + "id": "7", + "typeSymbolId": "OpenSysMLStack::stack::pilot", + "featureValues": { + "api": { + "featureName": "api", + "value": { + "instanceId": "9" + }, + "materialized": true + }, + "boundingShapes": { + "featureName": "boundingShapes", + "materialized": true + }, + "checkedConstraints": { + "featureName": "checkedConstraints", + "materialized": true + }, + "envelopingShapes": { + "featureName": "envelopingShapes", + "materialized": true + }, + "exhibitedStates": { + "featureName": "exhibitedStates" + }, + "isSolid": { + "featureName": "isSolid", + "value": { + "boolValue": true + }, + "materialized": true + }, + "kind": { + "featureName": "kind", + "value": { + "stringValue": "reference" + }, + "materialized": true + }, + "label": { + "featureName": "label", + "value": { + "stringValue": "SysML v2 Pilot Implementation" + }, + "materialized": true + }, + "lang": { + "featureName": "lang", + "value": { + "stringValue": "Java" + }, + "materialized": true + }, + "ownedActions": { + "featureName": "ownedActions", + "materialized": true + }, + "ownedPorts": { + "featureName": "ownedPorts", + "values": [ + { + "instanceId": "9" + } + ], + "materialized": true + }, + "ownedStates": { + "featureName": "ownedStates", + "materialized": true + }, + "performedActions": { + "featureName": "performedActions" + }, + "repo": { + "featureName": "repo", + "value": { + "stringValue": "https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation" + }, + "materialized": true + }, + "role": { + "featureName": "role", + "value": { + "stringValue": "OMG reference" + }, + "materialized": true + }, + "shape": { + "featureName": "shape", + "value": { + "unset": true + }, + "materialized": true + }, + "subitems": { + "featureName": "subitems", + "materialized": true + }, + "subparts": { + "featureName": "subparts", + "materialized": true + }, + "voids": { + "featureName": "voids", + "materialized": true + } + } + }, + { + "id": "9", + "typeSymbolId": "OpenSysMLStack::ModelPort", + "featureValues": { + "interfacingPorts": { + "featureName": "interfacingPorts" + }, + "subports": { + "featureName": "subports", + "materialized": true + } + } + }, + { + "id": "8", + "typeSymbolId": "OpenSysMLStack::Interchange", + "featureValues": { + "boundingShapes": { + "featureName": "boundingShapes", + "materialized": true + }, + "checkedConstraints": { + "featureName": "checkedConstraints", + "materialized": true + }, + "client": { + "featureName": "client", + "value": { + "instanceId": "9" + }, + "materialized": true + }, + "envelopingShapes": { + "featureName": "envelopingShapes", + "materialized": true + }, + "exhibitedStates": { + "featureName": "exhibitedStates" + }, + "isSolid": { + "featureName": "isSolid", + "value": { + "boolValue": true + }, + "materialized": true + }, + "ownedActions": { + "featureName": "ownedActions", + "materialized": true + }, + "ownedPorts": { + "featureName": "ownedPorts", + "materialized": true + }, + "ownedStates": { + "featureName": "ownedStates", + "materialized": true + }, + "participant": { + "featureName": "participant" + }, + "performedActions": { + "featureName": "performedActions" + }, + "shape": { + "featureName": "shape", + "value": { + "unset": true + }, + "materialized": true + }, + "source": { + "featureName": "source", + "value": { + "instanceId": "9" + }, + "materialized": true + }, + "store": { + "featureName": "store", + "value": { + "instanceId": "6" + }, + "materialized": true + }, + "subitems": { + "featureName": "subitems", + "materialized": true + }, + "subparts": { + "featureName": "subparts", + "materialized": true + }, + "target": { + "featureName": "target", + "value": { + "instanceId": "6" + }, + "materialized": true + }, + "voids": { + "featureName": "voids", + "materialized": true + } + } + }, + { + "id": "10", + "typeSymbolId": "OpenSysMLStack::stack::toolkit", + "featureValues": { + "api": { + "featureName": "api", + "value": { + "instanceId": "12" + }, + "materialized": true + }, + "boundingShapes": { + "featureName": "boundingShapes", + "materialized": true + }, + "checkedConstraints": { + "featureName": "checkedConstraints", + "materialized": true + }, + "envelopingShapes": { + "featureName": "envelopingShapes", + "materialized": true + }, + "exhibitedStates": { + "featureName": "exhibitedStates" + }, + "isSolid": { + "featureName": "isSolid", + "value": { + "boolValue": true + }, + "materialized": true + }, + "kind": { + "featureName": "kind", + "value": { + "stringValue": "toolchain" + }, + "materialized": true + }, + "label": { + "featureName": "label", + "value": { + "stringValue": "sysml-toolkit" + }, + "materialized": true + }, + "lang": { + "featureName": "lang", + "value": { + "stringValue": "Rust" + }, + "materialized": true + }, + "ownedActions": { + "featureName": "ownedActions", + "materialized": true + }, + "ownedPorts": { + "featureName": "ownedPorts", + "values": [ + { + "instanceId": "12" + } + ], + "materialized": true + }, + "ownedStates": { + "featureName": "ownedStates", + "materialized": true + }, + "performedActions": { + "featureName": "performedActions" + }, + "repo": { + "featureName": "repo", + "value": { + "stringValue": "https://github.com/Open-MBEE/sysml-toolkit" + }, + "materialized": true + }, + "role": { + "featureName": "role", + "value": { + "stringValue": "toolchain + LSP" + }, + "materialized": true + }, + "shape": { + "featureName": "shape", + "value": { + "unset": true + }, + "materialized": true + }, + "subitems": { + "featureName": "subitems", + "materialized": true + }, + "subparts": { + "featureName": "subparts", + "materialized": true + }, + "voids": { + "featureName": "voids", + "materialized": true + } + } + }, + { + "id": "12", + "typeSymbolId": "OpenSysMLStack::ModelPort", + "featureValues": { + "interfacingPorts": { + "featureName": "interfacingPorts" + }, + "subports": { + "featureName": "subports", + "materialized": true + } + } + }, + { + "id": "11", + "typeSymbolId": "OpenSysMLStack::Interchange", + "featureValues": { + "boundingShapes": { + "featureName": "boundingShapes", + "materialized": true + }, + "checkedConstraints": { + "featureName": "checkedConstraints", + "materialized": true + }, + "client": { + "featureName": "client", + "value": { + "instanceId": "12" + }, + "materialized": true + }, + "envelopingShapes": { + "featureName": "envelopingShapes", + "materialized": true + }, + "exhibitedStates": { + "featureName": "exhibitedStates" + }, + "isSolid": { + "featureName": "isSolid", + "value": { + "boolValue": true + }, + "materialized": true + }, + "ownedActions": { + "featureName": "ownedActions", + "materialized": true + }, + "ownedPorts": { + "featureName": "ownedPorts", + "materialized": true + }, + "ownedStates": { + "featureName": "ownedStates", + "materialized": true + }, + "participant": { + "featureName": "participant" + }, + "performedActions": { + "featureName": "performedActions" + }, + "shape": { + "featureName": "shape", + "value": { + "unset": true + }, + "materialized": true + }, + "source": { + "featureName": "source", + "value": { + "instanceId": "12" + }, + "materialized": true + }, + "store": { + "featureName": "store", + "value": { + "instanceId": "6" + }, + "materialized": true + }, + "subitems": { + "featureName": "subitems", + "materialized": true + }, + "subparts": { + "featureName": "subparts", + "materialized": true + }, + "target": { + "featureName": "target", + "value": { + "instanceId": "6" + }, + "materialized": true + }, + "voids": { + "featureName": "voids", + "materialized": true + } + } + } + ] +} diff --git a/editors/vscode/src/landing/tsconfig.json b/editors/vscode/src/landing/tsconfig.json new file mode 100644 index 0000000000..a4bdb1e780 --- /dev/null +++ b/editors/vscode/src/landing/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "resolveJsonModule": true, + "outDir": "../../out/landing" + }, + "exclude": [], + "include": ["../protocol.ts", "../style.ts", "../webview", "."] +} diff --git a/editors/vscode/src/webview/autolayout.test.ts b/editors/vscode/src/webview/autolayout.test.ts index ed41942898..aaaab620ee 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 { GAP, layoutCanvas, portFace, PORT_SIZE, type Box } from "./layout"; +import { GAP, layoutCanvas, portFace, type Box } from "./layout"; const origin = { uri: "file:///m.sysml", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 4 } }, digest: "d0" }; @@ -198,8 +198,8 @@ test("autoLayout places edge ports on opposite sides and routes to their faces", const [start, end] = [canvas.edges[0].points[0], canvas.edges[0].points.at(-1)!]; const startFace = portFace(a.box, aPort); const endFace = portFace(b.box, bPort); - assert.ok(Math.hypot(start.x - startFace.x, start.y - startFace.y) <= PORT_SIZE); - assert.ok(Math.hypot(end.x - endFace.x, end.y - endFace.y) <= PORT_SIZE); + assert.deepEqual(start, startFace); + assert.deepEqual(end, endFace); }); test("autoLayout keeps an unconnected port on the south after laying out connected ports", async () => { diff --git a/editors/vscode/src/webview/autolayout.ts b/editors/vscode/src/webview/autolayout.ts index 2910ef5638..dd5400f57f 100644 --- a/editors/vscode/src/webview/autolayout.ts +++ b/editors/vscode/src/webview/autolayout.ts @@ -119,6 +119,7 @@ async function layOut(result: RenderResult): Promise { id: port.id, width: PORT_SIZE, height: PORT_SIZE, + layoutOptions: { "elk.port.borderOffset": String(-PORT_SIZE / 2) }, labels: [{ text: port.name, width: label.width, height: label.height }], }; }); diff --git a/editors/vscode/src/webview/avoid.test.ts b/editors/vscode/src/webview/avoid.test.ts index c8ef8a6f19..3f8d559f47 100644 --- a/editors/vscode/src/webview/avoid.test.ts +++ b/editors/vscode/src/webview/avoid.test.ts @@ -2,12 +2,62 @@ import assert from "node:assert/strict"; import path from "node:path"; import { test } from "node:test"; -import { avoidRoutes, CLEARANCE, loadAvoid, type AvoidPort, type AvoidShape } from "./avoid"; +import type { RenderEdge, RenderNode, RenderPoint, RenderResult } from "../protocol"; +import { + avoidRoutes, + CLEARANCE, + loadAvoid, + MIN_JOG, + NUDGING, + portExitReach, + routingObstacle, + routingPinPositions, + straightenJogs, + type AvoidPort, + type AvoidShape, + type RoutingObstacle, +} from "./avoid"; import { portFace, PORT_SIZE, type Box, type Side } from "./geometry"; -import type { RenderPoint } from "../protocol"; +import { clampNodeToBounds, layoutCanvas } from "./layout"; const WASM = path.resolve("node_modules/libavoid-js/dist/libavoid.wasm"); const SCENE_SEED = 0x6d2b79f5; +const origin = { + uri: "file:///m.sysml", + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 4 } }, + digest: "d0", +}; + +function node(id: string, x: number, y: number, ports: RenderNode["ports"] = []): RenderNode { + return { + id, + kind: "part", + name: id, + type: "", + detail: "", + fqn: `M::${id}`, + origin, + x, + y, + width: 80, + height: 40, + ports, + }; +} + +function rendering(nodes: RenderNode[], edges: RenderEdge[]): RenderResult { + return { + view: "M::V", + kind: "interconnection", + stated: "", + form: "mermaid", + artifact: "", + nodes, + edges, + notices: [], + version: 7, + }; +} interface Pair { source: number; @@ -96,6 +146,221 @@ function randomSharedTargetPairs(boxCount: number, seed: number): Pair[] { return indices.slice(1, 4).map((source) => ({ source, target })); } +test("portExitReach reserves the lane reach for each shared connection", () => { + assert.equal(NUDGING, 8); + assert.deepEqual([0, 1, 2, 3].map(portExitReach), [ + CLEARANCE, + CLEARANCE, + CLEARANCE + NUDGING, + CLEARANCE + 2 * NUDGING, + ]); +}); + +test("straightenJogs removes a short Z jog without moving its endpoints", () => { + const route = [ + { x: -40, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 24 }, + { x: 8, y: 24 }, + { x: 8, y: 64 }, + { x: 40, y: 64 }, + ]; + const routes = new Map([[0, route]]); + const straightened = straightenJogs(routes, new Map(), new Map()); + const result = straightened.get(0)!; + + assert.equal(MIN_JOG, 16); + assert.notEqual(straightened, routes); + assert.notEqual(result, route); + assert.equal(result.length, 4); + assert.deepEqual(result[0], route[0]); + assert.deepEqual(result.at(-1), route.at(-1)); + assert.deepEqual(result[1], { x: 8, y: 0 }); + assert.deepEqual(route[2], { x: 0, y: 24 }); + for (let index = 1; index < result.length; index++) { + assert.ok(result[index - 1].x === result[index].x || result[index - 1].y === result[index].y); + } +}); + +test("straightenJogs keeps a jog of exactly one grid square", () => { + const route = [ + { x: -40, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 40 }, + { x: 16, y: 40 }, + { x: 16, y: 80 }, + { x: 40, y: 80 }, + ]; + assert.deepEqual(straightenJogs(new Map([[0, route]]), new Map(), new Map()).get(0), route); +}); + +test("straightenJogs keeps a short jog forced by facing endpoints", () => { + const route = [ + { x: 0, y: 0 }, + { x: 16, y: 0 }, + { x: 16, y: 6 }, + { x: 100, y: 6 }, + ]; + assert.deepEqual(straightenJogs(new Map([[0, route]]), new Map(), new Map()).get(0), route); +}); + +test("straightenJogs leaves a U-turn unchanged", () => { + const route = [ + { x: 0, y: 0 }, + { x: 0, y: 40 }, + { x: 8, y: 40 }, + { x: 8, y: 0 }, + { x: 40, y: 0 }, + ]; + assert.deepEqual(straightenJogs(new Map([[0, route]]), new Map(), new Map()).get(0), route); +}); + +test("straightenJogs tries the other shift when the shorter one crosses an obstacle", () => { + const route = [ + { x: -40, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 40 }, + { x: 8, y: 40 }, + { x: 8, y: 80 }, + { x: 40, y: 80 }, + ]; + const box = { x: 7, y: 10, width: 2, height: 20 }; + const obstacles = new Map([["obstacle", { routing: box, raw: box }]]); + const result = straightenJogs(new Map([[0, route]]), obstacles, new Map()).get(0)!; + + assert.ok(result.length < route.length); + assert.deepEqual(result[0], route[0]); + assert.deepEqual(result.at(-1), route.at(-1)); + assert.equal(result[1].x, 0); +}); + +test("straightenJogs keeps a jog when both shifts cross obstacles", () => { + const route = [ + { x: -40, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 40 }, + { x: 8, y: 40 }, + { x: 8, y: 80 }, + { x: 40, y: 80 }, + ]; + const first = { x: 7, y: 10, width: 2, height: 20 }; + const second = { x: -1, y: 50, width: 2, height: 20 }; + const obstacles = new Map([ + ["first", { routing: first, raw: first }], + ["second", { routing: second, raw: second }], + ]); + assert.deepEqual(straightenJogs(new Map([[0, route]]), obstacles, new Map()).get(0), route); +}); + +test("straightenJogs preserves the routing clearance from shape buffers", () => { + const route = [ + { x: -40, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 40 }, + { x: 15, y: 40 }, + { x: 15, y: 100 }, + { x: 45, y: 100 }, + ]; + const raw = { x: 16, y: -100, width: 40, height: 100 }; + const obstacle = routingObstacle({ box: raw }); + const routes = new Map([[0, route]]); + const result = straightenJogs( + routes, + new Map([["obstacle", obstacle]]), + new Map([[0, ["source", "target"]]]), + ).get(0)!; + + assert.ok(result.length < route.length); + assert.deepEqual(result[1], { x: 0, y: 0 }); + assert.deepEqual(result[2], { x: 0, y: 100 }); +}); + +test("straightenJogs keeps the port exit leg at least its clearance", () => { + const route = [ + { x: 0, y: 0 }, + { x: 16, y: 0 }, + { x: 16, y: 40 }, + { x: 8, y: 40 }, + { x: 8, y: 80 }, + { x: 40, y: 80 }, + ]; + const result = straightenJogs(new Map([[0, route]]), new Map(), new Map()).get(0)!; + + assert.deepEqual(result[0], route[0]); + assert.deepEqual(result.at(-1), route.at(-1)); + assert.ok(Math.abs(result[1].x - result[0].x) + Math.abs(result[1].y - result[0].y) >= CLEARANCE); + assert.ok(result.length < route.length); +}); + +test("straightenJogs keeps a lane away from a nearby parallel route", () => { + const route = [ + { x: -40, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 40 }, + { x: 8, y: 40 }, + { x: 8, y: 80 }, + { x: 40, y: 80 }, + ]; + const nearby = [{ x: 4, y: -20 }, { x: 4, y: 100 }]; + const routes = new Map([ + [0, route], + [1, nearby], + ]); + assert.deepEqual(straightenJogs(routes, new Map(), new Map()).get(0), route); +}); + +test("straightenJogs rejects a shift that crosses another route when the other shift is legal", () => { + const route = [ + { x: -30, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 40 }, + { x: 15, y: 40 }, + { x: 15, y: 80 }, + { x: 45, y: 80 }, + ]; + const crossing = [{ x: 12, y: 20 }, { x: 30, y: 20 }]; + const routes = new Map([ + [0, route], + [1, crossing], + ]); + const result = straightenJogs(routes, new Map(), new Map()).get(0)!; + + assert.ok(result.length < route.length); + assert.deepEqual(result.slice(1, 3), [{ x: 0, y: 0 }, { x: 0, y: 80 }]); +}); + +test("straightenJogs keeps a jog when both shifts would add crossings", () => { + const route = [ + { x: -30, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 40 }, + { x: 15, y: 40 }, + { x: 15, y: 80 }, + { x: 45, y: 80 }, + ]; + const crossingAbove = [{ x: 12, y: 20 }, { x: 30, y: 20 }]; + const crossingBelow = [{ x: -10, y: 60 }, { x: 10, y: 60 }]; + const routes = new Map([ + [0, route], + [1, crossingAbove], + [2, crossingBelow], + ]); + + assert.deepEqual(straightenJogs(routes, new Map(), new Map()).get(0), route); +}); + +test("straightenJogs rejects a shift that leaves the routing bounds", () => { + const route = [ + { x: 8, y: 0 }, + { x: 8, y: 20 }, + { x: 0, y: 20 }, + { x: 0, y: 60 }, + { x: 40, y: 60 }, + ]; + const bounds: Box = { x: 0, y: 0, width: 4, height: 80 }; + assert.deepEqual(straightenJogs(new Map([[0, route]]), new Map(), new Map(), bounds).get(0), route); +}); + function onBorder(point: RenderPoint, box: Box): boolean { return ( ((point.x === box.x || point.x === box.x + box.width) && point.y >= box.y && point.y <= box.y + box.height) || @@ -207,6 +472,40 @@ function assertRoutes(boxes: Box[], pairs: Pair[], routes: Array, +): void { + const current = new Map(); + routes.forEach((route, index) => { + if (route) { + current.set(index, route); + } + }); + const obstacles = new Map(); + boxes.forEach((box, index) => { + obstacles.set(`node-${index}`, routingObstacle({ box })); + }); + const ends = new Map(); + pairs.forEach(({ source, target }, index) => { + ends.set(index, [`node-${source}`, `node-${target}`]); + }); + const straightened = straightenJogs(current, obstacles, ends); + for (const [index, route] of current) { + const result = straightened.get(index)!; + assert.deepEqual(result, route, `straightening changed seeded route ${index} a second time`); + assert.deepEqual(result[0], route[0], `straightening moved route ${index}'s first endpoint`); + assert.deepEqual(result.at(-1), route.at(-1), `straightening moved route ${index}'s last endpoint`); + for (let segment = 1; segment < result.length; segment++) { + assert.ok( + result[segment - 1].x === result[segment].x || result[segment - 1].y === result[segment].y, + `straightening made seeded route ${index} non-orthogonal`, + ); + } + } +} + const samples = 500; const extraPairSeed = (sample: number): number => (0x9e3779b9 ^ Math.imul(sample + 1, 0x85ebca6b)) >>> 0; const sharedBoxSeed = (sample: number): number => (0x243f6a88 ^ Math.imul(sample + 1, 0x9e3779b1)) >>> 0; @@ -217,7 +516,9 @@ test("avoidRoutes routes every edge orthogonally over 500 seeded scenes", async let sample = 0; for (const { boxes, pair } of randomScenes(samples)) { const pairs = [pair, ...randomPairs(boxes.length, 2, extraPairSeed(sample))]; - routed += assertRoutes(boxes, pairs, routeScene(boxes, pairs)); + const routes = routeScene(boxes, pairs); + routed += assertRoutes(boxes, pairs, routes); + assertStraighteningIdempotent(boxes, pairs, routes); sample++; } assert.equal(routed, samples * 3); @@ -230,7 +531,9 @@ test("avoidRoutes separates three sources sharing one target over 500 seeded sce for (const { boxes } of randomScenes(samples)) { const grown = completeScene(boxes, 4, sharedBoxSeed(sample)); const pairs = randomSharedTargetPairs(grown.length, extraPairSeed(sample)); - routed += assertRoutes(grown, pairs, routeScene(grown, pairs)); + const routes = routeScene(grown, pairs); + routed += assertRoutes(grown, pairs, routes); + assertStraighteningIdempotent(grown, pairs, routes); sample++; } assert.equal(routed, samples * 3); @@ -285,6 +588,92 @@ test("avoidRoutes sends same-side edges to their distinct port faces", async () } }); +test("avoidRoutes keeps three shared-port lanes inside the bounds after clamping", async () => { + await loadAvoid(WASM); + const edges: RenderEdge[] = Array.from({ length: 3 }, (_, index) => ({ + from: `source-${index}`, + to: "target", + toPort: "target.in", + label: "", + kind: "connection", + fqn: `M::edge${index}`, + })); + const layout = layoutCanvas(rendering([ + node("source-0", 300, 20), + node("source-1", 300, 140), + node("source-2", 300, 260), + node("target", 600, 140, [{ id: "target.in", name: "in" }]), + ], edges)); + const target = layout.nodes.get("target")!; + const port = target.ports[0]; + assert.equal(port.side, "west"); + const bounds: Box = { x: 0, y: 0, width: 700, height: 400 }; + const at = clampNodeToBounds(target, { ...target.box, x: -1000 }, bounds, portExitReach(3)); + const targetBox = { ...target.box, ...at }; + assert.equal(portFace(targetBox, port).x - portExitReach(3), bounds.x); + const shapes = new Map( + [...layout.nodes].map(([id, entry]) => [ + id, + { + box: id === "target" ? targetBox : entry.box, + ...(id === "target" + ? { ports: target.ports.map(({ port: placed, side, offset }) => ({ id: placed.id, side, offset })) } + : {}), + }, + ]), + ); + const routes = avoidRoutes( + shapes, + edges.map((edge, index) => ({ + index, + from: edge.from, + to: edge.to, + toPort: edge.toPort, + })), + ); + assert.ok(routes); + for (const index of edges.keys()) { + const route = routes.get(index); + assert.ok(route && route.length >= 2); + assert.ok(route.every(({ x }) => x >= bounds.x), `route is clipped by the hero edge: ${JSON.stringify(route)}`); + } +}); + +test("avoidRoutes bounds an orthogonal port route that otherwise escapes below an obstacle", async () => { + await loadAvoid(WASM); + const bounds: Box = { x: 0, y: 0, width: 500, height: 260 }; + const source = { x: 50, y: 190, width: 80, height: 40 }; + const target = { x: 370, y: 190, width: 80, height: 40 }; + const obstacle = { x: 210, y: 80, width: 80, height: 172 }; + const sourcePort: AvoidPort = { id: "source.out", side: "east", offset: 0.5 }; + const targetPort: AvoidPort = { id: "target.in", side: "west", offset: 0.5 }; + const shapes = new Map([ + ["source", { box: source, ports: [sourcePort] }], + ["target", { box: target, ports: [targetPort] }], + ["obstacle", { box: obstacle }], + ]); + const edges = [{ index: 0, from: "source", to: "target", fromPort: sourcePort.id, toPort: targetPort.id }]; + const unbounded = avoidRoutes(shapes, edges); + assert.ok(unbounded); + const unboundedRoute = unbounded.get(0); + assert.ok(unboundedRoute); + assert.ok( + unboundedRoute.some(({ x, y }) => x < bounds.x || y < bounds.y || x > bounds.x + bounds.width || y > bounds.y + bounds.height), + `expected an unbounded route to escape: ${JSON.stringify(unboundedRoute)}`, + ); + + const bounded = avoidRoutes(shapes, edges, bounds); + assert.ok(bounded); + const route = bounded.get(0); + assert.ok(route && route.length >= 2); + assert.deepEqual(route[0], portFace(source, sourcePort)); + assert.deepEqual(route.at(-1), portFace(target, targetPort)); + assert.ok(route.every(({ x, y }) => x >= bounds.x && y >= bounds.y && x <= bounds.x + bounds.width && y <= bounds.y + bounds.height)); + for (let index = 1; index < route.length; index++) { + assert.ok(route[index - 1].x === route[index].x || route[index - 1].y === route[index].y); + } +}); + test("avoidRoutes keeps the generic pin on the node box when another side has a port", async () => { await loadAvoid(WASM); const source = { x: 0, y: 0, width: 100, height: 60 }; @@ -300,6 +689,21 @@ test("avoidRoutes keeps the generic pin on the node box when another side has a assert.equal(route[0].x, source.x + source.width); }); +test("routingPinPositions bounds generic and named portions on fractional west-port boxes", () => { + const pins = routingPinPositions({ + box: { x: -169.9174346923828, y: 417.2325134277344, width: 234, height: 70 }, + ports: [{ id: "n19.api", side: "west", offset: 0.5 }], + }); + + assert.equal(pins.length, 13); + for (const { x, y } of pins) { + assert.ok(x >= 0 && x <= 1, `x portion ${x} was outside [0, 1]`); + assert.ok(y >= 0 && y <= 1, `y portion ${y} was outside [0, 1]`); + } + assert.deepEqual(pins.at(-1), { id: "n19.api", side: "west", x: 0, y: 0.5 }); + assert.ok(pins.filter(({ side }) => side === "east").every(({ x }) => x === 1)); +}); + test("avoidRoutes keeps an unported route when a distant ported connection is added", async () => { await loadAvoid(WASM); const source = { x: 0, y: 40, width: 80, height: 40 }; diff --git a/editors/vscode/src/webview/avoid.ts b/editors/vscode/src/webview/avoid.ts index 029461b9b9..c878f61544 100644 --- a/editors/vscode/src/webview/avoid.ts +++ b/editors/vscode/src/webview/avoid.ts @@ -6,8 +6,14 @@ import { GAP, portFace, PORT_SIZE, snap, type Box, type PortPosition } from "./g import type { RenderPoint } from "../protocol"; export const CLEARANCE = GAP / 2; +export const NUDGING = 8; +export const MIN_JOG = 16; export const EXCLUSIVE_PIN_LIMIT = 12; +export function portExitReach(sharing: number): number { + return CLEARANCE + NUDGING * Math.max(0, sharing - 1); +} + type Avoid = ReturnType; type ShapeRef = InstanceType; type ConnRef = InstanceType; @@ -57,15 +63,27 @@ interface ShapePins { ports: Map; } +/** Holds a shape's routing-clearance bounds and its raw interior. */ +export interface RoutingObstacle { + routing: Box; + raw: Box; +} + /** The route libavoid finds for each edge, by edge index; undefined when the router is not loaded or the pass fails. */ -export function avoidRoutes(shapes: Map, edges: AvoidEdge[]): Map | undefined { +export function avoidRoutes( + shapes: Map, + edges: AvoidEdge[], + /** Routes stay inside bounds. */ + bounds?: Box, +): Map | undefined { if (!avoid) { return undefined; } const api = avoid; const router = new api.Router(api.OrthogonalRouting); + const frameRefs: ShapeRef[] = []; try { - router.setRoutingParameter(api.idealNudgingDistance, 8); + router.setRoutingParameter(api.idealNudgingDistance, NUDGING); router.setRoutingParameter(api.segmentPenalty, 50); router.setRoutingOption(api.nudgeSharedPathsWithCommonEndPoint, true); router.setRoutingOption(api.performUnifyingNudgingPreprocessingStep, true); @@ -91,6 +109,9 @@ export function avoidRoutes(shapes: Map, edges: AvoidEdge[]) addShape(api, router, shape, (counts.get(id) ?? 0) <= EXCLUSIVE_PIN_LIMIT, () => nextPortClass++), ); } + if (bounds) { + frameRefs.push(...addBoundsFrames(api, router, bounds)); + } const connectors: Array<{ index: number; connector: ConnRef; edge: AvoidEdge }> = []; for (const edge of usable) { @@ -140,7 +161,15 @@ export function avoidRoutes(shapes: Map, edges: AvoidEdge[]) routes.set(index, route); } } - return routes; + const obstacles = new Map(); + for (const [id, shape] of shapes) { + obstacles.set(id, routingObstacle(shape)); + } + const ends = new Map(); + for (const edge of usable) { + ends.set(edge.index, [edge.from, edge.to]); + } + return straightenJogs(routes, obstacles, ends, bounds); } catch (error) { reportAvoidFailure("routing failed", error); return undefined; @@ -149,27 +178,505 @@ export function avoidRoutes(shapes: Map, edges: AvoidEdge[]) } } -// addShape is a box as a routing obstacle, with twelve proportional pins spread -// over its sides for connectors to attach at. -function addShape( +interface OrthogonalSegment { + axis: "horizontal" | "vertical"; + direction: -1 | 1; + length: number; +} + +interface JogShift { + points: [number, number]; + movedSegment: number; + changedSegment: number; + mergedSegment: [number, number]; + delta: RenderPoint; +} + +/** Straightens short Z-jogs without losing clearance or adding wire crossings. */ +export function straightenJogs( + routes: Map, + obstacles: Map, + ends: Map, + bounds?: Box, +): Map { + const straightened = new Map(); + for (const [index, route] of routes) { + straightened.set(index, route.map(({ x, y }) => ({ x, y }))); + } + + while (true) { + let changed = false; + for (const [index, route] of straightened) { + for (let jog = 1; jog + 2 < route.length; jog++) { + const shifts = jogShifts(route, jog); + const ordered = shifts.sort( + (first, second) => + segmentLength(route[first.movedSegment], route[first.movedSegment + 1]) - + segmentLength(route[second.movedSegment], route[second.movedSegment + 1]), + ); + for (const shift of ordered) { + const candidate = shiftedRoute(route, shift); + if (!candidate || !legalShift(index, route, candidate, shift, straightened, obstacles, ends, bounds)) { + continue; + } + straightened.set(index, compactRoute(candidate)); + changed = true; + break; + } + if (changed) { + break; + } + } + if (changed) { + break; + } + } + if (!changed) { + return straightened; + } + } +} + +function jogShifts(route: RenderPoint[], jog: number): JogShift[] { + const previous = orthogonalSegment(route[jog - 1], route[jog]); + const short = orthogonalSegment(route[jog], route[jog + 1]); + const next = orthogonalSegment(route[jog + 1], route[jog + 2]); + if ( + !previous || + !short || + !next || + short.length >= MIN_JOG || + previous.axis !== next.axis || + previous.axis === short.axis || + previous.direction !== next.direction + ) { + return []; + } + + const delta = { x: route[jog + 1].x - route[jog].x, y: route[jog + 1].y - route[jog].y }; + const shifts: JogShift[] = []; + if (jog > 1) { + shifts.push({ + points: [jog - 1, jog], + movedSegment: jog - 1, + changedSegment: jog - 2, + mergedSegment: [jog - 1, jog + 2], + delta, + }); + } + if (jog + 2 < route.length - 1) { + shifts.push({ + points: [jog + 1, jog + 2], + movedSegment: jog + 1, + changedSegment: jog + 2, + mergedSegment: [jog - 1, jog + 2], + delta: { x: -delta.x, y: -delta.y }, + }); + } + return shifts; +} + +function shiftedRoute(route: RenderPoint[], shift: JogShift): RenderPoint[] { + return route.map((point, index) => + shift.points.includes(index) + ? { x: point.x + shift.delta.x, y: point.y + shift.delta.y } + : { x: point.x, y: point.y }, + ); +} + +function legalShift( + index: number, + route: RenderPoint[], + candidate: RenderPoint[], + shift: JogShift, + routes: Map, + obstacles: Map, + ends: Map, + bounds?: Box, +): boolean { + if (bounds && shift.points.some((point) => !inside(candidate[point], bounds))) { + return false; + } + for (const segmentIndex of [shift.movedSegment, shift.changedSegment]) { + const before = orthogonalSegment(route[segmentIndex], route[segmentIndex + 1]); + const after = orthogonalSegment(candidate[segmentIndex], candidate[segmentIndex + 1]); + if (!before || !after || before.axis !== after.axis || before.direction !== after.direction) { + return false; + } + if ( + (segmentIndex === 0 || segmentIndex === route.length - 2) && + after.length < Math.min(before.length, CLEARANCE) + ) { + return false; + } + if (crossesObstacle(index, candidate[segmentIndex], candidate[segmentIndex + 1], obstacles, ends)) { + return false; + } + for (const [otherIndex, other] of routes) { + if ( + otherIndex !== index && + other.slice(1).some((point, otherSegment) => + lanesTooClose( + candidate[segmentIndex], + candidate[segmentIndex + 1], + other[otherSegment], + point, + ), + ) + ) { + return false; + } + } + } + const [mergeStart, mergeEnd] = shift.mergedSegment; + const merged = orthogonalSegment(candidate[mergeStart], candidate[mergeEnd]); + if (!merged) { + return false; + } + if ( + bounds && + (!inside(candidate[mergeStart], bounds) || !inside(candidate[mergeEnd], bounds)) + ) { + return false; + } + if (crossesObstacle(index, candidate[mergeStart], candidate[mergeEnd], obstacles, ends)) { + return false; + } + if ( + (mergeStart === 0 && + merged.length < Math.min(segmentLength(route[0], route[1]), CLEARANCE)) || + (mergeEnd === route.length - 1 && + merged.length < Math.min(segmentLength(route.at(-2)!, route.at(-1)!), CLEARANCE)) + ) { + return false; + } + for (const [otherIndex, other] of routes) { + if ( + otherIndex !== index && + other.slice(1).some((point, otherSegment) => + lanesTooClose( + candidate[mergeStart], + candidate[mergeEnd], + other[otherSegment], + point, + ), + ) + ) { + return false; + } + } + return !addsCrossings(index, route, candidate, routes); +} + +function crossesObstacle( + route: number, + a: RenderPoint, + b: RenderPoint, + obstacles: Map, + ends: Map, +): boolean { + const routeEnds: string[] = ends.get(route) ?? []; + for (const [id, obstacle] of obstacles) { + const box = routeEnds.includes(id) ? obstacle.raw : obstacle.routing; + if (crossesInterior(a, b, box)) { + return true; + } + } + return false; +} + +function addsCrossings( + index: number, + route: RenderPoint[], + candidate: RenderPoint[], + routes: Map, +): boolean { + for (const [otherIndex, other] of routes) { + if (otherIndex !== index && properCrossings(candidate, other) > properCrossings(route, other)) { + return true; + } + } + return false; +} + +function properCrossings(route: RenderPoint[], other: RenderPoint[]): number { + let crossings = 0; + for (let first = 1; first < route.length; first++) { + for (let second = 1; second < other.length; second++) { + if (properlyCrosses(route[first - 1], route[first], other[second - 1], other[second])) { + crossings++; + } + } + } + return crossings; +} + +function properlyCrosses(a: RenderPoint, b: RenderPoint, c: RenderPoint, d: RenderPoint): boolean { + const first = orthogonalSegment(a, b); + const second = orthogonalSegment(c, d); + if (!first || !second || first.axis === second.axis) { + return false; + } + const horizontalStart = first.axis === "horizontal" ? a : c; + const horizontalEnd = first.axis === "horizontal" ? b : d; + const verticalStart = first.axis === "vertical" ? a : c; + const verticalEnd = first.axis === "vertical" ? b : d; + const x = verticalStart.x; + const y = horizontalStart.y; + return ( + x > Math.min(horizontalStart.x, horizontalEnd.x) && + x < Math.max(horizontalStart.x, horizontalEnd.x) && + y > Math.min(verticalStart.y, verticalEnd.y) && + y < Math.max(verticalStart.y, verticalEnd.y) + ); +} + +function orthogonalSegment(a: RenderPoint, b: RenderPoint): OrthogonalSegment | undefined { + if (a.x === b.x && a.y !== b.y) { + return { axis: "vertical", direction: Math.sign(b.y - a.y) as -1 | 1, length: Math.abs(b.y - a.y) }; + } + if (a.y === b.y && a.x !== b.x) { + return { axis: "horizontal", direction: Math.sign(b.x - a.x) as -1 | 1, length: Math.abs(b.x - a.x) }; + } + return undefined; +} + +function segmentLength(a: RenderPoint, b: RenderPoint): number { + return Math.abs(b.x - a.x) + Math.abs(b.y - a.y); +} + +function inside(point: RenderPoint, bounds: Box): boolean { + return ( + point.x >= bounds.x && + point.y >= bounds.y && + point.x <= bounds.x + bounds.width && + point.y <= bounds.y + bounds.height + ); +} + +function crossesInterior(a: RenderPoint, b: RenderPoint, box: Box): boolean { + if (a.y === b.y) { + return ( + a.y > box.y && + a.y < box.y + box.height && + Math.max(Math.min(a.x, b.x), box.x) < Math.min(Math.max(a.x, b.x), box.x + box.width) + ); + } + return ( + a.x > box.x && + a.x < box.x + box.width && + Math.max(Math.min(a.y, b.y), box.y) < Math.min(Math.max(a.y, b.y), box.y + box.height) + ); +} + +function lanesTooClose(a: RenderPoint, b: RenderPoint, c: RenderPoint, d: RenderPoint): boolean { + const first = orthogonalSegment(a, b); + const second = orthogonalSegment(c, d); + if (!first || !second || first.axis !== second.axis) { + return false; + } + if (first.axis === "horizontal") { + return ( + Math.abs(a.y - c.y) < NUDGING && + Math.min(Math.max(a.x, b.x), Math.max(c.x, d.x)) > + Math.max(Math.min(a.x, b.x), Math.min(c.x, d.x)) + ); + } + return ( + Math.abs(a.x - c.x) < NUDGING && + Math.min(Math.max(a.y, b.y), Math.max(c.y, d.y)) > + Math.max(Math.min(a.y, b.y), Math.min(c.y, d.y)) + ); +} + +/** Removes zero-length and same-direction collinear points from an orthogonal route. */ +export function compactRoute(route: RenderPoint[]): RenderPoint[] { + const compacted: RenderPoint[] = []; + for (const point of route) { + if (compacted.at(-1)?.x === point.x && compacted.at(-1)?.y === point.y) { + continue; + } + while (compacted.length >= 2) { + const previous = orthogonalSegment(compacted.at(-2)!, compacted.at(-1)!); + const next = orthogonalSegment(compacted.at(-1)!, point); + if (!previous || !next || previous.axis !== next.axis || previous.direction !== next.direction) { + break; + } + compacted.pop(); + } + compacted.push(point); + } + return compacted; +} + +function addBoundsFrames( api: Avoid, router: InstanceType, - shapeData: AvoidShape, - exclusive: boolean, - allocatePortClass: () => number, -): ShapePins { + bounds: Box, +): ShapeRef[] { + const thickness = Math.max(bounds.width, bounds.height, CLEARANCE); + const horizontalWidth = bounds.width + 2 * thickness; + const verticalHeight = bounds.height + 2 * thickness; + const frames: Box[] = [ + { + x: bounds.x - thickness, + y: bounds.y - CLEARANCE - thickness, + width: horizontalWidth, + height: thickness, + }, + { + x: bounds.x - thickness, + y: bounds.y + bounds.height + CLEARANCE, + width: horizontalWidth, + height: thickness, + }, + { + x: bounds.x - CLEARANCE - thickness, + y: bounds.y - thickness, + width: thickness, + height: verticalHeight, + }, + { + x: bounds.x + bounds.width + CLEARANCE, + y: bounds.y - thickness, + width: thickness, + height: verticalHeight, + }, + ]; + return frames.map((box) => { + const center = new api.Point(box.x + box.width / 2, box.y + box.height / 2); + const rectangle = new api.Rectangle(center, box.width, box.height); + try { + return new api.ShapeRef(router, rectangle); + } finally { + api.destroy(rectangle); + api.destroy(center); + } + }); +} + +function shapeRoutingBox(shapeData: AvoidShape): Box { const box = shapeData.box; const portsOn = (side: PortPosition["side"]) => shapeData.ports?.some((port) => port.side === side) ?? false; const westGrowth = portsOn("west") ? PORT_SIZE / 2 : 0; const eastGrowth = portsOn("east") ? PORT_SIZE / 2 : 0; const northGrowth = portsOn("north") ? PORT_SIZE / 2 : 0; const southGrowth = portsOn("south") ? PORT_SIZE / 2 : 0; - const routingBox = { + return { x: box.x - westGrowth, y: box.y - northGrowth, width: box.width + westGrowth + eastGrowth, height: box.height + northGrowth + southGrowth, }; +} + +/** Builds the routing geometry shared by libavoid and route simplification. */ +export function routingObstacle(shapeData: AvoidShape): RoutingObstacle { + const box = shapeRoutingBox(shapeData); + return { + routing: { + x: box.x - CLEARANCE, + y: box.y - CLEARANCE, + width: box.width + 2 * CLEARANCE, + height: box.height + 2 * CLEARANCE, + }, + raw: shapeData.box, + }; +} + +export interface RoutingPinPosition { + id?: string; + side: PortPosition["side"]; + x: number; + y: number; +} + +/** Computes bounded libavoid portions for a shape's generic and named pins. */ +export function routingPinPositions(shapeData: AvoidShape): RoutingPinPosition[] { + const box = shapeData.box; + const routingBox = shapeRoutingBox(shapeData); + const offsets = [0.25, 0.5, 0.75] as const; + const generic: RoutingPinPosition[] = [ + ...offsets.map((offset) => ({ side: "north" as const, x: box.x + box.width * offset, y: box.y })), + ...offsets.map((offset) => ({ + side: "east" as const, + x: box.x + box.width, + y: box.y + box.height * offset, + })), + ...offsets.map((offset) => ({ + side: "south" as const, + x: box.x + box.width * offset, + y: box.y + box.height, + })), + ...offsets.map((offset) => ({ side: "west" as const, x: box.x, y: box.y + box.height * offset })), + ]; + const named = (shapeData.ports ?? []).map((port) => ({ + id: port.id, + side: port.side, + ...portFace(box, port), + })); + const portion = (value: number): number => Math.max(0, Math.min(1, value)); + const genericPins = generic.map(({ side, x, y }) => { + switch (side) { + case "north": + return { + side, + x: portion((x - routingBox.x) / routingBox.width), + y: shapeData.ports?.some((port) => port.side === side) + ? portion((box.y - routingBox.y) / routingBox.height) + : 0, + }; + case "east": + return { + side, + x: shapeData.ports?.some((port) => port.side === side) + ? portion((box.x + box.width - routingBox.x) / routingBox.width) + : 1, + y: portion((y - routingBox.y) / routingBox.height), + }; + case "south": + return { + side, + x: portion((x - routingBox.x) / routingBox.width), + y: shapeData.ports?.some((port) => port.side === side) + ? portion((box.y + box.height - routingBox.y) / routingBox.height) + : 1, + }; + case "west": + return { + side, + x: shapeData.ports?.some((port) => port.side === side) + ? portion((box.x - routingBox.x) / routingBox.width) + : 0, + y: portion((y - routingBox.y) / routingBox.height), + }; + } + }); + const namedPins = named.map(({ id, side, x, y }) => { + switch (side) { + case "north": + return { id, side, x: portion((x - routingBox.x) / routingBox.width), y: 0 }; + case "east": + return { id, side, x: 1, y: portion((y - routingBox.y) / routingBox.height) }; + case "south": + return { id, side, x: portion((x - routingBox.x) / routingBox.width), y: 1 }; + case "west": + return { id, side, x: 0, y: portion((y - routingBox.y) / routingBox.height) }; + } + }); + return [...genericPins, ...namedPins]; +} + +// addShape is a box as a routing obstacle, with twelve proportional pins spread +// over its sides for connectors to attach at. +function addShape( + api: Avoid, + router: InstanceType, + shapeData: AvoidShape, + exclusive: boolean, + allocatePortClass: () => number, +): ShapePins { + const routingBox = shapeRoutingBox(shapeData); const center = new api.Point(routingBox.x + routingBox.width / 2, routingBox.y + routingBox.height / 2); const rectangle = new api.Rectangle(center, routingBox.width, routingBox.height); let shape: ShapeRef; @@ -180,52 +687,30 @@ function addShape( api.destroy(center); } - const offsets = [0.25, 0.5, 0.75] as const; - const position = (x: number, y: number, direction: number) => - [(x - routingBox.x) / routingBox.width, (y - routingBox.y) / routingBox.height, direction] as const; - const pins = [ - ...offsets.map((offset) => position(box.x + box.width * offset, box.y, api.ConnDirUp)), - ...offsets.map((offset) => position(box.x + box.width, box.y + box.height * offset, api.ConnDirRight)), - ...offsets.map((offset) => position(box.x + box.width * offset, box.y + box.height, api.ConnDirDown)), - ...offsets.map((offset) => position(box.x, box.y + box.height * offset, api.ConnDirLeft)), - ]; - for (const [x, y, direction] of pins) { - const pin = new api.ShapeConnectionPin(shape, PIN_CLASS, x, y, true, 0, direction); - pin.setExclusive(exclusive); - } + const direction = { + north: api.ConnDirUp, + east: api.ConnDirRight, + south: api.ConnDirDown, + west: api.ConnDirLeft, + }; const ports = new Map(); - (shapeData.ports ?? []).forEach((port) => { - const classId = allocatePortClass(); - const face = portFace(box, port); - let x: number; - let y: number; - let direction: number; - switch (port.side) { - case "north": - x = (face.x - routingBox.x) / routingBox.width; - y = 0; - direction = api.ConnDirUp; - break; - case "east": - x = 1; - y = (face.y - routingBox.y) / routingBox.height; - direction = api.ConnDirRight; - break; - case "south": - x = (face.x - routingBox.x) / routingBox.width; - y = 1; - direction = api.ConnDirDown; - break; - case "west": - x = 0; - y = (face.y - routingBox.y) / routingBox.height; - direction = api.ConnDirLeft; - break; + for (const pinPosition of routingPinPositions(shapeData)) { + const named = pinPosition.id !== undefined; + const classId = named ? allocatePortClass() : PIN_CLASS; + const pin = new api.ShapeConnectionPin( + shape, + classId, + pinPosition.x, + pinPosition.y, + true, + 0, + direction[pinPosition.side], + ); + pin.setExclusive(named ? false : exclusive); + if (pinPosition.id !== undefined) { + ports.set(pinPosition.id, classId); } - const pin = new api.ShapeConnectionPin(shape, classId, x, y, true, 0, direction); - pin.setExclusive(false); - ports.set(port.id, classId); - }); + } return { shape, ports }; } diff --git a/editors/vscode/src/webview/layout.test.ts b/editors/vscode/src/webview/layout.test.ts index 98b259f99f..176b9a95d8 100644 --- a/editors/vscode/src/webview/layout.test.ts +++ b/editors/vscode/src/webview/layout.test.ts @@ -1,12 +1,21 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import path from "node:path"; import { test } from "node:test"; -import type { RenderEdge, RenderNode, RenderResult } from "../protocol"; -import type { AutoLayout } from "./autolayout"; +import type { LayoutGeometry, RenderEdge, RenderNode, RenderPoint, RenderResult } from "../protocol"; +import type { EngineInstance } from "../landing/model"; +import { landingModel } from "../landing/model"; +import { presented } from "../landing/present"; +import { autoLayout, type AutoLayout } from "./autolayout"; import { + alignedPlacement, anchor, + clampNodeToBounds, + type CanvasLayout, GAP, + freePlacement, + keepOrthogonalRoutes, insertedWaypoint, labelLines, layoutCanvas, @@ -15,20 +24,33 @@ import { movable, movedNode, movedWaypoint, + nodeExtent, nodeUnder, overridesOf, + type PlacedNode, + type PlacedPort, portBox, portCenter, portFace, + portLabelPlacement, PORT_SIZE, + reattachRoute, removedWaypoint, shapeOf, steerable, + type Box, + type Side, } from "./layout"; -import { CLEARANCE, loadAvoid } from "./avoid"; +import { CLEARANCE, loadAvoid, MIN_JOG, portExitReach } from "./avoid"; const WASM = path.resolve("node_modules/libavoid-js/dist/libavoid.wasm"); +interface LandingFixture { + hash: string; + render: Omit; + instances: EngineInstance[]; +} + 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 { @@ -50,6 +72,200 @@ function rendering(nodes: RenderNode[], edges: RenderEdge[] = [], extra: Partial }; } +function placedNode( + id: string, + x: number, + y: number, + width = 80, + height = 40, + ports: RenderNode["ports"] = [], +) { + return layoutCanvas(rendering([node(id, id, { x, y, width, height, ports })])).nodes.get(id)!; +} + +function layoutWithPorts( + nodes: RenderNode[], + edges: RenderEdge[], + ports: AutoLayout["ports"], + bounds: Box, +): CanvasLayout { + return layoutCanvas( + rendering(nodes, edges), + { bounds }, + { nodes: new Map(), routes: new Map(), ports }, + ); +} + +function orthogonal(points: RenderPoint[]): boolean { + return points.every((point, index) => { + if (index === 0) { + return true; + } + const previous = points[index - 1]; + return Math.abs(point.x - previous.x) <= 1e-6 || Math.abs(point.y - previous.y) <= 1e-6; + }); +} + +function assertPointNear(actual: RenderPoint, expected: RenderPoint, context: string): void { + assert.ok( + Math.abs(actual.x - expected.x) <= 1e-6 && Math.abs(actual.y - expected.y) <= 1e-6, + `${context}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); +} + +function pointDirection(start: RenderPoint, end: RenderPoint): RenderPoint { + const dx = end.x - start.x; + const dy = end.y - start.y; + return Math.abs(dx) >= Math.abs(dy) ? { x: Math.sign(dx), y: 0 } : { x: 0, y: Math.sign(dy) }; +} + +function assertRouteDirections( + points: RenderPoint[], + start: RenderPoint, + end: RenderPoint, + startDirection: RenderPoint, + endDirection: RenderPoint, +): void { + assert.ok(orthogonal(points)); + assertPointNear(points[0], start, "route start"); + assertPointNear(points.at(-1)!, end, "route end"); + assert.ok(Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y) > 1e-6); + assert.ok(Math.hypot(points.at(-1)!.x - points.at(-2)!.x, points.at(-1)!.y - points.at(-2)!.y) > 1e-6); + assert.deepEqual(pointDirection(points[0], points[1]), startDirection); + assert.deepEqual(pointDirection(points.at(-1)!, points.at(-2)!), endDirection); +} + +function portNode(id: string, x: number, y: number, width = 160, height = 70): RenderNode { + return node(id, id, { x, y, width, height, ports: [{ id: `${id}.api`, name: "api" }] }); +} + +function connectedEdge(from: string, to: string): RenderEdge { + return { + from, + to, + fromPort: `${from}.api`, + toPort: `${to}.api`, + label: "", + kind: "connection", + fqn: `M::${from}_${to}`, + }; +} + +function fixedPorts(...entries: Array<[string, Side, number]>): AutoLayout["ports"] { + const ports: AutoLayout["ports"] = new Map(); + for (const [id, side, offset] of entries) { + ports.set(id, { side, offset }); + } + return ports; +} + +function freeAt( + node: PlacedNode, + at: RenderPoint, + others: PlacedNode[], + portExitLeg: number | ((node: PlacedNode, port: PlacedPort) => number) = 0, +): boolean { + const exitLeg = (entry: PlacedNode, port: PlacedPort): number => + typeof portExitLeg === "number" ? portExitLeg : portExitLeg(entry, port); + const extent = nodeExtent( + { ...node, box: { ...node.box, x: at.x, y: at.y } }, + (port) => exitLeg(node, port), + ); + const expanded = { + x: extent.x - CLEARANCE, + y: extent.y - CLEARANCE, + width: extent.width + 2 * CLEARANCE, + height: extent.height + 2 * CLEARANCE, + }; + return others.every((other) => { + const extent = nodeExtent(other, (port) => exitLeg(other, port)); + const obstacle = { + x: extent.x - CLEARANCE, + y: extent.y - CLEARANCE, + width: extent.width + 2 * CLEARANCE, + height: extent.height + 2 * CLEARANCE, + }; + return ( + expanded.x >= obstacle.x + obstacle.width || + expanded.x + expanded.width <= obstacle.x || + expanded.y >= obstacle.y + obstacle.height || + expanded.y + expanded.height <= obstacle.y + ); + }); +} + +function nearestFreeByBruteForce( + node: PlacedNode, + at: RenderPoint, + others: PlacedNode[], + bounds: Box, +): RenderPoint | undefined { + const min = clampNodeToBounds( + node, + { x: Number.NEGATIVE_INFINITY, y: Number.NEGATIVE_INFINITY }, + bounds, + ); + const max = clampNodeToBounds( + node, + { x: Number.POSITIVE_INFINITY, y: Number.POSITIVE_INFINITY }, + bounds, + ); + let nearest: RenderPoint | undefined; + let nearestDistance = Number.POSITIVE_INFINITY; + for (let x = min.x; x <= max.x; x++) { + for (let y = min.y; y <= max.y; y++) { + const candidate = { x, y }; + const distance = Math.hypot(candidate.x - at.x, candidate.y - at.y); + if (distance < nearestDistance && freeAt(node, candidate, others)) { + nearest = candidate; + nearestDistance = distance; + } + } + } + return nearest; +} + +function insetBox(box: Box, distance: number): Box { + return { + x: box.x + distance, + y: box.y + distance, + width: Math.max(0, box.width - 2 * distance), + height: Math.max(0, box.height - 2 * distance), + }; +} + +function assertOrthogonalRoutes(layout: CanvasLayout, bounds: Box, context = "layout"): void { + for (const edge of layout.edges) { + const source = layout.nodes.get(edge.edge.from)!; + const target = layout.nodes.get(edge.edge.to)!; + const sourcePort = source.ports.find((port) => port.port.id === edge.edge.fromPort)!; + const targetPort = target.ports.find((port) => port.port.id === edge.edge.toPort)!; + assert.equal(edge.rerouted, true); + assert.deepEqual(edge.points[0], portFace(source.box, sourcePort)); + assert.deepEqual(edge.points.at(-1), portFace(target.box, targetPort)); + assert.ok( + edge.points.every( + ({ x, y }) => + x >= bounds.x && y >= bounds.y && x <= bounds.x + bounds.width && y <= bounds.y + bounds.height, + ), + ); + for (let index = 1; index < edge.points.length; index++) { + const previous = edge.points[index - 1]; + const current = edge.points[index]; + assert.ok( + previous.x === current.x || previous.y === current.y, + `${context} route ${edge.edge.from} → ${edge.edge.to} has a diagonal segment: ${JSON.stringify({ + segment: [previous, current], + source: source.box, + sourcePort, + target: target.box, + targetPort, + })}`, + ); + } + } +} + test("layoutCanvas draws a pinned node's edge straight until the router has loaded", () => { const layout = layoutCanvas(rendering( [ @@ -860,6 +1076,984 @@ test("layoutCanvas places edge ports on the sides facing the opposite endpoint", assert.equal(square.height, PORT_SIZE); }); +test("clampNodeToBounds keeps port squares, labels, and pin exit legs inside its bounds", () => { + const layout = layoutCanvas(rendering( + [ + node("a", "a", { x: 0, y: 0, width: 100, height: 60, ports: [{ id: "a.i", name: "i" }] }), + node("b", "b", { x: 300, y: 0, width: 100, height: 60, ports: [{ id: "b.i", name: "i" }] }), + ], + [{ from: "a", to: "b", fromPort: "a.i", toPort: "b.i", label: "", kind: "connection", fqn: "M::ab" }], + )); + const entry = layout.nodes.get("a")!; + const port = entry.ports[0]; + assert.equal(port.side, "east"); + const bounds = { x: 10, y: 20, width: 200, height: 120 }; + const extent = nodeExtent(entry, CLEARANCE); + const square = portBox(entry.box, port); + const label = portLabelPlacement(entry.box, port).bounds; + const face = portFace(entry.box, port); + const exit = { x: face.x + CLEARANCE, y: face.y }; + assert.ok(extent.x <= square.x && extent.y <= square.y); + assert.ok(extent.x + extent.width >= square.x + square.width); + assert.ok(extent.y + extent.height >= square.y + square.height); + assert.ok(extent.x <= label.x && extent.y <= label.y); + assert.ok(extent.x + extent.width >= label.x + label.width); + assert.ok(extent.y + extent.height >= label.y + label.height); + assert.ok(extent.x + extent.width >= exit.x); + assert.ok(exit.y >= extent.y && exit.y <= extent.y + extent.height); + + const at = clampNodeToBounds(entry, { x: 1000, y: 1000 }, bounds, CLEARANCE); + const dx = at.x - entry.box.x; + const dy = at.y - entry.box.y; + const movedExtent = { ...extent, x: extent.x + dx, y: extent.y + dy }; + assert.ok(movedExtent.x >= bounds.x); + assert.ok(movedExtent.y >= bounds.y); + assert.ok(movedExtent.x + movedExtent.width <= bounds.x + bounds.width); + assert.ok(movedExtent.y + movedExtent.height <= bounds.y + bounds.height); +}); + +test("clampNodeToBounds reserves a different exit reach for each port", () => { + const layout = layoutCanvas(rendering( + [ + node("west", "west", { x: 0, y: 60, width: 80, height: 40 }), + node("target", "target", { + x: 300, + y: 60, + width: 100, + height: 60, + ports: [ + { id: "target.in", name: "in" }, + { id: "target.out", name: "out" }, + ], + }), + node("east", "east", { x: 700, y: 60, width: 80, height: 40 }), + ], + [ + { from: "west", to: "target", toPort: "target.in", label: "", kind: "connection", fqn: "M::westTarget" }, + { from: "target", to: "east", fromPort: "target.out", label: "", kind: "connection", fqn: "M::targetEast" }, + ], + )); + const entry = layout.nodes.get("target")!; + const west = entry.ports.find((port) => port.port.id === "target.in")!; + const east = entry.ports.find((port) => port.port.id === "target.out")!; + assert.equal(west.side, "west"); + assert.equal(east.side, "east"); + const exitLeg = (port: (typeof entry.ports)[number]) => (port === west ? 40 : 60); + const extent = nodeExtent(entry, exitLeg); + assert.equal(extent.x, portFace(entry.box, west).x - 40); + assert.equal(extent.x + extent.width, portFace(entry.box, east).x + 60); + + const bounds = { x: 10, y: 0, width: 600, height: 300 }; + const at = clampNodeToBounds(entry, { x: -1000, y: entry.box.y }, bounds, exitLeg); + const moved = { ...entry, box: { ...entry.box, ...at } }; + const movedExtent = nodeExtent(moved, exitLeg); + assert.equal(movedExtent.x, bounds.x); + assert.ok(movedExtent.x + movedExtent.width <= bounds.x + bounds.width); + assert.equal(portFace(moved.box, west).x - 40, bounds.x); + assert.ok(portFace(moved.box, east).x + 60 <= bounds.x + bounds.width); +}); + +test("freePlacement returns an already-free position unchanged", () => { + const moving = placedNode("moving", 100, 100); + const other = placedNode("other", 300, 100); + const bounds: Box = { x: 0, y: 0, width: 500, height: 300 }; + + assert.deepEqual(freePlacement(moving, moving.box, [other], bounds, 0), { x: moving.box.x, y: moving.box.y }); +}); + +test("freePlacement moves an overlap on the left to its nearest free side", () => { + const moving = placedNode("moving", 100, 100); + const other = placedNode("other", 150, 100); + const bounds: Box = { x: 0, y: 0, width: 500, height: 300 }; + + assert.deepEqual(freePlacement(moving, moving.box, [other], bounds, 0), { x: 38, y: 100 }); +}); + +test("freePlacement respects a wall and chooses the other side of an overlap", () => { + const moving = placedNode("moving", 100, 20); + const other = placedNode("other", 70, 20); + const bounds: Box = { x: 0, y: 0, width: 300, height: 80 }; + + assert.deepEqual(freePlacement(moving, moving.box, [other], bounds, 0), { x: 182, y: 20 }); +}); + +test("freePlacement includes each node's port exit leg in its extent", () => { + const moving = placedNode("moving", 100, 100, 80, 40, [{ id: "moving.out", name: "out" }]); + moving.ports[0].side = "east"; + const other = placedNode("other", 230, 100); + const bounds: Box = { x: 0, y: 0, width: 500, height: 300 }; + const withNoExit = freePlacement(moving, moving.box, [other], bounds, 0); + const withExit = freePlacement(moving, moving.box, [other], bounds, (entry, port) => { + assert.equal(entry.node.id, "moving"); + assert.equal(port.port.id, "moving.out"); + return 40; + }); + + assert.deepEqual(withNoExit, { x: 88, y: moving.box.y }); + assert.ok(withExit); + assert.notDeepEqual(withExit, moving.box); +}); + +test("freePlacement returns its clamped position when no free spot exists", () => { + const moving = placedNode("moving", 100, 20); + const other = placedNode("other", 0, 0, 200, 80); + const bounds: Box = { x: 0, y: 0, width: 200, height: 80 }; + + assert.deepEqual(freePlacement(moving, moving.box, [other], bounds, 0), { x: moving.box.x, y: moving.box.y }); +}); + +test("freePlacement jumps past an overlap in the requested direction", () => { + const moving = placedNode("moving", 100, 20, 40, 40); + const other = placedNode("other", 110, 20, 40, 40); + const bounds: Box = { x: 0, y: 0, width: 300, height: 100 }; + + assert.deepEqual( + freePlacement(moving, { x: 100, y: 20 }, [other], bounds, 0, { x: 1, y: 0 }), + { x: 182, y: 20 }, + ); +}); + +test("freePlacement leaves a directional step unchanged when that direction is blocked", () => { + const moving = placedNode("moving", 100, 20, 40, 40); + const other = placedNode("other", 110, 20, 40, 40); + const bounds: Box = { x: 0, y: 0, width: 200, height: 100 }; + + assert.equal(freePlacement(moving, { x: 100, y: 20 }, [other], bounds, 0, { x: 1, y: 0 }), undefined); +}); + +test("alignedPlacement lines up a short east-to-west offset in either direction", () => { + const bounds: Box = { x: 0, y: 0, width: 900, height: 400 }; + for (const offset of [5, -5]) { + const movingNode = portNode("moving", 80, 100); + const targetNode = portNode("target", 500, 100 + offset); + const layout = layoutWithPorts( + [movingNode, targetNode], + [connectedEdge("moving", "target")], + fixedPorts(["moving.api", "east", 0.5], ["target.api", "west", 0.5]), + bounds, + ); + const moving = layout.nodes.get("moving")!; + const target = layout.nodes.get("target")!; + const at = { x: moving.box.x, y: moving.box.y }; + const placed = alignedPlacement(moving, at, layout, bounds, 0); + const ownCenter = portCenter({ ...moving.box, ...placed }, moving.ports[0]); + const targetCenter = portCenter(target.box, target.ports[0]); + + assert.equal(placed.x, at.x); + assert.equal(placed.y, at.y + offset); + assert.equal(ownCenter.y, targetCenter.y); + } +}); + +test("alignedPlacement ignores east ports wired to other east ports", () => { + const bounds: Box = { x: 0, y: 0, width: 900, height: 400 }; + const layout = layoutWithPorts( + [portNode("moving", 80, 100), portNode("target", 500, 105)], + [connectedEdge("moving", "target")], + fixedPorts(["moving.api", "east", 0.5], ["target.api", "east", 0.5]), + bounds, + ); + const moving = layout.nodes.get("moving")!; + const at = { x: moving.box.x, y: moving.box.y }; + + assert.deepEqual(alignedPlacement(moving, at, layout, bounds, 0), at); +}); + +test("alignedPlacement ignores a west port to the left of an east port", () => { + const bounds: Box = { x: 0, y: 0, width: 900, height: 400 }; + const layout = layoutWithPorts( + [portNode("moving", 500, 100), portNode("target", 100, 105)], + [connectedEdge("moving", "target")], + fixedPorts(["moving.api", "east", 0.5], ["target.api", "west", 0.5]), + bounds, + ); + const moving = layout.nodes.get("moving")!; + const at = { x: moving.box.x, y: moving.box.y }; + + assert.deepEqual(alignedPlacement(moving, at, layout, bounds, 0), at); +}); + +test("alignedPlacement ignores south-to-north ports that face apart", () => { + const bounds: Box = { x: 0, y: 0, width: 900, height: 500 }; + const layout = layoutWithPorts( + [portNode("moving", 80, 300), portNode("target", 85, 100)], + [connectedEdge("moving", "target")], + fixedPorts(["moving.api", "south", 0.5], ["target.api", "north", 0.5]), + bounds, + ); + const moving = layout.nodes.get("moving")!; + const at = { x: moving.box.x, y: moving.box.y }; + + assert.deepEqual(alignedPlacement(moving, at, layout, bounds, 0), at); +}); + +test("alignedPlacement leaves zero and exactly MIN_JOG offsets unchanged", () => { + const bounds: Box = { x: 0, y: 0, width: 900, height: 400 }; + for (const offset of [0, MIN_JOG]) { + const layout = layoutWithPorts( + [portNode("moving", 80, 100), portNode("target", 500, 100 + offset)], + [connectedEdge("moving", "target")], + fixedPorts(["moving.api", "east", 0.5], ["target.api", "west", 0.5]), + bounds, + ); + const moving = layout.nodes.get("moving")!; + const at = { x: moving.box.x, y: moving.box.y }; + + assert.deepEqual(alignedPlacement(moving, at, layout, bounds, 0), at); + } +}); + +test("alignedPlacement rejects an alignment blocked inside the clearance buffers", () => { + const bounds: Box = { x: 0, y: 0, width: 900, height: 500 }; + const movingNode = portNode("moving", 80, 100); + const targetNode = portNode("target", 500, 105); + const edges = [connectedEdge("moving", "target")]; + const ports = fixedPorts(["moving.api", "east", 0.5], ["target.api", "west", 0.5]); + const initial = layoutWithPorts([movingNode, targetNode], edges, ports, bounds); + const movingExtent = nodeExtent(initial.nodes.get("moving")!); + const blocker = node("blocker", "blocker", { + x: movingNode.x, + y: movingExtent.y + movingExtent.height + 2 * CLEARANCE, + width: movingNode.width, + height: 40, + }); + const layout = layoutWithPorts([movingNode, targetNode, blocker], edges, ports, bounds); + const moving = layout.nodes.get("moving")!; + const at = { x: moving.box.x, y: moving.box.y }; + const others = [...layout.nodes.values()].filter((entry) => entry.node.id !== "moving"); + + assert.deepEqual(freePlacement(moving, at, others, bounds, 0), at); + assert.deepEqual(alignedPlacement(moving, at, layout, bounds, 0), at); +}); + +test("alignedPlacement rejects an alignment that would cross the bounds", () => { + const bounds: Box = { x: 0, y: 0, width: 900, height: 260 }; + const layout = layoutWithPorts( + [portNode("moving", 80, 190), portNode("target", 500, 195)], + [connectedEdge("moving", "target")], + fixedPorts(["moving.api", "east", 0.5], ["target.api", "west", 0.5]), + bounds, + ); + const moving = layout.nodes.get("moving")!; + const at = { x: moving.box.x, y: moving.box.y }; + const others = [...layout.nodes.values()].filter((entry) => entry.node.id !== "moving"); + + assert.deepEqual(freePlacement(moving, at, others, bounds, 0), at); + assert.deepEqual(alignedPlacement(moving, at, layout, bounds, 0), at); +}); + +test("alignedPlacement minimizes remaining jogs before choosing the smaller shift", () => { + const bounds: Box = { x: 0, y: 0, width: 1000, height: 400 }; + const layout = layoutWithPorts( + [portNode("moving", 80, 100), portNode("plus", 450, 104), portNode("minus", 700, 94)], + [connectedEdge("moving", "plus"), connectedEdge("moving", "minus")], + fixedPorts( + ["moving.api", "east", 0.5], + ["plus.api", "west", 0.5], + ["minus.api", "west", 0.5], + ), + bounds, + ); + const moving = layout.nodes.get("moving")!; + const at = { x: moving.box.x, y: moving.box.y }; + + assert.deepEqual(alignedPlacement(moving, at, layout, bounds, 0), { x: at.x, y: at.y + 4 }); +}); + +test("alignedPlacement lines up north-to-south ports on x", () => { + const bounds: Box = { x: 0, y: 0, width: 900, height: 500 }; + const layout = layoutWithPorts( + [portNode("moving", 80, 300), portNode("target", 85, 100)], + [connectedEdge("moving", "target")], + fixedPorts(["moving.api", "north", 0.5], ["target.api", "south", 0.5]), + bounds, + ); + const moving = layout.nodes.get("moving")!; + const at = { x: moving.box.x, y: moving.box.y }; + const placed = alignedPlacement(moving, at, layout, bounds, 0); + + assert.deepEqual(placed, { x: at.x + 5, y: at.y }); + assert.equal(portCenter({ ...moving.box, ...placed }, moving.ports[0]).x, portCenter( + layout.nodes.get("target")!.box, + layout.nodes.get("target")!.ports[0], + ).x); +}); + +test("alignedPlacement ignores mixed-side port pairs", () => { + const bounds: Box = { x: 0, y: 0, width: 900, height: 400 }; + const layout = layoutWithPorts( + [portNode("moving", 80, 100), portNode("target", 500, 105)], + [connectedEdge("moving", "target")], + fixedPorts(["moving.api", "east", 0.5], ["target.api", "north", 0.5]), + bounds, + ); + const moving = layout.nodes.get("moving")!; + const at = { x: moving.box.x, y: moving.box.y }; + + assert.deepEqual(alignedPlacement(moving, at, layout, bounds, 0), at); +}); + +test("alignedPlacement ignores hidden edges and hidden other nodes", () => { + const bounds: Box = { x: 0, y: 0, width: 900, height: 400 }; + const makeLayout = (): CanvasLayout => + layoutWithPorts( + [portNode("moving", 80, 100), portNode("target", 500, 105)], + [connectedEdge("moving", "target")], + fixedPorts(["moving.api", "east", 0.5], ["target.api", "west", 0.5]), + bounds, + ); + const hiddenEdgeLayout = makeLayout(); + hiddenEdgeLayout.edges[0].hidden = true; + const hiddenEdgeMoving = hiddenEdgeLayout.nodes.get("moving")!; + const hiddenEdgeAt = { x: hiddenEdgeMoving.box.x, y: hiddenEdgeMoving.box.y }; + assert.deepEqual( + alignedPlacement(hiddenEdgeMoving, hiddenEdgeAt, hiddenEdgeLayout, bounds, 0), + hiddenEdgeAt, + ); + + const hiddenNodeLayout = makeLayout(); + hiddenNodeLayout.nodes.get("target")!.hidden = true; + const hiddenNodeMoving = hiddenNodeLayout.nodes.get("moving")!; + const hiddenNodeAt = { x: hiddenNodeMoving.box.x, y: hiddenNodeMoving.box.y }; + assert.deepEqual( + alignedPlacement(hiddenNodeMoving, hiddenNodeAt, hiddenNodeLayout, bounds, 0), + hiddenNodeAt, + ); +}); + +test("alignedPlacement preserves jog-length libavoid routes across seeded short offsets", async () => { + await loadAvoid(WASM); + const bounds: Box = { x: 0, y: 0, width: 800, height: 450 }; + const edges = [connectedEdge("moving", "target")]; + const ports = fixedPorts(["moving.api", "east", 0.5], ["target.api", "west", 0.5]); + let seed = 0x91b4c7d3; + const randomInt = (max: number): number => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return seed % (max + 1); + }; + for (let sample = 0; sample < 200; sample++) { + const offset = (randomInt(1) === 0 ? -1 : 1) * (1 + randomInt(MIN_JOG - 2)); + const targetY = 60 + randomInt(260); + const nodes = [portNode("moving", 80, targetY - offset), portNode("target", 500, targetY)]; + const initial = layoutWithPorts(nodes, edges, ports, bounds); + const moving = initial.nodes.get("moving")!; + const others = [...initial.nodes.values()].filter((entry) => entry.node.id !== "moving"); + const at = freePlacement(moving, moving.box, others, bounds, portExitReach(1))!; + const placed = alignedPlacement(moving, at, initial, bounds, portExitReach(1)); + const layout = layoutCanvas( + rendering(nodes, edges), + { nodes: new Map([["moving", { ...moving.box, ...placed }]]), bounds }, + { nodes: new Map(), routes: new Map(), ports }, + ); + const edge = layout.edges[0]; + const source = layout.nodes.get("moving")!; + const target = layout.nodes.get("target")!; + + assert.equal( + portCenter(source.box, source.ports[0]).y, + portCenter(target.box, target.ports[0]).y, + `seeded scene ${sample}: ${JSON.stringify({ offset, targetY, at, placed, source: source.box, target: target.box })}`, + ); + assert.equal(edge.rerouted, true); + for (let index = 1; index < edge.points.length; index++) { + assert.ok( + Math.hypot( + edge.points[index].x - edge.points[index - 1].x, + edge.points[index].y - edge.points[index - 1].y, + ) >= MIN_JOG, + `seeded scene ${sample} had a short route segment: ${JSON.stringify(edge.points)}`, + ); + } + } +}); + +test("freePlacement finds the nearest free position in the off-grid review scene", () => { + const bounds: Box = { x: 0, y: 0, width: 180, height: 120 }; + const moving = placedNode("moving", 6, 70, 16, 16); + const others = [ + placedNode("a", 67, 58, 48, 48), + placedNode("b", 1, 88, 16, 23), + placedNode("c", 19, 69, 33, 14), + placedNode("d", 79, 30, 15, 36), + placedNode("e", 15, 2, 17, 31), + ]; + const at = { x: 6, y: 70 }; + const result = freePlacement(moving, at, others, bounds, 0)!; + const bruteForce = nearestFreeByBruteForce(moving, at, others, bounds); + + assert.ok(bruteForce); + assert.ok(freeAt(moving, result, others)); + assert.ok( + Math.hypot(result.x - at.x, result.y - at.y) <= + Math.hypot(bruteForce.x - at.x, bruteForce.y - at.y) + 1e-9, + ); +}); + +test("freePlacement matches a one-unit brute-force search over 200 seeded scenes", () => { + const bounds: Box = { x: 0, y: 0, width: 120, height: 120 }; + let seed = 0x91e10da5; + const integer = (min: number, max: number): number => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return min + (seed % (max - min + 1)); + }; + let checked = 0; + for (let sample = 0; sample < 200; sample++) { + const width = integer(8, 32); + const height = integer(8, 32); + const moving = placedNode( + "moving", + integer(0, bounds.width - width), + integer(0, bounds.height - height), + width, + height, + ); + const at = { + x: integer(0, bounds.width - width), + y: integer(0, bounds.height - height), + }; + const others = Array.from({ length: integer(1, 5) }, (_, index) => { + const otherWidth = integer(12, 40); + const otherHeight = integer(12, 40); + return placedNode( + `other-${sample}-${index}`, + integer(0, bounds.width - otherWidth), + integer(0, bounds.height - otherHeight), + otherWidth, + otherHeight, + ); + }); + const bruteForce = nearestFreeByBruteForce(moving, at, others, bounds); + if (!bruteForce) { + continue; + } + checked++; + const result = freePlacement(moving, at, others, bounds, 0); + + assert.ok(result, `no placement returned in seeded scene ${sample}`); + assert.ok(freeAt(moving, result, others), `placement is occupied in seeded scene ${sample}`); + assert.ok( + Math.hypot(result.x - at.x, result.y - at.y) <= + Math.hypot(bruteForce.x - at.x, bruteForce.y - at.y) + 1, + `placement is not nearest in seeded scene ${sample}`, + ); + } + assert.ok(checked > 100, `only ${checked} scenes had a free spot`); +}); + +test("reattachRoute moves a start perpendicular without changing its exit leg", () => { + const route = [ + { x: 0, y: 0 }, + { x: 20, y: 0 }, + { x: 20, y: 40 }, + { x: 80, y: 40 }, + ]; + const start = { x: 0, y: 5 }; + const end = route.at(-1)!; + const reattached = reattachRoute(route, start, end); + + assert.ok(orthogonal(reattached)); + assertPointNear(reattached[0], start, "start"); + assertPointNear(reattached.at(-1)!, end, "end"); + assert.equal(Math.hypot(reattached[1].x - reattached[0].x, reattached[1].y - reattached[0].y), 20); +}); + +test("reattachRoute moves the first two bends along the start axis to preserve its exit leg", () => { + const route = [ + { x: 0, y: 0 }, + { x: 20, y: 0 }, + { x: 20, y: 40 }, + { x: 80, y: 40 }, + ]; + const start = { x: 5, y: 0 }; + const end = route.at(-1)!; + const reattached = reattachRoute(route, start, end); + + assert.ok(orthogonal(reattached)); + assertPointNear(reattached[0], start, "start"); + assertPointNear(reattached.at(-1)!, end, "end"); + assert.equal(Math.hypot(reattached[1].x - reattached[0].x, reattached[1].y - reattached[0].y), 20); +}); + +test("reattachRoute makes a straight route into an orthogonal Z when its ends are no longer level", () => { + const horizontal = reattachRoute( + [{ x: 0, y: 0 }, { x: 100, y: 0 }], + { x: 10, y: 20 }, + { x: 100, y: 0 }, + ); + assert.ok(orthogonal(horizontal)); + assertPointNear(horizontal[0], { x: 10, y: 20 }, "horizontal start"); + assertPointNear(horizontal.at(-1)!, { x: 100, y: 0 }, "horizontal end"); + assert.equal(horizontal.length, 4); + + const vertical = reattachRoute( + [{ x: 10, y: 0 }, { x: 10, y: 100 }], + { x: 0, y: 10 }, + { x: 30, y: 100 }, + ); + assert.ok(orthogonal(vertical)); + assertPointNear(vertical[0], { x: 0, y: 10 }, "vertical start"); + assertPointNear(vertical.at(-1)!, { x: 30, y: 100 }, "vertical end"); + assert.equal(vertical.length, 4); +}); + +test("reattachRoute keeps an L orthogonal when its start moves perpendicular", () => { + const start = { x: 0, y: 10 }; + const end = { x: 40, y: 60 }; + const reattached = reattachRoute([{ x: 0, y: 0 }, { x: 40, y: 0 }, end], start, end); + + assert.ok(orthogonal(reattached)); + assertPointNear(reattached[0], start, "start"); + assertPointNear(reattached.at(-1)!, end, "end"); +}); + +test("reattachRoute keeps both moved ends orthogonal", () => { + const start = { x: 0, y: 10 }; + const end = { x: 70, y: 80 }; + const reattached = reattachRoute( + [ + { x: 0, y: 0 }, + { x: 20, y: 0 }, + { x: 20, y: 30 }, + { x: 60, y: 30 }, + { x: 60, y: 70 }, + ], + start, + end, + ); + + assert.ok(orthogonal(reattached)); + assertPointNear(reattached[0], start, "start"); + assertPointNear(reattached.at(-1)!, end, "end"); +}); + +test("reattachRoute leaves a non-orthogonal input unchanged", () => { + const route = [{ x: 0, y: 0 }, { x: 10, y: 5 }, { x: 20, y: 0 }]; + assert.equal(reattachRoute(route, { x: 0, y: 10 }, { x: 20, y: 10 }), route); +}); + +test("reattachRoute keeps a three-point horizontal exit pointing out after the start passes its bend", () => { + const route = [{ x: 100, y: 100 }, { x: 120, y: 100 }, { x: 120, y: 200 }]; + const start = { x: 150, y: 100 }; + const end = route.at(-1)!; + const reattached = reattachRoute(route, start, end); + + assertRouteDirections(reattached, start, end, { x: 1, y: 0 }, { x: 0, y: -1 }); +}); + +test("reattachRoute keeps a three-point vertical exit pointing out after the start passes its bend", () => { + const route = [{ x: 100, y: 100 }, { x: 100, y: 120 }, { x: 200, y: 120 }]; + const start = { x: 100, y: 150 }; + const end = route.at(-1)!; + const reattached = reattachRoute(route, start, end); + + assertRouteDirections(reattached, start, end, { x: 0, y: 1 }, { x: -1, y: 0 }); +}); + +test("reattachRoute keeps a three-point end pointing into its port after the end passes its bend", () => { + const route = [{ x: 100, y: 100 }, { x: 100, y: 200 }, { x: 120, y: 200 }]; + const start = route[0]; + const end = { x: 80, y: 200 }; + const reattached = reattachRoute(route, start, end); + + assertRouteDirections(reattached, start, end, { x: 0, y: 1 }, { x: -1, y: 0 }); +}); + +test("reattachRoute preserves outward directions when a four-point Z start passes its middle bend", () => { + const route = [{ x: 100, y: 100 }, { x: 120, y: 100 }, { x: 120, y: 150 }, { x: 180, y: 150 }]; + const start = { x: 130, y: 100 }; + const end = route.at(-1)!; + const reattached = reattachRoute(route, start, end); + + assertRouteDirections(reattached, start, end, { x: 1, y: 0 }, { x: -1, y: 0 }); +}); + +test("reattachRoute goes around vertically when a straight route's start passes its end", () => { + const start = { x: 250, y: 120 }; + const end = { x: 200, y: 100 }; + const reattached = reattachRoute([{ x: 100, y: 100 }, { x: 200, y: 100 }], start, end); + + assertRouteDirections(reattached, start, end, { x: 1, y: 0 }, { x: -1, y: 0 }); + const a = { x: start.x + CLEARANCE, y: start.y }; + const b = { x: end.x - CLEARANCE, y: end.y }; + const middleY = (a.y + b.y) / 2; + assert.deepEqual(reattached, [ + start, + a, + { x: a.x, y: middleY }, + { x: b.x, y: middleY }, + b, + end, + ]); +}); + +test("keepOrthogonalRoutes replaces only diagonals with matching visible orthogonal routes", () => { + const bounds: Box = { x: 0, y: 0, width: 900, height: 500 }; + const nodes = [portNode("a", 80, 100), portNode("b", 500, 100), portNode("c", 500, 300)]; + const nextEdges = [ + connectedEdge("a", "b"), + connectedEdge("a", "b"), + connectedEdge("a", "b"), + connectedEdge("a", "b"), + connectedEdge("a", "b"), + connectedEdge("a", "c"), + ]; + const previousEdges = [...nextEdges]; + previousEdges[5] = connectedEdge("a", "b"); + const ports = fixedPorts(["a.api", "east", 0.5], ["b.api", "west", 0.5], ["c.api", "west", 0.5]); + const previous = layoutWithPorts(nodes, previousEdges, ports, bounds); + const next = layoutWithPorts(nodes, nextEdges, ports, bounds); + const endpoints = (layout: CanvasLayout, index: number): [RenderPoint, RenderPoint] => { + const edge = layout.edges[index]; + const source = layout.nodes.get(edge.edge.from)!; + const target = layout.nodes.get(edge.edge.to)!; + return [portFace(source.box, source.ports[0]), portFace(target.box, target.ports[0])]; + }; + const setPoints = (layout: CanvasLayout, index: number, points: RenderPoint[], hidden?: boolean): void => { + layout.edges[index] = { + ...layout.edges[index], + points, + route: points.slice(1, -1), + hidden: hidden ?? layout.edges[index].hidden, + }; + }; + const [aToBStart, aToBEnd] = endpoints(next, 0); + const [aToCStart, aToCEnd] = endpoints(next, 5); + const diagonal = (start: RenderPoint, end: RenderPoint): RenderPoint[] => [ + start, + { x: (start.x + end.x) / 2, y: start.y + 20 }, + end, + ]; + + setPoints(previous, 0, [aToBStart, aToBEnd]); + setPoints(next, 0, diagonal(aToBStart, aToBEnd)); + setPoints(previous, 1, [aToBStart, aToBEnd]); + setPoints(next, 1, [aToBStart, { x: 300, y: aToBStart.y }, { x: 300, y: 180 }, { x: aToBEnd.x, y: 180 }, aToBEnd]); + setPoints(previous, 2, diagonal(aToBStart, aToBEnd)); + setPoints(next, 2, diagonal(aToBStart, aToBEnd)); + setPoints(previous, 3, [aToBStart, aToBEnd]); + setPoints(next, 3, diagonal(aToBStart, aToBEnd), true); + setPoints(previous, 4, [aToBStart, aToBEnd], true); + setPoints(next, 4, diagonal(aToBStart, aToBEnd)); + setPoints(previous, 5, [aToBStart, aToBEnd]); + setPoints(next, 5, diagonal(aToCStart, aToCEnd)); + + const kept = keepOrthogonalRoutes(next, previous); + const replacement = kept.edges[0]; + assert.ok(orthogonal(replacement.points)); + assert.deepEqual(replacement.points, [aToBStart, aToBEnd]); + assert.deepEqual(replacement.route, []); + assert.deepEqual(replacement.label, { x: (aToBStart.x + aToBEnd.x) / 2, y: aToBStart.y }); + assert.equal(replacement.rerouted, true); + for (const index of [1, 2, 3, 4, 5]) { + assert.equal(kept.edges[index], next.edges[index], `edge ${index} should stay unchanged`); + } +}); + +test("layoutCanvas passes bounds through to libavoid's route constraints", async () => { + await loadAvoid(WASM); + const bounds: Box = { x: 0, y: 0, width: 500, height: 260 }; + const result = rendering( + [ + node("source", "source", { + x: 50, + y: 190, + width: 80, + height: 40, + ports: [{ id: "source.out", name: "out" }], + }), + node("target", "target", { + x: 370, + y: 190, + width: 80, + height: 40, + ports: [{ id: "target.in", name: "in" }], + }), + node("obstacle", "obstacle", { x: 210, y: 80, width: 80, height: 172 }), + ], + [{ from: "source", to: "target", fromPort: "source.out", toPort: "target.in", label: "", kind: "connection", fqn: "M::edge" }], + ); + const unbounded = layoutCanvas(result); + assert.ok(unbounded.edges[0].points.some( + ({ x, y }) => x < bounds.x || y < bounds.y || x > bounds.x + bounds.width || y > bounds.y + bounds.height, + )); + + const bounded = layoutCanvas(result, { bounds }); + const route = bounded.edges[0].points; + const source = bounded.nodes.get("source")!; + const target = bounded.nodes.get("target")!; + assert.equal(bounded.edges[0].rerouted, true); + assert.deepEqual(route[0], portFace(source.box, source.ports[0])); + assert.deepEqual(route.at(-1), portFace(target.box, target.ports[0])); + assert.ok(route.every( + ({ x, y }) => x >= bounds.x && y >= bounds.y && x <= bounds.x + bounds.width && y <= bounds.y + bounds.height, + )); + for (let index = 1; index < route.length; index++) { + assert.ok(route[index - 1].x === route[index].x || route[index - 1].y === route[index].y); + } +}); + +test("layoutCanvas reroutes attached orthogonal wires after a dropped node is freed", async () => { + await loadAvoid(WASM); + const bounds: Box = { x: 0, y: 0, width: 700, height: 320 }; + const result = rendering( + [ + node("source", "source", { + x: 40, + y: 120, + width: 80, + height: 40, + ports: [{ id: "source.out", name: "out" }], + }), + node("moving", "moving", { + x: 300, + y: 120, + width: 80, + height: 40, + ports: [ + { id: "moving.in", name: "in" }, + { id: "moving.out", name: "out" }, + ], + }), + node("target", "target", { + x: 560, + y: 120, + width: 80, + height: 40, + ports: [{ id: "target.in", name: "in" }], + }), + ], + [ + { from: "source", to: "moving", fromPort: "source.out", toPort: "moving.in", label: "", kind: "connection", fqn: "M::sourceMoving" }, + { from: "moving", to: "target", fromPort: "moving.out", toPort: "target.in", label: "", kind: "connection", fqn: "M::movingTarget" }, + ], + ); + const before = layoutCanvas(result, { bounds }); + const moving = before.nodes.get("moving")!; + const at = freePlacement( + moving, + before.nodes.get("source")!.box, + [...before.nodes.values()].filter((entry) => entry.node.id !== "moving"), + bounds, + CLEARANCE, + )!; + assert.notDeepEqual(at, before.nodes.get("source")!.box); + + const after = layoutCanvas(result, { nodes: new Map([["moving", at]]), bounds }); + for (const edge of after.edges) { + const source = after.nodes.get(edge.edge.from)!; + const target = after.nodes.get(edge.edge.to)!; + const sourcePort = source.ports.find((port) => port.port.id === edge.edge.fromPort)!; + const targetPort = target.ports.find((port) => port.port.id === edge.edge.toPort)!; + assert.equal(edge.rerouted, true); + assert.deepEqual(edge.points[0], portFace(source.box, sourcePort)); + assert.deepEqual(edge.points.at(-1), portFace(target.box, targetPort)); + for (let index = 1; index < edge.points.length; index++) { + assert.ok(edge.points[index - 1].x === edge.points[index].x || edge.points[index - 1].y === edge.points[index].y); + } + } +}); + +test("landing drag keeps right-angled routes at the captured Pilot obstruction", async () => { + await loadAvoid(WASM); + const fixture = JSON.parse(readFileSync("src/landing/stack.json", "utf8")) as LandingFixture; + const model = landingModel(fixture.hash, fixture.render as RenderResult, fixture.instances); + const result = presented(model); + const auto = await autoLayout(result); + assert.ok(auto, "ELK should lay out the saved landing render"); + + const bounds: Box = { + x: -213.082559, + y: -413.648204, + width: 1098.165089, + height: 907.244513, + }; + const positions: Record = { + n1: { x: 301.3853, y: 116.4220 }, + n7: { x: 282.2569, y: 218.4220 }, + n13: { x: 12.8074, y: 95.7248 }, + n19: { x: 628.9174, y: 255.5811 }, + }; + const layoutAt = (pilotX: number): CanvasLayout => { + const nodes = new Map(); + for (const entry of result.nodes) { + const geometry = auto.nodes.get(entry.id); + const position = positions[entry.id]; + assert.ok(geometry && position, `ELK should place landing box ${entry.id}`); + nodes.set(entry.id, { ...geometry, x: entry.id === "n13" ? pilotX : position.x, y: position.y }); + } + return layoutCanvas(result, { nodes, bounds }, auto); + }; + const next = layoutAt(positions.n13.x); + const pilotEdge = next.edges.find(({ edge }) => edge.from === "n13" && edge.to === "n19"); + assert.ok(pilotEdge); + assert.ok( + !orthogonal(pilotEdge.points), + "the captured Pilot position should reproduce its diagonal route", + ); + + const previous = layoutAt(positions.n13.x - 40); + for (const edge of previous.edges) { + assert.ok( + orthogonal(edge.points), + `the prior Pilot position should route ${edge.edge.from} → ${edge.edge.to} orthogonally`, + ); + } + + const kept = keepOrthogonalRoutes(next, previous); + for (const edge of kept.edges) { + assert.ok(orthogonal(edge.points), `kept route ${edge.edge.from} → ${edge.edge.to} should be orthogonal`); + const source = kept.nodes.get(edge.edge.from)!; + const target = kept.nodes.get(edge.edge.to)!; + const sourcePort = source.ports.find((port) => port.port.id === edge.edge.fromPort); + const targetPort = target.ports.find((port) => port.port.id === edge.edge.toPort); + assert.ok(sourcePort && targetPort); + assertPointNear(edge.points[0], portFace(source.box, sourcePort), `${edge.edge.from} port face`); + assertPointNear(edge.points.at(-1)!, portFace(target.box, targetPort), `${edge.edge.to} port face`); + } +}); + +test("landing stacked-drop repro starts with diagonal routes", async () => { + await loadAvoid(WASM); + const boxes: Record = { + n1: { x: 133, y: 240, width: 159, height: 70 }, + n7: { x: -206.9174346923828, y: 331.2325134277344, width: 189, height: 70 }, + n13: { x: 36, y: 36, width: 256, height: 70 }, + n19: { x: -169.9174346923828, y: 417.2325134277344, width: 234, height: 70 }, + }; + const names: Record = { + n1: "OpenSysML", + n7: "sysml-toolkit", + n13: "SysML v2 Pilot Implementation", + n19: "Flexo MMS", + }; + const nodes = Object.entries(boxes).map(([id, box]) => + node(id, names[id], { ...box, ports: [{ id: `${id}.api`, name: "api" }] }), + ); + const edges = [ + { from: "n13", to: "n19", fromPort: "n13.api", toPort: "n19.api", label: "", kind: "connection", fqn: "M::n13n19" }, + { from: "n1", to: "n19", fromPort: "n1.api", toPort: "n19.api", label: "", kind: "connection", fqn: "M::n1n19" }, + { from: "n7", to: "n19", fromPort: "n7.api", toPort: "n19.api", label: "", kind: "connection", fqn: "M::n7n19" }, + ] as const; + const ports: AutoLayout["ports"] = new Map(); + ports.set("n1.api", { side: "east", offset: 0.5 }); + ports.set("n7.api", { side: "east", offset: 0.5 }); + ports.set("n13.api", { side: "east", offset: 0.5 }); + ports.set("n19.api", { side: "west", offset: 0.5 }); + const auto: AutoLayout = { nodes: new Map(), routes: new Map(), ports }; + const bounds: Box = { + x: -213.08255948571724, + y: -413.64820438139134, + width: 1098.1650895737687, + height: 907.2445130634958, + }; + const result = rendering(nodes, [...edges]); + const stacked = layoutCanvas(result, { bounds }, auto); + const exitReach = (entry: PlacedNode, port: PlacedPort): number => { + const sharing = edges.filter( + (edge) => + (edge.from === entry.node.id && edge.fromPort === port.port.id) || + (edge.to === entry.node.id && edge.toPort === port.port.id), + ).length; + return portExitReach(sharing); + }; + + assert.ok( + stacked.edges.some(({ points }) => + points.some( + (point, index) => + index > 0 && point.x !== points[index - 1].x && point.y !== points[index - 1].y, + ), + ), + "the stacked layout should reproduce at least one diagonal fallback route", + ); + + const settled = new Map(); + for (const id of ["n7", "n19"]) { + const current = layoutCanvas(result, { nodes: settled, bounds }, auto); + const moving = current.nodes.get(id)!; + const others = [...current.nodes.values()].filter((entry) => entry.node.id !== id && !entry.hidden); + const at = freePlacement(moving, moving.box, others, insetBox(bounds, CLEARANCE), exitReach); + + assert.ok(at, `expected ${id} to have a free placement`); + assert.ok(freeAt(moving, at, others, exitReach), `expected ${id} to be clear of both routing buffers`); + settled.set(id, at); + } + assertOrthogonalRoutes(layoutCanvas(result, { nodes: settled, bounds }, auto), bounds); +}); + +test("freePlacement keeps a seeded four-node chain routable after drops", async () => { + await loadAvoid(WASM); + const bounds: Box = { x: 0, y: 0, width: 700, height: 400 }; + const result = rendering( + [ + node("a", "a", { x: 40, y: 180, width: 80, height: 40, ports: [{ id: "a.out", name: "out" }] }), + node("b", "b", { + x: 220, + y: 180, + width: 80, + height: 40, + ports: [{ id: "b.in", name: "in" }, { id: "b.out", name: "out" }], + }), + node("c", "c", { + x: 400, + y: 180, + width: 80, + height: 40, + ports: [{ id: "c.in", name: "in" }, { id: "c.out", name: "out" }], + }), + node("d", "d", { x: 580, y: 180, width: 80, height: 40, ports: [{ id: "d.in", name: "in" }] }), + ], + [ + { from: "a", to: "b", fromPort: "a.out", toPort: "b.in", label: "", kind: "connection", fqn: "M::ab" }, + { from: "b", to: "c", fromPort: "b.out", toPort: "c.in", label: "", kind: "connection", fqn: "M::bc" }, + { from: "c", to: "d", fromPort: "c.out", toPort: "d.in", label: "", kind: "connection", fqn: "M::cd" }, + ], + ); + const auto: AutoLayout = { + nodes: new Map(), + routes: new Map(), + ports: new Map([ + ["a.out", { side: "east", offset: 0.5 }], + ["b.in", { side: "west", offset: 0.5 }], + ["b.out", { side: "east", offset: 0.5 }], + ["c.in", { side: "west", offset: 0.5 }], + ["c.out", { side: "east", offset: 0.5 }], + ["d.in", { side: "west", offset: 0.5 }], + ]), + }; + let seed = 0x7a4d39b1; + const randomInt = (max: number): number => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return seed % (max + 1); + }; + const inset = insetBox(bounds, CLEARANCE); + const exitReach = () => portExitReach(1); + let checked = 0; + for (let sample = 0; sample < 200; sample++) { + const settled = new Map(); + let current = layoutCanvas(result, { bounds }, auto); + let allFree = true; + for (const id of ["a", "b", "c", "d"]) { + const moving = current.nodes.get(id)!; + const others = [...current.nodes.values()].filter((entry) => entry.node.id !== id && !entry.hidden); + const at = { x: randomInt(620), y: randomInt(320) }; + const placed = freePlacement(moving, at, others, inset, exitReach); + if (!placed || !freeAt(moving, placed, others, exitReach)) { + allFree = false; + break; + } + settled.set(id, placed); + current = layoutCanvas(result, { nodes: settled, bounds }, auto); + } + if (allFree) { + checked++; + const boxes = [...current.nodes.values()].map(({ node: { id }, box }) => ({ id, box })); + assertOrthogonalRoutes(current, bounds, `seeded scene ${sample}: ${JSON.stringify(boxes)}`); + } + } + assert.ok(checked > 0, "expected seeded scenes where every drop found a free position"); +}); + test("layoutCanvas routes a lower-left sender into a west port from outside its face", async () => { await loadAvoid(WASM); const layout = layoutCanvas(rendering( diff --git a/editors/vscode/src/webview/layout.ts b/editors/vscode/src/webview/layout.ts index 228dd97a12..f5106f1493 100644 --- a/editors/vscode/src/webview/layout.ts +++ b/editors/vscode/src/webview/layout.ts @@ -25,7 +25,7 @@ import { type PortPosition, type Side, } from "./geometry"; -import { avoidRoutes, type AvoidShape } from "./avoid"; +import { avoidRoutes, CLEARANCE, compactRoute, MIN_JOG, type AvoidShape } from "./avoid"; export { GAP, portBox, portCenter, portFace, PORT_SIZE, snap } from "./geometry"; export type { Box, PortPosition, Side } from "./geometry"; @@ -100,6 +100,8 @@ export interface Overrides { nodes?: Map; /** By edge index; an entry of no points shows the edge straight. */ routes?: Map; + /** Rerouted edges stay inside this box. */ + bounds?: Box; /** The layout a gesture started from: its rerouted edges whose ends have not moved are kept, the rest drawn straight, so a drag does not re-route. */ held?: CanvasLayout; } @@ -199,16 +201,9 @@ export function layoutCanvas(result: RenderResult, overrides: Overrides = {}, au if (entry.hidden) { continue; } - reach(entry.box.x, entry.box.y); - reach(entry.box.x + entry.box.width, entry.box.y + entry.box.height); - for (const port of entry.ports) { - const square = portBox(entry.box, port); - reach(square.x, square.y); - reach(square.x + square.width, square.y + square.height); - const label = portLabelPlacement(entry.box, port).bounds; - reach(label.x, label.y); - reach(label.x + label.width, label.y + label.height); - } + const nodeBounds = nodeExtent(entry); + reach(nodeBounds.x, nodeBounds.y); + reach(nodeBounds.x + nodeBounds.width, nodeBounds.y + nodeBounds.height); } const edges = (result.edges ?? []).map((edge, index) => routeEdge(edge, index, placed, overrides.routes, auto)); rerouteAroundBoxes(edges, placed, roots, overrides, auto); @@ -434,6 +429,456 @@ export function portLabelPlacement(box: Box, port: PlacedPort): PortLabelPlaceme }; } +/** nodeExtent covers a node, its ports and labels, plus any outward port-pin leg requested. */ +export function nodeExtent( + node: PlacedNode, + portExitLeg: number | ((port: PlacedPort) => number) = 0, +): Box { + let left = node.box.x; + let top = node.box.y; + let right = node.box.x + node.box.width; + let bottom = node.box.y + node.box.height; + const reach = (x: number, y: number): void => { + left = Math.min(left, x); + top = Math.min(top, y); + right = Math.max(right, x); + bottom = Math.max(bottom, y); + }; + for (const port of node.ports) { + const square = portBox(node.box, port); + const label = portLabelPlacement(node.box, port).bounds; + reach(square.x, square.y); + reach(square.x + square.width, square.y + square.height); + reach(label.x, label.y); + reach(label.x + label.width, label.y + label.height); + const exitLeg = typeof portExitLeg === "function" ? portExitLeg(port) : portExitLeg; + if (exitLeg > 0) { + const face = portFace(node.box, port); + switch (port.side) { + case "north": + reach(face.x, face.y - exitLeg); + break; + case "east": + reach(face.x + exitLeg, face.y); + break; + case "south": + reach(face.x, face.y + exitLeg); + break; + case "west": + reach(face.x - exitLeg, face.y); + break; + } + } + } + return { x: left, y: top, width: right - left, height: bottom - top }; +} + +/** clampNodeToBounds keeps a node's full visible and routing extent inside a box. */ +export function clampNodeToBounds( + node: PlacedNode, + at: RenderPoint, + bounds: Box, + portExitLeg: number | ((port: PlacedPort) => number) = 0, +): RenderPoint { + const extent = nodeExtent(node, portExitLeg); + const left = extent.x - node.box.x; + const top = extent.y - node.box.y; + const right = extent.x + extent.width - node.box.x; + const bottom = extent.y + extent.height - node.box.y; + const minX = bounds.x - left; + const minY = bounds.y - top; + return { + x: Math.min(Math.max(at.x, minX), Math.max(minX, bounds.x + bounds.width - right)), + y: Math.min(Math.max(at.y, minY), Math.max(minY, bounds.y + bounds.height - bottom)), + }; +} + +/** Finds the nearest free, in-bounds position for a node. */ +export function freePlacement( + node: PlacedNode, + at: RenderPoint, + others: PlacedNode[], + bounds: Box, + portExitLeg: number | ((node: PlacedNode, port: PlacedPort) => number), + direction?: { x: number; y: number }, +): RenderPoint | undefined { + const exitLeg = (entry: PlacedNode, port: PlacedPort): number => + typeof portExitLeg === "number" ? portExitLeg : portExitLeg(entry, port); + const clamp = (point: RenderPoint): RenderPoint => + clampNodeToBounds(node, point, bounds, (port) => exitLeg(node, port)); + const extentAt = (entry: PlacedNode, point: RenderPoint): Box => + nodeExtent({ ...entry, box: { ...entry.box, x: point.x, y: point.y } }, (port) => exitLeg(entry, port)); + const expandedAt = (point: RenderPoint): Box => inflate(extentAt(node, point), CLEARANCE); + const otherExtents = others + .filter((other) => other.node.id !== node.node.id && !other.hidden) + .map((other) => inflate(extentAt(other, other.box), CLEARANCE)); + const isFree = (point: RenderPoint): boolean => { + const extent = expandedAt(point); + return otherExtents.every((other) => !intersectsBoxes(extent, other)); + }; + const clampedAt = clamp(at); + if (isFree(clampedAt)) { + return clampedAt; + } + + const movingAtOrigin = expandedAt({ x: node.box.x, y: node.box.y }); + const leftOffset = movingAtOrigin.x - node.box.x; + const rightOffset = movingAtOrigin.x + movingAtOrigin.width - node.box.x; + const topOffset = movingAtOrigin.y - node.box.y; + const bottomOffset = movingAtOrigin.y + movingAtOrigin.height - node.box.y; + const baseExtent = extentAt(node, node.box); + const minX = bounds.x - (baseExtent.x - node.box.x); + const minY = bounds.y - (baseExtent.y - node.box.y); + const maxX = Math.max(minX, bounds.x + bounds.width - (baseExtent.x + baseExtent.width - node.box.x)); + const maxY = Math.max(minY, bounds.y + bounds.height - (baseExtent.y + baseExtent.height - node.box.y)); + const xs = new Set([at.x, minX, maxX]); + const ys = new Set([at.y, minY, maxY]); + for (const extent of otherExtents) { + xs.add(extent.x - rightOffset); + xs.add(extent.x + extent.width - leftOffset); + ys.add(extent.y - bottomOffset); + ys.add(extent.y + extent.height - topOffset); + } + + const candidates = new Map(); + for (const x of xs) { + if (x < minX || x > maxX) { + continue; + } + for (const y of ys) { + if (y >= minY && y <= maxY) { + candidates.set(`${x},${y}`, { x, y }); + } + } + } + + const ordered = [...candidates.values()].sort( + (first, second) => Math.hypot(first.x - at.x, first.y - at.y) - Math.hypot(second.x - at.x, second.y - at.y), + ); + for (const candidate of ordered) { + if (direction && !movesInDirection(candidate, at, direction)) { + continue; + } + if (isFree(candidate)) { + return candidate; + } + } + return direction ? undefined : clampedAt; +} + +/** Lines a dropped node's port up with a wired port less than MIN_JOG out of line, where that spot is free. */ +export function alignedPlacement( + node: PlacedNode, + at: RenderPoint, + layout: CanvasLayout, + bounds: Box, + portExitLeg: number | ((node: PlacedNode, port: PlacedPort) => number), +): RenderPoint { + const nodeId = node.node.id; + const others = [...layout.nodes.values()].filter((entry) => entry.node.id !== nodeId && !entry.hidden); + const pairs: Array<{ + port: PlacedPort; + other: PlacedPort; + otherNode: PlacedNode; + axis: "x" | "y"; + }> = []; + for (const placed of layout.edges) { + if (placed.hidden) { + continue; + } + const edge = placed.edge; + const fromIsNode = edge.from === nodeId; + const toIsNode = edge.to === nodeId; + if (fromIsNode === toIsNode) { + continue; + } + const otherNode = layout.nodes.get(fromIsNode ? edge.to : edge.from); + if (!otherNode || otherNode.hidden) { + continue; + } + const ownPortId = fromIsNode ? edge.fromPort : edge.toPort; + const otherPortId = fromIsNode ? edge.toPort : edge.fromPort; + if (ownPortId === undefined || otherPortId === undefined) { + continue; + } + const port = node.ports.find((candidate) => candidate.port.id === ownPortId); + const other = otherNode.ports.find((candidate) => candidate.port.id === otherPortId); + if (!port || !other) { + continue; + } + const ownFace = portFace({ ...node.box, x: at.x, y: at.y }, port); + const otherFace = portFace(otherNode.box, other); + const axis = + (port.side === "east" && other.side === "west" && otherFace.x > ownFace.x) || + (port.side === "west" && other.side === "east" && otherFace.x < ownFace.x) + ? "y" + : (port.side === "south" && other.side === "north" && otherFace.y > ownFace.y) || + (port.side === "north" && other.side === "south" && otherFace.y < ownFace.y) + ? "x" + : undefined; + if (axis) { + pairs.push({ port, other, otherNode, axis }); + } + } + + const offsetAt = ( + pair: (typeof pairs)[number], + position: RenderPoint, + ): number => { + const ownCenter = portCenter({ ...node.box, x: position.x, y: position.y }, pair.port); + const otherCenter = portCenter(pair.otherNode.box, pair.other); + return pair.axis === "x" ? otherCenter.x - ownCenter.x : otherCenter.y - ownCenter.y; + }; + const xShifts = new Set([0]); + const yShifts = new Set([0]); + for (const pair of pairs) { + const offset = offsetAt(pair, at); + if (Math.abs(offset) > 1e-6 && Math.abs(offset) < MIN_JOG) { + (pair.axis === "x" ? xShifts : yShifts).add(offset); + } + } + + const jogCount = (position: RenderPoint): number => + pairs.reduce((count, pair) => { + const offset = Math.abs(offsetAt(pair, position)); + return count + (offset > 1e-6 && offset < MIN_JOG ? 1 : 0); + }, 0); + let best: RenderPoint | undefined; + let bestJogs = Number.POSITIVE_INFINITY; + let bestDistance = Number.POSITIVE_INFINITY; + for (const xShift of xShifts) { + for (const yShift of yShifts) { + const candidate = { x: at.x + xShift, y: at.y + yShift }; + const placed = freePlacement(node, candidate, others, bounds, portExitLeg); + if ( + !placed || + Math.abs(placed.x - candidate.x) > 1e-6 || + Math.abs(placed.y - candidate.y) > 1e-6 + ) { + continue; + } + const jogs = jogCount(candidate); + const distance = Math.hypot(xShift, yShift); + if (jogs < bestJogs || (jogs === bestJogs && distance < bestDistance)) { + best = candidate; + bestJogs = jogs; + bestDistance = distance; + } + } + } + return best ?? at; +} + +function orthogonalRoute(points: RenderPoint[]): boolean { + return points.every((point, index) => { + if (index === 0) { + return true; + } + const previous = points[index - 1]; + return Math.abs(point.x - previous.x) <= 1e-6 || Math.abs(point.y - previous.y) <= 1e-6; + }); +} + +function routeDirection(start: RenderPoint, end: RenderPoint): RenderPoint { + const dx = end.x - start.x; + const dy = end.y - start.y; + return Math.abs(dx) >= Math.abs(dy) ? { x: Math.sign(dx), y: 0 } : { x: 0, y: Math.sign(dy) }; +} + +function followsDirection(start: RenderPoint, end: RenderPoint, direction: RenderPoint): boolean { + const dx = end.x - start.x; + const dy = end.y - start.y; + const length = Math.hypot(dx, dy); + return ( + length > 1e-6 && + Math.abs(dx / length - direction.x) <= 1e-6 && + Math.abs(dy / length - direction.y) <= 1e-6 + ); +} + +function manhattanRoute(start: RenderPoint, startDirection: RenderPoint, end: RenderPoint, endDirection: RenderPoint): RenderPoint[] { + const a = { + x: start.x + startDirection.x * CLEARANCE, + y: start.y + startDirection.y * CLEARANCE, + }; + const b = { + x: end.x + endDirection.x * CLEARANCE, + y: end.y + endDirection.y * CLEARANCE, + }; + let middle: RenderPoint[]; + if (startDirection.y === 0 && endDirection.y === 0) { + const dx = b.x - a.x; + if (dx === 0 || Math.sign(dx) === startDirection.x) { + const middleX = (a.x + b.x) / 2; + middle = [a, { x: middleX, y: a.y }, { x: middleX, y: b.y }, b]; + } else { + const middleY = (a.y + b.y) / 2; + middle = [a, { x: a.x, y: middleY }, { x: b.x, y: middleY }, b]; + } + } else if (startDirection.x === 0 && endDirection.x === 0) { + const dy = b.y - a.y; + if (dy === 0 || Math.sign(dy) === startDirection.y) { + const middleY = (a.y + b.y) / 2; + middle = [a, { x: a.x, y: middleY }, { x: b.x, y: middleY }, b]; + } else { + const middleX = (a.x + b.x) / 2; + middle = [a, { x: middleX, y: a.y }, { x: middleX, y: b.y }, b]; + } + } else if (startDirection.y === 0) { + middle = [a, { x: b.x, y: a.y }, b]; + } else { + middle = [a, { x: a.x, y: b.y }, b]; + } + return compactRoute([{ ...start }, ...middle, { ...end }]); +} + +/** Moves an orthogonal route's ends to new points, keeping every segment orthogonal. */ +export function reattachRoute(route: RenderPoint[], start: RenderPoint, end: RenderPoint): RenderPoint[] { + const points = compactRoute(route.map((point) => ({ ...point }))); + if (!orthogonalRoute(points) || points.length < 2) { + return route; + } + const startDirection = routeDirection(points[0], points[1]); + const endDirection = routeDirection(points.at(-1)!, points.at(-2)!); + const finish = (result: RenderPoint[]): RenderPoint[] => { + const compacted = compactRoute(result); + if ( + compacted.length >= 2 && + followsDirection(compacted[0], compacted[1], startDirection) && + followsDirection(compacted.at(-1)!, compacted.at(-2)!, endDirection) + ) { + return compacted; + } + return manhattanRoute(start, startDirection, end, endDirection); + }; + + if (points.length === 2) { + const [first, last] = points; + if (Math.abs(last.y - first.y) <= 1e-6) { + if (Math.abs(start.y - end.y) <= 1e-6) { + return finish([{ ...start }, { ...end }]); + } + const middleX = (start.x + end.x) / 2; + return finish([ + { ...start }, + { x: middleX, y: start.y }, + { x: middleX, y: end.y }, + { ...end }, + ]); + } + if (Math.abs(start.x - end.x) <= 1e-6) { + return finish([{ ...start }, { ...end }]); + } + const middleY = (start.y + end.y) / 2; + return finish([ + { ...start }, + { x: start.x, y: middleY }, + { x: end.x, y: middleY }, + { ...end }, + ]); + } + + const startDx = start.x - points[0].x; + const startDy = start.y - points[0].y; + if (Math.abs(points[1].y - points[0].y) <= 1e-6) { + points[1].y += startDy; + if (points.length >= 4) { + points[1].x += startDx; + points[2].x += startDx; + } + } else { + points[1].x += startDx; + if (points.length >= 4) { + points[1].y += startDy; + points[2].y += startDy; + } + } + points[0] = { ...start }; + + const last = points.length - 1; + const endDx = end.x - points[last].x; + const endDy = end.y - points[last].y; + if (Math.abs(points[last].y - points[last - 1].y) <= 1e-6) { + points[last - 1].y += endDy; + if (points.length >= 4) { + points[last - 1].x += endDx; + points[last - 2].x += endDx; + } + } else { + points[last - 1].x += endDx; + if (points.length >= 4) { + points[last - 1].y += endDy; + points[last - 2].y += endDy; + } + } + points[last] = { ...end }; + return finish(points); +} + +/** Keeps each edge's previous orthogonal route, reattached to its current ends, where the new route is not orthogonal. */ +export function keepOrthogonalRoutes(next: CanvasLayout, previous: CanvasLayout): CanvasLayout { + let edges: PlacedEdge[] | undefined; + for (let index = 0; index < next.edges.length; index++) { + const edge = next.edges[index]; + const old = previous.edges[index]; + if ( + edge.hidden || + orthogonalRoute(edge.points) || + !old || + old.hidden || + !orthogonalRoute(old.points) || + edge.edge.from !== old.edge.from || + edge.edge.to !== old.edge.to || + edge.edge.fromPort !== old.edge.fromPort || + edge.edge.toPort !== old.edge.toPort + ) { + continue; + } + + const source = next.nodes.get(edge.edge.from); + const target = next.nodes.get(edge.edge.to); + const sourcePort = source?.ports.find((port) => port.port.id === edge.edge.fromPort); + const targetPort = target?.ports.find((port) => port.port.id === edge.edge.toPort); + const start = source && sourcePort ? portFace(source.box, sourcePort) : edge.points[0]; + const end = target && targetPort ? portFace(target.box, targetPort) : edge.points.at(-1); + if (!start || !end) { + continue; + } + const points = reattachRoute(old.points, start, end); + edges ??= [...next.edges]; + edges[index] = { ...edge, points, route: points.slice(1, -1), label: midpoint(points), rerouted: true }; + } + return edges ? { ...next, edges } : next; +} + +function movesInDirection(candidate: RenderPoint, at: RenderPoint, direction: RenderPoint): boolean { + if (direction.x !== 0 && direction.y === 0) { + return (candidate.x - at.x) * direction.x > 0 && candidate.y === at.y; + } + if (direction.y !== 0 && direction.x === 0) { + return (candidate.y - at.y) * direction.y > 0 && candidate.x === at.x; + } + return false; +} + +function inflate(box: Box, distance: number): Box { + return { + x: box.x - distance, + y: box.y - distance, + width: box.width + 2 * distance, + height: box.height + 2 * distance, + }; +} + +function intersectsBoxes(first: Box, second: Box): boolean { + return ( + first.x < second.x + second.width && + first.x + first.width > second.x && + first.y < second.y + second.height && + first.y + first.height > second.y + ); +} + function placePorts(edges: RenderEdge[], placed: Map, auto?: AutoLayout): void { for (const entry of placed.values()) { if (entry.hidden) { @@ -489,18 +934,15 @@ function includePortExtent(layout: CanvasLayout): CanvasLayout { let bottom = layout.origin.y + layout.height; let hasPorts = false; for (const entry of layout.nodes.values()) { - if (entry.hidden) { + if (entry.hidden || entry.ports.length === 0) { continue; } - for (const port of entry.ports) { - hasPorts = true; - const square = portBox(entry.box, port); - const label = portLabelPlacement(entry.box, port).bounds; - left = Math.min(left, square.x, label.x); - top = Math.min(top, square.y, label.y); - right = Math.max(right, square.x + square.width, label.x + label.width); - bottom = Math.max(bottom, square.y + square.height, label.y + label.height); - } + hasPorts = true; + const extent = nodeExtent(entry); + left = Math.min(left, extent.x); + top = Math.min(top, extent.y); + right = Math.max(right, extent.x + extent.width); + bottom = Math.max(bottom, extent.y + extent.height); } if (!hasPorts) { return layout; @@ -781,6 +1223,7 @@ function rerouteAroundBoxes( fromPort: edges[index].edge.fromPort, toPort: edges[index].edge.toPort, })), + overrides.bounds, ); if (!routes) { continue; diff --git a/editors/vscode/tools/build-landing.mjs b/editors/vscode/tools/build-landing.mjs new file mode 100644 index 0000000000..8f44357de2 --- /dev/null +++ b/editors/vscode/tools/build-landing.mjs @@ -0,0 +1,20 @@ +import { build } from "esbuild"; +import { copyFile, mkdir } from "node:fs/promises"; + +const dev = process.argv.includes("--dev"); +const outDir = "../../docs/assets/landing"; + +await mkdir(outDir, { recursive: true }); +await copyFile("node_modules/libavoid-js/dist/libavoid.wasm", `${outDir}/libavoid.wasm`); +await copyFile("node_modules/libavoid-js/LICENSE", `${outDir}/libavoid-js.LICENSE.txt`); +await build({ + entryPoints: ["src/landing/main.ts"], + bundle: true, + outfile: `${outDir}/diagram.js`, + format: "iife", + platform: "browser", + target: "es2020", + sourcemap: dev, + minify: !dev, + logLevel: "info", +}); diff --git a/editors/vscode/tsconfig.json b/editors/vscode/tsconfig.json index e0700b10a0..7134cf3ed2 100644 --- a/editors/vscode/tsconfig.json +++ b/editors/vscode/tsconfig.json @@ -15,5 +15,5 @@ "sourceMap": true }, "include": ["src"], - "exclude": ["src/webview"] + "exclude": ["src/webview", "src/landing"] } diff --git a/mkdocs.yml b/mkdocs.yml index c2d597dbe3..8af28230d5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -290,5 +290,6 @@ nav: - Grammar: reference/grammar/README.md - Grammar conformance audit: reference/grammar/conformance-audit.md - WebAssembly builds: reference/wasm.md - # internals/ and project/ are published but kept out of the tab bar: they are contributor - # records, reachable through links, search and GitHub (see not_in_nav). + - Third-party notices: project/third-party-notices.md + # internals/ and project/ contributor records are kept out of the tab bar; the + # public third-party notices are the exception. diff --git a/overrides/home.html b/overrides/home.html index e7dbd68cf0..c057a6e1f4 100644 --- a/overrides/home.html +++ b/overrides/home.html @@ -567,44 +567,55 @@ margin: 0 auto; padding: 1rem 1.2rem .6rem; } - .osml-schematic svg { - display: block; - width: 100%; - height: auto; + .osml-hero { position: relative; } + /* The stage reserves the diagram's room in the hero; the drawing itself covers the + whole hero, so a box can be dragged anywhere on the blue. */ + .osml-schematic__stage { position: relative; - /* Boxes can be dragged anywhere in the hero, beyond the figure's own box. */ - overflow: visible; - font-family: Helvetica, Arial, sans-serif; + min-height: 14rem; } - .osml-wire { - fill: none; - stroke: rgba(255, 255, 255, .55); - stroke-width: 1; - stroke-linecap: round; + .osml-schematic--drawn .osml-schematic__stage { min-height: 0; } + .osml-diagram { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: hidden; + pointer-events: none; + z-index: 1; + font-family: Helvetica, Arial, sans-serif; } - .osml-node { + .osml-diagram .shape, + .osml-diagram .port-shape { fill: #fff; stroke: #181818; stroke-width: 1; } - .osml-nnode[data-osml-kind="runtime"] .osml-node { fill: #e3f2fd; stroke: #1565c0; } - .osml-nnode[data-osml-kind="runtime"] .osml-node-stereo { fill: #1565c0; } - .osml-nnode[data-osml-kind="toolchain"] .osml-node { fill: #e8f5e9; stroke: #2e7d32; } - .osml-nnode[data-osml-kind="toolchain"] .osml-node-stereo { fill: #2e7d32; } - .osml-nnode[data-osml-kind="reference"] .osml-node { fill: #f3e5f5; stroke: #6a1b9a; } - .osml-nnode[data-osml-kind="reference"] .osml-node-stereo { fill: #6a1b9a; } - .osml-nnode[data-osml-kind="model store"] .osml-node { fill: #fff3e0; stroke: var(--md-accent-fg-color); stroke-width: 2; } - .osml-nnode[data-osml-kind="model store"] .osml-node-stereo { fill: var(--md-accent-fg-color); } - .osml-node-stereo { - fill: #181818; - font-style: italic; - } - .osml-node-label { - fill: #181818; - font-weight: 700; - } - .osml-node-sub { - fill: #181818; + .osml-diagram g[data-kind="runtime"] > .shape, + .osml-diagram g[data-kind="runtime"] .port-shape { fill: #e3f2fd; stroke: #1565c0; } + .osml-diagram g[data-kind="runtime"] .keyword { fill: #1565c0; } + .osml-diagram g[data-kind="toolchain"] > .shape, + .osml-diagram g[data-kind="toolchain"] .port-shape { fill: #e8f5e9; stroke: #2e7d32; } + .osml-diagram g[data-kind="toolchain"] .keyword { fill: #2e7d32; } + .osml-diagram g[data-kind="reference"] > .shape, + .osml-diagram g[data-kind="reference"] .port-shape { fill: #f3e5f5; stroke: #6a1b9a; } + .osml-diagram g[data-kind="reference"] .keyword { fill: #6a1b9a; } + .osml-diagram g[data-kind="model store"] > .shape, + .osml-diagram g[data-kind="model store"] .port-shape { fill: #fff3e0; stroke: var(--md-accent-fg-color); stroke-width: 2; } + .osml-diagram g[data-kind="model store"] .keyword { fill: var(--md-accent-fg-color); } + .osml-diagram .label { fill: #181818; } + .osml-diagram .label .head { font-weight: 700; } + .osml-diagram .label .keyword { font-style: italic; font-size: .8em; } + .osml-diagram .label .detail { font-size: .8em; } + .osml-diagram .port-label { + fill: rgba(255, 255, 255, .85); + font-size: 11px; + } + .osml-diagram .line { + fill: none; + stroke: rgba(255, 255, 255, .6); + stroke-width: 1.25; + stroke-linejoin: round; } .osml-schematic__caption { text-align: center; @@ -612,36 +623,37 @@ opacity: .75; margin: .4rem 0 0; } - .osml-nnode { + .osml-diagram g.osml-part { + pointer-events: auto; cursor: grab; touch-action: none; outline: none; } - .osml-nnode text { - pointer-events: none; + .osml-diagram g.osml-part text { user-select: none; } - .osml-nnode.is-dragging { + .osml-hero--dragging, + .osml-hero--dragging .osml-diagram g.osml-part { cursor: grabbing; } - .osml-nnode, - .osml-wire { + .osml-diagram g.osml-part, + .osml-diagram g.opensysml-edge { transition: opacity .25s; } - /* The hot node and its wires stay lit while the rest of the figure dims back. */ - .osml-schematic--focus .osml-nnode:not(.is-hot), - .osml-schematic--focus .osml-wire:not(.is-hot) { + /* The hot box and its wires stay lit while the rest of the figure dims back. */ + .osml-hero--focus .osml-diagram g.osml-part:not(.is-hot), + .osml-hero--focus .osml-diagram g.opensysml-edge:not(.is-hot) { opacity: .28; } - .osml-nnode.is-hot .osml-node, - .osml-nnode:focus-visible .osml-node { + .osml-diagram g.osml-part.is-hot > .shape, + .osml-diagram g.osml-part:focus-visible > .shape { stroke: #fff; stroke-width: 3; } - .osml-nnode.is-hot { + .osml-diagram g.osml-part.is-hot { filter: drop-shadow(0 0 .3rem #fff) drop-shadow(0 0 .9rem rgba(255, 255, 255, .55)); } - .osml-wire.is-hot { + .osml-diagram g.opensysml-edge.is-hot .line { stroke: #fff; stroke-width: 2; } @@ -659,6 +671,7 @@ font-size: .62rem; line-height: 1.45; text-align: left; + overflow: auto; } .osml-nodecard[hidden] { display: none; } .osml-nodecard__stereo { @@ -691,14 +704,14 @@ } .osml-nodecard__close:hover { opacity: 1; } /* A run of the model: the token travels the wires the execution crossed. */ - .osml-nnode.is-live .osml-node { + .osml-diagram g.osml-part.is-live > .shape { stroke: #ffd54f; stroke-width: 3; } - .osml-nnode.is-live { + .osml-diagram g.osml-part.is-live { filter: drop-shadow(0 0 .5rem #ffd54f); } - .osml-wire.is-live { + .osml-diagram g.opensysml-edge.is-live .line { stroke: #ffd54f; stroke-width: 2; } @@ -913,41 +926,12 @@

SysML v2 you can actually run.

- - - - - - - - - - - - «model store» - Flexo MMS - Kotlin · the versioned store - - - - «runtime» - OpenSysML - Go · runtime + LSP - - - - «toolchain» - sysml-toolkit - Rust · toolchain + LSP - - - - «reference» - SysML v2 Pilot Implementation - Java · OMG reference - - + data-osml-wasm-exec="{{ base_url }}/assets/wasm_exec.js" + data-osml-libavoid="{{ base_url }}/assets/landing/libavoid.wasm" + data-osml-script="{{ base_url }}/assets/landing/diagram.js"> +
+ +

One open interchange, three implementations, a version-controlled store. Drag the boxes around — click opens the project, right-click shows its attributes. Edit the model and the engine redraws the diagram as you type.

@@ -961,665 +945,24 @@

SysML v2 you can actually run.

+
@@ -1759,6 +1102,7 @@

Licensing

  • The one exception is the OMG reference implementation, which is licensed EPL-2.0 rather than Apache-2.0.
  • See the stack above for the specific license per project.
  • +
  • The diagram above bundles elkjs (EPL-2.0) and libavoid (LGPL-2.1-or-later); see the third-party notices.
  • diff --git a/scripts/ci-changed-areas-test.sh b/scripts/ci-changed-areas-test.sh index 875d893906..89072308a2 100755 --- a/scripts/ci-changed-areas-test.sh +++ b/scripts/ci-changed-areas-test.sh @@ -43,6 +43,7 @@ case_() { } case_ docs-only docs docs/guide/index.md +case_ overrides-only docs overrides/home.html case_ changelog-only docs CHANGELOG.md case_ changelog-fragment docs changes/unreleased/repl-thing.added.md case_ java-module java,mdk client/java/opensysml-client/pom.xml diff --git a/scripts/ci-changed-areas.sh b/scripts/ci-changed-areas.sh index 5fed5c86d6..975f2f5341 100755 --- a/scripts/ci-changed-areas.sh +++ b/scripts/ci-changed-areas.sh @@ -18,7 +18,7 @@ changed=$(git diff --name-only "$(git merge-base "$base" "$head")" "$head") # The extension's grammar generator and its committed output are here too: the # test that holds them together is a Go test, run by the Go suite. service_pattern='^(api/proto/|cmd/|internal/|tools/|client/opensysml/|client/release-digests\.json$|conformance/|tests/|scripts/|examples/|editors/vscode/tools/|editors/vscode/syntaxes/|Makefile$|go\.mod$|go\.sum$|\.github/workflows/|\.circleci/)' -docs_pattern='^(docs/|packaging/man/|mkdocs\.yml$|README\.md$|CHANGELOG\.md$|CONTRIBUTING\.md$|AGENTS\.md$|.*\.md$)' +docs_pattern='^(docs/|overrides/|packaging/man/|mkdocs\.yml$|README\.md$|CHANGELOG\.md$|CONTRIBUTING\.md$|AGENTS\.md$|.*\.md$)' node_pattern='^client/node/' python_pattern='^client/python/' java_pattern='^client/java/' diff --git a/tests/wasm/landing_model_test.go b/tests/wasm/landing_model_test.go index e036b7265a..4cae95aab5 100644 --- a/tests/wasm/landing_model_test.go +++ b/tests/wasm/landing_model_test.go @@ -2,6 +2,7 @@ package wasm import ( "encoding/json" + "flag" "os" "path/filepath" "reflect" @@ -11,6 +12,8 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/frontend/engine" ) +var updateLanding = flag.Bool("update-landing", false, "rewrite editors/vscode/src/landing/stack.json") + // TestLandingStackModel pins what the landing page reads from // docs/assets/opensysml-stack.sysml through sysml-engine: a clean parse, the // stack diagram rendered through RenderView, each part's attributes and API @@ -24,7 +27,7 @@ func TestLandingStackModel(t *testing.T) { if err != nil { t.Fatalf("engine.New: %v", err) } - call := func(method string, params any, into any) { + call := func(method string, params any, into any) []byte { t.Helper() raw, err := json.Marshal(params) if err != nil { @@ -34,6 +37,7 @@ func TestLandingStackModel(t *testing.T) { if err := json.Unmarshal(body, into); err != nil { t.Fatalf("decoding %s: %v\n%s", method, err, body) } + return body } var parsed struct { @@ -62,7 +66,7 @@ func TestLandingStackModel(t *testing.T) { Error string `json:"error"` Instances []instance `json:"instances"` } - call("Instantiate", map[string]string{"modelHash": parsed.ModelHash, "symbolId": "OpenSysMLStack::stack"}, &built) + instantiateBody := call("Instantiate", map[string]string{"modelHash": parsed.ModelHash, "symbolId": "OpenSysMLStack::stack"}, &built) if built.Error != "" { t.Fatalf("Instantiate: %s", built.Error) } @@ -130,10 +134,12 @@ func TestLandingStackModel(t *testing.T) { } var diagram engine.JRenderViewResponse - call("RenderView", map[string]string{ + renderBody := call("RenderView", map[string]string{ "modelHash": parsed.ModelHash, "view": "#interconnection:OpenSysMLStack::stack", + "ports": "minimal", }, &diagram) + pinLandingFixture(t, parsed.ModelHash, renderBody, instantiateBody) if diagram.Kind != "interconnection" { t.Fatalf("landing diagram kind = %q, want interconnection", diagram.Kind) } @@ -225,7 +231,7 @@ func TestLandingStackModel(t *testing.T) { call("ExecuteState", map[string]any{ "modelHash": parsed.ModelHash, "stateMachineSymbolId": "OpenSysMLStack::ModelJourney", - "events": []string{"commit", "pull", "push", "check"}, + "events": []string{"Commit", "Pull", "Push", "Check"}, }, &journey) if journey.Error != "" { t.Fatalf("ExecuteState: %s", journey.Error) @@ -234,3 +240,63 @@ func TestLandingStackModel(t *testing.T) { t.Errorf("journey: got %v, want %v", journey.StatesVisited, want) } } + +type landingFixture struct { + Hash string `json:"hash"` + Render json.RawMessage `json:"render"` + Instances json.RawMessage `json:"instances"` +} + +func pinLandingFixture(t *testing.T, hash string, render, instantiate []byte) { + t.Helper() + var response struct { + Instances json.RawMessage `json:"instances"` + } + if err := json.Unmarshal(instantiate, &response); err != nil { + t.Fatalf("decoding Instantiate fixture data: %v\n%s", err, instantiate) + } + fixture := landingFixture{ + Hash: hash, + Render: json.RawMessage(render), + Instances: response.Instances, + } + path := filepath.Join("..", "..", "editors", "vscode", "src", "landing", "stack.json") + if *updateLanding { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("creating landing fixture directory: %v", err) + } + data, err := json.MarshalIndent(fixture, "", " ") + if err != nil { + t.Fatalf("encoding landing fixture: %v", err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + t.Fatalf("writing landing fixture: %v", err) + } + t.Logf("updated %s", path) + return + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v; run go test -count=1 ./tests/wasm -run '^TestLandingStackModel$' -update-landing", path, err) + } + var want landingFixture + if err := json.Unmarshal(data, &want); err != nil { + t.Fatalf("decoding %s: %v", path, err) + } + var gotRender, wantRender, gotInstances, wantInstances any + if err := json.Unmarshal(fixture.Render, &gotRender); err != nil { + t.Fatalf("decoding current RenderView result: %v", err) + } + if err := json.Unmarshal(want.Render, &wantRender); err != nil { + t.Fatalf("decoding fixture RenderView result: %v", err) + } + if err := json.Unmarshal(fixture.Instances, &gotInstances); err != nil { + t.Fatalf("decoding current Instantiate instances: %v", err) + } + if err := json.Unmarshal(want.Instances, &wantInstances); err != nil { + t.Fatalf("decoding fixture Instantiate instances: %v", err) + } + if fixture.Hash != want.Hash || !reflect.DeepEqual(gotRender, wantRender) || !reflect.DeepEqual(gotInstances, wantInstances) { + t.Fatalf("%s is stale; run go test -count=1 ./tests/wasm -run '^TestLandingStackModel$' -update-landing", path) + } +}