feat: some updates
📊 Benchmark the Runtime / benchmark (pull_request) Successful in 1m13s
🚀 Lint & Test & Deploy / quality (pull_request) Failing after 44s
🚀 Lint & Test & Deploy / test-unit (pull_request) Failing after 29s
🚀 Lint & Test & Deploy / test-e2e (pull_request) Failing after 33s
🚀 Lint & Test & Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
2026-05-04 11:27:21 +02:00
parent 7499b80789
commit ef217b1c40
11 changed files with 222 additions and 64 deletions
@@ -19,7 +19,7 @@ import EventEmitter from './helpers/EventEmitter';
import { HistoryManager } from './history-manager'; import { HistoryManager } from './history-manager';
const logger = createLogger('graph-manager'); const logger = createLogger('graph-manager');
logger.mute(); // logger.mute();
const remoteRegistry = new RemoteNodeRegistry(''); const remoteRegistry = new RemoteNodeRegistry('');
@@ -73,9 +73,22 @@ export class GraphManager extends EventEmitter<{
id = $state(0); id = $state(0);
nodes = new SvelteMap<number, NodeInstance>(); nodes = new SvelteMap<number, NodeInstance>();
nodeArray = $derived(Array.from(this.nodes.values()));
edges = $state<Edge[]>([]); edges = $state<Edge[]>([]);
// 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<number, NodeInstance>;
savedEdges: Edge[];
outerGraph: Graph;
groupId: number;
nodeId: number;
cameraPosition: [number, number, number];
}[] = [];
settingTypes: Record<string, NodeInput> = {}; settingTypes: Record<string, NodeInput> = {};
settings = $state<Record<string, unknown>>(); settings = $state<Record<string, unknown>>();
@@ -90,9 +103,25 @@ export class GraphManager extends EventEmitter<{
}); });
history: HistoryManager = new HistoryManager(); 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(() => { execute = throttle(() => {
if (this.loaded === false) return; if (this.loaded === false) return;
this.emit('result', this.serialize()); this.emit('result', this.serializeFullGraph());
}, 10); }, 10);
constructor(public registry: NodeRegistry) { 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][] { getEdgesBetweenNodes(nodes: NodeInstance[]): [number, number, number, string][] {
const edges = []; const edges = [];
for (const node of nodes) { for (const node of nodes) {
@@ -290,13 +341,11 @@ export class GraphManager extends EventEmitter<{
private _init(graph: Graph) { private _init(graph: Graph) {
const nodes = new SvelteMap( const nodes = new SvelteMap(
graph.nodes.map((node) => { graph.nodes.map((node) => {
const nodeType = this.registry.getNode(node.type);
const n = node as NodeInstance; const n = node as NodeInstance;
if (nodeType) { const registryType = this.registry.getNode(node.type);
n.state = { n.state = registryType ? { type: registryType } : {};
type: nodeType const resolvedType = this.getNodeType(n);
}; if (resolvedType) n.state = { type: resolvedType };
}
return [node.id, n]; return [node.id, n];
}) })
); );
@@ -436,6 +485,39 @@ export class GraphManager extends EventEmitter<{
} }
getNodeType(node: NodeInstance) { 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 // Construct the group inputs on the fly
if (node.type === '__internal/group/instance') { if (node.type === '__internal/group/instance') {
const groupDefinition = this.getGroup(node.props?.groupId as number); const groupDefinition = this.getGroup(node.props?.groupId as number);
@@ -446,15 +528,15 @@ export class GraphManager extends EventEmitter<{
} }
const inputs = { const inputs = {
...(node.state.type?.inputs || {}),
...groupDefinition?.inputs,
'groupId': { 'groupId': {
type: 'select', type: 'select',
label: '', label: '',
value: node.props?.groupId, value: node.props?.groupId,
internal: true, internal: true,
options: this.graph.groups.map(g => g.id) options: this.graph.groups.map(g => g.id)
}, }
...(node.state.type?.inputs || {}),
...groupDefinition?.inputs
}; };
const groupType = { const groupType = {
@@ -576,6 +658,58 @@ export class GraphManager extends EventEmitter<{
return this.graph.groups.find(g => g.id === id); 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() { createNodeId() {
const ids = [ const ids = [
...this.nodes.keys(), ...this.nodes.keys(),
@@ -583,6 +717,8 @@ export class GraphManager extends EventEmitter<{
...this.graph.groups.flatMap(g => g.nodes.map(n => n.id)) ...this.graph.groups.flatMap(g => g.nodes.map(n => n.id))
]; ];
console.log('CREATE NODE ID', ids);
let id = 0; let id = 0;
while (ids.includes(id)) { while (ids.includes(id)) {
id++; id++;
@@ -723,7 +859,7 @@ export class GraphManager extends EventEmitter<{
return [groupInputNode.id, 0, edge[2].id, edge[3]]; return [groupInputNode.id, 0, edge[2].id, edge[3]];
// Going out to the group // Going out to the group
} else if (!ids.has(edge[2].id)) { } 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]]; return [edge[0].id, edge[1], edge[2].id, edge[3]];
}) as [number, number, number, string][]; }) as [number, number, number, string][];
@@ -900,8 +1036,9 @@ export class GraphManager extends EventEmitter<{
return; return;
} }
this.emit('save', state); const fullState = this.graphStack.length > 0 ? this.serializeFullGraph() : state;
logger.log('saving graphs', state); this.emit('save', fullState);
logger.log('saving graphs', fullState);
} }
getParentsOfNode(node: NodeInstance) { getParentsOfNode(node: NodeInstance) {
@@ -152,10 +152,6 @@ export class GraphState {
this.edges.delete(edgeId); this.edges.delete(edgeId);
} }
getEdgeData() {
return this.edges;
}
updateNodePosition(node: NodeInstance) { updateNodePosition(node: NodeInstance) {
if ( if (
node.state.x === node.position[0] node.state.x === node.position[0]
@@ -190,29 +186,6 @@ export class GraphState {
return 1; 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() { copyNodes() {
if (this.activeNodeId === -1 && !this.selectedNodes?.size) { if (this.activeNodeId === -1 && !this.selectedNodes?.size) {
return; return;
@@ -362,7 +335,8 @@ export class GraphState {
for (const node of this.graph.nodes.values()) { for (const node of this.graph.nodes.values()) {
const x = node.position[0]; const x = node.position[0];
const y = node.position[1]; 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) { if (downX > x && downX < x + 20 && downY > y && downY < y + height) {
clickedNodeId = node.id; clickedNodeId = node.id;
break; break;
@@ -374,6 +348,7 @@ export class GraphState {
} }
isNodeInView(node: NodeInstance) { isNodeInView(node: NodeInstance) {
if (!node) return false;
const height = getNodeHeight(this.graph.getNodeType(node)!); const height = getNodeHeight(this.graph.getNodeType(node)!);
const width = 20; const width = 20;
return node.position[0] > this.cameraBounds[0] - width return node.position[0] > this.cameraBounds[0] - width
@@ -388,8 +363,21 @@ export class GraphState {
enterGroupNode() { enterGroupNode() {
if (this.activeNodeId === -1) return; if (this.activeNodeId === -1) return;
const selectedNode = this.graph.getNode(this.activeNodeId); const node = this.graph.getNode(this.activeNodeId);
if (!selectedNode || selectedNode.type.startsWith('__internal/group/instance')) return; 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( getSocketPosition(
+37 -8
View File
@@ -95,8 +95,9 @@
graphState.addMenuPosition = null; 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); const nodeType = graph.getNodeType(node);
console.log({ nodeType, index });
if (typeof index === 'string') { if (typeof index === 'string') {
return nodeType?.inputs?.[index].type || 'unknown'; return nodeType?.inputs?.[index].type || 'unknown';
} }
@@ -169,6 +170,14 @@
{/if} {/if}
{#if graph.status === 'idle'} {#if graph.status === 'idle'}
{#if graph.isInsideGroup}
<HTML transform={false}>
<button class="exit-group" onclick={() => graphState.exitGroupNode()}>
↑ Exit Group
</button>
</HTML>
{/if}
{#if graphState.addMenuPosition} {#if graphState.addMenuPosition}
<AddMenu <AddMenu
onnode={handleNodeCreation} onnode={handleNodeCreation}
@@ -182,8 +191,8 @@
{#if graphState.activeSocket} {#if graphState.activeSocket}
<EdgeEl <EdgeEl
z={graphState.cameraPosition[2]} z={graphState.cameraPosition[2]}
inputType={getSocketType(graphState.activeSocket.node, graphState.activeSocket.index, 'c')} inputType={getSocketType(graphState.activeSocket.node, graphState.activeSocket.index)}
outputType={getSocketType(graphState.activeSocket.node, graphState.activeSocket.index, 'd')} outputType={getSocketType(graphState.activeSocket.node, graphState.activeSocket.index)}
x1={graphState.activeSocket.position[0]} x1={graphState.activeSocket.position[0]}
y1={graphState.activeSocket.position[1]} y1={graphState.activeSocket.position[1]}
x2={graphState.edgeEndPosition?.[0] ?? graphState.mousePosition[0]} x2={graphState.edgeEndPosition?.[0] ?? graphState.mousePosition[0]}
@@ -196,8 +205,8 @@
<EdgeEl <EdgeEl
id={graph.getEdgeId(edge)} id={graph.getEdgeId(edge)}
z={graphState.cameraPosition[2]} z={graphState.cameraPosition[2]}
inputType={getSocketType(edge[0], edge[1], 'a')} inputType={getSocketType(edge[0], edge[1])}
outputType={getSocketType(edge[2], edge[3], 'b')} outputType={getSocketType(edge[2], edge[3])}
{x1} {x1}
{y1} {y1}
{x2} {x2}
@@ -216,10 +225,10 @@
style:transform={`scale(${graphState.cameraPosition[2] * 0.1})`} style:transform={`scale(${graphState.cameraPosition[2] * 0.1})`}
class:hovering-sockets={graphState.activeSocket} class:hovering-sockets={graphState.activeSocket}
> >
{#each graph.getAllNodes() as node (node.id)} {#each graph.nodeArray as node, index (node.id)}
<NodeEl <NodeEl
{node} bind:node={graph.nodeArray[index]}
inView={graphState.isNodeInView(node)} inView={node ? graphState.isNodeInView(node) : false}
/> />
{/each} {/each}
</div> </div>
@@ -244,6 +253,26 @@
height: 100%; 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 { .wrapper {
position: absolute; position: absolute;
z-index: 100; z-index: 100;
@@ -190,7 +190,7 @@ export class MouseEventManager {
// if we clicked on a node // if we clicked on a node
if (clickedNodeId !== -1) { if (clickedNodeId !== -1) {
if (event.ctrlKey && event.shiftKey) { if (event.ctrlKey && event.shiftKey) {
this.state.tryConnectToDebugNode(clickedNodeId); this.graph.tryConnectToDebugNode(clickedNodeId);
return; return;
} }
if (this.state.activeNodeId === -1) { if (this.state.activeNodeId === -1) {
@@ -25,15 +25,12 @@ export function getParameterHeight(node: NodeDefinition, inputKey: string) {
const nodeHeightCache: Record<string, number> = {}; const nodeHeightCache: Record<string, number> = {};
export function getNodeHeight(node: NodeDefinition) { export function getNodeHeight(node: NodeDefinition) {
if (!node) { if (!node || !('inputs' in node)) {
console.trace('Node is undefined', node); return 5;
} }
if (node.id in nodeHeightCache) { if (node.id in nodeHeightCache) {
return nodeHeightCache[node.id]; return nodeHeightCache[node.id];
} }
if (!node?.inputs) {
return 5;
}
let height = 5; let height = 5;
for (const key in node.inputs) { for (const key in node.inputs) {
+5
View File
@@ -47,6 +47,10 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
key: 'Escape', key: 'Escape',
description: 'Deselect nodes', description: 'Deselect nodes',
callback: () => { callback: () => {
if (graph.isInsideGroup) {
graphState.exitGroupNode();
return;
}
graphState.activeNodeId = -1; graphState.activeNodeId = -1;
graphState.clearSelection(); graphState.clearSelection();
graphState.edgeEndPosition = null; graphState.edgeEndPosition = null;
@@ -64,6 +68,7 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
keymap.addShortcut({ keymap.addShortcut({
key: 'Tab', key: 'Tab',
preventDefault: true,
description: 'Enter selected node group', description: 'Enter selected node group',
callback: () => graphState.enterGroupNode() callback: () => graphState.enterGroupNode()
}); });
+2 -2
View File
@@ -34,14 +34,14 @@
const sectionHeights = $derived( const sectionHeights = $derived(
Object Object
.keys(nodeType.inputs || {}) .keys(nodeType?.inputs || {})
.map(key => getParameterHeight(nodeType, key) / 10) .map(key => getParameterHeight(nodeType, key) / 10)
.filter(b => !!b) .filter(b => !!b)
); );
let meshRef: Mesh | undefined = $state(); let meshRef: Mesh | undefined = $state();
const height = $derived(getNodeHeight(nodeType)); const height = $derived(nodeType ? getNodeHeight(nodeType) : 20);
const zoom = $derived(graphState.cameraPosition[2]); const zoom = $derived(graphState.cameraPosition[2]);
@@ -19,7 +19,7 @@
let { node = $bindable(), input, id, isLast }: Props = $props(); 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]); const inputType = $derived(nodeType.inputs?.[id]);
+2 -1
View File
@@ -4,7 +4,8 @@ export function grid(width: number, height: number) {
const graph: Graph = { const graph: Graph = {
id: Math.floor(Math.random() * 100000), id: Math.floor(Math.random() * 100000),
edges: [], edges: [],
nodes: [] nodes: [],
groups: []
}; };
const amount = width * height; const amount = width * height;
+2 -1
View File
@@ -47,6 +47,7 @@ export function tree(depth: number): Graph {
return { return {
id: Math.floor(Math.random() * 100000), id: Math.floor(Math.random() * 100000),
nodes, nodes,
edges edges,
groups: []
}; };
} }
+1 -1
View File
@@ -159,7 +159,7 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
// Only load non-virtual types (virtual nodes are resolved locally) // Only load non-virtual types (virtual nodes are resolved locally)
const nonVirtualTypes = graph.nodes const nonVirtualTypes = graph.nodes
.map(node => node.type) .map(node => node.type)
.filter(t => !t.startsWith('__virtual/')); .filter(t => !t.startsWith('__internal/'));
await this.registry.load(nonVirtualTypes as any); await this.registry.load(nonVirtualTypes as any);
const typeMap = new Map<string, NodeDefinition>(); const typeMap = new Map<string, NodeDefinition>();