From 09a9f8ce2c3af6aa24dc94449f8025632f6e5e8e Mon Sep 17 00:00:00 2001 From: Max Richter Date: Fri, 24 Apr 2026 17:34:10 +0200 Subject: [PATCH] feat: initial app code --- .../graph-interface/graph-manager.svelte.ts | 107 +++++++++++++++--- app/src/lib/graph-interface/node/Node.svelte | 2 +- .../lib/graph-interface/node/NodeHTML.svelte | 24 +++- app/src/lib/settings/NestedSettings.svelte | 2 +- .../sidebar/panels/GroupContextPanel.svelte | 31 ++++- packages/types/src/inputs.ts | 5 +- packages/ui/src/lib/Input.svelte | 2 +- packages/ui/src/lib/inputs/InputSelect.svelte | 23 +++- 8 files changed, 165 insertions(+), 31 deletions(-) diff --git a/app/src/lib/graph-interface/graph-manager.svelte.ts b/app/src/lib/graph-interface/graph-manager.svelte.ts index 6e4227d..08d6419 100644 --- a/app/src/lib/graph-interface/graph-manager.svelte.ts +++ b/app/src/lib/graph-interface/graph-manager.svelte.ts @@ -91,7 +91,7 @@ export class GraphManager extends EventEmitter<{ currentUndoGroup: number | null = null; // Group-related state - groups: Map = new Map(); + groups = new SvelteMap(); groupNodeDefinitions: Map = new Map(); currentGroupContext: string | null = null; graphStack: { rootGraph: Graph; groupId: string; cameraPosition: [number, number, number] }[] = $state([]); @@ -113,12 +113,15 @@ export class GraphManager extends EventEmitter<{ let merged: Graph = this.serialize(); for (let i = this.graphStack.length - 1; i >= 0; i--) { const { rootGraph, groupId } = $state.snapshot(this.graphStack[i]); + // Prefer the live definition (may have been updated via addGroupSocket/rename) + // over the snapshot taken when we entered the group. + const currentDef = (this.graph.groups ?? rootGraph.groups)?.[groupId]; merged = { ...rootGraph, groups: { ...rootGraph.groups, [groupId]: { - ...rootGraph.groups?.[groupId]!, + ...(currentDef as NodeGroupDefinition), graph: { nodes: merged.nodes, edges: merged.edges } } } @@ -142,9 +145,13 @@ export class GraphManager extends EventEmitter<{ return { id: `__virtual/group/${group.id}` as NodeId, meta: { title: group.name }, - inputs: Object.fromEntries( - group.inputs.map(s => [s.name, { type: s.type, external: true } as NodeInput]) - ), + inputs: { + // Placeholder for the group-selector dropdown — counted in height/socket math + '__virtual/groupId': { type: 'select' as const, internal: true, label: '' } as NodeInput, + ...Object.fromEntries( + group.inputs.map(s => [s.name, { type: s.type, external: true } as NodeInput]) + ) + }, outputs: group.outputs.map(s => s.type), execute(input: Int32Array): Int32Array { return input; } }; @@ -153,7 +160,12 @@ export class GraphManager extends EventEmitter<{ buildGroupInputNodeDef(group: NodeGroupDefinition): NodeDefinition { return { id: '__virtual/group/input' as NodeId, - inputs: {}, + meta: { title: 'Input' }, + // Each group input socket gets a labeled row (external = no control widget, + // internal = no left-side socket dot; it's an output-only node). + inputs: Object.fromEntries( + group.inputs.map(s => [s.name, { type: s.type, external: true, internal: true }]) + ) as Record, outputs: group.inputs.map(s => s.type), execute(input: Int32Array): Int32Array { return input; } }; @@ -434,11 +446,19 @@ export class GraphManager extends EventEmitter<{ const group = this.groups.get(this.currentGroupContext); if (!group) return; - const arr = kind === 'input' ? group.inputs : group.outputs; - const name = `${kind}_${arr.length}`; - arr.push({ name, type: socketType }); + const oldArr = kind === 'input' ? group.inputs : group.outputs; + const name = `${kind}_${oldArr.length}`; + const updatedGroup: NodeGroupDefinition = { + ...group, + inputs: kind === 'input' ? [...oldArr, { name, type: socketType }] : group.inputs, + outputs: kind === 'output' ? [...oldArr, { name, type: socketType }] : group.outputs + }; - this._refreshGroupContext(group); + if (!this.graph.groups) this.graph.groups = {}; + this.graph.groups[this.currentGroupContext] = updatedGroup; + + // Reinitialize so virtual group input/output nodes pick up the new socket reactively + this._init(this.serialize()); this.save(); } @@ -447,10 +467,17 @@ export class GraphManager extends EventEmitter<{ const group = this.groups.get(this.currentGroupContext); if (!group) return; - const arr = kind === 'input' ? group.inputs : group.outputs; - arr.splice(index, 1); + const oldArr = kind === 'input' ? group.inputs : group.outputs; + const updatedGroup: NodeGroupDefinition = { + ...group, + inputs: kind === 'input' ? oldArr.filter((_, i) => i !== index) : group.inputs, + outputs: kind === 'output' ? oldArr.filter((_, i) => i !== index) : group.outputs + }; - this._refreshGroupContext(group); + if (!this.graph.groups) this.graph.groups = {}; + this.graph.groups[this.currentGroupContext] = updatedGroup; + + this._init(this.serialize()); this.save(); } @@ -479,6 +506,51 @@ export class GraphManager extends EventEmitter<{ } } + pruneUnusedGroups() { + const usedIds = new Set(); + + // Scan nodes in the current graph + for (const node of this.nodes.values()) { + if (node.type === '__virtual/group/instance') { + const gid = node.props?.groupId as string | undefined; + if (gid) usedIds.add(gid); + } + } + + // Scan nodes in every stacked (parent) graph + for (const { rootGraph } of this.graphStack) { + for (const node of rootGraph.nodes) { + if (node.type === '__virtual/group/instance' && node.props?.groupId) { + usedIds.add(node.props.groupId as string); + } + } + } + + // Scan internal graphs of all known groups (nested group nodes) + for (const group of this.groups.values()) { + for (const node of group.graph.nodes) { + if (node.type === '__virtual/group/instance' && node.props?.groupId) { + usedIds.add(node.props.groupId as string); + } + } + } + + let changed = false; + for (const groupId of [...this.groups.keys()]) { + if (!usedIds.has(groupId)) { + this.groups.delete(groupId); + this.groupNodeDefinitions.delete(`__virtual/group/${groupId}`); + if (this.graph.groups) delete this.graph.groups[groupId]; + changed = true; + } + } + + if (changed) { + this.execute(); + this.save(); + } + } + // --- Group navigation --- enterGroup(nodeId: number, cameraPosition: [number, number, number] = [0, 0, 4]): boolean { @@ -984,6 +1056,7 @@ export class GraphManager extends EventEmitter<{ } removeNode(node: NodeInstance, { restoreEdges = false } = {}) { + if (node.type === '__virtual/group/input' || node.type === '__virtual/group/output') return; 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]) { @@ -1211,11 +1284,9 @@ export class GraphManager extends EventEmitter<{ return; } - // Don't emit save event while navigating inside a group - if (this.graphStack.length > 0) 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/node/Node.svelte b/app/src/lib/graph-interface/node/Node.svelte index cba7fd7..688364a 100644 --- a/app/src/lib/graph-interface/node/Node.svelte +++ b/app/src/lib/graph-interface/node/Node.svelte @@ -40,7 +40,7 @@ let meshRef: Mesh | undefined = $state(); - const height = getNodeHeight(node.state.type!); + const height = $derived(getNodeHeight(node.state.type!)); const zoom = $derived(graphState.cameraPosition[2]); diff --git a/app/src/lib/graph-interface/node/NodeHTML.svelte b/app/src/lib/graph-interface/node/NodeHTML.svelte index 4bbeb8b..6c5a329 100644 --- a/app/src/lib/graph-interface/node/NodeHTML.svelte +++ b/app/src/lib/graph-interface/node/NodeHTML.svelte @@ -37,16 +37,38 @@ ); if (node.type === '__virtual/group/instance') { + const groupOptions = [...(manager?.groups?.entries() ?? [])].map(([id, g]) => ({ + label: g.name, + value: id + })); + // Remove the static placeholder from the definition (height-only) and replace + // with a fully dynamic version that carries current names + value. + parameters = parameters.filter(([key]) => key !== '__virtual/groupId'); parameters = [['__virtual/groupId', { type: 'select', value: node.props?.groupId as string, - options: [...manager?.groups?.keys()] + options: groupOptions }], ...parameters]; } return parameters; } + $effect(() => { + const props = node.props as Record | undefined; + const virtualGroupId = props?.['__virtual/groupId'] as string | undefined; + if (!virtualGroupId) return; + const activeGroupId = props?.groupId as string | undefined; + if (virtualGroupId === activeGroupId) return; + const newGroupDef = manager?.groupNodeDefinitions.get(`__virtual/group/${virtualGroupId}`); + if (!newGroupDef) return; + const { children, parents, ref } = node.state; + node.props = { ...props, groupId: virtualGroupId, '__virtual/groupId': virtualGroupId }; + node.state = { type: newGroupDef, children, parents, ref }; + manager?.execute(); + manager?.save(); + }); + const parameters = $derived(buildParameters(node, node?.state?.type?.inputs || {})); const currentGroupId = $derived((node.props?.groupId as string) ?? ''); diff --git a/app/src/lib/settings/NestedSettings.svelte b/app/src/lib/settings/NestedSettings.svelte index af2aa3c..7d6e395 100644 --- a/app/src/lib/settings/NestedSettings.svelte +++ b/app/src/lib/settings/NestedSettings.svelte @@ -52,7 +52,7 @@ // select input: use index into options if ('options' in node && Array.isArray(node.options)) { if (typeof inputValue === 'string') { - return node.options.indexOf(inputValue); + return (node.options as string[]).indexOf(inputValue); } return 0; } diff --git a/app/src/lib/sidebar/panels/GroupContextPanel.svelte b/app/src/lib/sidebar/panels/GroupContextPanel.svelte index 277c185..889c496 100644 --- a/app/src/lib/sidebar/panels/GroupContextPanel.svelte +++ b/app/src/lib/sidebar/panels/GroupContextPanel.svelte @@ -5,8 +5,6 @@ type Props = { manager: GraphManager; groupId: string }; const { manager, groupId }: Props = $props(); - $inspect({ groupId }); - const group = $derived(manager.groups.get(groupId)); const COMMON_TYPES = ['plant', 'float', 'int', 'vec3', 'bool']; @@ -34,6 +32,10 @@ function removeSocket(index: number) { manager.removeGroupSocket('input', index); } + + function prune() { + manager.pruneUnusedGroups(); + }
@@ -85,6 +87,11 @@
+ +
+ + +
diff --git a/packages/types/src/inputs.ts b/packages/types/src/inputs.ts index 4a09b6e..ca50946 100644 --- a/packages/types/src/inputs.ts +++ b/packages/types/src/inputs.ts @@ -61,7 +61,10 @@ export const NodeInputBooleanSchema = z.object({ export const NodeInputSelectSchema = z.object({ ...DefaultOptionsSchema.shape, type: z.literal('select'), - options: z.array(z.string()).optional(), + options: z.union([ + z.array(z.string()), + z.array(z.object({ label: z.string(), value: z.string() })) + ]).optional(), value: z.string().optional() }); diff --git a/packages/ui/src/lib/Input.svelte b/packages/ui/src/lib/Input.svelte index 437d35c..aa4ae80 100644 --- a/packages/ui/src/lib/Input.svelte +++ b/packages/ui/src/lib/Input.svelte @@ -40,7 +40,7 @@ {:else if input.type === 'boolean'} {:else if input.type === 'select'} - + {:else if input.type === 'vec3'} {/if} diff --git a/packages/ui/src/lib/inputs/InputSelect.svelte b/packages/ui/src/lib/inputs/InputSelect.svelte index faf7c43..b9752f9 100644 --- a/packages/ui/src/lib/inputs/InputSelect.svelte +++ b/packages/ui/src/lib/inputs/InputSelect.svelte @@ -1,17 +1,28 @@