13 Commits

Author SHA1 Message Date
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
max 7f082ad8f6 feat: implement node sockets ui
📊 Benchmark the Runtime / benchmark (pull_request) Successful in 1m22s
🚀 Lint & Test & Deploy / quality (pull_request) Failing after 1m6s
🚀 Lint & Test & Deploy / test-unit (pull_request) Failing after 43s
🚀 Lint & Test & Deploy / test-e2e (pull_request) Successful in 2m5s
🚀 Lint & Test & Deploy / deploy (pull_request) Has been skipped
2026-05-05 21:07:57 +02:00
max ed11195327 chore: refactor graphStack to be simpler 2026-05-05 18:45:54 +02:00
max 8ad62cfc8e feat: add node group breadcrumbs 2026-05-05 12:44:44 +02:00
max bff140a764 feat: show different ui when inside group 2026-05-05 11:11:33 +02:00
max 85e2fd1a71 fix: use correct id for debug node 2026-05-04 23:54:43 +02:00
max 5beb03196d fix: broken format command for @nodarium/planty 2026-05-04 23:47:29 +02:00
max 83e0e47082 refactor: only show group/node panel when selected 2026-05-04 23:47:03 +02:00
32 changed files with 1141 additions and 500 deletions
-3
View File
@@ -8,9 +8,6 @@ test('test', async ({ page }) => {
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: 'New', exact: true }).click();
await page.getByRole('combobox').selectOption('2');
Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

