diff --git a/app/src/lib/graph-interface/graph-manager.svelte.ts b/app/src/lib/graph-interface/graph-manager.svelte.ts index da0f928..7029192 100644 --- a/app/src/lib/graph-interface/graph-manager.svelte.ts +++ b/app/src/lib/graph-interface/graph-manager.svelte.ts @@ -152,6 +152,7 @@ export class GraphManager extends EventEmitter<{ return { id: group.id, + name: group.name, inputs: group.inputs, outputs: group.outputs, nodes: groupNodes, @@ -527,16 +528,25 @@ export class GraphManager extends EventEmitter<{ return; } - const inputs = { + const defaultInputs = { ...(node.state.type?.inputs || {}), - ...groupDefinition?.inputs, + ...groupDefinition?.inputs + }; + + // This is to make sure the the groupId is always first + delete defaultInputs['groupId']; + const inputs = { 'groupId': { type: 'select', label: '', value: node.props?.groupId, internal: true, - options: this.graph.groups.map(g => g.id) - } + options: this.graph.groups.map((g, i) => ({ + value: g.id, + label: g.name || `Group ${i + 1}` + })) + }, + ...defaultInputs }; const groupType = { @@ -658,6 +668,13 @@ export class GraphManager extends EventEmitter<{ return this.graph.groups.find(g => g.id === id); } + renameGroup(groupId: number, name: string) { + const group = this.getGroup(groupId); + if (!group) return; + group.name = name; + this.save(); + } + isInsideGroup = $state(false); enterGroup(nodeId: number, cameraPosition: [number, number, number]): boolean { @@ -845,35 +862,53 @@ export class GraphManager extends EventEmitter<{ groupPosition[0] /= nodes.length; groupPosition[1] /= nodes.length; + // Map from deduped edge source key → group input index, used for both + // internal edge wiring and external edge socket naming. + const inputIndexByEdgeKey = new Map(); + [...groupInputs.keys()].forEach((key, i) => inputIndexByEdgeKey.set(key, i)); + + // Allocate all needed IDs up front so sequential calls never collide. + const usedIds = new Set([ + ...this.nodes.keys(), + ...this.graph.groups.map(g => g.id), + ...this.graph.groups.flatMap(g => g.nodes.map(n => n.id)) + ]); + const nextId = () => { + let id = 0; + while (usedIds.has(id)) id++; + usedIds.add(id); + return id; + }; + const groupInputNode: NodeInstance = { - id: this.createNodeId(), + id: nextId(), type: '__internal/group/input', position: [bounds.minX - 50, (bounds.minY + bounds.maxY) / 2], state: {} }; const groupOutputNode: NodeInstance = { - id: this.createNodeId(), + id: nextId(), type: '__internal/group/output', position: [bounds.maxX + 25, (bounds.minY + bounds.maxY) / 2], state: {} }; - // Edges that are inside the group + // Edges that are inside the group, routed through boundary nodes at + // the correct input/output index for each unique external connection. 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 + const idx = inputIndexByEdgeKey.get(`${edge[0].id}-${edge[1]}`) ?? 0; + return [groupInputNode.id, idx, edge[2].id, edge[3]]; } else if (!ids.has(edge[2].id)) { 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][]; - const groupId = this.createNodeId(); + const groupId = nextId(); const groupDefinition: GroupDefinition = { id: groupId, inputs: inputs, @@ -882,6 +917,9 @@ export class GraphManager extends EventEmitter<{ nodes: [groupInputNode, ...nodes, groupOutputNode] }; + // Push before createNode so createNodeId() inside sees the allocated IDs. + this.graph.groups.push(groupDefinition); + const groupNode = this.createNode({ type: '__internal/group/instance', position: [groupPosition[0], groupPosition[1]], @@ -892,20 +930,17 @@ export class GraphManager extends EventEmitter<{ 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 + // Rewire external edges to/from the group node using the correct input socket. 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; + const idx = inputIndexByEdgeKey.get(`${edge[0].id}-${edge[1]}`) ?? 0; + return [edge[0], edge[1], groupNode, `input_${idx}`] 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; diff --git a/app/src/lib/graph-interface/graph-state.svelte.test.ts b/app/src/lib/graph-interface/graph-state.svelte.test.ts index 7e5f175..f5492ac 100644 --- a/app/src/lib/graph-interface/graph-state.svelte.test.ts +++ b/app/src/lib/graph-interface/graph-state.svelte.test.ts @@ -178,7 +178,8 @@ describe('exitGroupNode', () => { state.exitGroupNode(); - expect(state.activeNodeId).toBe(-1); + // Group instance node is re-selected on exit; internal selection is cleared + expect(state.activeNodeId).toBe(groupNode!.id); expect(state.selectedNodes.size).toBe(0); }); diff --git a/app/src/lib/graph-interface/graph-state.svelte.ts b/app/src/lib/graph-interface/graph-state.svelte.ts index fb24cc4..37e820b 100644 --- a/app/src/lib/graph-interface/graph-state.svelte.ts +++ b/app/src/lib/graph-interface/graph-state.svelte.ts @@ -376,7 +376,7 @@ export class GraphState { const result = this.graph.exitGroup(); if (!result) return; this.cameraPosition = result.camera; - this.activeNodeId = -1; + this.activeNodeId = result.nodeId; this.clearSelection(); } diff --git a/app/src/lib/sidebar/panels/ActiveNodeSettings.svelte b/app/src/lib/sidebar/panels/ActiveNodeSettings.svelte index ba61908..df27f85 100644 --- a/app/src/lib/sidebar/panels/ActiveNodeSettings.svelte +++ b/app/src/lib/sidebar/panels/ActiveNodeSettings.svelte @@ -9,6 +9,21 @@ }; let { manager, node = $bindable() }: Props = $props(); + + const isGroupInstance = $derived(node?.type === '__internal/group/instance'); + const activeGroup = $derived( + isGroupInstance ? manager.getGroup(node!.props?.groupId as number) : undefined + ); + + let groupName = $state(''); + $effect(() => { + groupName = activeGroup?.name ?? ''; + }); + + function handleRename(e: Event) { + const name = (e.target as HTMLInputElement).value; + if (activeGroup) manager.renameGroup(activeGroup.id, name); + }
@@ -17,7 +32,18 @@ {#if node} {#key node.id} - {#if node} + {#if isGroupInstance && activeGroup} +
+ + +
+ {:else if node} {/if} {/key} @@ -26,7 +52,59 @@ {/if} {#if manager?.graph.groups.length} - +
+ +
{/if} + + diff --git a/packages/types/src/inputs.ts b/packages/types/src/inputs.ts index 4a09b6e..8ecd33d 100644 --- a/packages/types/src/inputs.ts +++ b/packages/types/src/inputs.ts @@ -61,8 +61,10 @@ export const NodeInputBooleanSchema = z.object({ export const NodeInputSelectSchema = z.object({ ...DefaultOptionsSchema.shape, type: z.literal('select'), - options: z.array(z.string()).optional(), - value: z.string().optional() + options: z.array( + z.union([z.string(), z.object({ value: z.number(), label: z.string() })]) + ).optional(), + value: z.union([z.string(), z.number()]).optional() }); export const NodeInputSeedSchema = z.object({ diff --git a/packages/ui/src/lib/inputs/InputSelect.svelte b/packages/ui/src/lib/inputs/InputSelect.svelte index faf7c43..3f0b084 100644 --- a/packages/ui/src/lib/inputs/InputSelect.svelte +++ b/packages/ui/src/lib/inputs/InputSelect.svelte @@ -1,16 +1,24 @@ diff --git a/packages/ui/src/routes/+page.svelte b/packages/ui/src/routes/+page.svelte index 12a91ec..be16114 100644 --- a/packages/ui/src/routes/+page.svelte +++ b/packages/ui/src/routes/+page.svelte @@ -21,7 +21,7 @@ let vecValue = $state([0.2, 0.3, 0.4]); const options = ['strawberry', 'raspberry', 'chickpeas']; let selectValue = $state(0); - const d = $derived(options[selectValue]); + let selectValue2 = $state(0); let checked = $state(false); let colorValue = $state<[number, number, number]>([59, 130, 246]); let mirrorShape = $state(true); @@ -82,9 +82,28 @@ -
- Select with simple values +
+

+ Select with simple values +
+ value={options[selectValue]} +

+
+
+

+ Select with {option: number, label: string}[] +
+ value={selectValue2} +

+