diff --git a/.gitea/actions/setup/action.yml b/.gitea/actions/setup/action.yml new file mode 100644 index 0000000..3246ccf --- /dev/null +++ b/.gitea/actions/setup/action.yml @@ -0,0 +1,28 @@ +name: Setup +description: Restore caches and install pnpm dependencies (run after checkout) + +runs: + using: composite + steps: + - name: πŸ’Ύ Setup pnpm Cache + uses: actions/cache@v4 + with: + path: .pnpm-store + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + + - name: πŸ¦€ Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: πŸ“¦ Install Dependencies + shell: bash + run: pnpm install --frozen-lockfile --store-dir .pnpm-store diff --git a/.gitea/workflows/benchmark.yaml b/.gitea/workflows/benchmark.yaml index c965006..8cabde8 100644 --- a/.gitea/workflows/benchmark.yaml +++ b/.gitea/workflows/benchmark.yaml @@ -12,9 +12,9 @@ env: CARGO_TARGET_DIR: target jobs: - release: + benchmark: runs-on: ubuntu-latest - container: git.max-richter.dev/max/nodarium-ci:bce06da456e3c008851ac006033cfff256015a47 + container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69 steps: - name: πŸ“‘ Checkout Code @@ -23,37 +23,45 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITEA_TOKEN }} - - name: πŸ’Ύ Setup pnpm Cache - uses: actions/cache@v4 - with: - path: ${{ env.PNPM_CACHE_FOLDER }} - key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm- + - name: πŸ”§ Setup + uses: ./.gitea/actions/setup - - name: πŸ¦€ Cache Cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo- - - - name: πŸ“¦ Install Dependencies - run: pnpm install --frozen-lockfile --store-dir ${{ env.PNPM_CACHE_FOLDER }} - - - name: πŸ› οΈBuild Nodes + - name: πŸ› οΈ Build Nodes run: pnpm build:nodes - name: πŸƒ Execute Runtime run: pnpm run --filter @nodarium/app bench - - name: πŸ“€ Upload Benchmark Results - uses: actions/upload-artifact@v3 - with: - name: benchmark-data - path: app/benchmark/out/ - compression: 9 + - name: πŸ”‘ Setup SSH key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.GIT_SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + cat >> ~/.ssh/config <<'EOF' + Host git.max-richter.dev + Port 2222 + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes + EOF + ssh-keyscan -p 2222 -H git.max-richter.dev >> ~/.ssh/known_hosts + + - name: πŸ“€ Push Results + env: + BENCH_REPO: "git@git.max-richter.dev:max/nodarium-benchmarks.git" + run: | + git config --global user.name "nodarium-bot" + git config --global user.email "nodarium-bot@max-richter.dev" + + git clone git@git.max-richter.dev:max/nodarium-benchmarks.git target_bench_repo + + BRANCH="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" + SAFE_PR_NAME=$(printf "%s" "$BRANCH" | tr '/' '-') + DEST_DIR="target_bench_repo/data/$SAFE_PR_NAME/$(date +%s)" + mkdir -p "$DEST_DIR" + + cp app/benchmark/out/*.json "$DEST_DIR/" + + cd target_bench_repo + git add . + git commit -m "Update benchmarks for $SAFE_PR_NAME: ${{ gitea.sha }}" + git push origin main diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 67f75a6..abf9f44 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -13,9 +13,9 @@ env: CARGO_TARGET_DIR: target jobs: - release: + quality: runs-on: ubuntu-latest - container: git.max-richter.dev/max/nodarium-ci:bce06da456e3c008851ac006033cfff256015a47 + container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69 steps: - name: πŸ“‘ Checkout Code @@ -24,27 +24,8 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITEA_TOKEN }} - - name: πŸ’Ύ Setup pnpm Cache - uses: actions/cache@v4 - with: - path: ${{ env.PNPM_CACHE_FOLDER }} - key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm- - - - name: πŸ¦€ Cache Cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo- - - - name: πŸ“¦ Install Dependencies - run: pnpm install --frozen-lockfile --store-dir ${{ env.PNPM_CACHE_FOLDER }} + - name: πŸ”§ Setup + uses: ./.gitea/actions/setup - name: 🧹 Quality Control run: | @@ -52,7 +33,61 @@ jobs: pnpm format:check pnpm check pnpm build - xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" pnpm test + + test-unit: + runs-on: ubuntu-latest + container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69 + + steps: + - name: πŸ“‘ Checkout Code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITEA_TOKEN }} + + - name: πŸ”§ Setup + uses: ./.gitea/actions/setup + + - name: πŸ§ͺ Run Tests + run: xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" pnpm test:unit + + test-e2e: + runs-on: ubuntu-latest + container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69 + + steps: + - name: πŸ“‘ Checkout Code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITEA_TOKEN }} + + - name: πŸ”§ Setup + uses: ./.gitea/actions/setup + + - name: πŸ—οΈ Build Web Assets + run: pnpm build + + - name: πŸ§ͺ Run Tests + run: xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" pnpm test:e2e + + deploy: + runs-on: ubuntu-latest + needs: [quality, test-e2e, test-unit] + container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69 + + steps: + - name: πŸ“‘ Checkout Code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITEA_TOKEN }} + + - name: πŸ”§ Setup + uses: ./.gitea/actions/setup + + - name: πŸ—οΈ Build Web Assets + run: pnpm build - name: πŸš€ Create Release Commit if: gitea.ref_type == 'tag' diff --git a/app/benchmark/index.ts b/app/benchmark/index.ts index beec3ea..bd6070c 100644 --- a/app/benchmark/index.ts +++ b/app/benchmark/index.ts @@ -1,54 +1,204 @@ import type { Graph, Graph as GraphType, NodeId } from '@nodarium/types'; -import { createLogger, createPerformanceStore } from '@nodarium/utils'; +import { createLogger, createPerformanceStore, splitNestedArray } from '@nodarium/utils'; + import { mkdir, writeFile } from 'node:fs/promises'; +import { freemem, loadavg, totalmem } from 'node:os'; import { resolve } from 'node:path'; + import { MemoryRuntimeExecutor } from '../src/lib/runtime/runtime-executor.ts'; import { BenchmarkRegistry } from './benchmarkRegistry.ts'; + +import { + getMachineInfo, + measureCpuUsage, + readCgroupCpuStat, + readCpuSnapshot, + readProcMemInfo, + SystemSample +} from './systemStats.ts'; import defaultPlantTemplate from './templates/default.json' assert { type: 'json' }; import lottaFacesTemplate from './templates/lotta-faces.json' assert { type: 'json' }; import plantTemplate from './templates/plant.json' assert { type: 'json' }; const registry = new BenchmarkRegistry(); const r = new MemoryRuntimeExecutor(registry); -const perfStore = createPerformanceStore(); const log = createLogger('bench'); const templates: Record = { - 'plant': plantTemplate as unknown as GraphType, + plant: plantTemplate as unknown as GraphType, 'lotta-faces': lottaFacesTemplate as unknown as GraphType, - 'default': defaultPlantTemplate as unknown as GraphType + default: defaultPlantTemplate as unknown as GraphType }; +function average(values: number[]) { + if (values.length === 0) return 0; + return values.reduce((a, b) => a + b, 0) / values.length; +} + +function countGeometry(result: Int32Array): { + totalVertices: number; + totalFaces: number; +} { + const parts = splitNestedArray(result); + + let totalVertices = 0; + let totalFaces = 0; + + for (const part of parts) { + const type = part[0]; + + const vertexCount = part[1] >>> 0; + const faceCount = part[2] >>> 0; + + if (type === 2) { + const instanceCount = part[3] >>> 0; + + totalVertices += vertexCount * instanceCount; + totalFaces += faceCount * instanceCount; + } else { + totalVertices += vertexCount; + totalFaces += faceCount; + } + } + + return { + totalVertices, + totalFaces + }; +} + async function run(g: GraphType, amount: number) { - await registry.load(plantTemplate.nodes.map(n => n.type) as NodeId[]); + await registry.load(g.nodes.map(n => n.type) as NodeId[]); + log.log('loaded ' + g.nodes.length + ' nodes'); log.log('warming up'); - // Warm up the runtime? maybe this does something? for (let index = 0; index < 10; index++) { await r.execute(g, { randomSeed: true }); } + const systemSamples: SystemSample[] = []; + + let previousCpuSnapshot = await readCpuSnapshot(); + + const sampler = setInterval(async () => { + try { + const cpu = await measureCpuUsage(previousCpuSnapshot); + + previousCpuSnapshot = cpu.snapshot; + + const [l1, l5, l15] = loadavg(); + + systemSamples.push({ + timestamp: Date.now(), + + cpuUsagePercent: cpu.usagePercent, + cpuStealPercent: cpu.stealPercent, + + load1: l1, + load5: l5, + load15: l15, + + freeMemory: freemem(), + totalMemory: totalmem() + }); + } catch (err) { + console.error(err); + } + }, 1000); + log.log('executing'); + + const perfStore = createPerformanceStore(); + r.perf = perfStore; + + let res: Int32Array | undefined; + + const cgroupBefore = await readCgroupCpuStat(); + for (let i = 0; i < amount; i++) { r.perf?.startRun(); - await r.execute(g, { randomSeed: true }); + + res = await r.execute(g, { randomSeed: true }); + r.perf?.stopRun(); + + const { totalVertices, totalFaces } = countGeometry(res!); + + r.perf?.addToLastRun('total-vertices', totalVertices); + r.perf?.addToLastRun('total-faces', totalFaces); } + + const cgroupAfter = await readCgroupCpuStat(); + + clearInterval(sampler); + log.log('finished'); - return r.perf.get(); + + return { + data: r.perf.get(), + metadata: { + timestamp: new Date().toISOString(), + + machine: getMachineInfo(), + + process: { + pid: process.pid, + uptime: process.uptime(), + + memoryUsage: process.memoryUsage() + }, + + system: { + averages: { + cpuUsagePercent: average( + systemSamples.map(s => s.cpuUsagePercent) + ), + + cpuStealPercent: average( + systemSamples.map(s => s.cpuStealPercent) + ), + + load1: average(systemSamples.map(s => s.load1)), + load5: average(systemSamples.map(s => s.load5)), + load15: average(systemSamples.map(s => s.load15)), + + freeMemory: average( + systemSamples.map(s => s.freeMemory) + ) + }, + + samples: systemSamples, + + meminfo: await readProcMemInfo() + }, + + cgroup: { + before: cgroupBefore, + after: cgroupAfter + } + } + }; } async function main() { const outPath = resolve('benchmark/out/'); + await mkdir(outPath, { recursive: true }); + for (const key in templates) { log.log('executing ' + key); + const perfData = await run(templates[key], 100); - await writeFile(resolve(outPath, key + '.json'), JSON.stringify(perfData)); + + await writeFile( + resolve(outPath, key + '.json'), + JSON.stringify(perfData, null, 2) + ); + await new Promise(res => setTimeout(res, 200)); } } diff --git a/app/benchmark/systemStats.ts b/app/benchmark/systemStats.ts new file mode 100644 index 0000000..e7bcc1f --- /dev/null +++ b/app/benchmark/systemStats.ts @@ -0,0 +1,129 @@ +import { readFile } from 'node:fs/promises'; +import { cpus, totalmem } from 'node:os'; + +export type CpuSnapshot = { + idle: number; + total: number; + steal: number; +}; + +export type SystemSample = { + timestamp: number; + cpuUsagePercent: number; + cpuStealPercent: number; + load1: number; + load5: number; + load15: number; + freeMemory: number; + totalMemory: number; +}; + +export async function readCpuSnapshot(): Promise { + const stat = await readFile('/proc/stat', 'utf8'); + const line = stat.split('\n')[0]; + + const parts: number[] = line + .trim() + .split(/\s+/) + .slice(1) + .map((v: unknown) => Number(v)); + + const idle = parts[3]; + const iowait = parts[4]; + const steal = parts[7]; + + return { + idle: idle + iowait, + total: parts.reduce((a, b) => a + b, 0), + steal: steal ?? 0 + }; +} + +export async function measureCpuUsage( + previous: CpuSnapshot +): Promise<{ + snapshot: CpuSnapshot; + usagePercent: number; + stealPercent: number; +}> { + const current = await readCpuSnapshot(); + + const idle = current.idle - previous.idle; + const total = current.total - previous.total; + const steal = current.steal - previous.steal; + + return { + snapshot: current, + usagePercent: total === 0 ? 0 : 100 * (1 - idle / total), + stealPercent: total === 0 ? 0 : 100 * (steal / total) + }; +} + +export async function readCgroupCpuStat() { + const possiblePaths = [ + '/sys/fs/cgroup/cpu.stat', + '/sys/fs/cgroup/cpu/cpu.stat' + ]; + + for (const path of possiblePaths) { + try { + const txt: string = await readFile(path, 'utf8'); + + return Object.fromEntries( + txt + .trim() + .split('\n') + .map(line => { + const [k, v] = line.trim().split(/\s+/); + return [k, Number(v)]; + }) + ); + } catch { + // continue + } + } + + return null; +} + +export async function readProcMemInfo() { + try { + const txt = await readFile('/proc/meminfo', 'utf8'); + + const result: Record = {}; + + for (const line of txt.split('\n')) { + const match = line.match(/^(\w+):\s+(\d+)/); + + if (!match) continue; + + result[match[1]] = Number(match[2]); + } + + return result; + } catch { + return null; + } +} + +export function getMachineInfo() { + const cpuInfo = cpus(); + + return { + platform: process.platform, + arch: process.arch, + nodeVersion: process.version, + + cpuModel: cpuInfo[0]?.model ?? 'unknown', + cpuCount: cpuInfo.length, + + totalMemory: totalmem(), + + ci: { + githubActions: process.env.GITHUB_ACTIONS ?? false, + runnerName: process.env.RUNNER_NAME ?? null, + runnerOs: process.env.RUNNER_OS ?? null, + runnerArch: process.env.RUNNER_ARCH ?? null + } + }; +} diff --git a/app/e2e/main.test.ts b/app/e2e/main.test.ts index 0f13a8e..03d1a73 100644 --- a/app/e2e/main.test.ts +++ b/app/e2e/main.test.ts @@ -8,9 +8,6 @@ test('test', async ({ page }) => { await page.goto('http://localhost:4173', { waitUntil: 'load' }); - // await expect(page).toHaveScreenshot(); - await expect(page.locator('.graph-wrapper')).toHaveScreenshot(); - await page.getByRole('button', { name: 'projects' }).click(); await page.getByRole('button', { name: 'New', exact: true }).click(); await page.getByRole('combobox').selectOption('2'); diff --git a/app/e2e/main.test.ts-snapshots/test-1-linux.png b/app/e2e/main.test.ts-snapshots/test-1-linux.png deleted file mode 100644 index bbafab1..0000000 Binary files a/app/e2e/main.test.ts-snapshots/test-1-linux.png and /dev/null differ diff --git a/app/package.json b/app/package.json index c509764..181f032 100644 --- a/app/package.json +++ b/app/package.json @@ -7,7 +7,7 @@ "dev": "vite dev", "predev": "rm static/CHANGELOG.md && ln -s ../../CHANGELOG.md static/CHANGELOG.md", "build": "svelte-kit sync && vite build", - "test:unit": "vitest", + "test:unit": "vitest --browser=false", "test": "npm run test:unit -- --run && npm run test:e2e", "test:e2e": "playwright test", "preview": "vite preview", @@ -18,49 +18,49 @@ "bench": "tsx ./benchmark/index.ts" }, "dependencies": { + "@nodarium/planty": "workspace:*", "@nodarium/ui": "workspace:*", "@nodarium/utils": "workspace:*", - "@nodarium/planty": "workspace:*", - "@sveltejs/kit": "^2.50.2", - "@tailwindcss/vite": "^4.1.18", - "@threlte/core": "8.3.1", - "@threlte/extras": "9.7.1", + "@sveltejs/kit": "^2.59.0", + "@tailwindcss/vite": "^4.2.4", + "@threlte/core": "8.5.11", + "@threlte/extras": "9.15.1", "comlink": "^4.4.2", "file-saver": "^2.0.5", "idb": "^8.0.3", "jsondiffpatch": "^0.7.3", "micromark": "^4.0.2", - "tailwindcss": "^4.1.18", - "three": "^0.182.0" + "tailwindcss": "^4.2.4", + "three": "^0.184.0" }, "devDependencies": { - "@eslint/compat": "^2.0.2", - "@eslint/js": "^9.39.2", - "@iconify-json/tabler": "^1.2.26", - "@iconify/tailwind4": "^1.2.1", + "@eslint/compat": "^2.0.5", + "@eslint/js": "^10.0.1", + "@iconify-json/tabler": "^1.2.33", + "@iconify/tailwind4": "^1.2.3", "@nodarium/types": "workspace:^", - "@playwright/test": "^1.58.1", + "@playwright/test": "^1.59.1", "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/vite-plugin-svelte": "^6.2.4", - "@tsconfig/svelte": "^5.0.7", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tsconfig/svelte": "^5.0.8", "@types/file-saver": "^2.0.7", - "@types/three": "^0.182.0", - "@vitest/browser-playwright": "^4.0.18", - "dprint": "^0.51.1", - "eslint": "^9.39.2", - "eslint-plugin-svelte": "^3.14.0", - "globals": "^17.3.0", - "svelte": "^5.49.2", - "svelte-check": "^4.3.6", + "@types/three": "^0.184.0", + "@vitest/browser-playwright": "^4.1.5", + "dprint": "^0.54.0", + "eslint": "^10.3.0", + "eslint-plugin-svelte": "^3.17.1", + "globals": "^17.6.0", + "svelte": "^5.55.5", + "svelte-check": "^4.4.7", "tslib": "^2.8.1", "tsx": "^4.21.0", - "typescript": "^5.9.3", - "typescript-eslint": "^8.54.0", - "vite": "^7.3.1", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.1", + "vite": "^8.0.10", "vite-plugin-comlink": "^5.3.0", - "vite-plugin-glsl": "^1.5.5", - "vite-plugin-wasm": "^3.5.0", - "vitest": "^4.0.18", - "vitest-browser-svelte": "^2.0.2" + "vite-plugin-glsl": "^1.6.0", + "vite-plugin-wasm": "^3.6.0", + "vitest": "^4.1.5", + "vitest-browser-svelte": "^2.1.1" } } diff --git a/app/src/lib/graph-interface/components/AddMenu.svelte b/app/src/lib/graph-interface/components/AddMenu.svelte index 36d46c6..391be4f 100644 --- a/app/src/lib/graph-interface/components/AddMenu.svelte +++ b/app/src/lib/graph-interface/components/AddMenu.svelte @@ -183,7 +183,7 @@ activeNodeId = node.id; }} > - {node.id.split('/').at(-1)} + {node.meta?.title ?? node.id.split('/').at(-1)} {/each} diff --git a/app/src/lib/graph-interface/components/GroupBreadcrumps.svelte b/app/src/lib/graph-interface/components/GroupBreadcrumps.svelte new file mode 100644 index 0000000..eb4a85c --- /dev/null +++ b/app/src/lib/graph-interface/components/GroupBreadcrumps.svelte @@ -0,0 +1,75 @@ + + +
+ +{#if graph.isInsideGroup} +
+ + + {#each intermediateGroups as entry (entry.id)} + + + {/each} + + + +
+{/if} + + diff --git a/app/src/lib/graph-interface/graph-manager.svelte.test.ts b/app/src/lib/graph-interface/graph-manager.svelte.test.ts index de4d88c..a228f2e 100644 --- a/app/src/lib/graph-interface/graph-manager.svelte.test.ts +++ b/app/src/lib/graph-interface/graph-manager.svelte.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { assert, describe, expect, it } from 'vitest'; import { GraphManager } from './graph-manager.svelte'; import { createMockNodeRegistry, @@ -9,257 +9,399 @@ import { mockVec3OutputNode } from './test-utils'; -describe('GraphManager', () => { - describe('getPossibleSockets', () => { - describe('when dragging an output socket', () => { - it('should return compatible input sockets based on type', () => { - const registry = createMockNodeRegistry([ - mockFloatOutputNode, - mockFloatInputNode, - mockGeometryOutputNode, - mockPathInputNode - ]); +describe('groupNodes', () => { + it('should not do anything if no nodes are selected', () => { + const registry = createMockNodeRegistry([ + mockFloatOutputNode, + mockFloatInputNode, + mockGeometryOutputNode, + mockPathInputNode + ]); - const manager = new GraphManager(registry); + const manager = new GraphManager(registry); - const floatInputNode = manager.createNode({ - type: 'test/node/input', - position: [100, 100], - props: {} - }); + const floatInputNode = manager.createNode({ + type: 'test/node/input', + position: [100, 100], + props: {} + }); - const floatOutputNode = manager.createNode({ - type: 'test/node/output', - position: [0, 0], - props: {} - }); + assert.isDefined(floatInputNode); - expect(floatInputNode).toBeDefined(); - expect(floatOutputNode).toBeDefined(); + const floatOutputNode = manager.createNode({ + type: 'test/node/output', + position: [0, 0], + props: {} + }); + assert.isDefined(floatOutputNode); - const possibleSockets = manager.getPossibleSockets({ - node: floatOutputNode!, - index: 0, - position: [0, 0] - }); + const edge = manager.createEdge(floatInputNode, 0, floatOutputNode, 'input'); + assert.isDefined(edge); + manager.save(); - expect(possibleSockets.length).toBe(1); - const socketNodeIds = possibleSockets.map(([node]) => node.id); - expect(socketNodeIds).toContain(floatInputNode!.id); + manager.groupNodes([]); + + const graph = manager.serialize(); + expect(graph.nodes.length).toBe(2); + expect(graph.edges.length).toBe(1); + expect(graph.groups.length).toBe(0); + }); + + it('should group selected nodes and create a group node', () => { + const registry = createMockNodeRegistry([ + mockFloatOutputNode, + mockFloatInputNode, + mockGeometryOutputNode, + mockPathInputNode + ]); + + const manager = new GraphManager(registry); + + const floatInputNode = manager.createNode({ + type: 'test/node/input', + position: [100, 100], + props: {} + }); + + assert.isDefined(floatInputNode); + + const floatOutputNode = manager.createNode({ + type: 'test/node/output', + position: [0, 0], + props: {} + }); + assert.isDefined(floatOutputNode); + + const edge = manager.createEdge(floatInputNode, 0, floatOutputNode, 'input'); + assert.isDefined(edge); + manager.save(); + + const groupNode = manager.groupNodes([floatInputNode.id]); + assert.isDefined(groupNode); + + const graph = manager.serialize(); + + expect(graph.nodes.map(n => n.id), 'graph to contain group node').to.contain(groupNode.id); + expect(graph.groups[0].nodes.map(n => n.id), 'group graph to contain float node').to.contain( + floatInputNode.id + ); + expect(graph.nodes.map(n => n.id)).not.to.contain(floatInputNode.id); + + expect(graph.nodes.length).toBe(2); + expect(graph.edges.length).toBe(1); + expect(graph.groups.length).toBe(1); + }); + + it('should rewire external edges when grouping a middle node in a chain', () => { + const registry = createMockNodeRegistry([mockFloatOutputNode, mockFloatInputNode]); + const manager = new GraphManager(registry); + + // A β†’ B β†’ C (float chain: output β†’ middle β†’ input) + const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} }); + const nodeB = manager.createNode({ type: 'test/node/output', position: [100, 0], props: {} }); + const nodeC = manager.createNode({ type: 'test/node/input', position: [200, 0], props: {} }); + + assert.isDefined(nodeA); + assert.isDefined(nodeB); + assert.isDefined(nodeC); + + manager.createEdge(nodeA, 0, nodeB, 'input'); + manager.createEdge(nodeB, 0, nodeC, 'value'); + + const groupNode = manager.groupNodes([nodeB.id]); + assert.isDefined(groupNode); + + const graph = manager.serialize(); + + // Top-level: A, C, groupNode β€” B is gone + expect(graph.nodes.length, 'top-level node count').toBe(3); + const topLevelIds = graph.nodes.map(n => n.id); + expect(topLevelIds).toContain(nodeA.id); + expect(topLevelIds).toContain(nodeC.id); + expect(topLevelIds).toContain(groupNode.id); + expect(topLevelIds).not.toContain(nodeB.id); + + // Both original edges survive, now routing through the group node + expect(graph.edges.length, 'edge count unchanged').toBe(2); + const edgeSources = graph.edges.map(e => e[0]); + const edgeTargets = graph.edges.map(e => e[2]); + expect(edgeTargets).toContain(groupNode.id); // A β†’ groupNode + expect(edgeSources).toContain(groupNode.id); // groupNode β†’ C + + // One group definition was created + expect(graph.groups.length).toBe(1); + const group = graph.groups[0]; + + // Group contains B plus the two boundary nodes + const groupNodeIds = group.nodes.map(n => n.id); + expect(groupNodeIds).toContain(nodeB.id); + const inputBoundary = group.nodes.find(n => n.type === '__internal/group/input'); + const outputBoundary = group.nodes.find(n => n.type === '__internal/group/output'); + expect(inputBoundary, 'group input boundary node').toBeDefined(); + expect(outputBoundary, 'group output boundary node').toBeDefined(); + + // Group declares one input slot and one output slot + expect(Object.keys(group.inputs ?? {}).length, 'group input count').toBe(1); + expect(group.outputs?.length, 'group output count').toBe(1); + + // Internal edges wire: inputBoundary β†’ B β†’ outputBoundary + expect(group.edges.length, 'internal edge count').toBe(2); + const internalSources = group.edges.map(e => e[0]); + const internalTargets = group.edges.map(e => e[2]); + expect(internalTargets).toContain(nodeB.id); + expect(internalSources).toContain(nodeB.id); + }); +}); + +describe('getPossibleSockets', () => { + describe('when dragging an output socket', () => { + it('should return compatible input sockets based on type', () => { + const registry = createMockNodeRegistry([ + mockFloatOutputNode, + mockFloatInputNode, + mockGeometryOutputNode, + mockPathInputNode + ]); + + const manager = new GraphManager(registry); + + const floatInputNode = manager.createNode({ + type: 'test/node/input', + position: [100, 100], + props: {} }); - it('should exclude self node from possible sockets', () => { - const registry = createMockNodeRegistry([ - mockFloatOutputNode, - mockFloatInputNode - ]); - - const manager = new GraphManager(registry); - - const floatInputNode = manager.createNode({ - type: 'test/node/input', - position: [100, 100], - props: {} - }); - - expect(floatInputNode).toBeDefined(); - - const possibleSockets = manager.getPossibleSockets({ - node: floatInputNode!, - index: 'value', - position: [0, 0] - }); - - const socketNodeIds = possibleSockets.map(([node]) => node.id); - expect(socketNodeIds).not.toContain(floatInputNode!.id); + const floatOutputNode = manager.createNode({ + type: 'test/node/output', + position: [0, 0], + props: {} }); - it('should exclude parent nodes from possible sockets when dragging output', () => { - const registry = createMockNodeRegistry([ - mockFloatOutputNode, - mockFloatInputNode - ]); + expect(floatInputNode).toBeDefined(); + expect(floatOutputNode).toBeDefined(); - const manager = new GraphManager(registry); - - const parentNode = manager.createNode({ - type: 'test/node/output', - position: [0, 0], - props: {} - }); - - const childNode = manager.createNode({ - type: 'test/node/input', - position: [100, 100], - props: {} - }); - - expect(parentNode).toBeDefined(); - expect(childNode).toBeDefined(); - - if (parentNode && childNode) { - manager.createEdge(parentNode, 0, childNode, 'value'); - } - - const possibleSockets = manager.getPossibleSockets({ - node: parentNode!, - index: 0, - position: [0, 0] - }); - - const socketNodeIds = possibleSockets.map(([node]) => node.id); - expect(socketNodeIds).not.toContain(childNode!.id); + const possibleSockets = manager.getPossibleSockets({ + node: floatOutputNode!, + index: 0, + position: [0, 0] }); - it('should return sockets compatible with accepts property', () => { - const registry = createMockNodeRegistry([ - mockGeometryOutputNode, - mockPathInputNode - ]); + expect(possibleSockets.length).toBe(1); + const socketNodeIds = possibleSockets.map(([node]) => node.id); + expect(socketNodeIds).toContain(floatInputNode!.id); + }); - const manager = new GraphManager(registry); + it('should exclude self node from possible sockets', () => { + const registry = createMockNodeRegistry([ + mockFloatOutputNode, + mockFloatInputNode + ]); - const geometryOutputNode = manager.createNode({ - type: 'test/node/geometry', - position: [0, 0], - props: {} - }); + const manager = new GraphManager(registry); - const pathInputNode = manager.createNode({ - type: 'test/node/path', - position: [100, 100], - props: {} - }); - - expect(geometryOutputNode).toBeDefined(); - expect(pathInputNode).toBeDefined(); - - const possibleSockets = manager.getPossibleSockets({ - node: geometryOutputNode!, - index: 0, - position: [0, 0] - }); - - const socketNodeIds = possibleSockets.map(([node]) => node.id); - expect(socketNodeIds).toContain(pathInputNode!.id); + const floatInputNode = manager.createNode({ + type: 'test/node/input', + position: [100, 100], + props: {} }); - it('should return empty array when no compatible sockets exist', () => { - const registry = createMockNodeRegistry([ - mockVec3OutputNode, - mockFloatInputNode - ]); + expect(floatInputNode).toBeDefined(); - const manager = new GraphManager(registry); - - const vec3OutputNode = manager.createNode({ - type: 'test/node/vec3', - position: [0, 0], - props: {} - }); - - const floatInputNode = manager.createNode({ - type: 'test/node/input', - position: [100, 100], - props: {} - }); - - expect(vec3OutputNode).toBeDefined(); - expect(floatInputNode).toBeDefined(); - - const possibleSockets = manager.getPossibleSockets({ - node: vec3OutputNode!, - index: 0, - position: [0, 0] - }); - - const socketNodeIds = possibleSockets.map(([node]) => node.id); - expect(socketNodeIds).not.toContain(floatInputNode!.id); - expect(possibleSockets.length).toBe(0); + const possibleSockets = manager.getPossibleSockets({ + node: floatInputNode!, + index: 'value', + position: [0, 0] }); - it('should return socket info with correct socket key for inputs', () => { - const registry = createMockNodeRegistry([ - mockFloatOutputNode, - mockFloatInputNode - ]); + const socketNodeIds = possibleSockets.map(([node]) => node.id); + expect(socketNodeIds).not.toContain(floatInputNode!.id); + }); - const manager = new GraphManager(registry); + it('should exclude parent nodes from possible sockets when dragging output', () => { + const registry = createMockNodeRegistry([ + mockFloatOutputNode, + mockFloatInputNode + ]); - const floatOutputNode = manager.createNode({ - type: 'test/node/output', - position: [0, 0], - props: {} - }); + const manager = new GraphManager(registry); - const floatInputNode = manager.createNode({ - type: 'test/node/input', - position: [100, 100], - props: {} - }); - - expect(floatOutputNode).toBeDefined(); - expect(floatInputNode).toBeDefined(); - - const possibleSockets = manager.getPossibleSockets({ - node: floatOutputNode!, - index: 0, - position: [0, 0] - }); - - const matchingSocket = possibleSockets.find(([node]) => node.id === floatInputNode!.id); - expect(matchingSocket).toBeDefined(); - expect(matchingSocket![1]).toBe('value'); + const parentNode = manager.createNode({ + type: 'test/node/output', + position: [0, 0], + props: {} }); - it('should return multiple compatible sockets', () => { - const registry = createMockNodeRegistry([ - mockFloatOutputNode, - mockFloatInputNode, - mockGeometryOutputNode, - mockPathInputNode - ]); - - const manager = new GraphManager(registry); - - const floatOutputNode = manager.createNode({ - type: 'test/node/output', - position: [0, 0], - props: {} - }); - - const geometryOutputNode = manager.createNode({ - type: 'test/node/geometry', - position: [200, 0], - props: {} - }); - - const floatInputNode = manager.createNode({ - type: 'test/node/input', - position: [100, 100], - props: {} - }); - - const pathInputNode = manager.createNode({ - type: 'test/node/path', - position: [300, 100], - props: {} - }); - - expect(floatOutputNode).toBeDefined(); - expect(geometryOutputNode).toBeDefined(); - expect(floatInputNode).toBeDefined(); - expect(pathInputNode).toBeDefined(); - - const possibleSocketsForFloat = manager.getPossibleSockets({ - node: floatOutputNode!, - index: 0, - position: [0, 0] - }); - - expect(possibleSocketsForFloat.length).toBe(1); - expect(possibleSocketsForFloat.map(([n]) => n.id)).toContain(floatInputNode!.id); + const childNode = manager.createNode({ + type: 'test/node/input', + position: [100, 100], + props: {} }); + + expect(parentNode).toBeDefined(); + expect(childNode).toBeDefined(); + + if (parentNode && childNode) { + manager.createEdge(parentNode, 0, childNode, 'value'); + } + + const possibleSockets = manager.getPossibleSockets({ + node: parentNode!, + index: 0, + position: [0, 0] + }); + + const socketNodeIds = possibleSockets.map(([node]) => node.id); + expect(socketNodeIds).not.toContain(childNode!.id); + }); + + it('should return sockets compatible with accepts property', () => { + const registry = createMockNodeRegistry([ + mockGeometryOutputNode, + mockPathInputNode + ]); + + const manager = new GraphManager(registry); + + const geometryOutputNode = manager.createNode({ + type: 'test/node/geometry', + position: [0, 0], + props: {} + }); + + const pathInputNode = manager.createNode({ + type: 'test/node/path', + position: [100, 100], + props: {} + }); + + expect(geometryOutputNode).toBeDefined(); + expect(pathInputNode).toBeDefined(); + + const possibleSockets = manager.getPossibleSockets({ + node: geometryOutputNode!, + index: 0, + position: [0, 0] + }); + + const socketNodeIds = possibleSockets.map(([node]) => node.id); + expect(socketNodeIds).toContain(pathInputNode!.id); + }); + + it('should return empty array when no compatible sockets exist', () => { + const registry = createMockNodeRegistry([ + mockVec3OutputNode, + mockFloatInputNode + ]); + + const manager = new GraphManager(registry); + + const vec3OutputNode = manager.createNode({ + type: 'test/node/vec3', + position: [0, 0], + props: {} + }); + + const floatInputNode = manager.createNode({ + type: 'test/node/input', + position: [100, 100], + props: {} + }); + + expect(vec3OutputNode).toBeDefined(); + expect(floatInputNode).toBeDefined(); + + const possibleSockets = manager.getPossibleSockets({ + node: vec3OutputNode!, + index: 0, + position: [0, 0] + }); + + const socketNodeIds = possibleSockets.map(([node]) => node.id); + expect(socketNodeIds).not.toContain(floatInputNode!.id); + expect(possibleSockets.length).toBe(0); + }); + + it('should return socket info with correct socket key for inputs', () => { + const registry = createMockNodeRegistry([ + mockFloatOutputNode, + mockFloatInputNode + ]); + + const manager = new GraphManager(registry); + + const floatOutputNode = manager.createNode({ + type: 'test/node/output', + position: [0, 0], + props: {} + }); + + const floatInputNode = manager.createNode({ + type: 'test/node/input', + position: [100, 100], + props: {} + }); + + expect(floatOutputNode).toBeDefined(); + expect(floatInputNode).toBeDefined(); + + const possibleSockets = manager.getPossibleSockets({ + node: floatOutputNode!, + index: 0, + position: [0, 0] + }); + + const matchingSocket = possibleSockets.find(([node]) => node.id === floatInputNode!.id); + expect(matchingSocket).toBeDefined(); + expect(matchingSocket![1]).toBe('value'); + }); + + it('should return multiple compatible sockets', () => { + const registry = createMockNodeRegistry([ + mockFloatOutputNode, + mockFloatInputNode, + mockGeometryOutputNode, + mockPathInputNode + ]); + + const manager = new GraphManager(registry); + + const floatOutputNode = manager.createNode({ + type: 'test/node/output', + position: [0, 0], + props: {} + }); + + const geometryOutputNode = manager.createNode({ + type: 'test/node/geometry', + position: [200, 0], + props: {} + }); + + const floatInputNode = manager.createNode({ + type: 'test/node/input', + position: [100, 100], + props: {} + }); + + const pathInputNode = manager.createNode({ + type: 'test/node/path', + position: [300, 100], + props: {} + }); + + expect(floatOutputNode).toBeDefined(); + expect(geometryOutputNode).toBeDefined(); + expect(floatInputNode).toBeDefined(); + expect(pathInputNode).toBeDefined(); + + const possibleSocketsForFloat = manager.getPossibleSockets({ + node: floatOutputNode!, + index: 0, + position: [0, 0] + }); + + expect(possibleSocketsForFloat.length).toBe(1); + expect(possibleSocketsForFloat.map(([n]) => n.id)).toContain(floatInputNode!.id); }); }); }); diff --git a/app/src/lib/graph-interface/graph-manager.svelte.ts b/app/src/lib/graph-interface/graph-manager.svelte.ts index 1a8907f..b86afc8 100644 --- a/app/src/lib/graph-interface/graph-manager.svelte.ts +++ b/app/src/lib/graph-interface/graph-manager.svelte.ts @@ -1,61 +1,37 @@ +import { clone } from '$lib/helpers'; import throttle from '$lib/helpers/throttle'; import { RemoteNodeRegistry } from '$lib/node-registry/index'; import type { + Box, Edge, Graph, + GroupDefinition, NodeDefinition, NodeId, NodeInput, NodeInstance, NodeRegistry, + SerializedEdge, + SerializedNode, Socket } from '@nodarium/types'; import { fastHashString } from '@nodarium/utils'; import { createLogger } from '@nodarium/utils'; import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import EventEmitter from './helpers/EventEmitter'; +import { + areEdgesEqual, + areSocketsCompatible, + serializeEdge, + serializeNode +} from './helpers/nodeHelpers'; import { HistoryManager } from './history-manager'; -const logger = createLogger('graph-manager'); -logger.mute(); +const log = createLogger('graph-manager'); +log.mute(); const remoteRegistry = new RemoteNodeRegistry(''); -const clone = 'structuredClone' in self - ? self.structuredClone - : (args: unknown) => JSON.parse(JSON.stringify(args)); - -function areSocketsCompatible( - output: string | undefined, - inputs: string | (string | undefined)[] | undefined -) { - if (output === '*') return true; - if (Array.isArray(inputs) && output) { - return inputs.includes('*') || inputs.includes(output); - } - return inputs === output; -} - -function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) { - if (firstEdge[0].id !== secondEdge[0].id) { - return false; - } - - if (firstEdge[1] !== secondEdge[1]) { - return false; - } - - if (firstEdge[2].id !== secondEdge[2].id) { - return false; - } - - if (firstEdge[3] !== secondEdge[3]) { - return false; - } - - return true; -} - export class GraphManager extends EventEmitter<{ save: Graph; result: unknown; @@ -67,12 +43,26 @@ export class GraphManager extends EventEmitter<{ status = $state<'loading' | 'idle' | 'error'>(); loaded = false; - graph: Graph = { id: 0, nodes: [], edges: [] }; + // Snapshots of parent levels we navigated away from. Empty at root. + // Entry i has the saved state of depth i (0 = root graph, 1 = first group, …). + parentStack: { + id: number; + nodes: SerializedNode[]; + edges: SerializedEdge[]; + nodeId: number; // group instance node id that was entered to reach the next level + }[] = $state([]); + + // ID of the currently active group, or null when at the root graph. + currentGroupId = $state(null); + + // Graph Data id = $state(0); - + meta = $state({}); nodes = new SvelteMap(); - edges = $state([]); + groups: GroupDefinition[] = $state([]); + + nodeArray = $derived(Array.from(this.nodes.values())); settingTypes: Record = {}; settings = $state>(); @@ -88,6 +78,7 @@ export class GraphManager extends EventEmitter<{ }); history: HistoryManager = new HistoryManager(); + execute = throttle(() => { if (this.loaded === false) return; this.emit('result', this.serialize()); @@ -98,27 +89,44 @@ export class GraphManager extends EventEmitter<{ } serialize(): Graph { - const nodes = Array.from(this.nodes.values()).map((node) => ({ - id: node.id, - position: [...node.position], - type: node.type, - props: node.props - })) as NodeInstance[]; - const edges = this.edges.map((edge) => [ - edge[0].id, - edge[1], - edge[2].id, - edge[3] - ]) as Graph['edges']; - const serialized = { - id: this.graph.id, - settings: $state.snapshot(this.settings), - meta: $state.snapshot(this.graph.meta), + const nodes = + (this.parentStack.length === 0 ? Array.from(this.nodes.values()) : this.parentStack[0].nodes) + .map(n => serializeNode(n)); + const edges = + (this.parentStack.length === 0 ? Array.from(this.edges.values()) : this.parentStack[0].edges) + .map(e => serializeEdge(e)); + + const groups = this.groups?.map((group) => { + const isCurrentActive = this.currentGroupId === group.id; + const stackState = this.parentStack.find((s) => s.id === group.id); + const groupNodes = + (isCurrentActive ? [...this.nodes.values()] : stackState?.nodes ?? group.nodes).map( + n => serializeNode(n) + ); + const groupEdges = + (isCurrentActive ? [...this.edges.values()] : stackState?.edges ?? group.edges).map( + e => serializeEdge(e) + ); + return { + id: group.id, + name: group.name, + inputs: group.inputs, + outputs: group.outputs, + nodes: groupNodes, + edges: groupEdges + }; + }); + + const serialized = $state.snapshot({ + id: this.id, + settings: this.settings, + meta: this.meta, + groups, nodes, edges - }; - logger.log('serializing graph', serialized); - return clone($state.snapshot(serialized)); + }); + log.log('serializing graph', serialized); + return clone(serialized); } private lastSettingsHash = 0; @@ -182,7 +190,7 @@ export class GraphManager extends EventEmitter<{ ); if (!bestInputEntry || bestOutputIdx === -1) { - logger.error('Could not find compatible sockets for drop'); + log.error('Could not find compatible sockets for drop'); return; } @@ -242,6 +250,28 @@ export class GraphManager extends EventEmitter<{ }); } + tryConnectToDebugNode(nodeId: number) { + const node = this.nodes.get(nodeId); + if (!node) return; + if (node.type.endsWith('/debug')) return; + if (!node.state.type?.outputs?.length) return; + let debugNode = this.nodes.values().find(n => n.type.endsWith('/debug')); + + if (!debugNode) { + debugNode = this.createNode({ + type: '__internal/node/debug', + position: [node.position[0] + 30, node.position[1]], + props: {} + }); + } + + if (debugNode) { + this.createEdge(node, 0, debugNode, 'input'); + } + + return debugNode; + } + getEdgesBetweenNodes(nodes: NodeInstance[]): [number, number, number, string][] { const edges = []; for (const node of nodes) { @@ -266,23 +296,22 @@ export class GraphManager extends EventEmitter<{ return edges; } - private _init(graph: Graph) { - const nodes = new SvelteMap( - graph.nodes.map((node) => { - const nodeType = this.registry.getNode(node.type); - const n = node as NodeInstance; - if (nodeType) { - n.state = { - type: nodeType - }; - } - return [node.id, n]; - }) - ); + private _init( + graph: { nodes: SerializedNode[]; edges: SerializedEdge[] } + ) { + this.nodes.clear(); + for (const node of graph.nodes) { + const n = $state(node) as NodeInstance; + const registryType = this.registry.getNode(node.type); + n.state = registryType ? { type: registryType } : {}; + const resolvedType = this.getNodeType(n); + if (resolvedType) n.state = { type: resolvedType }; + this.nodes.set(n.id, n); + } this.edges = graph.edges.map((edge) => { - const from = nodes.get(edge[0]); - const to = nodes.get(edge[2]); + const from = this.nodes.get(edge[0]); + const to = this.nodes.get(edge[2]); if (!from || !to) { throw new Error('Edge references non-existing node'); } @@ -293,11 +322,6 @@ export class GraphManager extends EventEmitter<{ return [from, edge[1], to, edge[3]] as Edge; }); - this.nodes.clear(); - for (const [id, node] of nodes) { - this.nodes.set(id, node); - } - this.execute(); } @@ -305,19 +329,36 @@ export class GraphManager extends EventEmitter<{ const a = performance.now(); this.loaded = false; - this.graph = graph; + graph.groups ??= []; + this.meta = graph.meta; + this.groups = graph.groups; this.status = 'loading'; this.id = graph.id; - logger.info('loading graph', { nodes: graph.nodes, edges: graph.edges, id: graph.id }); + log.info( + 'loading graph', + { nodes: graph.nodes, edges: graph.edges, id: graph.id } + ); + + const nodeIds = Array + .from( + new SvelteSet( + [ + ...graph.nodes, + graph?.groups?.map(g => g.nodes).flat() + ] + .filter(n => n && 'type' in n) + .map((n) => n.type) + ) + ); - const nodeIds = Array.from(new SvelteSet([...graph.nodes.map((n) => n.type)])); await this.registry.load(nodeIds); // Fetch all nodes from all collections of the loaded nodes const allCollections = new SvelteSet<`${string}/${string}`>(); for (const id of nodeIds) { const [user, collection] = id.split('/'); + if (user === '__internal') continue; allCollections.add(`${user}/${collection}`); } for (const collection of allCollections) { @@ -329,20 +370,7 @@ export class GraphManager extends EventEmitter<{ }); } - logger.info('loaded node types', this.registry.getAllNodes()); - - for (const node of this.graph.nodes) { - const nodeType = this.registry.getNode(node.type); - if (!nodeType) { - logger.error(`Node type not found: ${node.type}`); - this.status = 'error'; - return; - } - // Turn into runtime node - const n = node as NodeInstance; - n.state = {}; - n.state.type = nodeType; - } + log.info('loaded node types', this.registry.getAllNodes()); // load settings const settingTypes: Record< @@ -373,31 +401,156 @@ export class GraphManager extends EventEmitter<{ } } + this.parentStack = []; + this.currentGroupId = null; + this.settings = settingValues; this.emit('settings', { types: settingTypes, values: settingValues }); this.history.reset(); - this._init(this.graph); - + this._init(graph); this.save(); - this.status = 'idle'; - this.loaded = true; - logger.log(`Graph loaded in ${performance.now() - a}ms`); + log.log(`Graph loaded in ${performance.now() - a}ms`); setTimeout(() => this.execute(), 100); } getAllNodes() { - return Array.from(this.nodes.values()); + return Array + .from(this.nodes.values()); } getNode(id: number) { return this.nodes.get(id); } - getNodeType(id: string) { - return this.registry.getNode(id); + getNodeType(node: NodeInstance) { + if (!node) { + console.trace('failed to get node type', { node }); + return; + } + + if (node.type === '__internal/group/input') { + const group = this.currentGroupId !== null ? this.getGroup(this.currentGroupId) : undefined; + if (!group) return node.state.type; + + const groupInputs: NodeDefinition['inputs'] = Object.fromEntries( + Object.values(group?.inputs || {}).map((o, i) => { + return [`in_${i}`, { + ...o, + external: true + }]; + }) || [] + ); + return { + id: '__internal/group/input' as NodeId, + meta: { + title: 'Group Input' + }, + inputs: groupInputs, + execute: (x: Int32Array) => x + } as NodeDefinition; + } + + if (node.type === '__internal/group/output') { + const group = this.currentGroupId !== null ? this.getGroup(this.currentGroupId) : undefined; + if (!group) return node.state.type; + return { + id: '__internal/group/output' as NodeId, + inputs: Object.fromEntries( + (group.outputs ?? []).map(( + o, + i + ) => [`out_${i}`, { type: o.type, label: o.label, external: true }]) + ), + meta: { + title: 'Group Output' + }, + outputs: [], + execute: (x: Int32Array) => x + } as NodeDefinition; + } + + // Construct the group inputs on the fly + if (node.type === '__internal/group/instance') { + const groupId = node.props?.groupId as number; + if (!groupId) { + return { + ...node.state.type, + meta: { + title: 'Group', + ...node?.state?.type?.meta || {} + }, + inputs: { + 'groupId': { + type: 'select', + label: '', + value: this.groups[0].id, + internal: true, + options: this.groups.map((g) => ({ + value: g.id, + label: g.name || `Group#${g.id}` + })).filter((g) => { + const activeIds = new SvelteSet([ + ...this.parentStack.filter(e => e.id !== this.id).map(e => e.id), + ...(this.currentGroupId !== null ? [this.currentGroupId] : []) + ]); + return !activeIds.has(g.value); + }) + } + }, + outputs: [] + } as NodeDefinition; + } + + const groupDefinition = this.getGroup(node.props?.groupId as number); + if (!groupDefinition) { + log.error(`Group not found: ${node.props?.groupId}`); + return; + } + + const defaultInputs = { + ...(node.state.type?.inputs || {}), + ...groupDefinition?.inputs + }; + + delete defaultInputs['groupId']; + + const inputs = { + 'groupId': { + type: 'select', + label: '', + value: node.props?.groupId, + internal: true, + options: this.groups.map((g) => ({ + value: g.id, + label: g.name || `Group#${g.id}` + })).filter((g) => { + const activeIds = new SvelteSet([ + ...this.parentStack.filter(e => e.id !== this.id).map(e => e.id), + ...(this.currentGroupId !== null ? [this.currentGroupId] : []) + ]); + return !activeIds.has(g.value); + }) + }, + ...defaultInputs + }; + + const groupType = { + ...node.state.type, + meta: { + title: 'Group', + ...node?.state?.type?.meta || {} + }, + inputs, + outputs: groupDefinition?.outputs?.map(o => o.type) + } as NodeDefinition; + + return groupType; + } + + return node.state.type; } async loadNodeType(id: NodeId) { @@ -459,6 +612,7 @@ export class GraphManager extends EventEmitter<{ } removeNode(node: NodeInstance, { restoreEdges = false } = {}) { + log.log('removing node', { id: node.id, type: node.type, restoreEdges }); const edgesToNode = this.edges.filter((edge) => edge[2].id === node.id); const edgesFromNode = this.edges.filter((edge) => edge[0].id === node.id); for (const edge of [...edgesToNode, ...edgesFromNode]) { @@ -502,8 +656,79 @@ export class GraphManager extends EventEmitter<{ } } + getGroup(id: number) { + return this.groups.find(g => g.id === id); + } + + renameGroup(groupId: number, name: string) { + log.log('renaming group', { groupId, name }); + const group = this.getGroup(groupId); + if (!group) return; + group.name = name; + this.save(); + } + + isInsideGroup = $derived(this.currentGroupId !== null); + + enterGroup(nodeId: number): boolean { + const groupNode = this.getNode(nodeId); + if (!groupNode || groupNode.type !== '__internal/group/instance') return false; + + log.log('entering group', { nodeId }); + + const groupId = groupNode.props?.groupId as number; + const group = this.getGroup(groupId); + if (!group) return false; + + // Snapshot current level and push it onto the parent stack. + this.parentStack.push({ + id: this.currentGroupId ?? this.id, + nodes: [...this.nodes.values()].map(n => serializeNode(n)), + edges: [...this.edges.values()].map(e => serializeEdge(e)), + nodeId + }); + this.currentGroupId = groupId; + + log.log('entered group', { groupId, depth: this.parentStack.length }); + this.history.reset(); + this._init(group); + return true; + } + + exitGroup() { + log.log('exiting group', { depth: this.parentStack.length }); + if (this.parentStack.length === 0) return; + + // Persist live edits back to the GroupDefinition. + const group = this.getGroup(this.currentGroupId!); + if (group) { + group.nodes = [...this.nodes.values()].map(n => serializeNode(n)); + group.edges = [...this.edges.values()].map(e => serializeEdge(e)); + } + + const parent = this.parentStack.pop()!; + this.currentGroupId = this.parentStack.length === 0 ? null : parent.id; + this._init(parent); + + this.history.reset(); + this.execute(); + this.save(); + + return { nodeId: parent.nodeId }; + } + createNodeId() { - return Math.max(0, ...this.nodes.keys()) + 1; + const ids = [ + ...this.nodes.keys(), + ...this.groups.map(g => g.id), + ...this.groups.flatMap(g => g.nodes.map(n => n.id)) + ]; + + let id = 0; + while (ids.includes(id)) { + id++; + } + return id; } createGraph(nodes: NodeInstance[], edges: [number, number, number, string][]) { @@ -516,7 +741,7 @@ export class GraphManager extends EventEmitter<{ const id = startId++; idMap.set(node.id, id); const type = this.registry.getNode(node.type); - if (!type) { + if (!type && !node.type.startsWith('__internal/')) { throw new Error(`Node type not found: ${node.type}`); } return { ...node, id, tmp: { type } }; @@ -549,6 +774,338 @@ export class GraphManager extends EventEmitter<{ return nodes; } + getUnusedGroups() { + const usedGroupIds = new SvelteSet(); + const queue: number[] = []; + + // Seed from root-level nodes so outer groups aren't treated as unused when inside a group. + const rootNodes = this.parentStack.length > 0 + ? this.parentStack[0].nodes + : [...this.nodes.values()]; + + for (const node of rootNodes) { + if (node.type === '__internal/group/instance' && node.props?.groupId !== undefined) { + queue.push(node.props.groupId as number); + } + } + + // Also seed from live nodes (may contain new group instances created inside a group). + if (this.currentGroupId !== null) { + for (const node of this.nodes.values()) { + if (node.type === '__internal/group/instance' && node.props?.groupId !== undefined) { + queue.push(node.props.groupId as number); + } + } + } + + // Every group on the navigation path is used by definition. + for (const entry of this.parentStack) { + if (entry.id !== this.id) usedGroupIds.add(entry.id); + } + if (this.currentGroupId !== null) usedGroupIds.add(this.currentGroupId); + + while (queue.length) { + const groupId = queue.pop()!; + if (usedGroupIds.has(groupId)) continue; + usedGroupIds.add(groupId); + const group = this.getGroup(groupId); + if (!group) continue; + for (const node of group.nodes) { + if (node.type === '__internal/group/instance' && node.props?.groupId !== undefined) { + const childId = node.props.groupId as number; + if (!usedGroupIds.has(childId)) queue.push(childId); + } + } + } + + return this.groups.filter(g => !usedGroupIds.has(g.id)); + } + + removeUnusedGroups() { + const unused = this.getUnusedGroups(); + const unusedIds = new SvelteSet(unused.map(g => g.id)); + this.groups = this.groups.filter(g => !unusedIds.has(g.id)); + this.save(); + return unused.length; + } + + groupNodes(nodeIds: number[]) { + this.startUndoGroup(); + this.removeUnusedGroups(); + + const nodes = [ + ...new SvelteSet(nodeIds).values().map(id => this.getNode(id)).filter(Boolean) + ] as NodeInstance[]; + + if (!nodes.length) return; + + log.log(`Grouping ${nodes.length} nodes`, { nodes }); + + const ids = new SvelteSet(nodes.map(n => n.id)); + + // We use the map to dedupe when one external node is connected to multiple internal nodes + // β”Œβ”€β”€internal_a + // external─── + // └──internal_b + // This should only result in one group input not two + const incomingEdges = this.edges.filter((edge) => ids.has(edge[2].id) && !ids.has(edge[0].id)); + const groupInputs = new SvelteMap(); + for (const edge of incomingEdges) { + groupInputs.set(`${edge[0].id}-${edge[1]}`, edge); + } + + // And the same for the outputs + const outgoingEdges = this.edges.filter((edge) => ids.has(edge[0].id) && !ids.has(edge[2].id)); + const groupOutputs = new SvelteMap(); + for (const edge of outgoingEdges) { + groupOutputs.set(`${edge[2].id}-${edge[3]}`, edge); + } + + const inputs: Record = {}; + [...groupInputs.values()].forEach((edge, i) => { + const internalInputDef = edge[2].state.type?.inputs?.[edge[3]]; + const input = { + label: internalInputDef?.label ?? edge[3], + type: edge[0].state.type?.outputs?.[edge[1]] || '*' + }; + inputs[`input_${i}`] = input as NodeInput; + }); + + const outputs = []; + if (groupOutputs.size) { + const edge = groupOutputs.values().next().value!; + const outputType = edge[0].state.type?.outputs?.[edge[1]] || '*'; + outputs.push({ + label: outputType === '*' + ? 'Output' + : outputType.charAt(0).toUpperCase() + outputType.slice(1), + type: edge[2].state.type?.inputs?.[edge[3]].type || '*' + }); + } + + const groupPosition = [0, 0] as [number, number]; + const bounds: Box = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity }; + for (const node of nodes) { + groupPosition[0] += node.position[0]; + groupPosition[1] += node.position[1]; + bounds.minX = Math.min(bounds.minX, node.position[0]); + bounds.maxX = Math.max(bounds.maxX, node.position[0]); + bounds.minY = Math.min(bounds.minY, node.position[1]); + bounds.maxY = Math.max(bounds.maxY, node.position[1]); + } + groupPosition[0] /= nodes.length; + groupPosition[1] /= nodes.length; + + // Map from deduped edge source key β†’ group input index, used for both + // internal edge wiring and external edge socket naming. + const inputIndexByEdgeKey = new SvelteMap(); + [...groupInputs.keys()].forEach((key, i) => inputIndexByEdgeKey.set(key, i)); + + // Allocate all needed IDs up front so sequential calls never collide. + const usedIds = new SvelteSet([ + ...this.nodes.keys(), + ...this.groups.map(g => g.id), + ...this.groups.flatMap(g => g.nodes.map(n => n.id)) + ]); + const nextId = () => { + let id = 0; + while (usedIds.has(id)) id++; + usedIds.add(id); + return id; + }; + + const groupInputNode: NodeInstance = { + id: nextId(), + type: '__internal/group/input', + position: [bounds.minX - 50, (bounds.minY + bounds.maxY) / 2], + state: {} + }; + + const groupOutputNode: NodeInstance = { + id: nextId(), + type: '__internal/group/output', + position: [bounds.maxX + 25, (bounds.minY + bounds.maxY) / 2], + state: {} + }; + + // Edges that are inside the group, routed through boundary nodes at + // the correct input/output index for each unique external connection. + const internalEdges = this.edges.filter((edge) => { + return ids.has(edge[0].id) || ids.has(edge[2].id); + }).map((edge) => { + if (!ids.has(edge[0].id)) { + const idx = inputIndexByEdgeKey.get(`${edge[0].id}-${edge[1]}`) ?? 0; + return [groupInputNode.id, idx, edge[2].id, edge[3]]; + } else if (!ids.has(edge[2].id)) { + return [edge[0].id, edge[1], groupOutputNode.id, 'out_0']; + } + return [edge[0].id, edge[1], edge[2].id, edge[3]]; + }) as [number, number, number, string][]; + + const groupId = nextId(); + const groupDefinition: GroupDefinition = { + id: groupId, + inputs: inputs, + outputs: outputs, + edges: internalEdges, + nodes: [groupInputNode, ...nodes, groupOutputNode] + }; + + // Push before createNode so createNodeId() inside sees the allocated IDs. + this.groups.push(groupDefinition); + + const groupNode = this.createNode({ + type: '__internal/group/instance', + position: [groupPosition[0], groupPosition[1]], + props: { + groupId: groupId + } + }); + + if (!groupNode) throw new Error('Failed to create group node'); + + // Rewire external edges to/from the group node using the correct input socket. + const externalEdges = this.edges.map((edge) => { + if (ids.has(edge[2].id)) { + const idx = inputIndexByEdgeKey.get(`${edge[0].id}-${edge[1]}`) ?? 0; + return [edge[0], edge[1], groupNode, `input_${idx}`] as Edge; + } else if (ids.has(edge[0].id)) { + return [groupNode, 0, edge[2], edge[3]] as Edge; + } + return edge; + }); + + this.nodes.set(groupNode.id, groupNode); + this.edges = externalEdges; + + // Remove nodes from graph which are not part of the group + for (const node of nodes) { + this.removeNode(node); + } + + this.saveUndoGroup(); + + return groupNode; + } + + ungroupNode(nodeId: number) { + const groupNode = this.getNode(nodeId); + if (!groupNode || groupNode.type !== '__internal/group/instance') return false; + + const groupId = groupNode.props?.groupId as number; + const group = this.getGroup(groupId); + if (!group) return false; + + log.log('ungrouping node', { groupId, group }); + + this.startUndoGroup(); + + const edgesToGroup = this.edges.filter(e => e[2].id === nodeId); + const edgesFromGroup = this.edges.filter(e => e[0].id === nodeId); + + const groupInputNode = group.nodes.find(n => n.type === '__internal/group/input'); + const groupOutputNode = group.nodes.find(n => n.type === '__internal/group/output'); + const internalNodes = group.nodes.filter( + n => n.type !== '__internal/group/input' && n.type !== '__internal/group/output' + ); + + // Offset internal nodes so their average position matches the group node's position + let centerX = 0, centerY = 0; + for (const n of internalNodes) { + centerX += n.position[0]; + centerY += n.position[1]; + } + const offsetX = internalNodes.length + ? groupNode.position[0] - centerX / internalNodes.length + : 0; + const offsetY = internalNodes.length + ? groupNode.position[1] - centerY / internalNodes.length + : 0; + + // Allocate new IDs that don't collide with anything in the current graph + const usedIds = new SvelteSet([ + ...this.nodes.keys(), + ...this.groups.map(g => g.id), + ...this.groups.flatMap(g => g.nodes.map(n => n.id)) + ]); + const nextId = () => { + let id = 0; + while (usedIds.has(id)) id++; + usedIds.add(id); + return id; + }; + + // Map old internal IDs (including boundary nodes) to fresh IDs + const idMap = new SvelteMap(); + for (const n of group.nodes) { + idMap.set(n.id, nextId()); + } + + // Instantiate internal nodes and add them to the graph + const newNodes: NodeInstance[] = internalNodes.map(n => { + const nodeType = this.registry.getNode(n.type); + const node: NodeInstance = $state({ + id: idMap.get(n.id)!, + type: n.type, + position: [n.position[0] + offsetX, n.position[1] + offsetY] as [number, number], + state: { type: nodeType }, + props: n.props || {} + }); + return node; + }); + + for (const node of newNodes) { + this.nodes.set(node.id, node); + } + + // input_X socket on the group node β†’ the external source that was feeding it + const inputIdxToExternal = new SvelteMap(); + for (const edge of edgesToGroup) { + const match = (edge[3] as string).match(/^input_(\d+)$/); + if (match) inputIdxToExternal.set(parseInt(match[1]), { node: edge[0], socket: edge[1] }); + } + + // All external nodes that received output from the group node + const externalOutputTargets = edgesFromGroup.map(e => ({ toNode: e[2], toSocket: e[3] })); + + // Recreate internal edges, substituting boundary nodes with the real external peers + for (const [fromId, fromSocketIdx, toId, toSocketKey] of group.edges) { + let fromNode: NodeInstance | undefined; + let resolvedFromSocket = fromSocketIdx; + + if (groupInputNode && fromId === groupInputNode.id) { + const ext = inputIdxToExternal.get(fromSocketIdx); + if (!ext) continue; + fromNode = ext.node; + resolvedFromSocket = ext.socket; + } else { + const newId = idMap.get(fromId); + if (newId !== undefined) fromNode = this.nodes.get(newId); + } + + if (!fromNode) continue; + + if (groupOutputNode && toId === groupOutputNode.id) { + for (const { toNode, toSocket } of externalOutputTargets) { + this.createEdge(fromNode, resolvedFromSocket, toNode, toSocket, { applyUpdate: false }); + } + } else { + const newToId = idMap.get(toId); + if (newToId === undefined) continue; + const toNode = this.nodes.get(newToId); + if (!toNode) continue; + this.createEdge(fromNode, resolvedFromSocket, toNode, toSocketKey, { applyUpdate: false }); + } + } + + // Remove the group instance node (also cleans up its edges) + this.removeNode(groupNode); + + this.saveUndoGroup(); + + return newNodes; + } + createNode({ type, position, @@ -559,8 +1116,8 @@ export class GraphManager extends EventEmitter<{ props: NodeInstance['props']; }) { const nodeType = this.registry.getNode(type); - if (!nodeType) { - logger.error(`Node type not found: ${type}`); + if (!nodeType && !type.startsWith('__internal/')) { + log.error(`Node type not found: ${type}`); return; } @@ -572,6 +1129,7 @@ export class GraphManager extends EventEmitter<{ props }); + log.log('creating node', { id: node.id, type, position, props }); this.nodes.set(node.id, node); this.save(); @@ -593,19 +1151,24 @@ export class GraphManager extends EventEmitter<{ (e) => e[0].id === from.id && e[1] === fromSocket && e[3] === toSocket ); if (existingEdge) { - logger.error('Edge already exists', existingEdge); + log.error('Edge already exists', existingEdge); return; } + const fromType = this.getNodeType(from); + const toType = this.getNodeType(to); + // check if socket types match - const fromSocketType = from.state?.type?.outputs?.[fromSocket]; - const toSocketType = [to.state?.type?.inputs?.[toSocket]?.type]; - if (to.state?.type?.inputs?.[toSocket]?.accepts) { - toSocketType.push(...(to?.state?.type?.inputs?.[toSocket]?.accepts || [])); + const fromSocketType = from.type === '__internal/group/input' + ? fromType?.inputs?.[Object.keys(fromType?.inputs || {})[fromSocket]].type + : fromType?.outputs?.[fromSocket]; + const toSocketType = [toType?.inputs?.[toSocket]?.type]; + if (toType?.inputs?.[toSocket]?.accepts) { + toSocketType.push(...(toType?.inputs?.[toSocket]?.accepts || [])); } if (!areSocketsCompatible(fromSocketType, toSocketType)) { - logger.error( + log.error( `Socket types do not match: ${fromSocketType} !== ${toSocketType}` ); return; @@ -620,6 +1183,7 @@ export class GraphManager extends EventEmitter<{ const edge = [from, fromSocket, to, toSocket] as Edge; + log.log('creating edge', { from: from.id, fromSocket, to: to.id, toSocket }); this.edges.push(edge); from.state.children = from.state.children || []; @@ -636,6 +1200,7 @@ export class GraphManager extends EventEmitter<{ } undo() { + log.log('undo'); const nextState = this.history.undo(); if (nextState) { this._init(nextState); @@ -644,6 +1209,7 @@ export class GraphManager extends EventEmitter<{ } redo() { + log.log('redo'); const nextState = this.history.redo(); if (nextState) { this._init(nextState); @@ -671,8 +1237,9 @@ export class GraphManager extends EventEmitter<{ return; } - this.emit('save', state); - logger.log('saving graphs', state); + const fullState = this.serialize(); + this.emit('save', fullState); + log.log('saving graphs', fullState); } getParentsOfNode(node: NodeInstance) { @@ -680,7 +1247,7 @@ export class GraphManager extends EventEmitter<{ const stack = node.state?.parents?.slice(0); while (stack?.length) { if (parents.length > 1000000) { - logger.warn('Infinite loop detected'); + log.warn('Infinite loop detected'); break; } const parent = stack.pop(); @@ -724,7 +1291,7 @@ export class GraphManager extends EventEmitter<{ } getPossibleSockets({ node, index }: Socket): [NodeInstance, string | number][] { - const nodeType = node?.state?.type; + const nodeType = this.getNodeType(node); if (!nodeType) return []; const sockets: [NodeInstance, string | number][] = []; @@ -740,7 +1307,7 @@ export class GraphManager extends EventEmitter<{ const ownType = nodeType?.inputs?.[index].type; for (const node of nodes) { - const nodeType = node?.state?.type; + const nodeType = this.getNodeType(node); const inputs = nodeType?.outputs; if (!inputs) continue; for (let index = 0; index < inputs.length; index++) { @@ -769,10 +1336,12 @@ export class GraphManager extends EventEmitter<{ } }); - const ownType = nodeType.outputs?.[index]; + const ownType = node.type === '__internal/group/input' + ? nodeType.inputs?.[Object.keys(nodeType?.inputs || {})[index]].type + : nodeType.outputs?.[index]; for (const node of nodes) { - const inputs = node?.state?.type?.inputs; + const inputs = this.getNodeType(node)?.inputs; if (!inputs) continue; for (const key in inputs) { const otherType = [inputs[key].type]; @@ -795,6 +1364,12 @@ export class GraphManager extends EventEmitter<{ edge: Edge, { applyDeletion = true }: { applyDeletion?: boolean } = {} ) { + log.log('removing edge', { + from: edge[0].id, + fromSocket: edge[1], + to: edge[2].id, + toSocket: edge[3] + }); const id0 = edge[0].id; const sid0 = edge[1]; const id2 = edge[2].id; diff --git a/app/src/lib/graph-interface/graph-state.svelte.test.ts b/app/src/lib/graph-interface/graph-state.svelte.test.ts new file mode 100644 index 0000000..43a2cc7 --- /dev/null +++ b/app/src/lib/graph-interface/graph-state.svelte.test.ts @@ -0,0 +1,262 @@ +import { assert, describe, expect, it } from 'vitest'; +import { GraphManager } from './graph-manager.svelte'; +import { GraphState } from './graph-state.svelte'; +import { createMockNodeRegistry, mockFloatInputNode, mockFloatOutputNode } from './test-utils'; + +// GraphState constructor reads localStorage synchronously β€” mock before any instantiation +Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + length: 0, + key: () => null + } as Storage, + writable: true, + configurable: true +}); + +function createFixture() { + const registry = createMockNodeRegistry([mockFloatOutputNode, mockFloatInputNode]); + const manager = new GraphManager(registry); + const state = new GraphState(manager); + return { manager, state }; +} + +describe('clearSelection', () => { + it('empties selectedNodes', () => { + const { state } = createFixture(); + state.selectedNodes.add(1); + state.selectedNodes.add(2); + state.clearSelection(); + expect(state.selectedNodes.size).toBe(0); + }); +}); + +describe('projectScreenToWorld', () => { + it('maps the viewport centre to the camera position', () => { + const { state } = createFixture(); + // cameraPosition default: [140, 100, 3.5], width=100, height=100 + state.width = 100; + state.height = 100; + state.cameraPosition = [140, 100, 3.5]; + const [wx, wy] = state.projectScreenToWorld(50, 50); + expect(wx).toBeCloseTo(140); + expect(wy).toBeCloseTo(100); + }); + + it('offsets correctly for a point not at centre', () => { + const { state } = createFixture(); + state.width = 100; + state.height = 100; + state.cameraPosition = [0, 0, 2]; + const [wx, wy] = state.projectScreenToWorld(100, 50); + // x: 0 + (100 - 50) / 2 = 25 + expect(wx).toBeCloseTo(25); + expect(wy).toBeCloseTo(0); + }); +}); + +describe('groupSelectedNodes', () => { + it('delegates to graph.groupNodes with selected IDs and activeNodeId', () => { + const { manager, state } = createFixture(); + + const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} }); + const nodeB = manager.createNode({ type: 'test/node/input', position: [100, 0], props: {} }); + assert.isDefined(nodeA); + assert.isDefined(nodeB); + + state.selectedNodes.add(nodeA!.id); + state.activeNodeId = nodeB!.id; + + const groupNode = state.groupSelectedNodes(); + assert.isDefined(groupNode); + + const graph = manager.serialize(); + expect(graph.groups.length).toBe(1); + expect(graph.nodes.map(n => n.id)).toContain(groupNode!.id); + }); + + it('works when only activeNodeId is set with no extra selection', () => { + const { manager, state } = createFixture(); + + const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} }); + assert.isDefined(nodeA); + + state.activeNodeId = nodeA!.id; + const groupNode = state.groupSelectedNodes(); + assert.isDefined(groupNode); + + expect(manager.groups.length).toBe(1); + }); +}); + +describe('enterGroupNode', () => { + it('does nothing when activeNodeId is -1', () => { + const { manager, state } = createFixture(); + state.activeNodeId = -1; + state.enterGroupNode(); + expect(manager.parentStack.length).toBe(0); + }); + + it('does nothing when the active node is not a group instance', () => { + const { manager, state } = createFixture(); + const node = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} }); + assert.isDefined(node); + state.activeNodeId = node!.id; + state.enterGroupNode(); + expect(manager.parentStack.length).toBe(0); + }); + + it('enters the group, pushes graphStack, and clears UI state', () => { + const { manager, state } = createFixture(); + + const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} }); + assert.isDefined(nodeA); + const groupNode = manager.groupNodes([nodeA!.id]); + assert.isDefined(groupNode); + + state.selectedNodes.add(nodeA!.id); + state.activeNodeId = groupNode!.id; + state.cameraPosition = [10, 20, 5]; + + state.enterGroupNode(); + + expect(manager.parentStack.length).toBe(1); + expect(state.activeNodeId).toBe(-1); + expect(state.selectedNodes.size).toBe(0); + expect(manager.isInsideGroup).toBe(true); + }); +}); + +describe('exitGroupNode', () => { + it('does nothing when not inside a group', () => { + const { manager, state } = createFixture(); + const before = [...state.cameraPosition]; + state.exitGroupNode(); + expect(manager.parentStack.length).toBe(0); + expect(state.cameraPosition).toEqual(before); + }); + + it('clears activeNodeId and selection after exit', () => { + const { manager, state } = createFixture(); + + const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} }); + assert.isDefined(nodeA); + const groupNode = manager.groupNodes([nodeA!.id]); + assert.isDefined(groupNode); + + state.activeNodeId = groupNode!.id; + state.enterGroupNode(); + state.activeNodeId = 99; + state.selectedNodes.add(99); + + state.exitGroupNode(); + + // Group instance node is re-selected on exit; internal selection is cleared + expect(state.activeNodeId).toBe(groupNode!.id); + expect(state.selectedNodes.size).toBe(0); + }); + + it('restores outer nodes to manager after exit', () => { + const { manager, state } = createFixture(); + + const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} }); + const nodeB = manager.createNode({ type: 'test/node/input', position: [100, 0], props: {} }); + assert.isDefined(nodeA); + assert.isDefined(nodeB); + + manager.createEdge(nodeA!, 0, nodeB!, 'value'); + const groupNode = manager.groupNodes([nodeA!.id]); + assert.isDefined(groupNode); + + state.activeNodeId = groupNode!.id; + state.enterGroupNode(); + + // Inside the group: nodeA is an internal node so it IS active; the outer + // nodes (nodeB, groupNode) are saved and no longer in the active Map. + expect(manager.nodes.has(nodeA!.id)).toBe(true); + expect(manager.nodes.has(nodeB!.id)).toBe(false); + + state.exitGroupNode(); + + // After exit: outer nodes are restored + expect(manager.nodes.has(nodeB!.id)).toBe(true); + expect(manager.nodes.has(groupNode!.id)).toBe(true); + expect(manager.isInsideGroup).toBe(false); + }); + + it('isInsideGroup is false after exiting the only group level', () => { + const { manager, state } = createFixture(); + + const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} }); + assert.isDefined(nodeA); + const groupNode = manager.groupNodes([nodeA!.id]); + assert.isDefined(groupNode); + + state.activeNodeId = groupNode!.id; + state.enterGroupNode(); + expect(manager.isInsideGroup).toBe(true); + + state.exitGroupNode(); + expect(manager.isInsideGroup).toBe(false); + }); +}); + +describe('copyNodes / pasteNodes', () => { + it('copies the active node into the clipboard', () => { + const { manager, state } = createFixture(); + const node = manager.createNode({ type: 'test/node/output', position: [10, 20], props: {} }); + assert.isDefined(node); + + state.activeNodeId = node!.id; + state.mousePosition = [0, 0]; + state.copyNodes(); + + assert.isNotNull(state.clipboard); + expect(state.clipboard!.nodes.map(n => n.id)).toContain(node!.id); + }); + + it('includes edges between copied nodes', () => { + const { manager, state } = createFixture(); + const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} }); + const nodeB = manager.createNode({ type: 'test/node/input', position: [100, 0], props: {} }); + assert.isDefined(nodeA); + assert.isDefined(nodeB); + + manager.createEdge(nodeA!, 0, nodeB!, 'value'); + + state.activeNodeId = nodeA!.id; + state.selectedNodes.add(nodeB!.id); + state.mousePosition = [0, 0]; + state.copyNodes(); + + assert.isNotNull(state.clipboard); + expect(state.clipboard!.edges.length).toBe(1); + }); + + it('pastes nodes and adds them to the graph', () => { + const { manager, state } = createFixture(); + const node = manager.createNode({ type: 'test/node/output', position: [10, 20], props: {} }); + assert.isDefined(node); + + state.activeNodeId = node!.id; + state.mousePosition = [0, 0]; + state.copyNodes(); + + const countBefore = manager.nodes.size; + state.mousePosition = [50, 50]; + state.pasteNodes(); + + expect(manager.nodes.size).toBe(countBefore + 1); + }); + + it('does nothing when clipboard is empty', () => { + const { manager, state } = createFixture(); + manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} }); + const countBefore = manager.nodes.size; + state.pasteNodes(); + expect(manager.nodes.size).toBe(countBefore); + }); +}); diff --git a/app/src/lib/graph-interface/graph-state.svelte.ts b/app/src/lib/graph-interface/graph-state.svelte.ts index 7127bb1..db7bcfc 100644 --- a/app/src/lib/graph-interface/graph-state.svelte.ts +++ b/app/src/lib/graph-interface/graph-state.svelte.ts @@ -5,7 +5,7 @@ import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import type { OrthographicCamera, Vector3 } from 'three'; import type { GraphManager } from './graph-manager.svelte'; import { ColorGenerator } from './graph/colors'; -import { getNodeHeight, getSocketPosition } from './helpers/nodeHelpers'; +import { getNodeHeight, getParameterHeight } from './helpers/nodeHelpers'; const graphStateKey = Symbol('graph-state'); export function getGraphState() { @@ -152,10 +152,6 @@ export class GraphState { this.edges.delete(edgeId); } - getEdgeData() { - return this.edges; - } - updateNodePosition(node: NodeInstance) { if ( node.state.x === node.position[0] @@ -190,29 +186,6 @@ export class GraphState { return 1; } - tryConnectToDebugNode(nodeId: number) { - const node = this.graph.nodes.get(nodeId); - if (!node) return; - if (node.type.endsWith('/debug')) return; - if (!node.state.type?.outputs?.length) return; - for (const _node of this.graph.nodes.values()) { - if (_node.type.endsWith('/debug')) { - this.graph.createEdge(node, 0, _node, 'input'); - return; - } - } - - const debugNode = this.graph.createNode({ - type: 'max/plantarium/debug', - position: [node.position[0] + 30, node.position[1]], - props: {} - }); - - if (debugNode) { - this.graph.createEdge(node, 0, debugNode, 'input'); - } - } - copyNodes() { if (this.activeNodeId === -1 && !this.selectedNodes?.size) { return; @@ -240,6 +213,14 @@ export class GraphState { }; } + unGroupSelectedNodes() { + return this.graph.ungroupNode(this.activeNodeId); + } + + groupSelectedNodes() { + return this.graph.groupNodes([...this.selectedNodes.keys(), this.activeNodeId]); + } + centerNode(node?: NodeInstance) { const average = [0, 0, 4]; if (node) { @@ -301,7 +282,7 @@ export class GraphState { if (edge[3] === index) { node = edge[0]; index = edge[1]; - position = getSocketPosition(node, index); + position = this.getSocketPosition(node, index); this.graph.removeEdge(edge); break; } @@ -321,7 +302,7 @@ export class GraphState { return { node, index, - position: getSocketPosition(node, index) + position: this.getSocketPosition(node, index) }; }); } @@ -358,7 +339,8 @@ export class GraphState { for (const node of this.graph.nodes.values()) { const x = node.position[0]; const y = node.position[1]; - const height = getNodeHeight(node.state.type!); + const nodeType = this.graph.getNodeType(node); + const height = nodeType ? getNodeHeight(nodeType) : 20; if (downX > x && downX < x + 20 && downY > y && downY < y + height) { clickedNodeId = node.id; break; @@ -370,7 +352,8 @@ export class GraphState { } isNodeInView(node: NodeInstance) { - const height = getNodeHeight(node.state.type!); + if (!node) return false; + const height = getNodeHeight(this.graph.getNodeType(node)!); const width = 20; return node.position[0] > this.cameraBounds[0] - width && node.position[0] < this.cameraBounds[1] @@ -381,4 +364,57 @@ export class GraphState { openNodePalette() { this.addMenuPosition = [this.mousePosition[0], this.mousePosition[1]]; } + + enterGroupNode() { + if (this.activeNodeId === -1) return; + const node = this.graph.getNode(this.activeNodeId); + if (!node || node.type !== '__internal/group/instance') return; + const ok = this.graph.enterGroup(this.activeNodeId); + if (ok) { + this.activeNodeId = -1; + this.clearSelection(); + } + } + + exitGroupNode() { + const result = this.graph.exitGroup(); + if (!result) return; + this.activeNodeId = result.nodeId; + this.clearSelection(); + } + + getSocketPosition( + node: NodeInstance, + index: string | number + ): [number, number] { + if (node.type === '__internal/group/input' && typeof index === 'number') { + return [ + (node?.state?.x ?? node.position[0]) + 20, + (node?.state?.y ?? node.position[1]) + 2.5 + 5 * index + 5 + ]; + } + + if (typeof index === 'number') { + return [ + (node?.state?.x ?? node.position[0]) + 20, + (node?.state?.y ?? node.position[1]) + 2.5 + 10 * index + ]; + } else { + let height = 5; + const nodeType = this.graph.getNodeType(node)!; + const inputs = nodeType.inputs || {}; + for (const inputKey in inputs) { + const h = getParameterHeight(nodeType, inputKey) / 10; + if (inputKey === index) { + height += h / 2; + break; + } + height += h; + } + return [ + node?.state?.x ?? node.position[0], + (node?.state?.y ?? node.position[1]) + height + ]; + } + } } diff --git a/app/src/lib/graph-interface/graph/Graph.svelte b/app/src/lib/graph-interface/graph/Graph.svelte index 2460265..3d96928 100644 --- a/app/src/lib/graph-interface/graph/Graph.svelte +++ b/app/src/lib/graph-interface/graph/Graph.svelte @@ -7,11 +7,11 @@ import AddMenu from '../components/AddMenu.svelte'; import BoxSelection from '../components/BoxSelection.svelte'; import Camera from '../components/Camera.svelte'; + import GroupBreadcrumps from '../components/GroupBreadcrumps.svelte'; import HelpView from '../components/HelpView.svelte'; import Debug from '../debug/Debug.svelte'; import EdgeEl from '../edges/Edge.svelte'; import { getGraphManager, getGraphState } from '../graph-state.svelte'; - import { getSocketPosition } from '../helpers/nodeHelpers'; import NodeEl from '../node/Node.svelte'; import { maxZoom, minZoom } from './constants'; import { FileDropEventManager } from './drop.events'; @@ -39,8 +39,8 @@ return [0, 0, 0, 0]; } - const pos1 = getSocketPosition(fromNode, edge[1]); - const pos2 = getSocketPosition(toNode, edge[3]); + const pos1 = graphState.getSocketPosition(fromNode, edge[1]); + const pos2 = graphState.getSocketPosition(toNode, edge[3]); return [pos1[0], pos1[1], pos2[0], pos2[1]]; } @@ -97,10 +97,17 @@ } function getSocketType(node: NodeInstance, index: number | string): string { + const nodeType = graph.getNodeType(node); if (typeof index === 'string') { - return node.state.type?.inputs?.[index].type || 'unknown'; + return nodeType?.inputs?.[index].type || 'unknown'; } - return node.state.type?.outputs?.[index] || 'unknown'; + + if (node.type === '__internal/group/input') { + const key = Object.keys(nodeType?.inputs || {})[index]; + return nodeType?.inputs?.[key].type || 'unknown'; + } + + return nodeType?.outputs?.[index] || 'unknown'; } @@ -114,6 +121,7 @@ bind:this={graphState.wrapper} class="graph-wrapper" style="height: 100%" + class:is-inside-group={graph.isInsideGroup} class:is-panning={graphState.isPanning} class:is-hovering={graphState.hoveredNodeId !== -1} aria-label="Graph" @@ -121,6 +129,7 @@ tabindex="0" bind:clientWidth={graphState.width} bind:clientHeight={graphState.height} + style:--padding-right="{safePadding?.right || 0}px" onkeydown={(ev) => keymap.handleKeyboardEvent(ev)} onmousedown={(ev) => mouseEvents.handleMouseDown(ev)} oncontextmenu={(ev) => mouseEvents.handleContextMenu(ev)} @@ -136,6 +145,8 @@ /> + + - {#each graph.nodes.values() as node (node.id)} + {#each graph.nodeArray as node, index (node.id)} {/each} diff --git a/app/src/lib/graph-interface/graph/Wrapper.svelte b/app/src/lib/graph-interface/graph/Wrapper.svelte index 9e13f91..7b3de2e 100644 --- a/app/src/lib/graph-interface/graph/Wrapper.svelte +++ b/app/src/lib/graph-interface/graph/Wrapper.svelte @@ -1,6 +1,7 @@ - -{#if Object.keys(nodeDefinition).length} - -{:else} -

Node has no settings

-{/if} diff --git a/app/src/lib/sidebar/panels/ActiveNodeSettings.svelte b/app/src/lib/sidebar/panels/ActiveNodeSettings.svelte index 31c764d..d32e847 100644 --- a/app/src/lib/sidebar/panels/ActiveNodeSettings.svelte +++ b/app/src/lib/sidebar/panels/ActiveNodeSettings.svelte @@ -1,26 +1,103 @@ -
-

Node Settings

-
- -{#if node} - {#key node.id} - {#if node} - - {/if} - {/key} -{:else} -

No node selected

+{#if !isGroupInstance && Object.keys(nodeDefinition).length} +
+

Node Settings

+
+ {/if} diff --git a/app/src/lib/sidebar/panels/GraphSource.svelte b/app/src/lib/sidebar/panels/GraphSource.svelte index fc28117..efd3fce 100644 --- a/app/src/lib/sidebar/panels/GraphSource.svelte +++ b/app/src/lib/sidebar/panels/GraphSource.svelte @@ -1,20 +1,23 @@ -
-  {graph ? convert(graph) : "No graph loaded"}
-
+
+ {#if data} + + {:else} + No graph loaded + {/if} +
diff --git a/app/src/lib/sidebar/panels/GroupSettings.svelte b/app/src/lib/sidebar/panels/GroupSettings.svelte new file mode 100644 index 0000000..bedb4df --- /dev/null +++ b/app/src/lib/sidebar/panels/GroupSettings.svelte @@ -0,0 +1,156 @@ + + +{#if activeGroup} +
+

Group Settings

+
+{/if} + +{#if activeGroup} + {#key activeGroup.id} +
+ + + + +
+ +
+ + +
+ + +
+
+ {/key} +{/if} + +{#if manager && !manager.isInsideGroup} + +{/if} + + diff --git a/app/src/lib/sidebar/panels/UnusedGroupsPanel.svelte b/app/src/lib/sidebar/panels/UnusedGroupsPanel.svelte new file mode 100644 index 0000000..b217532 --- /dev/null +++ b/app/src/lib/sidebar/panels/UnusedGroupsPanel.svelte @@ -0,0 +1,135 @@ + + +{#if unusedTree.length} +
+
+ Unused groups + +
+ +
    + {#snippet treeNode(node: GroupNode)} +
  • + {node.group.name || `Group #${node.group.id}`} + {#if node.children.length} +
      + {#each node.children as child (child.group.id)} + {@render treeNode(child)} + {/each} +
    + {/if} +
  • + {/snippet} + {#each unusedTree as node (node.group.id)} + {@render treeNode(node)} + {/each} +
+
+{/if} + + diff --git a/app/src/routes/+page.svelte b/app/src/routes/+page.svelte index 3759bd0..edc30a5 100644 --- a/app/src/routes/+page.svelte +++ b/app/src/routes/+page.svelte @@ -4,7 +4,8 @@ import Grid from '$lib/grid'; import { debounceAsyncFunction } from '$lib/helpers'; import { createKeyMap } from '$lib/helpers/createKeyMap'; - import { debugNode } from '$lib/node-registry/debugNode.js'; + import { debugNode } from '$lib/node-registry/debugNode'; + import { groupNode } from '$lib/node-registry/groupNode.js'; import { IndexDBCache, RemoteNodeRegistry } from '$lib/node-registry/index'; import NodeStore from '$lib/node-store/NodeStore.svelte'; import PerformanceViewer from '$lib/performance/PerformanceViewer.svelte'; @@ -21,6 +22,7 @@ import Changelog from '$lib/sidebar/panels/Changelog.svelte'; import ExportSettings from '$lib/sidebar/panels/ExportSettings.svelte'; import GraphSource from '$lib/sidebar/panels/GraphSource.svelte'; + import GroupSettings from '$lib/sidebar/panels/GroupSettings.svelte'; import Keymap from '$lib/sidebar/panels/Keymap.svelte'; import { panelState } from '$lib/sidebar/PanelState.svelte'; import Sidebar from '$lib/sidebar/Sidebar.svelte'; @@ -37,7 +39,7 @@ const registryCache = new IndexDBCache('node-registry'); - const nodeRegistry = new RemoteNodeRegistry('', registryCache, [debugNode]); + const nodeRegistry = new RemoteNodeRegistry('', registryCache, [debugNode, groupNode]); const workerRuntime = new WorkerRuntimeExecutor(); const runtimeCache = new MemoryRuntimeCache(); const memoryRuntime = new MemoryRuntimeExecutor(nodeRegistry, runtimeCache); @@ -94,7 +96,7 @@ randomSeed: { type: 'boolean', value: false } }); $effect(() => { - if (graphSettings && graphSettingTypes) { + if (graphSettings && graphSettingTypes && manager?.loaded) { manager?.setSettings($state.snapshot(graphSettings)); } }); @@ -254,20 +256,22 @@ {#if pm.graph} - pm.saveGraph(g)} - onresult={(result) => handleUpdate(result as Graph)} - /> + {#key pm.graph.id} + pm.saveGraph(g)} + onresult={(result) => handleUpdate(result as Graph)} + /> + {/key} {/if} @@ -321,7 +325,9 @@ hidden={!appSettings.value.debug.advancedMode} icon="i-[tabler--code]" > - + {#if manager?.status === 'idle'} + + {/if} + - + {#key activeNode} + + + {/key} ; // current parameter values + meta?: { title?: string; lastModified?: string }; + state: NodeRuntimeState; // runtime-only, NOT serialized +}; + +type NodeRuntimeState = { + type?: NodeDefinition; // resolved definition + parents?: NodeInstance[]; + children?: NodeInstance[]; + x?: number; + y?: number; // interpolated position + mesh?: Mesh; // Three.js mesh reference + ref?: HTMLElement; +}; + +type NodeDefinition = { + id: NodeId; + inputs?: Record; + outputs?: string[]; // output type names + meta?: { title?: string; description?: string }; + execute(input: Int32Array): Int32Array; // WASM function +}; + +// Edge: [fromNode, outputIndex, toNode, inputSocketName] +type Edge = [NodeInstance, number, NodeInstance, string]; + +type Graph = { + nodes: NodeInstance[]; + edges: [number, number, number, string][]; // serialized (IDs, not refs) + settings: Record; + groups: GroupDefinition[]; +}; + +type GroupDefinition = { + id: number; + nodes: NodeInstance[]; + edges: Edge[]; + inputs?: Record; + outputs?: string[]; +}; +``` + +### NodeInput socket types + +`float` | `integer` | `boolean` | `select` | `seed` | `vec3` | `geometry` | `path` | `shape` | `color` | `*` (wildcard) + +Each input can have: `value` (default), `label`, `hidden`, `external`, `setting` (link to graph setting), `accepts` (extra compatible types). + +--- + +## Patterns & Conventions + +### Svelte 5 reactivity + +The codebase uses Svelte 5 runes throughout β€” `$state`, `$derived`, `$effect`. Collections use `SvelteMap` and `SvelteSet` (from `svelte/reactivity`) instead of plain Map/Set so that mutations trigger reactive updates. + +### Context API + +`GraphManager` and `GraphState` are provided via Svelte context (`setContext` / `getContext`) inside `GraphInterface`. All child components (Node, Edge, etc.) consume them via context rather than props. + +### Edge representation + +In memory, edges are `[NodeInstance, outputIndex, NodeInstance, inputSocketName]` β€” direct object references for fast traversal. On serialization (`Graph.edges`), they become `[nodeId, outputIndex, nodeId, inputSocketName]` (plain IDs). + +### Socket compatibility + +```typescript +areSocketsCompatible(outputType: string, inputType: string | string[]): boolean +// '*' wildcard matches any type; 'geometry' accepts ['geometry', 'instances'] +``` + +### WASM execution interface + +Every node exposes a single function: `execute(input: Int32Array): Int32Array`. +Data encoding (Plantarium): + +- `[0, stemDepth, ...x,y,z,thickness]` β€” path +- `[1, vertexCount, faceCount, ...faces, ...vertices, ...normals]` β€” geometry +- `[2, vertexCount, faceCount, instanceCount, stemDepth, ...]` β€” instances + +### Event emitter + +`GraphManager extends EventEmitter<{ save, result, settings }>`. Subscribe with `manager.on('result', cb)`. Used to decouple the editor UI from runtime execution and persistence. + +### History + +Every mutation goes through `HistoryManager`. Call `this.history.save(this.serialize())` before mutations; undo/redo replays jsondiffpatch deltas. + +### Internal node IDs + +Built-in nodes use the `__internal/` namespace: `__internal/group/instance`, `__internal/node/debug`. Virtual boundary nodes use `__virtual/`: `__virtual/group/input`, `__virtual/group/output`. + +--- + +## In-Progress: Node Groups (`feat/group-node-own`) + +Group selected nodes with **Ctrl+G**. A `GroupDefinition` is stored in `Graph.groups[]`; a group instance node (`__internal/group/instance`) referencing it by `props.groupId` replaces the selected nodes. + +**Known gaps as of 2026-05-03:** + +| Issue | Location | +| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `createGroupNode()` called but not defined | `graph-state.svelte.ts:334` β†’ missing in `graph-manager.svelte.ts` | +| Runtime expects `group.graph.nodes/edges`; schema has flat `nodes/edges` | `runtime-executor.ts` vs `types.ts` | +| Runtime expects `group.inputs` as array; schema defines it as `Record` | Same mismatch | +| `enterGroupNode()` is a stub β€” no group navigation | `graph-state.svelte.ts` | +| `serialize()` writes parent-graph edges into group instead of group-internal edges | `graph-manager.svelte.ts` | + +--- + +## Dev Commands + +Run from `app/`: + +```bash +npm run dev # start dev server (Vite) +npm run build # production build +npm run check # svelte-check + tsc +npm run lint # eslint +npm run test # unit (vitest) + e2e (playwright) +npm run test:unit # vitest only +npm run test:e2e # playwright only +npm run bench # benchmark runner +``` diff --git a/package.json b/package.json index 4c74dbe..5553daa 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "qa": "pnpm lint && pnpm check && pnpm test", "format": "pnpm dprint fmt", "format:check": "pnpm dprint check", - "test": "pnpm run -r --parallel test", + "test:e2e": "pnpm run -r --parallel test:e2e", + "test:unit": "pnpm run -r --parallel test:unit", "check": "pnpm run -r --parallel check", "build": "pnpm build:nodes && pnpm build:app", "build:app": "BASE_PATH=/ui pnpm -r --filter 'ui' build && pnpm -r --filter 'app'... build", @@ -19,6 +20,6 @@ "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316", "devDependencies": { "chokidar-cli": "catalog:", - "dprint": "^0.51.1" + "dprint": "^0.54.0" } } diff --git a/packages/planty/package.json b/packages/planty/package.json index c6dcdb0..e977d94 100644 --- a/packages/planty/package.json +++ b/packages/planty/package.json @@ -10,8 +10,8 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "eslint .", - "format": "dprint fmt -c '../.dprint.jsonc' .", - "format:check": "dprint check -c '../.dprint.jsonc' ." + "format": "dprint fmt -c '../../.dprint.jsonc' .", + "format:check": "dprint check -c '../../.dprint.jsonc' ." }, "files": [ "dist", @@ -34,29 +34,29 @@ "svelte": "^5.0.0" }, "devDependencies": { - "@nodarium/ui": "workspace:*", - "@eslint/compat": "^2.0.4", + "@eslint/compat": "^2.0.5", "@eslint/js": "^10.0.1", + "@nodarium/ui": "workspace:*", "@sveltejs/adapter-auto": "^7.0.1", - "@sveltejs/kit": "^2.57.0", + "@sveltejs/kit": "^2.59.0", "@sveltejs/package": "^2.5.7", "@sveltejs/vite-plugin-svelte": "^7.0.0", - "@tailwindcss/vite": "^4.2.2", - "@types/node": "^24", - "eslint": "^10.2.0", + "@tailwindcss/vite": "^4.2.4", + "@types/node": "^25.6.0", + "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-svelte": "^3.17.0", - "globals": "^17.4.0", - "prettier": "^3.8.1", + "eslint-plugin-svelte": "^3.17.1", + "globals": "^17.6.0", + "prettier": "^3.8.3", "prettier-plugin-svelte": "^3.5.1", - "prettier-plugin-tailwindcss": "^0.7.2", + "prettier-plugin-tailwindcss": "^0.8.0", "publint": "^0.3.18", - "svelte": "^5.55.2", - "svelte-check": "^4.4.6", - "tailwindcss": "^4.2.2", - "typescript": "^6.0.2", - "typescript-eslint": "^8.58.1", - "vite": "^8.0.7" + "svelte": "^5.55.5", + "svelte-check": "^4.4.7", + "tailwindcss": "^4.2.4", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.1", + "vite": "^8.0.10" }, "keywords": [ "svelte" diff --git a/packages/planty/tsconfig.json b/packages/planty/tsconfig.json index 5b776c4..53f2350 100644 --- a/packages/planty/tsconfig.json +++ b/packages/planty/tsconfig.json @@ -9,7 +9,6 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "module": "NodeNext", "moduleResolution": "bundler" } } diff --git a/packages/types/package.json b/packages/types/package.json index 65e8970..64809ff 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -18,9 +18,9 @@ "author": "", "license": "ISC", "dependencies": { - "zod": "^4.3.6" + "zod": "^4.4.3" }, "devDependencies": { - "dprint": "^0.51.1" + "dprint": "^0.54.0" } } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 47809c1..31cb6bb 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -4,11 +4,13 @@ export type { Box, Edge, Graph, + GroupDefinition, NodeDefinition, NodeId, NodeInstance, + SerializedEdge, SerializedNode, Socket } from './types'; -export { GraphSchema, NodeSchema } from './types'; +export { GraphSchema, GroupSchema, NodeSchema } from './types'; export { NodeDefinitionSchema } from './types'; diff --git a/packages/types/src/inputs.ts b/packages/types/src/inputs.ts index 4a09b6e..8ecd33d 100644 --- a/packages/types/src/inputs.ts +++ b/packages/types/src/inputs.ts @@ -61,8 +61,10 @@ export const NodeInputBooleanSchema = z.object({ export const NodeInputSelectSchema = z.object({ ...DefaultOptionsSchema.shape, type: z.literal('select'), - options: z.array(z.string()).optional(), - value: z.string().optional() + options: z.array( + z.union([z.string(), z.object({ value: z.number(), label: z.string() })]) + ).optional(), + value: z.union([z.string(), z.number()]).optional() }); export const NodeInputSeedSchema = z.object({ diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 2a9167b..2a5f7fe 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -76,6 +76,24 @@ export type Socket = { export type Edge = [NodeInstance, number, NodeInstance, string]; +const SerializedEdgeSchema = z.tuple([z.number(), z.number(), z.number(), z.string()]); + +export type SerializedEdge = z.infer; + +export const GroupSchema = z.object({ + id: z.number(), + name: z.string().optional(), + nodes: z.array(NodeSchema), + edges: z.array(z.tuple([z.number(), z.number(), z.number(), z.string()])), + inputs: z.record(z.string(), NodeInputSchema).optional(), + outputs: z.array(z.object({ + type: z.string(), + label: z.string().optional() + })).optional() +}); + +export type GroupDefinition = z.infer; + export const GraphSchema = z.object({ id: z.number(), meta: z @@ -86,7 +104,8 @@ export const GraphSchema = z.object({ .optional(), settings: z.record(z.string(), z.any()).optional(), nodes: z.array(NodeSchema), - edges: z.array(z.tuple([z.number(), z.number(), z.number(), z.string()])) + edges: z.array(SerializedEdgeSchema), + groups: z.array(GroupSchema) }); export type Graph = z.infer; diff --git a/packages/ui/package.json b/packages/ui/package.json index a8e70ef..2d056fe 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -30,46 +30,47 @@ "svelte": "^4.0.0" }, "devDependencies": { - "@eslint/compat": "^2.0.2", - "@eslint/eslintrc": "^3.3.3", - "@eslint/js": "^9.39.2", + "@eslint/compat": "^2.0.5", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "^10.0.1", "@nodarium/types": "workspace:^", - "@playwright/test": "^1.58.1", + "@playwright/test": "^1.59.1", "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.50.2", + "@sveltejs/kit": "^2.59.0", "@sveltejs/package": "^2.5.7", - "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@sveltejs/vite-plugin-svelte": "^7.0.0", "@testing-library/svelte": "^5.3.1", "@types/eslint": "^9.6.1", - "@types/three": "^0.182.0", - "@typescript-eslint/eslint-plugin": "^8.54.0", - "@typescript-eslint/parser": "^8.54.0", - "@vitest/browser-playwright": "^4.0.18", - "dprint": "^0.51.1", - "eslint": "^9.39.2", - "eslint-plugin-svelte": "^3.14.0", - "globals": "^17.3.0", - "publint": "^0.3.17", - "svelte": "^5.49.2", - "svelte-check": "^4.3.6", - "svelte-eslint-parser": "^1.4.1", + "@types/node": "^25.6.0", + "@types/three": "^0.184.0", + "@typescript-eslint/eslint-plugin": "^8.59.1", + "@typescript-eslint/parser": "^8.59.1", + "@vitest/browser-playwright": "^4.1.5", + "dprint": "^0.54.0", + "eslint": "^10.3.0", + "eslint-plugin-svelte": "^3.17.1", + "globals": "^17.6.0", + "publint": "^0.3.18", + "svelte": "^5.55.5", + "svelte-check": "^4.4.7", + "svelte-eslint-parser": "^1.6.0", "tslib": "^2.8.1", - "typescript": "^5.9.3", - "typescript-eslint": "^8.54.0", - "vite": "^7.3.1", - "vitest": "^4.0.18", - "vitest-browser-svelte": "^2.0.2" + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.1", + "vite": "^8.0.10", + "vitest": "^4.1.5", + "vitest-browser-svelte": "^2.1.1" }, "svelte": "./dist/index.js", "types": "./dist/index.d.ts", "type": "module", "dependencies": { + "@iconify-json/tabler": "^1.2.33", + "@iconify/tailwind4": "^1.2.3", "@nodarium/ui": "workspace:*", - "@iconify-json/tabler": "^1.2.26", - "@iconify/tailwind4": "^1.2.1", - "@tailwindcss/vite": "^4.1.18", - "@threlte/core": "^8.3.1", - "@threlte/extras": "^9.7.1", - "tailwindcss": "^4.1.18" + "@tailwindcss/vite": "^4.2.4", + "@threlte/core": "^8.5.11", + "@threlte/extras": "^9.15.1", + "tailwindcss": "^4.2.4" } } diff --git a/packages/ui/src/lib/JsonViewer.svelte b/packages/ui/src/lib/JsonViewer.svelte new file mode 100644 index 0000000..267627e --- /dev/null +++ b/packages/ui/src/lib/JsonViewer.svelte @@ -0,0 +1,144 @@ + + + + + + {#if key !== undefined} + : + {/if} + + {#if isExpandable} + {#if items.length === 0} + {open_bracket}{close_bracket} + {:else if open} + {#if depth > 0} + + {/if} + {open_bracket} +
+ {#each items as [k, v], i (k)} +
+ {#if i < items.length - 1},{/if} +
+ {/each} +
+ {close_bracket} + {:else} + + {/if} + {:else if value === null} + null + {:else if typeof value === 'boolean'} + {value} + {:else if typeof value === 'number'} + {value} + {:else if typeof value === 'string'} + "{value}" + {:else} + {String(value)} + {/if} +
diff --git a/packages/ui/src/lib/app.css b/packages/ui/src/lib/app.css index f1d64c8..b7c185f 100644 --- a/packages/ui/src/lib/app.css +++ b/packages/ui/src/lib/app.css @@ -9,7 +9,7 @@ @source inline("{hover:,}{bg-,outline-,text-,}layer-3{/30,/50,}"); @source inline("{hover:,}{bg-,outline-,text-,}active"); @source inline("{hover:,}{bg-,outline-,text-,}selected"); -@source inline("{hover:,}{bg-,outline-,text-,}outline{!,}"); +@source inline("{hover:,}{bg-,outline-,text-,border-,divide-}outline{!,}"); @source inline("{hover:,}{bg-,outline-,text-,}connection"); @source inline("{hover:,}{bg-,outline-,text-,}text"); diff --git a/packages/ui/src/lib/index.ts b/packages/ui/src/lib/index.ts index 9a51c8b..03670f9 100644 --- a/packages/ui/src/lib/index.ts +++ b/packages/ui/src/lib/index.ts @@ -5,8 +5,10 @@ export { default as InputNumber } from './inputs/InputNumber.svelte'; export { default as InputSelect } from './inputs/InputSelect.svelte'; export { default as InputShape } from './inputs/InputShape.svelte'; export { default as InputVec3 } from './inputs/InputVec3.svelte'; +export { default as SocketTable } from './inputs/SocketTable.svelte'; export { default as Details } from './Details.svelte'; +export { default as JsonViewer } from './JsonViewer.svelte'; export { default as ShortCut } from './ShortCut.svelte'; import Input from './Input.svelte'; diff --git a/packages/ui/src/lib/inputs/InputSelect.svelte b/packages/ui/src/lib/inputs/InputSelect.svelte index faf7c43..1c9e923 100644 --- a/packages/ui/src/lib/inputs/InputSelect.svelte +++ b/packages/ui/src/lib/inputs/InputSelect.svelte @@ -1,16 +1,22 @@ diff --git a/packages/ui/src/lib/inputs/SocketTable.svelte b/packages/ui/src/lib/inputs/SocketTable.svelte new file mode 100644 index 0000000..1673cb6 --- /dev/null +++ b/packages/ui/src/lib/inputs/SocketTable.svelte @@ -0,0 +1,118 @@ + + +{#snippet row(input: { type: string; label?: string }, remove: () => void, add?: () => void)} +
+ + + + + {#if add} + + {/if} +
+{/snippet} + +
+ {#each Object.entries(inputs ?? {}) as [key, input] (key)} + {@render row(input, () => removeRow(key))} + {/each} + {#if potentialRow} +
+ {@render row(potentialRow, () => removeRow(), () => realizePotentialRow())} +
+ {:else} +
+
+
+ +
+
+ {/if} +
diff --git a/packages/ui/src/routes/+page.svelte b/packages/ui/src/routes/+page.svelte index 1daff88..32515cf 100644 --- a/packages/ui/src/routes/+page.svelte +++ b/packages/ui/src/routes/+page.svelte @@ -1,4 +1,5 @@