Compare commits
19 Commits
106797de32
...
v0.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
82c2f08a56
|
|||
|
a00db400bb
|
|||
|
2d9eb0c087
|
|||
|
1e28ded99b
|
|||
|
5fae518392
|
|||
| 954f5726c3 | |||
|
63d5b8079d
|
|||
|
3e32ca419a
|
|||
|
f0cb12a088
|
|||
|
1d60090ffe
|
|||
|
5b55056fc1
|
|||
|
e2c2b1a4d7
|
|||
|
7f082ad8f6
|
|||
|
ed11195327
|
|||
|
8ad62cfc8e
|
|||
|
bff140a764
|
|||
|
85e2fd1a71
|
|||
|
5beb03196d
|
|||
|
83e0e47082
|
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 throttle from '$lib/helpers/throttle';
|
||||||
import { RemoteNodeRegistry } from '$lib/node-registry/index';
|
import { RemoteNodeRegistry } from '$lib/node-registry/index';
|
||||||
import type {
|
import type {
|
||||||
@@ -10,54 +11,27 @@ import type {
|
|||||||
NodeInput,
|
NodeInput,
|
||||||
NodeInstance,
|
NodeInstance,
|
||||||
NodeRegistry,
|
NodeRegistry,
|
||||||
|
SerializedEdge,
|
||||||
|
SerializedNode,
|
||||||
Socket
|
Socket
|
||||||
} from '@nodarium/types';
|
} from '@nodarium/types';
|
||||||
import { fastHashString } from '@nodarium/utils';
|
import { fastHashString } from '@nodarium/utils';
|
||||||
import { createLogger } from '@nodarium/utils';
|
import { createLogger } from '@nodarium/utils';
|
||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import EventEmitter from './helpers/EventEmitter';
|
import EventEmitter from './helpers/EventEmitter';
|
||||||
|
import {
|
||||||
|
areEdgesEqual,
|
||||||
|
areSocketsCompatible,
|
||||||
|
serializeEdge,
|
||||||
|
serializeNode
|
||||||
|
} from './helpers/nodeHelpers';
|
||||||
import { HistoryManager } from './history-manager';
|
import { HistoryManager } from './history-manager';
|
||||||
|
|
||||||
const logger = createLogger('graph-manager');
|
const log = createLogger('graph-manager');
|
||||||
logger.mute();
|
log.mute();
|
||||||
|
|
||||||
const remoteRegistry = new RemoteNodeRegistry('');
|
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<{
|
export class GraphManager extends EventEmitter<{
|
||||||
save: Graph;
|
save: Graph;
|
||||||
result: unknown;
|
result: unknown;
|
||||||
@@ -69,25 +43,26 @@ 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.
|
||||||
|
// 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);
|
id = $state(0);
|
||||||
|
meta = $state<Graph['meta']>({});
|
||||||
nodes = new SvelteMap<number, NodeInstance>();
|
nodes = new SvelteMap<number, NodeInstance>();
|
||||||
nodeArray = $derived(Array.from(this.nodes.values()));
|
|
||||||
|
|
||||||
edges = $state<Edge[]>([]);
|
edges = $state<Edge[]>([]);
|
||||||
|
groups: GroupDefinition[] = $state([]);
|
||||||
|
|
||||||
// Plain array — NOT $state. rootGraph items are plain-serialized (safe for structuredClone).
|
nodeArray = $derived(Array.from(this.nodes.values()));
|
||||||
// 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];
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
settingTypes: Record<string, NodeInput> = {};
|
settingTypes: Record<string, NodeInput> = {};
|
||||||
settings = $state<Record<string, unknown>>();
|
settings = $state<Record<string, unknown>>();
|
||||||
@@ -104,24 +79,9 @@ export class GraphManager extends EventEmitter<{
|
|||||||
|
|
||||||
history: HistoryManager = new HistoryManager();
|
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(() => {
|
execute = throttle(() => {
|
||||||
if (this.loaded === false) return;
|
if (this.loaded === false) return;
|
||||||
this.emit('result', this.serializeFullGraph());
|
this.emit('result', this.serialize());
|
||||||
}, 10);
|
}, 10);
|
||||||
|
|
||||||
constructor(public registry: NodeRegistry) {
|
constructor(public registry: NodeRegistry) {
|
||||||
@@ -129,47 +89,44 @@ export class GraphManager extends EventEmitter<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
serialize(): Graph {
|
serialize(): Graph {
|
||||||
const nodes = Array.from(this.nodes.values()).map((node) => ({
|
const nodes =
|
||||||
id: node.id,
|
(this.parentStack.length === 0 ? Array.from(this.nodes.values()) : this.parentStack[0].nodes)
|
||||||
position: [...node.position],
|
.map(n => serializeNode(n));
|
||||||
type: node.type,
|
const edges =
|
||||||
props: node.props
|
(this.parentStack.length === 0 ? Array.from(this.edges.values()) : this.parentStack[0].edges)
|
||||||
})) as NodeInstance[];
|
.map(e => serializeEdge(e));
|
||||||
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 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 {
|
return {
|
||||||
id: group.id,
|
id: group.id,
|
||||||
name: group.name,
|
name: group.name,
|
||||||
inputs: group.inputs,
|
inputs: group.inputs,
|
||||||
outputs: group.outputs,
|
outputs: group.outputs,
|
||||||
nodes: groupNodes,
|
nodes: groupNodes,
|
||||||
edges: group.edges
|
edges: groupEdges
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const serialized = {
|
const serialized = $state.snapshot({
|
||||||
id: this.graph.id,
|
id: this.id,
|
||||||
settings: $state.snapshot(this.settings),
|
settings: this.settings,
|
||||||
meta: $state.snapshot(this.graph.meta),
|
meta: this.meta,
|
||||||
groups,
|
groups,
|
||||||
nodes,
|
nodes,
|
||||||
edges
|
edges
|
||||||
};
|
});
|
||||||
logger.log('serializing graph', serialized);
|
log.log('serializing graph', serialized);
|
||||||
return clone($state.snapshot(serialized));
|
return clone(serialized);
|
||||||
}
|
}
|
||||||
|
|
||||||
private lastSettingsHash = 0;
|
private lastSettingsHash = 0;
|
||||||
@@ -233,7 +190,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!bestInputEntry || bestOutputIdx === -1) {
|
if (!bestInputEntry || bestOutputIdx === -1) {
|
||||||
logger.error('Could not find compatible sockets for drop');
|
log.error('Could not find compatible sockets for drop');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,21 +296,22 @@ export class GraphManager extends EventEmitter<{
|
|||||||
return edges;
|
return edges;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _init(graph: Graph) {
|
private _init(
|
||||||
const nodes = new SvelteMap(
|
graph: { nodes: SerializedNode[]; edges: SerializedEdge[] }
|
||||||
graph.nodes.map((node) => {
|
) {
|
||||||
const n = node as NodeInstance;
|
this.nodes.clear();
|
||||||
const registryType = this.registry.getNode(node.type);
|
for (const node of graph.nodes) {
|
||||||
n.state = registryType ? { type: registryType } : {};
|
const n = $state(node) as NodeInstance;
|
||||||
const resolvedType = this.getNodeType(n);
|
const registryType = this.registry.getNode(node.type);
|
||||||
if (resolvedType) n.state = { type: resolvedType };
|
n.state = registryType ? { type: registryType } : {};
|
||||||
return [node.id, n];
|
const resolvedType = this.getNodeType(n);
|
||||||
})
|
if (resolvedType) n.state = { type: resolvedType };
|
||||||
);
|
this.nodes.set(n.id, n);
|
||||||
|
}
|
||||||
|
|
||||||
this.edges = graph.edges.map((edge) => {
|
this.edges = graph.edges.map((edge) => {
|
||||||
const from = nodes.get(edge[0]);
|
const from = this.nodes.get(edge[0]);
|
||||||
const to = nodes.get(edge[2]);
|
const to = this.nodes.get(edge[2]);
|
||||||
if (!from || !to) {
|
if (!from || !to) {
|
||||||
throw new Error('Edge references non-existing node');
|
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;
|
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();
|
this.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,11 +330,12 @@ export class GraphManager extends EventEmitter<{
|
|||||||
|
|
||||||
this.loaded = false;
|
this.loaded = false;
|
||||||
graph.groups ??= [];
|
graph.groups ??= [];
|
||||||
this.graph = graph;
|
this.meta = graph.meta;
|
||||||
|
this.groups = graph.groups;
|
||||||
this.status = 'loading';
|
this.status = 'loading';
|
||||||
this.id = graph.id;
|
this.id = graph.id;
|
||||||
|
|
||||||
logger.info(
|
log.info(
|
||||||
'loading graph',
|
'loading graph',
|
||||||
{ nodes: graph.nodes, edges: graph.edges, id: graph.id }
|
{ nodes: graph.nodes, edges: graph.edges, id: graph.id }
|
||||||
);
|
);
|
||||||
@@ -396,8 +350,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
.filter(n => n && 'type' in n)
|
.filter(n => n && 'type' in n)
|
||||||
.map((n) => n.type)
|
.map((n) => n.type)
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
.filter(n => !n.startsWith('__internal/'));
|
|
||||||
|
|
||||||
await this.registry.load(nodeIds);
|
await this.registry.load(nodeIds);
|
||||||
|
|
||||||
@@ -417,20 +370,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('loaded node types', this.registry.getAllNodes());
|
log.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// load settings
|
// load settings
|
||||||
const settingTypes: Record<
|
const settingTypes: Record<
|
||||||
@@ -461,18 +401,18 @@ export class GraphManager extends EventEmitter<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.parentStack = [];
|
||||||
|
this.currentGroupId = null;
|
||||||
|
|
||||||
this.settings = settingValues;
|
this.settings = settingValues;
|
||||||
this.emit('settings', { types: settingTypes, values: settingValues });
|
this.emit('settings', { types: settingTypes, values: settingValues });
|
||||||
|
|
||||||
this.history.reset();
|
this.history.reset();
|
||||||
this._init(this.graph);
|
this._init(graph);
|
||||||
|
|
||||||
this.save();
|
this.save();
|
||||||
|
|
||||||
this.status = 'idle';
|
this.status = 'idle';
|
||||||
|
|
||||||
this.loaded = true;
|
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);
|
setTimeout(() => this.execute(), 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,19 +432,15 @@ export class GraphManager extends EventEmitter<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === '__internal/group/input') {
|
if (node.type === '__internal/group/input') {
|
||||||
const groupId = this.graphStack.at(-1)?.groupId;
|
const group = this.currentGroupId !== null ? this.getGroup(this.currentGroupId) : undefined;
|
||||||
const group = groupId !== undefined ? this.getGroup(groupId) : undefined;
|
|
||||||
if (!group) return node.state.type;
|
if (!group) return node.state.type;
|
||||||
|
|
||||||
const groupInputs: NodeDefinition['inputs'] = Object.fromEntries(
|
const groupInputs: NodeDefinition['inputs'] = Object.fromEntries(
|
||||||
Object.values(group?.inputs || {}).map((o, i) => {
|
Object.values(group?.inputs || {}).map((o, i) => {
|
||||||
return [
|
return [`in_${i}`, {
|
||||||
`in_${i}`,
|
...o,
|
||||||
{
|
external: true
|
||||||
...o,
|
}];
|
||||||
external: true
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}) || []
|
}) || []
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -518,8 +454,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === '__internal/group/output') {
|
if (node.type === '__internal/group/output') {
|
||||||
const groupId = this.graphStack.at(-1)?.groupId;
|
const group = this.currentGroupId !== null ? this.getGroup(this.currentGroupId) : undefined;
|
||||||
const group = groupId !== undefined ? this.getGroup(groupId) : undefined;
|
|
||||||
if (!group) return node.state.type;
|
if (!group) return node.state.type;
|
||||||
return {
|
return {
|
||||||
id: '__internal/group/output' as NodeId,
|
id: '__internal/group/output' as NodeId,
|
||||||
@@ -539,10 +474,39 @@ 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) {
|
||||||
logger.error(`Group not found: ${node.props?.groupId}`);
|
log.error(`Group not found: ${node.props?.groupId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,18 +515,24 @@ export class GraphManager extends EventEmitter<{
|
|||||||
...groupDefinition?.inputs
|
...groupDefinition?.inputs
|
||||||
};
|
};
|
||||||
|
|
||||||
// This is to make sure the the groupId is always first
|
|
||||||
delete defaultInputs['groupId'];
|
delete defaultInputs['groupId'];
|
||||||
|
|
||||||
const inputs = {
|
const inputs = {
|
||||||
'groupId': {
|
'groupId': {
|
||||||
type: 'select',
|
type: 'select',
|
||||||
label: 'Group',
|
label: '',
|
||||||
value: node.props?.groupId,
|
value: node.props?.groupId,
|
||||||
internal: true,
|
internal: true,
|
||||||
options: this.graph.groups.map((g, i) => ({
|
options: this.groups.map((g) => ({
|
||||||
value: g.id,
|
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
|
...defaultInputs
|
||||||
};
|
};
|
||||||
@@ -642,6 +612,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeNode(node: NodeInstance, { restoreEdges = false } = {}) {
|
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 edgesToNode = this.edges.filter((edge) => edge[2].id === node.id);
|
||||||
const edgesFromNode = this.edges.filter((edge) => edge[0].id === node.id);
|
const edgesFromNode = this.edges.filter((edge) => edge[0].id === node.id);
|
||||||
for (const edge of [...edgesToNode, ...edgesFromNode]) {
|
for (const edge of [...edgesToNode, ...edgesFromNode]) {
|
||||||
@@ -686,82 +657,71 @@ export class GraphManager extends EventEmitter<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
getGroup(id: number) {
|
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) {
|
renameGroup(groupId: number, name: string) {
|
||||||
|
log.log('renaming group', { groupId, name });
|
||||||
const group = this.getGroup(groupId);
|
const group = this.getGroup(groupId);
|
||||||
if (!group) return;
|
if (!group) return;
|
||||||
group.name = name;
|
group.name = name;
|
||||||
this.save();
|
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);
|
const groupNode = this.getNode(nodeId);
|
||||||
if (!groupNode || groupNode.type !== '__internal/group/instance') return false;
|
if (!groupNode || groupNode.type !== '__internal/group/instance') return false;
|
||||||
|
|
||||||
|
log.log('entering group', { nodeId });
|
||||||
|
|
||||||
const groupId = groupNode.props?.groupId as number;
|
const groupId = groupNode.props?.groupId as number;
|
||||||
const group = this.getGroup(groupId);
|
const group = this.getGroup(groupId);
|
||||||
if (!group) return false;
|
if (!group) return false;
|
||||||
|
|
||||||
this.graphStack.push({
|
// Snapshot current level and push it onto the parent stack.
|
||||||
rootGraph: this.serialize(),
|
this.parentStack.push({
|
||||||
savedNodes: new SvelteMap(this.nodes),
|
id: this.currentGroupId ?? this.id,
|
||||||
savedEdges: [...this.edges],
|
nodes: [...this.nodes.values()].map(n => serializeNode(n)),
|
||||||
outerGraph: this.graph,
|
edges: [...this.edges.values()].map(e => serializeEdge(e)),
|
||||||
groupId,
|
nodeId
|
||||||
nodeId,
|
|
||||||
cameraPosition
|
|
||||||
});
|
});
|
||||||
this.graph = { ...this.graph, nodes: group.nodes, edges: group.edges };
|
this.currentGroupId = groupId;
|
||||||
this._init(this.graph);
|
|
||||||
|
log.log('entered group', { groupId, depth: this.parentStack.length });
|
||||||
this.history.reset();
|
this.history.reset();
|
||||||
this.isInsideGroup = true;
|
this._init(group);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
exitGroup(): { camera: [number, number, number]; nodeId: number } | false {
|
exitGroup() {
|
||||||
if (!this.graphStack.length) return false;
|
log.log('exiting group', { depth: this.parentStack.length });
|
||||||
const { savedNodes, savedEdges, outerGraph, groupId, nodeId, cameraPosition } = this.graphStack
|
if (this.parentStack.length === 0) return;
|
||||||
.pop()!;
|
|
||||||
const internalState = this.serialize();
|
|
||||||
|
|
||||||
// Clear stale DOM/mesh refs so the remounting components register fresh ones.
|
// Persist live edits back to the GroupDefinition.
|
||||||
// The $effect guards in NodeHTML/Node only set these when undefined, so without
|
const group = this.getGroup(this.currentGroupId!);
|
||||||
// this clear they'd keep pointing to the detached elements from before group entry.
|
if (group) {
|
||||||
for (const node of savedNodes.values()) {
|
group.nodes = [...this.nodes.values()].map(n => serializeNode(n));
|
||||||
node.state.ref = undefined;
|
group.edges = [...this.edges.values()].map(e => serializeEdge(e));
|
||||||
node.state.mesh = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore live reactive nodes and edges so drag-reactivity is preserved
|
const parent = this.parentStack.pop()!;
|
||||||
this.nodes.clear();
|
this.currentGroupId = this.parentStack.length === 0 ? null : parent.id;
|
||||||
for (const [id, node] of savedNodes) {
|
this._init(parent);
|
||||||
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
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
this.history.reset();
|
this.history.reset();
|
||||||
this.isInsideGroup = this.graphStack.length > 0;
|
|
||||||
this.execute();
|
this.execute();
|
||||||
this.save();
|
this.save();
|
||||||
return { camera: cameraPosition, nodeId };
|
|
||||||
|
return { nodeId: parent.nodeId };
|
||||||
}
|
}
|
||||||
|
|
||||||
createNodeId() {
|
createNodeId() {
|
||||||
const ids = [
|
const ids = [
|
||||||
...this.nodes.keys(),
|
...this.nodes.keys(),
|
||||||
...this.graph.groups.map(g => g.id),
|
...this.groups.map(g => g.id),
|
||||||
...this.graph.groups.flatMap(g => g.nodes.map(n => n.id))
|
...this.groups.flatMap(g => g.nodes.map(n => n.id))
|
||||||
];
|
];
|
||||||
|
|
||||||
let id = 0;
|
let id = 0;
|
||||||
@@ -814,12 +774,59 @@ export class GraphManager extends EventEmitter<{
|
|||||||
return nodes;
|
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() {
|
removeUnusedGroups() {
|
||||||
const usedGroups = new SvelteSet(this.getAllNodes().map(n => n.props?.groupId));
|
const unused = this.getUnusedGroups();
|
||||||
const unusedGroupAmount = this.graph.groups.length - usedGroups.size;
|
const unusedIds = new SvelteSet(unused.map(g => g.id));
|
||||||
this.graph.groups = this.graph.groups.filter(g => usedGroups.has(g.id));
|
this.groups = this.groups.filter(g => !unusedIds.has(g.id));
|
||||||
this.save();
|
this.save();
|
||||||
return unusedGroupAmount;
|
return unused.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
groupNodes(nodeIds: number[]) {
|
groupNodes(nodeIds: number[]) {
|
||||||
@@ -832,7 +839,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
|
|
||||||
if (!nodes.length) return;
|
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));
|
const ids = new SvelteSet(nodes.map(n => n.id));
|
||||||
|
|
||||||
@@ -856,17 +863,25 @@ 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;
|
||||||
});
|
});
|
||||||
|
|
||||||
const outputs = [...groupOutputs.values()].map((edge, i) => ({
|
const outputs = [];
|
||||||
label: `Output ${i}`,
|
if (groupOutputs.size) {
|
||||||
type: edge[2].state.type?.inputs?.[edge[3]].type || '*'
|
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 groupPosition = [0, 0] as [number, number];
|
||||||
const bounds: Box = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };
|
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.
|
// Allocate all needed IDs up front so sequential calls never collide.
|
||||||
const usedIds = new SvelteSet<number>([
|
const usedIds = new SvelteSet<number>([
|
||||||
...this.nodes.keys(),
|
...this.nodes.keys(),
|
||||||
...this.graph.groups.map(g => g.id),
|
...this.groups.map(g => g.id),
|
||||||
...this.graph.groups.flatMap(g => g.nodes.map(n => n.id))
|
...this.groups.flatMap(g => g.nodes.map(n => n.id))
|
||||||
]);
|
]);
|
||||||
const nextId = () => {
|
const nextId = () => {
|
||||||
let id = 0;
|
let id = 0;
|
||||||
@@ -937,7 +952,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Push before createNode so createNodeId() inside sees the allocated IDs.
|
// Push before createNode so createNodeId() inside sees the allocated IDs.
|
||||||
this.graph.groups.push(groupDefinition);
|
this.groups.push(groupDefinition);
|
||||||
|
|
||||||
const groupNode = this.createNode({
|
const groupNode = this.createNode({
|
||||||
type: '__internal/group/instance',
|
type: '__internal/group/instance',
|
||||||
@@ -973,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,
|
||||||
@@ -984,7 +1117,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
}) {
|
}) {
|
||||||
const nodeType = this.registry.getNode(type);
|
const nodeType = this.registry.getNode(type);
|
||||||
if (!nodeType && !type.startsWith('__internal/')) {
|
if (!nodeType && !type.startsWith('__internal/')) {
|
||||||
logger.error(`Node type not found: ${type}`);
|
log.error(`Node type not found: ${type}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -996,6 +1129,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
props
|
props
|
||||||
});
|
});
|
||||||
|
|
||||||
|
log.log('creating node', { id: node.id, type, position, props });
|
||||||
this.nodes.set(node.id, node);
|
this.nodes.set(node.id, node);
|
||||||
|
|
||||||
this.save();
|
this.save();
|
||||||
@@ -1017,7 +1151,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
(e) => e[0].id === from.id && e[1] === fromSocket && e[3] === toSocket
|
(e) => e[0].id === from.id && e[1] === fromSocket && e[3] === toSocket
|
||||||
);
|
);
|
||||||
if (existingEdge) {
|
if (existingEdge) {
|
||||||
logger.error('Edge already exists', existingEdge);
|
log.error('Edge already exists', existingEdge);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1034,7 +1168,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!areSocketsCompatible(fromSocketType, toSocketType)) {
|
if (!areSocketsCompatible(fromSocketType, toSocketType)) {
|
||||||
logger.error(
|
log.error(
|
||||||
`Socket types do not match: ${fromSocketType} !== ${toSocketType}`
|
`Socket types do not match: ${fromSocketType} !== ${toSocketType}`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -1049,6 +1183,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
|
|
||||||
const edge = [from, fromSocket, to, toSocket] as Edge;
|
const edge = [from, fromSocket, to, toSocket] as Edge;
|
||||||
|
|
||||||
|
log.log('creating edge', { from: from.id, fromSocket, to: to.id, toSocket });
|
||||||
this.edges.push(edge);
|
this.edges.push(edge);
|
||||||
|
|
||||||
from.state.children = from.state.children || [];
|
from.state.children = from.state.children || [];
|
||||||
@@ -1065,6 +1200,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
undo() {
|
undo() {
|
||||||
|
log.log('undo');
|
||||||
const nextState = this.history.undo();
|
const nextState = this.history.undo();
|
||||||
if (nextState) {
|
if (nextState) {
|
||||||
this._init(nextState);
|
this._init(nextState);
|
||||||
@@ -1073,6 +1209,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
redo() {
|
redo() {
|
||||||
|
log.log('redo');
|
||||||
const nextState = this.history.redo();
|
const nextState = this.history.redo();
|
||||||
if (nextState) {
|
if (nextState) {
|
||||||
this._init(nextState);
|
this._init(nextState);
|
||||||
@@ -1100,9 +1237,9 @@ export class GraphManager extends EventEmitter<{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullState = this.graphStack.length > 0 ? this.serializeFullGraph() : state;
|
const fullState = this.serialize();
|
||||||
this.emit('save', fullState);
|
this.emit('save', fullState);
|
||||||
logger.log('saving graphs', fullState);
|
log.log('saving graphs', fullState);
|
||||||
}
|
}
|
||||||
|
|
||||||
getParentsOfNode(node: NodeInstance) {
|
getParentsOfNode(node: NodeInstance) {
|
||||||
@@ -1110,7 +1247,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
const stack = node.state?.parents?.slice(0);
|
const stack = node.state?.parents?.slice(0);
|
||||||
while (stack?.length) {
|
while (stack?.length) {
|
||||||
if (parents.length > 1000000) {
|
if (parents.length > 1000000) {
|
||||||
logger.warn('Infinite loop detected');
|
log.warn('Infinite loop detected');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const parent = stack.pop();
|
const parent = stack.pop();
|
||||||
@@ -1227,6 +1364,12 @@ export class GraphManager extends EventEmitter<{
|
|||||||
edge: Edge,
|
edge: Edge,
|
||||||
{ applyDeletion = true }: { applyDeletion?: boolean } = {}
|
{ 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 id0 = edge[0].id;
|
||||||
const sid0 = edge[1];
|
const sid0 = edge[1];
|
||||||
const id2 = edge[2].id;
|
const id2 = edge[2].id;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ describe('enterGroupNode', () => {
|
|||||||
const { manager, state } = createFixture();
|
const { manager, state } = createFixture();
|
||||||
state.activeNodeId = -1;
|
state.activeNodeId = -1;
|
||||||
state.enterGroupNode();
|
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', () => {
|
it('does nothing when the active node is not a group instance', () => {
|
||||||
@@ -106,7 +106,7 @@ describe('enterGroupNode', () => {
|
|||||||
assert.isDefined(node);
|
assert.isDefined(node);
|
||||||
state.activeNodeId = node!.id;
|
state.activeNodeId = node!.id;
|
||||||
state.enterGroupNode();
|
state.enterGroupNode();
|
||||||
expect(manager.graphStack.length).toBe(0);
|
expect(manager.parentStack.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enters the group, pushes graphStack, and clears UI state', () => {
|
it('enters the group, pushes graphStack, and clears UI state', () => {
|
||||||
@@ -123,7 +123,7 @@ describe('enterGroupNode', () => {
|
|||||||
|
|
||||||
state.enterGroupNode();
|
state.enterGroupNode();
|
||||||
|
|
||||||
expect(manager.graphStack.length).toBe(1);
|
expect(manager.parentStack.length).toBe(1);
|
||||||
expect(state.activeNodeId).toBe(-1);
|
expect(state.activeNodeId).toBe(-1);
|
||||||
expect(state.selectedNodes.size).toBe(0);
|
expect(state.selectedNodes.size).toBe(0);
|
||||||
expect(manager.isInsideGroup).toBe(true);
|
expect(manager.isInsideGroup).toBe(true);
|
||||||
@@ -135,30 +135,10 @@ describe('exitGroupNode', () => {
|
|||||||
const { manager, state } = createFixture();
|
const { manager, state } = createFixture();
|
||||||
const before = [...state.cameraPosition];
|
const before = [...state.cameraPosition];
|
||||||
state.exitGroupNode();
|
state.exitGroupNode();
|
||||||
expect(manager.graphStack.length).toBe(0);
|
expect(manager.parentStack.length).toBe(0);
|
||||||
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]);
|
||||||
}
|
}
|
||||||
@@ -365,7 +369,7 @@ export class GraphState {
|
|||||||
if (this.activeNodeId === -1) return;
|
if (this.activeNodeId === -1) return;
|
||||||
const node = this.graph.getNode(this.activeNodeId);
|
const node = this.graph.getNode(this.activeNodeId);
|
||||||
if (!node || node.type !== '__internal/group/instance') return;
|
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) {
|
if (ok) {
|
||||||
this.activeNodeId = -1;
|
this.activeNodeId = -1;
|
||||||
this.clearSelection();
|
this.clearSelection();
|
||||||
@@ -375,7 +379,6 @@ export class GraphState {
|
|||||||
exitGroupNode() {
|
exitGroupNode() {
|
||||||
const result = this.graph.exitGroup();
|
const result = this.graph.exitGroup();
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
this.cameraPosition = result.camera;
|
|
||||||
this.activeNodeId = result.nodeId;
|
this.activeNodeId = result.nodeId;
|
||||||
this.clearSelection();
|
this.clearSelection();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import AddMenu from '../components/AddMenu.svelte';
|
import AddMenu from '../components/AddMenu.svelte';
|
||||||
import BoxSelection from '../components/BoxSelection.svelte';
|
import BoxSelection from '../components/BoxSelection.svelte';
|
||||||
import Camera from '../components/Camera.svelte';
|
import Camera from '../components/Camera.svelte';
|
||||||
|
import GroupBreadcrumps from '../components/GroupBreadcrumps.svelte';
|
||||||
import HelpView from '../components/HelpView.svelte';
|
import HelpView from '../components/HelpView.svelte';
|
||||||
import Debug from '../debug/Debug.svelte';
|
import Debug from '../debug/Debug.svelte';
|
||||||
import EdgeEl from '../edges/Edge.svelte';
|
import EdgeEl from '../edges/Edge.svelte';
|
||||||
@@ -108,14 +109,6 @@
|
|||||||
|
|
||||||
return nodeType?.outputs?.[index] || 'unknown';
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
@@ -142,7 +135,6 @@
|
|||||||
oncontextmenu={(ev) => mouseEvents.handleContextMenu(ev)}
|
oncontextmenu={(ev) => mouseEvents.handleContextMenu(ev)}
|
||||||
{...fileDropEvents.getEventListenerProps()}
|
{...fileDropEvents.getEventListenerProps()}
|
||||||
>
|
>
|
||||||
<div class="shadow"></div>
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="application/wasm,application/json"
|
accept="application/wasm,application/json"
|
||||||
@@ -153,14 +145,7 @@
|
|||||||
/>
|
/>
|
||||||
<label for="drop-zone"></label>
|
<label for="drop-zone"></label>
|
||||||
|
|
||||||
{#if graph.isInsideGroup}
|
<GroupBreadcrumps />
|
||||||
<button class="exit-group" onclick={() => graphState.exitGroupNode()}>
|
|
||||||
↑ Exit Group
|
|
||||||
</button>
|
|
||||||
<p class="group-name absolute">
|
|
||||||
Group <b>{getGroupName()}</b>
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Canvas shadows={false} renderMode="on-demand" colorManagementEnabled={false}>
|
<Canvas shadows={false} renderMode="on-demand" colorManagementEnabled={false}>
|
||||||
<Camera
|
<Camera
|
||||||
@@ -270,34 +255,6 @@
|
|||||||
height: 100%;
|
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 {
|
.wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
@@ -309,22 +266,6 @@
|
|||||||
cursor: pointer;
|
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 {
|
.is-panning {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createKeyMap } from '$lib/helpers/createKeyMap';
|
import { createKeyMap } from '$lib/helpers/createKeyMap';
|
||||||
import type { Graph, NodeInstance, NodeRegistry } from '@nodarium/types';
|
import type { Graph, NodeInstance, NodeRegistry } from '@nodarium/types';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { GraphManager } from '../graph-manager.svelte';
|
import { GraphManager } from '../graph-manager.svelte';
|
||||||
import { GraphState, setGraphManager, setGraphState } from '../graph-state.svelte';
|
import { GraphState, setGraphManager, setGraphState } from '../graph-state.svelte';
|
||||||
import { setupKeymaps } from '../keymaps';
|
import { setupKeymaps } from '../keymaps';
|
||||||
@@ -83,8 +84,8 @@
|
|||||||
|
|
||||||
manager.on('save', (save) => onsave?.(save));
|
manager.on('save', (save) => onsave?.(save));
|
||||||
|
|
||||||
$effect(() => {
|
onMount(() => {
|
||||||
if (graph && (manager.status !== 'idle' || manager.graph.id !== graph.id)) {
|
if (graph) {
|
||||||
manager.load(graph);
|
manager.load(graph);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ type Color = { hue: number; saturation: number; lightness: number };
|
|||||||
|
|
||||||
export class ColorGenerator {
|
export class ColorGenerator {
|
||||||
private colors: Map<string, Color> = new Map();
|
private colors: Map<string, Color> = new Map();
|
||||||
private lightnessLevels = [10, 60];
|
// private lightnessLevels = [10, 60];
|
||||||
|
|
||||||
constructor(predefined: Record<string, Color>) {
|
constructor(predefined: Record<string, Color>) {
|
||||||
for (const [id, colorStr] of Object.entries(predefined)) {
|
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 {
|
public getColor(id: string): string {
|
||||||
if (this.colors.has(id)) {
|
if (this.colors.has(id)) {
|
||||||
return this.colorToHsl(this.colors.get(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) {
|
export function getParameterHeight(node: NodeDefinition, inputKey: string) {
|
||||||
if (node.id === '__internal/group/input') {
|
if (node.id === '__internal/group/input') {
|
||||||
@@ -27,6 +33,23 @@ export function getParameterHeight(node: NodeDefinition, inputKey: string) {
|
|||||||
return 50;
|
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> = {};
|
const nodeHeightCache: Record<string, number> = {};
|
||||||
export function getNodeHeight(node: NodeDefinition) {
|
export function getNodeHeight(node: NodeDefinition) {
|
||||||
if (!node || !('inputs' in node)) {
|
if (!node || !('inputs' in node)) {
|
||||||
@@ -45,3 +68,34 @@ export function getNodeHeight(node: NodeDefinition) {
|
|||||||
nodeHeightCache[node.id] = height;
|
nodeHeightCache[node.id] = height;
|
||||||
return 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -26,6 +26,13 @@
|
|||||||
const rightBump = $derived(
|
const rightBump = $derived(
|
||||||
!!nodeType?.outputs?.length && node.type !== '__internal/group/input'
|
!!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;
|
const aspectRatio = 0.25;
|
||||||
|
|
||||||
@@ -35,6 +42,7 @@
|
|||||||
height: 34,
|
height: 34,
|
||||||
y: 49,
|
y: 49,
|
||||||
cornerTop,
|
cornerTop,
|
||||||
|
cornerBottom,
|
||||||
rightBump,
|
rightBump,
|
||||||
aspectRatio
|
aspectRatio
|
||||||
})
|
})
|
||||||
@@ -45,6 +53,7 @@
|
|||||||
height: 40,
|
height: 40,
|
||||||
y: 49,
|
y: 49,
|
||||||
cornerTop,
|
cornerTop,
|
||||||
|
cornerBottom,
|
||||||
rightBump,
|
rightBump,
|
||||||
aspectRatio
|
aspectRatio
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
export const debugNode = {
|
export const debugNode = {
|
||||||
id: '__internal/debug/instance',
|
id: '__internal/node/debug',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'Debug'
|
title: 'Debug'
|
||||||
},
|
},
|
||||||
inputs: {
|
inputs: {
|
||||||
input: {
|
input: {
|
||||||
type: '*'
|
type: '*',
|
||||||
|
label: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
execute(_data: Int32Array): Int32Array {
|
execute(_data: Int32Array): Int32Array {
|
||||||
|
|||||||
@@ -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: []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,26 @@ log.mute();
|
|||||||
export function expandGroups(graph: Graph): Graph {
|
export function expandGroups(graph: Graph): Graph {
|
||||||
if (!graph.groups || graph.groups.length === 0) return 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];
|
const nodes = [...graph.nodes];
|
||||||
let edges = [...graph.edges];
|
let edges = [...graph.edges];
|
||||||
|
|
||||||
@@ -57,10 +77,16 @@ export function expandGroups(graph: Graph): Graph {
|
|||||||
const newEdges: Graph['edges'] = [];
|
const newEdges: Graph['edges'] = [];
|
||||||
|
|
||||||
// external_source → [inputBoundary →] internal_target
|
// 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) {
|
if (inputBoundary) {
|
||||||
const fromInput = group.edges.filter(e => e[0] === inputBoundary.id);
|
const fromInput = group.edges.filter(e => e[0] === inputBoundary.id);
|
||||||
for (const extEdge of incomingExternal) {
|
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]);
|
const toId = idMap.get(intEdge[2]);
|
||||||
if (toId !== undefined) newEdges.push([extEdge[0], extEdge[1], toId, intEdge[3]]);
|
if (toId !== undefined) newEdges.push([extEdge[0], extEdge[1], toId, intEdge[3]]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,6 +204,13 @@
|
|||||||
|
|
||||||
.input-boolean > label {
|
.input-boolean > label {
|
||||||
order: 2;
|
order: 2;
|
||||||
|
font-size: 1em;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.first-level.input {
|
.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">
|
<script lang="ts">
|
||||||
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
|
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
|
||||||
import type { NodeInstance } from '@nodarium/types';
|
import NestedSettings from '$lib/settings/NestedSettings.svelte';
|
||||||
import ActiveNodeSelected from './ActiveNodeSelected.svelte';
|
import type { NodeId, NodeInput, NodeInstance } from '@nodarium/types';
|
||||||
|
|
||||||
|
type InternalNodeInput = NodeInput & {
|
||||||
|
__node_type?: NodeId;
|
||||||
|
__node_input: string;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
manager: GraphManager;
|
manager: GraphManager;
|
||||||
node: NodeInstance | undefined;
|
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');
|
const isGroupInstance = $derived(node?.type === '__internal/group/instance');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (store) {
|
||||||
|
updateNode();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</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'>
|
<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>
|
<h3>Node Settings</h3>
|
||||||
</div>
|
</div>
|
||||||
<ActiveNodeSelected {manager} bind:node />
|
<NestedSettings
|
||||||
|
id="activeNodeSettings"
|
||||||
|
bind:value={store}
|
||||||
|
type={nodeDefinition}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
|
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 type { NodeInstance } from '@nodarium/types';
|
||||||
|
import { SocketTable } from '@nodarium/ui';
|
||||||
|
import UnusedGroupsPanel from './UnusedGroupsPanel.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
manager: GraphManager;
|
manager: GraphManager;
|
||||||
|
graphState: GraphState;
|
||||||
node?: NodeInstance;
|
node?: NodeInstance;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { manager, node = $bindable() }: Props = $props();
|
const { manager, graphState, node = $bindable() }: Props = $props();
|
||||||
|
|
||||||
const activeGroup = $derived.by(() => {
|
const activeGroup = $derived.by(() => {
|
||||||
console.log('isInsideGroup', manager?.isInsideGroup);
|
if (node?.type === '__internal/group/instance') {
|
||||||
if (manager?.isInsideGroup) {
|
let group = manager.getGroup(node.props?.groupId as number);
|
||||||
const activeGroupId = manager.graphStack?.at(-1)?.groupId;
|
if (group) return group;
|
||||||
console.log('activeGroupId', activeGroupId);
|
|
||||||
if (activeGroupId !== undefined) {
|
|
||||||
return manager.getGroup(activeGroupId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node?.type === '__internal/group/instance') {
|
if (manager?.isInsideGroup && manager.currentGroupId !== null) {
|
||||||
return manager.getGroup(node.props?.groupId as number);
|
return manager.getGroup(manager.currentGroupId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,17 +30,49 @@
|
|||||||
if (activeGroup) manager.renameGroup(activeGroup.id, name);
|
if (activeGroup) manager.renameGroup(activeGroup.id, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasUnusedGroups = $derived.by(() => {
|
function handleRemoveInput(key: string) {
|
||||||
if (!manager) return false;
|
if (!activeGroup) return;
|
||||||
if (manager.isInsideGroup) return false;
|
const group = manager.getGroup(activeGroup?.id);
|
||||||
if (manager.graph.groups.length === 0) return false;
|
const inputs = $state.snapshot(group?.inputs ?? {});
|
||||||
return manager.graph.groups.filter(g => {
|
delete inputs[key];
|
||||||
return !manager.graph.nodes.find(n => n.props?.groupId === g.id);
|
activeGroup.inputs = inputs;
|
||||||
}).length;
|
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>
|
</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'>
|
<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>
|
<h3>Group Settings</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,7 +80,7 @@
|
|||||||
|
|
||||||
{#if activeGroup}
|
{#if activeGroup}
|
||||||
{#key activeGroup.id}
|
{#key activeGroup.id}
|
||||||
<div class="group-settings">
|
<div class="p-4 group-settings">
|
||||||
<label for="group-name">Group name</label>
|
<label for="group-name">Group name</label>
|
||||||
<input
|
<input
|
||||||
id="group-name"
|
id="group-name"
|
||||||
@@ -57,16 +89,43 @@
|
|||||||
value={groupName}
|
value={groupName}
|
||||||
oninput={handleRename}
|
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>
|
</div>
|
||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasUnusedGroups}
|
{#if manager && !manager.isInsideGroup}
|
||||||
<div class="group-actions">
|
<UnusedGroupsPanel {manager} />
|
||||||
<button onclick={() => manager.removeUnusedGroups()}>
|
|
||||||
Remove ({hasUnusedGroups}) orphaned groups
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -74,10 +133,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4em;
|
gap: 0.4em;
|
||||||
padding: 1em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-settings label {
|
label {
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
@@ -95,26 +153,4 @@
|
|||||||
.group-settings input:focus {
|
.group-settings input:focus {
|
||||||
outline: 1px solid var(--color-active);
|
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>
|
</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>
|
||||||
+30
-21
@@ -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);
|
||||||
@@ -95,7 +96,7 @@
|
|||||||
randomSeed: { type: 'boolean', value: false }
|
randomSeed: { type: 'boolean', value: false }
|
||||||
});
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (graphSettings && graphSettingTypes) {
|
if (graphSettings && graphSettingTypes && manager?.loaded) {
|
||||||
manager?.setSettings($state.snapshot(graphSettings));
|
manager?.setSettings($state.snapshot(graphSettings));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -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'
|
||||||
@@ -255,20 +258,22 @@
|
|||||||
</Grid.Cell>
|
</Grid.Cell>
|
||||||
<Grid.Cell>
|
<Grid.Cell>
|
||||||
{#if pm.graph}
|
{#if pm.graph}
|
||||||
<GraphInterface
|
{#key pm.graph.id}
|
||||||
graph={pm.graph}
|
<GraphInterface
|
||||||
bind:this={graphInterface}
|
graph={pm.graph}
|
||||||
registry={nodeRegistry}
|
bind:this={graphInterface}
|
||||||
safePadding={{ right: sidebarOpen ? 320 : undefined }}
|
registry={nodeRegistry}
|
||||||
backgroundType={appSettings.value.nodeInterface.backgroundType}
|
safePadding={{ right: sidebarOpen ? 321 : undefined }}
|
||||||
snapToGrid={appSettings.value.nodeInterface.snapToGrid}
|
backgroundType={appSettings.value.nodeInterface.backgroundType}
|
||||||
bind:activeNode
|
snapToGrid={appSettings.value.nodeInterface.snapToGrid}
|
||||||
bind:showHelp={appSettings.value.nodeInterface.showHelp}
|
bind:activeNode
|
||||||
bind:settings={graphSettings}
|
bind:showHelp={appSettings.value.nodeInterface.showHelp}
|
||||||
bind:settingTypes={graphSettingTypes}
|
bind:settings={graphSettings}
|
||||||
onsave={(g) => pm.saveGraph(g)}
|
bind:settingTypes={graphSettingTypes}
|
||||||
onresult={(result) => handleUpdate(result as Graph)}
|
onsave={(g) => pm.saveGraph(g)}
|
||||||
/>
|
onresult={(result) => handleUpdate(result as Graph)}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
<Sidebar bind:open={sidebarOpen}>
|
<Sidebar bind:open={sidebarOpen}>
|
||||||
<Panel id="general" title="General" icon="i-[tabler--settings]">
|
<Panel id="general" title="General" icon="i-[tabler--settings]">
|
||||||
@@ -322,7 +327,9 @@
|
|||||||
hidden={!appSettings.value.debug.advancedMode}
|
hidden={!appSettings.value.debug.advancedMode}
|
||||||
icon="i-[tabler--code]"
|
icon="i-[tabler--code]"
|
||||||
>
|
>
|
||||||
<GraphSource graph={manager?.serializeFullGraph()} />
|
{#if manager?.status === 'idle'}
|
||||||
|
<GraphSource graph={manager.serialize()} />
|
||||||
|
{/if}
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel
|
<Panel
|
||||||
id="benchmark"
|
id="benchmark"
|
||||||
@@ -343,8 +350,10 @@
|
|||||||
type={graphSettingTypes}
|
type={graphSettingTypes}
|
||||||
bind:value={graphSettings}
|
bind:value={graphSettings}
|
||||||
/>
|
/>
|
||||||
<ActiveNodeSettings {manager} bind:node={activeNode} />
|
{#key activeNode}
|
||||||
<GroupSettings {manager} bind:node={activeNode} />
|
<ActiveNodeSettings {manager} bind:node={activeNode} />
|
||||||
|
<GroupSettings graphState={graphInterface?.state} {manager} bind:node={activeNode} />
|
||||||
|
{/key}
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel
|
<Panel
|
||||||
id="changelog"
|
id="changelog"
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "dprint fmt -c '../.dprint.jsonc' .",
|
"format": "dprint fmt -c '../../.dprint.jsonc' .",
|
||||||
"format:check": "dprint check -c '../.dprint.jsonc' ."
|
"format:check": "dprint check -c '../../.dprint.jsonc' ."
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "bundler"
|
"moduleResolution": "bundler"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type {
|
|||||||
NodeDefinition,
|
NodeDefinition,
|
||||||
NodeId,
|
NodeId,
|
||||||
NodeInstance,
|
NodeInstance,
|
||||||
|
SerializedEdge,
|
||||||
SerializedNode,
|
SerializedNode,
|
||||||
Socket
|
Socket
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ export type Socket = {
|
|||||||
|
|
||||||
export type Edge = [NodeInstance, number, NodeInstance, string];
|
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({
|
export const GroupSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
@@ -100,7 +104,7 @@ export const GraphSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
settings: z.record(z.string(), z.any()).optional(),
|
settings: z.record(z.string(), z.any()).optional(),
|
||||||
nodes: z.array(NodeSchema),
|
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)
|
groups: z.array(GroupSchema)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
@source inline("{hover:,}{bg-,outline-,text-,}layer-3{/30,/50,}");
|
@source inline("{hover:,}{bg-,outline-,text-,}layer-3{/30,/50,}");
|
||||||
@source inline("{hover:,}{bg-,outline-,text-,}active");
|
@source inline("{hover:,}{bg-,outline-,text-,}active");
|
||||||
@source inline("{hover:,}{bg-,outline-,text-,}selected");
|
@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-,}connection");
|
||||||
@source inline("{hover:,}{bg-,outline-,text-,}text");
|
@source inline("{hover:,}{bg-,outline-,text-,}text");
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export { default as InputNumber } from './inputs/InputNumber.svelte';
|
|||||||
export { default as InputSelect } from './inputs/InputSelect.svelte';
|
export { default as InputSelect } from './inputs/InputSelect.svelte';
|
||||||
export { default as InputShape } from './inputs/InputShape.svelte';
|
export { default as InputShape } from './inputs/InputShape.svelte';
|
||||||
export { default as InputVec3 } from './inputs/InputVec3.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 Details } from './Details.svelte';
|
||||||
export { default as JsonViewer } from './JsonViewer.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>
|
||||||
@@ -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,
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
JsonViewer,
|
JsonViewer,
|
||||||
ShortCut
|
ShortCut
|
||||||
} from '$lib';
|
} from '$lib';
|
||||||
|
import SocketTable from '$lib/inputs/SocketTable.svelte';
|
||||||
import Section from './Section.svelte';
|
import Section from './Section.svelte';
|
||||||
import Theme from './Theme.svelte';
|
import Theme from './Theme.svelte';
|
||||||
import ThemeSelector from './ThemeSelector.svelte';
|
import ThemeSelector from './ThemeSelector.svelte';
|
||||||
@@ -38,6 +40,17 @@
|
|||||||
settings: { seed: 42, enabled: true }
|
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() {
|
function randomlyUpdateJson() {
|
||||||
const rand = Math.floor(Math.random() * 5);
|
const rand = Math.floor(Math.random() * 5);
|
||||||
if (rand === 0) {
|
if (rand === 0) {
|
||||||
@@ -150,6 +163,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Socket Table">
|
||||||
|
<SocketTable
|
||||||
|
colors={{
|
||||||
|
seed: '#f00',
|
||||||
|
float: '#0f0',
|
||||||
|
path: '#00f'
|
||||||
|
}}
|
||||||
|
types={['seed', 'float', 'path']}
|
||||||
|
bind:inputs={socketTypes}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section title="Shortcut">
|
<Section title="Shortcut">
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<ShortCut ctrl key="S" />
|
<ShortCut ctrl key="S" />
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 = (() => {
|
export const createLogger = (() => {
|
||||||
let maxLength = 5;
|
let maxLength = 5;
|
||||||
return (scope: string) => {
|
return (scope: string) => {
|
||||||
@@ -6,18 +63,35 @@ export const createLogger = (() => {
|
|||||||
|
|
||||||
let isGrouped = false;
|
let isGrouped = false;
|
||||||
|
|
||||||
function s(color: string, ...args: any) {
|
function s(color: string, ...args: unknown[]) {
|
||||||
return isGrouped
|
return isGrouped
|
||||||
? [...args]
|
? [...args]
|
||||||
: [`[%c${scope.padEnd(maxLength, ' ')}]:`, `color: ${color}`, ...args];
|
: [`[%c${scope.padEnd(maxLength, ' ')}]:`, `color: ${color}`, ...args];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function record(level: string, args: unknown[]) {
|
||||||
|
logBuffer.push({ time: formatTime(), scope, level, args });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
log: (...args: any[]) => !muted && console.log(...s('#888', ...args)),
|
log: (...args: unknown[]) => {
|
||||||
info: (...args: any[]) => !muted && console.info(...s('#888', ...args)),
|
record('log', args);
|
||||||
warn: (...args: any[]) => !muted && console.warn(...s('#888', ...args)),
|
!muted && console.log(...s('#888', ...args));
|
||||||
error: (...args: any[]) => console.error(...s('#f88', ...args)),
|
},
|
||||||
group: (...args: any[]) => {
|
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) {
|
if (!muted) {
|
||||||
console.groupCollapsed(...s('#888', ...args));
|
console.groupCollapsed(...s('#888', ...args));
|
||||||
isGrouped = true;
|
isGrouped = true;
|
||||||
|
|||||||
Generated
+3
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user