From 6c1e1c6f2e085bb1aba44b47cedc7fe5963a8d2f Mon Sep 17 00:00:00 2001 From: milanofthe Date: Sun, 13 Sep 2026 15:54:33 +0200 Subject: [PATCH 1/8] Add vitest, shared port geometry and routing test harness --- package-lock.json | 442 +++++++++++++++++++++++- package.json | 6 +- src/lib/components/FlowCanvas.svelte | 75 +--- src/lib/routing/index.ts | 14 +- src/lib/routing/portGeometry.test.ts | 18 + src/lib/routing/portGeometry.ts | 51 +++ src/lib/routing/router.test.ts | 27 ++ src/lib/routing/routing.perf.test.ts | 34 ++ src/lib/routing/testing/invariants.ts | 159 +++++++++ src/lib/routing/testing/legacyRouter.ts | 85 +++++ src/lib/routing/testing/scenario.ts | 189 ++++++++++ src/lib/routing/types.ts | 24 ++ src/lib/stores/routing.ts | 8 +- vite.config.ts | 6 +- 14 files changed, 1058 insertions(+), 80 deletions(-) create mode 100644 src/lib/routing/portGeometry.test.ts create mode 100644 src/lib/routing/portGeometry.ts create mode 100644 src/lib/routing/router.test.ts create mode 100644 src/lib/routing/routing.perf.test.ts create mode 100644 src/lib/routing/testing/invariants.ts create mode 100644 src/lib/routing/testing/legacyRouter.ts create mode 100644 src/lib/routing/testing/scenario.ts diff --git a/package-lock.json b/package-lock.json index 6c03a47e..f56b5218 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,8 @@ "svelte": "^5.0.0", "svelte-check": "^4.0.0", "typescript": "^5.0.0", - "vite": "^7.3.0" + "vite": "^7.3.0", + "vitest": "^3.2.7" } }, "../dom2svg": { @@ -1352,6 +1353,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -1408,6 +1420,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1476,6 +1495,121 @@ "@types/node": "*" } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xyflow/svelte": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-1.5.0.tgz", @@ -1597,6 +1731,16 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -1779,6 +1923,16 @@ "node": "*" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1809,6 +1963,23 @@ "node": ">=10.0.0" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1826,6 +1997,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2129,6 +2310,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2207,6 +2398,13 @@ "once": "^1.4.0" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -2518,6 +2716,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2538,6 +2746,16 @@ "bare-events": "^2.7.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -2930,6 +3148,13 @@ "dev": true, "license": "ISC" }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -3077,6 +3302,13 @@ "dev": true, "license": "MIT" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", @@ -3328,6 +3560,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pathfinding": { "version": "0.4.18", "resolved": "https://registry.npmjs.org/pathfinding/-/pathfinding-0.4.18.tgz", @@ -3336,6 +3575,16 @@ "heap": "0.2.5" } }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -3788,6 +4037,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -3874,6 +4130,13 @@ "specificity": "bin/specificity" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackblur-canvas": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", @@ -3884,6 +4147,13 @@ "node": ">=0.1.14" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/streamx": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", @@ -3943,6 +4213,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/style-mod": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", @@ -4175,6 +4458,20 @@ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4192,6 +4489,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -4352,6 +4679,29 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vitefu": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", @@ -4372,6 +4722,79 @@ } } }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -4401,6 +4824,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 8bdea0e3..32737697 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test": "vitest run", + "test:watch": "vitest", + "bench:routing": "vitest run --mode perf src/lib/routing/routing.perf.test.ts", "lint": "eslint .", "format": "prettier --write .", "server": "python -m pathview.app", @@ -37,7 +40,8 @@ "svelte": "^5.0.0", "svelte-check": "^4.0.0", "typescript": "^5.0.0", - "vite": "^7.3.0" + "vite": "^7.3.0", + "vitest": "^3.2.7" }, "dependencies": { "@codemirror/lang-python": "^6.0.0", diff --git a/src/lib/components/FlowCanvas.svelte b/src/lib/components/FlowCanvas.svelte index 6a541f72..144d9ca9 100644 --- a/src/lib/components/FlowCanvas.svelte +++ b/src/lib/components/FlowCanvas.svelte @@ -25,7 +25,8 @@ import { selectedNodeIds as graphSelectedNodeIds } from '$lib/stores/graph/state'; import { historyStore } from '$lib/stores/history'; import { routingStore, buildRoutingContext, type PortInfo } from '$lib/stores/routing'; - import { HANDLE_OFFSET, ARROW_INSET, type Direction, type PortStub } from '$lib/routing'; + import { getPortInfo as computePortInfo, type PortStub } from '$lib/routing'; + import type { RotationValue } from '$lib/types/common'; import { themeStore, type Theme } from '$lib/stores/theme'; import { clearSelectionTrigger, nudgeTrigger, selectNodeTrigger, registerHasSelection, triggerFitView } from '$lib/stores/viewActions'; import { screenToFlow } from '$lib/utils/viewUtils'; @@ -270,9 +271,7 @@ pendingNodeUpdates = []; } - // Helper to get port position and direction in world coordinates - // Returns handle tip position (accounting for handle offset from block edge) - // For inputs, also accounts for arrowhead so stub starts within arrow + // Port handle tip position and direction in world coordinates function getPortInfo(nodeId: string, portIndex: number, isOutput: boolean): PortInfo | null { const node = nodeMap.get(nodeId); if (!node) return null; @@ -281,75 +280,11 @@ const ports = isOutput ? nodeData.outputs : nodeData.inputs; if (portIndex >= ports.length) return null; - const rotation = (nodeData.params?.['_rotation'] as number) || 0; + const rotation = ((nodeData.params?.['_rotation'] as number) || 0) as RotationValue; const width = node.measured?.width ?? node.width ?? DEFAULT_NODE_WIDTH; const height = node.measured?.height ?? node.height ?? DEFAULT_NODE_HEIGHT; - // Calculate port offset from center based on rotation - const portCount = ports.length; - const portSpacing = 20; // G.x2 - const span = (portCount - 1) * portSpacing; - const offsetFromCenter = -span / 2 + portIndex * portSpacing; - - let x = node.position.x; - let y = node.position.y; - let direction: Direction; - - // Additional offset: handle tip is HANDLE_OFFSET outside block edge - // For inputs (targets), add ARROW_INSET so stub starts within arrowhead - const extraOffset = isOutput ? HANDLE_OFFSET : (HANDLE_OFFSET + ARROW_INSET); - - // Position and direction based on rotation (output = right side for rotation 0) - if (isOutput) { - switch (rotation) { - case 1: // outputs at bottom - x += offsetFromCenter; - y += height / 2 + extraOffset; - direction = 'down'; - break; - case 2: // outputs at left - x -= width / 2 + extraOffset; - y += offsetFromCenter; - direction = 'left'; - break; - case 3: // outputs at top - x += offsetFromCenter; - y -= height / 2 + extraOffset; - direction = 'up'; - break; - default: // rotation 0 - outputs at right - x += width / 2 + extraOffset; - y += offsetFromCenter; - direction = 'right'; - break; - } - } else { - // Inputs are opposite to outputs - switch (rotation) { - case 1: // inputs at top - x += offsetFromCenter; - y -= height / 2 + extraOffset; - direction = 'up'; - break; - case 2: // inputs at right - x += width / 2 + extraOffset; - y += offsetFromCenter; - direction = 'right'; - break; - case 3: // inputs at bottom - x += offsetFromCenter; - y += height / 2 + extraOffset; - direction = 'down'; - break; - default: // rotation 0 - inputs at left - x -= width / 2 + extraOffset; - y += offsetFromCenter; - direction = 'left'; - break; - } - } - - return { position: { x, y }, direction }; + return computePortInfo(node.position, width, height, rotation, portIndex, ports.length, isOutput); } // Update routing context and recalculate all routes diff --git a/src/lib/routing/index.ts b/src/lib/routing/index.ts index dab4047d..0f930a78 100644 --- a/src/lib/routing/index.ts +++ b/src/lib/routing/index.ts @@ -8,6 +8,9 @@ export { calculateRoute, calculateRouteWithWaypoints, calculateSimpleRoute, getP // Grid export { SparseGrid } from './gridBuilder'; +// Port geometry +export { getPortInfo } from './portGeometry'; + // Constants used by FlowCanvas export { ROUTING_MARGIN, @@ -23,4 +26,13 @@ export { } from './constants'; // Types -export type { Bounds, RoutingContext, RouteResult, Direction, PortStub } from './types'; +export type { + Bounds, + RoutingContext, + RouteResult, + Direction, + PortStub, + PortInfo, + RouteRequest, + RoutingScene +} from './types'; diff --git a/src/lib/routing/portGeometry.test.ts b/src/lib/routing/portGeometry.test.ts new file mode 100644 index 00000000..cf959d4b --- /dev/null +++ b/src/lib/routing/portGeometry.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { getPortInfo } from './portGeometry'; + +const center = { x: 100, y: 100 }; + +describe('getPortInfo', () => { + it('places outputs on the right and inputs on the left without rotation', () => { + expect(getPortInfo(center, 80, 40, 0, 0, 2, true)).toEqual({ position: { x: 145, y: 90 }, direction: 'right' }); + expect(getPortInfo(center, 80, 40, 0, 1, 2, false)).toEqual({ position: { x: 52.5, y: 110 }, direction: 'left' }); + }); + + it('follows the rotation', () => { + expect(getPortInfo(center, 40, 80, 1, 0, 2, true)).toEqual({ position: { x: 90, y: 145 }, direction: 'down' }); + expect(getPortInfo(center, 40, 80, 1, 0, 1, false)).toEqual({ position: { x: 100, y: 52.5 }, direction: 'up' }); + expect(getPortInfo(center, 80, 40, 2, 0, 1, false)).toEqual({ position: { x: 147.5, y: 100 }, direction: 'right' }); + expect(getPortInfo(center, 40, 80, 3, 2, 3, true)).toEqual({ position: { x: 120, y: 55 }, direction: 'up' }); + }); +}); diff --git a/src/lib/routing/portGeometry.ts b/src/lib/routing/portGeometry.ts new file mode 100644 index 00000000..21c2b63e --- /dev/null +++ b/src/lib/routing/portGeometry.ts @@ -0,0 +1,51 @@ +/** + * Port geometry - single source for port handle positions in world coordinates + */ + +import type { Position, RotationValue } from '$lib/types/common'; +import type { Direction, PortInfo } from './types'; +import { getPortOffset } from '$lib/constants/dimensions'; +import { HANDLE_OFFSET, ARROW_INSET } from './constants'; + +/** Facing direction of output ports per rotation */ +const OUTPUT_DIRECTION: Record = { 0: 'right', 1: 'down', 2: 'left', 3: 'up' }; + +/** Facing direction of input ports per rotation (opposite side of outputs) */ +const INPUT_DIRECTION: Record = { 0: 'left', 1: 'up', 2: 'right', 3: 'down' }; + +/** + * Get the handle tip position and facing direction of a port. + * Input tips are pulled back by the arrow inset so route stubs end inside the arrowhead. + * + * @param center - Node center (nodes use origin [0.5, 0.5]) + * @param width - Node width + * @param height - Node height + * @param rotation - Node rotation (0-3) + * @param index - Port index on its side + * @param count - Number of ports on that side + * @param isOutput - Output (source) or input (target) port + */ +export function getPortInfo( + center: Position, + width: number, + height: number, + rotation: RotationValue, + index: number, + count: number, + isOutput: boolean +): PortInfo { + const direction = isOutput ? OUTPUT_DIRECTION[rotation] : INPUT_DIRECTION[rotation]; + const along = getPortOffset(index, count); + const out = isOutput ? HANDLE_OFFSET : HANDLE_OFFSET + ARROW_INSET; + + switch (direction) { + case 'right': + return { position: { x: center.x + width / 2 + out, y: center.y + along }, direction }; + case 'left': + return { position: { x: center.x - width / 2 - out, y: center.y + along }, direction }; + case 'down': + return { position: { x: center.x + along, y: center.y + height / 2 + out }, direction }; + case 'up': + return { position: { x: center.x + along, y: center.y - height / 2 - out }, direction }; + } +} diff --git a/src/lib/routing/router.test.ts b/src/lib/routing/router.test.ts new file mode 100644 index 00000000..d64599c5 --- /dev/null +++ b/src/lib/routing/router.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { generateScenario } from './testing/scenario'; +import { GRID_SIZE } from './constants'; + +describe('scenario generator', () => { + it('is deterministic for a seed', () => { + const a = generateScenario({ connections: 200, rotated: 0.3, seed: 5 }); + const b = generateScenario({ connections: 200, rotated: 0.3, seed: 5 }); + expect(a).toEqual(b); + }); + + it('generates the requested connections without fan-in', () => { + const scenario = generateScenario({ connections: 500, seed: 2 }); + expect(scenario.connections.length).toBe(500); + const targets = new Set(scenario.connections.map((c) => `${c.targetNodeId}:${c.targetPortIndex}`)); + expect(targets.size).toBe(scenario.connections.length); + }); + + it('keeps nodes on the grid', () => { + const scenario = generateScenario({ connections: 300, rotated: 0.5, seed: 9 }); + for (const node of scenario.nodes) { + for (const v of [node.center.x, node.center.y, node.width, node.height]) { + expect(v % GRID_SIZE).toBe(0); + } + } + }); +}); diff --git a/src/lib/routing/routing.perf.test.ts b/src/lib/routing/routing.perf.test.ts new file mode 100644 index 00000000..21685dc6 --- /dev/null +++ b/src/lib/routing/routing.perf.test.ts @@ -0,0 +1,34 @@ +/** + * Routing benchmark, run with `npm run bench:routing` + */ + +import { describe, it } from 'vitest'; +import type { RouteResult, RoutingScene } from './types'; +import { generateScenario, buildScene } from './testing/scenario'; +import { measureRoutes } from './testing/invariants'; +import { routeSceneLegacy } from './testing/legacyRouter'; + +interface Benchmarked { + route: (scene: RoutingScene) => Map; + sizes: number[]; +} + +const routers: Record = { + // Pre-v2 baseline, larger sizes take minutes + legacy: { route: routeSceneLegacy, sizes: [1000] } +}; + +describe.skipIf(import.meta.env.MODE !== 'perf')('routing performance', () => { + for (const [name, { route, sizes }] of Object.entries(routers)) { + for (const size of sizes) { + it(`${name}: full routing of ${size} connections`, () => { + const scene = buildScene(generateScenario({ connections: size, rotated: 0.1, seed: 7 })); + const start = performance.now(); + const routes = route(scene); + const ms = performance.now() - start; + const metrics = measureRoutes(scene, routes); + console.log(JSON.stringify({ router: name, connections: size, ms: Math.round(ms), ...metrics })); + }, 30 * 60_000); + } + } +}); diff --git a/src/lib/routing/testing/invariants.ts b/src/lib/routing/testing/invariants.ts new file mode 100644 index 00000000..a90258db --- /dev/null +++ b/src/lib/routing/testing/invariants.ts @@ -0,0 +1,159 @@ +/** + * Routing invariants and quality metrics + * + * Invariants must hold for every route. Metrics compare router quality + * (length, bends, overlap between different nets) across implementations. + */ + +import type { Position } from '$lib/types/common'; +import type { Bounds, PortInfo, RouteResult, RoutingScene } from '../types'; +import { DIRECTION_VECTORS } from '../types'; +import { GRID_SIZE, SOURCE_CLEARANCE, TARGET_CLEARANCE } from '../constants'; +import { snapToGrid } from '../pathOptimizer'; + +export type ViolationKind = + | 'missing' + | 'fallback' + | 'not-orthogonal' + | 'off-grid' + | 'wrong-start' + | 'wrong-end' + | 'crosses-node'; + +export interface Violation { + connectionId: string; + kind: ViolationKind; + detail?: string; +} + +export interface RouteMetrics { + routes: number; + fallbacks: number; + totalLength: number; + bends: number; + /** Length of grid edges used by more than one net */ + overlapLength: number; +} + +function stubEnd(port: PortInfo, clearance: number): Position { + const vec = DIRECTION_VECTORS[port.direction]; + return snapToGrid({ + x: port.position.x + vec.x * clearance, + y: port.position.y + vec.y * clearance + }); +} + +function onGrid(value: number): boolean { + return Math.abs(value % GRID_SIZE) < 1e-9; +} + +function samePoint(a: Position, b: Position): boolean { + return a.x === b.x && a.y === b.y; +} + +/** Strict interior intersection of an axis-aligned segment with a rectangle */ +function segmentCrossesBounds(a: Position, b: Position, r: Bounds): boolean { + const minX = Math.min(a.x, b.x); + const maxX = Math.max(a.x, b.x); + const minY = Math.min(a.y, b.y); + const maxY = Math.max(a.y, b.y); + const insideX = minX === maxX ? minX > r.x && minX < r.x + r.width : maxX > r.x && minX < r.x + r.width; + const insideY = minY === maxY ? minY > r.y && minY < r.y + r.height : maxY > r.y && minY < r.y + r.height; + return insideX && insideY; +} + +/** + * Check all routes of a scene against the routing invariants. + * Node crossing is checked naively, intended for small test scenes. + */ +export function checkRoutes(scene: RoutingScene, routes: Map): Violation[] { + const violations: Violation[] = []; + + for (const request of scene.requests) { + const id = request.id; + const route = routes.get(id); + if (!route || route.path.length === 0) { + violations.push({ connectionId: id, kind: 'missing' }); + continue; + } + if (route.isFallback) violations.push({ connectionId: id, kind: 'fallback' }); + + const path = route.path; + if (!samePoint(path[0], stubEnd(request.source, SOURCE_CLEARANCE))) { + violations.push({ connectionId: id, kind: 'wrong-start', detail: JSON.stringify(path[0]) }); + } + if (!samePoint(path[path.length - 1], stubEnd(request.target, TARGET_CLEARANCE))) { + violations.push({ connectionId: id, kind: 'wrong-end', detail: JSON.stringify(path[path.length - 1]) }); + } + + for (const point of path) { + if (!onGrid(point.x) || !onGrid(point.y)) { + violations.push({ connectionId: id, kind: 'off-grid', detail: JSON.stringify(point) }); + break; + } + } + + for (let i = 0; i < path.length - 1; i++) { + const a = path[i]; + const b = path[i + 1]; + if (a.x !== b.x && a.y !== b.y) { + violations.push({ connectionId: id, kind: 'not-orthogonal', detail: `segment ${i}` }); + } + for (const [nodeId, bounds] of scene.nodeBounds) { + if (segmentCrossesBounds(a, b, bounds)) { + violations.push({ connectionId: id, kind: 'crosses-node', detail: `${nodeId} segment ${i}` }); + } + } + } + } + + return violations; +} + +/** Encode a unit grid edge (grid coordinates plus orientation) as a number key */ +function encodeEdge(gx: number, gy: number, horizontal: boolean): number { + return ((gx + 1_000_000) * 2_000_001 + (gy + 1_000_000)) * 2 + (horizontal ? 1 : 0); +} + +/** + * Measure route quality. Overlap counts unit grid edges shared by different nets; + * connections of the same net (fan-out) may share edges. + */ +export function measureRoutes(scene: RoutingScene, routes: Map): RouteMetrics { + const metrics: RouteMetrics = { routes: 0, fallbacks: 0, totalLength: 0, bends: 0, overlapLength: 0 }; + const SHARED = ''; + const edgeOwner = new Map(); + + for (const request of scene.requests) { + const route = routes.get(request.id); + if (!route || route.path.length === 0) continue; + metrics.routes++; + if (route.isFallback) metrics.fallbacks++; + + const path = route.path; + metrics.bends += Math.max(0, path.length - 2); + + for (let i = 0; i < path.length - 1; i++) { + const a = path[i]; + const b = path[i + 1]; + const horizontal = a.y === b.y; + metrics.totalLength += Math.abs(b.x - a.x) + Math.abs(b.y - a.y); + + const steps = Math.round((horizontal ? Math.abs(b.x - a.x) : Math.abs(b.y - a.y)) / GRID_SIZE); + const gx0 = Math.round(Math.min(a.x, b.x) / GRID_SIZE); + const gy0 = Math.round(Math.min(a.y, b.y) / GRID_SIZE); + for (let s = 0; s < steps; s++) { + const key = horizontal ? encodeEdge(gx0 + s, gy0, true) : encodeEdge(gx0, gy0 + s, false); + const owner = edgeOwner.get(key); + if (owner === undefined) { + edgeOwner.set(key, request.netId); + } else if (owner !== SHARED && owner !== request.netId) { + edgeOwner.set(key, SHARED); + metrics.overlapLength += GRID_SIZE; + } + } + } + } + + return metrics; +} diff --git a/src/lib/routing/testing/legacyRouter.ts b/src/lib/routing/testing/legacyRouter.ts new file mode 100644 index 00000000..6cc2fc9a --- /dev/null +++ b/src/lib/routing/testing/legacyRouter.ts @@ -0,0 +1,85 @@ +/** + * Baseline adapter: the pre-v2 routing pipeline as a pure function + * + * Mirrors routingStore.recalculateAllRoutes (sync pass followed by the overlap + * refinement pass) so tests and benchmarks can measure the baseline without + * loading the Svelte stores. + */ + +import type { Direction, RouteRequest, RouteResult, RoutingScene, Bounds } from '../types'; +import { SparseGrid } from '../gridBuilder'; +import { calculateRoute, calculateRouteWithWaypoints, getPathCells } from '../routeCalculator'; +import { ROUTING_MARGIN, ROUTING_CONTEXT_PADDING } from '../constants'; + +function canvasBoundsOf(nodeBounds: Map): Bounds { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const b of nodeBounds.values()) { + minX = Math.min(minX, b.x - ROUTING_MARGIN); + minY = Math.min(minY, b.y - ROUTING_MARGIN); + maxX = Math.max(maxX, b.x + b.width + ROUTING_MARGIN); + maxY = Math.max(maxY, b.y + b.height + ROUTING_MARGIN); + } + const p = ROUTING_CONTEXT_PADDING; + return { x: minX - p, y: minY - p, width: maxX - minX + 2 * p, height: maxY - minY + 2 * p }; +} + +export function routeSceneLegacy(scene: RoutingScene): Map { + const grid = new SparseGrid({ + nodeBounds: scene.nodeBounds, + canvasBounds: canvasBoundsOf(scene.nodeBounds), + portStubs: scene.portStubs + }); + + const route = (r: RouteRequest, usedCells?: Map>): RouteResult => + r.waypoints.length > 0 + ? calculateRouteWithWaypoints( + r.source.position, + r.target.position, + r.source.direction, + r.target.direction, + grid, + r.waypoints, + usedCells + ) + : calculateRoute(r.source.position, r.target.position, r.source.direction, r.target.direction, grid, usedCells); + + const distance = (r: RouteRequest) => + Math.abs(r.target.position.x - r.source.position.x) + Math.abs(r.target.position.y - r.source.position.y); + const sorted = [...scene.requests].sort((a, b) => distance(b) - distance(a)); + + const byNet = new Map(); + for (const r of sorted) { + const group = byNet.get(r.netId) || []; + group.push(r); + byNet.set(r.netId, group); + } + + const routes = new Map(); + + // Pass 1: without overlap avoidance + for (const group of byNet.values()) { + for (const r of group) routes.set(r.id, route(r)); + } + + // Pass 2: overlap-aware refinement + const usedCells = new Map>(); + for (const group of byNet.values()) { + const groupCells: Map>[] = []; + for (const r of group) { + const result = route(r, usedCells); + routes.set(r.id, result); + if (result.path.length > 0) groupCells.push(getPathCells(result.path, 2)); + } + for (const cells of groupCells) { + for (const [key, dirs] of cells) { + if (!usedCells.has(key)) usedCells.set(key, new Set()); + for (const dir of dirs) usedCells.get(key)!.add(dir); + } + } + } + + return routes; +} diff --git a/src/lib/routing/testing/scenario.ts b/src/lib/routing/testing/scenario.ts new file mode 100644 index 00000000..9230beca --- /dev/null +++ b/src/lib/routing/testing/scenario.ts @@ -0,0 +1,189 @@ +/** + * Synthetic routing scenarios for tests and benchmarks + * + * Deterministic (seeded) block diagrams on the canvas grid: rows and columns of + * multi-port blocks with forward wiring, fan-out and feedback connections. + */ + +import type { Position, RotationValue } from '$lib/types/common'; +import type { Bounds, PortStub, RouteRequest, RoutingScene } from '../types'; +import { calculateNodeDimensions, snapTo2G } from '$lib/constants/dimensions'; +import { G } from '$lib/constants/grid'; +import { getPortInfo } from '../portGeometry'; + +export interface ScenarioNode { + id: string; + center: Position; + width: number; + height: number; + rotation: RotationValue; + inputs: number; + outputs: number; +} + +export interface ScenarioConnection { + id: string; + sourceNodeId: string; + sourcePortIndex: number; + targetNodeId: string; + targetPortIndex: number; +} + +export interface Scenario { + nodes: ScenarioNode[]; + connections: ScenarioConnection[]; +} + +export interface ScenarioOptions { + /** Number of connections to generate */ + connections: number; + /** Input and output ports per block */ + portsPerSide?: number; + /** Probability that an output fans out to one more target */ + fanOut?: number; + /** Probability that a connection runs backwards (feedback) */ + feedback?: number; + /** Probability that a block is rotated */ + rotated?: number; + /** Empty grid cells between neighbouring blocks */ + gap?: number; + seed?: number; +} + +/** Mulberry32 PRNG, deterministic across runs and platforms */ +function createRandom(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Generate a block diagram with approximately `connections` connections. + * Fewer are generated only if the blocks run out of free input ports. + */ +export function generateScenario(options: ScenarioOptions): Scenario { + const { + connections: targetCount, + portsPerSide = 4, + fanOut = 0.1, + feedback = 0.05, + rotated = 0, + gap = 12, + seed = 1 + } = options; + + const random = createRandom(seed); + const randomInt = (min: number, max: number) => min + Math.floor(random() * (max - min + 1)); + + const blockCount = Math.max(2, Math.ceil((targetCount * 1.3) / portsPerSide)); + const rows = Math.max(1, Math.round(Math.sqrt(blockCount / 2))); + const columns = Math.ceil(blockCount / rows); + + // Pitch covers both orientations so rotated blocks never overlap + const horizontal = calculateNodeDimensions('f0000', portsPerSide, portsPerSide, 0, 0, 'Function'); + const vertical = calculateNodeDimensions('f0000', portsPerSide, portsPerSide, 0, 1, 'Function'); + const extent = Math.max(horizontal.width, horizontal.height, vertical.width, vertical.height); + const pitch = snapTo2G(extent + G.px(gap)); + + const nodes: ScenarioNode[] = []; + for (let i = 0; i < blockCount; i++) { + const rotation: RotationValue = random() < rotated ? (randomInt(1, 3) as RotationValue) : 0; + const size = rotation === 1 || rotation === 3 ? vertical : horizontal; + nodes.push({ + id: `n${i}`, + center: { x: Math.floor(i / rows) * pitch, y: (i % rows) * pitch }, + width: size.width, + height: size.height, + rotation, + inputs: portsPerSide, + outputs: portsPerSide + }); + } + + const nextInput = new Array(blockCount).fill(0); + const connections: ScenarioConnection[] = []; + + const pickTarget = (source: number): number | null => { + const column = Math.floor(source / rows); + const row = source % rows; + for (let attempt = 0; attempt < 8; attempt++) { + const backwards = random() < feedback || column === columns - 1; + const targetColumn = backwards ? column - randomInt(0, 3) : column + randomInt(1, 2); + const targetRow = row + randomInt(-2, 2); + if (targetColumn < 0 || targetColumn >= columns || targetRow < 0 || targetRow >= rows) continue; + const target = targetColumn * rows + targetRow; + if (target < blockCount && nextInput[target] < portsPerSide) return target; + } + return null; + }; + + const connect = (source: number, port: number): boolean => { + const target = pickTarget(source); + if (target === null) return false; + connections.push({ + id: `c${connections.length}`, + sourceNodeId: nodes[source].id, + sourcePortIndex: port, + targetNodeId: nodes[target].id, + targetPortIndex: nextInput[target]++ + }); + return true; + }; + + outer: for (let source = 0; source < blockCount; source++) { + for (let port = 0; port < portsPerSide; port++) { + if (connections.length >= targetCount) break outer; + if (connect(source, port) && random() < fanOut && connections.length < targetCount) { + connect(source, port); + } + } + } + + return { nodes, connections }; +} + +/** + * Build the routing input for a scenario + */ +export function buildScene(scenario: Scenario): RoutingScene { + const nodeBounds = new Map(); + const portStubs: PortStub[] = []; + const nodesById = new Map(scenario.nodes.map((n) => [n.id, n])); + + const portOf = (node: ScenarioNode, index: number, isOutput: boolean) => + getPortInfo( + node.center, + node.width, + node.height, + node.rotation, + index, + isOutput ? node.outputs : node.inputs, + isOutput + ); + + for (const node of scenario.nodes) { + nodeBounds.set(node.id, { + x: node.center.x - node.width / 2, + y: node.center.y - node.height / 2, + width: node.width, + height: node.height + }); + for (let i = 0; i < node.inputs; i++) portStubs.push(portOf(node, i, false)); + for (let i = 0; i < node.outputs; i++) portStubs.push(portOf(node, i, true)); + } + + const requests: RouteRequest[] = scenario.connections.map((c) => ({ + id: c.id, + netId: `${c.sourceNodeId}:${c.sourcePortIndex}`, + source: portOf(nodesById.get(c.sourceNodeId)!, c.sourcePortIndex, true), + target: portOf(nodesById.get(c.targetNodeId)!, c.targetPortIndex, false), + waypoints: [] + })); + + return { nodeBounds, portStubs, requests }; +} diff --git a/src/lib/routing/types.ts b/src/lib/routing/types.ts index 1d83c019..9319e609 100644 --- a/src/lib/routing/types.ts +++ b/src/lib/routing/types.ts @@ -38,6 +38,30 @@ export interface PortStub { direction: Direction; } +/** Port handle tip position and facing direction in world coordinates */ +export interface PortInfo { + position: Position; + direction: Direction; +} + +/** One connection to route */ +export interface RouteRequest { + id: string; + /** Connections from the same output port share a net and may share grid cells */ + netId: string; + source: PortInfo; + target: PortInfo; + /** User waypoints in route order */ + waypoints: Waypoint[]; +} + +/** Complete routing input: obstacles and connections */ +export interface RoutingScene { + nodeBounds: Map; + portStubs: PortStub[]; + requests: RouteRequest[]; +} + /** Routing context passed to calculator */ export interface RoutingContext { /** Node ID -> bounding box (world coordinates, already includes margin) */ diff --git a/src/lib/stores/routing.ts b/src/lib/stores/routing.ts index 93a67820..b1a4b399 100644 --- a/src/lib/stores/routing.ts +++ b/src/lib/stores/routing.ts @@ -5,7 +5,7 @@ import { writable, derived, get } from 'svelte/store'; import type { Position } from '$lib/types/common'; import type { Connection, Waypoint } from '$lib/types/nodes'; -import type { RoutingContext, RouteResult, Bounds, Direction, PortStub } from '$lib/routing'; +import type { RoutingContext, RouteResult, Bounds, Direction, PortStub, PortInfo } from '$lib/routing'; import { calculateRoute, calculateRouteWithWaypoints, @@ -23,11 +23,7 @@ import { generateId } from '$lib/stores/utils'; import { graphStore } from '$lib/stores/graph'; import { historyStore } from '$lib/stores/history'; -/** Port info returned from getPortInfo callback */ -export interface PortInfo { - position: Position; - direction: Direction; -} +export type { PortInfo }; /** Helper to extract user waypoints from a connection's waypoints array */ function getUserWaypoints(waypoints?: Waypoint[]): Waypoint[] { diff --git a/vite.config.ts b/vite.config.ts index deccddee..59e174a8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,5 @@ import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; +import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [sveltekit()], @@ -8,5 +8,9 @@ export default defineConfig({ }, worker: { format: 'es' + }, + test: { + include: ['src/**/*.test.ts'], + environment: 'node' } }); From 8c9d0e2279d1a6d2540d96f0aca9c0b5673960b0 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Sun, 13 Sep 2026 16:26:40 +0200 Subject: [PATCH 2/8] Add incremental routing engine with negotiated congestion and lane nudging --- src/lib/routing/engine.ts | 519 ++++++++++++++++++++++++ src/lib/routing/gridTypes.ts | 42 ++ src/lib/routing/nudge.ts | 338 +++++++++++++++ src/lib/routing/obstacleMap.ts | 93 +++++ src/lib/routing/occupancy.ts | 115 ++++++ src/lib/routing/router.test.ts | 107 ++++- src/lib/routing/routing.perf.test.ts | 46 ++- src/lib/routing/search.ts | 246 +++++++++++ src/lib/routing/testing/invariants.ts | 4 +- src/lib/routing/testing/legacyRouter.ts | 7 +- src/lib/routing/testing/scenario.ts | 73 ++-- src/lib/routing/types.ts | 11 +- 12 files changed, 1557 insertions(+), 44 deletions(-) create mode 100644 src/lib/routing/engine.ts create mode 100644 src/lib/routing/gridTypes.ts create mode 100644 src/lib/routing/nudge.ts create mode 100644 src/lib/routing/obstacleMap.ts create mode 100644 src/lib/routing/occupancy.ts create mode 100644 src/lib/routing/search.ts diff --git a/src/lib/routing/engine.ts b/src/lib/routing/engine.ts new file mode 100644 index 00000000..59154131 --- /dev/null +++ b/src/lib/routing/engine.ts @@ -0,0 +1,519 @@ +/** + * Routing engine - incremental orthogonal routing on the canvas grid + * + * Framework free so it can run in a worker. Connections are searched with + * congestion costs from the other nets (negotiated congestion routing), a + * negotiation loop re-searches nets that still share grid lines, and a final + * nudging pass separates the remaining overlaps into lanes. After changes only + * affected nets are searched again. + */ + +import type { Bounds, PortInfo, PortStub, RouteRequest, RouteResult, RoutingScene } from './types'; +import { ObstacleMap } from './obstacleMap'; +import { Occupancy, collectCells } from './occupancy'; +import { searchGridPath } from './search'; +import { nudgeRoutes, simplifyGridPath, type NudgeRoute } from './nudge'; +import { + DIRECTION_INDEX, + DX, + DY, + OPPOSITE, + toGrid, + fromGrid, + type GridPoint, + type GridRect +} from './gridTypes'; +import { GRID_SIZE, ROUTING_MARGIN, SOURCE_CLEARANCE, TARGET_CLEARANCE } from './constants'; + +/** Cells walkable in front of a port so routes can pass the node margin */ +const PORT_EXIT_CELLS = 3; + +/** Cells around a changed node within which existing routes are searched again */ +const REROUTE_PADDING = 2; + +/** Tile size (cells) of the spatial index over routes */ +const INDEX_TILE = 16; + +/** Congestion cost per other net on a cell in the first search */ +const PRESENT_COST = 2; + +/** History cost added to contested cells after each negotiation iteration */ +const HISTORY_COST = 1; + +/** Negotiation continues only while contested cells drop below this share of the previous iteration */ +const NEGOTIATION_PROGRESS = 0.8; + +export interface RoutingEngineOptions { + /** Search with congestion costs from other nets (default true) */ + congestion?: boolean; + /** Separate remaining overlaps into lanes (default true) */ + nudge?: boolean; +} + +export interface UpdateOptions { + /** Negotiation iterations re-searching nets that still overlap (default 0) */ + negotiate?: number; +} + +export interface RoutingUpdate { + /** New or changed routes */ + changed: Map; + /** Connections whose routes were removed */ + removed: string[]; +} + +interface NodeEntry { + /** Node interior, impassable */ + body: GridRect; + /** Node including its margin, passable at extra cost */ + rect: GridRect; + stubs: GridPoint[]; +} + +interface RawRoute { + corners: GridPoint[]; + isFallback: boolean; + tiles: number[]; +} + +function bodyRect(bounds: Bounds): GridRect { + return { + minGx: toGrid(bounds.x), + minGy: toGrid(bounds.y), + maxGx: toGrid(bounds.x + bounds.width), + maxGy: toGrid(bounds.y + bounds.height) + }; +} + +function marginRect(bounds: Bounds): GridRect { + return { + minGx: toGrid(bounds.x - ROUTING_MARGIN), + minGy: toGrid(bounds.y - ROUTING_MARGIN), + maxGx: toGrid(bounds.x + bounds.width + ROUTING_MARGIN), + maxGy: toGrid(bounds.y + bounds.height + ROUTING_MARGIN) + }; +} + +/** Cell directly in front of a port, kept free of other routes */ +function stubCell(port: PortStub): GridPoint { + const d = DIRECTION_INDEX[port.direction]; + return { + gx: toGrid(port.position.x + DX[d] * GRID_SIZE), + gy: toGrid(port.position.y + DY[d] * GRID_SIZE) + }; +} + +/** Route end cell of a port at the given clearance */ +function portCell(port: PortInfo, clearance: number): GridPoint { + const d = DIRECTION_INDEX[port.direction]; + return { + gx: toGrid(port.position.x + DX[d] * clearance), + gy: toGrid(port.position.y + DY[d] * clearance) + }; +} + +function ray(from: GridPoint, dir: number, length: number): GridPoint[] { + const cells: GridPoint[] = []; + for (let k = 0; k <= length; k++) cells.push({ gx: from.gx + DX[dir] * k, gy: from.gy + DY[dir] * k }); + return cells; +} + +function sameRect(a: GridRect, b: GridRect): boolean { + return a.minGx === b.minGx && a.minGy === b.minGy && a.maxGx === b.maxGx && a.maxGy === b.maxGy; +} + +function samePoints(a: GridPoint[], b: GridPoint[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i].gx !== b[i].gx || a[i].gy !== b[i].gy) return false; + } + return true; +} + +function samePort(a: PortInfo, b: PortInfo): boolean { + return a.direction === b.direction && a.position.x === b.position.x && a.position.y === b.position.y; +} + +function sameRequest(a: RouteRequest, b: RouteRequest): boolean { + if (a.netId !== b.netId || !samePort(a.source, b.source) || !samePort(a.target, b.target)) return false; + if (a.waypoints.length !== b.waypoints.length) return false; + for (let i = 0; i < a.waypoints.length; i++) { + const p = a.waypoints[i].position; + const q = b.waypoints[i].position; + if (p.x !== q.x || p.y !== q.y) return false; + } + return true; +} + +function samePath(a: RouteResult, b: RouteResult): boolean { + if (a.isFallback !== b.isFallback || a.path.length !== b.path.length || a.waypoints !== b.waypoints) return false; + for (let i = 0; i < a.path.length; i++) { + if (a.path[i].x !== b.path[i].x || a.path[i].y !== b.path[i].y) return false; + } + return true; +} + +function tileKey(tx: number, ty: number): number { + return (tx + 0x8000) * 0x10000 + (ty + 0x8000); +} + +/** Orthogonal fallback when no path exists: first along the start direction, then turn */ +function fallbackCorners(from: GridPoint, to: GridPoint, dir: number): GridPoint[] { + const corner = dir <= 1 ? { gx: to.gx, gy: from.gy } : { gx: from.gx, gy: to.gy }; + return [from, corner, to]; +} + +function sortedKeys(keys: Iterable): string[] { + return [...keys].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); +} + +export class RoutingEngine { + private readonly map = new ObstacleMap(); + private readonly occupancy = new Occupancy(); + private readonly nodes = new Map(); + private readonly requests = new Map(); + private readonly raw = new Map(); + /** Net id to connection ids */ + private readonly nets = new Map>(); + /** Cells each net currently holds in the occupancy */ + private readonly netCells = new Map(); + private readonly routeTiles = new Map>(); + private readonly fallbacks = new Set(); + private readonly dirty = new Set(); + private readonly dirtyNets = new Set(); + private readonly removedIds = new Set(); + private results = new Map(); + private changedSinceUpdate = false; + private readonly congestion: boolean; + private readonly nudge: boolean; + + constructor(options: RoutingEngineOptions = {}) { + this.congestion = options.congestion ?? true; + this.nudge = options.nudge ?? true; + } + + /** Add or move a node together with its ports */ + setNode(id: string, bounds: Bounds, ports: PortStub[]): void { + const entry: NodeEntry = { body: bodyRect(bounds), rect: marginRect(bounds), stubs: ports.map(stubCell) }; + const old = this.nodes.get(id); + if (old && sameRect(old.rect, entry.rect) && samePoints(old.stubs, entry.stubs)) return; + + if (old) this.applyObstacles(old, -1); + this.applyObstacles(entry, 1); + this.nodes.set(id, entry); + + this.markAround(entry.rect); + if (old) this.markAround(old.rect); + this.retryFallbacks(); + this.changedSinceUpdate = true; + } + + removeNode(id: string): void { + const old = this.nodes.get(id); + if (!old) return; + this.applyObstacles(old, -1); + this.nodes.delete(id); + this.markAround(old.rect); + this.retryFallbacks(); + this.changedSinceUpdate = true; + } + + /** Add or change a connection */ + setRequest(request: RouteRequest): void { + const old = this.requests.get(request.id); + this.requests.set(request.id, request); + this.removedIds.delete(request.id); + if (old && sameRequest(old, request)) return; + + if (old && old.netId !== request.netId) this.leaveNet(old.netId, request.id); + let members = this.nets.get(request.netId); + if (!members) { + members = new Set(); + this.nets.set(request.netId, members); + } + members.add(request.id); + + this.dirty.add(request.id); + this.changedSinceUpdate = true; + } + + removeRequest(id: string): void { + const old = this.requests.get(id); + if (!old) return; + this.requests.delete(id); + this.leaveNet(old.netId, id); + this.dirty.delete(id); + this.fallbacks.delete(id); + this.unindex(id); + this.raw.delete(id); + this.removedIds.add(id); + this.changedSinceUpdate = true; + } + + hasRequest(id: string): boolean { + return this.requests.has(id); + } + + /** Search dirty nets, optionally negotiate overlaps, separate lanes and report what changed */ + update(options: UpdateOptions = {}): RoutingUpdate { + const negotiate = options.negotiate ?? 0; + if (!this.changedSinceUpdate && negotiate === 0) return { changed: new Map(), removed: [] }; + this.changedSinceUpdate = false; + + const nets = new Set(this.dirtyNets); + for (const id of this.dirty) { + const request = this.requests.get(id); + if (request) nets.add(request.netId); + } + for (const netId of sortedKeys(nets)) this.routeNet(netId, false); + this.dirty.clear(); + this.dirtyNets.clear(); + + if (this.congestion) this.negotiate(negotiate); + + const next = this.finalize(); + const changed = new Map(); + for (const [id, route] of next) { + const prev = this.results.get(id); + if (!prev || !samePath(prev, route)) changed.set(id, route); + } + const removed = [...this.removedIds]; + this.removedIds.clear(); + this.results = next; + + return { changed, removed }; + } + + getRoute(id: string): RouteResult | undefined { + return this.results.get(id); + } + + getRoutes(): Map { + return this.results; + } + + private leaveNet(netId: string, id: string): void { + const members = this.nets.get(netId); + if (!members) return; + members.delete(id); + if (members.size === 0) this.nets.delete(netId); + this.dirtyNets.add(netId); + } + + /** + * Re-search connections of one net. Siblings keep or share their cells without + * congestion cost, so fan-out naturally forms a common trunk. + */ + private routeNet(netId: string, all: boolean): void { + const previous = this.netCells.get(netId); + if (previous) this.occupancy.add(previous, -1); + this.netCells.delete(netId); + + const members = this.nets.get(netId); + if (!members) return; + const ids = sortedKeys(members); + + const cells = new Set(); + const needsSearch = (id: string) => all || this.dirty.has(id) || !this.raw.has(id); + for (const id of ids) { + if (!needsSearch(id)) collectCells(this.raw.get(id)!.corners, cells); + } + + this.occupancy.own = cells; + for (const id of ids) { + if (!needsSearch(id)) continue; + const request = this.requests.get(id)!; + this.unindex(id); + const raw = this.search(request); + this.raw.set(id, raw); + if (raw.isFallback) this.fallbacks.add(id); + else this.fallbacks.delete(id); + this.index(id, raw); + collectCells(raw.corners, cells); + } + this.occupancy.own = null; + + const list = [...cells]; + this.occupancy.add(list, 1); + this.netCells.set(netId, list); + } + + /** + * Re-search nets that share grid lines with other nets, with rising costs. + * Stops early when contention no longer drops, e.g. in gaps too narrow for + * separate lanes. + */ + private negotiate(iterations: number): void { + let previousContested = Infinity; + for (let iteration = 0; iteration < iterations; iteration++) { + const conflicting: string[] = []; + let contestedCells = 0; + for (const [netId, cells] of this.netCells) { + let contested = false; + for (const key of cells) { + if (this.occupancy.countKey(key) > 1) { + this.occupancy.bumpHistory(key, HISTORY_COST); + contestedCells++; + contested = true; + } + } + if (contested) conflicting.push(netId); + } + if (conflicting.length === 0 || contestedCells > previousContested * NEGOTIATION_PROGRESS) break; + previousContested = contestedCells; + + this.occupancy.presentCost = PRESENT_COST * 2 ** (iteration + 1); + for (const netId of sortedKeys(conflicting)) this.routeNet(netId, true); + } + this.occupancy.presentCost = PRESENT_COST; + } + + private applyObstacles(entry: NodeEntry, delta: 1 | -1): void { + this.map.addRect(entry.body, 'hard', delta); + this.map.addRect(entry.rect, 'soft', delta); + for (const s of entry.stubs) { + this.map.addRect({ minGx: s.gx, minGy: s.gy, maxGx: s.gx, maxGy: s.gy }, 'soft', delta); + } + } + + private retryFallbacks(): void { + for (const id of this.fallbacks) this.dirty.add(id); + } + + /** Mark routes passing near a rectangle as dirty */ + private markAround(rect: GridRect): void { + const minTx = Math.floor((rect.minGx - REROUTE_PADDING) / INDEX_TILE); + const maxTx = Math.floor((rect.maxGx + REROUTE_PADDING) / INDEX_TILE); + const minTy = Math.floor((rect.minGy - REROUTE_PADDING) / INDEX_TILE); + const maxTy = Math.floor((rect.maxGy + REROUTE_PADDING) / INDEX_TILE); + for (let tx = minTx; tx <= maxTx; tx++) { + for (let ty = minTy; ty <= maxTy; ty++) { + const ids = this.routeTiles.get(tileKey(tx, ty)); + if (ids) for (const id of ids) this.dirty.add(id); + } + } + } + + private index(id: string, raw: RawRoute): void { + const keys = new Set(); + const pts = raw.corners; + for (let i = 0; i < pts.length; i++) { + const a = pts[i]; + const b = pts[Math.min(i + 1, pts.length - 1)]; + const minTx = Math.floor(Math.min(a.gx, b.gx) / INDEX_TILE); + const maxTx = Math.floor(Math.max(a.gx, b.gx) / INDEX_TILE); + const minTy = Math.floor(Math.min(a.gy, b.gy) / INDEX_TILE); + const maxTy = Math.floor(Math.max(a.gy, b.gy) / INDEX_TILE); + for (let tx = minTx; tx <= maxTx; tx++) { + for (let ty = minTy; ty <= maxTy; ty++) keys.add(tileKey(tx, ty)); + } + } + raw.tiles = [...keys]; + for (const key of raw.tiles) { + let ids = this.routeTiles.get(key); + if (!ids) { + ids = new Set(); + this.routeTiles.set(key, ids); + } + ids.add(id); + } + } + + private unindex(id: string): void { + const raw = this.raw.get(id); + if (!raw) return; + for (const key of raw.tiles) { + const ids = this.routeTiles.get(key); + if (!ids) continue; + ids.delete(id); + if (ids.size === 0) this.routeTiles.delete(key); + } + raw.tiles = []; + } + + private search(request: RouteRequest): RawRoute { + const start = portCell(request.source, SOURCE_CLEARANCE); + const end = portCell(request.target, TARGET_CLEARANCE); + const startDir = DIRECTION_INDEX[request.source.direction]; + const targetDir = DIRECTION_INDEX[request.target.direction]; + const exit = ray(start, startDir, PORT_EXIT_CELLS); + const entry = ray(end, targetDir, PORT_EXIT_CELLS); + const congestion = this.congestion ? this.occupancy : undefined; + + const stops = request.waypoints.map((w) => ({ gx: toGrid(w.position.x), gy: toGrid(w.position.y) })); + stops.push(end); + + const corners: GridPoint[] = []; + let isFallback = false; + let from = start; + let dir = startDir; + + for (let k = 0; k < stops.length; k++) { + const to = stops[k]; + const last = k === stops.length - 1; + const forced = k === 0 ? [...exit, to] : [from, to]; + if (last) forced.push(...entry); + + const path = searchGridPath(this.map, { + congestion, + start: from, + startDir: dir, + end: to, + endDir: last ? OPPOSITE[targetDir] : -1, + forced + }); + + let leg: GridPoint[]; + if (path) { + leg = path.corners; + dir = path.arrivalDir; + } else { + isFallback = true; + leg = fallbackCorners(from, to, dir); + } + const skipFirst = corners.length > 0 ? 1 : 0; + for (let i = skipFirst; i < leg.length; i++) corners.push(leg[i]); + from = to; + } + + return { corners: simplifyGridPath(corners), isFallback, tiles: [] }; + } + + private finalize(): Map { + const routes: NudgeRoute[] = []; + for (const [id, raw] of this.raw) { + const request = this.requests.get(id)!; + routes.push({ + id, + netId: request.netId, + points: raw.corners.map((p) => ({ gx: p.gx, gy: p.gy })), + anchors: request.waypoints.map((w) => ({ gx: toGrid(w.position.x), gy: toGrid(w.position.y) })) + }); + } + + if (this.nudge) nudgeRoutes(routes, this.map); + + const results = new Map(); + for (const route of routes) { + const request = this.requests.get(route.id)!; + results.set(route.id, { + path: route.points.map((p) => ({ x: fromGrid(p.gx), y: fromGrid(p.gy) })), + waypoints: request.waypoints, + isFallback: this.raw.get(route.id)!.isFallback + }); + } + return results; + } +} + +/** Route a complete scene in one go */ +export function routeScene( + scene: RoutingScene, + options: RoutingEngineOptions & UpdateOptions = {} +): Map { + const engine = new RoutingEngine(options); + for (const [id, node] of scene.nodes) engine.setNode(id, node.bounds, node.ports); + for (const request of scene.requests) engine.setRequest(request); + engine.update({ negotiate: options.negotiate ?? 3 }); + return engine.getRoutes(); +} diff --git a/src/lib/routing/gridTypes.ts b/src/lib/routing/gridTypes.ts new file mode 100644 index 00000000..ae839edc --- /dev/null +++ b/src/lib/routing/gridTypes.ts @@ -0,0 +1,42 @@ +/** + * Grid primitives shared by the routing engine + */ + +import type { Direction } from './types'; +import { GRID_SIZE } from './constants'; + +/** Cell in grid coordinates */ +export interface GridPoint { + gx: number; + gy: number; +} + +/** Inclusive rectangle in grid coordinates */ +export interface GridRect { + minGx: number; + minGy: number; + maxGx: number; + maxGy: number; +} + +/** Direction indices */ +export const RIGHT = 0; +export const LEFT = 1; +export const DOWN = 2; +export const UP = 3; + +export const DX: readonly number[] = [1, -1, 0, 0]; +export const DY: readonly number[] = [0, 0, 1, -1]; +export const OPPOSITE: readonly number[] = [LEFT, RIGHT, UP, DOWN]; + +export const DIRECTION_INDEX: Record = { right: RIGHT, left: LEFT, down: DOWN, up: UP }; + +/** World coordinate to grid coordinate (nearest grid line) */ +export function toGrid(value: number): number { + return Math.round(value / GRID_SIZE); +} + +/** Grid coordinate to world coordinate */ +export function fromGrid(value: number): number { + return value * GRID_SIZE; +} diff --git a/src/lib/routing/nudge.ts b/src/lib/routing/nudge.ts new file mode 100644 index 00000000..e6803896 --- /dev/null +++ b/src/lib/routing/nudge.ts @@ -0,0 +1,338 @@ +/** + * Lane separation (nudging) + * + * Routes are searched independently, so segments of different nets can end up + * on the same grid line. This pass moves inner segments of overlapping nets onto + * neighbouring free grid lines, ordered to avoid crossings. Segments attached to + * ports or passing through user waypoints stay in place. + */ + +import type { ObstacleMap } from './obstacleMap'; +import type { GridPoint } from './gridTypes'; + +export interface NudgeRoute { + id: string; + netId: string; + /** Corner points, modified in place */ + points: GridPoint[]; + /** Grid points the route has to keep passing through */ + anchors: GridPoint[]; +} + +/** Maximum lane distance from the original line, in grid cells */ +const MAX_LANE = 8; + +interface Segment { + route: NudgeRoute; + /** The segment runs from points[index] to points[index + 1] */ + index: number; + lo: number; + hi: number; + fixed: boolean; +} + +interface Unit { + netId: string; + segments: Segment[]; + lo: number; + hi: number; + fixed: boolean; + /** Direction of the leg at the low and high end: -1, 0 (none) or 1 */ + loDir: number; + hiDir: number; +} + +/** Axis accessors so one pass implementation serves both orientations */ +interface Axis { + along(p: GridPoint): number; + across(p: GridPoint): number; + setAcross(p: GridPoint, value: number): void; + /** Obstacle-free cells from a to b (inclusive) along the line `line` */ + lineFree(map: ObstacleMap, line: number, a: number, b: number): boolean; + /** Obstacle-free cells from a to b (inclusive) on the perpendicular line at `along` */ + legFree(map: ObstacleMap, along: number, a: number, b: number): boolean; +} + +const HORIZONTAL: Axis = { + along: (p) => p.gx, + across: (p) => p.gy, + setAcross: (p, v) => { + p.gy = v; + }, + lineFree: (map, line, a, b) => map.isFree(Math.min(a, b), line, Math.max(a, b), line), + legFree: (map, along, a, b) => map.isFree(along, Math.min(a, b), along, Math.max(a, b)) +}; + +const VERTICAL: Axis = { + along: (p) => p.gy, + across: (p) => p.gx, + setAcross: (p, v) => { + p.gx = v; + }, + lineFree: (map, line, a, b) => map.isFree(line, Math.min(a, b), line, Math.max(a, b)), + legFree: (map, along, a, b) => map.isFree(Math.min(a, b), along, Math.max(a, b), along) +}; + +/** + * Separate overlapping segments of different nets. + * Routes are modified in place. + */ +export function nudgeRoutes(routes: NudgeRoute[], map: ObstacleMap): void { + nudgePass(routes, map, HORIZONTAL); + nudgePass(routes, map, VERTICAL); + // Moving vertical segments stretches horizontal ones, which can create new overlaps + nudgePass(routes, map, HORIZONTAL); + for (const route of routes) route.points = simplifyGridPath(route.points); +} + +function nudgePass(routes: NudgeRoute[], map: ObstacleMap, axis: Axis): void { + const lines = new Map(); + + for (const route of routes) { + const pts = route.points; + const last = pts.length - 1; + for (let i = 0; i < last; i++) { + const a = pts[i]; + const b = pts[i + 1]; + const line = axis.across(a); + if (line !== axis.across(b) || axis.along(a) === axis.along(b)) continue; + + const lo = Math.min(axis.along(a), axis.along(b)); + const hi = Math.max(axis.along(a), axis.along(b)); + const fixed = + i === 0 || + i + 1 === last || + route.anchors.some((p) => axis.across(p) === line && axis.along(p) >= lo && axis.along(p) <= hi); + + let list = lines.get(line); + if (!list) { + list = []; + lines.set(line, list); + } + list.push({ route, index: i, lo, hi, fixed }); + } + } + + const lineKeys = [...lines.keys()].sort((a, b) => a - b); + for (const line of lineKeys) { + const segments = lines.get(line)!; + if (segments.length < 2) continue; + + const sorted = segments + .slice() + .sort((a, b) => a.lo - b.lo || a.hi - b.hi || compareIds(a.route.id, b.route.id) || a.index - b.index); + + let cluster: Segment[] = []; + let clusterHi = -Infinity; + for (const segment of sorted) { + if (cluster.length > 0 && segment.lo > clusterHi) { + resolveCluster(cluster, line, lines, map, axis); + cluster = []; + clusterHi = -Infinity; + } + cluster.push(segment); + clusterHi = Math.max(clusterHi, segment.hi); + } + resolveCluster(cluster, line, lines, map, axis); + } +} + +function compareIds(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +function legDirection(segment: Segment, atLow: boolean, line: number, axis: Axis): number { + const pts = segment.route.points; + const a = pts[segment.index]; + const b = pts[segment.index + 1]; + const lowIsFirst = axis.along(a) <= axis.along(b); + const other = lowIsFirst === atLow ? pts[segment.index - 1] : pts[segment.index + 2]; + return other ? Math.sign(axis.across(other) - line) : 0; +} + +function buildUnits(cluster: Segment[], line: number, axis: Axis): Unit[] { + const byNet = new Map(); + for (const segment of cluster) { + const list = byNet.get(segment.route.netId); + if (list) list.push(segment); + else byNet.set(segment.route.netId, [segment]); + } + + const units: Unit[] = []; + for (const [netId, segments] of byNet) { + let low = segments[0]; + let high = segments[0]; + for (const s of segments) { + if (s.lo < low.lo) low = s; + if (s.hi > high.hi) high = s; + } + units.push({ + netId, + segments, + lo: low.lo, + hi: high.hi, + fixed: segments.some((s) => s.fixed), + loDir: legDirection(low, true, line, axis), + hiDir: legDirection(high, false, line, axis) + }); + } + return units; +} + +/** + * Order units across the line: negative means `a` belongs on the lower side. + * Legs turning towards a side pull their unit to that side; for equal turns the + * unit whose leg is further inside the shared range goes outward, which avoids + * a crossing with the other unit's leg. + */ +function compareUnits(a: Unit, b: Unit): number { + let score = 0; + if (a.loDir !== b.loDir) score += a.loDir - b.loDir; + else if (a.loDir !== 0) score += a.loDir * Math.sign(a.lo - b.lo); + if (a.hiDir !== b.hiDir) score += a.hiDir - b.hiDir; + else if (a.hiDir !== 0) score += a.hiDir * Math.sign(b.hi - a.hi); + return Math.sign(score) || compareIds(a.netId, b.netId); +} + +function resolveCluster( + cluster: Segment[], + line: number, + lines: Map, + map: ObstacleMap, + axis: Axis +): void { + if (cluster.length < 2) return; + const units = buildUnits(cluster, line, axis); + if (units.length < 2) return; + + units.sort(compareUnits); + + // Lane assignment like interval colouring: a unit only moves when it overlaps a + // unit that already holds a lane, and it moves to the side given by the order. + const placed: { unit: Unit; order: number; offset: number }[] = []; + units.forEach((unit, order) => { + if (unit.fixed) placed.push({ unit, order, offset: 0 }); + }); + + units.forEach((unit, order) => { + if (unit.fixed) return; + const overlapping = placed.filter((p) => p.unit.hi >= unit.lo && p.unit.lo <= unit.hi); + if (overlapping.length === 0) { + placed.push({ unit, order, offset: 0 }); + return; + } + const below = overlapping.every((p) => p.order > order); + const start = below + ? Math.min(...overlapping.map((p) => p.offset)) - 1 + : Math.max(...overlapping.map((p) => p.offset)) + 1; + if (start === 0 && !overlapping.some((p) => p.offset === 0)) { + placed.push({ unit, order, offset: 0 }); + return; + } + const offset = moveUnit(unit, line, start, below ? -1 : 1, lines, map, axis); + placed.push({ unit, order, offset }); + }); +} + +/** Move a unit to the nearest feasible lane from `start` outwards. Returns the offset, 0 if it stays. */ +function moveUnit( + unit: Unit, + line: number, + start: number, + side: number, + lines: Map, + map: ObstacleMap, + axis: Axis +): number { + for (let offset = start; Math.abs(offset) <= MAX_LANE; offset += side) { + if (offset === 0) continue; + const target = line + offset; + if (!isFeasible(unit, line, target, lines, map, axis)) continue; + + for (const segment of unit.segments) { + const pts = segment.route.points; + axis.setAcross(pts[segment.index], target); + axis.setAcross(pts[segment.index + 1], target); + segment.fixed = true; + + const from = lines.get(line)!; + from.splice(from.indexOf(segment), 1); + let to = lines.get(target); + if (!to) { + to = []; + lines.set(target, to); + } + to.push(segment); + } + return offset; + } + return 0; +} + +function isFeasible( + unit: Unit, + line: number, + target: number, + lines: Map, + map: ObstacleMap, + axis: Axis +): boolean { + const occupants = lines.get(target); + + for (const segment of unit.segments) { + const pts = segment.route.points; + const a = pts[segment.index]; + const b = pts[segment.index + 1]; + const before = pts[segment.index - 1]; + const after = pts[segment.index + 2]; + + // Legs keep their orientation and a non-zero length + for (const far of [before, after]) { + const farAcross = axis.across(far); + if (target === farAcross || Math.sign(line - farAcross) !== Math.sign(target - farAcross)) return false; + } + + if (!axis.lineFree(map, target, segment.lo, segment.hi)) return false; + if (!legExtensionFree(map, axis, axis.along(a), axis.across(before), line, target)) return false; + if (!legExtensionFree(map, axis, axis.along(b), axis.across(after), line, target)) return false; + + if (occupants) { + for (const other of occupants) { + if (other.route.netId !== unit.netId && other.hi >= segment.lo && other.lo <= segment.hi) return false; + } + } + } + return true; +} + +/** Check only the cells a leg gains when its end moves from `line` to `target` */ +function legExtensionFree( + map: ObstacleMap, + axis: Axis, + along: number, + far: number, + line: number, + target: number +): boolean { + if (Math.abs(target - far) <= Math.abs(line - far)) return true; + const step = Math.sign(target - line); + return axis.legFree(map, along, line + step, target); +} + +/** Remove duplicate and collinear points */ +export function simplifyGridPath(points: GridPoint[]): GridPoint[] { + const result: GridPoint[] = []; + for (const p of points) { + const last = result[result.length - 1]; + if (last && last.gx === p.gx && last.gy === p.gy) continue; + if (result.length >= 2) { + const prev = result[result.length - 2]; + if ((prev.gx === last.gx && last.gx === p.gx) || (prev.gy === last.gy && last.gy === p.gy)) { + result[result.length - 1] = p; + continue; + } + } + result.push(p); + } + return result; +} diff --git a/src/lib/routing/obstacleMap.ts b/src/lib/routing/obstacleMap.ts new file mode 100644 index 00000000..3e0e2743 --- /dev/null +++ b/src/lib/routing/obstacleMap.ts @@ -0,0 +1,93 @@ +/** + * Obstacle map - grid cells with obstacle reference counts, stored in tiles + * + * Two layers per cell: hard obstacles (node interiors) are impassable, soft + * obstacles (node margins, cells in front of ports) are passable at extra cost. + * Unbounded in all directions; reference counts let overlapping obstacles be + * added and removed independently. + */ + +import type { GridRect } from './gridTypes'; + +const TILE_BITS = 6; +const TILE_SIZE = 1 << TILE_BITS; +const TILE_MASK = TILE_SIZE - 1; + +/** Cell value bits holding the hard obstacle count (the high byte counts soft ones) */ +export const HARD_MASK = 0xff; + +const LAYER_WEIGHT = { hard: 1, soft: 256 } as const; + +export type ObstacleLayer = keyof typeof LAYER_WEIGHT; + +function tileKey(tx: number, ty: number): number { + return (tx + 0x8000) * 0x10000 + (ty + 0x8000); +} + +export class ObstacleMap { + private readonly tiles = new Map(); + private cachedKey = -1; + private cachedTile: Uint16Array | undefined = undefined; + + /** Extent of all cells that were ever occupied (grows only) */ + readonly extent: GridRect = { minGx: 0, minGy: 0, maxGx: 0, maxGy: 0 }; + private hasExtent = false; + + /** Raw cell value: 0 when free, HARD_MASK bits set when impassable */ + cell(gx: number, gy: number): number { + const key = tileKey(gx >> TILE_BITS, gy >> TILE_BITS); + if (key !== this.cachedKey) { + this.cachedKey = key; + this.cachedTile = this.tiles.get(key); + } + const tile = this.cachedTile; + return tile === undefined ? 0 : tile[((gy & TILE_MASK) << TILE_BITS) | (gx & TILE_MASK)]; + } + + isHard(gx: number, gy: number): boolean { + return (this.cell(gx, gy) & HARD_MASK) !== 0; + } + + /** True if no cell in the inclusive rectangle holds any obstacle */ + isFree(minGx: number, minGy: number, maxGx: number, maxGy: number): boolean { + for (let gy = minGy; gy <= maxGy; gy++) { + for (let gx = minGx; gx <= maxGx; gx++) { + if (this.cell(gx, gy) !== 0) return false; + } + } + return true; + } + + /** Add (delta 1) or remove (delta -1) an obstacle rectangle on a layer */ + addRect(rect: GridRect, layer: ObstacleLayer, delta: 1 | -1): void { + this.cachedKey = -1; + if (delta > 0) this.grow(rect); + const step = LAYER_WEIGHT[layer] * delta; + + for (let gy = rect.minGy; gy <= rect.maxGy; gy++) { + for (let gx = rect.minGx; gx <= rect.maxGx; gx++) { + const key = tileKey(gx >> TILE_BITS, gy >> TILE_BITS); + let tile = this.tiles.get(key); + if (!tile) { + if (delta < 0) continue; + tile = new Uint16Array(TILE_SIZE * TILE_SIZE); + this.tiles.set(key, tile); + } + tile[((gy & TILE_MASK) << TILE_BITS) | (gx & TILE_MASK)] += step; + } + } + } + + private grow(rect: GridRect): void { + const e = this.extent; + if (!this.hasExtent) { + Object.assign(e, rect); + this.hasExtent = true; + return; + } + e.minGx = Math.min(e.minGx, rect.minGx); + e.minGy = Math.min(e.minGy, rect.minGy); + e.maxGx = Math.max(e.maxGx, rect.maxGx); + e.maxGy = Math.max(e.maxGy, rect.maxGy); + } +} diff --git a/src/lib/routing/occupancy.ts b/src/lib/routing/occupancy.ts new file mode 100644 index 00000000..0e862ba2 --- /dev/null +++ b/src/lib/routing/occupancy.ts @@ -0,0 +1,115 @@ +/** + * Occupancy - grid usage by nets for negotiated congestion routing + * + * Counts how many nets run through each cell, separately per axis, so that + * crossings stay cheap while running along another net costs extra. A history + * term raises the cost of cells that stayed contested across iterations. + */ + +import type { GridPoint } from './gridTypes'; +import type { CongestionCosts } from './search'; + +const TILE_BITS = 6; +const TILE_SIZE = 1 << TILE_BITS; +const TILE_MASK = TILE_SIZE - 1; + +const KEY_OFFSET = 1 << 20; +const KEY_SPAN = 1 << 21; + +/** Axis of a unit step: 0 horizontal, 1 vertical */ +export type Axis = 0 | 1; + +function tileKey(tx: number, ty: number): number { + return (tx + 0x8000) * 0x10000 + (ty + 0x8000); +} + +/** Numeric key of a cell on one axis */ +export function cellKey(gx: number, gy: number, axis: Axis): number { + return ((gx + KEY_OFFSET) * KEY_SPAN + (gy + KEY_OFFSET)) * 2 + axis; +} + +/** Add the cell keys covered by a corner path */ +export function collectCells(points: GridPoint[], into: Set): void { + for (let i = 0; i < points.length - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + if (a.gy === b.gy) { + for (let x = Math.min(a.gx, b.gx); x <= Math.max(a.gx, b.gx); x++) into.add(cellKey(x, a.gy, 0)); + } else { + for (let y = Math.min(a.gy, b.gy); y <= Math.max(a.gy, b.gy); y++) into.add(cellKey(a.gx, y, 1)); + } + } +} + +class TileCounts { + private readonly tiles = new Map(); + private cachedKey = -1; + private cachedTile: Uint16Array | undefined = undefined; + + get(gx: number, gy: number, axis: Axis): number { + const key = tileKey(gx >> TILE_BITS, gy >> TILE_BITS); + if (key !== this.cachedKey) { + this.cachedKey = key; + this.cachedTile = this.tiles.get(key); + } + const tile = this.cachedTile; + return tile === undefined ? 0 : tile[((((gy & TILE_MASK) << TILE_BITS) | (gx & TILE_MASK)) << 1) | axis]; + } + + add(gx: number, gy: number, axis: Axis, delta: number): void { + const key = tileKey(gx >> TILE_BITS, gy >> TILE_BITS); + let tile = this.tiles.get(key); + if (!tile) { + if (delta < 0) return; + tile = new Uint16Array(TILE_SIZE * TILE_SIZE * 2); + this.tiles.set(key, tile); + this.cachedKey = -1; + } + const index = ((((gy & TILE_MASK) << TILE_BITS) | (gx & TILE_MASK)) << 1) | axis; + tile[index] = Math.max(0, Math.min(0xffff, tile[index] + delta)); + } +} + +function decode(key: number): [number, number, Axis] { + const axis = (key % 2) as Axis; + const cell = (key - axis) / 2; + const gy = (cell % KEY_SPAN) - KEY_OFFSET; + const gx = Math.floor(cell / KEY_SPAN) - KEY_OFFSET; + return [gx, gy, axis]; +} + +export class Occupancy implements CongestionCosts { + private readonly usage = new TileCounts(); + private readonly history = new TileCounts(); + + /** Cost per other net using a cell on the same axis */ + presentCost = 2; + + /** Cells of the net currently being routed; they carry no congestion cost */ + own: Set | null = null; + + add(keys: Iterable, delta: 1 | -1): void { + for (const key of keys) { + const [gx, gy, axis] = decode(key); + this.usage.add(gx, gy, axis, delta); + } + } + + countKey(key: number): number { + const [gx, gy, axis] = decode(key); + return this.usage.get(gx, gy, axis); + } + + bumpHistory(key: number, amount: number): void { + const [gx, gy, axis] = decode(key); + this.history.add(gx, gy, axis, amount); + } + + penalty(gx: number, gy: number, axis: Axis): number { + const used = this.usage.get(gx, gy, axis); + const past = this.history.get(gx, gy, axis); + if (used === 0 && past === 0) return 0; + if (this.own !== null && this.own.has(cellKey(gx, gy, axis))) return 0; + return used * this.presentCost + past; + } +} diff --git a/src/lib/routing/router.test.ts b/src/lib/routing/router.test.ts index d64599c5..7cba7245 100644 --- a/src/lib/routing/router.test.ts +++ b/src/lib/routing/router.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { generateScenario } from './testing/scenario'; +import type { Position } from '$lib/types/common'; +import type { RouteResult } from './types'; +import { generateScenario, buildScene, sceneNode, sceneRequest, type ScenarioOptions } from './testing/scenario'; +import { checkRoutes, measureRoutes } from './testing/invariants'; +import { RoutingEngine, routeScene } from './engine'; import { GRID_SIZE } from './constants'; describe('scenario generator', () => { @@ -25,3 +29,104 @@ describe('scenario generator', () => { } }); }); + +function passesThrough(route: RouteResult, point: Position): boolean { + const path = route.path; + for (let i = 0; i < path.length - 1; i++) { + const a = path[i]; + const b = path[i + 1]; + const onX = point.x >= Math.min(a.x, b.x) && point.x <= Math.max(a.x, b.x); + const onY = point.y >= Math.min(a.y, b.y) && point.y <= Math.max(a.y, b.y); + if (onX && onY) return true; + } + return false; +} + +describe('routing engine', () => { + const cases: { name: string; options: ScenarioOptions }[] = [ + { name: 'plain', options: { connections: 150, seed: 3 } }, + { name: 'rotated', options: { connections: 300, rotated: 0.5, seed: 4 } }, + { name: 'dense', options: { connections: 400, rotated: 0.3, fanOut: 0.3, feedback: 0.2, gap: 4, seed: 11 } } + ]; + + it.each(cases)('satisfies the routing invariants ($name)', ({ options }) => { + const scene = buildScene(generateScenario(options)); + const routes = routeScene(scene); + expect(checkRoutes(scene, routes)).toEqual([]); + expect(routes.size).toBe(scene.requests.length); + }); + + it('separates overlapping nets into lanes', () => { + const scene = buildScene(generateScenario({ connections: 400, rotated: 0.2, fanOut: 0.2, seed: 21 })); + const raw = measureRoutes(scene, routeScene(scene, { congestion: false, nudge: false, negotiate: 0 })); + const nudged = measureRoutes(scene, routeScene(scene)); + expect(raw.overlapLength).toBeGreaterThan(0); + expect(nudged.overlapLength).toBeLessThan(raw.overlapLength * 0.5); + }); + + it('does not depend on connection order', () => { + const scene = buildScene(generateScenario({ connections: 300, rotated: 0.3, fanOut: 0.2, seed: 13 })); + const forward = routeScene(scene); + const backward = routeScene({ ...scene, requests: [...scene.requests].reverse() }); + for (const [id, route] of forward) { + expect(backward.get(id)!.path).toEqual(route.path); + } + }); + + it('routes through user waypoints', () => { + const scene = buildScene(generateScenario({ connections: 20, seed: 5 })); + const request = scene.requests[0]; + const position = { + x: Math.round((request.source.position.x + request.target.position.x) / 2 / GRID_SIZE) * GRID_SIZE, + y: -400 + }; + request.waypoints = [{ id: 'w1', position, isUserWaypoint: true }]; + + const routes = routeScene(scene); + expect(passesThrough(routes.get(request.id)!, position)).toBe(true); + expect(checkRoutes(scene, routes)).toEqual([]); + }); + + it('stays valid after incremental node moves', () => { + const scenario = generateScenario({ connections: 300, rotated: 0.3, seed: 8 }); + const nodesById = new Map(scenario.nodes.map((n) => [n.id, n])); + const engine = new RoutingEngine(); + for (const node of scenario.nodes) { + const s = sceneNode(node); + engine.setNode(node.id, s.bounds, s.ports); + } + for (const c of scenario.connections) engine.setRequest(sceneRequest(c, nodesById)); + engine.update(); + + for (const id of ['n3', 'n10', 'n17', 'n25', 'n31']) { + const node = nodesById.get(id)!; + node.center = { x: node.center.x + 40, y: node.center.y + 20 }; + const s = sceneNode(node); + engine.setNode(id, s.bounds, s.ports); + for (const c of scenario.connections) { + if (c.sourceNodeId === id || c.targetNodeId === id) engine.setRequest(sceneRequest(c, nodesById)); + } + engine.update(); + } + + expect(checkRoutes(buildScene(scenario), engine.getRoutes())).toEqual([]); + }); + + it('reports only changes', () => { + const scene = buildScene(generateScenario({ connections: 50, seed: 6 })); + const engine = new RoutingEngine(); + for (const [id, node] of scene.nodes) engine.setNode(id, node.bounds, node.ports); + for (const request of scene.requests) engine.setRequest(request); + + expect(engine.update().changed.size).toBe(50); + expect(engine.update().changed.size).toBe(0); + + for (const request of scene.requests) engine.setRequest(request); + expect(engine.update().changed.size).toBe(0); + + engine.removeRequest(scene.requests[0].id); + const update = engine.update(); + expect(update.removed).toEqual([scene.requests[0].id]); + expect(engine.getRoute(scene.requests[0].id)).toBeUndefined(); + }); +}); diff --git a/src/lib/routing/routing.perf.test.ts b/src/lib/routing/routing.perf.test.ts index 21685dc6..0b489e9b 100644 --- a/src/lib/routing/routing.perf.test.ts +++ b/src/lib/routing/routing.perf.test.ts @@ -4,9 +4,10 @@ import { describe, it } from 'vitest'; import type { RouteResult, RoutingScene } from './types'; -import { generateScenario, buildScene } from './testing/scenario'; +import { generateScenario, buildScene, sceneNode, sceneRequest } from './testing/scenario'; import { measureRoutes } from './testing/invariants'; import { routeSceneLegacy } from './testing/legacyRouter'; +import { RoutingEngine, routeScene } from './engine'; interface Benchmarked { route: (scene: RoutingScene) => Map; @@ -15,9 +16,12 @@ interface Benchmarked { const routers: Record = { // Pre-v2 baseline, larger sizes take minutes - legacy: { route: routeSceneLegacy, sizes: [1000] } + legacy: { route: routeSceneLegacy, sizes: [1000] }, + engine: { route: (scene) => routeScene(scene), sizes: [1000, 5000, 10000] } }; +const TIMEOUT = 30 * 60_000; + describe.skipIf(import.meta.env.MODE !== 'perf')('routing performance', () => { for (const [name, { route, sizes }] of Object.entries(routers)) { for (const size of sizes) { @@ -28,7 +32,43 @@ describe.skipIf(import.meta.env.MODE !== 'perf')('routing performance', () => { const ms = performance.now() - start; const metrics = measureRoutes(scene, routes); console.log(JSON.stringify({ router: name, connections: size, ms: Math.round(ms), ...metrics })); - }, 30 * 60_000); + }, TIMEOUT); } } + + it('engine: incremental node drag in 10000 connections', () => { + const scenario = generateScenario({ connections: 10000, rotated: 0.1, seed: 7 }); + const nodesById = new Map(scenario.nodes.map((n) => [n.id, n])); + const engine = new RoutingEngine(); + for (const node of scenario.nodes) { + const s = sceneNode(node); + engine.setNode(node.id, s.bounds, s.ports); + } + for (const c of scenario.connections) engine.setRequest(sceneRequest(c, nodesById)); + engine.update(); + + const node = scenario.nodes[Math.floor(scenario.nodes.length / 2)]; + const attached = scenario.connections.filter((c) => c.sourceNodeId === node.id || c.targetNodeId === node.id); + const samples: number[] = []; + for (let step = 0; step < 20; step++) { + node.center = { x: node.center.x + 10, y: node.center.y }; + const start = performance.now(); + const s = sceneNode(node); + engine.setNode(node.id, s.bounds, s.ports); + for (const c of attached) engine.setRequest(sceneRequest(c, nodesById)); + engine.update(); + samples.push(performance.now() - start); + } + samples.sort((a, b) => a - b); + console.log( + JSON.stringify({ + router: 'engine', + case: 'drag step', + connections: scenario.connections.length, + attached: attached.length, + medianMs: Math.round(samples[10] * 10) / 10, + maxMs: Math.round(samples[19] * 10) / 10 + }) + ); + }, TIMEOUT); }); diff --git a/src/lib/routing/search.ts b/src/lib/routing/search.ts new file mode 100644 index 00000000..641a1ba0 --- /dev/null +++ b/src/lib/routing/search.ts @@ -0,0 +1,246 @@ +/** + * Orthogonal A* on the obstacle grid + * + * Turn penalty, no reversals, fixed start direction and optional arrival + * direction. States (cell, direction) are packed into integers inside a + * bounded search window and all bookkeeping uses reused typed arrays, so a + * search allocates nothing but its result. + */ + +import { HARD_MASK, type ObstacleMap } from './obstacleMap'; +import { DX, DY, OPPOSITE, type GridPoint, type GridRect } from './gridTypes'; + +/** Cost of a 90 degree turn in grid steps */ +export const TURN_COST = 2; + +/** Extra cost per step through a soft obstacle (node margin, cell in front of a port) */ +export const SOFT_COST = 6; + +/** Window paddings (cells around start and end) tried before the full extent */ +const WINDOW_PADDINGS = [16, 64]; + +/** Upper bound for states of one search (caps memory at about 50 MB) */ +const MAX_STATES = 4_000_000; + +/** Extra step costs from other routes (negotiated congestion) */ +export interface CongestionCosts { + penalty(gx: number, gy: number, axis: 0 | 1): number; +} + +export interface GridSearch { + /** Optional congestion costs added per step */ + congestion?: CongestionCosts; + start: GridPoint; + startDir: number; + end: GridPoint; + /** Required arrival direction (turn penalty otherwise), or -1 for any */ + endDir: number; + /** Cells walkable regardless of obstacles (port exits and entries) */ + forced: GridPoint[]; +} + +export interface GridPath { + /** Corner cells from start to end, both included */ + corners: GridPoint[]; + arrivalDir: number; +} + +// Reused buffers, grown on demand +let capacity = 0; +let gScore = new Int32Array(0); +let parent = new Int32Array(0); +/** (generation << 1) | closed */ +let stamp = new Uint32Array(0); +let generation = 0; +let heapState = new Int32Array(1024); +let heapKey = new Float64Array(1024); +let heapSize = 0; + +function ensureCapacity(states: number): void { + if (states <= capacity) return; + capacity = Math.max(states, Math.min(capacity * 2, MAX_STATES)); + gScore = new Int32Array(capacity); + parent = new Int32Array(capacity); + stamp = new Uint32Array(capacity); + generation = 0; +} + +function heapPush(state: number, key: number): void { + if (heapSize === heapState.length) { + const states = new Int32Array(heapSize * 2); + const keys = new Float64Array(heapSize * 2); + states.set(heapState); + keys.set(heapKey); + heapState = states; + heapKey = keys; + } + let i = heapSize++; + while (i > 0) { + const p = (i - 1) >> 1; + if (heapKey[p] <= key) break; + heapState[i] = heapState[p]; + heapKey[i] = heapKey[p]; + i = p; + } + heapState[i] = state; + heapKey[i] = key; +} + +function heapPop(): number { + const top = heapState[0]; + heapSize--; + const lastState = heapState[heapSize]; + const lastKey = heapKey[heapSize]; + let i = 0; + while (true) { + let c = 2 * i + 1; + if (c >= heapSize) break; + if (c + 1 < heapSize && heapKey[c + 1] < heapKey[c]) c++; + if (heapKey[c] >= lastKey) break; + heapState[i] = heapState[c]; + heapKey[i] = heapKey[c]; + i = c; + } + heapState[i] = lastState; + heapKey[i] = lastKey; + return top; +} + +/** Minimum number of turns to reach an offset (dx, dy) moving in direction d, ignoring obstacles */ +function minTurns(dx: number, dy: number, d: number): number { + const ahead = d === 0 ? dx : d === 1 ? -dx : d === 2 ? dy : -dy; + const side = d <= 1 ? dy : dx; + if (side === 0) return ahead >= 0 ? 0 : 2; + return ahead >= 0 ? 1 : 2; +} + +function windowAround(request: GridSearch, padding: number): GridRect { + const { start, end } = request; + return { + minGx: Math.min(start.gx, end.gx) - padding, + minGy: Math.min(start.gy, end.gy) - padding, + maxGx: Math.max(start.gx, end.gx) + padding, + maxGy: Math.max(start.gy, end.gy) + padding + }; +} + +/** + * Find the cheapest orthogonal path, widening the search window when needed. + * Returns null if the end is unreachable. + */ +export function searchGridPath(map: ObstacleMap, request: GridSearch): GridPath | null { + for (const padding of WINDOW_PADDINGS) { + const path = searchWindow(map, request, windowAround(request, padding)); + if (path) return path; + } + const near = windowAround(request, WINDOW_PADDINGS[0]); + const e = map.extent; + return searchWindow(map, request, { + minGx: Math.min(near.minGx, e.minGx - WINDOW_PADDINGS[0]), + minGy: Math.min(near.minGy, e.minGy - WINDOW_PADDINGS[0]), + maxGx: Math.max(near.maxGx, e.maxGx + WINDOW_PADDINGS[0]), + maxGy: Math.max(near.maxGy, e.maxGy + WINDOW_PADDINGS[0]) + }); +} + +function searchWindow(map: ObstacleMap, request: GridSearch, win: GridRect): GridPath | null { + const w = win.maxGx - win.minGx + 1; + const h = win.maxGy - win.minGy + 1; + const states = w * h * 4; + if (states > MAX_STATES) return null; + ensureCapacity(states); + + generation++; + if (generation >= 0x7fffffff) { + stamp.fill(0); + generation = 1; + } + const seen = generation << 1; + const closed = seen | 1; + heapSize = 0; + + const { start, end, endDir, forced, congestion } = request; + const x0 = win.minGx; + const y0 = win.minGy; + + const isForced = (gx: number, gy: number): boolean => { + for (let i = 0; i < forced.length; i++) { + if (forced[i].gx === gx && forced[i].gy === gy) return true; + } + return false; + }; + + const startState = ((start.gy - y0) * w + (start.gx - x0)) * 4 + request.startDir; + gScore[startState] = 0; + parent[startState] = -1; + stamp[startState] = seen; + const h0 = Math.abs(end.gx - start.gx) + Math.abs(end.gy - start.gy); + heapPush(startState, h0 * 65536 + h0); + + while (heapSize > 0) { + const s = heapPop(); + if (stamp[s] === closed) continue; + stamp[s] = closed; + + const d = s & 3; + const cell = s >> 2; + const gx = (cell % w) + x0; + const gy = ((cell / w) | 0) + y0; + if (gx === end.gx && gy === end.gy) return reconstruct(s, w, x0, y0); + + const g = gScore[s]; + const isStart = parent[s] === -1; + + for (let nd = 0; nd < 4; nd++) { + if (nd === OPPOSITE[d]) continue; + if (isStart && nd !== d) continue; + + const nx = gx + DX[nd]; + const ny = gy + DY[nd]; + if (nx < win.minGx || nx > win.maxGx || ny < win.minGy || ny > win.maxGy) continue; + + let cost = g + 1 + (nd === d ? 0 : TURN_COST); + if (!isForced(nx, ny)) { + const value = map.cell(nx, ny); + if (value & HARD_MASK) continue; + if (value !== 0) cost += SOFT_COST; + } + if (congestion !== undefined) cost += congestion.penalty(nx, ny, nd <= 1 ? 0 : 1); + if (endDir >= 0 && nd !== endDir && nx === end.gx && ny === end.gy) cost += TURN_COST; + + const ns = ((ny - y0) * w + (nx - x0)) * 4 + nd; + const st = stamp[ns]; + if (st === closed) continue; + if (st === seen && gScore[ns] <= cost) continue; + + gScore[ns] = cost; + parent[ns] = s; + stamp[ns] = seen; + + const dx = end.gx - nx; + const dy = end.gy - ny; + const hh = Math.abs(dx) + Math.abs(dy) + TURN_COST * minTurns(dx, dy, nd); + heapPush(ns, (cost + hh) * 65536 + hh); + } + } + + return null; +} + +function reconstruct(goal: number, w: number, x0: number, y0: number): GridPath { + const corners: GridPoint[] = []; + let s = goal; + let nextDir = -1; + while (s !== -1) { + const d = s & 3; + const p = parent[s]; + if (nextDir === -1 || p === -1 || nextDir !== d) { + const cell = s >> 2; + corners.push({ gx: (cell % w) + x0, gy: ((cell / w) | 0) + y0 }); + } + nextDir = d; + s = p; + } + corners.reverse(); + return { corners, arrivalDir: goal & 3 }; +} diff --git a/src/lib/routing/testing/invariants.ts b/src/lib/routing/testing/invariants.ts index a90258db..102c6ee3 100644 --- a/src/lib/routing/testing/invariants.ts +++ b/src/lib/routing/testing/invariants.ts @@ -99,8 +99,8 @@ export function checkRoutes(scene: RoutingScene, routes: Map): Bounds { } export function routeSceneLegacy(scene: RoutingScene): Map { + const nodeBounds = new Map([...scene.nodes].map(([id, node]) => [id, node.bounds])); const grid = new SparseGrid({ - nodeBounds: scene.nodeBounds, - canvasBounds: canvasBoundsOf(scene.nodeBounds), - portStubs: scene.portStubs + nodeBounds, + canvasBounds: canvasBoundsOf(nodeBounds), + portStubs: [...scene.nodes.values()].flatMap((node) => node.ports) }); const route = (r: RouteRequest, usedCells?: Map>): RouteResult => diff --git a/src/lib/routing/testing/scenario.ts b/src/lib/routing/testing/scenario.ts index 9230beca..dc92edb4 100644 --- a/src/lib/routing/testing/scenario.ts +++ b/src/lib/routing/testing/scenario.ts @@ -6,7 +6,7 @@ */ import type { Position, RotationValue } from '$lib/types/common'; -import type { Bounds, PortStub, RouteRequest, RoutingScene } from '../types'; +import type { PortInfo, PortStub, RouteRequest, RoutingScene, SceneNode } from '../types'; import { calculateNodeDimensions, snapTo2G } from '$lib/constants/dimensions'; import { G } from '$lib/constants/grid'; import { getPortInfo } from '../portGeometry'; @@ -147,43 +147,52 @@ export function generateScenario(options: ScenarioOptions): Scenario { return { nodes, connections }; } -/** - * Build the routing input for a scenario - */ -export function buildScene(scenario: Scenario): RoutingScene { - const nodeBounds = new Map(); - const portStubs: PortStub[] = []; - const nodesById = new Map(scenario.nodes.map((n) => [n.id, n])); +function portOf(node: ScenarioNode, index: number, isOutput: boolean): PortInfo { + return getPortInfo( + node.center, + node.width, + node.height, + node.rotation, + index, + isOutput ? node.outputs : node.inputs, + isOutput + ); +} - const portOf = (node: ScenarioNode, index: number, isOutput: boolean) => - getPortInfo( - node.center, - node.width, - node.height, - node.rotation, - index, - isOutput ? node.outputs : node.inputs, - isOutput - ); - - for (const node of scenario.nodes) { - nodeBounds.set(node.id, { +/** Routing input for one node */ +export function sceneNode(node: ScenarioNode): SceneNode { + const ports: PortStub[] = []; + for (let i = 0; i < node.inputs; i++) ports.push(portOf(node, i, false)); + for (let i = 0; i < node.outputs; i++) ports.push(portOf(node, i, true)); + return { + bounds: { x: node.center.x - node.width / 2, y: node.center.y - node.height / 2, width: node.width, height: node.height - }); - for (let i = 0; i < node.inputs; i++) portStubs.push(portOf(node, i, false)); - for (let i = 0; i < node.outputs; i++) portStubs.push(portOf(node, i, true)); - } + }, + ports + }; +} - const requests: RouteRequest[] = scenario.connections.map((c) => ({ - id: c.id, - netId: `${c.sourceNodeId}:${c.sourcePortIndex}`, - source: portOf(nodesById.get(c.sourceNodeId)!, c.sourcePortIndex, true), - target: portOf(nodesById.get(c.targetNodeId)!, c.targetPortIndex, false), +/** Routing input for one connection */ +export function sceneRequest(connection: ScenarioConnection, nodesById: Map): RouteRequest { + return { + id: connection.id, + netId: `${connection.sourceNodeId}:${connection.sourcePortIndex}`, + source: portOf(nodesById.get(connection.sourceNodeId)!, connection.sourcePortIndex, true), + target: portOf(nodesById.get(connection.targetNodeId)!, connection.targetPortIndex, false), waypoints: [] - })); + }; +} - return { nodeBounds, portStubs, requests }; +/** + * Build the routing input for a scenario + */ +export function buildScene(scenario: Scenario): RoutingScene { + const nodesById = new Map(scenario.nodes.map((n) => [n.id, n])); + return { + nodes: new Map(scenario.nodes.map((n) => [n.id, sceneNode(n)])), + requests: scenario.connections.map((c) => sceneRequest(c, nodesById)) + }; } diff --git a/src/lib/routing/types.ts b/src/lib/routing/types.ts index 9319e609..9248b069 100644 --- a/src/lib/routing/types.ts +++ b/src/lib/routing/types.ts @@ -55,10 +55,15 @@ export interface RouteRequest { waypoints: Waypoint[]; } -/** Complete routing input: obstacles and connections */ +/** Routing input for one node: obstacle bounds and all its ports */ +export interface SceneNode { + bounds: Bounds; + ports: PortStub[]; +} + +/** Complete routing input: nodes and connections */ export interface RoutingScene { - nodeBounds: Map; - portStubs: PortStub[]; + nodes: Map; requests: RouteRequest[]; } From 3981f69e3b27564b0d8abd4c580a58e539f7c5b6 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Sun, 13 Sep 2026 16:30:13 +0200 Subject: [PATCH 3/8] Publish only searched routes, mark touched routes precisely, drop nudging --- src/lib/routing/engine.ts | 160 +++++++++------- src/lib/routing/gridTypes.ts | 18 ++ src/lib/routing/nudge.ts | 338 --------------------------------- src/lib/routing/router.test.ts | 10 +- 4 files changed, 113 insertions(+), 413 deletions(-) delete mode 100644 src/lib/routing/nudge.ts diff --git a/src/lib/routing/engine.ts b/src/lib/routing/engine.ts index 59154131..8e9d9e20 100644 --- a/src/lib/routing/engine.ts +++ b/src/lib/routing/engine.ts @@ -2,17 +2,15 @@ * Routing engine - incremental orthogonal routing on the canvas grid * * Framework free so it can run in a worker. Connections are searched with - * congestion costs from the other nets (negotiated congestion routing), a - * negotiation loop re-searches nets that still share grid lines, and a final - * nudging pass separates the remaining overlaps into lanes. After changes only - * affected nets are searched again. + * congestion costs from the other nets (negotiated congestion routing) and a + * negotiation loop re-searches nets that still share grid lines. After changes + * only affected nets are searched again and only their routes are published. */ import type { Bounds, PortInfo, PortStub, RouteRequest, RouteResult, RoutingScene } from './types'; import { ObstacleMap } from './obstacleMap'; import { Occupancy, collectCells } from './occupancy'; import { searchGridPath } from './search'; -import { nudgeRoutes, simplifyGridPath, type NudgeRoute } from './nudge'; import { DIRECTION_INDEX, DX, @@ -20,6 +18,7 @@ import { OPPOSITE, toGrid, fromGrid, + simplifyGridPath, type GridPoint, type GridRect } from './gridTypes'; @@ -28,7 +27,7 @@ import { GRID_SIZE, ROUTING_MARGIN, SOURCE_CLEARANCE, TARGET_CLEARANCE } from '. /** Cells walkable in front of a port so routes can pass the node margin */ const PORT_EXIT_CELLS = 3; -/** Cells around a changed node within which existing routes are searched again */ +/** Cells around a node's previous position within which routes are searched again */ const REROUTE_PADDING = 2; /** Tile size (cells) of the spatial index over routes */ @@ -46,8 +45,6 @@ const NEGOTIATION_PROGRESS = 0.8; export interface RoutingEngineOptions { /** Search with congestion costs from other nets (default true) */ congestion?: boolean; - /** Separate remaining overlaps into lanes (default true) */ - nudge?: boolean; } export interface UpdateOptions { @@ -134,25 +131,49 @@ function samePort(a: PortInfo, b: PortInfo): boolean { return a.direction === b.direction && a.position.x === b.position.x && a.position.y === b.position.y; } -function sameRequest(a: RouteRequest, b: RouteRequest): boolean { - if (a.netId !== b.netId || !samePort(a.source, b.source) || !samePort(a.target, b.target)) return false; - if (a.waypoints.length !== b.waypoints.length) return false; - for (let i = 0; i < a.waypoints.length; i++) { - const p = a.waypoints[i].position; - const q = b.waypoints[i].position; - if (p.x !== q.x || p.y !== q.y) return false; +function sameWaypoints(a: RouteRequest['waypoints'], b: RouteRequest['waypoints']): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i].position.x !== b[i].position.x || a[i].position.y !== b[i].position.y) return false; } return true; } -function samePath(a: RouteResult, b: RouteResult): boolean { - if (a.isFallback !== b.isFallback || a.path.length !== b.path.length || a.waypoints !== b.waypoints) return false; +function sameRequest(a: RouteRequest, b: RouteRequest): boolean { + return ( + a.netId === b.netId && + samePort(a.source, b.source) && + samePort(a.target, b.target) && + sameWaypoints(a.waypoints, b.waypoints) + ); +} + +function sameResult(a: RouteResult, b: RouteResult): boolean { + if (a.isFallback !== b.isFallback || a.path.length !== b.path.length) return false; + if (!sameWaypoints(a.waypoints, b.waypoints)) return false; for (let i = 0; i < a.path.length; i++) { if (a.path[i].x !== b.path[i].x || a.path[i].y !== b.path[i].y) return false; } return true; } +/** True if any segment of a corner path touches the inclusive rectangle */ +function pathTouches(points: GridPoint[], minGx: number, minGy: number, maxGx: number, maxGy: number): boolean { + for (let i = 0; i < points.length; i++) { + const a = points[i]; + const b = points[Math.min(i + 1, points.length - 1)]; + if ( + Math.max(a.gx, b.gx) >= minGx && + Math.min(a.gx, b.gx) <= maxGx && + Math.max(a.gy, b.gy) >= minGy && + Math.min(a.gy, b.gy) <= maxGy + ) { + return true; + } + } + return false; +} + function tileKey(tx: number, ty: number): number { return (tx + 0x8000) * 0x10000 + (ty + 0x8000); } @@ -181,15 +202,15 @@ export class RoutingEngine { private readonly fallbacks = new Set(); private readonly dirty = new Set(); private readonly dirtyNets = new Set(); + /** Connections searched since the last publish */ + private readonly touched = new Set(); private readonly removedIds = new Set(); - private results = new Map(); + private readonly results = new Map(); private changedSinceUpdate = false; private readonly congestion: boolean; - private readonly nudge: boolean; constructor(options: RoutingEngineOptions = {}) { this.congestion = options.congestion ?? true; - this.nudge = options.nudge ?? true; } /** Add or move a node together with its ports */ @@ -202,8 +223,9 @@ export class RoutingEngine { this.applyObstacles(entry, 1); this.nodes.set(id, entry); - this.markAround(entry.rect); - if (old) this.markAround(old.rect); + // Routes now running through the node or in front of its ports, and routes that hugged the old position + this.markTouching(entry.rect, 1); + if (old) this.markTouching(old.rect, REROUTE_PADDING); this.retryFallbacks(); this.changedSinceUpdate = true; } @@ -213,7 +235,7 @@ export class RoutingEngine { if (!old) return; this.applyObstacles(old, -1); this.nodes.delete(id); - this.markAround(old.rect); + this.markTouching(old.rect, REROUTE_PADDING); this.retryFallbacks(); this.changedSinceUpdate = true; } @@ -243,6 +265,7 @@ export class RoutingEngine { this.requests.delete(id); this.leaveNet(old.netId, id); this.dirty.delete(id); + this.touched.delete(id); this.fallbacks.delete(id); this.unindex(id); this.raw.delete(id); @@ -254,7 +277,7 @@ export class RoutingEngine { return this.requests.has(id); } - /** Search dirty nets, optionally negotiate overlaps, separate lanes and report what changed */ + /** Search dirty nets, optionally negotiate overlaps, and report what changed */ update(options: UpdateOptions = {}): RoutingUpdate { const negotiate = options.negotiate ?? 0; if (!this.changedSinceUpdate && negotiate === 0) return { changed: new Map(), removed: [] }; @@ -271,17 +294,7 @@ export class RoutingEngine { if (this.congestion) this.negotiate(negotiate); - const next = this.finalize(); - const changed = new Map(); - for (const [id, route] of next) { - const prev = this.results.get(id); - if (!prev || !samePath(prev, route)) changed.set(id, route); - } - const removed = [...this.removedIds]; - this.removedIds.clear(); - this.results = next; - - return { changed, removed }; + return this.publish(); } getRoute(id: string): RouteResult | undefined { @@ -329,6 +342,7 @@ export class RoutingEngine { if (raw.isFallback) this.fallbacks.add(id); else this.fallbacks.delete(id); this.index(id, raw); + this.touched.add(id); collectCells(raw.corners, cells); } this.occupancy.own = null; @@ -368,6 +382,33 @@ export class RoutingEngine { this.occupancy.presentCost = PRESENT_COST; } + /** Convert searched routes to results and report the ones that differ */ + private publish(): RoutingUpdate { + const changed = new Map(); + for (const id of this.touched) { + const raw = this.raw.get(id); + const request = this.requests.get(id); + if (!raw || !request) continue; + const route: RouteResult = { + path: raw.corners.map((p) => ({ x: fromGrid(p.gx), y: fromGrid(p.gy) })), + waypoints: request.waypoints, + isFallback: raw.isFallback + }; + const previous = this.results.get(id); + if (!previous || !sameResult(previous, route)) { + this.results.set(id, route); + changed.set(id, route); + } + } + this.touched.clear(); + + const removed = [...this.removedIds]; + for (const id of removed) this.results.delete(id); + this.removedIds.clear(); + + return { changed, removed }; + } + private applyObstacles(entry: NodeEntry, delta: 1 | -1): void { this.map.addRect(entry.body, 'hard', delta); this.map.addRect(entry.rect, 'soft', delta); @@ -380,16 +421,21 @@ export class RoutingEngine { for (const id of this.fallbacks) this.dirty.add(id); } - /** Mark routes passing near a rectangle as dirty */ - private markAround(rect: GridRect): void { - const minTx = Math.floor((rect.minGx - REROUTE_PADDING) / INDEX_TILE); - const maxTx = Math.floor((rect.maxGx + REROUTE_PADDING) / INDEX_TILE); - const minTy = Math.floor((rect.minGy - REROUTE_PADDING) / INDEX_TILE); - const maxTy = Math.floor((rect.maxGy + REROUTE_PADDING) / INDEX_TILE); - for (let tx = minTx; tx <= maxTx; tx++) { - for (let ty = minTy; ty <= maxTy; ty++) { + /** Mark routes that touch a rectangle (grown by padding) as dirty */ + private markTouching(rect: GridRect, padding: number): void { + const minGx = rect.minGx - padding; + const minGy = rect.minGy - padding; + const maxGx = rect.maxGx + padding; + const maxGy = rect.maxGy + padding; + for (let tx = Math.floor(minGx / INDEX_TILE); tx <= Math.floor(maxGx / INDEX_TILE); tx++) { + for (let ty = Math.floor(minGy / INDEX_TILE); ty <= Math.floor(maxGy / INDEX_TILE); ty++) { const ids = this.routeTiles.get(tileKey(tx, ty)); - if (ids) for (const id of ids) this.dirty.add(id); + if (!ids) continue; + for (const id of ids) { + if (this.dirty.has(id)) continue; + const raw = this.raw.get(id); + if (raw && pathTouches(raw.corners, minGx, minGy, maxGx, maxGy)) this.dirty.add(id); + } } } } @@ -478,32 +524,6 @@ export class RoutingEngine { return { corners: simplifyGridPath(corners), isFallback, tiles: [] }; } - - private finalize(): Map { - const routes: NudgeRoute[] = []; - for (const [id, raw] of this.raw) { - const request = this.requests.get(id)!; - routes.push({ - id, - netId: request.netId, - points: raw.corners.map((p) => ({ gx: p.gx, gy: p.gy })), - anchors: request.waypoints.map((w) => ({ gx: toGrid(w.position.x), gy: toGrid(w.position.y) })) - }); - } - - if (this.nudge) nudgeRoutes(routes, this.map); - - const results = new Map(); - for (const route of routes) { - const request = this.requests.get(route.id)!; - results.set(route.id, { - path: route.points.map((p) => ({ x: fromGrid(p.gx), y: fromGrid(p.gy) })), - waypoints: request.waypoints, - isFallback: this.raw.get(route.id)!.isFallback - }); - } - return results; - } } /** Route a complete scene in one go */ diff --git a/src/lib/routing/gridTypes.ts b/src/lib/routing/gridTypes.ts index ae839edc..7f69dd49 100644 --- a/src/lib/routing/gridTypes.ts +++ b/src/lib/routing/gridTypes.ts @@ -40,3 +40,21 @@ export function toGrid(value: number): number { export function fromGrid(value: number): number { return value * GRID_SIZE; } + +/** Remove duplicate and collinear points from a corner path */ +export function simplifyGridPath(points: GridPoint[]): GridPoint[] { + const result: GridPoint[] = []; + for (const p of points) { + const last = result[result.length - 1]; + if (last && last.gx === p.gx && last.gy === p.gy) continue; + if (result.length >= 2) { + const prev = result[result.length - 2]; + if ((prev.gx === last.gx && last.gx === p.gx) || (prev.gy === last.gy && last.gy === p.gy)) { + result[result.length - 1] = p; + continue; + } + } + result.push(p); + } + return result; +} diff --git a/src/lib/routing/nudge.ts b/src/lib/routing/nudge.ts deleted file mode 100644 index e6803896..00000000 --- a/src/lib/routing/nudge.ts +++ /dev/null @@ -1,338 +0,0 @@ -/** - * Lane separation (nudging) - * - * Routes are searched independently, so segments of different nets can end up - * on the same grid line. This pass moves inner segments of overlapping nets onto - * neighbouring free grid lines, ordered to avoid crossings. Segments attached to - * ports or passing through user waypoints stay in place. - */ - -import type { ObstacleMap } from './obstacleMap'; -import type { GridPoint } from './gridTypes'; - -export interface NudgeRoute { - id: string; - netId: string; - /** Corner points, modified in place */ - points: GridPoint[]; - /** Grid points the route has to keep passing through */ - anchors: GridPoint[]; -} - -/** Maximum lane distance from the original line, in grid cells */ -const MAX_LANE = 8; - -interface Segment { - route: NudgeRoute; - /** The segment runs from points[index] to points[index + 1] */ - index: number; - lo: number; - hi: number; - fixed: boolean; -} - -interface Unit { - netId: string; - segments: Segment[]; - lo: number; - hi: number; - fixed: boolean; - /** Direction of the leg at the low and high end: -1, 0 (none) or 1 */ - loDir: number; - hiDir: number; -} - -/** Axis accessors so one pass implementation serves both orientations */ -interface Axis { - along(p: GridPoint): number; - across(p: GridPoint): number; - setAcross(p: GridPoint, value: number): void; - /** Obstacle-free cells from a to b (inclusive) along the line `line` */ - lineFree(map: ObstacleMap, line: number, a: number, b: number): boolean; - /** Obstacle-free cells from a to b (inclusive) on the perpendicular line at `along` */ - legFree(map: ObstacleMap, along: number, a: number, b: number): boolean; -} - -const HORIZONTAL: Axis = { - along: (p) => p.gx, - across: (p) => p.gy, - setAcross: (p, v) => { - p.gy = v; - }, - lineFree: (map, line, a, b) => map.isFree(Math.min(a, b), line, Math.max(a, b), line), - legFree: (map, along, a, b) => map.isFree(along, Math.min(a, b), along, Math.max(a, b)) -}; - -const VERTICAL: Axis = { - along: (p) => p.gy, - across: (p) => p.gx, - setAcross: (p, v) => { - p.gx = v; - }, - lineFree: (map, line, a, b) => map.isFree(line, Math.min(a, b), line, Math.max(a, b)), - legFree: (map, along, a, b) => map.isFree(Math.min(a, b), along, Math.max(a, b), along) -}; - -/** - * Separate overlapping segments of different nets. - * Routes are modified in place. - */ -export function nudgeRoutes(routes: NudgeRoute[], map: ObstacleMap): void { - nudgePass(routes, map, HORIZONTAL); - nudgePass(routes, map, VERTICAL); - // Moving vertical segments stretches horizontal ones, which can create new overlaps - nudgePass(routes, map, HORIZONTAL); - for (const route of routes) route.points = simplifyGridPath(route.points); -} - -function nudgePass(routes: NudgeRoute[], map: ObstacleMap, axis: Axis): void { - const lines = new Map(); - - for (const route of routes) { - const pts = route.points; - const last = pts.length - 1; - for (let i = 0; i < last; i++) { - const a = pts[i]; - const b = pts[i + 1]; - const line = axis.across(a); - if (line !== axis.across(b) || axis.along(a) === axis.along(b)) continue; - - const lo = Math.min(axis.along(a), axis.along(b)); - const hi = Math.max(axis.along(a), axis.along(b)); - const fixed = - i === 0 || - i + 1 === last || - route.anchors.some((p) => axis.across(p) === line && axis.along(p) >= lo && axis.along(p) <= hi); - - let list = lines.get(line); - if (!list) { - list = []; - lines.set(line, list); - } - list.push({ route, index: i, lo, hi, fixed }); - } - } - - const lineKeys = [...lines.keys()].sort((a, b) => a - b); - for (const line of lineKeys) { - const segments = lines.get(line)!; - if (segments.length < 2) continue; - - const sorted = segments - .slice() - .sort((a, b) => a.lo - b.lo || a.hi - b.hi || compareIds(a.route.id, b.route.id) || a.index - b.index); - - let cluster: Segment[] = []; - let clusterHi = -Infinity; - for (const segment of sorted) { - if (cluster.length > 0 && segment.lo > clusterHi) { - resolveCluster(cluster, line, lines, map, axis); - cluster = []; - clusterHi = -Infinity; - } - cluster.push(segment); - clusterHi = Math.max(clusterHi, segment.hi); - } - resolveCluster(cluster, line, lines, map, axis); - } -} - -function compareIds(a: string, b: string): number { - return a < b ? -1 : a > b ? 1 : 0; -} - -function legDirection(segment: Segment, atLow: boolean, line: number, axis: Axis): number { - const pts = segment.route.points; - const a = pts[segment.index]; - const b = pts[segment.index + 1]; - const lowIsFirst = axis.along(a) <= axis.along(b); - const other = lowIsFirst === atLow ? pts[segment.index - 1] : pts[segment.index + 2]; - return other ? Math.sign(axis.across(other) - line) : 0; -} - -function buildUnits(cluster: Segment[], line: number, axis: Axis): Unit[] { - const byNet = new Map(); - for (const segment of cluster) { - const list = byNet.get(segment.route.netId); - if (list) list.push(segment); - else byNet.set(segment.route.netId, [segment]); - } - - const units: Unit[] = []; - for (const [netId, segments] of byNet) { - let low = segments[0]; - let high = segments[0]; - for (const s of segments) { - if (s.lo < low.lo) low = s; - if (s.hi > high.hi) high = s; - } - units.push({ - netId, - segments, - lo: low.lo, - hi: high.hi, - fixed: segments.some((s) => s.fixed), - loDir: legDirection(low, true, line, axis), - hiDir: legDirection(high, false, line, axis) - }); - } - return units; -} - -/** - * Order units across the line: negative means `a` belongs on the lower side. - * Legs turning towards a side pull their unit to that side; for equal turns the - * unit whose leg is further inside the shared range goes outward, which avoids - * a crossing with the other unit's leg. - */ -function compareUnits(a: Unit, b: Unit): number { - let score = 0; - if (a.loDir !== b.loDir) score += a.loDir - b.loDir; - else if (a.loDir !== 0) score += a.loDir * Math.sign(a.lo - b.lo); - if (a.hiDir !== b.hiDir) score += a.hiDir - b.hiDir; - else if (a.hiDir !== 0) score += a.hiDir * Math.sign(b.hi - a.hi); - return Math.sign(score) || compareIds(a.netId, b.netId); -} - -function resolveCluster( - cluster: Segment[], - line: number, - lines: Map, - map: ObstacleMap, - axis: Axis -): void { - if (cluster.length < 2) return; - const units = buildUnits(cluster, line, axis); - if (units.length < 2) return; - - units.sort(compareUnits); - - // Lane assignment like interval colouring: a unit only moves when it overlaps a - // unit that already holds a lane, and it moves to the side given by the order. - const placed: { unit: Unit; order: number; offset: number }[] = []; - units.forEach((unit, order) => { - if (unit.fixed) placed.push({ unit, order, offset: 0 }); - }); - - units.forEach((unit, order) => { - if (unit.fixed) return; - const overlapping = placed.filter((p) => p.unit.hi >= unit.lo && p.unit.lo <= unit.hi); - if (overlapping.length === 0) { - placed.push({ unit, order, offset: 0 }); - return; - } - const below = overlapping.every((p) => p.order > order); - const start = below - ? Math.min(...overlapping.map((p) => p.offset)) - 1 - : Math.max(...overlapping.map((p) => p.offset)) + 1; - if (start === 0 && !overlapping.some((p) => p.offset === 0)) { - placed.push({ unit, order, offset: 0 }); - return; - } - const offset = moveUnit(unit, line, start, below ? -1 : 1, lines, map, axis); - placed.push({ unit, order, offset }); - }); -} - -/** Move a unit to the nearest feasible lane from `start` outwards. Returns the offset, 0 if it stays. */ -function moveUnit( - unit: Unit, - line: number, - start: number, - side: number, - lines: Map, - map: ObstacleMap, - axis: Axis -): number { - for (let offset = start; Math.abs(offset) <= MAX_LANE; offset += side) { - if (offset === 0) continue; - const target = line + offset; - if (!isFeasible(unit, line, target, lines, map, axis)) continue; - - for (const segment of unit.segments) { - const pts = segment.route.points; - axis.setAcross(pts[segment.index], target); - axis.setAcross(pts[segment.index + 1], target); - segment.fixed = true; - - const from = lines.get(line)!; - from.splice(from.indexOf(segment), 1); - let to = lines.get(target); - if (!to) { - to = []; - lines.set(target, to); - } - to.push(segment); - } - return offset; - } - return 0; -} - -function isFeasible( - unit: Unit, - line: number, - target: number, - lines: Map, - map: ObstacleMap, - axis: Axis -): boolean { - const occupants = lines.get(target); - - for (const segment of unit.segments) { - const pts = segment.route.points; - const a = pts[segment.index]; - const b = pts[segment.index + 1]; - const before = pts[segment.index - 1]; - const after = pts[segment.index + 2]; - - // Legs keep their orientation and a non-zero length - for (const far of [before, after]) { - const farAcross = axis.across(far); - if (target === farAcross || Math.sign(line - farAcross) !== Math.sign(target - farAcross)) return false; - } - - if (!axis.lineFree(map, target, segment.lo, segment.hi)) return false; - if (!legExtensionFree(map, axis, axis.along(a), axis.across(before), line, target)) return false; - if (!legExtensionFree(map, axis, axis.along(b), axis.across(after), line, target)) return false; - - if (occupants) { - for (const other of occupants) { - if (other.route.netId !== unit.netId && other.hi >= segment.lo && other.lo <= segment.hi) return false; - } - } - } - return true; -} - -/** Check only the cells a leg gains when its end moves from `line` to `target` */ -function legExtensionFree( - map: ObstacleMap, - axis: Axis, - along: number, - far: number, - line: number, - target: number -): boolean { - if (Math.abs(target - far) <= Math.abs(line - far)) return true; - const step = Math.sign(target - line); - return axis.legFree(map, along, line + step, target); -} - -/** Remove duplicate and collinear points */ -export function simplifyGridPath(points: GridPoint[]): GridPoint[] { - const result: GridPoint[] = []; - for (const p of points) { - const last = result[result.length - 1]; - if (last && last.gx === p.gx && last.gy === p.gy) continue; - if (result.length >= 2) { - const prev = result[result.length - 2]; - if ((prev.gx === last.gx && last.gx === p.gx) || (prev.gy === last.gy && last.gy === p.gy)) { - result[result.length - 1] = p; - continue; - } - } - result.push(p); - } - return result; -} diff --git a/src/lib/routing/router.test.ts b/src/lib/routing/router.test.ts index 7cba7245..46f9c17b 100644 --- a/src/lib/routing/router.test.ts +++ b/src/lib/routing/router.test.ts @@ -56,12 +56,12 @@ describe('routing engine', () => { expect(routes.size).toBe(scene.requests.length); }); - it('separates overlapping nets into lanes', () => { + it('keeps different nets off shared grid lines', () => { const scene = buildScene(generateScenario({ connections: 400, rotated: 0.2, fanOut: 0.2, seed: 21 })); - const raw = measureRoutes(scene, routeScene(scene, { congestion: false, nudge: false, negotiate: 0 })); - const nudged = measureRoutes(scene, routeScene(scene)); - expect(raw.overlapLength).toBeGreaterThan(0); - expect(nudged.overlapLength).toBeLessThan(raw.overlapLength * 0.5); + const independent = measureRoutes(scene, routeScene(scene, { congestion: false, negotiate: 0 })); + const negotiated = measureRoutes(scene, routeScene(scene)); + expect(independent.overlapLength).toBeGreaterThan(0); + expect(negotiated.overlapLength).toBe(0); }); it('does not depend on connection order', () => { From e51cc163d4bc150ccc52fddcd3f103233f8e036e Mon Sep 17 00:00:00 2001 From: milanofthe Date: Sun, 13 Sep 2026 16:35:06 +0200 Subject: [PATCH 4/8] Skip searches for covered ports and cap search expansions --- src/lib/routing/engine.ts | 24 ++++++++++- src/lib/routing/occupancy.ts | 73 ++++++++++++++++++---------------- src/lib/routing/router.test.ts | 31 +++++++++++++++ src/lib/routing/search.ts | 28 +++++++++---- 4 files changed, 113 insertions(+), 43 deletions(-) diff --git a/src/lib/routing/engine.ts b/src/lib/routing/engine.ts index 8e9d9e20..6f8ff122 100644 --- a/src/lib/routing/engine.ts +++ b/src/lib/routing/engine.ts @@ -45,6 +45,8 @@ const NEGOTIATION_PROGRESS = 0.8; export interface RoutingEngineOptions { /** Search with congestion costs from other nets (default true) */ congestion?: boolean; + /** A* heuristic weight, see GridSearch.heuristicWeight (default 1) */ + heuristicWeight?: number; } export interface UpdateOptions { @@ -208,9 +210,11 @@ export class RoutingEngine { private readonly results = new Map(); private changedSinceUpdate = false; private readonly congestion: boolean; + private readonly heuristicWeight: number; constructor(options: RoutingEngineOptions = {}) { this.congestion = options.congestion ?? true; + this.heuristicWeight = options.heuristicWeight ?? 1; } /** Add or move a node together with its ports */ @@ -417,6 +421,19 @@ export class RoutingEngine { } } + /** True if no cell of a port ray has a passable neighbour off the ray */ + private isEnclosed(portRay: GridPoint[], dir: number): boolean { + for (let i = 0; i < portRay.length; i++) { + const cell = portRay[i]; + const isLast = i === portRay.length - 1; + for (let nd = 0; nd < 4; nd++) { + if (nd === OPPOSITE[dir] || (nd === dir && !isLast)) continue; + if (!this.map.isHard(cell.gx + DX[nd], cell.gy + DY[nd])) return false; + } + } + return true; + } + private retryFallbacks(): void { for (const id of this.fallbacks) this.dirty.add(id); } @@ -485,6 +502,8 @@ export class RoutingEngine { const exit = ray(start, startDir, PORT_EXIT_CELLS); const entry = ray(end, targetDir, PORT_EXIT_CELLS); const congestion = this.congestion ? this.occupancy : undefined; + // A port covered by another node is unreachable; skip the (exhaustive) search + const enclosed = this.isEnclosed(exit, startDir) || this.isEnclosed(entry, targetDir); const stops = request.waypoints.map((w) => ({ gx: toGrid(w.position.x), gy: toGrid(w.position.y) })); stops.push(end); @@ -500,8 +519,11 @@ export class RoutingEngine { const forced = k === 0 ? [...exit, to] : [from, to]; if (last) forced.push(...entry); - const path = searchGridPath(this.map, { + const path = enclosed + ? null + : searchGridPath(this.map, { congestion, + heuristicWeight: this.heuristicWeight, start: from, startDir: dir, end: to, diff --git a/src/lib/routing/occupancy.ts b/src/lib/routing/occupancy.ts index 0e862ba2..a06a5afc 100644 --- a/src/lib/routing/occupancy.ts +++ b/src/lib/routing/occupancy.ts @@ -13,6 +13,10 @@ const TILE_BITS = 6; const TILE_SIZE = 1 << TILE_BITS; const TILE_MASK = TILE_SIZE - 1; +/** Slots per cell: usage horizontal, usage vertical, history horizontal, history vertical */ +const SLOTS = 4; +const HISTORY_SLOT = 2; + const KEY_OFFSET = 1 << 20; const KEY_SPAN = 1 << 21; @@ -41,73 +45,74 @@ export function collectCells(points: GridPoint[], into: Set): void { } } -class TileCounts { +function decode(key: number): [number, number, Axis] { + const axis = (key % 2) as Axis; + const cell = (key - axis) / 2; + const gy = (cell % KEY_SPAN) - KEY_OFFSET; + const gx = Math.floor(cell / KEY_SPAN) - KEY_OFFSET; + return [gx, gy, axis]; +} + +export class Occupancy implements CongestionCosts { private readonly tiles = new Map(); private cachedKey = -1; private cachedTile: Uint16Array | undefined = undefined; - get(gx: number, gy: number, axis: Axis): number { + /** Cost per other net using a cell on the same axis */ + presentCost = 2; + + /** Cells of the net currently being routed; they carry no congestion cost */ + own: Set | null = null; + + private slotIndex(gx: number, gy: number): number { + return (((gy & TILE_MASK) << TILE_BITS) | (gx & TILE_MASK)) * SLOTS; + } + + private tile(gx: number, gy: number): Uint16Array | undefined { const key = tileKey(gx >> TILE_BITS, gy >> TILE_BITS); if (key !== this.cachedKey) { this.cachedKey = key; this.cachedTile = this.tiles.get(key); } - const tile = this.cachedTile; - return tile === undefined ? 0 : tile[((((gy & TILE_MASK) << TILE_BITS) | (gx & TILE_MASK)) << 1) | axis]; + return this.cachedTile; } - add(gx: number, gy: number, axis: Axis, delta: number): void { - const key = tileKey(gx >> TILE_BITS, gy >> TILE_BITS); - let tile = this.tiles.get(key); + private addSlot(gx: number, gy: number, slot: number, delta: number): void { + let tile = this.tile(gx, gy); if (!tile) { if (delta < 0) return; - tile = new Uint16Array(TILE_SIZE * TILE_SIZE * 2); - this.tiles.set(key, tile); + tile = new Uint16Array(TILE_SIZE * TILE_SIZE * SLOTS); + this.tiles.set(tileKey(gx >> TILE_BITS, gy >> TILE_BITS), tile); this.cachedKey = -1; } - const index = ((((gy & TILE_MASK) << TILE_BITS) | (gx & TILE_MASK)) << 1) | axis; + const index = this.slotIndex(gx, gy) + slot; tile[index] = Math.max(0, Math.min(0xffff, tile[index] + delta)); } -} - -function decode(key: number): [number, number, Axis] { - const axis = (key % 2) as Axis; - const cell = (key - axis) / 2; - const gy = (cell % KEY_SPAN) - KEY_OFFSET; - const gx = Math.floor(cell / KEY_SPAN) - KEY_OFFSET; - return [gx, gy, axis]; -} - -export class Occupancy implements CongestionCosts { - private readonly usage = new TileCounts(); - private readonly history = new TileCounts(); - - /** Cost per other net using a cell on the same axis */ - presentCost = 2; - - /** Cells of the net currently being routed; they carry no congestion cost */ - own: Set | null = null; add(keys: Iterable, delta: 1 | -1): void { for (const key of keys) { const [gx, gy, axis] = decode(key); - this.usage.add(gx, gy, axis, delta); + this.addSlot(gx, gy, axis, delta); } } countKey(key: number): number { const [gx, gy, axis] = decode(key); - return this.usage.get(gx, gy, axis); + const tile = this.tile(gx, gy); + return tile === undefined ? 0 : tile[this.slotIndex(gx, gy) + axis]; } bumpHistory(key: number, amount: number): void { const [gx, gy, axis] = decode(key); - this.history.add(gx, gy, axis, amount); + this.addSlot(gx, gy, HISTORY_SLOT + axis, amount); } penalty(gx: number, gy: number, axis: Axis): number { - const used = this.usage.get(gx, gy, axis); - const past = this.history.get(gx, gy, axis); + const tile = this.tile(gx, gy); + if (tile === undefined) return 0; + const index = this.slotIndex(gx, gy); + const used = tile[index + axis]; + const past = tile[index + HISTORY_SLOT + axis]; if (used === 0 && past === 0) return 0; if (this.own !== null && this.own.has(cellKey(gx, gy, axis))) return 0; return used * this.presentCost + past; diff --git a/src/lib/routing/router.test.ts b/src/lib/routing/router.test.ts index 46f9c17b..793b64d5 100644 --- a/src/lib/routing/router.test.ts +++ b/src/lib/routing/router.test.ts @@ -112,6 +112,37 @@ describe('routing engine', () => { expect(checkRoutes(buildScene(scenario), engine.getRoutes())).toEqual([]); }); + it('flags routes to covered ports and recovers when the node moves away', () => { + const scenario = generateScenario({ connections: 40, seed: 12 }); + const nodesById = new Map(scenario.nodes.map((n) => [n.id, n])); + const engine = new RoutingEngine(); + const place = (id: string) => { + const s = sceneNode(nodesById.get(id)!); + engine.setNode(id, s.bounds, s.ports); + for (const c of scenario.connections) { + if (c.sourceNodeId === id || c.targetNodeId === id) engine.setRequest(sceneRequest(c, nodesById)); + } + }; + for (const node of scenario.nodes) place(node.id); + engine.update(); + + const connection = scenario.connections[0]; + const target = nodesById.get(connection.targetNodeId)!; + const blocker = scenario.nodes.find((n) => n.id !== connection.sourceNodeId && n.id !== target.id)!; + const original = blocker.center; + + // Cover the input side of the target, including the route entry cells + blocker.center = { x: target.center.x - target.width / 2 - 20, y: target.center.y }; + place(blocker.id); + engine.update(); + expect(engine.getRoute(connection.id)!.isFallback).toBe(true); + + blocker.center = original; + place(blocker.id); + engine.update(); + expect(checkRoutes(buildScene(scenario), engine.getRoutes())).toEqual([]); + }); + it('reports only changes', () => { const scene = buildScene(generateScenario({ connections: 50, seed: 6 })); const engine = new RoutingEngine(); diff --git a/src/lib/routing/search.ts b/src/lib/routing/search.ts index 641a1ba0..45cd4e6a 100644 --- a/src/lib/routing/search.ts +++ b/src/lib/routing/search.ts @@ -22,6 +22,12 @@ const WINDOW_PADDINGS = [16, 64]; /** Upper bound for states of one search (caps memory at about 50 MB) */ const MAX_STATES = 4_000_000; +/** Upper bound for expanded states of one search window (caps time for unreachable ends) */ +const MAX_EXPANSIONS = 250_000; + +/** Counters for benchmarks and profiling */ +export const searchStats = { searches: 0, expanded: 0 }; + /** Extra step costs from other routes (negotiated congestion) */ export interface CongestionCosts { penalty(gx: number, gy: number, axis: 0 | 1): number; @@ -30,6 +36,8 @@ export interface CongestionCosts { export interface GridSearch { /** Optional congestion costs added per step */ congestion?: CongestionCosts; + /** Heuristic weight; above 1 trades bounded path cost for fewer expanded states (default 1) */ + heuristicWeight?: number; start: GridPoint; startDir: number; end: GridPoint; @@ -149,6 +157,7 @@ function searchWindow(map: ObstacleMap, request: GridSearch, win: GridRect): Gri const states = w * h * 4; if (states > MAX_STATES) return null; ensureCapacity(states); + searchStats.searches++; generation++; if (generation >= 0x7fffffff) { @@ -160,6 +169,7 @@ function searchWindow(map: ObstacleMap, request: GridSearch, win: GridRect): Gri heapSize = 0; const { start, end, endDir, forced, congestion } = request; + const weight = request.heuristicWeight ?? 1; const x0 = win.minGx; const y0 = win.minGy; @@ -175,12 +185,15 @@ function searchWindow(map: ObstacleMap, request: GridSearch, win: GridRect): Gri parent[startState] = -1; stamp[startState] = seen; const h0 = Math.abs(end.gx - start.gx) + Math.abs(end.gy - start.gy); - heapPush(startState, h0 * 65536 + h0); + heapPush(startState, weight * h0 * 65536 + h0); + let expanded = 0; while (heapSize > 0) { const s = heapPop(); if (stamp[s] === closed) continue; stamp[s] = closed; + searchStats.expanded++; + if (++expanded > MAX_EXPANSIONS) return null; const d = s & 3; const cell = s >> 2; @@ -199,12 +212,11 @@ function searchWindow(map: ObstacleMap, request: GridSearch, win: GridRect): Gri const ny = gy + DY[nd]; if (nx < win.minGx || nx > win.maxGx || ny < win.minGy || ny > win.maxGy) continue; - let cost = g + 1 + (nd === d ? 0 : TURN_COST); - if (!isForced(nx, ny)) { - const value = map.cell(nx, ny); - if (value & HARD_MASK) continue; - if (value !== 0) cost += SOFT_COST; - } + let value = map.cell(nx, ny); + if (value !== 0 && isForced(nx, ny)) value = 0; + if (value & HARD_MASK) continue; + + let cost = g + 1 + (nd === d ? 0 : TURN_COST) + (value === 0 ? 0 : SOFT_COST); if (congestion !== undefined) cost += congestion.penalty(nx, ny, nd <= 1 ? 0 : 1); if (endDir >= 0 && nd !== endDir && nx === end.gx && ny === end.gy) cost += TURN_COST; @@ -220,7 +232,7 @@ function searchWindow(map: ObstacleMap, request: GridSearch, win: GridRect): Gri const dx = end.gx - nx; const dy = end.gy - ny; const hh = Math.abs(dx) + Math.abs(dy) + TURN_COST * minTurns(dx, dy, nd); - heapPush(ns, (cost + hh) * 65536 + hh); + heapPush(ns, (cost + weight * hh) * 65536 + hh); } } From 9e57225b64bad9c3197212b2196d64f558bb1894 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Sun, 13 Sep 2026 16:58:34 +0200 Subject: [PATCH 5/8] Route edges with the incremental engine in a web worker --- .gitignore | 3 + package-lock.json | 22 - package.json | 2 - src/lib/components/FlowCanvas.svelte | 163 +--- src/lib/components/canvas/routingSync.ts | 175 ++++ .../components/edges/OrthogonalEdge.svelte | 24 +- src/lib/routing/client.test.ts | 56 ++ src/lib/routing/client.ts | 168 ++++ src/lib/routing/constants.ts | 6 - src/lib/routing/engine.ts | 22 +- src/lib/routing/gridBuilder.ts | 246 ----- src/lib/routing/index.ts | 17 +- src/lib/routing/pathOptimizer.ts | 48 - src/lib/routing/pathfinder.ts | 347 ------- src/lib/routing/protocol.ts | 35 + src/lib/routing/routeCalculator.ts | 254 ----- src/lib/routing/routing.perf.test.ts | 3 - src/lib/routing/routing.worker.ts | 13 + src/lib/routing/scene.ts | 39 + src/lib/routing/testing/invariants.ts | 10 +- src/lib/routing/testing/legacyRouter.ts | 86 -- src/lib/routing/types.ts | 10 - src/lib/stores/graph/state.ts | 23 +- src/lib/stores/routing.ts | 877 +++--------------- 24 files changed, 724 insertions(+), 1925 deletions(-) create mode 100644 src/lib/components/canvas/routingSync.ts create mode 100644 src/lib/routing/client.test.ts create mode 100644 src/lib/routing/client.ts delete mode 100644 src/lib/routing/gridBuilder.ts delete mode 100644 src/lib/routing/pathOptimizer.ts delete mode 100644 src/lib/routing/pathfinder.ts create mode 100644 src/lib/routing/protocol.ts delete mode 100644 src/lib/routing/routeCalculator.ts create mode 100644 src/lib/routing/routing.worker.ts create mode 100644 src/lib/routing/scene.ts delete mode 100644 src/lib/routing/testing/legacyRouter.ts diff --git a/.gitignore b/.gitignore index daca3259..c7b407f1 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ pathview/static/ # Generated screenshots static/examples/screenshots/ + +# Generated large models for routing checks +static/bench/ diff --git a/package-lock.json b/package-lock.json index f56b5218..f600afcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "jspdf": "^4.1.0", "katex": "^0.16.0", "opentype.js": "^1.3.4", - "pathfinding": "^0.4.18", "plotly.js-dist-min": "^2.35.0", "pyodide": "^0.26.0", "svg2pdf.js": "^2.7.0" @@ -26,7 +25,6 @@ "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@types/node": "^22.0.0", - "@types/pathfinding": "^0.1.0", "@types/plotly.js": "^3.0.8", "eslint": "^9.0.0", "eslint-plugin-svelte": "^2.0.0", @@ -1456,13 +1454,6 @@ "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", "license": "MIT" }, - "node_modules/@types/pathfinding": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@types/pathfinding/-/pathfinding-0.1.0.tgz", - "integrity": "sha512-QkKZqdAPhfkHIoiCWa/4yVolcCDin10qScbdiyqtDsY9cxlcslVgoU1g9yVdFytsHqJQevkYNcNBgD/P7OZbQg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/plotly.js": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/@types/plotly.js/-/plotly.js-3.0.8.tgz", @@ -2999,11 +2990,6 @@ "node": ">=8" } }, - "node_modules/heap": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.5.tgz", - "integrity": "sha512-G7HLD+WKcrOyJP5VQwYZNC3Z6FcQ7YYjEFiFoIj8PfEr73mu421o8B1N5DKUcc8K37EsJ2XXWA8DtrDz/2dReg==" - }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", @@ -3567,14 +3553,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathfinding": { - "version": "0.4.18", - "resolved": "https://registry.npmjs.org/pathfinding/-/pathfinding-0.4.18.tgz", - "integrity": "sha512-R0TGEQ9GRcFCDvAWlJAWC+KGJ9SLbW4c0nuZRcioVlXVTlw+F5RvXQ8SQgSqI9KXWC1ew95vgmIiyaWTlCe9Ag==", - "dependencies": { - "heap": "0.2.5" - } - }, "node_modules/pathval": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", diff --git a/package.json b/package.json index 32737697..a0675468 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@types/node": "^22.0.0", - "@types/pathfinding": "^0.1.0", "@types/plotly.js": "^3.0.8", "eslint": "^9.0.0", "eslint-plugin-svelte": "^2.0.0", @@ -52,7 +51,6 @@ "jspdf": "^4.1.0", "katex": "^0.16.0", "opentype.js": "^1.3.4", - "pathfinding": "^0.4.18", "plotly.js-dist-min": "^2.35.0", "pyodide": "^0.26.0", "svg2pdf.js": "^2.7.0" diff --git a/src/lib/components/FlowCanvas.svelte b/src/lib/components/FlowCanvas.svelte index 144d9ca9..8c3773a0 100644 --- a/src/lib/components/FlowCanvas.svelte +++ b/src/lib/components/FlowCanvas.svelte @@ -24,9 +24,9 @@ import { eventStore, setEventSelection } from '$lib/stores/events'; import { selectedNodeIds as graphSelectedNodeIds } from '$lib/stores/graph/state'; import { historyStore } from '$lib/stores/history'; - import { routingStore, buildRoutingContext, type PortInfo } from '$lib/stores/routing'; - import { getPortInfo as computePortInfo, type PortStub } from '$lib/routing'; - import type { RotationValue } from '$lib/types/common'; + import { routingStore } from '$lib/stores/routing'; + import type { Bounds } from '$lib/routing'; + import type { Position } from '$lib/types/common'; import { themeStore, type Theme } from '$lib/stores/theme'; import { clearSelectionTrigger, nudgeTrigger, selectNodeTrigger, registerHasSelection, triggerFitView } from '$lib/stores/viewActions'; import { screenToFlow } from '$lib/utils/viewUtils'; @@ -37,7 +37,7 @@ import { nodeRegistry } from '$lib/nodes'; import { NODE_TYPES } from '$lib/constants/nodeTypes'; import { GRID_SIZE, SNAP_GRID, BACKGROUND_GAP } from '$lib/constants/grid'; - import { DEFAULT_NODE_WIDTH, DEFAULT_NODE_HEIGHT } from '$lib/constants/dimensions'; + import { createRoutingSync } from './canvas/routingSync'; import { shallowEqualArray, shallowEqualRecord } from '$lib/utils/shallowEqual'; import type { NodeInstance, Connection, Annotation } from '$lib/nodes/types'; import type { EventInstance } from '$lib/events/types'; @@ -73,7 +73,7 @@ if (routingContextTimer !== null) clearTimeout(routingContextTimer); routingContextTimer = setTimeout(() => { routingContextTimer = null; - updateRoutingContext(); + routingSync.syncAll(); }, 0); } @@ -112,8 +112,7 @@ const gridSize = 10; const snappedX = Math.round(flowPos.x / gridSize) * gridSize; const snappedY = Math.round(flowPos.y / gridSize) * gridSize; - // Pass getPortInfo for immediate single-route recalculation (no full recalc needed) - routingStore.addUserWaypoint(selectedEdge.id, { x: snappedX, y: snappedY }, getPortInfo); + routingStore.addUserWaypoint(selectedEdge.id, { x: snappedX, y: snappedY }); } return; } @@ -136,11 +135,8 @@ if (updatedNodes.length > 0) { pendingNodeUpdates = [...updatedNodes]; - // Recalculate routes for rotated/flipped nodes after FlowUpdater processes - setTimeout(() => { - const connections = get(graphStore.connections); - routingStore.recalculateRoutesForNodes(new Set(updatedNodes), connections, getPortInfo); - }, 0); + // Report rotated/flipped nodes to routing after FlowUpdater processes + setTimeout(() => routingSync.syncNodes(updatedNodes), 0); } } @@ -271,54 +267,21 @@ pendingNodeUpdates = []; } - // Port handle tip position and direction in world coordinates - function getPortInfo(nodeId: string, portIndex: number, isOutput: boolean): PortInfo | null { - const node = nodeMap.get(nodeId); - if (!node) return null; - - const nodeData = node.data as NodeInstance; - const ports = isOutput ? nodeData.outputs : nodeData.inputs; - if (portIndex >= ports.length) return null; - - const rotation = ((nodeData.params?.['_rotation'] as number) || 0) as RotationValue; - const width = node.measured?.width ?? node.width ?? DEFAULT_NODE_WIDTH; - const height = node.measured?.height ?? node.height ?? DEFAULT_NODE_HEIGHT; - - return computePortInfo(node.position, width, height, rotation, portIndex, ports.length, isOutput); - } - - // Update routing context and recalculate all routes - function updateRoutingContext() { - // Only include block nodes (not events or annotations) for routing - const blockNodesForRouting = nodes.filter(n => n.type === 'pathview'); - if (blockNodesForRouting.length === 0) { - routingStore.clearRoutes(); - return; - } - - const { nodeBounds, canvasBounds } = buildRoutingContext(blockNodesForRouting); - - // Collect all port stubs for obstacle marking - const portStubs: PortStub[] = []; - for (const node of blockNodesForRouting) { - const nodeData = node.data as NodeInstance; - // Collect input port stubs - for (let i = 0; i < nodeData.inputs.length; i++) { - const info = getPortInfo(node.id, i, false); - if (info) portStubs.push({ position: info.position, direction: info.direction }); - } - // Collect output port stubs - for (let i = 0; i < nodeData.outputs.length; i++) { - const info = getPortInfo(node.id, i, true); - if (info) portStubs.push({ position: info.position, direction: info.direction }); - } - } - - routingStore.setContext(nodeBounds, canvasBounds, portStubs); + // Routing: the scene is diffed here and routed by the routing engine in a worker + const routingSync = createRoutingSync({ + blockNodes: () => nodes.filter((n) => n.type === 'pathview'), + node: (id) => nodeMap.get(id), + connections: () => get(graphStore.connections), + visibleBounds + }); - // Recalculate all routes - const connections = get(graphStore.connections); - routingStore.recalculateAllRoutes(connections, getPortInfo); + // Visible canvas area in flow coordinates + function visibleBounds(): Bounds | null { + if (!canvasEl) return null; + const rect = canvasEl.getBoundingClientRect(); + const topLeft = screenToFlow({ x: rect.left, y: rect.top }); + const bottomRight = screenToFlow({ x: rect.right, y: rect.bottom }); + return { x: topLeft.x, y: topLeft.y, width: bottomRight.x - topLeft.x, height: bottomRight.y - topLeft.y }; } // Custom node types - will add more for different shapes @@ -362,10 +325,8 @@ }); }); - // Reroute connections when a block's measured size changes (icon-mode toggle, - // pinned-params, name-length, port-label visibility, …). The initial - // measurement after mount is recorded silently — only later changes trigger - // a recalculation. + // Report measured block sizes to routing: the first measurement after mount and + // later changes (icon-mode toggle, pinned params, name length, port labels) const lastMeasuredDims = new Map(); $effect(() => { const changed = new Set(); @@ -376,31 +337,19 @@ if (w === undefined || h === undefined) continue; const last = lastMeasuredDims.get(node.id); if (!last || last.w !== w || last.h !== h) { - if (last) changed.add(node.id); + changed.add(node.id); lastMeasuredDims.set(node.id, { w, h }); - routingStore.updateNodeBounds(node.id, { - x: node.position.x - w / 2, - y: node.position.y - h / 2, - width: w, - height: h - }); } } - if (changed.size > 0) { - const connections = get(graphStore.connections); - routingStore.recalculateRoutesForNodes(changed, connections, getPortInfo); - } + if (changed.size > 0) untrack(() => routingSync.syncNodes(changed)); }); - // On model load, the initial routing pass runs with fallback 80×40 - // dimensions because nodes haven't been measured yet, and the per-node - // $effect above silently records first measurements without rerouting. - // Trigger a full reroute once measurements have settled. + // On model load, resync once the assembly animation has settled all measurements let lastAssemblyTrigger = 0; const unsubAssembly = assemblyAnimationTrigger.subscribe((v) => { if (v > lastAssemblyTrigger) { lastAssemblyTrigger = v; - setTimeout(() => updateRoutingContext(), 400); + setTimeout(() => routingSync.syncAll(), 400); } }); onDestroy(() => unsubAssembly()); @@ -579,15 +528,14 @@ } blockNodes = updatedNodes; + // Routing obstacles follow added and removed blocks + scheduleRoutingUpdate(); // Queue node internal updates for nodes with changed ports/rotation if (nodesToUpdate.length > 0) { pendingNodeUpdates = [...nodesToUpdate]; - // Recalculate routes for affected nodes after FlowUpdater processes - setTimeout(() => { - const connections = get(graphStore.connections); - routingStore.recalculateRoutesForNodes(new Set(nodesToUpdate), connections, getPortInfo); - }, 0); + // Report affected nodes to routing after FlowUpdater processes + setTimeout(() => routingSync.syncNodes(nodesToUpdate), 0); } // Mark initial load as complete after first non-empty sync @@ -615,8 +563,8 @@ cleanups.push(graphStore.currentPath.subscribe((path) => { currentPath = path; updateEventNodes(); - // Clear grid and routes when navigating - forces full rebuild for new context - routingStore.clearContext(); + // Routes belong to one graph level; start over when navigating + routingSync.reset(); })); // Subscribe to root-level events (eventStore) @@ -691,39 +639,28 @@ } } - // Handle node drag - reroute at discrete grid positions (only affected routes) + // Handle node drag - report blocks that reached a new grid position to routing function handleNodeDrag({ nodes: draggedNodes }: { nodes: Node[] }) { - // Check if any node moved to a new grid position - const changedNodeIds = new Set(); + const positions = new Map(); + const moved: string[] = []; for (const node of draggedNodes) { - // Skip non-block nodes (events, annotations don't affect routing) + // Events and annotations don't affect routing if (node.type !== 'pathview') continue; - const snappedX = Math.round(node.position.x / GRID_SIZE) * GRID_SIZE; - const snappedY = Math.round(node.position.y / GRID_SIZE) * GRID_SIZE; - const lastPos = lastDraggedPositions.get(node.id); + const snapped = { + x: Math.round(node.position.x / GRID_SIZE) * GRID_SIZE, + y: Math.round(node.position.y / GRID_SIZE) * GRID_SIZE + }; + positions.set(node.id, snapped); - if (!lastPos || lastPos.x !== snappedX || lastPos.y !== snappedY) { - lastDraggedPositions.set(node.id, { x: snappedX, y: snappedY }); - changedNodeIds.add(node.id); - - // Incrementally update grid obstacle for this node - const width = node.measured?.width ?? node.width ?? DEFAULT_NODE_WIDTH; - const height = node.measured?.height ?? node.height ?? DEFAULT_NODE_HEIGHT; - routingStore.updateNodeBounds(node.id, { - x: snappedX - width / 2, - y: snappedY - height / 2, - width, - height - }); + const lastPos = lastDraggedPositions.get(node.id); + if (!lastPos || lastPos.x !== snapped.x || lastPos.y !== snapped.y) { + lastDraggedPositions.set(node.id, snapped); + moved.push(node.id); } } - // Only recalculate routes connected to moved nodes - if (changedNodeIds.size > 0) { - const connections = get(graphStore.connections); - routingStore.recalculateRoutesForNodes(changedNodeIds, connections, getPortInfo); - } + if (moved.length > 0) routingSync.syncNodes(moved, positions); } // Handle node drag end - sync position back to store and finalize undo entry @@ -749,8 +686,8 @@ } historyStore.endDrag(); - // Update routing context and recalculate routes (final) - updateRoutingContext(); + // Report final positions to routing + scheduleRoutingUpdate(); } // Handle node and edge delete diff --git a/src/lib/components/canvas/routingSync.ts b/src/lib/components/canvas/routingSync.ts new file mode 100644 index 00000000..0fb676ea --- /dev/null +++ b/src/lib/components/canvas/routingSync.ts @@ -0,0 +1,175 @@ +/** + * Routing sync - reports the canvas scene to the routing store + * + * Builds routing input from SvelteFlow block nodes and graph connections. Node + * geometry is diffed on every sync and connections by identity, so only changed + * nodes and the connections attached to them are sent to the routing engine. + */ + +import type { Node } from '@xyflow/svelte'; +import type { Position, RotationValue } from '$lib/types/common'; +import type { Connection, NodeInstance } from '$lib/nodes/types'; +import type { Bounds, PortInfo, PortStub, RouteRequest, SceneNode } from '$lib/routing'; +import { getPortInfo } from '$lib/routing'; +import { DEFAULT_NODE_WIDTH, DEFAULT_NODE_HEIGHT } from '$lib/constants/dimensions'; +import { routingStore } from '$lib/stores/routing'; + +export interface RoutingSyncSource { + /** Block nodes currently shown on the canvas */ + blockNodes(): Node[]; + node(id: string): Node | undefined; + /** Connections of the current graph level */ + connections(): Connection[]; + /** Visible canvas area in flow coordinates, routed first */ + visibleBounds(): Bounds | null; +} + +function nodeSize(node: Node): { width: number; height: number } { + return { + width: node.measured?.width ?? node.width ?? DEFAULT_NODE_WIDTH, + height: node.measured?.height ?? node.height ?? DEFAULT_NODE_HEIGHT + }; +} + +function nodeRotation(node: Node): RotationValue { + return (((node.data as NodeInstance).params?.['_rotation'] as number) || 0) as RotationValue; +} + +/** Port handle tip of a block node, optionally at an overridden position */ +export function blockPortInfo( + node: Node, + index: number, + isOutput: boolean, + position: Position = node.position +): PortInfo | null { + const data = node.data as NodeInstance; + const count = isOutput ? data.outputs.length : data.inputs.length; + if (index >= count) return null; + const { width, height } = nodeSize(node); + return getPortInfo(position, width, height, nodeRotation(node), index, count, isOutput); +} + +function sceneNodeOf(node: Node, position: Position = node.position): SceneNode { + const data = node.data as NodeInstance; + const { width, height } = nodeSize(node); + const rotation = nodeRotation(node); + const ports: PortStub[] = []; + for (let i = 0; i < data.inputs.length; i++) { + ports.push(getPortInfo(position, width, height, rotation, i, data.inputs.length, false)); + } + for (let i = 0; i < data.outputs.length; i++) { + ports.push(getPortInfo(position, width, height, rotation, i, data.outputs.length, true)); + } + return { bounds: { x: position.x - width / 2, y: position.y - height / 2, width, height }, ports }; +} + +export function createRoutingSync(source: RoutingSyncSource) { + /** Connections as last reported, compared by identity */ + let routed = new Map(); + let byNode = new Map(); + + function indexByNode(connections: Connection[]): void { + byNode = new Map(); + for (const c of connections) { + for (const id of [c.sourceNodeId, c.targetNodeId]) { + const list = byNode.get(id); + if (list) list.push(c); + else byNode.set(id, [c]); + } + } + } + + function collectAttached(nodeIds: Iterable, into: Map): void { + for (const id of nodeIds) { + for (const c of byNode.get(id) ?? []) into.set(c.id, c); + } + } + + function requestOf(c: Connection, positions?: Map): RouteRequest | null { + const sourceNode = source.node(c.sourceNodeId); + const targetNode = source.node(c.targetNodeId); + if (!sourceNode || !targetNode) return null; + const sourcePort = blockPortInfo(sourceNode, c.sourcePortIndex, true, positions?.get(sourceNode.id)); + const targetPort = blockPortInfo(targetNode, c.targetPortIndex, false, positions?.get(targetNode.id)); + if (!sourcePort || !targetPort) return null; + return { + id: c.id, + netId: `${c.sourceNodeId}:${c.sourcePortIndex}`, + source: sourcePort, + target: targetPort, + waypoints: (c.waypoints ?? []).filter((w) => w.isUserWaypoint) + }; + } + + function requestsOf(connections: Iterable, positions?: Map): RouteRequest[] { + const requests: RouteRequest[] = []; + for (const c of connections) { + const request = requestOf(c, positions); + if (request) requests.push(request); + } + return requests; + } + + return { + /** Compare the whole canvas with what the routing engine knows and send the differences */ + syncAll(): void { + const nodes = source.blockNodes(); + const present = new Set(nodes.map((n) => n.id)); + const { changed, removed } = routingStore.diffNodes( + nodes.map((n) => [n.id, sceneNodeOf(n)]), + true + ); + + const connections = source + .connections() + .filter((c) => present.has(c.sourceNodeId) && present.has(c.targetNodeId)); + const next = new Map(connections.map((c) => [c.id, c])); + const affected = new Map(); + for (const c of connections) { + if (routed.get(c.id) !== c) affected.set(c.id, c); + } + const removedRequests = [...routed.keys()].filter((id) => !next.has(id)); + routed = next; + indexByNode(connections); + collectAttached( + changed.map(([id]) => id), + affected + ); + + routingStore.send( + { nodes: changed, removedNodes: removed, requests: requestsOf(affected.values()), removedRequests }, + source.visibleBounds() + ); + }, + + /** Send nodes at (possibly dragged) positions together with their connections */ + syncNodes(ids: Iterable, positions?: Map): void { + const entries: [string, SceneNode][] = []; + for (const id of ids) { + const node = source.node(id); + if (node?.type === 'pathview') entries.push([id, sceneNodeOf(node, positions?.get(id))]); + } + const { changed } = routingStore.diffNodes(entries, false); + if (changed.length === 0) return; + + const affected = new Map(); + collectAttached( + changed.map(([id]) => id), + affected + ); + routingStore.send({ + nodes: changed, + removedNodes: [], + requests: requestsOf(affected.values(), positions), + removedRequests: [] + }); + }, + + /** Forget the scene, e.g. when navigating into a subsystem */ + reset(): void { + routed = new Map(); + byNode = new Map(); + routingStore.reset(); + } + }; +} diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index d3fb816c..3cba005b 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -25,7 +25,7 @@ import { historyStore } from '$lib/stores/history'; import { screenToFlow } from '$lib/utils/viewUtils'; import { GRID_SIZE, EDGE_SOURCE_OFFSET, EDGE_TARGET_OFFSET, EDGE_CORNER_RADIUS } from '$lib/routing/constants'; - import type { RouteResult, Direction } from '$lib/routing'; + import type { Direction } from '$lib/routing'; import type { Waypoint } from '$lib/types/nodes'; let { @@ -107,7 +107,7 @@ } activeDrag.lastSnappedPos = snappedPos; - routingStore.moveWaypoint(activeDrag.edgeId, activeDrag.waypointId, snappedPos, activeDrag.getPortInfo); + routingStore.moveWaypoint(activeDrag.edgeId, activeDrag.waypointId, snappedPos); }; const onUp = (e: PointerEvent) => { @@ -147,20 +147,11 @@ function handleWaypointDoubleClick(event: MouseEvent, waypoint: Waypoint) { event.stopPropagation(); event.preventDefault(); - routingStore.removeUserWaypoint(id, waypoint.id, getPortInfo); + routingStore.removeUserWaypoint(id, waypoint.id); } - // Get cached route from routing store - let routeResult = $state(null); - let unsubscribeRoute: (() => void) | null = null; - - // Subscribe to route changes using $effect to capture id reactively - $effect(() => { - // Unsubscribe from previous if any - if (unsubscribeRoute) unsubscribeRoute(); - // Subscribe to new route - unsubscribeRoute = routingStore.getRoute(id).subscribe((r) => (routeResult = r)); - }); + // Route from the routing store, reactive for this connection only + const routeResult = $derived(routingStore.route(id) ?? null); // Check if this edge is connected to the hovered handle let hovered = $state<{ nodeId: string; handleId: string; color?: string } | null>(null); @@ -172,7 +163,6 @@ // Cleanup all subscriptions on destroy onDestroy(() => { - if (unsubscribeRoute) unsubscribeRoute(); unsubscribeHovered(); unsubscribeSelected(); }); @@ -385,7 +375,7 @@ const insertIndex = countWaypointsBeforeSegment(segmentIndex, waypoints); // Create waypoint at correct position in the array - const waypointId = routingStore.addUserWaypointAtIndex(id, snappedPos, insertIndex, getPortInfo); + const waypointId = routingStore.addUserWaypointAtIndex(id, snappedPos, insertIndex); if (waypointId) { // Set up drag using the same module-level mechanism as waypoint drag const onMove = (e: PointerEvent) => { @@ -405,7 +395,7 @@ } activeDrag.lastSnappedPos = snap; - routingStore.moveWaypoint(activeDrag.edgeId, activeDrag.waypointId, snap, activeDrag.getPortInfo); + routingStore.moveWaypoint(activeDrag.edgeId, activeDrag.waypointId, snap); }; const onUp = (e: PointerEvent) => { diff --git a/src/lib/routing/client.test.ts b/src/lib/routing/client.test.ts new file mode 100644 index 00000000..3cf25d3a --- /dev/null +++ b/src/lib/routing/client.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import type { RoutingResponse } from './protocol'; +import { RoutingClient } from './client'; +import { generateScenario, buildScene } from './testing/scenario'; + +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('routing client (without worker)', () => { + const scene = buildScene(generateScenario({ connections: 30, seed: 4 })); + const nodes = [...scene.nodes]; + + it('routes sent changes and merges changes queued while busy', async () => { + const responses: RoutingResponse[] = []; + const client = new RoutingClient((r) => responses.push(r)); + + client.send({ nodes, removedNodes: [], requests: scene.requests.slice(0, 10), removedRequests: [] }); + client.send({ nodes: [], removedNodes: [], requests: scene.requests.slice(10, 20), removedRequests: [] }); + client.send({ nodes: [], removedNodes: [], requests: scene.requests.slice(20), removedRequests: [] }); + await settle(); + await settle(); + + // First delta goes out immediately, the two queued ones are merged into one + expect(responses.length).toBe(2); + const routed = new Set(responses.flatMap((r) => r.changed.map(([id]) => id))); + expect(routed.size).toBe(scene.requests.length); + client.dispose(); + }); + + it('drops responses from before a reset', async () => { + const responses: RoutingResponse[] = []; + const client = new RoutingClient((r) => responses.push(r)); + + client.send({ nodes, removedNodes: [], requests: scene.requests, removedRequests: [] }); + client.reset(); + await settle(); + await settle(); + + expect(responses.every((r) => r.changed.length === 0)).toBe(true); + client.dispose(); + }); + + it('reports removed connections', async () => { + const responses: RoutingResponse[] = []; + const client = new RoutingClient((r) => responses.push(r)); + + client.send({ nodes, removedNodes: [], requests: scene.requests, removedRequests: [] }); + await settle(); + client.send({ nodes: [], removedNodes: [], requests: [], removedRequests: [scene.requests[0].id] }); + await settle(); + + expect(responses[responses.length - 1].removed).toEqual([scene.requests[0].id]); + client.dispose(); + }); +}); diff --git a/src/lib/routing/client.ts b/src/lib/routing/client.ts new file mode 100644 index 00000000..de70894f --- /dev/null +++ b/src/lib/routing/client.ts @@ -0,0 +1,168 @@ +/** + * Routing client - runs the routing engine in a web worker + * + * Sends scene differences and receives changed routes. While the worker is + * busy, further differences are merged, so it never works through a backlog. + * Once changes settle, a negotiation pass refines overlapping routes. Without + * worker support (tests) the engine runs on the calling thread. + */ + +import type { RouteRequest, SceneNode } from './types'; +import { applyDelta, type RoutingDelta, type RoutingResponse } from './protocol'; +import { RoutingEngine } from './engine'; + +/** Time without changes before overlapping nets are negotiated (ms) */ +const REFINE_DELAY = 300; + +/** Negotiation iterations of a refinement pass */ +const REFINE_ITERATIONS = 3; + +export type RoutingChanges = Pick; + +/** Differences waiting for the worker; later changes to the same id win */ +class PendingDelta { + reset = false; + negotiate = 0; + readonly nodes = new Map(); + readonly removedNodes = new Set(); + readonly requests = new Map(); + readonly removedRequests = new Set(); + + get hasChanges(): boolean { + return ( + this.nodes.size > 0 || this.removedNodes.size > 0 || this.requests.size > 0 || this.removedRequests.size > 0 + ); + } + + get isEmpty(): boolean { + return !this.reset && this.negotiate === 0 && !this.hasChanges; + } + + merge(changes: RoutingChanges): void { + for (const id of changes.removedNodes) { + this.nodes.delete(id); + this.removedNodes.add(id); + } + for (const [id, node] of changes.nodes) { + this.removedNodes.delete(id); + this.nodes.set(id, node); + } + for (const id of changes.removedRequests) { + this.requests.delete(id); + this.removedRequests.add(id); + } + for (const request of changes.requests) { + this.removedRequests.delete(request.id); + this.requests.set(request.id, request); + } + } + + clear(): void { + this.negotiate = 0; + this.nodes.clear(); + this.removedNodes.clear(); + this.requests.clear(); + this.removedRequests.clear(); + } + + take(epoch: number): RoutingDelta { + const delta: RoutingDelta = { + epoch, + reset: this.reset, + nodes: [...this.nodes], + removedNodes: [...this.removedNodes], + requests: [...this.requests.values()], + removedRequests: [...this.removedRequests], + negotiate: this.negotiate + }; + this.reset = false; + this.clear(); + return delta; + } +} + +export class RoutingClient { + private worker: Worker | null = null; + private local: RoutingEngine | null = null; + private busy = false; + private epoch = 0; + private readonly pending = new PendingDelta(); + private refineTimer: ReturnType | null = null; + private needsRefine = false; + + constructor(private readonly onRoutes: (response: RoutingResponse) => void) {} + + /** Queue scene changes; they are routed without negotiation for a fast response */ + send(changes: RoutingChanges): void { + this.pending.merge(changes); + if (!this.pending.hasChanges) return; + this.needsRefine = true; + this.cancelRefine(); + this.flush(); + } + + /** Drop the scene, e.g. when the canvas shows a different graph level */ + reset(): void { + this.epoch++; + this.pending.clear(); + this.pending.reset = true; + this.needsRefine = false; + this.cancelRefine(); + this.flush(); + } + + dispose(): void { + this.cancelRefine(); + this.worker?.terminate(); + this.worker = null; + } + + private flush(): void { + if (this.busy || this.pending.isEmpty) return; + const delta = this.pending.take(this.epoch); + this.busy = true; + + const worker = this.ensureWorker(); + if (worker) { + worker.postMessage(delta); + return; + } + queueMicrotask(() => { + if (delta.reset || !this.local) this.local = new RoutingEngine(); + this.receive(applyDelta(this.local, delta)); + }); + } + + private receive(response: RoutingResponse): void { + this.busy = false; + if (response.epoch === this.epoch) this.onRoutes(response); + if (!this.pending.isEmpty) { + this.flush(); + } else if (this.needsRefine) { + this.scheduleRefine(); + } + } + + private ensureWorker(): Worker | null { + if (this.worker) return this.worker; + if (typeof Worker === 'undefined') return null; + this.worker = new Worker(new URL('./routing.worker.ts', import.meta.url), { type: 'module' }); + this.worker.onmessage = (event: MessageEvent) => this.receive(event.data); + return this.worker; + } + + private scheduleRefine(): void { + this.cancelRefine(); + this.refineTimer = setTimeout(() => { + this.refineTimer = null; + this.needsRefine = false; + this.pending.negotiate = REFINE_ITERATIONS; + this.flush(); + }, REFINE_DELAY); + } + + private cancelRefine(): void { + if (this.refineTimer !== null) clearTimeout(this.refineTimer); + this.refineTimer = null; + } +} diff --git a/src/lib/routing/constants.ts b/src/lib/routing/constants.ts index cf90adca..ab5e67d8 100644 --- a/src/lib/routing/constants.ts +++ b/src/lib/routing/constants.ts @@ -22,18 +22,12 @@ export const HANDLE_OFFSET = G.unit / 2; /** Arrow head length - stub should start within arrowhead (0.25 grid units = 2.5px) */ export const ARROW_INSET = G.unit / 4; -/** How many routes to recompute synchronously before yielding to the browser. */ -export const ASYNC_BATCH_SIZE = 8; - /** Merge user waypoints closer than this many pixels. */ export const WAYPOINT_MERGE_THRESHOLD = 15; /** Treat three waypoints as collinear if the middle one is within this many pixels of the line. */ export const WAYPOINT_COLLINEAR_THRESHOLD = 5; -/** Default routing-context padding around node bounds (in pixels). */ -export const ROUTING_CONTEXT_PADDING = 100; - /** * Sub-pixel offsets that pull the visible edge endpoint slightly out of the * handle hitbox so the rounded corner geometry doesn't overlap the port. diff --git a/src/lib/routing/engine.ts b/src/lib/routing/engine.ts index 6f8ff122..bea60eb6 100644 --- a/src/lib/routing/engine.ts +++ b/src/lib/routing/engine.ts @@ -11,6 +11,7 @@ import type { Bounds, PortInfo, PortStub, RouteRequest, RouteResult, RoutingScen import { ObstacleMap } from './obstacleMap'; import { Occupancy, collectCells } from './occupancy'; import { searchGridPath } from './search'; +import { sameRequest, sameWaypoints } from './scene'; import { DIRECTION_INDEX, DX, @@ -129,27 +130,6 @@ function samePoints(a: GridPoint[], b: GridPoint[]): boolean { return true; } -function samePort(a: PortInfo, b: PortInfo): boolean { - return a.direction === b.direction && a.position.x === b.position.x && a.position.y === b.position.y; -} - -function sameWaypoints(a: RouteRequest['waypoints'], b: RouteRequest['waypoints']): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i].position.x !== b[i].position.x || a[i].position.y !== b[i].position.y) return false; - } - return true; -} - -function sameRequest(a: RouteRequest, b: RouteRequest): boolean { - return ( - a.netId === b.netId && - samePort(a.source, b.source) && - samePort(a.target, b.target) && - sameWaypoints(a.waypoints, b.waypoints) - ); -} - function sameResult(a: RouteResult, b: RouteResult): boolean { if (a.isFallback !== b.isFallback || a.path.length !== b.path.length) return false; if (!sameWaypoints(a.waypoints, b.waypoints)) return false; diff --git a/src/lib/routing/gridBuilder.ts b/src/lib/routing/gridBuilder.ts deleted file mode 100644 index 8d7675fb..00000000 --- a/src/lib/routing/gridBuilder.ts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * Sparse grid for pathfinding - stores only obstacles, computes walkability on demand - * Supports incremental updates for efficient node dragging - * - * Performance: spatial hash uses numeric bucket keys to avoid string GC - * in the hot isWalkableAt path (called thousands of times per A* run). - */ - -import type { RoutingContext, Bounds, PortStub } from './types'; -import { DIRECTION_VECTORS } from './types'; -import { GRID_SIZE, ROUTING_MARGIN } from './constants'; - -/** Size of spatial hash buckets (in grid cells) */ -const SPATIAL_BUCKET_SIZE = 10; - -/** - * Encode bucket coordinates into a single number. - * Bucket coords are small (typically -50..+50), so 10_000 offset is plenty. - */ -function encodeBucket(bx: number, by: number): number { - return (bx + 10_000) * 20_001 + (by + 10_000); -} - -/** - * Convert world coordinates to grid coordinates - * Since everything is grid-aligned, this is a simple division - */ -export function worldToGrid(x: number): number { - return Math.round(x / GRID_SIZE); -} - -/** - * Convert grid coordinates back to world coordinates - */ -export function gridToWorld(gx: number): number { - return gx * GRID_SIZE; -} - -/** - * Obstacle in grid coordinates (inclusive bounds) - */ -interface GridObstacle { - minGx: number; - minGy: number; - maxGx: number; - maxGy: number; -} - -/** - * Convert world bounds to grid obstacle - */ -function boundsToObstacle(bounds: Bounds, offsetX: number, offsetY: number): GridObstacle { - // Add margin around node - const marginBounds = { - x: bounds.x - ROUTING_MARGIN, - y: bounds.y - ROUTING_MARGIN, - width: bounds.width + 2 * ROUTING_MARGIN, - height: bounds.height + 2 * ROUTING_MARGIN - }; - - return { - minGx: worldToGrid(marginBounds.x - offsetX), - minGy: worldToGrid(marginBounds.y - offsetY), - maxGx: worldToGrid(marginBounds.x + marginBounds.width - offsetX), - maxGy: worldToGrid(marginBounds.y + marginBounds.height - offsetY) - }; -} - -/** - * Sparse grid that computes walkability on-demand from obstacle list - * No matrix storage - O(obstacles) memory instead of O(width x height) - * Supports incremental updates - O(1) to update a single node - * Effectively unbounded - only obstacles block movement - */ -export class SparseGrid { - offsetX: number = 0; - offsetY: number = 0; - - /** Node obstacles keyed by node ID for O(1) updates */ - private nodeObstacles: Map = new Map(); - - /** Port stub obstacles (rebuilt when stubs change) */ - private portStubObstacles: GridObstacle[] = []; - - /** Spatial hash: numeric bucket key -> set of node IDs with obstacles in that bucket */ - private spatialHash: Map> = new Map(); - - constructor(context?: RoutingContext) { - if (context) { - this.initFromContext(context); - } - } - - /** Get numeric bucket key for a grid coordinate */ - private getBucketKey(gx: number, gy: number): number { - const bx = Math.floor(gx / SPATIAL_BUCKET_SIZE); - const by = Math.floor(gy / SPATIAL_BUCKET_SIZE); - return encodeBucket(bx, by); - } - - /** Get all bucket keys that an obstacle overlaps */ - private getObstacleBuckets(obs: GridObstacle): number[] { - const keys: number[] = []; - const minBx = Math.floor(obs.minGx / SPATIAL_BUCKET_SIZE); - const maxBx = Math.floor(obs.maxGx / SPATIAL_BUCKET_SIZE); - const minBy = Math.floor(obs.minGy / SPATIAL_BUCKET_SIZE); - const maxBy = Math.floor(obs.maxGy / SPATIAL_BUCKET_SIZE); - - for (let bx = minBx; bx <= maxBx; bx++) { - for (let by = minBy; by <= maxBy; by++) { - keys.push(encodeBucket(bx, by)); - } - } - return keys; - } - - /** Add a node to the spatial hash */ - private addToSpatialHash(nodeId: string, obs: GridObstacle): void { - for (const key of this.getObstacleBuckets(obs)) { - if (!this.spatialHash.has(key)) { - this.spatialHash.set(key, new Set()); - } - this.spatialHash.get(key)!.add(nodeId); - } - } - - /** Remove a node from the spatial hash */ - private removeFromSpatialHash(nodeId: string, obs: GridObstacle): void { - for (const key of this.getObstacleBuckets(obs)) { - this.spatialHash.get(key)?.delete(nodeId); - } - } - - /** - * Initialize grid from full context (used on first load) - */ - private initFromContext(context: RoutingContext): void { - const { canvasBounds } = context; - - // Snap offset to grid (used for coordinate conversion) - this.offsetX = Math.floor(canvasBounds.x / GRID_SIZE) * GRID_SIZE; - this.offsetY = Math.floor(canvasBounds.y / GRID_SIZE) * GRID_SIZE; - - // Build obstacle map from node bounds and spatial hash - this.nodeObstacles.clear(); - this.spatialHash.clear(); - for (const [nodeId, bounds] of context.nodeBounds) { - const obs = boundsToObstacle(bounds, this.offsetX, this.offsetY); - this.nodeObstacles.set(nodeId, obs); - this.addToSpatialHash(nodeId, obs); - } - - // Build port stub obstacles - this.updatePortStubs(context.portStubs); - } - - /** - * Update canvas bounds (call when nodes are added/removed or canvas resizes) - */ - updateBounds(canvasBounds: Bounds): void { - this.offsetX = Math.floor(canvasBounds.x / GRID_SIZE) * GRID_SIZE; - this.offsetY = Math.floor(canvasBounds.y / GRID_SIZE) * GRID_SIZE; - } - - /** - * Update a single node's obstacle - O(1) - */ - updateNode(nodeId: string, bounds: Bounds): void { - // Remove from old buckets if exists - const oldObs = this.nodeObstacles.get(nodeId); - if (oldObs) { - this.removeFromSpatialHash(nodeId, oldObs); - } - - // Add new obstacle - const newObs = boundsToObstacle(bounds, this.offsetX, this.offsetY); - this.nodeObstacles.set(nodeId, newObs); - - // Add to new buckets - this.addToSpatialHash(nodeId, newObs); - } - - /** - * Remove a node's obstacle - O(1) - */ - removeNode(nodeId: string): void { - const obs = this.nodeObstacles.get(nodeId); - if (obs) { - this.removeFromSpatialHash(nodeId, obs); - } - this.nodeObstacles.delete(nodeId); - } - - /** - * Update port stub obstacles (called when connections change) - */ - updatePortStubs(portStubs?: PortStub[]): void { - this.portStubObstacles = []; - if (portStubs) { - for (const stub of portStubs) { - const vec = DIRECTION_VECTORS[stub.direction]; - const stubX = stub.position.x + vec.x * GRID_SIZE; - const stubY = stub.position.y + vec.y * GRID_SIZE; - const gx = worldToGrid(stubX - this.offsetX); - const gy = worldToGrid(stubY - this.offsetY); - this.portStubObstacles.push({ minGx: gx, minGy: gy, maxGx: gx, maxGy: gy }); - } - } - } - - /** - * Check if a grid cell is walkable (not blocked by any obstacle) - * O(bucket size) per query using spatial hash - fast for large graphs - * No bounds check - grid is effectively infinite - */ - isWalkableAt(gx: number, gy: number): boolean { - const bucketKey = this.getBucketKey(gx, gy); - - // Check node obstacles in this bucket only - const nodeIds = this.spatialHash.get(bucketKey); - if (nodeIds) { - for (const nodeId of nodeIds) { - const obs = this.nodeObstacles.get(nodeId); - if (obs && gx >= obs.minGx && gx <= obs.maxGx && gy >= obs.minGy && gy <= obs.maxGy) { - return false; - } - } - } - - // Port stubs are few, linear scan is fine - for (const obs of this.portStubObstacles) { - if (gx >= obs.minGx && gx <= obs.maxGx && gy >= obs.minGy && gy <= obs.maxGy) { - return false; - } - } - - return true; - } - - /** - * Get offset for converting world to local grid coordinates - */ - getOffset(): { x: number; y: number } { - return { x: this.offsetX, y: this.offsetY }; - } -} diff --git a/src/lib/routing/index.ts b/src/lib/routing/index.ts index 0f930a78..56a47c47 100644 --- a/src/lib/routing/index.ts +++ b/src/lib/routing/index.ts @@ -2,24 +2,23 @@ * Routing module public API */ -// Route calculation -export { calculateRoute, calculateRouteWithWaypoints, calculateSimpleRoute, getPathCells } from './routeCalculator'; +// Engine and worker client +export { RoutingEngine, routeScene } from './engine'; +export { RoutingClient, type RoutingChanges } from './client'; -// Grid -export { SparseGrid } from './gridBuilder'; +// Scene comparisons +export { sameRequest, sameSceneNode } from './scene'; // Port geometry export { getPortInfo } from './portGeometry'; -// Constants used by FlowCanvas +// Constants used by canvas and edges export { ROUTING_MARGIN, HANDLE_OFFSET, ARROW_INSET, - ASYNC_BATCH_SIZE, WAYPOINT_MERGE_THRESHOLD, WAYPOINT_COLLINEAR_THRESHOLD, - ROUTING_CONTEXT_PADDING, EDGE_SOURCE_OFFSET, EDGE_TARGET_OFFSET, EDGE_CORNER_RADIUS @@ -28,11 +27,11 @@ export { // Types export type { Bounds, - RoutingContext, RouteResult, Direction, PortStub, PortInfo, RouteRequest, - RoutingScene + RoutingScene, + SceneNode } from './types'; diff --git a/src/lib/routing/pathOptimizer.ts b/src/lib/routing/pathOptimizer.ts deleted file mode 100644 index a1cabdc5..00000000 --- a/src/lib/routing/pathOptimizer.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Path optimization utilities - */ - -import type { Position } from '$lib/types/common'; -import { GRID_SIZE } from './constants'; - -/** - * Remove collinear intermediate points (keeps only corners) - * This simplifies the path from A* which returns every grid cell - */ -export function simplifyPath(path: Position[]): Position[] { - if (path.length < 3) return path; - - const result: Position[] = [path[0]]; - - for (let i = 1; i < path.length - 1; i++) { - const prev = result[result.length - 1]; - const curr = path[i]; - const next = path[i + 1]; - - // Calculate direction vectors - const dx1 = Math.sign(curr.x - prev.x); - const dy1 = Math.sign(curr.y - prev.y); - const dx2 = Math.sign(next.x - curr.x); - const dy2 = Math.sign(next.y - curr.y); - - // Keep point if direction changes (it's a corner) - const directionChanged = dx1 !== dx2 || dy1 !== dy2; - if (directionChanged) { - result.push(curr); - } - } - - result.push(path[path.length - 1]); - return result; -} - -/** - * Snap a single point to grid - */ -export function snapToGrid(point: Position): Position { - return { - x: Math.round(point.x / GRID_SIZE) * GRID_SIZE, - y: Math.round(point.y / GRID_SIZE) * GRID_SIZE - }; -} - diff --git a/src/lib/routing/pathfinder.ts b/src/lib/routing/pathfinder.ts deleted file mode 100644 index 01dab2e9..00000000 --- a/src/lib/routing/pathfinder.ts +++ /dev/null @@ -1,347 +0,0 @@ -/** - * A* pathfinding with turn penalty and no 180-degree turns - * - * Performance notes: - * - MinHeap uses a Map sidecar for O(1) membership/update lookups - * - Closed set and forced-walkable set use numeric keys to avoid string GC - * - Direction is encoded as 0-3 integer for fast hashing - */ - -import type { Position } from '$lib/types/common'; -import type { Direction } from './types'; -import { OPPOSITE_DIRECTION } from './types'; -import type { SparseGrid } from './gridBuilder'; -import { worldToGrid, gridToWorld } from './gridBuilder'; -import { GRID_SIZE } from './constants'; - -/** Cost for making a 90-degree turn (in grid units) */ -const TURN_PENALTY = 2; - -/** Cost for running parallel to another path (same direction) */ -const PATH_OVERLAP_PENALTY = 20; - -/** Cost for crossing another path (perpendicular) - low since crossings are acceptable */ -const PATH_CROSSING_PENALTY = 2; - -/** Number of cells to force walkable in initial direction (exit from port) */ -const EXIT_PATH_LENGTH = 3; - -/** Maximum iterations before giving up (prevents infinite search) */ -const MAX_ITERATIONS = 10000; - -/** Direction to integer index for numeric hashing */ -const DIR_INDEX: Record = { right: 0, left: 1, down: 2, up: 3 }; - -/** Neighbor offsets with their directions and precomputed direction index */ -const NEIGHBORS: Array<{ dx: number; dy: number; dir: Direction; dirIdx: number }> = [ - { dx: 1, dy: 0, dir: 'right', dirIdx: 0 }, - { dx: -1, dy: 0, dir: 'left', dirIdx: 1 }, - { dx: 0, dy: 1, dir: 'down', dirIdx: 2 }, - { dx: 0, dy: -1, dir: 'up', dirIdx: 3 } -]; - -/** - * Encode (x, y, dirIdx) into a single number for use as Map key. - * Uses a large multiplier to avoid collisions in the expected coordinate range. - * Grid coordinates are typically -500..+500, so 20_000 provides ample space. - */ -function encodeState(x: number, y: number, dirIdx: number): number { - return ((x + 10_000) * 20_001 + (y + 10_000)) * 4 + dirIdx; -} - -/** Encode (x, y) into a single number for walkability sets */ -function encodeCell(x: number, y: number): number { - return (x + 10_000) * 20_001 + (y + 10_000); -} - -/** Priority queue node for A* with direction tracking */ -interface AStarNode { - x: number; - y: number; - g: number; // Cost from start - h: number; // Heuristic to end - f: number; // Total cost (g + h) - parent: AStarNode | null; - direction: Direction; // Actual direction we arrived from - dirIdx: number; // Numeric direction index (0-3) - stateKey: number; // Precomputed encodeState key - heapIdx: number; // Current index in the heap array (maintained by MinHeap) -} - -/** - * Binary min-heap with O(1) membership test and O(log n) update. - * Uses a Map sidecar for fast lookups. - * Each node stores its current heap index so bubbleUp/bubbleDown can - * update the sidecar without re-scanning. - */ -class MinHeap { - private heap: AStarNode[] = []; - private index: Map = new Map(); - - push(node: AStarNode): void { - node.heapIdx = this.heap.length; - this.heap.push(node); - this.index.set(node.stateKey, node); - this.bubbleUp(node.heapIdx); - } - - pop(): AStarNode | undefined { - if (this.heap.length === 0) return undefined; - const min = this.heap[0]; - this.index.delete(min.stateKey); - const last = this.heap.pop()!; - if (this.heap.length > 0) { - last.heapIdx = 0; - this.heap[0] = last; - this.bubbleDown(0); - } - return min; - } - - get length(): number { - return this.heap.length; - } - - /** O(1) lookup + O(log n) re-heap if better */ - updateIfBetter(stateKey: number, newG: number, newH: number, parent: AStarNode): boolean { - const existing = this.index.get(stateKey); - if (!existing) return false; // Not in open set - if (newG < existing.g) { - existing.g = newG; - existing.f = newG + existing.h; - existing.parent = parent; - this.bubbleUp(existing.heapIdx); - return true; - } - return false; // Existing path is better or equal - } - - /** O(1) membership test */ - has(stateKey: number): boolean { - return this.index.has(stateKey); - } - - private bubbleUp(i: number): void { - const heap = this.heap; - while (i > 0) { - const parentIdx = (i - 1) >> 1; - if (heap[i].f >= heap[parentIdx].f) break; - this.swap(i, parentIdx); - i = parentIdx; - } - } - - private bubbleDown(i: number): void { - const heap = this.heap; - const n = heap.length; - while (true) { - const left = 2 * i + 1; - const right = 2 * i + 2; - let smallest = i; - if (left < n && heap[left].f < heap[smallest].f) smallest = left; - if (right < n && heap[right].f < heap[smallest].f) smallest = right; - if (smallest === i) break; - this.swap(i, smallest); - i = smallest; - } - } - - private swap(a: number, b: number): void { - const heap = this.heap; - const nodeA = heap[a]; - const nodeB = heap[b]; - heap[a] = nodeB; - heap[b] = nodeA; - nodeA.heapIdx = b; - nodeB.heapIdx = a; - } -} - -/** Result from pathfinding */ -export interface PathResult { - path: Position[]; - isFallback: boolean; -} - -/** - * Find orthogonal path between two points using A* with turn penalty - * Only allows 90-degree turns (no reversing/180-degree turns) - * @param start - Start position in world coordinates - * @param end - End position in world coordinates - * @param grid - Sparse grid with obstacles - * @param offset - Grid offset (canvas origin) - * @param initialDir - Initial direction of travel - * @param usedCells - Optional map of cells to directions used by other paths - * @returns PathResult with path and fallback flag - */ -export function findPathWithTurnPenalty( - start: Position, - end: Position, - grid: SparseGrid, - offset: Position, - initialDir: Direction, - usedCells?: Map> -): PathResult { - // Convert to grid coordinates - const startGx = worldToGrid(start.x - offset.x); - const startGy = worldToGrid(start.y - offset.y); - const endGx = worldToGrid(end.x - offset.x); - const endGy = worldToGrid(end.y - offset.y); - - // Precompute offset in grid units for usedCells lookup - const offsetGx = worldToGrid(offset.x); - const offsetGy = worldToGrid(offset.y); - - // Cells that are forced walkable (start, end, exit path) — numeric keys - const forcedWalkable = new Set(); - forcedWalkable.add(encodeCell(startGx, startGy)); - forcedWalkable.add(encodeCell(endGx, endGy)); - - // Force first few cells in initial direction walkable (exit path from port) - const initVec = NEIGHBORS.find((n) => n.dir === initialDir); - if (initVec) { - for (let i = 1; i <= EXIT_PATH_LENGTH; i++) { - forcedWalkable.add(encodeCell(startGx + initVec.dx * i, startGy + initVec.dy * i)); - } - } - - // Helper to check walkability (sparse grid + forced walkable) - const isWalkable = (gx: number, gy: number): boolean => { - if (forcedWalkable.has(encodeCell(gx, gy))) return true; - return grid.isWalkableAt(gx, gy); - }; - - // Initialize open (min-heap) and closed set (numeric keys) - const openSet = new MinHeap(); - const closedSet = new Set(); - - const initialDirIdx = DIR_INDEX[initialDir]; - const startStateKey = encodeState(startGx, startGy, initialDirIdx); - const startH = manhattanDistance(startGx, startGy, endGx, endGy); - - // Create start node - const startNode: AStarNode = { - x: startGx, - y: startGy, - g: 0, - h: startH, - f: startH, - parent: null, - direction: initialDir, - dirIdx: initialDirIdx, - stateKey: startStateKey, - heapIdx: 0 - }; - openSet.push(startNode); - - let iterations = 0; - while (openSet.length > 0 && iterations < MAX_ITERATIONS) { - iterations++; - const current = openSet.pop()!; - - // Check if we reached the end - if (current.x === endGx && current.y === endGy) { - return { path: reconstructPath(current, offset), isFallback: false }; - } - - // Skip if already processed with this direction - if (closedSet.has(current.stateKey)) continue; - closedSet.add(current.stateKey); - - // Get the direction we must NOT go (opposite = 180-degree turn) - const blockedDir = OPPOSITE_DIRECTION[current.direction]; - const isStartNode = current.parent === null; - - // Explore neighbors - for (const { dx, dy, dir, dirIdx } of NEIGHBORS) { - if (dir === blockedDir) continue; - if (isStartNode && dir !== initialDir) continue; - - const nx = current.x + dx; - const ny = current.y + dy; - - // Skip if not walkable (no bounds check - grid is unbounded) - if (!isWalkable(nx, ny)) continue; - - // Skip if already closed with this direction - const neighborStateKey = encodeState(nx, ny, dirIdx); - if (closedSet.has(neighborStateKey)) continue; - - // Calculate movement cost - let moveCost = 1; - if (current.direction !== dir) moveCost += TURN_PENALTY; - - // Add penalty for cells used by other paths - if (usedCells) { - const worldGx = nx + offsetGx; - const worldGy = ny + offsetGy; - const existingDirs = usedCells.get(`${worldGx},${worldGy}`); - if (existingDirs) { - const isHorizontal = dir === 'left' || dir === 'right'; - const hasPerpendicular = isHorizontal - ? existingDirs.has('up') || existingDirs.has('down') - : existingDirs.has('left') || existingDirs.has('right'); - moveCost += hasPerpendicular ? PATH_CROSSING_PENALTY : PATH_OVERLAP_PENALTY; - } - } - - const tentativeG = current.g + moveCost; - - // Try to update existing node in open set, or add new one - if (!openSet.updateIfBetter(neighborStateKey, tentativeG, 0, current)) { - if (!openSet.has(neighborStateKey)) { - const h = manhattanDistance(nx, ny, endGx, endGy); - openSet.push({ - x: nx, - y: ny, - g: tentativeG, - h, - f: tentativeG + h, - parent: current, - direction: dir, - dirIdx, - stateKey: neighborStateKey, - heapIdx: 0 - }); - } - } - } - } - - // No path found, return L-shaped fallback (never diagonal) - // Go in initial direction first, then turn - if (initialDir === 'right' || initialDir === 'left') { - // Horizontal first, then vertical - return { path: [start, { x: end.x, y: start.y }, end], isFallback: true }; - } else { - // Vertical first, then horizontal - return { path: [start, { x: start.x, y: end.y }, end], isFallback: true }; - } -} - -/** - * Manhattan distance heuristic - */ -function manhattanDistance(x1: number, y1: number, x2: number, y2: number): number { - return Math.abs(x1 - x2) + Math.abs(y1 - y2); -} - -/** - * Reconstruct path from A* result - * Uses push + reverse instead of unshift to avoid O(n²) array shifting - */ -function reconstructPath(endNode: AStarNode, offset: Position): Position[] { - const path: Position[] = []; - let current: AStarNode | null = endNode; - - while (current !== null) { - path.push({ - x: gridToWorld(current.x) + offset.x, - y: gridToWorld(current.y) + offset.y - }); - current = current.parent; - } - - path.reverse(); - return path; -} diff --git a/src/lib/routing/protocol.ts b/src/lib/routing/protocol.ts new file mode 100644 index 00000000..328931f6 --- /dev/null +++ b/src/lib/routing/protocol.ts @@ -0,0 +1,35 @@ +/** + * Routing protocol - scene differences to the engine, route changes back + */ + +import type { RouteRequest, RouteResult, SceneNode } from './types'; +import type { RoutingEngine } from './engine'; + +export interface RoutingDelta { + /** Increments on reset; responses of older epochs are dropped */ + epoch: number; + /** Discard the whole scene before applying this delta */ + reset: boolean; + nodes: [string, SceneNode][]; + removedNodes: string[]; + requests: RouteRequest[]; + removedRequests: string[]; + /** Negotiation iterations after applying the delta */ + negotiate: number; +} + +export interface RoutingResponse { + epoch: number; + changed: [string, RouteResult][]; + removed: string[]; +} + +/** Apply a delta to an engine and collect the resulting route changes */ +export function applyDelta(engine: RoutingEngine, delta: RoutingDelta): RoutingResponse { + for (const id of delta.removedRequests) engine.removeRequest(id); + for (const id of delta.removedNodes) engine.removeNode(id); + for (const [id, node] of delta.nodes) engine.setNode(id, node.bounds, node.ports); + for (const request of delta.requests) engine.setRequest(request); + const update = engine.update({ negotiate: delta.negotiate }); + return { epoch: delta.epoch, changed: [...update.changed], removed: update.removed }; +} diff --git a/src/lib/routing/routeCalculator.ts b/src/lib/routing/routeCalculator.ts deleted file mode 100644 index 83410b66..00000000 --- a/src/lib/routing/routeCalculator.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * Simple orthogonal route calculator - */ - -import type { Position } from '$lib/types/common'; -import type { Waypoint } from '$lib/types/nodes'; -import type { RoutingContext, RouteResult, Direction } from './types'; -import { DIRECTION_VECTORS } from './types'; -import { worldToGrid, type SparseGrid } from './gridBuilder'; -import { findPathWithTurnPenalty } from './pathfinder'; -import { simplifyPath, snapToGrid } from './pathOptimizer'; -import { SOURCE_CLEARANCE, TARGET_CLEARANCE, GRID_SIZE } from './constants'; - -/** Number of cells to skip at path start for shared source port overlap */ -const SHARED_SOURCE_CELLS = 2; - -/** - * Calculate stub endpoint (grid-aligned point where stub ends) - */ -function getStubEnd(portPos: Position, direction: Direction, clearance: number): Position { - const vec = DIRECTION_VECTORS[direction]; - const point = { - x: portPos.x + vec.x * clearance, - y: portPos.y + vec.y * clearance - }; - return snapToGrid(point); -} - -/** - * Calculate route between two ports - * @param grid - Sparse grid with obstacles (from routing store) - * @param usedCells - Optional map of cells to directions used by other paths - */ -export function calculateRoute( - sourcePos: Position, - targetPos: Position, - sourceDir: Direction, - targetDir: Direction, - grid: SparseGrid, - usedCells?: Map> -): RouteResult { - // Calculate stub endpoints (grid-aligned virtual ports for A*) - const sourceStubEnd = getStubEnd(sourcePos, sourceDir, SOURCE_CLEARANCE); - const targetStubEnd = getStubEnd(targetPos, targetDir, TARGET_CLEARANCE); - - const offset = grid.getOffset(); - - // Find path from source stub end to target stub end - const result = findPathWithTurnPenalty(sourceStubEnd, targetStubEnd, grid, offset, sourceDir, usedCells); - const simplified = simplifyPath(result.path); - - // Path is: [sourceStubEnd, ...intermediates..., targetStubEnd] - // simplifyPath already includes start and end - return { path: simplified, waypoints: [], isFallback: result.isFallback }; -} - -/** - * Simple L-shaped route (no pathfinding) - */ -export function calculateSimpleRoute( - sourcePos: Position, - targetPos: Position, - sourceDir: Direction = 'right', - targetDir: Direction = 'left' -): RouteResult { - const sourceStubEnd = getStubEnd(sourcePos, sourceDir, SOURCE_CLEARANCE); - const targetStubEnd = getStubEnd(targetPos, targetDir, TARGET_CLEARANCE); - - // Simple L-shape: go in source direction, then turn toward target - const path: Position[] = [sourceStubEnd]; - - // Add corner if not aligned - if (sourceStubEnd.x !== targetStubEnd.x && sourceStubEnd.y !== targetStubEnd.y) { - if (sourceDir === 'right' || sourceDir === 'left') { - // Horizontal first, then vertical - path.push(snapToGrid({ x: targetStubEnd.x, y: sourceStubEnd.y })); - } else { - // Vertical first, then horizontal - path.push(snapToGrid({ x: sourceStubEnd.x, y: targetStubEnd.y })); - } - } - - path.push(targetStubEnd); - - return { path, waypoints: [] }; -} - -/** - * Extract grid cells used by a path with direction info (for overlap/crossing avoidance) - * - * Optimized for simplified paths (corner-only): each segment is axis-aligned, - * so we compute the grid cell range directly instead of stepping every cell. - * - * @param path - Path positions in world coordinates (simplified = corners only) - * @param skipStart - Number of cells to skip at start (for shared source ports) - * @returns Map of cell key to set of directions traveled through that cell - */ -export function getPathCells(path: Position[], skipStart = 2): Map> { - const cells = new Map>(); - let cellCount = 0; - - for (let i = 0; i < path.length - 1; i++) { - const segStart = path[i]; - const segEnd = path[i + 1]; - - // Determine direction of this segment - const dx = segEnd.x - segStart.x; - const dy = segEnd.y - segStart.y; - let dir: Direction; - if (Math.abs(dx) > Math.abs(dy)) { - dir = dx > 0 ? 'right' : 'left'; - } else { - dir = dy > 0 ? 'down' : 'up'; - } - - // Compute grid range for this axis-aligned segment - const gx1 = worldToGrid(segStart.x); - const gy1 = worldToGrid(segStart.y); - const gx2 = worldToGrid(segEnd.x); - const gy2 = worldToGrid(segEnd.y); - - const minGx = Math.min(gx1, gx2); - const maxGx = Math.max(gx1, gx2); - const minGy = Math.min(gy1, gy2); - const maxGy = Math.max(gy1, gy2); - - // Iterate over the grid range (one axis is constant for orthogonal segments) - for (let gx = minGx; gx <= maxGx; gx++) { - for (let gy = minGy; gy <= maxGy; gy++) { - cellCount++; - if (cellCount > skipStart) { - const key = `${gx},${gy}`; - if (!cells.has(key)) cells.set(key, new Set()); - cells.get(key)!.add(dir); - } - } - } - } - - return cells; -} - -/** - * Infer direction from one point to another (for approach/exit directions) - */ -function inferDirection(from: Position, to: Position): Direction { - const dx = to.x - from.x; - const dy = to.y - from.y; - if (Math.abs(dx) > Math.abs(dy)) { - return dx > 0 ? 'right' : 'left'; - } - return dy > 0 ? 'down' : 'up'; -} - -/** - * Infer exit direction from the end of a path segment - */ -function inferExitDirection(path: Position[]): Direction { - if (path.length < 2) return 'right'; - const last = path[path.length - 1]; - const prev = path[path.length - 2]; - return inferDirection(prev, last); -} - - -/** - * Calculate route through user waypoints using sequential A* segments - * Route: Source -> A* -> W1 -> A* -> W2 -> ... -> A* -> Target - * @param grid - Sparse grid with obstacles (from routing store) - */ -export function calculateRouteWithWaypoints( - sourcePos: Position, - targetPos: Position, - sourceDir: Direction, - targetDir: Direction, - grid: SparseGrid, - userWaypoints: Waypoint[], - usedCells?: Map> -): RouteResult { - // If no waypoints, use regular routing - if (userWaypoints.length === 0) { - return calculateRoute(sourcePos, targetPos, sourceDir, targetDir, grid, usedCells); - } - - // Use waypoints in their stored order (insertion order from segment splitting) - const offset = grid.getOffset(); - - // Build path segments through all waypoints - const fullPath: Position[] = []; - let currentPos = getStubEnd(sourcePos, sourceDir, SOURCE_CLEARANCE); - let currentDir = sourceDir; - let hasFallback = false; - - for (let i = 0; i < userWaypoints.length; i++) { - const waypoint = userWaypoints[i]; - const waypointPos = snapToGrid(waypoint.position); - - // Route from current position to waypoint - const segmentResult = findPathWithTurnPenalty( - currentPos, - waypointPos, - grid, - offset, - currentDir, - usedCells - ); - - if (segmentResult.isFallback) hasFallback = true; - - // Append path (skip first point if not first segment to avoid duplicates) - if (fullPath.length === 0) { - fullPath.push(...segmentResult.path); - } else if (segmentResult.path.length > 0) { - fullPath.push(...segmentResult.path.slice(1)); - } - - // Update current position and direction for next segment - currentPos = waypointPos; - if (segmentResult.path.length >= 2) { - // Exit direction is opposite of how we approached (continue in same direction) - currentDir = inferExitDirection(segmentResult.path); - } else { - // Fallback: infer from next waypoint or target - const nextTarget = i < userWaypoints.length - 1 - ? userWaypoints[i + 1].position - : targetPos; - currentDir = inferDirection(waypointPos, nextTarget); - } - } - - // Final segment: last waypoint -> target - const targetStubEnd = getStubEnd(targetPos, targetDir, TARGET_CLEARANCE); - const finalResult = findPathWithTurnPenalty( - currentPos, - targetStubEnd, - grid, - offset, - currentDir, - usedCells - ); - - if (finalResult.isFallback) hasFallback = true; - - if (fullPath.length === 0) { - fullPath.push(...finalResult.path); - } else if (finalResult.path.length > 0) { - fullPath.push(...finalResult.path.slice(1)); - } - - // Simplify the combined path - const simplified = simplifyPath(fullPath); - - return { path: simplified, waypoints: userWaypoints, isFallback: hasFallback }; -} diff --git a/src/lib/routing/routing.perf.test.ts b/src/lib/routing/routing.perf.test.ts index 0b489e9b..a0b05c9d 100644 --- a/src/lib/routing/routing.perf.test.ts +++ b/src/lib/routing/routing.perf.test.ts @@ -6,7 +6,6 @@ import { describe, it } from 'vitest'; import type { RouteResult, RoutingScene } from './types'; import { generateScenario, buildScene, sceneNode, sceneRequest } from './testing/scenario'; import { measureRoutes } from './testing/invariants'; -import { routeSceneLegacy } from './testing/legacyRouter'; import { RoutingEngine, routeScene } from './engine'; interface Benchmarked { @@ -15,8 +14,6 @@ interface Benchmarked { } const routers: Record = { - // Pre-v2 baseline, larger sizes take minutes - legacy: { route: routeSceneLegacy, sizes: [1000] }, engine: { route: (scene) => routeScene(scene), sizes: [1000, 5000, 10000] } }; diff --git a/src/lib/routing/routing.worker.ts b/src/lib/routing/routing.worker.ts new file mode 100644 index 00000000..a6ebd478 --- /dev/null +++ b/src/lib/routing/routing.worker.ts @@ -0,0 +1,13 @@ +/** + * Routing worker - hosts the routing engine off the main thread + */ + +import { RoutingEngine } from './engine'; +import { applyDelta, type RoutingDelta } from './protocol'; + +let engine = new RoutingEngine(); + +onmessage = (event: MessageEvent) => { + if (event.data.reset) engine = new RoutingEngine(); + postMessage(applyDelta(engine, event.data)); +}; diff --git a/src/lib/routing/scene.ts b/src/lib/routing/scene.ts new file mode 100644 index 00000000..8a7f3b43 --- /dev/null +++ b/src/lib/routing/scene.ts @@ -0,0 +1,39 @@ +/** + * Scene comparisons - decide whether routing input changed + */ + +import type { Waypoint } from '$lib/types/nodes'; +import type { PortInfo, RouteRequest, SceneNode } from './types'; + +function samePort(a: PortInfo, b: PortInfo): boolean { + return a.direction === b.direction && a.position.x === b.position.x && a.position.y === b.position.y; +} + +export function sameWaypoints(a: Waypoint[], b: Waypoint[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i].position.x !== b[i].position.x || a[i].position.y !== b[i].position.y) return false; + } + return true; +} + +export function sameRequest(a: RouteRequest | undefined, b: RouteRequest): boolean { + return ( + a !== undefined && + a.netId === b.netId && + samePort(a.source, b.source) && + samePort(a.target, b.target) && + sameWaypoints(a.waypoints, b.waypoints) + ); +} + +export function sameSceneNode(a: SceneNode | undefined, b: SceneNode): boolean { + if (a === undefined || a.ports.length !== b.ports.length) return false; + const p = a.bounds; + const q = b.bounds; + if (p.x !== q.x || p.y !== q.y || p.width !== q.width || p.height !== q.height) return false; + for (let i = 0; i < a.ports.length; i++) { + if (!samePort(a.ports[i], b.ports[i])) return false; + } + return true; +} diff --git a/src/lib/routing/testing/invariants.ts b/src/lib/routing/testing/invariants.ts index 102c6ee3..a9d6dc07 100644 --- a/src/lib/routing/testing/invariants.ts +++ b/src/lib/routing/testing/invariants.ts @@ -9,7 +9,7 @@ import type { Position } from '$lib/types/common'; import type { Bounds, PortInfo, RouteResult, RoutingScene } from '../types'; import { DIRECTION_VECTORS } from '../types'; import { GRID_SIZE, SOURCE_CLEARANCE, TARGET_CLEARANCE } from '../constants'; -import { snapToGrid } from '../pathOptimizer'; +import { toGrid, fromGrid } from '../gridTypes'; export type ViolationKind = | 'missing' @@ -37,10 +37,10 @@ export interface RouteMetrics { function stubEnd(port: PortInfo, clearance: number): Position { const vec = DIRECTION_VECTORS[port.direction]; - return snapToGrid({ - x: port.position.x + vec.x * clearance, - y: port.position.y + vec.y * clearance - }); + return { + x: fromGrid(toGrid(port.position.x + vec.x * clearance)), + y: fromGrid(toGrid(port.position.y + vec.y * clearance)) + }; } function onGrid(value: number): boolean { diff --git a/src/lib/routing/testing/legacyRouter.ts b/src/lib/routing/testing/legacyRouter.ts deleted file mode 100644 index e3ff5036..00000000 --- a/src/lib/routing/testing/legacyRouter.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Baseline adapter: the pre-v2 routing pipeline as a pure function - * - * Mirrors routingStore.recalculateAllRoutes (sync pass followed by the overlap - * refinement pass) so tests and benchmarks can measure the baseline without - * loading the Svelte stores. - */ - -import type { Direction, RouteRequest, RouteResult, RoutingScene, Bounds } from '../types'; -import { SparseGrid } from '../gridBuilder'; -import { calculateRoute, calculateRouteWithWaypoints, getPathCells } from '../routeCalculator'; -import { ROUTING_MARGIN, ROUTING_CONTEXT_PADDING } from '../constants'; - -function canvasBoundsOf(nodeBounds: Map): Bounds { - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (const b of nodeBounds.values()) { - minX = Math.min(minX, b.x - ROUTING_MARGIN); - minY = Math.min(minY, b.y - ROUTING_MARGIN); - maxX = Math.max(maxX, b.x + b.width + ROUTING_MARGIN); - maxY = Math.max(maxY, b.y + b.height + ROUTING_MARGIN); - } - const p = ROUTING_CONTEXT_PADDING; - return { x: minX - p, y: minY - p, width: maxX - minX + 2 * p, height: maxY - minY + 2 * p }; -} - -export function routeSceneLegacy(scene: RoutingScene): Map { - const nodeBounds = new Map([...scene.nodes].map(([id, node]) => [id, node.bounds])); - const grid = new SparseGrid({ - nodeBounds, - canvasBounds: canvasBoundsOf(nodeBounds), - portStubs: [...scene.nodes.values()].flatMap((node) => node.ports) - }); - - const route = (r: RouteRequest, usedCells?: Map>): RouteResult => - r.waypoints.length > 0 - ? calculateRouteWithWaypoints( - r.source.position, - r.target.position, - r.source.direction, - r.target.direction, - grid, - r.waypoints, - usedCells - ) - : calculateRoute(r.source.position, r.target.position, r.source.direction, r.target.direction, grid, usedCells); - - const distance = (r: RouteRequest) => - Math.abs(r.target.position.x - r.source.position.x) + Math.abs(r.target.position.y - r.source.position.y); - const sorted = [...scene.requests].sort((a, b) => distance(b) - distance(a)); - - const byNet = new Map(); - for (const r of sorted) { - const group = byNet.get(r.netId) || []; - group.push(r); - byNet.set(r.netId, group); - } - - const routes = new Map(); - - // Pass 1: without overlap avoidance - for (const group of byNet.values()) { - for (const r of group) routes.set(r.id, route(r)); - } - - // Pass 2: overlap-aware refinement - const usedCells = new Map>(); - for (const group of byNet.values()) { - const groupCells: Map>[] = []; - for (const r of group) { - const result = route(r, usedCells); - routes.set(r.id, result); - if (result.path.length > 0) groupCells.push(getPathCells(result.path, 2)); - } - for (const cells of groupCells) { - for (const [key, dirs] of cells) { - if (!usedCells.has(key)) usedCells.set(key, new Set()); - for (const dir of dirs) usedCells.get(key)!.add(dir); - } - } - } - - return routes; -} diff --git a/src/lib/routing/types.ts b/src/lib/routing/types.ts index 9248b069..67e8780c 100644 --- a/src/lib/routing/types.ts +++ b/src/lib/routing/types.ts @@ -67,16 +67,6 @@ export interface RoutingScene { requests: RouteRequest[]; } -/** Routing context passed to calculator */ -export interface RoutingContext { - /** Node ID -> bounding box (world coordinates, already includes margin) */ - nodeBounds: Map; - /** Canvas bounds for grid calculation */ - canvasBounds: Bounds; - /** Port stubs to mark as obstacles */ - portStubs?: PortStub[]; -} - /** Result from route calculation */ export interface RouteResult { /** Grid-aligned points including source/target */ diff --git a/src/lib/stores/graph/state.ts b/src/lib/stores/graph/state.ts index 2ee10883..26a8ed5f 100644 --- a/src/lib/stores/graph/state.ts +++ b/src/lib/stores/graph/state.ts @@ -2,7 +2,7 @@ * Graph store - Core state (writable and derived stores) */ -import { writable, derived, get } from 'svelte/store'; +import { writable, derived, readable, get, type Readable } from 'svelte/store'; import type { NodeInstance, Connection, SubsystemGraph, Annotation } from '$lib/nodes/types'; import type { EventInstance } from '$lib/events/types'; import { NODE_TYPES } from '$lib/constants/nodeTypes'; @@ -344,10 +344,23 @@ export const currentNodes = derived( () => getCurrentGraph().nodes ); -/** Current context connections */ -export const currentConnections = derived( - [rootNodes, rootConnections, currentPath], - () => getCurrentGraph().connections +/** Wrap a store so subscribers are notified only when the value identity changes */ +function distinct(store: Readable): Readable { + return readable(undefined as T, (set) => { + let hasValue = false; + let last: T; + return store.subscribe((value) => { + if (hasValue && value === last) return; + hasValue = true; + last = value; + set(value); + }); + }); +} + +/** Current context connections; emits only when the connection list changes, not on node edits */ +export const currentConnections = distinct( + derived([rootNodes, rootConnections, currentPath], () => getCurrentGraph().connections) ); /** Current context annotations */ diff --git a/src/lib/stores/routing.ts b/src/lib/stores/routing.ts index b1a4b399..b93fdac9 100644 --- a/src/lib/stores/routing.ts +++ b/src/lib/stores/routing.ts @@ -1,824 +1,239 @@ /** - * Routing store - manages route calculations and caching + * Routing store - connects the canvas to the routing engine + * + * The canvas reports its scene (block nodes with ports, connections). The store + * remembers what was sent, forwards only differences to the routing worker and + * keeps the returned routes in a reactive map, so an edge re-renders only when + * its own route changes. Waypoint edits change the graph; the resulting + * connection change is routed like any other. */ -import { writable, derived, get } from 'svelte/store'; +import { get } from 'svelte/store'; +import { SvelteMap } from 'svelte/reactivity'; import type { Position } from '$lib/types/common'; import type { Connection, Waypoint } from '$lib/types/nodes'; -import type { RoutingContext, RouteResult, Bounds, Direction, PortStub, PortInfo } from '$lib/routing'; import { - calculateRoute, - calculateRouteWithWaypoints, - calculateSimpleRoute, - getPathCells, - ROUTING_MARGIN, - ASYNC_BATCH_SIZE, + RoutingClient, + sameRequest, + sameSceneNode, WAYPOINT_MERGE_THRESHOLD, WAYPOINT_COLLINEAR_THRESHOLD, - ROUTING_CONTEXT_PADDING + type Bounds, + type PortInfo, + type RouteRequest, + type RouteResult, + type RoutingChanges, + type SceneNode } from '$lib/routing'; -import { DEFAULT_NODE_WIDTH, DEFAULT_NODE_HEIGHT } from '$lib/constants/dimensions'; -import { SparseGrid } from '$lib/routing/gridBuilder'; import { generateId } from '$lib/stores/utils'; import { graphStore } from '$lib/stores/graph'; import { historyStore } from '$lib/stores/history'; export type { PortInfo }; -/** Helper to extract user waypoints from a connection's waypoints array */ -function getUserWaypoints(waypoints?: Waypoint[]): Waypoint[] { - return (waypoints || []).filter((w) => w.isUserWaypoint); -} - -/** Compute route with or without waypoints */ -function computeRoute( - sourcePos: Position, - targetPos: Position, - sourceDir: Direction, - targetDir: Direction, - grid: SparseGrid | null, - waypoints: Waypoint[], - usedCells?: Map> -): RouteResult { - if (!grid) { - return calculateSimpleRoute(sourcePos, targetPos, sourceDir, targetDir); - } - return waypoints.length > 0 - ? calculateRouteWithWaypoints(sourcePos, targetPos, sourceDir, targetDir, grid, waypoints, usedCells) - : calculateRoute(sourcePos, targetPos, sourceDir, targetDir, grid, usedCells); -} - -/** Generate hash of route inputs for change detection */ -function hashRouteInputs( - sourcePos: Position, - targetPos: Position, - sourceDir: Direction, - targetDir: Direction, - waypoints: Waypoint[] -): string { - const wpHash = waypoints.map(w => `${w.position.x},${w.position.y}`).join(';'); - return `${sourcePos.x},${sourcePos.y}|${targetPos.x},${targetPos.y}|${sourceDir}|${targetDir}|${wpHash}`; -} +/** Minimum number of changed connections for routing the visible ones in a separate first round */ +const VISIBLE_FIRST_MIN = 200; -interface RoutingState { - /** Cached routes by connection ID */ - routes: Map; - /** Current routing context (node bounds) */ - context: RoutingContext | null; - /** Sparse grid built from context - O(obstacles) memory */ - grid: SparseGrid | null; - /** Cache of route input hashes for change detection */ - routeInputHashes: Map; -} +const routes = new SvelteMap(); +const sentNodes = new Map(); +const sentRequests = new Map(); -const state = writable({ - routes: new Map(), - context: null, - grid: null, - routeInputHashes: new Map() +const client = new RoutingClient((response) => { + for (const [id, route] of response.changed) { + if (sentRequests.has(id)) routes.set(id, route); + } + for (const id of response.removed) routes.delete(id); }); -/** Generation counter — incremented on each recalculateAllRoutes call to cancel stale async work */ -let routingGeneration = 0; +function intersects(request: RouteRequest, area: Bounds): boolean { + const a = request.source.position; + const b = request.target.position; + return ( + Math.max(a.x, b.x) >= area.x && + Math.min(a.x, b.x) <= area.x + area.width && + Math.max(a.y, b.y) >= area.y && + Math.min(a.y, b.y) <= area.y + area.height + ); +} function findConnection(connectionId: string): Connection | undefined { return get(graphStore.connections).find((c) => c.id === connectionId); } -/** - * Persist new waypoints for a connection, then either recompute the route - * synchronously (when port positions are available) or invalidate the cached - * route so a fresh calculation runs on the next read. - * - * Shared by addUserWaypoint, addUserWaypointAtIndex, removeUserWaypoint, and - * moveWaypoint — they all do the same thing after they've decided what the - * new waypoint list should look like. - */ -function applyWaypointUpdate( - connection: Connection, - updatedWaypoints: Waypoint[], - getPortInfo: ((nodeId: string, portIndex: number, isOutput: boolean) => PortInfo | null) | undefined -): void { - graphStore.updateConnectionWaypoints(connection.id, updatedWaypoints); - - if (getPortInfo) { - const $state = get(state); - const sourceInfo = getPortInfo(connection.sourceNodeId, connection.sourcePortIndex, true); - const targetInfo = getPortInfo(connection.targetNodeId, connection.targetPortIndex, false); - - if (sourceInfo && targetInfo) { - const userWaypoints = getUserWaypoints(updatedWaypoints); - const result = computeRoute( - sourceInfo.position, - targetInfo.position, - sourceInfo.direction, - targetInfo.direction, - $state.grid, - userWaypoints - ); +function userWaypoints(waypoints?: Waypoint[]): Waypoint[] { + return (waypoints ?? []).filter((w) => w.isUserWaypoint); +} - state.update((s) => { - const routes = new Map(s.routes); - routes.set(connection.id, result); - return { ...s, routes }; - }); - return; - } - } +function distance(a: Position, b: Position): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} - routingStore.invalidateRoute(connection.id); +/** Middle point lies (almost) on the line between its neighbours */ +function isCollinear(prev: Position, curr: Position, next: Position): boolean { + const dx = next.x - prev.x; + const dy = next.y - prev.y; + const length = Math.hypot(dx, dy); + if (length < 1) return true; + const cross = Math.abs((curr.x - prev.x) * dy - (curr.y - prev.y) * dx); + return cross / length < WAYPOINT_COLLINEAR_THRESHOLD; } -/** - * Routing store - manages route calculations and caching - */ export const routingStore = { - subscribe: state.subscribe, - - /** - * Update routing context from current nodes - * Uses incremental updates when possible for better performance - */ - setContext(nodeBounds: Map, canvasBounds: Bounds, portStubs?: PortStub[]): void { - const context: RoutingContext = { nodeBounds, canvasBounds, portStubs }; - - state.update((s) => { - let grid = s.grid; - - if (!grid) { - // First time - build full grid - grid = new SparseGrid(context); - } else { - // Incremental update - grid.updateBounds(canvasBounds); - - // Update changed nodes, add new ones, remove deleted ones - const currentNodeIds = new Set(nodeBounds.keys()); - const existingNodeIds = new Set(); - - // Track which nodes exist in the grid (we need to check via context) - if (s.context) { - for (const nodeId of s.context.nodeBounds.keys()) { - existingNodeIds.add(nodeId); - } - } - - // Update or add nodes - for (const [nodeId, bounds] of nodeBounds) { - grid.updateNode(nodeId, bounds); - } - - // Remove deleted nodes - for (const nodeId of existingNodeIds) { - if (!currentNodeIds.has(nodeId)) { - grid.removeNode(nodeId); - } - } - - // Update port stubs - grid.updatePortStubs(portStubs); - } - - return { ...s, context, grid }; - }); - }, - - /** - * Update a single node's bounds - O(1) incremental update - * Use this during node dragging for best performance - */ - updateNodeBounds(nodeId: string, bounds: Bounds): void { - state.update((s) => { - if (s.grid) { - s.grid.updateNode(nodeId, bounds); - } - if (s.context) { - s.context.nodeBounds.set(nodeId, bounds); - } - return s; - }); + /** Route of a connection, reactive per connection */ + route(connectionId: string): RouteResult | undefined { + return routes.get(connectionId); }, /** - * Recalculate only routes connected to specific nodes - * Much faster than recalculateAllRoutes during node dragging + * Compare nodes with what was sent before and remember them. Returns the changed + * nodes; with `complete`, previously sent nodes missing from the list are removed. */ - recalculateRoutesForNodes( - nodeIds: Set, - connections: Connection[], - getPortInfo: (nodeId: string, portIndex: number, isOutput: boolean) => PortInfo | null - ): void { - const $state = get(state); - if (!$state.grid) return; - - // Filter connections to only those connected to the specified nodes - const affectedConnections = connections.filter( - (c) => nodeIds.has(c.sourceNodeId) || nodeIds.has(c.targetNodeId) - ); - - if (affectedConnections.length === 0) return; - - // Memoize port info lookups for this batch - const portInfoCache = new Map(); - const getPortInfoCached = (nodeId: string, portIndex: number, isOutput: boolean): PortInfo | null => { - const key = `${nodeId}:${portIndex}:${isOutput}`; - if (!portInfoCache.has(key)) { - portInfoCache.set(key, getPortInfo(nodeId, portIndex, isOutput)); - } - return portInfoCache.get(key)!; - }; - - const routes = new Map($state.routes); - const routeInputHashes = new Map($state.routeInputHashes); - - for (const conn of affectedConnections) { - const sourceInfo = getPortInfoCached(conn.sourceNodeId, conn.sourcePortIndex, true); - const targetInfo = getPortInfoCached(conn.targetNodeId, conn.targetPortIndex, false); - - if (!sourceInfo || !targetInfo) continue; - - const userWaypoints = getUserWaypoints(conn.waypoints); - - // Check if inputs have changed using hash - const inputHash = hashRouteInputs( - sourceInfo.position, - targetInfo.position, - sourceInfo.direction, - targetInfo.direction, - userWaypoints - ); - - if (inputHash === $state.routeInputHashes.get(conn.id) && routes.has(conn.id)) { - // Inputs unchanged, skip recalculation - continue; - } - - const result = computeRoute( - sourceInfo.position, - targetInfo.position, - sourceInfo.direction, - targetInfo.direction, - $state.grid, - userWaypoints - ); - - routes.set(conn.id, result); - routeInputHashes.set(conn.id, inputHash); + diffNodes(nodes: [string, SceneNode][], complete: boolean): { changed: [string, SceneNode][]; removed: string[] } { + const changed: [string, SceneNode][] = []; + for (const entry of nodes) { + if (sameSceneNode(sentNodes.get(entry[0]), entry[1])) continue; + changed.push(entry); + sentNodes.set(entry[0], entry[1]); } - state.update((s) => ({ ...s, routes, routeInputHashes })); - }, - - /** - * Get route for a specific connection (as a derived store) - */ - getRoute(connectionId: string) { - return derived(state, ($state) => $state.routes.get(connectionId) || null); - }, - - /** - * Get route synchronously (non-reactive) - */ - getRouteSync(connectionId: string): RouteResult | null { - return get(state).routes.get(connectionId) || null; - }, - - /** - * Calculate and cache route for a single connection - */ - calcRoute( - connection: Connection, - sourcePos: Position, - targetPos: Position, - sourceDir: Direction = 'right', - targetDir: Direction = 'left' - ): RouteResult | null { - const $state = get(state); - - // Extract user waypoints from connection - const userWaypoints = getUserWaypoints(connection.waypoints); - - const result = computeRoute( - sourcePos, - targetPos, - sourceDir, - targetDir, - $state.grid, - userWaypoints - ); - - state.update((s) => { - const routes = new Map(s.routes); - routes.set(connection.id, result); - return { ...s, routes }; - }); - - return result; - }, - - /** - * Recalculate all routes using a two-pass strategy: - * Pass 1 (sync): Fast route for every connection WITHOUT overlap avoidance. - * Routes appear on screen immediately. - * Pass 2 (async): Refine routes WITH overlap avoidance, yielding to the - * browser every ASYNC_BATCH_SIZE routes so the UI stays responsive. - * Cancelled automatically if a newer recalculation starts. - * - * @param connections - All connections to route - * @param getPortInfo - Function to get port world position and direction - */ - recalculateAllRoutes( - connections: Connection[], - getPortInfo: (nodeId: string, portIndex: number, isOutput: boolean) => PortInfo | null - ): void { - const $state = get(state); - - // Bump generation — any in-flight async pass with an older generation will bail out - const generation = ++routingGeneration; - - // Memoize port info lookups for this batch (used during sorting and routing) - const portInfoCache = new Map(); - const getPortInfoCached = (nodeId: string, portIndex: number, isOutput: boolean): PortInfo | null => { - const key = `${nodeId}:${portIndex}:${isOutput}`; - if (!portInfoCache.has(key)) { - portInfoCache.set(key, getPortInfo(nodeId, portIndex, isOutput)); + const removed: string[] = []; + if (complete) { + const listed = new Set(nodes.map(([id]) => id)); + for (const id of sentNodes.keys()) { + if (!listed.has(id)) removed.push(id); } - return portInfoCache.get(key)!; - }; - - // ── Pass 1: fast sync routing (no overlap avoidance) ───────────── - - const routes = new Map($state.routes); - const routeInputHashes = new Map(); - - // Prepare sorted + grouped connections (shared by both passes) - const sortedConnections = [...connections].sort((a, b) => { - const aSource = getPortInfoCached(a.sourceNodeId, a.sourcePortIndex, true); - const aTarget = getPortInfoCached(a.targetNodeId, a.targetPortIndex, false); - const bSource = getPortInfoCached(b.sourceNodeId, b.sourcePortIndex, true); - const bTarget = getPortInfoCached(b.targetNodeId, b.targetPortIndex, false); - - const aDist = aSource && aTarget - ? Math.abs(aTarget.position.x - aSource.position.x) + Math.abs(aTarget.position.y - aSource.position.y) - : 0; - const bDist = bSource && bTarget - ? Math.abs(bTarget.position.x - bSource.position.x) + Math.abs(bTarget.position.y - bSource.position.y) - : 0; - - return bDist - aDist; // Longest first - }); - - // Group connections by source port so paths from same port can share cells - const bySourcePort = new Map(); - for (const conn of sortedConnections) { - const key = `${conn.sourceNodeId}:${conn.sourcePortIndex}`; - const group = bySourcePort.get(key) || []; - group.push(conn); - bySourcePort.set(key, group); - } - - // Flat ordered list of connections for pass 2 batching - const orderedConnections: Connection[] = []; - for (const [, groupConns] of bySourcePort) { - orderedConnections.push(...groupConns); - } - - // Pass 1: calculate every route without usedCells (fast) - for (const conn of orderedConnections) { - const sourceInfo = getPortInfoCached(conn.sourceNodeId, conn.sourcePortIndex, true); - const targetInfo = getPortInfoCached(conn.targetNodeId, conn.targetPortIndex, false); - if (!sourceInfo || !targetInfo) continue; - - const userWaypoints = getUserWaypoints(conn.waypoints); - const result = computeRoute( - sourceInfo.position, - targetInfo.position, - sourceInfo.direction, - targetInfo.direction, - $state.grid, - userWaypoints - // no usedCells — fast path - ); - routes.set(conn.id, result); - routeInputHashes.set(conn.id, hashRouteInputs( - sourceInfo.position, - targetInfo.position, - sourceInfo.direction, - targetInfo.direction, - userWaypoints - )); - } - - state.update((s) => ({ ...s, routes, routeInputHashes })); - - // ── Pass 2: async overlap-aware refinement ─────────────────────── - // Only needed when there are enough connections to overlap - if (orderedConnections.length > 1) { - this._refineRoutesAsync( - generation, - orderedConnections, - bySourcePort, - getPortInfoCached, - routeInputHashes - ); + for (const id of removed) sentNodes.delete(id); } + return { changed, removed }; }, - /** - * Async pass 2 — recalculate routes with overlap avoidance in batches. - * Yields to the browser every ASYNC_BATCH_SIZE routes. - * Bails out if routingGeneration has advanced (newer routing started). - */ - async _refineRoutesAsync( - generation: number, - orderedConnections: Connection[], - bySourcePort: Map, - getPortInfoCached: (nodeId: string, portIndex: number, isOutput: boolean) => PortInfo | null, - routeInputHashes: Map - ): Promise { - const usedCells = new Map>(); - const refinedRoutes = new Map(); - let processed = 0; - - for (const [, groupConns] of bySourcePort) { - const groupCells: Map>[] = []; - - for (const conn of groupConns) { - // Bail out if a newer recalculation has been triggered - if (generation !== routingGeneration) return; - - const $state = get(state); - const sourceInfo = getPortInfoCached(conn.sourceNodeId, conn.sourcePortIndex, true); - const targetInfo = getPortInfoCached(conn.targetNodeId, conn.targetPortIndex, false); - if (!sourceInfo || !targetInfo) continue; - - const userWaypoints = getUserWaypoints(conn.waypoints); - const result = computeRoute( - sourceInfo.position, - targetInfo.position, - sourceInfo.direction, - targetInfo.direction, - $state.grid, - userWaypoints, - usedCells - ); - refinedRoutes.set(conn.id, result); - - if (result.path.length > 0) { - groupCells.push(getPathCells(result.path, 2)); - } - - processed++; - - // Yield to browser every ASYNC_BATCH_SIZE routes - if (processed % ASYNC_BATCH_SIZE === 0) { - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - if (generation !== routingGeneration) return; - } - } - - // Add all cells from this group to usedCells for subsequent groups - for (const cells of groupCells) { - for (const [cellKey, dirs] of cells) { - if (!usedCells.has(cellKey)) usedCells.set(cellKey, new Set()); - for (const dir of dirs) { - usedCells.get(cellKey)!.add(dir); - } - } - } + /** Send scene changes; with many changed connections, those crossing `visible` are routed first */ + send(changes: RoutingChanges, visible?: Bounds | null): void { + const requests = changes.requests.filter((r) => !sameRequest(sentRequests.get(r.id), r)); + for (const request of requests) sentRequests.set(request.id, request); + for (const id of changes.removedRequests) { + sentRequests.delete(id); + routes.delete(id); } - // Final check before committing - if (generation !== routingGeneration) return; - - // Merge refined routes into current state - state.update((s) => { - const routes = new Map(s.routes); - for (const [id, route] of refinedRoutes) { - routes.set(id, route); - } - return { ...s, routes, routeInputHashes: new Map(routeInputHashes) }; - }); + const structure = { + nodes: changes.nodes, + removedNodes: changes.removedNodes, + removedRequests: changes.removedRequests + }; + if (visible && requests.length >= VISIBLE_FIRST_MIN) { + client.send({ ...structure, requests: requests.filter((r) => intersects(r, visible)) }); + client.send({ + nodes: [], + removedNodes: [], + removedRequests: [], + requests: requests.filter((r) => !intersects(r, visible)) + }); + } else { + client.send({ ...structure, requests }); + } }, - /** - * Invalidate route for a specific connection (will be recalculated on next render) - */ - invalidateRoute(connectionId: string): void { - state.update((s) => { - const routes = new Map(s.routes); - const routeInputHashes = new Map(s.routeInputHashes); - routes.delete(connectionId); - routeInputHashes.delete(connectionId); - return { ...s, routes, routeInputHashes }; - }); + /** Forget the scene and all routes */ + reset(): void { + sentNodes.clear(); + sentRequests.clear(); + routes.clear(); + client.reset(); }, - /** - * Invalidate routes for connections involving specific nodes - */ - invalidateRoutesForNodes(nodeIds: Set): void { - const connections = get(graphStore.connections); - const toInvalidate = connections.filter( - (c) => nodeIds.has(c.sourceNodeId) || nodeIds.has(c.targetNodeId) - ); - - state.update((s) => { - const routes = new Map(s.routes); - const routeInputHashes = new Map(s.routeInputHashes); - for (const conn of toInvalidate) { - routes.delete(conn.id); - routeInputHashes.delete(conn.id); - } - return { ...s, routes, routeInputHashes }; - }); - }, - - /** - * Add a user waypoint to a connection - * @param getPortInfo - Optional callback to get port info for immediate route recalculation - */ - addUserWaypoint( - connectionId: string, - position: Position, - getPortInfo?: (nodeId: string, portIndex: number, isOutput: boolean) => PortInfo | null - ): string | null { + addUserWaypoint(connectionId: string, position: Position): string | null { let waypointId: string | null = null; historyStore.mutate(() => { const connection = findConnection(connectionId); if (!connection) return; - - waypointId = generateId(); - const newWaypoint: Waypoint = { id: waypointId, position, isUserWaypoint: true }; - - // Drop auto waypoints — they'll be regenerated from the new user-waypoint set. - const existingUserWaypoints = getUserWaypoints(connection.waypoints); - const updatedWaypoints = [...existingUserWaypoints, newWaypoint]; - - applyWaypointUpdate(connection, updatedWaypoints, getPortInfo); + const id = generateId(); + waypointId = id; + graphStore.updateConnectionWaypoints(connectionId, [ + ...userWaypoints(connection.waypoints), + { id, position, isUserWaypoint: true } + ]); }); return waypointId; }, - /** - * Add a user waypoint at a specific index (for segment dragging) - * @param getPortInfo - Optional callback to get port info for immediate route recalculation - * @returns The ID of the new waypoint - */ - addUserWaypointAtIndex( - connectionId: string, - position: Position, - insertIndex: number, - getPortInfo?: (nodeId: string, portIndex: number, isOutput: boolean) => PortInfo | null - ): string | null { + /** Insert a user waypoint at a position in the route order (segment dragging) */ + addUserWaypointAtIndex(connectionId: string, position: Position, insertIndex: number): string | null { let waypointId: string | null = null; historyStore.mutate(() => { const connection = findConnection(connectionId); if (!connection) return; - - waypointId = generateId(); - const newWaypoint: Waypoint = { id: waypointId, position, isUserWaypoint: true }; - - const existingUserWaypoints = getUserWaypoints(connection.waypoints); - const updatedWaypoints = [ - ...existingUserWaypoints.slice(0, insertIndex), - newWaypoint, - ...existingUserWaypoints.slice(insertIndex) - ]; - - applyWaypointUpdate(connection, updatedWaypoints, getPortInfo); + const id = generateId(); + waypointId = id; + const existing = userWaypoints(connection.waypoints); + graphStore.updateConnectionWaypoints(connectionId, [ + ...existing.slice(0, insertIndex), + { id, position, isUserWaypoint: true }, + ...existing.slice(insertIndex) + ]); }); return waypointId; }, - /** - * Remove a user waypoint from a connection - * @param getPortInfo - Optional callback to get port info for immediate route recalculation - */ - removeUserWaypoint( - connectionId: string, - waypointId: string, - getPortInfo?: (nodeId: string, portIndex: number, isOutput: boolean) => PortInfo | null - ): void { + removeUserWaypoint(connectionId: string, waypointId: string): void { historyStore.mutate(() => { const connection = findConnection(connectionId); if (!connection?.waypoints) return; - - const updatedWaypoints = connection.waypoints.filter( - (w) => w.id !== waypointId || !w.isUserWaypoint + graphStore.updateConnectionWaypoints( + connectionId, + connection.waypoints.filter((w) => w.id !== waypointId || !w.isUserWaypoint) ); - - applyWaypointUpdate(connection, updatedWaypoints, getPortInfo); }); }, /** - * Move a waypoint to a new position and recalculate route. - * Not wrapped in historyStore.mutate — the caller owns the drag transaction - * so a single drag becomes one undo entry rather than one per move event. - * @param getPortInfo - Optional callback to get port info for route recalculation + * Move a waypoint. Not wrapped in historyStore.mutate: the caller owns the drag + * transaction, so a whole drag becomes one undo entry. */ - moveWaypoint( - connectionId: string, - waypointId: string, - newPosition: Position, - getPortInfo?: (nodeId: string, portIndex: number, isOutput: boolean) => PortInfo | null - ): void { + moveWaypoint(connectionId: string, waypointId: string, position: Position): void { const connection = findConnection(connectionId); if (!connection?.waypoints) return; - - const updatedWaypoints = connection.waypoints.map((w) => - w.id === waypointId ? { ...w, position: newPosition } : w + graphStore.updateConnectionWaypoints( + connectionId, + connection.waypoints.map((w) => (w.id === waypointId ? { ...w, position } : w)) ); - - applyWaypointUpdate(connection, updatedWaypoints, getPortInfo); }, - /** - * Clean up waypoints after drag ends - removes redundant/collinear waypoints - * and merges waypoints that are too close together - */ + /** After a waypoint drag: merge waypoints that are too close and drop collinear ones */ cleanupWaypoints( connectionId: string, getPortInfo?: (nodeId: string, portIndex: number, isOutput: boolean) => PortInfo | null ): void { - const connections = get(graphStore.connections); - const connection = connections.find((c) => c.id === connectionId); - if (!connection?.waypoints) return; - - const userWaypoints = getUserWaypoints(connection.waypoints); - if (userWaypoints.length === 0) return; - - // Get source and target positions for collinearity check - let sourceInfo: PortInfo | null = null; - let targetInfo: PortInfo | null = null; - if (getPortInfo) { - sourceInfo = getPortInfo(connection.sourceNodeId, connection.sourcePortIndex, true); - targetInfo = getPortInfo(connection.targetNodeId, connection.targetPortIndex, false); - } - const sourcePos = sourceInfo?.position || null; - const targetPos = targetInfo?.position || null; - - const MERGE_THRESHOLD = WAYPOINT_MERGE_THRESHOLD; - const COLLINEAR_THRESHOLD = WAYPOINT_COLLINEAR_THRESHOLD; - - // Helper to check if point is collinear with prev and next - const isCollinear = (prev: Position, curr: Position, next: Position): boolean => { - // Calculate perpendicular distance from curr to line prev->next - const dx = next.x - prev.x; - const dy = next.y - prev.y; - const len = Math.sqrt(dx * dx + dy * dy); - if (len < 1) return true; // prev and next are same point - - // Perpendicular distance = |cross product| / |line length| - const cross = Math.abs((curr.x - prev.x) * dy - (curr.y - prev.y) * dx); - const dist = cross / len; - return dist < COLLINEAR_THRESHOLD; - }; - - // Helper to check distance between two points - const distance = (a: Position, b: Position): number => { - return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); - }; - - let cleaned = [...userWaypoints]; - let changed = false; - - // Pass 1: Merge waypoints that are too close together - for (let i = cleaned.length - 1; i > 0; i--) { - if (distance(cleaned[i].position, cleaned[i - 1].position) < MERGE_THRESHOLD) { - // Keep the earlier waypoint, remove the later one - cleaned.splice(i, 1); - changed = true; - } - } - - // Pass 2: Remove collinear waypoints - // Build points array: [source, ...waypoints, target] - const points: Position[] = []; - if (sourcePos) points.push(sourcePos); - points.push(...cleaned.map(w => w.position)); - if (targetPos) points.push(targetPos); - - // Check each waypoint for collinearity (skip first and last which are source/target) - const startIdx = sourcePos ? 1 : 0; - const endIdx = targetPos ? points.length - 1 : points.length; - - const toRemove = new Set(); - for (let i = startIdx; i < endIdx; i++) { - const waypointIdx = sourcePos ? i - 1 : i; - if (waypointIdx < 0 || waypointIdx >= cleaned.length) continue; + const connection = findConnection(connectionId); + if (!connection) return; + const waypoints = userWaypoints(connection.waypoints); + if (waypoints.length === 0) return; - const prev = points[i - 1]; - const curr = points[i]; - const next = points[i + 1]; + const source = getPortInfo?.(connection.sourceNodeId, connection.sourcePortIndex, true)?.position; + const target = getPortInfo?.(connection.targetNodeId, connection.targetPortIndex, false)?.position; - if (prev && next && isCollinear(prev, curr, next)) { - toRemove.add(waypointIdx); - changed = true; - } - } + // Keep the earlier of two waypoints that are too close together + const merged = waypoints.filter( + (w, i) => i === 0 || distance(w.position, waypoints[i - 1].position) >= WAYPOINT_MERGE_THRESHOLD + ); - // Remove collinear waypoints (in reverse order to preserve indices) - const removeIndices = Array.from(toRemove).sort((a, b) => b - a); - for (const idx of removeIndices) { - cleaned.splice(idx, 1); - } + const points = [...(source ? [source] : []), ...merged.map((w) => w.position), ...(target ? [target] : [])]; + const offset = source ? 1 : 0; + const cleaned = merged.filter((w, i) => { + const prev = points[i + offset - 1]; + const next = points[i + offset + 1]; + return !(prev && next && isCollinear(prev, w.position, next)); + }); - // Only update if something changed - if (changed) { + if (cleaned.length !== waypoints.length) { graphStore.updateConnectionWaypoints(connectionId, cleaned); - - // Immediately recalculate route if we have port info (prevents flicker) - const $state = get(state); - if (sourceInfo && targetInfo) { - const result = computeRoute( - sourceInfo.position, - targetInfo.position, - sourceInfo.direction, - targetInfo.direction, - $state.grid, - cleaned - ); - - state.update((s) => { - const routes = new Map(s.routes); - routes.set(connectionId, result); - return { ...s, routes }; - }); - } } }, - /** - * Clear all user waypoints from a connection (reset route) - */ + /** Remove all user waypoints of a connection */ resetRoute(connectionId: string): void { historyStore.mutate(() => { graphStore.updateConnectionWaypoints(connectionId, []); - routingStore.invalidateRoute(connectionId); }); - }, - - /** - * Clear all cached routes - */ - clearRoutes(): void { - state.update((s) => ({ ...s, routes: new Map(), routeInputHashes: new Map() })); - }, - - /** - * Clear routes and grid context (call when navigating between levels) - * Forces a full grid rebuild on next setContext call - */ - clearContext(): void { - state.update((s) => ({ ...s, routes: new Map(), routeInputHashes: new Map(), context: null, grid: null })); } }; - -/** - * Build routing context from SvelteFlow nodes - */ -export function buildRoutingContext( - nodes: Array<{ id: string; position: Position; width?: number; height?: number; measured?: { width?: number; height?: number } }>, - padding = ROUTING_CONTEXT_PADDING -): { nodeBounds: Map; canvasBounds: Bounds } { - const nodeBounds = new Map(); - - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - - for (const node of nodes) { - const width = node.measured?.width ?? node.width ?? DEFAULT_NODE_WIDTH; - const height = node.measured?.height ?? node.height ?? DEFAULT_NODE_HEIGHT; - - // Node position is center (nodeOrigin = [0.5, 0.5]) - const left = node.position.x - width / 2; - const top = node.position.y - height / 2; - - nodeBounds.set(node.id, { - x: left, - y: top, - width, - height - }); - - // Track canvas bounds - minX = Math.min(minX, left - ROUTING_MARGIN); - minY = Math.min(minY, top - ROUTING_MARGIN); - maxX = Math.max(maxX, left + width + ROUTING_MARGIN); - maxY = Math.max(maxY, top + height + ROUTING_MARGIN); - } - - // Add padding - const canvasBounds: Bounds = { - x: minX - padding, - y: minY - padding, - width: maxX - minX + 2 * padding, - height: maxY - minY + 2 * padding - }; - - return { nodeBounds, canvasBounds }; -} From e9c916395fff47bf6fef3120f017b50d89e19232 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Sun, 13 Sep 2026 17:07:07 +0200 Subject: [PATCH 6/8] Use raw state for canvas nodes and edges, compute edge highlights centrally, lift zoom-out limit --- src/lib/components/FlowCanvas.svelte | 20 +- src/lib/components/edges/ArrowEdge.svelte | 153 ------ .../components/edges/OrthogonalEdge.svelte | 464 ++++++------------ src/lib/constants/layout.ts | 3 + src/lib/stores/edgeHighlight.ts | 62 +++ 5 files changed, 228 insertions(+), 474 deletions(-) delete mode 100644 src/lib/components/edges/ArrowEdge.svelte create mode 100644 src/lib/stores/edgeHighlight.ts diff --git a/src/lib/components/FlowCanvas.svelte b/src/lib/components/FlowCanvas.svelte index 8c3773a0..5d3be6e8 100644 --- a/src/lib/components/FlowCanvas.svelte +++ b/src/lib/components/FlowCanvas.svelte @@ -38,6 +38,8 @@ import { NODE_TYPES } from '$lib/constants/nodeTypes'; import { GRID_SIZE, SNAP_GRID, BACKGROUND_GAP } from '$lib/constants/grid'; import { createRoutingSync } from './canvas/routingSync'; + import { createEdgeHighlighter } from '$lib/stores/edgeHighlight'; + import { CANVAS_MIN_ZOOM } from '$lib/constants/layout'; import { shallowEqualArray, shallowEqualRecord } from '$lib/utils/shallowEqual'; import type { NodeInstance, Connection, Annotation } from '$lib/nodes/types'; import type { EventInstance } from '$lib/events/types'; @@ -298,14 +300,15 @@ // SvelteFlow state - this is the source of truth for visual state // Block nodes from graphStore - let blockNodes = $state([]); + // Raw state (no deep proxies): arrays and objects are replaced, never mutated + let blockNodes = $state.raw([]); // Event nodes from eventStore - let eventNodes = $state([]); + let eventNodes = $state.raw([]); // Annotation nodes from graphStore - let annotationNodes = $state([]); + let annotationNodes = $state.raw([]); // Combined nodes for SvelteFlow - let nodes = $state([]); - let edges = $state([]); + let nodes = $state.raw([]); + let edges = $state.raw([]); // O(1) node lookup map — kept in sync with nodes array via $effect let nodeMap = $derived(new Map(nodes.map(n => [n.id, n]))); @@ -595,6 +598,10 @@ return ids; } + // Edge highlights for the hovered handle and selected node, computed centrally + const edgeHighlighter = createEdgeHighlighter(() => edges); + cleanups.push(edgeHighlighter.destroy); + function rebuildEdges(connections: Connection[]): void { const visibleIds = getVisibleNodeIds(); const currentEdgeSelection = new Map(edges.map((e) => [e.id, e.selected])); @@ -605,6 +612,7 @@ if (currentEdgeSelection.get(conn.id)) edge.selected = true; return edge; }); + edgeHighlighter.refresh(); } // Subscribe to current connections (filtered by current navigation context) @@ -760,6 +768,7 @@ // Force sync edges from store after deletion const afterConnections = get(graphStore.connections); edges = afterConnections.map(toFlowEdge); + edgeHighlighter.refresh(); isSyncing = false; } @@ -970,6 +979,7 @@ onedgecontextmenu={readonly ? undefined : handleEdgeContextMenu} onpanecontextmenu={readonly ? undefined : handlePaneContextMenu} nodeOrigin={[0.5, 0.5]} + minZoom={CANVAS_MIN_ZOOM} {...{ snapToGrid: true, snapGrid: SNAP_GRID } as any} deleteKeyCode={readonly ? null : ['Delete', 'Backspace']} selectionKeyCode={readonly ? null : ['Shift']} diff --git a/src/lib/components/edges/ArrowEdge.svelte b/src/lib/components/edges/ArrowEdge.svelte deleted file mode 100644 index 7bc8684e..00000000 --- a/src/lib/components/edges/ArrowEdge.svelte +++ /dev/null @@ -1,153 +0,0 @@ - - - - - - - - - - - - diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index 3cba005b..5aba0f89 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -1,39 +1,44 @@ - - + + - + - {#each userWaypoints() as waypoint (waypoint.id)} + {#each userWaypoints as waypoint (waypoint.id)} - {#each segmentMidpoints() as midpoint (midpoint.segmentIndex)} + {#each segmentMidpoints as midpoint (midpoint.segmentIndex)} - + diff --git a/src/lib/constants/layout.ts b/src/lib/constants/layout.ts index 62a17e74..ea34cac9 100644 --- a/src/lib/constants/layout.ts +++ b/src/lib/constants/layout.ts @@ -15,6 +15,9 @@ export const PANEL_TOGGLES_WIDTH = 52; /** Minimum width for bottom panels when split */ export const MIN_BOTTOM_PANEL_WIDTH = 300; +/** Smallest canvas zoom; low enough that zooming out is effectively unlimited */ +export const CANVAS_MIN_ZOOM = 0.01; + /** Default offset for duplicated nodes */ export const DUPLICATE_OFFSET = { x: 50, y: 50 }; diff --git a/src/lib/stores/edgeHighlight.ts b/src/lib/stores/edgeHighlight.ts new file mode 100644 index 00000000..f954d58e --- /dev/null +++ b/src/lib/stores/edgeHighlight.ts @@ -0,0 +1,62 @@ +/** + * Edge highlight - colors of edges attached to the hovered handle or the selected node + * + * Computed once per hover or selection change, so an edge only re-renders when + * its own highlight changes instead of every edge evaluating each change. + */ + +import type { Edge } from '@xyflow/svelte'; +import { SvelteMap } from 'svelte/reactivity'; +import { hoveredHandle, selectedNodeHighlight } from './hoveredHandle'; + +/** Highlight color when the node defines none */ +const ACCENT = 'var(--accent)'; + +/** Edge id to highlight color; absent when the edge is not highlighted */ +export const edgeHighlights = new SvelteMap(); + +export function createEdgeHighlighter(getEdges: () => Edge[]) { + let hovered: { nodeId: string; handleId: string; color?: string } | null = null; + let selected: { nodeId: string; color?: string } | null = null; + + /** Recompute highlights, e.g. after the edge list changed */ + function refresh(): void { + const next = new Map(); + if (hovered || selected) { + for (const edge of getEdges()) { + const onHoveredHandle = + hovered !== null && + ((edge.source === hovered.nodeId && edge.sourceHandle === hovered.handleId) || + (edge.target === hovered.nodeId && edge.targetHandle === hovered.handleId)); + if (onHoveredHandle) { + next.set(edge.id, hovered!.color || ACCENT); + } else if (selected && (edge.source === selected.nodeId || edge.target === selected.nodeId)) { + next.set(edge.id, selected.color || ACCENT); + } + } + } + + for (const id of [...edgeHighlights.keys()]) { + if (!next.has(id)) edgeHighlights.delete(id); + } + for (const [id, color] of next) { + if (edgeHighlights.get(id) !== color) edgeHighlights.set(id, color); + } + } + + const unsubscribers = [ + hoveredHandle.subscribe((h) => { + hovered = h; + refresh(); + }), + selectedNodeHighlight.subscribe((s) => { + selected = s; + refresh(); + }) + ]; + + return { + refresh, + destroy: () => unsubscribers.forEach((unsubscribe) => unsubscribe()) + }; +} From 4d308b9c5a035359070fc138e97b0708d3844f20 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Sun, 13 Sep 2026 17:28:17 +0200 Subject: [PATCH 7/8] Route through waypoints optimally as a whole instead of leg by leg --- src/lib/routing/engine.ts | 129 +++++++++++++++++++++++---------- src/lib/routing/router.test.ts | 28 ++++++- src/lib/routing/search.ts | 119 +++++++++++++++++++++++------- 3 files changed, 207 insertions(+), 69 deletions(-) diff --git a/src/lib/routing/engine.ts b/src/lib/routing/engine.ts index bea60eb6..365d6b1d 100644 --- a/src/lib/routing/engine.ts +++ b/src/lib/routing/engine.ts @@ -10,7 +10,7 @@ import type { Bounds, PortInfo, PortStub, RouteRequest, RouteResult, RoutingScene } from './types'; import { ObstacleMap } from './obstacleMap'; import { Occupancy, collectCells } from './occupancy'; -import { searchGridPath } from './search'; +import { searchGridPath, searchGridArrivals, TURN_COST, type GridPath, type SearchStart } from './search'; import { sameRequest, sameWaypoints } from './scene'; import { DIRECTION_INDEX, @@ -160,10 +160,48 @@ function tileKey(tx: number, ty: number): number { return (tx + 0x8000) * 0x10000 + (ty + 0x8000); } -/** Orthogonal fallback when no path exists: first along the start direction, then turn */ -function fallbackCorners(from: GridPoint, to: GridPoint, dir: number): GridPoint[] { - const corner = dir <= 1 ? { gx: to.gx, gy: from.gy } : { gx: from.gx, gy: to.gy }; - return [from, corner, to]; +/** Orthogonal fallback through all stops when no path exists: first along the start direction, then turn */ +function fallbackCorners(from: GridPoint, stops: GridPoint[], dir: number): GridPoint[] { + const corners = [from]; + let current = from; + for (const stop of stops) { + if (dir <= 1) corners.push({ gx: stop.gx, gy: current.gy }); + else corners.push({ gx: current.gx, gy: stop.gy }); + corners.push(stop); + current = stop; + } + return corners; +} + +/** Cost of leaving a waypoint in a direction after an arrival: turning costs extra, reversing is not allowed */ +function continuationCost(arrival: GridPath, dir: number): number { + if (dir === OPPOSITE[arrival.arrivalDir]) return Infinity; + return arrival.cost + (dir === arrival.arrivalDir ? 0 : TURN_COST); +} + +/** Cheapest arrival to continue from in a direction */ +function bestArrivalFor(arrivals: (GridPath | undefined)[], dir: number): GridPath | undefined { + let best: GridPath | undefined; + let bestCost = Infinity; + for (const arrival of arrivals) { + if (!arrival) continue; + const cost = continuationCost(arrival, dir); + if (cost < bestCost) { + best = arrival; + bestCost = cost; + } + } + return best; +} + +/** Start directions for the leg after a waypoint, each with the cheapest way to get there */ +function continuations(arrivals: (GridPath | undefined)[]): SearchStart[] { + const starts: SearchStart[] = []; + for (let dir = 0; dir < 4; dir++) { + const arrival = bestArrivalFor(arrivals, dir); + if (arrival) starts.push({ dir, cost: continuationCost(arrival, dir) }); + } + return starts; } function sortedKeys(keys: Iterable): string[] { @@ -481,50 +519,61 @@ export class RoutingEngine { const targetDir = DIRECTION_INDEX[request.target.direction]; const exit = ray(start, startDir, PORT_EXIT_CELLS); const entry = ray(end, targetDir, PORT_EXIT_CELLS); - const congestion = this.congestion ? this.occupancy : undefined; + const waypoints = request.waypoints.map((w) => ({ gx: toGrid(w.position.x), gy: toGrid(w.position.y) })); + const fallback = (): RawRoute => ({ + corners: simplifyGridPath(fallbackCorners(start, [...waypoints, end], startDir)), + isFallback: true, + tiles: [] + }); + // A port covered by another node is unreachable; skip the (exhaustive) search - const enclosed = this.isEnclosed(exit, startDir) || this.isEnclosed(entry, targetDir); + if (this.isEnclosed(exit, startDir) || this.isEnclosed(entry, targetDir)) return fallback(); - const stops = request.waypoints.map((w) => ({ gx: toGrid(w.position.x), gy: toGrid(w.position.y) })); - stops.push(end); + const options = { + congestion: this.congestion ? this.occupancy : undefined, + heuristicWeight: this.heuristicWeight + }; - const corners: GridPoint[] = []; - let isFallback = false; + // Legs to waypoints keep the cheapest arrival per direction, so the route is + // optimal as a whole instead of leg by leg (no loops around waypoints) + const legs: (GridPath | undefined)[][] = []; let from = start; - let dir = startDir; - - for (let k = 0; k < stops.length; k++) { - const to = stops[k]; - const last = k === stops.length - 1; - const forced = k === 0 ? [...exit, to] : [from, to]; - if (last) forced.push(...entry); - - const path = enclosed - ? null - : searchGridPath(this.map, { - congestion, - heuristicWeight: this.heuristicWeight, + let starts: SearchStart[] = [{ dir: startDir, cost: 0 }]; + for (let k = 0; k < waypoints.length; k++) { + const arrivals = searchGridArrivals(this.map, { + ...options, start: from, - startDir: dir, - end: to, - endDir: last ? OPPOSITE[targetDir] : -1, - forced + starts, + end: waypoints[k], + endDir: -1, + forced: k === 0 ? [...exit, waypoints[k]] : [from, waypoints[k]] }); + if (!arrivals.some((a) => a !== undefined)) return fallback(); + legs.push(arrivals); + starts = continuations(arrivals); + from = waypoints[k]; + } - let leg: GridPoint[]; - if (path) { - leg = path.corners; - dir = path.arrivalDir; - } else { - isFallback = true; - leg = fallbackCorners(from, to, dir); - } - const skipFirst = corners.length > 0 ? 1 : 0; - for (let i = skipFirst; i < leg.length; i++) corners.push(leg[i]); - from = to; + const last = searchGridPath(this.map, { + ...options, + start: from, + starts, + end, + endDir: OPPOSITE[targetDir], + forced: [...(waypoints.length === 0 ? exit : [from]), ...entry] + }); + if (!last) return fallback(); + + // Walk back through the legs, taking the arrival each continuation started from + let corners = last.corners; + let dir = last.startDir; + for (let k = legs.length - 1; k >= 0; k--) { + const arrival = bestArrivalFor(legs[k], dir)!; + corners = [...arrival.corners, ...corners.slice(1)]; + dir = arrival.startDir; } - return { corners: simplifyGridPath(corners), isFallback, tiles: [] }; + return { corners: simplifyGridPath(corners), isFallback: false, tiles: [] }; } } diff --git a/src/lib/routing/router.test.ts b/src/lib/routing/router.test.ts index 793b64d5..ec9f26bd 100644 --- a/src/lib/routing/router.test.ts +++ b/src/lib/routing/router.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect } from 'vitest'; import type { Position } from '$lib/types/common'; -import type { RouteResult } from './types'; +import type { RouteResult, RoutingScene } from './types'; import { generateScenario, buildScene, sceneNode, sceneRequest, type ScenarioOptions } from './testing/scenario'; import { checkRoutes, measureRoutes } from './testing/invariants'; import { RoutingEngine, routeScene } from './engine'; +import { getPortInfo } from './portGeometry'; import { GRID_SIZE } from './constants'; describe('scenario generator', () => { @@ -143,6 +144,31 @@ describe('routing engine', () => { expect(checkRoutes(buildScene(scenario), engine.getRoutes())).toEqual([]); }); + it('routes through a waypoint beside the straight line without a detour', () => { + const source = getPortInfo({ x: 0, y: 0 }, 80, 40, 0, 0, 1, true); + const target = getPortInfo({ x: 400, y: 0 }, 80, 40, 0, 0, 1, false); + const waypoint = { id: 'w', position: { x: 200, y: 20 }, isUserWaypoint: true }; + const scene: RoutingScene = { + nodes: new Map([ + ['a', { bounds: { x: -40, y: -20, width: 80, height: 40 }, ports: [source] }], + ['b', { bounds: { x: 360, y: -20, width: 80, height: 40 }, ports: [target] }] + ]), + requests: [{ id: 'c', netId: 'a:0', source, target, waypoints: [waypoint] }] + }; + + const routes = routeScene(scene); + const path = routes.get('c')!.path; + expect(checkRoutes(scene, routes)).toEqual([]); + expect(passesThrough(routes.get('c')!, waypoint.position)).toBe(true); + + // Straight run plus going 20px aside and back; a loop around the waypoint would be longer + let length = 0; + for (let i = 0; i < path.length - 1; i++) { + length += Math.abs(path[i + 1].x - path[i].x) + Math.abs(path[i + 1].y - path[i].y); + } + expect(length).toBe(path[path.length - 1].x - path[0].x + 2 * 20); + }); + it('reports only changes', () => { const scene = buildScene(generateScenario({ connections: 50, seed: 6 })); const engine = new RoutingEngine(); diff --git a/src/lib/routing/search.ts b/src/lib/routing/search.ts index 45cd4e6a..384fb073 100644 --- a/src/lib/routing/search.ts +++ b/src/lib/routing/search.ts @@ -1,7 +1,7 @@ /** * Orthogonal A* on the obstacle grid * - * Turn penalty, no reversals, fixed start direction and optional arrival + * Turn penalty, no reversals, weighted start directions and optional arrival * direction. States (cell, direction) are packed into integers inside a * bounded search window and all bookkeeping uses reused typed arrays, so a * search allocates nothing but its result. @@ -25,6 +25,9 @@ const MAX_STATES = 4_000_000; /** Upper bound for expanded states of one search window (caps time for unreachable ends) */ const MAX_EXPANSIONS = 250_000; +/** Cost above the cheapest arrival up to which arrivals from other directions are still collected */ +const ARRIVAL_SLACK = 2 * TURN_COST + 2; + /** Counters for benchmarks and profiling */ export const searchStats = { searches: 0, expanded: 0 }; @@ -33,13 +36,20 @@ export interface CongestionCosts { penalty(gx: number, gy: number, axis: 0 | 1): number; } +/** A start direction with the cost already spent to get there */ +export interface SearchStart { + dir: number; + cost: number; +} + export interface GridSearch { /** Optional congestion costs added per step */ congestion?: CongestionCosts; /** Heuristic weight; above 1 trades bounded path cost for fewer expanded states (default 1) */ heuristicWeight?: number; start: GridPoint; - startDir: number; + /** Start directions; the first step follows the start direction */ + starts: SearchStart[]; end: GridPoint; /** Required arrival direction (turn penalty otherwise), or -1 for any */ endDir: number; @@ -50,7 +60,10 @@ export interface GridSearch { export interface GridPath { /** Corner cells from start to end, both included */ corners: GridPoint[]; + startDir: number; arrivalDir: number; + /** Total cost including the start cost */ + cost: number; } // Reused buffers, grown on demand @@ -122,6 +135,13 @@ function minTurns(dx: number, dy: number, d: number): number { return ahead >= 0 ? 1 : 2; } +/** Admissible remaining cost estimate */ +function heuristic(gx: number, gy: number, d: number, end: GridPoint): number { + const dx = end.gx - gx; + const dy = end.gy - gy; + return Math.abs(dx) + Math.abs(dy) + TURN_COST * minTurns(dx, dy, d); +} + function windowAround(request: GridSearch, padding: number): GridRect { const { start, end } = request; return { @@ -132,26 +152,50 @@ function windowAround(request: GridSearch, padding: number): GridRect { }; } +/** Search in widening windows; arrivals are indexed by arrival direction */ +function searchWindows(map: ObstacleMap, request: GridSearch, collect: boolean): (GridPath | undefined)[] | null { + for (const padding of WINDOW_PADDINGS) { + const found = searchWindow(map, request, windowAround(request, padding), collect); + if (found) return found; + } + const near = windowAround(request, WINDOW_PADDINGS[0]); + const e = map.extent; + return searchWindow( + map, + request, + { + minGx: Math.min(near.minGx, e.minGx - WINDOW_PADDINGS[0]), + minGy: Math.min(near.minGy, e.minGy - WINDOW_PADDINGS[0]), + maxGx: Math.max(near.maxGx, e.maxGx + WINDOW_PADDINGS[0]), + maxGy: Math.max(near.maxGy, e.maxGy + WINDOW_PADDINGS[0]) + }, + collect + ); +} + /** * Find the cheapest orthogonal path, widening the search window when needed. * Returns null if the end is unreachable. */ export function searchGridPath(map: ObstacleMap, request: GridSearch): GridPath | null { - for (const padding of WINDOW_PADDINGS) { - const path = searchWindow(map, request, windowAround(request, padding)); - if (path) return path; - } - const near = windowAround(request, WINDOW_PADDINGS[0]); - const e = map.extent; - return searchWindow(map, request, { - minGx: Math.min(near.minGx, e.minGx - WINDOW_PADDINGS[0]), - minGy: Math.min(near.minGy, e.minGy - WINDOW_PADDINGS[0]), - maxGx: Math.max(near.maxGx, e.maxGx + WINDOW_PADDINGS[0]), - maxGy: Math.max(near.maxGy, e.maxGy + WINDOW_PADDINGS[0]) - }); + return searchWindows(map, request, false)?.find((path) => path !== undefined) ?? null; +} + +/** + * Find the cheapest path to the end for each arrival direction that is not far + * more expensive than the cheapest one. Used for waypoints, where the best way + * to arrive depends on how the route continues. + */ +export function searchGridArrivals(map: ObstacleMap, request: GridSearch): (GridPath | undefined)[] { + return searchWindows(map, request, true) ?? []; } -function searchWindow(map: ObstacleMap, request: GridSearch, win: GridRect): GridPath | null { +function searchWindow( + map: ObstacleMap, + request: GridSearch, + win: GridRect, + collect: boolean +): (GridPath | undefined)[] | null { const w = win.maxGx - win.minGx + 1; const h = win.maxGy - win.minGy + 1; const states = w * h * 4; @@ -180,26 +224,45 @@ function searchWindow(map: ObstacleMap, request: GridSearch, win: GridRect): Gri return false; }; - const startState = ((start.gy - y0) * w + (start.gx - x0)) * 4 + request.startDir; - gScore[startState] = 0; - parent[startState] = -1; - stamp[startState] = seen; - const h0 = Math.abs(end.gx - start.gx) + Math.abs(end.gy - start.gy); - heapPush(startState, weight * h0 * 65536 + h0); + const startCell = (start.gy - y0) * w + (start.gx - x0); + for (const s of request.starts) { + const state = startCell * 4 + s.dir; + if (stamp[state] === seen && gScore[state] <= s.cost) continue; + gScore[state] = s.cost; + parent[state] = -1; + stamp[state] = seen; + const hh = heuristic(start.gx, start.gy, s.dir, end); + heapPush(state, (s.cost + weight * hh) * 65536 + hh); + } + const arrivals: (GridPath | undefined)[] = []; + let arrivalCount = 0; + let bestArrival = Infinity; let expanded = 0; + while (heapSize > 0) { + if (arrivalCount > 0 && heapKey[0] / 65536 > bestArrival + ARRIVAL_SLACK) break; const s = heapPop(); if (stamp[s] === closed) continue; stamp[s] = closed; searchStats.expanded++; - if (++expanded > MAX_EXPANSIONS) return null; + if (++expanded > MAX_EXPANSIONS) break; const d = s & 3; const cell = s >> 2; const gx = (cell % w) + x0; const gy = ((cell / w) | 0) + y0; - if (gx === end.gx && gy === end.gy) return reconstruct(s, w, x0, y0); + + if (gx === end.gx && gy === end.gy) { + const path = reconstruct(s, w, x0, y0); + if (!collect) return [path]; + if (arrivals[d] === undefined) { + arrivals[d] = path; + arrivalCount++; + bestArrival = Math.min(bestArrival, path.cost); + if (arrivalCount === 4) break; + } + } const g = gScore[s]; const isStart = parent[s] === -1; @@ -229,20 +292,19 @@ function searchWindow(map: ObstacleMap, request: GridSearch, win: GridRect): Gri parent[ns] = s; stamp[ns] = seen; - const dx = end.gx - nx; - const dy = end.gy - ny; - const hh = Math.abs(dx) + Math.abs(dy) + TURN_COST * minTurns(dx, dy, nd); + const hh = heuristic(nx, ny, nd, end); heapPush(ns, (cost + weight * hh) * 65536 + hh); } } - return null; + return arrivalCount > 0 ? arrivals : null; } function reconstruct(goal: number, w: number, x0: number, y0: number): GridPath { const corners: GridPoint[] = []; let s = goal; let nextDir = -1; + let startDir = goal & 3; while (s !== -1) { const d = s & 3; const p = parent[s]; @@ -250,9 +312,10 @@ function reconstruct(goal: number, w: number, x0: number, y0: number): GridPath const cell = s >> 2; corners.push({ gx: (cell % w) + x0, gy: ((cell / w) | 0) + y0 }); } + if (p === -1) startDir = d; nextDir = d; s = p; } corners.reverse(); - return { corners, arrivalDir: goal & 3 }; + return { corners, startDir, arrivalDir: goal & 3, cost: gScore[goal] }; } From 115646c4283c028301f35c0c08b841d9198f8466 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Sun, 13 Sep 2026 17:31:27 +0200 Subject: [PATCH 8/8] Cap assembly animation at 2 seconds for large graphs --- src/lib/animation/assemblyAnimation.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/lib/animation/assemblyAnimation.ts b/src/lib/animation/assemblyAnimation.ts index 9b3d3da8..095b3715 100644 --- a/src/lib/animation/assemblyAnimation.ts +++ b/src/lib/animation/assemblyAnimation.ts @@ -31,10 +31,16 @@ const CONFIG = { nodeStagger: 50, // Delay between nodes (ms) flyDistanceMargin: 100, // Extra margin beyond viewport edge (px in flow coords) + nodeLanded: 0.7, // Share of a node's fly-in after which its edges may start drawing + // Edge animation edgeDuration: 300, // Duration of edge drawing (ms) arrowDuration: 150, // Duration of arrow pop-in (ms) + // Total duration + maxDuration: 2000, // Upper bound for the whole animation; large graphs stagger faster (ms) + cleanupDelay: 200, // Extra time before animation classes are removed (ms) + // Timing delays initialDelay: 100, // Wait for DOM to be ready (ms) fitViewDelay: 50, // Wait after fitView before animating (ms) @@ -215,9 +221,18 @@ export function runAssemblyAnimation( // Internal Functions // ============================================================================ +/** Node stagger, shortened for large graphs so the whole animation stays within maxDuration */ +function nodeStaggerFor(nodeCount: number): number { + const fixed = + CONFIG.nodeDuration * (1 + CONFIG.nodeLanded) + CONFIG.edgeDuration + CONFIG.arrowDuration + CONFIG.cleanupDelay; + const budget = Math.max(0, CONFIG.maxDuration - fixed); + return Math.min(CONFIG.nodeStagger, budget / Math.max(1, nodeCount)); +} + function calculateAnimationTiming(nodes: NodeInfo[], edges: EdgeInfo[], viewport: ViewportInfo): void { // Shuffle nodes for organic feel const shuffledNodes = [...nodes].sort(() => Math.random() - 0.5); + const stagger = nodeStaggerFor(shuffledNodes.length); // Calculate top-left corner of viewport in flow coordinates // This is where the PathView logo is @@ -234,7 +249,7 @@ function calculateAnimationTiming(nodes: NodeInfo[], edges: EdgeInfo[], viewport nodeFlyFrom = new Map(); shuffledNodes.forEach((node, index) => { - nodeDelays.set(node.id, index * CONFIG.nodeStagger); + nodeDelays.set(node.id, index * stagger); // Calculate offset from node's final position to the spawn point nodeFlyFrom.set(node.id, { @@ -248,7 +263,7 @@ function calculateAnimationTiming(nodes: NodeInfo[], edges: EdgeInfo[], viewport edges.forEach((edge) => { const sourceDelay = nodeDelays.get(edge.source) ?? 0; const targetDelay = nodeDelays.get(edge.target) ?? 0; - const bothNodesLanded = Math.max(sourceDelay, targetDelay) + CONFIG.nodeDuration * 0.7; + const bothNodesLanded = Math.max(sourceDelay, targetDelay) + CONFIG.nodeDuration * CONFIG.nodeLanded; edgeDelays.set(edge.id, bothNodesLanded); }); @@ -262,10 +277,10 @@ function calculateAnimationTiming(nodes: NodeInfo[], edges: EdgeInfo[], viewport addSkipListeners(); // Schedule cleanup - const maxNodeDelay = shuffledNodes.length * CONFIG.nodeStagger; + const maxNodeDelay = shuffledNodes.length * stagger; const maxEdgeDelay = Math.max(...Array.from(edgeDelays.values()), 0); const totalDuration = Math.max(maxNodeDelay, maxEdgeDelay) + - CONFIG.nodeDuration + CONFIG.edgeDuration + CONFIG.arrowDuration + 200; + CONFIG.nodeDuration + CONFIG.edgeDuration + CONFIG.arrowDuration + CONFIG.cleanupDelay; cleanupTimeoutId = setTimeout(cleanup, totalDuration); }