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 31c51a0..d123933 100644 --- a/app/src/lib/graph-interface/graph-manager.svelte.ts +++ b/app/src/lib/graph-interface/graph-manager.svelte.ts @@ -1,8 +1,10 @@ import throttle from '$lib/helpers/throttle'; import { RemoteNodeRegistry } from '$lib/node-registry/index'; import type { + Box, Edge, Graph, + GroupDefinition, NodeDefinition, NodeId, NodeInput, @@ -10,7 +12,6 @@ import type { NodeRegistry, Socket } from '@nodarium/types'; -import { type GroupDefinition } from '@nodarium/types'; import { fastHashString } from '@nodarium/utils'; import { createLogger } from '@nodarium/utils'; import { SvelteMap, SvelteSet } from 'svelte/reactivity'; @@ -120,19 +121,12 @@ export class GraphManager extends EventEmitter<{ props: node.props })) as NodeInstance[]; - const groupEdges = this.edges.map((edge) => [ - edge[0].id, - edge[1], - edge[2].id, - edge[3] - ]) as Graph['edges']; - return { id: group.id, inputs: group.inputs, outputs: group.outputs, nodes: groupNodes, - edges: groupEdges + edges: group.edges }; }); @@ -332,15 +326,29 @@ export class GraphManager extends EventEmitter<{ const a = performance.now(); this.loaded = false; + graph.groups ??= []; this.graph = graph; this.status = 'loading'; this.id = graph.id; - logger.info('loading graph', { nodes: graph.nodes, edges: graph.edges, id: graph.id }); + logger.info( + 'loading graph', + { nodes: graph.nodes, edges: graph.edges, id: graph.id } + ); const nodeIds = Array - .from(new SvelteSet([...graph.nodes.map((n) => n.type)])) + .from( + new SvelteSet( + [ + ...graph.nodes, + graph?.groups?.map(g => g.nodes).flat() + ] + .filter(n => n && 'type' in n) + .map((n) => n.type) + ) + ) .filter(n => !n.startsWith('__internal/')); + await this.registry.load(nodeIds); // Fetch all nodes from all collections of the loaded nodes @@ -419,15 +427,6 @@ export class GraphManager extends EventEmitter<{ } getAllNodes() { - this.graph.groups ??= []; - if (!this.graph.groups.length) { - this.graph.groups.push({ - id: 0, - nodes: [], - edges: [] - }); - } - return Array .from(this.nodes.values()); } @@ -437,26 +436,34 @@ export class GraphManager extends EventEmitter<{ } getNodeType(node: NodeInstance) { - // Construct the inputs on the fly + // Construct the group inputs on the fly if (node.type === '__internal/group/instance') { const groupDefinition = this.getGroup(node.props?.groupId as number); + if (!groupDefinition) { + logger.error(`Group not found: ${node.props?.groupId}`); + return; + } + const inputs = { 'groupId': { type: 'select', label: '', - value: node.props?.groupId.toString(), + value: node.props?.groupId, internal: true, - options: this.graph.groups.map(g => g.id.toString()) + options: this.graph.groups.map(g => g.id) }, ...(node.state.type?.inputs || {}), ...groupDefinition?.inputs }; - return { + const groupType = { ...node.state.type, - inputs + inputs, + outputs: groupDefinition?.outputs?.map(o => o.type) } as NodeDefinition; + + return groupType; } return node.state.type; @@ -521,6 +528,7 @@ export class GraphManager extends EventEmitter<{ } removeNode(node: NodeInstance, { restoreEdges = false } = {}) { + console.log('REMOVING NODE', $state.snapshot({ node })); 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]) { @@ -564,16 +572,22 @@ export class GraphManager extends EventEmitter<{ } } - createGroupId() { - return Math.max(0, ...this.graph.groups.keys()) + 1; - } - getGroup(id: number) { return this.graph.groups.find(g => g.id === id); } createNodeId() { - return Math.max(0, ...this.nodes.keys()) + 1; + const ids = [ + ...this.nodes.keys(), + ...this.graph.groups.map(g => g.id), + ...this.graph.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][]) { @@ -586,7 +600,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 } }; @@ -619,6 +633,148 @@ export class GraphManager extends EventEmitter<{ return nodes; } + removeUnusedGroups() { + const usedGroups = new Set(this.getAllNodes().map(n => n.props?.groupId)); + const unusedGroupAmount = this.graph.groups.length - usedGroups.size; + this.graph.groups = this.graph.groups.filter(g => usedGroups.has(g.id)); + this.save(); + return unusedGroupAmount; + } + + groupNodes(nodeIds: number[]) { + this.startUndoGroup(); + this.removeUnusedGroups(); + + const nodes = [ + ...new Set(nodeIds).values().map(id => this.getNode(id)).filter(Boolean) + ] as NodeInstance[]; + + if (!nodes.length) return; + + logger.log(`Grouping ${nodes.length} nodes`, { nodes }); + + const ids = new Set(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 Map(); + 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 Map(); + for (const edge of outgoingEdges) { + groupOutputs.set(`${edge[2].id}-${edge[3]}`, edge); + } + + const inputs: Record = {}; + [...groupInputs.values()].forEach((edge, i) => { + const input = { + label: `Input ${i}`, + type: edge[0].state.type?.outputs?.[edge[1]] || '*' + }; + inputs[`input_${i}`] = input as NodeInput; + }); + + const outputs = [...groupOutputs.values()].map((edge, i) => ({ + label: `Output ${i}`, + 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; + + const groupInputNode: NodeInstance = { + id: this.createNodeId(), + type: '__internal/group/input', + position: [bounds.minX - 50, (bounds.minY + bounds.maxY) / 2], + state: {} + }; + + const groupOutputNode: NodeInstance = { + id: this.createNodeId(), + type: '__internal/group/output', + position: [bounds.maxX + 25, (bounds.minY + bounds.maxY) / 2], + state: {} + }; + + // Edges that are inside the group + const internalEdges = this.edges.filter((edge) => { + return ids.has(edge[0].id) || ids.has(edge[2].id); + }).map((edge) => { + // Going in from the group + if (!ids.has(edge[0].id)) { + return [groupInputNode.id, 0, edge[2].id, edge[3]]; + // Going out to the group + } else if (!ids.has(edge[2].id)) { + return [edge[0].id, edge[1], groupOutputNode.id, 'Out']; + } + return [edge[0].id, edge[1], edge[2].id, edge[3]]; + }) as [number, number, number, string][]; + + const groupId = this.createNodeId(); + const groupDefinition: GroupDefinition = { + id: groupId, + inputs: inputs, + outputs: outputs, + edges: internalEdges, + nodes: [groupInputNode, ...nodes, groupOutputNode] + }; + + 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'); + + // Update the edges that are now inside + // the group to be connected to that group node + const externalEdges = this.edges.map((edge) => { + if (ids.has(edge[2].id)) { + // Edge going into the group + return [edge[0], edge[1], groupNode, 'input_0'] as Edge; + } else if (ids.has(edge[0].id)) { + // Edge going out of the group + return [groupNode, 0, edge[2], edge[3]] as Edge; + } + return edge; + }); + + this.graph.groups.push(groupDefinition); + 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); + } + + console.log('FINISHED', this.serialize()); + this.saveUndoGroup(); + + return groupNode; + } + createNode({ type, position, @@ -629,7 +785,7 @@ export class GraphManager extends EventEmitter<{ props: NodeInstance['props']; }) { const nodeType = this.registry.getNode(type); - if (!nodeType) { + if (!nodeType && !type.startsWith('__internal/')) { logger.error(`Node type not found: ${type}`); return; } @@ -649,26 +805,6 @@ export class GraphManager extends EventEmitter<{ return node; } - createGroupNode(position: [number, number], groupDefinition: GroupDefinition): NodeInstance { - this.graph.groups ??= []; - this.graph.groups.push(groupDefinition); - const node = { - id: this.createNodeId(), - type: '__internal/group/instance', - meta: { - title: 'Group' - }, - props: { - groupId: groupDefinition.id - }, - position, - state: {} - } as const; - - this.nodes.set(node.id, node); - return node; - } - createEdge( from: NodeInstance, fromSocket: number, diff --git a/app/src/lib/graph-interface/graph-state.svelte.ts b/app/src/lib/graph-interface/graph-state.svelte.ts index 53cb68b..ce5b24e 100644 --- a/app/src/lib/graph-interface/graph-state.svelte.ts +++ b/app/src/lib/graph-interface/graph-state.svelte.ts @@ -1,5 +1,5 @@ import { animate, lerp } from '$lib/helpers'; -import type { Box, Edge, GroupDefinition, NodeInput, NodeInstance, Socket } from '@nodarium/types'; +import type { NodeInstance, Socket } from '@nodarium/types'; import { getContext, setContext } from 'svelte'; import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import type { OrthographicCamera, Vector3 } from 'three'; @@ -240,117 +240,8 @@ export class GraphState { }; } - groupSelectedNodes(nodeIds = [...this.selectedNodes.keys(), this.activeNodeId]) { - const ids = new Set(nodeIds); - const nodes = [ - ...ids.values().map(id => this.graph.getNode(id)).filter(Boolean) - ] as NodeInstance[]; - - const incomingEdges = this.graph.edges.filter((edge) => - ids.has(edge[2].id) && !ids.has(edge[0].id) - ); - const groupInputs = new Map(); - for (const edge of incomingEdges) { - groupInputs.set(`${edge[0].id}-${edge[1]}`, edge); - } - - const outgoingEdges = this.graph.edges.filter((edge) => - ids.has(edge[0].id) && !ids.has(edge[2].id) - ); - const groupOutputs = new Map(); - for (const edge of outgoingEdges) { - groupOutputs.set(`${edge[2].id}-${edge[3]}`, edge); - } - - const inputs: Record = {}; - [...groupInputs.values()].forEach((edge, i) => { - const input = { - label: `Input ${i}`, - type: edge[0].state.type?.outputs?.[edge[1]] || '*' - }; - inputs[`input_${i}`] = input as NodeInput; - }); - - const outputs = [...groupOutputs.values()].map((edge, i) => ({ - label: `Output ${i}`, - 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; - - const groupInputNode: NodeInstance = { - id: this.graph.createNodeId(), - type: '__internal/group/input', - position: [bounds.minX - 50, (bounds.minY + bounds.maxY) / 2], - state: {} - }; - - const groupOutputNode: NodeInstance = { - id: this.graph.createNodeId(), - type: '__internal/group/output', - position: [bounds.maxX + 25, (bounds.minY + bounds.maxY) / 2], - state: {} - }; - - // Edges that are inside the group - const internalEdges = this.graph.edges.filter((edge) => { - return ids.has(edge[0].id) || ids.has(edge[2].id); - }).map((edge) => { - // Going in from the group - if (!ids.has(edge[0].id)) { - return [groupInputNode, 0, edge[2], edge[3]]; - // Going out to the group - } else if (!ids.has(edge[2].id)) { - return [edge[0], edge[1], groupOutputNode, 'Out']; - } - return edge; - }); - - const groupId = this.graph.createGroupId(); - const groupDefinition: GroupDefinition = { - id: groupId, - inputs: inputs, - outputs: outputs, - edges: internalEdges, - nodes: [groupInputNode, ...nodes, groupOutputNode] - }; - const groupNode = this.graph.createGroupNode(groupPosition, groupDefinition); - - // Update the edges that are now inside - // the group to be connected to that group node - const externalEdges = this.graph.edges.map((edge) => { - if (ids.has(edge[2].id)) { - // Edge going into the group - return [edge[0], edge[1], groupNode, 'input_0'] as Edge; - } else if (ids.has(edge[0].id)) { - // Edge going out of the group - return [groupNode, 0, edge[2], edge[3]] as Edge; - } - return edge; - }); - - for (const node of nodes) { - this.graph.nodes.delete(node.id); - } - this.graph.edges = externalEdges; - this.graph.saveUndoGroup(); - console.log( - $state.snapshot({ - groupNode, - groupDefinition - }) - ); + groupSelectedNodes() { + return this.graph.groupNodes([...this.selectedNodes.keys(), this.activeNodeId]); } centerNode(node?: NodeInstance) { diff --git a/app/src/lib/graph-interface/graph/Graph.svelte b/app/src/lib/graph-interface/graph/Graph.svelte index 108b9a3..bbe40e1 100644 --- a/app/src/lib/graph-interface/graph/Graph.svelte +++ b/app/src/lib/graph-interface/graph/Graph.svelte @@ -97,7 +97,6 @@ function getSocketType(node: NodeInstance, index: number | string, e: unknown): string { const nodeType = graph.getNodeType(node); - console.log($state.snapshot({ nodeType, index, e })); if (typeof index === 'string') { return nodeType?.inputs?.[index].type || 'unknown'; } diff --git a/app/src/lib/graph-interface/graph/Wrapper.svelte b/app/src/lib/graph-interface/graph/Wrapper.svelte index 9e13f91..55866d3 100644 --- a/app/src/lib/graph-interface/graph/Wrapper.svelte +++ b/app/src/lib/graph-interface/graph/Wrapper.svelte @@ -1,6 +1,7 @@