diff --git a/app/src/lib/graph-interface/graph-manager.svelte.ts b/app/src/lib/graph-interface/graph-manager.svelte.ts index d123933..5d18e90 100644 --- a/app/src/lib/graph-interface/graph-manager.svelte.ts +++ b/app/src/lib/graph-interface/graph-manager.svelte.ts @@ -19,7 +19,7 @@ import EventEmitter from './helpers/EventEmitter'; import { HistoryManager } from './history-manager'; const logger = createLogger('graph-manager'); -logger.mute(); +// logger.mute(); const remoteRegistry = new RemoteNodeRegistry(''); @@ -73,9 +73,22 @@ export class GraphManager extends EventEmitter<{ id = $state(0); nodes = new SvelteMap(); + nodeArray = $derived(Array.from(this.nodes.values())); edges = $state([]); + // Plain array — NOT $state. rootGraph items are plain-serialized (safe for structuredClone). + // savedNodes/savedEdges hold live reactive references so reactivity is preserved on exit. + graphStack: { + rootGraph: Graph; + savedNodes: Map; + savedEdges: Edge[]; + outerGraph: Graph; + groupId: number; + nodeId: number; + cameraPosition: [number, number, number]; + }[] = []; + settingTypes: Record = {}; settings = $state>(); @@ -90,9 +103,25 @@ export class GraphManager extends EventEmitter<{ }); history: HistoryManager = new HistoryManager(); + + private serializeFullGraph(): Graph { + if (this.graphStack.length === 0) return this.serialize(); + let merged = this.serialize(); + for (let i = this.graphStack.length - 1; i >= 0; i--) { + const { rootGraph, groupId } = this.graphStack[i]; + merged = { + ...rootGraph, + groups: rootGraph.groups.map(g => + g.id === groupId ? { ...g, nodes: merged.nodes, edges: merged.edges } : g + ) + }; + } + return merged; + } + execute = throttle(() => { if (this.loaded === false) return; - this.emit('result', this.serialize()); + this.emit('result', this.serializeFullGraph()); }, 10); constructor(public registry: NodeRegistry) { @@ -263,6 +292,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) { @@ -290,13 +341,11 @@ export class GraphManager extends EventEmitter<{ 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 - }; - } + const registryType = this.registry.getNode(node.type); + n.state = registryType ? { type: registryType } : {}; + const resolvedType = this.getNodeType(n); + if (resolvedType) n.state = { type: resolvedType }; return [node.id, n]; }) ); @@ -436,6 +485,39 @@ export class GraphManager extends EventEmitter<{ } getNodeType(node: NodeInstance) { + if (!node) { + console.trace('failed to get node type'); + return; + } + + if (node.type === '__internal/group/input') { + const groupId = this.graphStack.at(-1)?.groupId; + const group = groupId !== undefined ? this.getGroup(groupId) : undefined; + if (!group) return node.state.type; + return { + id: '__internal/group/input' as NodeId, + outputs: Object.values(group.inputs ?? {}).map(i => i.type), + execute: (x: Int32Array) => x + } as NodeDefinition; + } + + if (node.type === '__internal/group/output') { + const groupId = this.graphStack.at(-1)?.groupId; + const group = groupId !== undefined ? this.getGroup(groupId) : 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 }]) + ), + outputs: [], + execute: (x: Int32Array) => x + } as NodeDefinition; + } + // Construct the group inputs on the fly if (node.type === '__internal/group/instance') { const groupDefinition = this.getGroup(node.props?.groupId as number); @@ -446,15 +528,15 @@ export class GraphManager extends EventEmitter<{ } const inputs = { + ...(node.state.type?.inputs || {}), + ...groupDefinition?.inputs, 'groupId': { type: 'select', label: '', value: node.props?.groupId, internal: true, options: this.graph.groups.map(g => g.id) - }, - ...(node.state.type?.inputs || {}), - ...groupDefinition?.inputs + } }; const groupType = { @@ -576,6 +658,58 @@ export class GraphManager extends EventEmitter<{ return this.graph.groups.find(g => g.id === id); } + isInsideGroup = $state(false); + + enterGroup(nodeId: number, cameraPosition: [number, number, number]): boolean { + 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; + + this.graphStack.push({ + rootGraph: this.serialize(), + savedNodes: new Map(this.nodes), + savedEdges: [...this.edges], + outerGraph: this.graph, + groupId, + nodeId, + cameraPosition + }); + this.graph = { ...this.graph, nodes: group.nodes, edges: group.edges }; + this._init(this.graph); + this.history.reset(); + this.isInsideGroup = true; + return true; + } + + exitGroup(): { camera: [number, number, number]; nodeId: number } | false { + if (!this.graphStack.length) return false; + const { savedNodes, savedEdges, outerGraph, groupId, nodeId, cameraPosition } = this.graphStack.pop()!; + const internalState = this.serialize(); + + // Restore live reactive nodes and edges so drag-reactivity is preserved + this.nodes.clear(); + for (const [id, node] of savedNodes) { + this.nodes.set(id, node); + } + this.edges = savedEdges; + + // Patch the group definition with the edited internal graph + this.graph = { + ...outerGraph, + groups: (outerGraph.groups ?? []).map(g => + g.id === groupId ? { ...g, nodes: internalState.nodes, edges: internalState.edges } : g + ) + }; + + this.history.reset(); + this.isInsideGroup = this.graphStack.length > 0; + this.execute(); + this.save(); + return { camera: cameraPosition, nodeId }; + } + createNodeId() { const ids = [ ...this.nodes.keys(), @@ -583,6 +717,8 @@ export class GraphManager extends EventEmitter<{ ...this.graph.groups.flatMap(g => g.nodes.map(n => n.id)) ]; + console.log('CREATE NODE ID', ids); + let id = 0; while (ids.includes(id)) { id++; @@ -723,7 +859,7 @@ export class GraphManager extends EventEmitter<{ 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], groupOutputNode.id, 'out_0']; } return [edge[0].id, edge[1], edge[2].id, edge[3]]; }) as [number, number, number, string][]; @@ -900,8 +1036,9 @@ export class GraphManager extends EventEmitter<{ return; } - this.emit('save', state); - logger.log('saving graphs', state); + const fullState = this.graphStack.length > 0 ? this.serializeFullGraph() : state; + this.emit('save', fullState); + logger.log('saving graphs', fullState); } getParentsOfNode(node: NodeInstance) { diff --git a/app/src/lib/graph-interface/graph-state.svelte.ts b/app/src/lib/graph-interface/graph-state.svelte.ts index ce5b24e..fb24cc4 100644 --- a/app/src/lib/graph-interface/graph-state.svelte.ts +++ b/app/src/lib/graph-interface/graph-state.svelte.ts @@ -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: '__internal/node/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; @@ -362,7 +335,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(this.graph.getNodeType(node)!); + 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; @@ -374,6 +348,7 @@ export class GraphState { } isNodeInView(node: NodeInstance) { + if (!node) return false; const height = getNodeHeight(this.graph.getNodeType(node)!); const width = 20; return node.position[0] > this.cameraBounds[0] - width @@ -388,8 +363,21 @@ export class GraphState { enterGroupNode() { if (this.activeNodeId === -1) return; - const selectedNode = this.graph.getNode(this.activeNodeId); - if (!selectedNode || selectedNode.type.startsWith('__internal/group/instance')) return; + const node = this.graph.getNode(this.activeNodeId); + if (!node || node.type !== '__internal/group/instance') return; + const ok = this.graph.enterGroup(this.activeNodeId, [...this.cameraPosition]); + if (ok) { + this.activeNodeId = -1; + this.clearSelection(); + } + } + + exitGroupNode() { + const result = this.graph.exitGroup(); + if (!result) return; + this.cameraPosition = result.camera; + this.activeNodeId = -1; + this.clearSelection(); } getSocketPosition( diff --git a/app/src/lib/graph-interface/graph/Graph.svelte b/app/src/lib/graph-interface/graph/Graph.svelte index bbe40e1..f1bad0b 100644 --- a/app/src/lib/graph-interface/graph/Graph.svelte +++ b/app/src/lib/graph-interface/graph/Graph.svelte @@ -95,8 +95,9 @@ graphState.addMenuPosition = null; } - function getSocketType(node: NodeInstance, index: number | string, e: unknown): string { + function getSocketType(node: NodeInstance, index: number | string): string { const nodeType = graph.getNodeType(node); + console.log({ nodeType, index }); if (typeof index === 'string') { return nodeType?.inputs?.[index].type || 'unknown'; } @@ -169,6 +170,14 @@ {/if} {#if graph.status === 'idle'} + {#if graph.isInsideGroup} + + + + {/if} + {#if graphState.addMenuPosition} - {#each graph.getAllNodes() as node (node.id)} + {#each graph.nodeArray as node, index (node.id)} {/each} @@ -244,6 +253,26 @@ height: 100%; } + :global(.exit-group) { + position: fixed; + top: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + padding: 4px 12px; + background: var(--color-layer-2); + border: 1px solid var(--stroke); + border-radius: 4px; + color: inherit; + font-size: 0.85em; + cursor: pointer; + opacity: 0.85; + } + + :global(.exit-group:hover) { + opacity: 1; + } + .wrapper { position: absolute; z-index: 100; diff --git a/app/src/lib/graph-interface/graph/mouse.events.ts b/app/src/lib/graph-interface/graph/mouse.events.ts index 336fd1f..38bb791 100644 --- a/app/src/lib/graph-interface/graph/mouse.events.ts +++ b/app/src/lib/graph-interface/graph/mouse.events.ts @@ -190,7 +190,7 @@ export class MouseEventManager { // if we clicked on a node if (clickedNodeId !== -1) { if (event.ctrlKey && event.shiftKey) { - this.state.tryConnectToDebugNode(clickedNodeId); + this.graph.tryConnectToDebugNode(clickedNodeId); return; } if (this.state.activeNodeId === -1) { diff --git a/app/src/lib/graph-interface/helpers/nodeHelpers.ts b/app/src/lib/graph-interface/helpers/nodeHelpers.ts index 71a8d47..f4dd252 100644 --- a/app/src/lib/graph-interface/helpers/nodeHelpers.ts +++ b/app/src/lib/graph-interface/helpers/nodeHelpers.ts @@ -25,15 +25,12 @@ export function getParameterHeight(node: NodeDefinition, inputKey: string) { const nodeHeightCache: Record = {}; export function getNodeHeight(node: NodeDefinition) { - if (!node) { - console.trace('Node is undefined', node); + if (!node || !('inputs' in node)) { + return 5; } if (node.id in nodeHeightCache) { return nodeHeightCache[node.id]; } - if (!node?.inputs) { - return 5; - } let height = 5; for (const key in node.inputs) { diff --git a/app/src/lib/graph-interface/keymaps.ts b/app/src/lib/graph-interface/keymaps.ts index 3be6742..e96f6e6 100644 --- a/app/src/lib/graph-interface/keymaps.ts +++ b/app/src/lib/graph-interface/keymaps.ts @@ -47,6 +47,10 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr key: 'Escape', description: 'Deselect nodes', callback: () => { + if (graph.isInsideGroup) { + graphState.exitGroupNode(); + return; + } graphState.activeNodeId = -1; graphState.clearSelection(); graphState.edgeEndPosition = null; @@ -64,6 +68,7 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr keymap.addShortcut({ key: 'Tab', + preventDefault: true, description: 'Enter selected node group', callback: () => graphState.enterGroupNode() }); diff --git a/app/src/lib/graph-interface/node/Node.svelte b/app/src/lib/graph-interface/node/Node.svelte index 8265695..6090fe1 100644 --- a/app/src/lib/graph-interface/node/Node.svelte +++ b/app/src/lib/graph-interface/node/Node.svelte @@ -34,14 +34,14 @@ const sectionHeights = $derived( Object - .keys(nodeType.inputs || {}) + .keys(nodeType?.inputs || {}) .map(key => getParameterHeight(nodeType, key) / 10) .filter(b => !!b) ); let meshRef: Mesh | undefined = $state(); - const height = $derived(getNodeHeight(nodeType)); + const height = $derived(nodeType ? getNodeHeight(nodeType) : 20); const zoom = $derived(graphState.cameraPosition[2]); diff --git a/app/src/lib/graph-interface/node/NodeParameter.svelte b/app/src/lib/graph-interface/node/NodeParameter.svelte index 36249cb..55579aa 100644 --- a/app/src/lib/graph-interface/node/NodeParameter.svelte +++ b/app/src/lib/graph-interface/node/NodeParameter.svelte @@ -19,7 +19,7 @@ let { node = $bindable(), input, id, isLast }: Props = $props(); - const nodeType = $derived(graph.getNodeType(node)!); + let nodeType = $derived(graph.getNodeType(node)!); const inputType = $derived(nodeType.inputs?.[id]); diff --git a/app/src/lib/graph-templates/grid.ts b/app/src/lib/graph-templates/grid.ts index bc759e6..7510243 100644 --- a/app/src/lib/graph-templates/grid.ts +++ b/app/src/lib/graph-templates/grid.ts @@ -4,7 +4,8 @@ export function grid(width: number, height: number) { const graph: Graph = { id: Math.floor(Math.random() * 100000), edges: [], - nodes: [] + nodes: [], + groups: [] }; const amount = width * height; diff --git a/app/src/lib/graph-templates/tree.ts b/app/src/lib/graph-templates/tree.ts index e382a1c..6c39992 100644 --- a/app/src/lib/graph-templates/tree.ts +++ b/app/src/lib/graph-templates/tree.ts @@ -47,6 +47,7 @@ export function tree(depth: number): Graph { return { id: Math.floor(Math.random() * 100000), nodes, - edges + edges, + groups: [] }; } diff --git a/app/src/lib/runtime/runtime-executor.ts b/app/src/lib/runtime/runtime-executor.ts index d527a23..3b19183 100644 --- a/app/src/lib/runtime/runtime-executor.ts +++ b/app/src/lib/runtime/runtime-executor.ts @@ -159,7 +159,7 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor { // Only load non-virtual types (virtual nodes are resolved locally) const nonVirtualTypes = graph.nodes .map(node => node.type) - .filter(t => !t.startsWith('__virtual/')); + .filter(t => !t.startsWith('__internal/')); await this.registry.load(nonVirtualTypes as any); const typeMap = new Map();