@@ -0,0 +1,75 @@
<script lang="ts">
import { getGraphManager } from '../graph-state.svelte';
const graph = getGraphManager();
function getGroupName(groupId: number) {
const group = graph.getGroup(groupId);
return group?.name || `Group#${groupId}`;
}
function exitToGroup(targetId?: number) {
while (graph.currentGroupId !== (targetId ?? null)) {
graph.exitGroup();
}
}
// Intermediate groups: parent stack entries that are groups (not the root graph).
const intermediateGroups = $derived(
graph.parentStack.filter(e => e.id !== graph.id)
);
</script>
<div class="shadow" class:is-inside-group={graph.isInsideGroup}></div>
{#if graph.isInsideGroup}
<div class="group-name flex gap-1 items-center">
<button
class="bg-layer-2 opacity-75 hover:opacity-100 cursor-pointer rounded-sm p-1 px-2"
onclick={() => exitToGroup()}
>
Root
</button>
{#each intermediateGroups as entry (entry.id)}
<span class="i-[tabler--arrow-right]"></span>
<button
class="bg-layer-2 opacity-75 hover:opacity-100 cursor-pointer rounded-sm p-1 px-2"
onclick={() => exitToGroup(entry.id)}
>
{getGroupName(entry.id)}
</button>
{/each}
<span class="i-[tabler--arrow-right]"></span>
<button class="bg-layer-2 opacity-100 cursor-pointer rounded-sm p-1 px-2">
{getGroupName(graph.currentGroupId!)}
</button>
</div>
{/if}
<style>
.shadow {
position: absolute;
top: -5px;
left: -5px;
right: calc(var(--padding-right) - 5px);
bottom: -5px;
z-index: 1;
transition: box-shadow 0.3s ease;
box-shadow: 0 0 0px 0px var(--color-layer-2) inset;
pointer-events: none;
}
.shadow.is-inside-group {
box-shadow: 0 0 0px 8px var(--color-layer-2) inset;
}
.group-name {
position: absolute;
left: calc(50% - var(--padding-right) / 2);
transition: left 0.3s ease;
top: 12px;
transform: translateX(-50%);
z-index: 1000;
}
</style>
@@ -1,3 +1,4 @@
import { clone } from '$lib/helpers';
import throttle from '$lib/helpers/throttle';
import { RemoteNodeRegistry } from '$lib/node-registry/index';
import type {
@@ -10,54 +11,27 @@ import type {
NodeInput,
NodeInstance,
NodeRegistry,
SerializedEdge,
SerializedNode,
Socket
} from '@nodarium/types';
import { fastHashString } from '@nodarium/utils';
import { createLogger } from '@nodarium/utils';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import EventEmitter from './helpers/EventEmitter';
import {
areEdgesEqual,
areSocketsCompatible,
serializeEdge,
serializeNode
} from './helpers/nodeHelpers';
import { HistoryManager } from './history-manager';
const logger = createLogger('graph-manager');
logger.mute();
const log = createLogger('graph-manager');
log.mute();
const remoteRegistry = new RemoteNodeRegistry('');
const clone = 'structuredClone' in globalThis
? globalThis.structuredClone
: (args: unknown) => JSON.parse(JSON.stringify(args));
function areSocketsCompatible(
output: string | undefined,
inputs: string | (string | undefined)[] | undefined
) {
if (output === '*') return true;
if (Array.isArray(inputs) && output) {
return inputs.includes('*') || inputs.includes(output);
}
return inputs === output;
}
function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) {
if (firstEdge[0].id !== secondEdge[0].id) {
return false;
}
if (firstEdge[1] !== secondEdge[1]) {
return false;
}
if (firstEdge[2].id !== secondEdge[2].id) {
return false;
}
if (firstEdge[3] !== secondEdge[3]) {
return false;
}
return true;
}
export class GraphManager extends EventEmitter<{
save: Graph;
result: unknown;
@@ -69,25 +43,26 @@ export class GraphManager extends EventEmitter<{
status = $state<'loading' | 'idle' | 'error'>();
loaded = false;
graph: Graph = $state({ id: 0, nodes: [], edges: [], groups: [] });
// 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, …).
parentStack: {
id: number;
nodes: SerializedNode[];
edges: SerializedEdge[];
nodeId: number; // group instance node id that was entered to reach the next level
}[] = $state([]);
// ID of the currently active group, or null when at the root graph.
currentGroupId = $state<number | null>(null);
// Graph Data
id = $state(0);
meta = $state<Graph['meta']>({});
nodes = new SvelteMap<number, NodeInstance>();
nodeArray = $derived(Array.from(this.nodes.values()));
edges = $state<Edge[]>([]);
groups: GroupDefinition[] = $state([]);
// 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];
}[] = [];
nodeArray = $derived(Array.from(this.nodes.values()));
settingTypes: Record<string, NodeInput> = {};
settings = $state<Record<string, unknown>>();
@@ -104,24 +79,9 @@ export class GraphManager extends EventEmitter<{
history: HistoryManager = new HistoryManager();
public 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(() => {
if (this.loaded === false) return;
this.emit('result', this.serializeFullGraph());
this.emit('result', this.serialize());
}, 10);
constructor(public registry: NodeRegistry) {
@@ -129,47 +89,44 @@ export class GraphManager extends EventEmitter<{
}
serialize(): Graph {
const nodes = Array.from(this.nodes.values()).map((node) => ({
id: node.id,
position: [...node.position],
type: node.type,
props: node.props
})) as NodeInstance[];
const edges = this.edges.map((edge) => [
edge[0].id,
edge[1],
edge[2].id,
edge[3]
]) as Graph['edges'];
const groups = this.graph.groups?.map((group) => {
const groupNodes = group.nodes.map((node) => ({
id: node.id,
position: [...node.position],
type: node.type,
props: node.props
})) as NodeInstance[];
const nodes =
(this.parentStack.length === 0 ? Array.from(this.nodes.values()) : this.parentStack[0].nodes)
.map(n => serializeNode(n));
const edges =
(this.parentStack.length === 0 ? Array.from(this.edges.values()) : this.parentStack[0].edges)
.map(e => serializeEdge(e));
const groups = this.groups?.map((group) => {
const isCurrentActive = this.currentGroupId === group.id;
const stackState = this.parentStack.find((s) => s.id === group.id);
const groupNodes =
(isCurrentActive ? [...this.nodes.values()] : stackState?.nodes ?? group.nodes).map(
n => serializeNode(n)
);
const groupEdges =
(isCurrentActive ? [...this.edges.values()] : stackState?.edges ?? group.edges).map(
e => serializeEdge(e)
);
return {
id: group.id,
name: group.name,
inputs: group.inputs,
outputs: group.outputs,
nodes: groupNodes,
edges: group.edges
edges: groupEdges
};
});
const serialized = {
id: this.graph.id,
settings: $state.snapshot(this.settings),
meta: $state.snapshot(this.graph.meta),
const serialized = $state.snapshot({
id: this.id,
settings: this.settings,
meta: this.meta,
groups,
nodes,
edges
};
logger.log('serializing graph', serialized);
return clone($state.snapshot(serialized));
});
log.log('serializing graph', serialized);
return clone(serialized);
}
private lastSettingsHash = 0;
@@ -233,7 +190,7 @@ export class GraphManager extends EventEmitter<{
);
if (!bestInputEntry || bestOutputIdx === -1) {
logger.error('Could not find compatible sockets for drop');
log.error('Could not find compatible sockets for drop');
return;
}
@@ -339,21 +296,22 @@ export class GraphManager extends EventEmitter<{
return edges;
}
private _init(graph: Graph) {
const nodes = new SvelteMap(
graph.nodes.map((node) => {
const n = node as NodeInstance;
const registryType = this.registry.getNode(node.type);
n.state = registryType ? { type: registryType } : {};
const resolvedType = this.getNodeType(n);
if (resolvedType) n.state = { type: resolvedType };
return [node.id, n];
})
);
private _init(
graph: { nodes: SerializedNode[]; edges: SerializedEdge[] }
) {
this.nodes.clear();
for (const node of graph.nodes) {
const n = $state(node) as NodeInstance;
const registryType = this.registry.getNode(node.type);
n.state = registryType ? { type: registryType } : {};
const resolvedType = this.getNodeType(n);
if (resolvedType) n.state = { type: resolvedType };
this.nodes.set(n.id, n);
}
this.edges = graph.edges.map((edge) => {
const from = nodes.get(edge[0]);
const to = nodes.get(edge[2]);
const from = this.nodes.get(edge[0]);
const to = this.nodes.get(edge[2]);
if (!from || !to) {
throw new Error('Edge references non-existing node');
}
@@ -364,11 +322,6 @@ export class GraphManager extends EventEmitter<{
return [from, edge[1], to, edge[3]] as Edge;
});
this.nodes.clear();
for (const [id, node] of nodes) {
this.nodes.set(id, node);
}
this.execute();
}
@@ -377,11 +330,12 @@ export class GraphManager extends EventEmitter<{
this.loaded = false;
graph.groups ??= [];
this.graph = graph;
this.meta = graph.meta;
this.groups = graph.groups;
this.status = 'loading';
this.id = graph.id;
logger.info(
log.info(
'loading graph',
{ nodes: graph.nodes, edges: graph.edges, id: graph.id }
);
@@ -396,8 +350,7 @@ export class GraphManager extends EventEmitter<{
.filter(n => n && 'type' in n)
.map((n) => n.type)
)
)
.filter(n => !n.startsWith('__internal/'));
);
await this.registry.load(nodeIds);
@@ -417,20 +370,7 @@ export class GraphManager extends EventEmitter<{
});
}
logger.info('loaded node types', this.registry.getAllNodes());
for (const node of this.graph.nodes) {
const nodeType = this.registry.getNode(node.type);
if (!nodeType && !node.type.startsWith('__internal/')) {
logger.error(`Node type not found: ${node.type}`);
this.status = 'error';
return;
}
// Turn into runtime node
const n = node as NodeInstance;
n.state = {};
n.state.type = nodeType;
}
log.info('loaded node types', this.registry.getAllNodes());
// load settings
const settingTypes: Record<
@@ -461,18 +401,18 @@ export class GraphManager extends EventEmitter<{
}
}
this.parentStack = [];
this.currentGroupId = null;
this.settings = settingValues;
this.emit('settings', { types: settingTypes, values: settingValues });
this.history.reset();
this._init(this.graph);
this._init(graph);
this.save();
this.status = 'idle';
this.loaded = true;
logger.log(`Graph loaded in ${performance.now() - a}ms`);
log.log(`Graph loaded in ${performance.now() - a}ms`);
setTimeout(() => this.execute(), 100);
}
@@ -492,19 +432,15 @@ export class GraphManager extends EventEmitter<{
}
if (node.type === '__internal/group/input') {
const groupId = this.graphStack.at(-1)?.groupId;
const group = groupId !== undefined ? this.getGroup(groupId) : undefined;
const group = this.currentGroupId !== null ? this.getGroup(this.currentGroupId) : undefined;
if (!group) return node.state.type;
const groupInputs: NodeDefinition['inputs'] = Object.fromEntries(
Object.values(group?.inputs || {}).map((o, i) => {
return [
`in_${i}`,
{
...o,
external: true
}
];
return [`in_${i}`, {
...o,
external: true
}];
}) || []
);
return {
@@ -518,8 +454,7 @@ export class GraphManager extends EventEmitter<{
}
if (node.type === '__internal/group/output') {
const groupId = this.graphStack.at(-1)?.groupId;
const group = groupId !== undefined ? this.getGroup(groupId) : undefined;
const group = this.currentGroupId !== null ? this.getGroup(this.currentGroupId) : undefined;
if (!group) return node.state.type;
return {
id: '__internal/group/output' as NodeId,
@@ -539,10 +474,39 @@ export class GraphManager extends EventEmitter<{
// Construct the group inputs on the fly
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) {
logger.error(`Group not found: ${node.props?.groupId}`);
log.error(`Group not found: ${node.props?.groupId}`);
return;
}
@@ -551,18 +515,24 @@ export class GraphManager extends EventEmitter<{
...groupDefinition?.inputs
};
// This is to make sure the the groupId is always first
delete defaultInputs['groupId'];
const inputs = {
'groupId': {
type: 'select',
label: 'Group',
label: '',
value: node.props?.groupId,
internal: true,
options: this.graph.groups.map((g, i) => ({
options: this.groups.map((g) => ({
value: g.id,
label: g.name || `Group ${i + 1}`
}))
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);
})
},
...defaultInputs
};
@@ -642,6 +612,7 @@ export class GraphManager extends EventEmitter<{
}
removeNode(node: NodeInstance, { restoreEdges = false } = {}) {
log.log('removing node', { id: node.id, type: node.type, restoreEdges });
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]) {
@@ -686,82 +657,71 @@ export class GraphManager extends EventEmitter<{
}
getGroup(id: number) {
return this.graph.groups.find(g => g.id === id);
return this.groups.find(g => g.id === id);
}
renameGroup(groupId: number, name: string) {
log.log('renaming group', { groupId, name });
const group = this.getGroup(groupId);
if (!group) return;
group.name = name;
this.save();
}
isInsideGroup = $state(false);
isInsideGroup = $derived(this.currentGroupId !== null);
enterGroup(nodeId: number, cameraPosition: [number, number, number]): boolean {
enterGroup(nodeId: number): boolean {
const groupNode = this.getNode(nodeId);
if (!groupNode || groupNode.type !== '__internal/group/instance') return false;
log.log('entering group', { nodeId });
const groupId = groupNode.props?.groupId as number;
const group = this.getGroup(groupId);
if (!group) return false;
this.graphStack.push({
rootGraph: this.serialize(),
savedNodes: new SvelteMap(this.nodes),
savedEdges: [...this.edges],
outerGraph: this.graph,
groupId,
nodeId,
cameraPosition
// Snapshot current level and push it onto the parent stack.
this.parentStack.push({
id: this.currentGroupId ?? this.id,
nodes: [...this.nodes.values()].map(n => serializeNode(n)),
edges: [...this.edges.values()].map(e => serializeEdge(e)),
nodeId
});
this.graph = { ...this.graph, nodes: group.nodes, edges: group.edges };
this._init(this.graph);
this.currentGroupId = groupId;
log.log('entered group', { groupId, depth: this.parentStack.length });
this.history.reset();
this.isInsideGroup = true;
this._init(group);
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();
exitGroup() {
log.log('exiting group', { depth: this.parentStack.length });
if (this.parentStack.length === 0) return;
// Clear stale DOM/mesh refs so the remounting components register fresh ones.
// The $effect guards in NodeHTML/Node only set these when undefined, so without
// this clear they'd keep pointing to the detached elements from before group entry.
for (const node of savedNodes.values()) {
node.state.ref = undefined;
node.state.mesh = undefined;
// Persist live edits back to the GroupDefinition.
const group = this.getGroup(this.currentGroupId!);
if (group) {
group.nodes = [...this.nodes.values()].map(n => serializeNode(n));
group.edges = [...this.edges.values()].map(e => serializeEdge(e));
}
// 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
)
};
const parent = this.parentStack.pop()!;
this.currentGroupId = this.parentStack.length === 0 ? null : parent.id;
this._init(parent);
this.history.reset();
this.isInsideGroup = this.graphStack.length > 0;
this.execute();
this.save();
return { camera: cameraPosition, nodeId };
return { nodeId: parent.nodeId };
}
createNodeId() {
const ids = [
...this.nodes.keys(),
...this.graph.groups.map(g => g.id),
...this.graph.groups.flatMap(g => g.nodes.map(n => n.id))
...this.groups.map(g => g.id),
...this.groups.flatMap(g => g.nodes.map(n => n.id))
];
let id = 0;
@@ -814,12 +774,59 @@ export class GraphManager extends EventEmitter<{
return nodes;
}
getUnusedGroups() {
const usedGroupIds = new SvelteSet<number>();
const queue: number[] = [];
// Seed from root-level nodes so outer groups aren't treated as unused when inside a group.
const rootNodes = this.parentStack.length > 0
? this.parentStack[0].nodes
: [...this.nodes.values()];
for (const node of rootNodes) {
if (node.type === '__internal/group/instance' && node.props?.groupId !== undefined) {
queue.push(node.props.groupId as number);
}
}
// Also seed from live nodes (may contain new group instances created inside a group).
if (this.currentGroupId !== null) {
for (const node of this.nodes.values()) {
if (node.type === '__internal/group/instance' && node.props?.groupId !== undefined) {
queue.push(node.props.groupId as number);
}
}
}
// Every group on the navigation path is used by definition.
for (const entry of this.parentStack) {
if (entry.id !== this.id) usedGroupIds.add(entry.id);
}
if (this.currentGroupId !== null) usedGroupIds.add(this.currentGroupId);
while (queue.length) {
const groupId = queue.pop()!;
if (usedGroupIds.has(groupId)) continue;
usedGroupIds.add(groupId);
const group = this.getGroup(groupId);
if (!group) continue;
for (const node of group.nodes) {
if (node.type === '__internal/group/instance' && node.props?.groupId !== undefined) {
const childId = node.props.groupId as number;
if (!usedGroupIds.has(childId)) queue.push(childId);
}
}
}
return this.groups.filter(g => !usedGroupIds.has(g.id));
}
removeUnusedGroups() {
const usedGroups = new SvelteSet(this.getAllNodes().map(n => n.props?.groupId));
const unusedGroupAmount = this.graph.groups.length - usedGroups.size;
this.graph.groups = this.graph.groups.filter(g => usedGroups.has(g.id));
const unused = this.getUnusedGroups();
const unusedIds = new SvelteSet(unused.map(g => g.id));
this.groups = this.groups.filter(g => !unusedIds.has(g.id));
this.save();
return unusedGroupAmount;
return unused.length;
}
groupNodes(nodeIds: number[]) {
@@ -832,7 +839,7 @@ export class GraphManager extends EventEmitter<{
if (!nodes.length) return;
logger.log(`Grouping ${nodes.length} nodes`, { nodes });
log.log(`Grouping ${nodes.length} nodes`, { nodes });
const ids = new SvelteSet(nodes.map(n => n.id));
@@ -856,17 +863,25 @@ export class GraphManager extends EventEmitter<{
const inputs: Record<string, NodeInput> = {};
[...groupInputs.values()].forEach((edge, i) => {
const internalInputDef = edge[2].state.type?.inputs?.[edge[3]];
const input = {
label: `Input ${i}`,
label: internalInputDef?.label ?? edge[3],
type: edge[0].state.type?.outputs?.[edge[1]] || '*'
};
inputs[`input_${i}`] = input as NodeInput;
});
const outputs = [...groupOutputs.values()].map((edge, i) => ({
label: `Output ${i}`,
type: edge[2].state.type?.inputs?.[edge[3]].type || '*'
}));
const outputs = [];
if (groupOutputs.size) {
const edge = groupOutputs.values().next().value!;
const outputType = edge[0].state.type?.outputs?.[edge[1]] || '*';
outputs.push({
label: outputType === '*'
? 'Output'
: outputType.charAt(0).toUpperCase() + outputType.slice(1),
type: edge[2].state.type?.inputs?.[edge[3]].type || '*'
});
}
const groupPosition = [0, 0] as [number, number];
const bounds: Box = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };
@@ -889,8 +904,8 @@ export class GraphManager extends EventEmitter<{
// Allocate all needed IDs up front so sequential calls never collide.
const usedIds = new SvelteSet<number>([
...this.nodes.keys(),
...this.graph.groups.map(g => g.id),
...this.graph.groups.flatMap(g => g.nodes.map(n => n.id))
...this.groups.map(g => g.id),
...this.groups.flatMap(g => g.nodes.map(n => n.id))
]);
const nextId = () => {
let id = 0;
@@ -937,7 +952,7 @@ export class GraphManager extends EventEmitter<{
};
// Push before createNode so createNodeId() inside sees the allocated IDs.
this.graph.groups.push(groupDefinition);
this.groups.push(groupDefinition);
const groupNode = this.createNode({
type: '__internal/group/instance',
@@ -973,6 +988,124 @@ export class GraphManager extends EventEmitter<{
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({
type,
position,
@@ -984,7 +1117,7 @@ export class GraphManager extends EventEmitter<{
}) {
const nodeType = this.registry.getNode(type);
if (!nodeType && !type.startsWith('__internal/')) {
logger.error(`Node type not found: ${type}`);
log.error(`Node type not found: ${type}`);
return;
}
@@ -996,6 +1129,7 @@ export class GraphManager extends EventEmitter<{
props
});
log.log('creating node', { id: node.id, type, position, props });
this.nodes.set(node.id, node);
this.save();
@@ -1017,7 +1151,7 @@ export class GraphManager extends EventEmitter<{
(e) => e[0].id === from.id && e[1] === fromSocket && e[3] === toSocket
);
if (existingEdge) {
logger.error('Edge already exists', existingEdge);
log.error('Edge already exists', existingEdge);
return;
}
@@ -1034,7 +1168,7 @@ export class GraphManager extends EventEmitter<{
}
if (!areSocketsCompatible(fromSocketType, toSocketType)) {
logger.error(
log.error(
`Socket types do not match: ${fromSocketType} !== ${toSocketType}`
);
return;
@@ -1049,6 +1183,7 @@ export class GraphManager extends EventEmitter<{
const edge = [from, fromSocket, to, toSocket] as Edge;
log.log('creating edge', { from: from.id, fromSocket, to: to.id, toSocket });
this.edges.push(edge);
from.state.children = from.state.children || [];
@@ -1065,6 +1200,7 @@ export class GraphManager extends EventEmitter<{
}
undo() {
log.log('undo');
const nextState = this.history.undo();
if (nextState) {
this._init(nextState);
@@ -1073,6 +1209,7 @@ export class GraphManager extends EventEmitter<{
}
redo() {
log.log('redo');
const nextState = this.history.redo();
if (nextState) {
this._init(nextState);
@@ -1100,9 +1237,9 @@ export class GraphManager extends EventEmitter<{
return;
}
const fullState = this.graphStack.length > 0 ? this.serializeFullGraph() : state;
const fullState = this.serialize();
this.emit('save', fullState);
logger.log('saving graphs', fullState);
log.log('saving graphs', fullState);
}
getParentsOfNode(node: NodeInstance) {
@@ -1110,7 +1247,7 @@ export class GraphManager extends EventEmitter<{
const stack = node.state?.parents?.slice(0);
while (stack?.length) {
if (parents.length > 1000000) {
logger.warn('Infinite loop detected');
log.warn('Infinite loop detected');
break;
}
const parent = stack.pop();
@@ -1227,6 +1364,12 @@ export class GraphManager extends EventEmitter<{
edge: Edge,
{ applyDeletion = true }: { applyDeletion?: boolean } = {}
) {
log.log('removing edge', {
from: edge[0].id,
fromSocket: edge[1],
to: edge[2].id,
toSocket: edge[3]
});
const id0 = edge[0].id;
const sid0 = edge[1];
const id2 = edge[2].id;
@@ -88,7 +88,7 @@ describe('groupSelectedNodes', () => {
const groupNode = state.groupSelectedNodes();
assert.isDefined(groupNode);
expect(manager.graph.groups.length).toBe(1);
expect(manager.groups.length).toBe(1);
});
});
@@ -97,7 +97,7 @@ describe('enterGroupNode', () => {
const { manager, state } = createFixture();
state.activeNodeId = -1;
state.enterGroupNode();
expect(manager.graphStack.length).toBe(0);
expect(manager.parentStack.length).toBe(0);
});
it('does nothing when the active node is not a group instance', () => {
@@ -106,7 +106,7 @@ describe('enterGroupNode', () => {
assert.isDefined(node);
state.activeNodeId = node!.id;
state.enterGroupNode();
expect(manager.graphStack.length).toBe(0);
expect(manager.parentStack.length).toBe(0);
});
it('enters the group, pushes graphStack, and clears UI state', () => {
@@ -123,7 +123,7 @@ describe('enterGroupNode', () => {
state.enterGroupNode();
expect(manager.graphStack.length).toBe(1);
expect(manager.parentStack.length).toBe(1);
expect(state.activeNodeId).toBe(-1);
expect(state.selectedNodes.size).toBe(0);
expect(manager.isInsideGroup).toBe(true);
@@ -135,30 +135,10 @@ describe('exitGroupNode', () => {
const { manager, state } = createFixture();
const before = [...state.cameraPosition];
state.exitGroupNode();
expect(manager.graphStack.length).toBe(0);
expect(manager.parentStack.length).toBe(0);
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', () => {
const { manager, state } = createFixture();
@@ -213,6 +213,10 @@ export class GraphState {
};
}
unGroupSelectedNodes() {
return this.graph.ungroupNode(this.activeNodeId);
}
groupSelectedNodes() {
return this.graph.groupNodes([...this.selectedNodes.keys(), this.activeNodeId]);
}
@@ -365,7 +369,7 @@ export class GraphState {
if (this.activeNodeId === -1) return;
const node = this.graph.getNode(this.activeNodeId);
if (!node || node.type !== '__internal/group/instance') return;
const ok = this.graph.enterGroup(this.activeNodeId, [...this.cameraPosition]);
const ok = this.graph.enterGroup(this.activeNodeId);
if (ok) {
this.activeNodeId = -1;
this.clearSelection();
@@ -375,7 +379,6 @@ export class GraphState {
exitGroupNode() {
const result = this.graph.exitGroup();
if (!result) return;
this.cameraPosition = result.camera;
this.activeNodeId = result.nodeId;
this.clearSelection();
}
+2 -61
View File
@@ -7,6 +7,7 @@
import AddMenu from '../components/AddMenu.svelte';
import BoxSelection from '../components/BoxSelection.svelte';
import Camera from '../components/Camera.svelte';
import GroupBreadcrumps from '../components/GroupBreadcrumps.svelte';
import HelpView from '../components/HelpView.svelte';
import Debug from '../debug/Debug.svelte';
import EdgeEl from '../edges/Edge.svelte';
@@ -108,14 +109,6 @@
return nodeType?.outputs?.[index] || 'unknown';
}
function getGroupName() {
const groupId = graph.graphStack.at(-1)?.groupId;
if (groupId !== undefined) {
const group = graph.getGroup(groupId);
return group?.name || `Group#${groupId}`;
}
}
</script>
<svelte:window
@@ -142,7 +135,6 @@
oncontextmenu={(ev) => mouseEvents.handleContextMenu(ev)}
{...fileDropEvents.getEventListenerProps()}
>
<div class="shadow"></div>
<input
type="file"
accept="application/wasm,application/json"
@@ -153,14 +145,7 @@
/>
<label for="drop-zone"></label>
{#if graph.isInsideGroup}
<button class="exit-group" onclick={() => graphState.exitGroupNode()}>
↑ Exit Group
</button>
<p class="group-name absolute">
Group <b>{getGroupName()}</b>
</p>
{/if}
<GroupBreadcrumps />
<Canvas shadows={false} renderMode="on-demand" colorManagementEnabled={false}>
<Camera
@@ -270,34 +255,6 @@
height: 100%;
}
.group-name {
position: absolute;
left: calc(50% - var(--padding-right) / 2);
transition: left 0.3s ease;
top: 12px;
transform: translateX(-50%);
z-index: 1000;
}
.exit-group {
position: absolute;
top: 12px;
left: 12px;
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;
}
.exit-group:hover {
opacity: 1;
}
.wrapper {
position: absolute;
z-index: 100;
@@ -309,22 +266,6 @@
cursor: pointer;
}
.shadow {
position: absolute;
top: -5px;
left: -5px;
right: calc(var(--padding-right) - 5px);
bottom: -5px;
z-index: 1;
transition: box-shadow 0.3s ease;
box-shadow: 0 0 0px 0px var(--color-layer-2) inset;
pointer-events: none;
}
.is-inside-group .shadow {
box-shadow: 0 0 0px 8px var(--color-layer-2) inset;
}
.is-panning {
cursor: grab;
}
@@ -1,6 +1,7 @@
<script lang="ts">
import { createKeyMap } from '$lib/helpers/createKeyMap';
import type { Graph, NodeInstance, NodeRegistry } from '@nodarium/types';
import { onMount } from 'svelte';
import { GraphManager } from '../graph-manager.svelte';
import { GraphState, setGraphManager, setGraphState } from '../graph-state.svelte';
import { setupKeymaps } from '../keymaps';
@@ -83,8 +84,8 @@
manager.on('save', (save) => onsave?.(save));
$effect(() => {
if (graph && (manager.status !== 'idle' || manager.graph.id !== graph.id)) {
onMount(() => {
if (graph) {
manager.load(graph);
}
});
+9 -1
View File
@@ -2,7 +2,7 @@ type Color = { hue: number; saturation: number; lightness: number };
export class ColorGenerator {
private colors: Map<string, Color> = new Map();
private lightnessLevels = [10, 60];
// private lightnessLevels = [10, 60];
constructor(predefined: Record<string, Color>) {
for (const [id, colorStr] of Object.entries(predefined)) {
@@ -10,6 +10,14 @@ export class ColorGenerator {
}
}
public getColors() {
return Object.fromEntries(
this.colors.entries().map(([key, col]) => {
return [key, this.colorToHsl(col)];
})
);
}
public getColor(id: string): string {
if (this.colors.has(id)) {
return this.colorToHsl(this.colors.get(id)!);
@@ -1,4 +1,10 @@
import type { NodeDefinition } from '@nodarium/types';
import type {
Edge,
NodeDefinition,
NodeInstance,
SerializedEdge,
SerializedNode
} from '@nodarium/types';
export function getParameterHeight(node: NodeDefinition, inputKey: string) {
if (node.id === '__internal/group/input') {
@@ -27,6 +33,23 @@ export function getParameterHeight(node: NodeDefinition, inputKey: string) {
return 50;
}
export function serializeNode(node: SerializedNode | NodeInstance): SerializedNode {
return {
id: node.id,
position: [...node.position],
type: node.type,
props: node.props
};
}
export function serializeEdge(edge: SerializedEdge | Edge): SerializedEdge {
if (typeof edge[0] === 'number' && typeof edge[2] === 'number') {
return [edge[0], edge[1], edge[2], edge[3]];
}
const e = edge as Edge;
return [e[0].id, e[1], e[2].id, e[3]];
}
const nodeHeightCache: Record<string, number> = {};
export function getNodeHeight(node: NodeDefinition) {
if (!node || !('inputs' in node)) {
@@ -45,3 +68,34 @@ export function getNodeHeight(node: NodeDefinition) {
nodeHeightCache[node.id] = height;
return height;
}
export function areSocketsCompatible(
output: string | undefined,
inputs: string | (string | undefined)[] | undefined
) {
if (output === '*') return true;
if (Array.isArray(inputs) && output) {
return inputs.includes('*') || inputs.includes(output);
}
return inputs === output;
}
export function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) {
if (firstEdge[0].id !== secondEdge[0].id) {
return false;
}
if (firstEdge[1] !== secondEdge[1]) {
return false;
}
if (firstEdge[2].id !== secondEdge[2].id) {
return false;
}
if (firstEdge[3] !== secondEdge[3]) {
return false;
}
return true;
}
+8
View File
@@ -66,6 +66,14 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
callback: () => graphState.groupSelectedNodes()
});
keymap.addShortcut({
key: 'g',
alt: true,
preventDefault: true,
description: 'Ungroup selected nodes',
callback: () => graphState.unGroupSelectedNodes()
});
keymap.addShortcut({
key: 'Tab',
preventDefault: true,
@@ -26,6 +26,13 @@
const rightBump = $derived(
!!nodeType?.outputs?.length && node.type !== '__internal/group/input'
);
const cornerBottom = $derived(
node.type === '__internal/group/input'
? (Object.keys(nodeType?.inputs ?? {}).length ? 0 : 10)
: node.type === '__internal/group/output'
? (nodeType?.outputs?.length ? 0 : 10)
: 0
);
const aspectRatio = 0.25;
@@ -35,6 +42,7 @@
height: 34,
y: 49,
cornerTop,
cornerBottom,
rightBump,
aspectRatio
})
@@ -45,6 +53,7 @@
height: 40,
y: 49,
cornerTop,
cornerBottom,
rightBump,
aspectRatio
})
+3 -2
View File
@@ -1,11 +1,12 @@
export const debugNode = {
id: '__internal/debug/instance',
id: '__internal/node/debug',
meta: {
title: 'Debug'
},
inputs: {
input: {
type: '*'
type: '*',
label: ''
}
},
execute(_data: Int32Array): Int32Array {
+2 -1
View File
@@ -2,7 +2,8 @@ export const groupNode = {
id: '__internal/group/instance',
meta: { title: 'Group' },
inputs: {
input: {
groupId: {
label: '',
type: 'select',
values: []
}
+27 -1
View File
@@ -21,6 +21,26 @@ log.mute();
export function expandGroups(graph: Graph): Graph {
if (!graph.groups || graph.groups.length === 0) return graph;
function groupContainsSelf(groupId: number, visited = new Set<number>()): boolean {
if (visited.has(groupId)) return true;
visited.add(groupId);
const group = graph.groups!.find(g => g.id === groupId);
if (!group) return false;
for (const n of group.nodes) {
if (n.type === '__internal/group/instance') {
const nestedId = n.props?.groupId as number | undefined;
if (nestedId !== undefined && groupContainsSelf(nestedId, visited)) return true;
}
}
return false;
}
for (const group of graph.groups) {
if (groupContainsSelf(group.id)) {
throw new Error(`Circular group reference: group ${group.id} contains itself`);
}
}
const nodes = [...graph.nodes];
let edges = [...graph.edges];
@@ -57,10 +77,16 @@ export function expandGroups(graph: Graph): Graph {
const newEdges: Graph['edges'] = [];
// external_source → [inputBoundary →] internal_target
//
// External socket names are "input_N" where N equals the input boundary's
// output index. Match each external edge only to the internal edges that
// originate from that specific output slot — not a cartesian product of all.
if (inputBoundary) {
const fromInput = group.edges.filter(e => e[0] === inputBoundary.id);
for (const extEdge of incomingExternal) {
for (const intEdge of fromInput) {
const inputIndex = parseInt((extEdge[3] as string).replace('input_', ''), 10);
const matchingIntEdges = fromInput.filter(e => e[1] === inputIndex);
for (const intEdge of matchingIntEdges) {
const toId = idMap.get(intEdge[2]);
if (toId !== undefined) newEdges.push([extEdge[0], extEdge[1], toId, intEdge[3]]);
}
@@ -204,6 +204,13 @@
.input-boolean > label {
order: 2;
font-size: 1em;
opacity: 0.9;
}
label {
font-size: 0.8em;
opacity: 0.7;
}
.first-level.input {
@@ -1,99 +0,0 @@
<script lang="ts">
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
import NestedSettings from '$lib/settings/NestedSettings.svelte';
import type { NodeId, NodeInput, NodeInstance } from '@nodarium/types';
type InternalNodeInput = NodeInput & {
__node_type?: NodeId;
__node_input: string;
};
type Props = {
manager: GraphManager;
node: NodeInstance;
};
const { manager, node = $bindable() }: Props = $props();
function filterInputs(inputs?: Record<string, NodeInput>) {
const _inputs = $state.snapshot(
inputs as Record<string, InternalNodeInput>
);
return Object.fromEntries(
Object.entries(structuredClone(_inputs ?? {}))
.filter(([, value]) => {
return value.hidden === true;
})
.map(([key, value]) => {
value.__node_type = node.state.type?.id;
value.__node_input = key;
return [key, value];
})
);
}
const nodeDefinition = filterInputs(node.state.type?.inputs);
type Store = Record<string, number | number[]>;
let store = $state<Store>(createStore(node?.props, nodeDefinition));
function createStore(
props: NodeInstance['props'],
inputs: Record<string, NodeInput>
): Store {
const store: Store = {};
Object.keys(inputs).forEach((key) => {
if (props) {
const value = props[key] !== undefined ? props[key] : inputs[key].value;
if (Array.isArray(value) || typeof value === 'number') {
store[key] = value;
} else if (typeof value === 'boolean') {
store[key] = value ? 1 : 0;
} else {
console.error('Wrong error', { value });
}
}
});
return store;
}
let lastPropsHash = '';
function updateNode() {
if (!node || !store) return;
let needsUpdate = false;
Object.keys(store).forEach((_key: string) => {
node.props = node.props || {};
const key = _key as keyof typeof store;
if (node && store) {
needsUpdate = true;
const value = store[key];
if (value !== undefined) {
node.props[key] = value;
}
}
});
let propsHash = JSON.stringify(node.props);
if (propsHash === lastPropsHash) {
return;
}
lastPropsHash = propsHash;
if (needsUpdate) {
manager.save();
manager.execute();
}
}
$effect(() => {
if (store) {
updateNode();
}
});
</script>
{#if Object.keys(nodeDefinition).length}
<NestedSettings
id="activeNodeSettings"
bind:value={store}
type={nodeDefinition}
/>
{/if}
@@ -1,21 +1,103 @@
<script lang="ts">
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
import type { NodeInstance } from '@nodarium/types';
import ActiveNodeSelected from './ActiveNodeSelected.svelte';
import NestedSettings from '$lib/settings/NestedSettings.svelte';
import type { NodeId, NodeInput, NodeInstance } from '@nodarium/types';
type InternalNodeInput = NodeInput & {
__node_type?: NodeId;
__node_input: string;
};
type Props = {
manager: GraphManager;
node: NodeInstance | undefined;
};
let { manager, node = $bindable() }: Props = $props();
const { manager, node = $bindable() }: Props = $props();
function filterInputs(inputs?: Record<string, NodeInput>) {
if (!node) return {};
return Object.fromEntries(
Object.entries(inputs ?? {})
.filter(([, value]) => {
return value.hidden === true;
})
.map(([key, value]) => {
const v = value as InternalNodeInput;
v.__node_type = node.state.type?.id;
v.__node_input = key;
return [key, v];
})
);
}
const nodeDefinition = node ? filterInputs(node.state.type?.inputs) : {};
type Store = Record<string, number | number[]>;
let store = $state<Store>(createStore(node?.props, nodeDefinition));
function createStore(
props: NodeInstance['props'],
inputs: Record<string, NodeInput>
): Store {
const store: Store = {};
Object.keys(inputs).forEach((key) => {
if (props) {
const value = props[key] !== undefined ? props[key] : inputs[key].value;
if (Array.isArray(value) || typeof value === 'number') {
store[key] = value;
} else if (typeof value === 'boolean') {
store[key] = value ? 1 : 0;
} else {
console.error('Wrong error', { value });
}
}
});
return store;
}
let lastPropsHash = '';
function updateNode() {
if (!node || !store) return;
let needsUpdate = false;
Object.keys(store).forEach((_key: string) => {
node.props = node.props || {};
const key = _key as keyof typeof store;
if (node && store) {
needsUpdate = true;
const value = store[key];
if (value !== undefined) {
node.props[key] = value;
}
}
});
let propsHash = JSON.stringify(node.props);
if (propsHash === lastPropsHash) {
return;
}
lastPropsHash = propsHash;
if (needsUpdate) {
manager.save();
manager.execute();
}
}
const isGroupInstance = $derived(node?.type === '__internal/group/instance');
$effect(() => {
if (store) {
updateNode();
}
});
</script>
{#if node && !isGroupInstance}
{#if !isGroupInstance && Object.keys(nodeDefinition).length}
<div class='{node?"border-l-2 pl-3.5!":""} bg-layer-2 flex items-center h-[70px] border-b-1 border-l-selected border-b-outline pl-4'>
<h3>Node Settings</h3>
</div>
<ActiveNodeSelected {manager} bind:node />
<NestedSettings
id="activeNodeSettings"
bind:value={store}
type={nodeDefinition}
/>
{/if}
+85 -49
View File
@@ -1,26 +1,26 @@
<script lang="ts">
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
import { GraphState } from '$lib/graph-interface/graph-state.svelte';
import type { NodeInstance } from '@nodarium/types';
import { SocketTable } from '@nodarium/ui';
import UnusedGroupsPanel from './UnusedGroupsPanel.svelte';
type Props = {
manager: GraphManager;
graphState: GraphState;
node?: NodeInstance;
};
const { manager, node = $bindable() }: Props = $props();
const { manager, graphState, node = $bindable() }: Props = $props();
const activeGroup = $derived.by(() => {
console.log('isInsideGroup', manager?.isInsideGroup);
if (manager?.isInsideGroup) {
const activeGroupId = manager.graphStack?.at(-1)?.groupId;
console.log('activeGroupId', activeGroupId);
if (activeGroupId !== undefined) {
return manager.getGroup(activeGroupId);
}
if (node?.type === '__internal/group/instance') {
let group = manager.getGroup(node.props?.groupId as number);
if (group) return group;
}
if (node?.type === '__internal/group/instance') {
return manager.getGroup(node.props?.groupId as number);
if (manager?.isInsideGroup && manager.currentGroupId !== null) {
return manager.getGroup(manager.currentGroupId);
}
});
@@ -30,17 +30,49 @@
if (activeGroup) manager.renameGroup(activeGroup.id, name);
}
const hasUnusedGroups = $derived.by(() => {
if (!manager) return false;
if (manager.isInsideGroup) return false;
if (manager.graph.groups.length === 0) return false;
return manager.graph.groups.filter(g => {
return !manager.graph.nodes.find(n => n.props?.groupId === g.id);
}).length;
function handleRemoveInput(key: string) {
if (!activeGroup) return;
const group = manager.getGroup(activeGroup?.id);
const inputs = $state.snapshot(group?.inputs ?? {});
delete inputs[key];
activeGroup.inputs = inputs;
manager.nodes = manager.nodes;
manager.save();
}
const types = $derived(
Array.from(
new Set(
manager?.registry
? manager.registry.getAllNodes()
.flatMap(n =>
Object.values(n.inputs ?? {})
.map(v => v.type)
)
: []
)
)
);
let outputType = $derived(activeGroup?.outputs?.[0]?.type ?? 'unknown');
$effect(() => {
if (!activeGroup) return;
const group = manager.getGroup(activeGroup?.id);
const outputs = $state.snapshot(group?.outputs ?? []);
if (outputs?.[0]?.type === outputType) return;
activeGroup.outputs = [
{
label: outputs[0]?.label ?? 'Output',
type: outputType
}
];
manager.nodes = manager.nodes;
manager.save();
});
</script>
{#if activeGroup || hasUnusedGroups}
{#if activeGroup}
<div class='{node?"border-l-2 pl-3.5!":""} bg-layer-2 flex items-center h-[70px] border-b-1 border-l-selected border-b-outline pl-4'>
<h3>Group Settings</h3>
</div>
@@ -48,7 +80,7 @@
{#if activeGroup}
{#key activeGroup.id}
<div class="group-settings">
<div class="p-4 group-settings">
<label for="group-name">Group name</label>
<input
id="group-name"
@@ -57,16 +89,43 @@
value={groupName}
oninput={handleRename}
/>
<label for="group-name">Group Inputs</label>
<div>
<SocketTable
{types}
onremove={handleRemoveInput}
bind:inputs={activeGroup.inputs}
colors={graphState?.colors?.getColors()}
/>
</div>
<label for="group-name mb-2">Group output</label>
<div class="flex bg-layer-2 rounded-sm outline outline-outline w-min">
<span
style:background={graphState?.colors?.getColor(outputType)}
class="block opacity-50 min-w-2 ml-2 w-2 h-2 my-auto rounded-sm"
></span>
<select
class="text-[0.9em] shrink-0 px-2 py-1 border-outline"
bind:value={outputType}
>
{#each types as type (type)}
<option>
<span
style="background: {graphState?.colors?.getColor(type)}; width: 5px; height: 5px; border-radius: 5px;"
></span>
{type}
</option>
{/each}
</select>
</div>
</div>
{/key}
{/if}
{#if hasUnusedGroups}
<div class="group-actions">
<button onclick={() => manager.removeUnusedGroups()}>
Remove ({hasUnusedGroups}) orphaned groups
</button>
</div>
{#if manager && !manager.isInsideGroup}
<UnusedGroupsPanel {manager} />
{/if}
<style>
@@ -74,10 +133,9 @@
display: flex;
flex-direction: column;
gap: 0.4em;
padding: 1em;
}
.group-settings label {
label {
font-size: 0.8em;
opacity: 0.7;
}
@@ -95,26 +153,4 @@
.group-settings input:focus {
outline: 1px solid var(--color-active);
}
.group-actions {
padding: 0.75em 1em;
border-bottom: 1px solid var(--color-outline);
margin-top: auto;
}
.group-actions button {
width: 100%;
padding: 0.4em 0.8em;
background: var(--color-layer-1);
border: 1px solid var(--color-outline);
border-radius: 4px;
color: var(--color-text);
font-family: var(--font-family);
font-size: 0.85em;
cursor: pointer;
}
.group-actions button:hover {
border-color: var(--color-active);
}
</style>
@@ -0,0 +1,135 @@
<script lang="ts">
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
import type { GroupDefinition } from '@nodarium/types';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
type Props = { manager: GraphManager };
const { manager }: Props = $props();
type GroupNode = { group: GroupDefinition; children: GroupNode[] };
const unusedTree = $derived.by((): GroupNode[] => {
const unused = manager.getUnusedGroups();
if (!unused.length) return [];
const unusedIds = new Set(unused.map(g => g.id));
// Build child map: which unused groups reference which other unused groups
const childrenOf = new SvelteMap<number, number[]>();
const referencedBy = new SvelteSet<number>();
for (const group of unused) {
const refs: number[] = [];
for (const node of group.nodes) {
if (node.type === '__internal/group/instance' && node.props?.groupId !== undefined) {
const childId = node.props.groupId as number;
if (unusedIds.has(childId)) {
refs.push(childId);
referencedBy.add(childId);
}
}
}
childrenOf.set(group.id, refs);
}
const byId = new Map(unused.map(g => [g.id, g]));
function buildNode(g: GroupDefinition): GroupNode {
return {
group: g,
children: (childrenOf.get(g.id) ?? []).map(id => buildNode(byId.get(id)!))
};
}
return unused
.filter(g => !referencedBy.has(g.id))
.map(buildNode);
});
</script>
{#if unusedTree.length}
<div class="panel p-4">
<div class="header">
<span>Unused groups</span>
<button class="remove-all" onclick={() => manager.removeUnusedGroups()}>
Remove all
</button>
</div>
<ul class="tree">
{#snippet treeNode(node: GroupNode)}
<li>
<span class="group-name">{node.group.name || `Group #${node.group.id}`}</span>
{#if node.children.length}
<ul>
{#each node.children as child (child.group.id)}
{@render treeNode(child)}
{/each}
</ul>
{/if}
</li>
{/snippet}
{#each unusedTree as node (node.group.id)}
{@render treeNode(node)}
{/each}
</ul>
</div>
{/if}
<style>
.panel {
border-top: 1px solid var(--color-outline);
margin-top: -1px;
border-bottom: 1px solid var(--color-outline);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5em;
font-size: 0.8em;
opacity: 0.7;
}
.remove-all {
background: none;
border: 1px solid var(--color-outline);
border-radius: 4px;
color: var(--color-text);
cursor: pointer;
font-family: var(--font-family);
font-size: 0.85em;
padding: 0.2em 0.5em;
}
.remove-all:hover {
border-color: var(--color-active);
}
.tree {
list-style: none;
margin: 0;
padding: 0;
}
.tree ul {
list-style: none;
margin: 0;
padding-left: 1.2em;
border-left: 1px solid var(--color-outline);
}
.tree li {
padding: 0.15em 0;
}
.group-name {
font-size: 0.85em;
}
.tree ul .group-name::before {
content: '└ ';
opacity: 0.4;
}
</style>
+26 -19
View File
@@ -5,6 +5,7 @@
import { debounceAsyncFunction } from '$lib/helpers';
import { createKeyMap } from '$lib/helpers/createKeyMap';
import { debugNode } from '$lib/node-registry/debugNode';
import { groupNode } from '$lib/node-registry/groupNode.js';
import { IndexDBCache, RemoteNodeRegistry } from '$lib/node-registry/index';
import NodeStore from '$lib/node-store/NodeStore.svelte';
import PerformanceViewer from '$lib/performance/PerformanceViewer.svelte';
@@ -38,7 +39,7 @@
const registryCache = new IndexDBCache('node-registry');
const nodeRegistry = new RemoteNodeRegistry('', registryCache, [debugNode]);
const nodeRegistry = new RemoteNodeRegistry('', registryCache, [debugNode, groupNode]);
const workerRuntime = new WorkerRuntimeExecutor();
const runtimeCache = new MemoryRuntimeCache();
const memoryRuntime = new MemoryRuntimeExecutor(nodeRegistry, runtimeCache);
@@ -95,7 +96,7 @@
randomSeed: { type: 'boolean', value: false }
});
$effect(() => {
if (graphSettings && graphSettingTypes) {
if (graphSettings && graphSettingTypes && manager?.loaded) {
manager?.setSettings($state.snapshot(graphSettings));
}
});
@@ -255,20 +256,22 @@
</Grid.Cell>
<Grid.Cell>
{#if pm.graph}
<GraphInterface
graph={pm.graph}
bind:this={graphInterface}
registry={nodeRegistry}
safePadding={{ right: sidebarOpen ? 320 : undefined }}
backgroundType={appSettings.value.nodeInterface.backgroundType}
snapToGrid={appSettings.value.nodeInterface.snapToGrid}
bind:activeNode
bind:showHelp={appSettings.value.nodeInterface.showHelp}
bind:settings={graphSettings}
bind:settingTypes={graphSettingTypes}
onsave={(g) => pm.saveGraph(g)}
onresult={(result) => handleUpdate(result as Graph)}
/>
{#key pm.graph.id}
<GraphInterface
graph={pm.graph}
bind:this={graphInterface}
registry={nodeRegistry}
safePadding={{ right: sidebarOpen ? 321 : undefined }}
backgroundType={appSettings.value.nodeInterface.backgroundType}
snapToGrid={appSettings.value.nodeInterface.snapToGrid}
bind:activeNode
bind:showHelp={appSettings.value.nodeInterface.showHelp}
bind:settings={graphSettings}
bind:settingTypes={graphSettingTypes}
onsave={(g) => pm.saveGraph(g)}
onresult={(result) => handleUpdate(result as Graph)}
/>
{/key}
{/if}
<Sidebar bind:open={sidebarOpen}>
<Panel id="general" title="General" icon="i-[tabler--settings]">
@@ -322,7 +325,9 @@
hidden={!appSettings.value.debug.advancedMode}
icon="i-[tabler--code]"
>
<GraphSource graph={manager?.serializeFullGraph()} />
{#if manager?.status === 'idle'}
<GraphSource graph={manager.serialize()} />
{/if}
</Panel>
<Panel
id="benchmark"
@@ -343,8 +348,10 @@
type={graphSettingTypes}
bind:value={graphSettings}
/>
<ActiveNodeSettings {manager} bind:node={activeNode} />
<GroupSettings {manager} bind:node={activeNode} />
{#key activeNode}
<ActiveNodeSettings {manager} bind:node={activeNode} />
<GroupSettings graphState={graphInterface?.state} {manager} bind:node={activeNode} />
{/key}
</Panel>
<Panel
id="changelog"
+2 -2
View File
@@ -10,8 +10,8 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "dprint fmt -c '../.dprint.jsonc' .",
"format:check": "dprint check -c '../.dprint.jsonc' ."
"format": "dprint fmt -c '../../.dprint.jsonc' .",
"format:check": "dprint check -c '../../.dprint.jsonc' ."
},
"files": [
"dist",
-1
View File
@@ -9,7 +9,6 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"module": "NodeNext",
"moduleResolution": "bundler"
}
}
+1
View File
@@ -8,6 +8,7 @@ export type {
NodeDefinition,
NodeId,
NodeInstance,
SerializedEdge,
SerializedNode,
Socket
} from './types';
+5 -1
View File
@@ -76,6 +76,10 @@ export type Socket = {
export type Edge = [NodeInstance, number, NodeInstance, string];
const SerializedEdgeSchema = z.tuple([z.number(), z.number(), z.number(), z.string()]);
export type SerializedEdge = z.infer<typeof SerializedEdgeSchema>;
export const GroupSchema = z.object({
id: z.number(),
name: z.string().optional(),
@@ -100,7 +104,7 @@ export const GraphSchema = z.object({
.optional(),
settings: z.record(z.string(), z.any()).optional(),
nodes: z.array(NodeSchema),
edges: z.array(z.tuple([z.number(), z.number(), z.number(), z.string()])),
edges: z.array(SerializedEdgeSchema),
groups: z.array(GroupSchema)
});
+1
View File
@@ -41,6 +41,7 @@
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@testing-library/svelte": "^5.3.1",
"@types/eslint": "^9.6.1",
"@types/node": "^25.6.0",
"@types/three": "^0.184.0",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
+1 -1
View File
@@ -9,7 +9,7 @@
@source inline("{hover:,}{bg-,outline-,text-,}layer-3{/30,/50,}");
@source inline("{hover:,}{bg-,outline-,text-,}active");
@source inline("{hover:,}{bg-,outline-,text-,}selected");
@source inline("{hover:,}{bg-,outline-,text-,}outline{!,}");
@source inline("{hover:,}{bg-,outline-,text-,border-,divide-}outline{!,}");
@source inline("{hover:,}{bg-,outline-,text-,}connection");
@source inline("{hover:,}{bg-,outline-,text-,}text");
+1
View File
@@ -5,6 +5,7 @@ export { default as InputNumber } from './inputs/InputNumber.svelte';
export { default as InputSelect } from './inputs/InputSelect.svelte';
export { default as InputShape } from './inputs/InputShape.svelte';
export { default as InputVec3 } from './inputs/InputVec3.svelte';
export { default as SocketTable } from './inputs/SocketTable.svelte';
export { default as Details } from './Details.svelte';
export { default as JsonViewer } from './JsonViewer.svelte';
@@ -0,0 +1,118 @@
<script lang="ts">
import type { NodeInput } from '@nodarium/types';
type Props = {
inputs?: Record<string, NodeInput>;
colors: Record<string, string>;
onremove?: (key: string) => void;
types: string[];
};
let { inputs = $bindable(), onremove, colors = {}, types = ['seed', 'float', 'path'] }: Props =
$props();
let potentialRow = $state<
{
type: string;
label: string;
} | undefined
>();
function showPotentialRow() {
potentialRow = {
type: types[0],
label: 'Input ' + Object.keys(inputs ?? {}).length
};
}
function realizePotentialRow() {
if (inputs) inputs[`input_${Object.keys(inputs).length}`] = potentialRow as NodeInput;
potentialRow = undefined;
}
function removeRow(key?: string) {
if (!key) {
potentialRow = undefined;
} else if (inputs) {
onremove?.(key);
}
}
function getColor(type: string) {
if (type in colors) {
return colors[type];
}
return '#f00';
}
</script>
{#snippet row(input: { type: string; label?: string }, remove: () => void, add?: () => void)}
<div class="flex min-w-0">
<span
style:background={getColor(input.type)}
data-type={input.type}
class="block opacity-50 min-w-2 ml-2 w-2 h-2 my-auto rounded-sm"
></span>
<select
class="text-[0.9em] border-r w-19 shrink-0 px-2 py-1 border-outline"
bind:value={input.type}
>
{#each types as type (type)}
<option>
<span
style="background: {getColor(type)}; width: 5px; height: 5px; border-radius: 5px;"
></span>
{type}
</option>
{/each}
</select>
<input
class="px-2 grow min-w-30 border-r border-outline text-[0.9em]"
type="text"
bind:value={input.label}
/>
<button
class="px-2 cursor-pointer opacity-50 hover:opacity-100 hover:bg-red-400"
onclick={remove}
aria-label="remove"
>
{#if add}
<span class="py-1 block i-[tabler--cancel]"></span>
{:else}
<span class="py-1 block i-[tabler--trash]"></span>
{/if}
</button>
{#if add}
<button
class="px-2 border-l hover:bg-green-300 opacity-50 hover:opacity-100 hover:text-layer-1 border-outline cursor-pointer"
onclick={add}
aria-label="add"
>
<span class="py-1 block i-[tabler--circle-plus]"></span>
</button>
{/if}
</div>
{/snippet}
<div class="rounded-sm overflow-hidden bg-layer-2 divide-y divide-outline outline-1 outline-outline">
{#each Object.entries(inputs ?? {}) as [key, input] (key)}
{@render row(input, () => removeRow(key))}
{/each}
{#if potentialRow}
<div class="opacity-80">
{@render row(potentialRow, () => removeRow(), () => realizePotentialRow())}
</div>
{:else}
<div class="opacity-40">
<div class="flex h-[27px]">
<div class="flex-1"></div>
<button
class="border-l hover:bg-green-300 hover:text-layer-1 border-outline py-1 px-2 cursor-pointer"
onclick={() => showPotentialRow()}
aria-label="remove"
>
<span class="block i-[tabler--circle-plus]"></span>
</button>
</div>
</div>
{/if}
</div>
+25
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import type { NodeInput } from '@nodarium/types';
import '$lib/app.css';
import {
Details,
@@ -11,6 +12,7 @@
JsonViewer,
ShortCut
} from '$lib';
import SocketTable from '$lib/inputs/SocketTable.svelte';
import Section from './Section.svelte';
import Theme from './Theme.svelte';
import ThemeSelector from './ThemeSelector.svelte';
@@ -38,6 +40,17 @@
settings: { seed: 42, enabled: true }
});
let socketTypes: Record<string, NodeInput> = $state({
input_0: {
'label': 'Input 0',
'type': 'path'
},
input_1: {
'label': 'Input 1',
'type': 'float'
}
});
function randomlyUpdateJson() {
const rand = Math.floor(Math.random() * 5);
if (rand === 0) {
@@ -150,6 +163,18 @@
</div>
</Section>
<Section title="Socket Table">
<SocketTable
colors={{
seed: '#f00',
float: '#0f0',
path: '#00f'
}}
types={['seed', 'float', 'path']}
bind:inputs={socketTypes}
/>
</Section>
<Section title="Shortcut">
<div class="flex gap-4">
<ShortCut ctrl key="S" />
+80 -6
View File
@@ -1,3 +1,60 @@
interface LogEntry {
time: string;
scope: string;
level: string;
args: unknown[];
}
const logBuffer: LogEntry[] = [];
const startTime = Date.now();
function formatTime(): string {
const ms = Date.now() - startTime;
const h = Math.floor(ms / 3600000).toString().padStart(2, '0');
const m = Math.floor((ms % 3600000) / 60000).toString().padStart(2, '0');
const s = Math.floor((ms % 60000) / 1000).toString().padStart(2, '0');
const mss = (ms % 1000).toString().padStart(3, '0');
return `${h}:${m}:${s}.${mss}`;
}
function serialize(arg: unknown): string {
if (typeof arg === 'string') return arg;
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
}
function formatEntry(entry: LogEntry, scopeWidth: number): string {
const scope = `[${entry.scope}]`.padEnd(scopeWidth + 2);
const level = entry.level.toUpperCase().padEnd(5);
const msg = entry.args.map(serialize).join(' ');
return `${entry.time} ${scope} ${level} ${msg}`;
}
(globalThis as Record<string, unknown>).copyLogs = () => {
if (logBuffer.length === 0) {
console.log('%c[logger] No log entries to copy', 'color: #888');
return;
}
const scopeWidth = logBuffer.reduce((max, e) => Math.max(max, e.scope.length), 0);
const lines = [
`=== Log Export (${logBuffer.length} entries) ===`,
'',
...logBuffer.map(e => formatEntry(e, scopeWidth))
].join('\n');
navigator.clipboard.writeText(lines).then(() => {
console.log(`%c[logger] Copied ${logBuffer.length} entries to clipboard`, 'color: #4f4');
});
};
(globalThis as Record<string, unknown>).clearLogs = () => {
logBuffer.length = 0;
console.log('%c[logger] Log buffer cleared', 'color: #888');
};
export const createLogger = (() => {
let maxLength = 5;
return (scope: string) => {
@@ -6,18 +63,35 @@ export const createLogger = (() => {
let isGrouped = false;
function s(color: string, ...args: any) {
function s(color: string, ...args: unknown[]) {
return isGrouped
? [...args]
: [`[%c${scope.padEnd(maxLength, ' ')}]:`, `color: ${color}`, ...args];
}
function record(level: string, args: unknown[]) {
logBuffer.push({ time: formatTime(), scope, level, args });
}
return {
log: (...args: any[]) => !muted && console.log(...s('#888', ...args)),
info: (...args: any[]) => !muted && console.info(...s('#888', ...args)),
warn: (...args: any[]) => !muted && console.warn(...s('#888', ...args)),
error: (...args: any[]) => console.error(...s('#f88', ...args)),
group: (...args: any[]) => {
log: (...args: unknown[]) => {
record('log', args);
!muted && console.log(...s('#888', ...args));
},
info: (...args: unknown[]) => {
record('info', args);
!muted && console.info(...s('#888', ...args));
},
warn: (...args: unknown[]) => {
record('warn', args);
!muted && console.warn(...s('#888', ...args));
},
error: (...args: unknown[]) => {
record('error', args);
console.error(...s('#f88', ...args));
},
group: (...args: unknown[]) => {
record('group', args);
if (!muted) {
console.groupCollapsed(...s('#888', ...args));
isGrouped = true;
+3
View File
@@ -290,6 +290,9 @@ importers:
'@types/eslint':
specifier: ^9.6.1
version: 9.6.1
'@types/node':
specifier: ^25.6.0
version: 25.6.0
'@types/three':
specifier: ^0.184.0
version: 0.184.0