12 Commits

Author SHA1 Message Date
max 82c2f08a56 chore: cleanup changelog
📊 Benchmark the Runtime / benchmark (push) Successful in 1m34s
🚀 Lint & Test & Deploy / deploy (push) Successful in 2m7s
🚀 Lint & Test & Deploy / test-unit (push) Successful in 47s
🚀 Lint & Test & Deploy / quality (push) Successful in 2m28s
🚀 Lint & Test & Deploy / test-e2e (push) Successful in 1m59s
2026-05-05 22:58:42 +02:00
max a00db400bb fix: dont crash when no groups exist
📊 Benchmark the Runtime / benchmark (push) Successful in 1m24s
🚀 Lint & Test & Deploy / test-unit (push) Successful in 48s
🚀 Lint & Test & Deploy / quality (push) Successful in 2m24s
🚀 Lint & Test & Deploy / test-e2e (push) Successful in 1m59s
🚀 Lint & Test & Deploy / deploy (push) Has been cancelled
2026-05-05 22:52:24 +02:00
max 2d9eb0c087 fix: make planty work
📊 Benchmark the Runtime / benchmark (push) Successful in 1m18s
🚀 Lint & Test & Deploy / quality (push) Successful in 2m9s
🚀 Lint & Test & Deploy / test-unit (push) Successful in 33s
🚀 Lint & Test & Deploy / test-e2e (push) Successful in 1m54s
🚀 Lint & Test & Deploy / deploy (push) Successful in 2m4s
2026-05-05 22:45:20 +02:00
nodarium-bot 1e28ded99b chore(release): v0.0.6 2026-05-05 20:33:26 +00:00
nodarium-bot 5fae518392 chore(release): v0.0.6 2026-05-05 20:23:21 +00:00
max 954f5726c3 Merge pull request 'feat: initial node groups' (#44) from feat/group-node-own into main
📊 Benchmark the Runtime / benchmark (push) Successful in 1m11s
🚀 Lint & Test & Deploy / quality (push) Successful in 2m14s
🚀 Lint & Test & Deploy / test-unit (push) Successful in 49s
🚀 Lint & Test & Deploy / test-e2e (push) Successful in 2m3s
🚀 Lint & Test & Deploy / deploy (push) Successful in 3m4s
Reviewed-on: #44
2026-05-05 22:08:17 +02:00
max 63d5b8079d chore: pnpm format
📊 Benchmark the Runtime / benchmark (pull_request) Successful in 1m39s
🚀 Lint & Test & Deploy / quality (pull_request) Successful in 2m21s
🚀 Lint & Test & Deploy / test-unit (pull_request) Successful in 48s
🚀 Lint & Test & Deploy / test-e2e (pull_request) Successful in 1m51s
🚀 Lint & Test & Deploy / deploy (pull_request) Successful in 2m1s
2026-05-05 21:55:32 +02:00
max 3e32ca419a feat: ungroup nodes
📊 Benchmark the Runtime / benchmark (pull_request) Successful in 1m45s
🚀 Lint & Test & Deploy / quality (pull_request) Failing after 1m7s
🚀 Lint & Test & Deploy / test-unit (pull_request) Successful in 35s
🚀 Lint & Test & Deploy / test-e2e (pull_request) Successful in 2m4s
🚀 Lint & Test & Deploy / deploy (pull_request) Has been skipped
2026-05-05 21:51:17 +02:00
max f0cb12a088 chore: fix some type issues 2026-05-05 21:28:03 +02:00
max 1d60090ffe chore: fixup graph manager tests 2026-05-05 21:27:53 +02:00
max 5b55056fc1 chore: remove graph element in graphManager 2026-05-05 21:27:42 +02:00
max e2c2b1a4d7 chore: remove e2e test screenshots (too flaky) 2026-05-05 21:27:23 +02:00
18 changed files with 190 additions and 55 deletions
-3
View File
@@ -8,9 +8,6 @@ test('test', async ({ page }) => {
await page.goto('http://localhost:4173', { waitUntil: 'load' }); await page.goto('http://localhost:4173', { waitUntil: 'load' });
// await expect(page).toHaveScreenshot();
await expect(page.locator('.graph-wrapper')).toHaveScreenshot();
await page.getByRole('button', { name: 'projects' }).click(); await page.getByRole('button', { name: 'projects' }).click();
await page.getByRole('button', { name: 'New', exact: true }).click(); await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('combobox').selectOption('2'); await page.getByRole('combobox').selectOption('2');
Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@nodarium/app", "name": "@nodarium/app",
"private": true, "private": true,
"version": "0.0.5", "version": "0.0.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -41,9 +41,7 @@
{/each} {/each}
<span class="i-[tabler--arrow-right]"></span> <span class="i-[tabler--arrow-right]"></span>
<button <button class="bg-layer-2 opacity-100 cursor-pointer rounded-sm p-1 px-2">
class="bg-layer-2 opacity-100 cursor-pointer rounded-sm p-1 px-2"
>
{getGroupName(graph.currentGroupId!)} {getGroupName(graph.currentGroupId!)}
</button> </button>
</div> </div>
@@ -43,8 +43,6 @@ export class GraphManager extends EventEmitter<{
status = $state<'loading' | 'idle' | 'error'>(); status = $state<'loading' | 'idle' | 'error'>();
loaded = false; loaded = false;
graph: Graph = $state({ id: 0, nodes: [], edges: [], groups: [] });
// Snapshots of parent levels we navigated away from. Empty at root. // Snapshots of parent levels we navigated away from. Empty at root.
// Entry i has the saved state of depth i (0 = root graph, 1 = first group, …). // Entry i has the saved state of depth i (0 = root graph, 1 = first group, …).
parentStack: { parentStack: {
@@ -59,6 +57,7 @@ export class GraphManager extends EventEmitter<{
// Graph Data // Graph Data
id = $state(0); id = $state(0);
meta = $state<Graph['meta']>({});
nodes = new SvelteMap<number, NodeInstance>(); nodes = new SvelteMap<number, NodeInstance>();
edges = $state<Edge[]>([]); edges = $state<Edge[]>([]);
groups: GroupDefinition[] = $state([]); groups: GroupDefinition[] = $state([]);
@@ -121,7 +120,7 @@ export class GraphManager extends EventEmitter<{
const serialized = $state.snapshot({ const serialized = $state.snapshot({
id: this.id, id: this.id,
settings: this.settings, settings: this.settings,
meta: this.graph.meta, meta: this.meta,
groups, groups,
nodes, nodes,
edges edges
@@ -331,8 +330,8 @@ export class GraphManager extends EventEmitter<{
this.loaded = false; this.loaded = false;
graph.groups ??= []; graph.groups ??= [];
this.meta = graph.meta;
this.groups = graph.groups; this.groups = graph.groups;
this.graph = graph;
this.status = 'loading'; this.status = 'loading';
this.id = graph.id; this.id = graph.id;
@@ -475,8 +474,37 @@ export class GraphManager extends EventEmitter<{
// 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 groupId = node.props?.groupId as number;
if (!groupId) {
return {
...node.state.type,
meta: {
title: 'Group',
...node?.state?.type?.meta || {}
},
inputs: {
'groupId': {
type: 'select',
label: '',
value: this.groups?.[0]?.id,
internal: true,
options: this.groups.map((g) => ({
value: g.id,
label: g.name || `Group#${g.id}`
})).filter((g) => {
const activeIds = new SvelteSet([
...this.parentStack.filter(e => e.id !== this.id).map(e => e.id),
...(this.currentGroupId !== null ? [this.currentGroupId] : [])
]);
return !activeIds.has(g.value);
})
}
},
outputs: []
} as NodeDefinition;
}
const groupDefinition = this.getGroup(node.props?.groupId as number);
if (!groupDefinition) { if (!groupDefinition) {
log.error(`Group not found: ${node.props?.groupId}`); log.error(`Group not found: ${node.props?.groupId}`);
return; return;
@@ -835,8 +863,9 @@ export class GraphManager extends EventEmitter<{
const inputs: Record<string, NodeInput> = {}; const inputs: Record<string, NodeInput> = {};
[...groupInputs.values()].forEach((edge, i) => { [...groupInputs.values()].forEach((edge, i) => {
const internalInputDef = edge[2].state.type?.inputs?.[edge[3]];
const input = { const input = {
label: `Input ${i}`, label: internalInputDef?.label ?? edge[3],
type: edge[0].state.type?.outputs?.[edge[1]] || '*' type: edge[0].state.type?.outputs?.[edge[1]] || '*'
}; };
inputs[`input_${i}`] = input as NodeInput; inputs[`input_${i}`] = input as NodeInput;
@@ -845,8 +874,11 @@ export class GraphManager extends EventEmitter<{
const outputs = []; const outputs = [];
if (groupOutputs.size) { if (groupOutputs.size) {
const edge = groupOutputs.values().next().value!; const edge = groupOutputs.values().next().value!;
const outputType = edge[0].state.type?.outputs?.[edge[1]] || '*';
outputs.push({ outputs.push({
label: `Output`, label: outputType === '*'
? 'Output'
: outputType.charAt(0).toUpperCase() + outputType.slice(1),
type: edge[2].state.type?.inputs?.[edge[3]].type || '*' type: edge[2].state.type?.inputs?.[edge[3]].type || '*'
}); });
} }
@@ -956,6 +988,124 @@ export class GraphManager extends EventEmitter<{
return groupNode; return groupNode;
} }
ungroupNode(nodeId: number) {
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;
log.log('ungrouping node', { groupId, group });
this.startUndoGroup();
const edgesToGroup = this.edges.filter(e => e[2].id === nodeId);
const edgesFromGroup = this.edges.filter(e => e[0].id === nodeId);
const groupInputNode = group.nodes.find(n => n.type === '__internal/group/input');
const groupOutputNode = group.nodes.find(n => n.type === '__internal/group/output');
const internalNodes = group.nodes.filter(
n => n.type !== '__internal/group/input' && n.type !== '__internal/group/output'
);
// Offset internal nodes so their average position matches the group node's position
let centerX = 0, centerY = 0;
for (const n of internalNodes) {
centerX += n.position[0];
centerY += n.position[1];
}
const offsetX = internalNodes.length
? groupNode.position[0] - centerX / internalNodes.length
: 0;
const offsetY = internalNodes.length
? groupNode.position[1] - centerY / internalNodes.length
: 0;
// Allocate new IDs that don't collide with anything in the current graph
const usedIds = new SvelteSet<number>([
...this.nodes.keys(),
...this.groups.map(g => g.id),
...this.groups.flatMap(g => g.nodes.map(n => n.id))
]);
const nextId = () => {
let id = 0;
while (usedIds.has(id)) id++;
usedIds.add(id);
return id;
};
// Map old internal IDs (including boundary nodes) to fresh IDs
const idMap = new SvelteMap<number, number>();
for (const n of group.nodes) {
idMap.set(n.id, nextId());
}
// Instantiate internal nodes and add them to the graph
const newNodes: NodeInstance[] = internalNodes.map(n => {
const nodeType = this.registry.getNode(n.type);
const node: NodeInstance = $state({
id: idMap.get(n.id)!,
type: n.type,
position: [n.position[0] + offsetX, n.position[1] + offsetY] as [number, number],
state: { type: nodeType },
props: n.props || {}
});
return node;
});
for (const node of newNodes) {
this.nodes.set(node.id, node);
}
// input_X socket on the group node → the external source that was feeding it
const inputIdxToExternal = new SvelteMap<number, { node: NodeInstance; socket: number }>();
for (const edge of edgesToGroup) {
const match = (edge[3] as string).match(/^input_(\d+)$/);
if (match) inputIdxToExternal.set(parseInt(match[1]), { node: edge[0], socket: edge[1] });
}
// All external nodes that received output from the group node
const externalOutputTargets = edgesFromGroup.map(e => ({ toNode: e[2], toSocket: e[3] }));
// Recreate internal edges, substituting boundary nodes with the real external peers
for (const [fromId, fromSocketIdx, toId, toSocketKey] of group.edges) {
let fromNode: NodeInstance | undefined;
let resolvedFromSocket = fromSocketIdx;
if (groupInputNode && fromId === groupInputNode.id) {
const ext = inputIdxToExternal.get(fromSocketIdx);
if (!ext) continue;
fromNode = ext.node;
resolvedFromSocket = ext.socket;
} else {
const newId = idMap.get(fromId);
if (newId !== undefined) fromNode = this.nodes.get(newId);
}
if (!fromNode) continue;
if (groupOutputNode && toId === groupOutputNode.id) {
for (const { toNode, toSocket } of externalOutputTargets) {
this.createEdge(fromNode, resolvedFromSocket, toNode, toSocket, { applyUpdate: false });
}
} else {
const newToId = idMap.get(toId);
if (newToId === undefined) continue;
const toNode = this.nodes.get(newToId);
if (!toNode) continue;
this.createEdge(fromNode, resolvedFromSocket, toNode, toSocketKey, { applyUpdate: false });
}
}
// Remove the group instance node (also cleans up its edges)
this.removeNode(groupNode);
this.saveUndoGroup();
return newNodes;
}
createNode({ createNode({
type, type,
position, position,
@@ -88,7 +88,7 @@ describe('groupSelectedNodes', () => {
const groupNode = state.groupSelectedNodes(); const groupNode = state.groupSelectedNodes();
assert.isDefined(groupNode); assert.isDefined(groupNode);
expect(manager.graph.groups.length).toBe(1); expect(manager.groups.length).toBe(1);
}); });
}); });
@@ -139,26 +139,6 @@ describe('exitGroupNode', () => {
expect(state.cameraPosition).toEqual(before); expect(state.cameraPosition).toEqual(before);
}); });
it('restores the camera position from before entry', () => {
const { manager, state } = createFixture();
const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} });
assert.isDefined(nodeA);
const groupNode = manager.groupNodes([nodeA!.id]);
assert.isDefined(groupNode);
state.activeNodeId = groupNode!.id;
state.cameraPosition = [77, 88, 4];
state.enterGroupNode();
// Simulate camera moving inside the group
state.cameraPosition = [0, 0, 1];
state.exitGroupNode();
expect(state.cameraPosition).toEqual([77, 88, 4]);
});
it('clears activeNodeId and selection after exit', () => { it('clears activeNodeId and selection after exit', () => {
const { manager, state } = createFixture(); const { manager, state } = createFixture();
@@ -213,6 +213,10 @@ export class GraphState {
}; };
} }
unGroupSelectedNodes() {
return this.graph.ungroupNode(this.activeNodeId);
}
groupSelectedNodes() { groupSelectedNodes() {
return this.graph.groupNodes([...this.selectedNodes.keys(), this.activeNodeId]); return this.graph.groupNodes([...this.selectedNodes.keys(), this.activeNodeId]);
} }
@@ -109,16 +109,6 @@
return nodeType?.outputs?.[index] || 'unknown'; return nodeType?.outputs?.[index] || 'unknown';
} }
let groupSize = 0;
$effect(() => {
if (graph.graph.groups.length > groupSize) {
groupSize = graph.graph.groups.length;
}
if (graph.graph.groups.length < groupSize) {
console.error('We have lost a group!');
}
});
</script> </script>
<svelte:window <svelte:window
+8
View File
@@ -66,6 +66,14 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
callback: () => graphState.groupSelectedNodes() callback: () => graphState.groupSelectedNodes()
}); });
keymap.addShortcut({
key: 'g',
alt: true,
preventDefault: true,
description: 'Ungroup selected nodes',
callback: () => graphState.unGroupSelectedNodes()
});
keymap.addShortcut({ keymap.addShortcut({
key: 'Tab', key: 'Tab',
preventDefault: true, preventDefault: true,
+2 -1
View File
@@ -2,7 +2,8 @@ export const groupNode = {
id: '__internal/group/instance', id: '__internal/group/instance',
meta: { title: 'Group' }, meta: { title: 'Group' },
inputs: { inputs: {
input: { groupId: {
label: '',
type: 'select', type: 'select',
values: [] values: []
} }
+6 -3
View File
@@ -5,6 +5,7 @@
import { debounceAsyncFunction } from '$lib/helpers'; import { debounceAsyncFunction } from '$lib/helpers';
import { createKeyMap } from '$lib/helpers/createKeyMap'; import { createKeyMap } from '$lib/helpers/createKeyMap';
import { debugNode } from '$lib/node-registry/debugNode'; import { debugNode } from '$lib/node-registry/debugNode';
import { groupNode } from '$lib/node-registry/groupNode.js';
import { IndexDBCache, RemoteNodeRegistry } from '$lib/node-registry/index'; import { IndexDBCache, RemoteNodeRegistry } from '$lib/node-registry/index';
import NodeStore from '$lib/node-store/NodeStore.svelte'; import NodeStore from '$lib/node-store/NodeStore.svelte';
import PerformanceViewer from '$lib/performance/PerformanceViewer.svelte'; import PerformanceViewer from '$lib/performance/PerformanceViewer.svelte';
@@ -38,7 +39,7 @@
const registryCache = new IndexDBCache('node-registry'); const registryCache = new IndexDBCache('node-registry');
const nodeRegistry = new RemoteNodeRegistry('', registryCache, [debugNode]); const nodeRegistry = new RemoteNodeRegistry('', registryCache, [debugNode, groupNode]);
const workerRuntime = new WorkerRuntimeExecutor(); const workerRuntime = new WorkerRuntimeExecutor();
const runtimeCache = new MemoryRuntimeCache(); const runtimeCache = new MemoryRuntimeCache();
const memoryRuntime = new MemoryRuntimeExecutor(nodeRegistry, runtimeCache); const memoryRuntime = new MemoryRuntimeExecutor(nodeRegistry, runtimeCache);
@@ -171,6 +172,7 @@
config={tutorialConfig} config={tutorialConfig}
actions={{ actions={{
'setup-default': () => { 'setup-default': () => {
console.log('setup-default');
const ts = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const ts = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
pm.handleCreateProject( pm.handleCreateProject(
structuredClone(templates.defaultPlant) as unknown as Graph, structuredClone(templates.defaultPlant) as unknown as Graph,
@@ -178,15 +180,16 @@
); );
}, },
'load-tutorial-template': () => { 'load-tutorial-template': () => {
console.log('load-tutorial-template');
if (!pm.graph) return; if (!pm.graph) return;
const g = structuredClone(templates.tutorial) as unknown as Graph; const g = structuredClone(templates.tutorial) as unknown as Graph;
g.id = pm.graph.id; g.id = pm.graph.id;
g.meta = { ...pm.graph.meta }; g.meta = { ...pm.graph.meta };
pm.graph = g; manager.load(g);
pm.saveGraph(g);
graphInterface.state.centerNode(graphInterface.manager.getAllNodes()[0]); graphInterface.state.centerNode(graphInterface.manager.getAllNodes()[0]);
}, },
'open-github-nodes': () => { 'open-github-nodes': () => {
console.log('open-github-nodes');
window.open( window.open(
'https://github.com/jim-fx/nodarium/tree/main/nodes/max/plantarium', 'https://github.com/jim-fx/nodarium/tree/main/nodes/max/plantarium',
'__blank' '__blank'
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@nodarium/planty", "name": "@nodarium/planty",
"version": "0.0.1", "version": "0.0.6",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
-1
View File
@@ -9,7 +9,6 @@
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"module": "NodeNext",
"moduleResolution": "bundler" "moduleResolution": "bundler"
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@nodarium/types", "name": "@nodarium/types",
"version": "0.0.5", "version": "0.0.6",
"description": "", "description": "",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@nodarium/ui", "name": "@nodarium/ui",
"version": "0.0.5", "version": "0.0.6",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
@@ -41,6 +41,7 @@
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@testing-library/svelte": "^5.3.1", "@testing-library/svelte": "^5.3.1",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/node": "^25.6.0",
"@types/three": "^0.184.0", "@types/three": "^0.184.0",
"@typescript-eslint/eslint-plugin": "^8.59.1", "@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1", "@typescript-eslint/parser": "^8.59.1",
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { NodeInput } from '@nodarium/types';
import '$lib/app.css'; import '$lib/app.css';
import { import {
Details, Details,
@@ -39,7 +40,7 @@
settings: { seed: 42, enabled: true } settings: { seed: 42, enabled: true }
}); });
let socketTypes = $state({ let socketTypes: Record<string, NodeInput> = $state({
input_0: { input_0: {
'label': 'Input 0', 'label': 'Input 0',
'type': 'path' 'type': 'path'
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@nodarium/utils", "name": "@nodarium/utils",
"version": "0.0.5", "version": "0.0.6",
"description": "", "description": "",
"main": "./src/index.ts", "main": "./src/index.ts",
"type": "module", "type": "module",
+3
View File
@@ -290,6 +290,9 @@ importers:
'@types/eslint': '@types/eslint':
specifier: ^9.6.1 specifier: ^9.6.1
version: 9.6.1 version: 9.6.1
'@types/node':
specifier: ^25.6.0
version: 25.6.0
'@types/three': '@types/three':
specifier: ^0.184.0 specifier: ^0.184.0
version: 0.184.0 version: 0.184.0