From 7499b80789e99b87df54d6891597fac7edb56b0f Mon Sep 17 00:00:00 2001 From: Max Richter Date: Sun, 3 May 2026 17:49:14 +0200 Subject: [PATCH] fix: make the runtime work with groups --- app/src/lib/runtime/runtime-executor.test.ts | 154 ++++++++++++++++++ app/src/lib/runtime/runtime-executor.ts | 130 ++++----------- packages/ui/src/lib/inputs/InputSelect.svelte | 7 - 3 files changed, 190 insertions(+), 101 deletions(-) create mode 100644 app/src/lib/runtime/runtime-executor.test.ts diff --git a/app/src/lib/runtime/runtime-executor.test.ts b/app/src/lib/runtime/runtime-executor.test.ts new file mode 100644 index 0000000..7549e0a --- /dev/null +++ b/app/src/lib/runtime/runtime-executor.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import { expandGroups } from './runtime-executor'; +import type { Graph } from '@nodarium/types'; + +// Helpers to build minimal serialized nodes/edges +function node(id: number, type: string, props?: Record) { + return { id, type: type as Graph['nodes'][0]['type'], position: [0, 0] as [number, number], ...(props ? { props } : {}) }; +} + +function edge(from: number, fromSocket: number, to: number, toSocket: string): [number, number, number, string] { + return [from, fromSocket, to, toSocket]; +} + +describe('expandGroups', () => { + it('returns graph unchanged when there are no groups', () => { + const graph: Graph = { + id: 1, + nodes: [node(0, 'test/node/output'), node(1, 'test/node/input')], + edges: [edge(0, 0, 1, 'value')], + groups: [] + }; + + const result = expandGroups(graph); + + expect(result.nodes.length).toBe(2); + expect(result.edges.length).toBe(1); + expect(result).toBe(graph); // same reference — no copy needed + }); + + it('expands a simple group: A → [B] → C rewires to A → B → C', () => { + // IDs: A=1, B=2, C=3, groupNode=4, group.id=5, inputBoundary=6, outputBoundary=7 + const groupId = 5; + const groupNodeId = 4; + const remappedB = (groupNodeId + 1) * 1_000_000 + 2; // 5_000_002 + + const graph: Graph = { + id: 1, + nodes: [ + node(1, 'test/node/output'), + node(groupNodeId, '__internal/group/instance', { groupId }), + node(3, 'test/node/input') + ], + edges: [ + edge(1, 0, groupNodeId, 'input_0'), // A → group + edge(groupNodeId, 0, 3, 'value') // group → C + ], + groups: [{ + id: groupId, + nodes: [ + node(6, '__internal/group/input'), + node(2, 'test/node/output'), + node(7, '__internal/group/output') + ], + edges: [ + edge(6, 0, 2, 'input'), // inputBoundary → B + edge(2, 0, 7, 'Out') // B → outputBoundary + ], + inputs: { input_0: { type: 'float' } }, + outputs: [{ type: 'float', label: 'Output 0' }] + }] + }; + + const result = expandGroups(graph); + + const ids = result.nodes.map(n => n.id); + expect(ids).not.toContain(groupNodeId); + expect(ids).toContain(remappedB); + expect(ids).toContain(1); // A + expect(ids).toContain(3); // C + expect(result.nodes.length).toBe(3); // A, B(remapped), C + + expect(result.edges).toContainEqual(edge(1, 0, remappedB, 'input')); // A → B + expect(result.edges).toContainEqual(edge(remappedB, 0, 3, 'value')); // B → C + expect(result.edges.length).toBe(2); + }); + + it('expands a group with two internal nodes (B→D) and preserves the internal edge', () => { + // A → [B → D] → C + const groupId = 10; + const groupNodeId = 5; + const remappedB = (groupNodeId + 1) * 1_000_000 + 1; // 6_000_001 + const remappedD = (groupNodeId + 1) * 1_000_000 + 2; // 6_000_002 + + const graph: Graph = { + id: 1, + nodes: [ + node(0, 'test/node/output'), + node(groupNodeId, '__internal/group/instance', { groupId }), + node(9, 'test/node/input') + ], + edges: [ + edge(0, 0, groupNodeId, 'input_0'), + edge(groupNodeId, 0, 9, 'value') + ], + groups: [{ + id: groupId, + nodes: [ + node(3, '__internal/group/input'), + node(1, 'test/node/output'), // B + node(2, 'test/node/output'), // D + node(4, '__internal/group/output') + ], + edges: [ + edge(3, 0, 1, 'input'), // inputBoundary → B + edge(1, 0, 2, 'input'), // B → D (internal) + edge(2, 0, 4, 'Out') // D → outputBoundary + ], + inputs: { input_0: { type: 'float' } }, + outputs: [{ type: 'float' }] + }] + }; + + const result = expandGroups(graph); + + expect(result.nodes.map(n => n.id)).not.toContain(groupNodeId); + expect(result.nodes.map(n => n.id)).toContain(remappedB); + expect(result.nodes.map(n => n.id)).toContain(remappedD); + + expect(result.edges).toContainEqual(edge(0, 0, remappedB, 'input')); // A → B + expect(result.edges).toContainEqual(edge(remappedB, 0, remappedD, 'input')); // B → D (internal) + expect(result.edges).toContainEqual(edge(remappedD, 0, 9, 'value')); // D → C + expect(result.edges.length).toBe(3); + }); + + it('expands a group with no external connections (isolated)', () => { + const groupId = 20; + const groupNodeId = 1; + const remappedB = (groupNodeId + 1) * 1_000_000 + 2; // 2_000_002 + + const graph: Graph = { + id: 1, + nodes: [node(groupNodeId, '__internal/group/instance', { groupId })], + edges: [], + groups: [{ + id: groupId, + nodes: [ + node(3, '__internal/group/input'), + node(2, 'test/node/output'), + node(4, '__internal/group/output') + ], + edges: [ + edge(3, 0, 2, 'input'), + edge(2, 0, 4, 'Out') + ] + }] + }; + + const result = expandGroups(graph); + + expect(result.nodes.map(n => n.id)).not.toContain(groupNodeId); + expect(result.nodes.map(n => n.id)).toContain(remappedB); + expect(result.edges.length).toBe(0); + }); +}); diff --git a/app/src/lib/runtime/runtime-executor.ts b/app/src/lib/runtime/runtime-executor.ts index e8203bc..d527a23 100644 --- a/app/src/lib/runtime/runtime-executor.ts +++ b/app/src/lib/runtime/runtime-executor.ts @@ -7,18 +7,11 @@ import type { SyncCache } from '@nodarium/types'; -function isGroupInstanceType(type: string): boolean { - return type === '__virtual/group/instance'; -} - export function expandGroups(graph: Graph): Graph { - if (!graph.groups || Object.keys(graph.groups).length === 0) { - return graph; - } + if (!graph.groups || graph.groups.length === 0) return graph; let nodes = [...graph.nodes]; let edges = [...graph.edges]; - const groups = graph.groups; let changed = true; while (changed) { @@ -26,120 +19,69 @@ export function expandGroups(graph: Graph): Graph { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; - if (!isGroupInstanceType(node.type)) continue; + if (node.type !== '__internal/group/instance') continue; - const groupId = (node.props as Record | undefined)?.groupId as - | string - | undefined; - if (!groupId) continue; - const group = groups[groupId]; + const groupId = node.props?.groupId as number | undefined; + if (groupId === undefined) continue; + + const group = graph.groups.find(g => g.id === groupId); if (!group) continue; changed = true; - // Recursively expand nested groups inside this group's internal graph - const expandedInternal = expandGroups({ - id: 0, - nodes: group.graph.nodes, - edges: group.graph.edges, - groups - }); - - const ID_PREFIX = node.id * 1000000; + const ID_OFFSET = (node.id + 1) * 1_000_000; const idMap = new Map(); - const inputVirtualNode = expandedInternal.nodes.find( - n => n.type === '__virtual/group/input' - ); - const outputVirtualNode = expandedInternal.nodes.find( - n => n.type === '__virtual/group/output' + const inputBoundary = group.nodes.find(n => n.type === '__internal/group/input'); + const outputBoundary = group.nodes.find(n => n.type === '__internal/group/output'); + + const realNodes = group.nodes.filter( + n => n.type !== '__internal/group/input' && n.type !== '__internal/group/output' ); - const realInternalNodes = expandedInternal.nodes.filter( - n => n.type !== '__virtual/group/input' && n.type !== '__virtual/group/output' - ); - - for (const n of realInternalNodes) { - idMap.set(n.id, ID_PREFIX + n.id); - } - - const parentIncomingEdges = edges.filter(e => e[2] === node.id); - const parentOutgoingEdges = edges.filter(e => e[0] === node.id); - - // Edges from/to virtual nodes in the expanded internal graph - const edgesFromInput = expandedInternal.edges.filter( - e => e[0] === inputVirtualNode?.id - ); - const edgesToOutput = expandedInternal.edges.filter( - e => e[2] === outputVirtualNode?.id - ); + for (const n of realNodes) idMap.set(n.id, ID_OFFSET + n.id); + const incomingExternal = edges.filter(e => e[2] === node.id); + const outgoingExternal = edges.filter(e => e[0] === node.id); const newEdges: Graph['edges'] = []; - // Short-circuit: parent source → internal target (via group input) - for (const parentEdge of parentIncomingEdges) { - const socketName = parentEdge[3]; - const socketIdx = group.inputs.findIndex(s => s.name === socketName); - if (socketIdx === -1) continue; - - for (const internalEdge of edgesFromInput.filter(e => e[1] === socketIdx)) { - const remappedId = idMap.get(internalEdge[2]); - if (remappedId !== undefined) { - newEdges.push([parentEdge[0], parentEdge[1], remappedId, internalEdge[3]]); + // external_source → [inputBoundary →] internal_target + if (inputBoundary) { + const fromInput = group.edges.filter(e => e[0] === inputBoundary.id); + for (const extEdge of incomingExternal) { + for (const intEdge of fromInput) { + const toId = idMap.get(intEdge[2]); + if (toId !== undefined) newEdges.push([extEdge[0], extEdge[1], toId, intEdge[3]]); } } } - // Short-circuit: internal source → parent target (via group output) - for (const parentEdge of parentOutgoingEdges) { - const outputIdx = parentEdge[1]; - const outputSocketName = group.outputs[outputIdx]?.name; - if (!outputSocketName) continue; - - for (const internalEdge of edgesToOutput.filter(e => e[3] === outputSocketName)) { - const remappedId = idMap.get(internalEdge[0]); - if (remappedId !== undefined) { - newEdges.push([remappedId, internalEdge[1], parentEdge[2], parentEdge[3]]); + // internal_source → [outputBoundary →] external_target + if (outputBoundary) { + const toOutput = group.edges.filter(e => e[2] === outputBoundary.id); + for (const extEdge of outgoingExternal) { + for (const intEdge of toOutput) { + const fromId = idMap.get(intEdge[0]); + if (fromId !== undefined) newEdges.push([fromId, intEdge[1], extEdge[2], extEdge[3]]); } } } - // Remap internal-to-internal edges - const internalEdges = expandedInternal.edges.filter( - e => - e[0] !== inputVirtualNode?.id - && e[0] !== outputVirtualNode?.id - && e[2] !== inputVirtualNode?.id - && e[2] !== outputVirtualNode?.id - ); - - for (const e of internalEdges) { + // internal-to-internal edges (skip boundary edges) + for (const e of group.edges) { + if (e[0] === inputBoundary?.id || e[2] === outputBoundary?.id) continue; const fromId = idMap.get(e[0]); const toId = idMap.get(e[2]); - if (fromId !== undefined && toId !== undefined) { - newEdges.push([fromId, e[1], toId, e[3]]); - } + if (fromId !== undefined && toId !== undefined) newEdges.push([fromId, e[1], toId, e[3]]); } - // Remove the group node nodes.splice(i, 1); + for (const n of realNodes) nodes.push({ ...n, id: idMap.get(n.id)! }); - // Add remapped internal nodes - for (const n of realInternalNodes) { - nodes.push({ ...n, id: idMap.get(n.id)! }); - } - - // Remove group node's edges and add short-circuit edges - const groupEdgeKeys = new Set([ - ...parentIncomingEdges.map(e => `${e[0]}-${e[1]}-${e[2]}-${e[3]}`), - ...parentOutgoingEdges.map(e => `${e[0]}-${e[1]}-${e[2]}-${e[3]}`) - ]); - edges = edges.filter( - e => !groupEdgeKeys.has(`${e[0]}-${e[1]}-${e[2]}-${e[3]}`) - ); + edges = edges.filter(e => e[0] !== node.id && e[2] !== node.id); edges.push(...newEdges); - break; // Restart loop with updated nodes array + break; } } diff --git a/packages/ui/src/lib/inputs/InputSelect.svelte b/packages/ui/src/lib/inputs/InputSelect.svelte index 12e3e74..faf7c43 100644 --- a/packages/ui/src/lib/inputs/InputSelect.svelte +++ b/packages/ui/src/lib/inputs/InputSelect.svelte @@ -6,13 +6,6 @@ } let { options = [], value = $bindable(0), id = '' }: Props = $props(); - - $effect(() => { - console.log({ options, value }); - if (typeof value !== typeof options[0]) { - console.trace('WARNING: value type does not match options type'); - } - });