Compare commits
3 Commits
05506704bf
...
9c9a7b8c67
| Author | SHA1 | Date | |
|---|---|---|---|
|
9c9a7b8c67
|
|||
|
7499b80789
|
|||
|
a5b663f6fc
|
@@ -34,7 +34,7 @@ jobs:
|
|||||||
pnpm check
|
pnpm check
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
test:
|
test-unit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69
|
container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69
|
||||||
|
|
||||||
@@ -49,11 +49,28 @@ jobs:
|
|||||||
uses: ./.gitea/actions/setup
|
uses: ./.gitea/actions/setup
|
||||||
|
|
||||||
- name: 🧪 Run Tests
|
- name: 🧪 Run Tests
|
||||||
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" pnpm test
|
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" pnpm test:unit
|
||||||
|
|
||||||
|
test-e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📑 Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
|
- name: 🔧 Setup
|
||||||
|
uses: ./.gitea/actions/setup
|
||||||
|
|
||||||
|
- name: 🧪 Run Tests
|
||||||
|
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" pnpm test:e2e
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [quality, test]
|
needs: [quality, test-e2e, test-unit]
|
||||||
container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69
|
container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ export class GraphManager extends EventEmitter<{
|
|||||||
|
|
||||||
edges = $state<Edge[]>([]);
|
edges = $state<Edge[]>([]);
|
||||||
|
|
||||||
|
graphStack: {
|
||||||
|
rootGraph: Graph;
|
||||||
|
groupId: number;
|
||||||
|
nodeId: number;
|
||||||
|
cameraPosition: [number, number, number];
|
||||||
|
}[] = $state([]);
|
||||||
|
|
||||||
settingTypes: Record<string, NodeInput> = {};
|
settingTypes: Record<string, NodeInput> = {};
|
||||||
settings = $state<Record<string, unknown>>();
|
settings = $state<Record<string, unknown>>();
|
||||||
|
|
||||||
@@ -90,9 +97,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) {
|
||||||
@@ -290,13 +313,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;
|
const registryType = this.registry.getNode(node.type);
|
||||||
if (nodeType) {
|
n.state = registryType ? { type: registryType } : {};
|
||||||
n.state = {
|
const resolvedType = this.getNodeType(n);
|
||||||
type: nodeType
|
if (resolvedType) n.state = { type: resolvedType };
|
||||||
};
|
|
||||||
}
|
|
||||||
return [node.id, n];
|
return [node.id, n];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -436,6 +457,31 @@ export class GraphManager extends EventEmitter<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
getNodeType(node: NodeInstance) {
|
getNodeType(node: NodeInstance) {
|
||||||
|
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 }])
|
||||||
|
),
|
||||||
|
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);
|
||||||
@@ -576,6 +622,41 @@ export class GraphManager extends EventEmitter<{
|
|||||||
return this.graph.groups.find(g => g.id === id);
|
return this.graph.groups.find(g => g.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isInsideGroup() {
|
||||||
|
return this.graphStack.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(), groupId, nodeId, cameraPosition });
|
||||||
|
this.graph = { ...this.graph, nodes: group.nodes, edges: group.edges };
|
||||||
|
this._init(this.graph);
|
||||||
|
this.history.reset();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
exitGroup(): { camera: [number, number, number]; nodeId: number } | false {
|
||||||
|
if (!this.graphStack.length) return false;
|
||||||
|
const { rootGraph, groupId, nodeId, cameraPosition } = this.graphStack.pop()!;
|
||||||
|
const internalState = this.serialize();
|
||||||
|
const updatedRoot = {
|
||||||
|
...rootGraph,
|
||||||
|
groups: rootGraph.groups.map(g =>
|
||||||
|
g.id === groupId ? { ...g, nodes: internalState.nodes, edges: internalState.edges } : g
|
||||||
|
)
|
||||||
|
};
|
||||||
|
this.graph = updatedRoot;
|
||||||
|
this._init(updatedRoot);
|
||||||
|
this.history.reset();
|
||||||
|
this.save();
|
||||||
|
return { camera: cameraPosition, nodeId };
|
||||||
|
}
|
||||||
|
|
||||||
createNodeId() {
|
createNodeId() {
|
||||||
const ids = [
|
const ids = [
|
||||||
...this.nodes.keys(),
|
...this.nodes.keys(),
|
||||||
@@ -900,8 +981,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) {
|
||||||
|
|||||||
@@ -388,8 +388,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(
|
||||||
|
|||||||
@@ -169,6 +169,12 @@
|
|||||||
{/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}
|
||||||
@@ -244,6 +250,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { expandGroups } from './runtime-executor';
|
||||||
|
import type { Graph } from '@nodarium/types';
|
||||||
|
|
||||||
|
// Helpers to build minimal serialized nodes/edges
|
||||||
|
function node(id: number, type: string, props?: Record<string, number>) {
|
||||||
|
return { id, type: type as Graph['nodes'][0]['type'], position: [0, 0] as [number, number], ...(props ? { props } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function edge(from: number, fromSocket: number, to: number, toSocket: string): [number, number, number, string] {
|
||||||
|
return [from, fromSocket, to, toSocket];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('expandGroups', () => {
|
||||||
|
it('returns graph unchanged when there are no groups', () => {
|
||||||
|
const graph: Graph = {
|
||||||
|
id: 1,
|
||||||
|
nodes: [node(0, 'test/node/output'), node(1, 'test/node/input')],
|
||||||
|
edges: [edge(0, 0, 1, 'value')],
|
||||||
|
groups: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = expandGroups(graph);
|
||||||
|
|
||||||
|
expect(result.nodes.length).toBe(2);
|
||||||
|
expect(result.edges.length).toBe(1);
|
||||||
|
expect(result).toBe(graph); // same reference — no copy needed
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands a simple group: A → [B] → C rewires to A → B → C', () => {
|
||||||
|
// IDs: A=1, B=2, C=3, groupNode=4, group.id=5, inputBoundary=6, outputBoundary=7
|
||||||
|
const groupId = 5;
|
||||||
|
const groupNodeId = 4;
|
||||||
|
const remappedB = (groupNodeId + 1) * 1_000_000 + 2; // 5_000_002
|
||||||
|
|
||||||
|
const graph: Graph = {
|
||||||
|
id: 1,
|
||||||
|
nodes: [
|
||||||
|
node(1, 'test/node/output'),
|
||||||
|
node(groupNodeId, '__internal/group/instance', { groupId }),
|
||||||
|
node(3, 'test/node/input')
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
edge(1, 0, groupNodeId, 'input_0'), // A → group
|
||||||
|
edge(groupNodeId, 0, 3, 'value') // group → C
|
||||||
|
],
|
||||||
|
groups: [{
|
||||||
|
id: groupId,
|
||||||
|
nodes: [
|
||||||
|
node(6, '__internal/group/input'),
|
||||||
|
node(2, 'test/node/output'),
|
||||||
|
node(7, '__internal/group/output')
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
edge(6, 0, 2, 'input'), // inputBoundary → B
|
||||||
|
edge(2, 0, 7, 'Out') // B → outputBoundary
|
||||||
|
],
|
||||||
|
inputs: { input_0: { type: 'float' } },
|
||||||
|
outputs: [{ type: 'float', label: 'Output 0' }]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = expandGroups(graph);
|
||||||
|
|
||||||
|
const ids = result.nodes.map(n => n.id);
|
||||||
|
expect(ids).not.toContain(groupNodeId);
|
||||||
|
expect(ids).toContain(remappedB);
|
||||||
|
expect(ids).toContain(1); // A
|
||||||
|
expect(ids).toContain(3); // C
|
||||||
|
expect(result.nodes.length).toBe(3); // A, B(remapped), C
|
||||||
|
|
||||||
|
expect(result.edges).toContainEqual(edge(1, 0, remappedB, 'input')); // A → B
|
||||||
|
expect(result.edges).toContainEqual(edge(remappedB, 0, 3, 'value')); // B → C
|
||||||
|
expect(result.edges.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands a group with two internal nodes (B→D) and preserves the internal edge', () => {
|
||||||
|
// A → [B → D] → C
|
||||||
|
const groupId = 10;
|
||||||
|
const groupNodeId = 5;
|
||||||
|
const remappedB = (groupNodeId + 1) * 1_000_000 + 1; // 6_000_001
|
||||||
|
const remappedD = (groupNodeId + 1) * 1_000_000 + 2; // 6_000_002
|
||||||
|
|
||||||
|
const graph: Graph = {
|
||||||
|
id: 1,
|
||||||
|
nodes: [
|
||||||
|
node(0, 'test/node/output'),
|
||||||
|
node(groupNodeId, '__internal/group/instance', { groupId }),
|
||||||
|
node(9, 'test/node/input')
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
edge(0, 0, groupNodeId, 'input_0'),
|
||||||
|
edge(groupNodeId, 0, 9, 'value')
|
||||||
|
],
|
||||||
|
groups: [{
|
||||||
|
id: groupId,
|
||||||
|
nodes: [
|
||||||
|
node(3, '__internal/group/input'),
|
||||||
|
node(1, 'test/node/output'), // B
|
||||||
|
node(2, 'test/node/output'), // D
|
||||||
|
node(4, '__internal/group/output')
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
edge(3, 0, 1, 'input'), // inputBoundary → B
|
||||||
|
edge(1, 0, 2, 'input'), // B → D (internal)
|
||||||
|
edge(2, 0, 4, 'Out') // D → outputBoundary
|
||||||
|
],
|
||||||
|
inputs: { input_0: { type: 'float' } },
|
||||||
|
outputs: [{ type: 'float' }]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = expandGroups(graph);
|
||||||
|
|
||||||
|
expect(result.nodes.map(n => n.id)).not.toContain(groupNodeId);
|
||||||
|
expect(result.nodes.map(n => n.id)).toContain(remappedB);
|
||||||
|
expect(result.nodes.map(n => n.id)).toContain(remappedD);
|
||||||
|
|
||||||
|
expect(result.edges).toContainEqual(edge(0, 0, remappedB, 'input')); // A → B
|
||||||
|
expect(result.edges).toContainEqual(edge(remappedB, 0, remappedD, 'input')); // B → D (internal)
|
||||||
|
expect(result.edges).toContainEqual(edge(remappedD, 0, 9, 'value')); // D → C
|
||||||
|
expect(result.edges.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands a group with no external connections (isolated)', () => {
|
||||||
|
const groupId = 20;
|
||||||
|
const groupNodeId = 1;
|
||||||
|
const remappedB = (groupNodeId + 1) * 1_000_000 + 2; // 2_000_002
|
||||||
|
|
||||||
|
const graph: Graph = {
|
||||||
|
id: 1,
|
||||||
|
nodes: [node(groupNodeId, '__internal/group/instance', { groupId })],
|
||||||
|
edges: [],
|
||||||
|
groups: [{
|
||||||
|
id: groupId,
|
||||||
|
nodes: [
|
||||||
|
node(3, '__internal/group/input'),
|
||||||
|
node(2, 'test/node/output'),
|
||||||
|
node(4, '__internal/group/output')
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
edge(3, 0, 2, 'input'),
|
||||||
|
edge(2, 0, 4, 'Out')
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = expandGroups(graph);
|
||||||
|
|
||||||
|
expect(result.nodes.map(n => n.id)).not.toContain(groupNodeId);
|
||||||
|
expect(result.nodes.map(n => n.id)).toContain(remappedB);
|
||||||
|
expect(result.edges.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,18 +7,11 @@ import type {
|
|||||||
SyncCache
|
SyncCache
|
||||||
} from '@nodarium/types';
|
} from '@nodarium/types';
|
||||||
|
|
||||||
function isGroupInstanceType(type: string): boolean {
|
|
||||||
return type === '__virtual/group/instance';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function expandGroups(graph: Graph): Graph {
|
export function expandGroups(graph: Graph): Graph {
|
||||||
if (!graph.groups || Object.keys(graph.groups).length === 0) {
|
if (!graph.groups || graph.groups.length === 0) return graph;
|
||||||
return graph;
|
|
||||||
}
|
|
||||||
|
|
||||||
let nodes = [...graph.nodes];
|
let nodes = [...graph.nodes];
|
||||||
let edges = [...graph.edges];
|
let edges = [...graph.edges];
|
||||||
const groups = graph.groups;
|
|
||||||
|
|
||||||
let changed = true;
|
let changed = true;
|
||||||
while (changed) {
|
while (changed) {
|
||||||
@@ -26,120 +19,69 @@ export function expandGroups(graph: Graph): Graph {
|
|||||||
|
|
||||||
for (let i = 0; i < nodes.length; i++) {
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
const node = nodes[i];
|
const node = nodes[i];
|
||||||
if (!isGroupInstanceType(node.type)) continue;
|
if (node.type !== '__internal/group/instance') continue;
|
||||||
|
|
||||||
const groupId = (node.props as Record<string, unknown> | undefined)?.groupId as
|
const groupId = node.props?.groupId as number | undefined;
|
||||||
| string
|
if (groupId === undefined) continue;
|
||||||
| undefined;
|
|
||||||
if (!groupId) continue;
|
const group = graph.groups.find(g => g.id === groupId);
|
||||||
const group = groups[groupId];
|
|
||||||
if (!group) continue;
|
if (!group) continue;
|
||||||
|
|
||||||
changed = true;
|
changed = true;
|
||||||
|
|
||||||
// Recursively expand nested groups inside this group's internal graph
|
const ID_OFFSET = (node.id + 1) * 1_000_000;
|
||||||
const expandedInternal = expandGroups({
|
|
||||||
id: 0,
|
|
||||||
nodes: group.graph.nodes,
|
|
||||||
edges: group.graph.edges,
|
|
||||||
groups
|
|
||||||
});
|
|
||||||
|
|
||||||
const ID_PREFIX = node.id * 1000000;
|
|
||||||
const idMap = new Map<number, number>();
|
const idMap = new Map<number, number>();
|
||||||
|
|
||||||
const inputVirtualNode = expandedInternal.nodes.find(
|
const inputBoundary = group.nodes.find(n => n.type === '__internal/group/input');
|
||||||
n => n.type === '__virtual/group/input'
|
const outputBoundary = group.nodes.find(n => n.type === '__internal/group/output');
|
||||||
);
|
|
||||||
const outputVirtualNode = expandedInternal.nodes.find(
|
const realNodes = group.nodes.filter(
|
||||||
n => n.type === '__virtual/group/output'
|
n => n.type !== '__internal/group/input' && n.type !== '__internal/group/output'
|
||||||
);
|
);
|
||||||
|
|
||||||
const realInternalNodes = expandedInternal.nodes.filter(
|
for (const n of realNodes) idMap.set(n.id, ID_OFFSET + n.id);
|
||||||
n => n.type !== '__virtual/group/input' && n.type !== '__virtual/group/output'
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const n of realInternalNodes) {
|
|
||||||
idMap.set(n.id, ID_PREFIX + n.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentIncomingEdges = edges.filter(e => e[2] === node.id);
|
|
||||||
const parentOutgoingEdges = edges.filter(e => e[0] === node.id);
|
|
||||||
|
|
||||||
// Edges from/to virtual nodes in the expanded internal graph
|
|
||||||
const edgesFromInput = expandedInternal.edges.filter(
|
|
||||||
e => e[0] === inputVirtualNode?.id
|
|
||||||
);
|
|
||||||
const edgesToOutput = expandedInternal.edges.filter(
|
|
||||||
e => e[2] === outputVirtualNode?.id
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const incomingExternal = edges.filter(e => e[2] === node.id);
|
||||||
|
const outgoingExternal = edges.filter(e => e[0] === node.id);
|
||||||
const newEdges: Graph['edges'] = [];
|
const newEdges: Graph['edges'] = [];
|
||||||
|
|
||||||
// Short-circuit: parent source → internal target (via group input)
|
// external_source → [inputBoundary →] internal_target
|
||||||
for (const parentEdge of parentIncomingEdges) {
|
if (inputBoundary) {
|
||||||
const socketName = parentEdge[3];
|
const fromInput = group.edges.filter(e => e[0] === inputBoundary.id);
|
||||||
const socketIdx = group.inputs.findIndex(s => s.name === socketName);
|
for (const extEdge of incomingExternal) {
|
||||||
if (socketIdx === -1) continue;
|
for (const intEdge of fromInput) {
|
||||||
|
const toId = idMap.get(intEdge[2]);
|
||||||
for (const internalEdge of edgesFromInput.filter(e => e[1] === socketIdx)) {
|
if (toId !== undefined) newEdges.push([extEdge[0], extEdge[1], toId, intEdge[3]]);
|
||||||
const remappedId = idMap.get(internalEdge[2]);
|
|
||||||
if (remappedId !== undefined) {
|
|
||||||
newEdges.push([parentEdge[0], parentEdge[1], remappedId, internalEdge[3]]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Short-circuit: internal source → parent target (via group output)
|
// internal_source → [outputBoundary →] external_target
|
||||||
for (const parentEdge of parentOutgoingEdges) {
|
if (outputBoundary) {
|
||||||
const outputIdx = parentEdge[1];
|
const toOutput = group.edges.filter(e => e[2] === outputBoundary.id);
|
||||||
const outputSocketName = group.outputs[outputIdx]?.name;
|
for (const extEdge of outgoingExternal) {
|
||||||
if (!outputSocketName) continue;
|
for (const intEdge of toOutput) {
|
||||||
|
const fromId = idMap.get(intEdge[0]);
|
||||||
for (const internalEdge of edgesToOutput.filter(e => e[3] === outputSocketName)) {
|
if (fromId !== undefined) newEdges.push([fromId, intEdge[1], extEdge[2], extEdge[3]]);
|
||||||
const remappedId = idMap.get(internalEdge[0]);
|
|
||||||
if (remappedId !== undefined) {
|
|
||||||
newEdges.push([remappedId, internalEdge[1], parentEdge[2], parentEdge[3]]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remap internal-to-internal edges
|
// internal-to-internal edges (skip boundary edges)
|
||||||
const internalEdges = expandedInternal.edges.filter(
|
for (const e of group.edges) {
|
||||||
e =>
|
if (e[0] === inputBoundary?.id || e[2] === outputBoundary?.id) continue;
|
||||||
e[0] !== inputVirtualNode?.id
|
|
||||||
&& e[0] !== outputVirtualNode?.id
|
|
||||||
&& e[2] !== inputVirtualNode?.id
|
|
||||||
&& e[2] !== outputVirtualNode?.id
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const e of internalEdges) {
|
|
||||||
const fromId = idMap.get(e[0]);
|
const fromId = idMap.get(e[0]);
|
||||||
const toId = idMap.get(e[2]);
|
const toId = idMap.get(e[2]);
|
||||||
if (fromId !== undefined && toId !== undefined) {
|
if (fromId !== undefined && toId !== undefined) newEdges.push([fromId, e[1], toId, e[3]]);
|
||||||
newEdges.push([fromId, e[1], toId, e[3]]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the group node
|
|
||||||
nodes.splice(i, 1);
|
nodes.splice(i, 1);
|
||||||
|
for (const n of realNodes) nodes.push({ ...n, id: idMap.get(n.id)! });
|
||||||
|
|
||||||
// Add remapped internal nodes
|
edges = edges.filter(e => e[0] !== node.id && e[2] !== node.id);
|
||||||
for (const n of realInternalNodes) {
|
|
||||||
nodes.push({ ...n, id: idMap.get(n.id)! });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove group node's edges and add short-circuit edges
|
|
||||||
const groupEdgeKeys = new Set([
|
|
||||||
...parentIncomingEdges.map(e => `${e[0]}-${e[1]}-${e[2]}-${e[3]}`),
|
|
||||||
...parentOutgoingEdges.map(e => `${e[0]}-${e[1]}-${e[2]}-${e[3]}`)
|
|
||||||
]);
|
|
||||||
edges = edges.filter(
|
|
||||||
e => !groupEdgeKeys.has(`${e[0]}-${e[1]}-${e[2]}-${e[3]}`)
|
|
||||||
);
|
|
||||||
edges.push(...newEdges);
|
edges.push(...newEdges);
|
||||||
|
|
||||||
break; // Restart loop with updated nodes array
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,8 @@
|
|||||||
"qa": "pnpm lint && pnpm check && pnpm test",
|
"qa": "pnpm lint && pnpm check && pnpm test",
|
||||||
"format": "pnpm dprint fmt",
|
"format": "pnpm dprint fmt",
|
||||||
"format:check": "pnpm dprint check",
|
"format:check": "pnpm dprint check",
|
||||||
"test": "pnpm run -r --parallel test",
|
"test:e2e": "pnpm run -r --parallel test:e2e",
|
||||||
|
"test:unit": "pnpm run -r --parallel test:unit",
|
||||||
"check": "pnpm run -r --parallel check",
|
"check": "pnpm run -r --parallel check",
|
||||||
"build": "pnpm build:nodes && pnpm build:app",
|
"build": "pnpm build:nodes && pnpm build:app",
|
||||||
"build:app": "BASE_PATH=/ui pnpm -r --filter 'ui' build && pnpm -r --filter 'app'... build",
|
"build:app": "BASE_PATH=/ui pnpm -r --filter 'ui' build && pnpm -r --filter 'app'... build",
|
||||||
|
|||||||
@@ -6,13 +6,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let { options = [], value = $bindable(0), id = '' }: Props = $props();
|
let { options = [], value = $bindable(0), id = '' }: Props = $props();
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
console.log({ options, value });
|
|
||||||
if (typeof value !== typeof options[0]) {
|
|
||||||
console.trace('WARNING: value type does not match options type');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<select {id} bind:value class="bg-layer-2 text-text">
|
<select {id} bind:value class="bg-layer-2 text-text">
|
||||||
|
|||||||
Reference in New Issue
Block a user