Compare commits
6 Commits
ef217b1c40
...
2a54fa7590
| Author | SHA1 | Date | |
|---|---|---|---|
|
2a54fa7590
|
|||
|
6d5cac65e8
|
|||
|
3ee074b11c
|
|||
|
59a1e63396
|
|||
|
317d1552ce
|
|||
|
78439b19e9
|
@@ -10,7 +10,6 @@ import plantTemplate from './templates/plant.json' assert { type: 'json' };
|
|||||||
|
|
||||||
const registry = new BenchmarkRegistry();
|
const registry = new BenchmarkRegistry();
|
||||||
const r = new MemoryRuntimeExecutor(registry);
|
const r = new MemoryRuntimeExecutor(registry);
|
||||||
const perfStore = createPerformanceStore();
|
|
||||||
|
|
||||||
const log = createLogger('bench');
|
const log = createLogger('bench');
|
||||||
|
|
||||||
@@ -26,10 +25,12 @@ function countGeometry(result: Int32Array): { totalVertices: number; totalFaces:
|
|||||||
let totalFaces = 0;
|
let totalFaces = 0;
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
const type = part[0];
|
const type = part[0];
|
||||||
const vertexCount = part[1];
|
// Values are stored as uint32 in the wasm output but read as signed int32;
|
||||||
const faceCount = part[2];
|
// >>> 0 reinterprets the bit pattern as unsigned.
|
||||||
|
const vertexCount = part[1] >>> 0;
|
||||||
|
const faceCount = part[2] >>> 0;
|
||||||
if (type === 2) {
|
if (type === 2) {
|
||||||
const instanceCount = part[3];
|
const instanceCount = part[3] >>> 0;
|
||||||
totalVertices += vertexCount * instanceCount;
|
totalVertices += vertexCount * instanceCount;
|
||||||
totalFaces += faceCount * instanceCount;
|
totalFaces += faceCount * instanceCount;
|
||||||
} else {
|
} else {
|
||||||
@@ -41,17 +42,16 @@ function countGeometry(result: Int32Array): { totalVertices: number; totalFaces:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function run(g: GraphType, amount: number) {
|
async function run(g: GraphType, amount: number) {
|
||||||
await registry.load(plantTemplate.nodes.map(n => n.type) as NodeId[]);
|
await registry.load(g.nodes.map(n => n.type) as NodeId[]);
|
||||||
log.log('loaded ' + g.nodes.length + ' nodes');
|
log.log('loaded ' + g.nodes.length + ' nodes');
|
||||||
|
|
||||||
log.log('warming up');
|
log.log('warming up');
|
||||||
|
|
||||||
// Warm up the runtime? maybe this does something?
|
|
||||||
for (let index = 0; index < 10; index++) {
|
for (let index = 0; index < 10; index++) {
|
||||||
await r.execute(g, { randomSeed: true });
|
await r.execute(g, { randomSeed: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
log.log('executing');
|
log.log('executing');
|
||||||
|
const perfStore = createPerformanceStore();
|
||||||
r.perf = perfStore;
|
r.perf = perfStore;
|
||||||
let res;
|
let res;
|
||||||
for (let i = 0; i < amount; i++) {
|
for (let i = 0; i < amount; i++) {
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: group.id,
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
inputs: group.inputs,
|
inputs: group.inputs,
|
||||||
outputs: group.outputs,
|
outputs: group.outputs,
|
||||||
nodes: groupNodes,
|
nodes: groupNodes,
|
||||||
@@ -486,7 +487,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
|
|
||||||
getNodeType(node: NodeInstance) {
|
getNodeType(node: NodeInstance) {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
console.trace('failed to get node type');
|
console.trace('failed to get node type', { node });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,16 +528,25 @@ export class GraphManager extends EventEmitter<{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputs = {
|
const defaultInputs = {
|
||||||
...(node.state.type?.inputs || {}),
|
...(node.state.type?.inputs || {}),
|
||||||
...groupDefinition?.inputs,
|
...groupDefinition?.inputs
|
||||||
|
};
|
||||||
|
|
||||||
|
// This is to make sure the the groupId is always first
|
||||||
|
delete defaultInputs['groupId'];
|
||||||
|
const inputs = {
|
||||||
'groupId': {
|
'groupId': {
|
||||||
type: 'select',
|
type: 'select',
|
||||||
label: '',
|
label: '',
|
||||||
value: node.props?.groupId,
|
value: node.props?.groupId,
|
||||||
internal: true,
|
internal: true,
|
||||||
options: this.graph.groups.map(g => g.id)
|
options: this.graph.groups.map((g, i) => ({
|
||||||
}
|
value: g.id,
|
||||||
|
label: g.name || `Group ${i + 1}`
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
...defaultInputs
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupType = {
|
const groupType = {
|
||||||
@@ -658,6 +668,13 @@ export class GraphManager extends EventEmitter<{
|
|||||||
return this.graph.groups.find(g => g.id === id);
|
return this.graph.groups.find(g => g.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renameGroup(groupId: number, name: string) {
|
||||||
|
const group = this.getGroup(groupId);
|
||||||
|
if (!group) return;
|
||||||
|
group.name = name;
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
isInsideGroup = $state(false);
|
isInsideGroup = $state(false);
|
||||||
|
|
||||||
enterGroup(nodeId: number, cameraPosition: [number, number, number]): boolean {
|
enterGroup(nodeId: number, cameraPosition: [number, number, number]): boolean {
|
||||||
@@ -685,9 +702,18 @@ export class GraphManager extends EventEmitter<{
|
|||||||
|
|
||||||
exitGroup(): { camera: [number, number, number]; nodeId: number } | false {
|
exitGroup(): { camera: [number, number, number]; nodeId: number } | false {
|
||||||
if (!this.graphStack.length) return false;
|
if (!this.graphStack.length) return false;
|
||||||
const { savedNodes, savedEdges, outerGraph, groupId, nodeId, cameraPosition } = this.graphStack.pop()!;
|
const { savedNodes, savedEdges, outerGraph, groupId, nodeId, cameraPosition } = this.graphStack
|
||||||
|
.pop()!;
|
||||||
const internalState = this.serialize();
|
const internalState = this.serialize();
|
||||||
|
|
||||||
|
// Clear stale DOM/mesh refs so the remounting components register fresh ones.
|
||||||
|
// The $effect guards in NodeHTML/Node only set these when undefined, so without
|
||||||
|
// this clear they'd keep pointing to the detached elements from before group entry.
|
||||||
|
for (const node of savedNodes.values()) {
|
||||||
|
node.state.ref = undefined;
|
||||||
|
node.state.mesh = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Restore live reactive nodes and edges so drag-reactivity is preserved
|
// Restore live reactive nodes and edges so drag-reactivity is preserved
|
||||||
this.nodes.clear();
|
this.nodes.clear();
|
||||||
for (const [id, node] of savedNodes) {
|
for (const [id, node] of savedNodes) {
|
||||||
@@ -836,35 +862,53 @@ export class GraphManager extends EventEmitter<{
|
|||||||
groupPosition[0] /= nodes.length;
|
groupPosition[0] /= nodes.length;
|
||||||
groupPosition[1] /= nodes.length;
|
groupPosition[1] /= nodes.length;
|
||||||
|
|
||||||
|
// Map from deduped edge source key → group input index, used for both
|
||||||
|
// internal edge wiring and external edge socket naming.
|
||||||
|
const inputIndexByEdgeKey = new Map<string, number>();
|
||||||
|
[...groupInputs.keys()].forEach((key, i) => inputIndexByEdgeKey.set(key, i));
|
||||||
|
|
||||||
|
// Allocate all needed IDs up front so sequential calls never collide.
|
||||||
|
const usedIds = new Set<number>([
|
||||||
|
...this.nodes.keys(),
|
||||||
|
...this.graph.groups.map(g => g.id),
|
||||||
|
...this.graph.groups.flatMap(g => g.nodes.map(n => n.id))
|
||||||
|
]);
|
||||||
|
const nextId = () => {
|
||||||
|
let id = 0;
|
||||||
|
while (usedIds.has(id)) id++;
|
||||||
|
usedIds.add(id);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
const groupInputNode: NodeInstance = {
|
const groupInputNode: NodeInstance = {
|
||||||
id: this.createNodeId(),
|
id: nextId(),
|
||||||
type: '__internal/group/input',
|
type: '__internal/group/input',
|
||||||
position: [bounds.minX - 50, (bounds.minY + bounds.maxY) / 2],
|
position: [bounds.minX - 50, (bounds.minY + bounds.maxY) / 2],
|
||||||
state: {}
|
state: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupOutputNode: NodeInstance = {
|
const groupOutputNode: NodeInstance = {
|
||||||
id: this.createNodeId(),
|
id: nextId(),
|
||||||
type: '__internal/group/output',
|
type: '__internal/group/output',
|
||||||
position: [bounds.maxX + 25, (bounds.minY + bounds.maxY) / 2],
|
position: [bounds.maxX + 25, (bounds.minY + bounds.maxY) / 2],
|
||||||
state: {}
|
state: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Edges that are inside the group
|
// Edges that are inside the group, routed through boundary nodes at
|
||||||
|
// the correct input/output index for each unique external connection.
|
||||||
const internalEdges = this.edges.filter((edge) => {
|
const internalEdges = this.edges.filter((edge) => {
|
||||||
return ids.has(edge[0].id) || ids.has(edge[2].id);
|
return ids.has(edge[0].id) || ids.has(edge[2].id);
|
||||||
}).map((edge) => {
|
}).map((edge) => {
|
||||||
// Going in from the group
|
|
||||||
if (!ids.has(edge[0].id)) {
|
if (!ids.has(edge[0].id)) {
|
||||||
return [groupInputNode.id, 0, edge[2].id, edge[3]];
|
const idx = inputIndexByEdgeKey.get(`${edge[0].id}-${edge[1]}`) ?? 0;
|
||||||
// Going out to the group
|
return [groupInputNode.id, idx, edge[2].id, edge[3]];
|
||||||
} else if (!ids.has(edge[2].id)) {
|
} else if (!ids.has(edge[2].id)) {
|
||||||
return [edge[0].id, edge[1], groupOutputNode.id, 'out_0'];
|
return [edge[0].id, edge[1], groupOutputNode.id, 'out_0'];
|
||||||
}
|
}
|
||||||
return [edge[0].id, edge[1], edge[2].id, edge[3]];
|
return [edge[0].id, edge[1], edge[2].id, edge[3]];
|
||||||
}) as [number, number, number, string][];
|
}) as [number, number, number, string][];
|
||||||
|
|
||||||
const groupId = this.createNodeId();
|
const groupId = nextId();
|
||||||
const groupDefinition: GroupDefinition = {
|
const groupDefinition: GroupDefinition = {
|
||||||
id: groupId,
|
id: groupId,
|
||||||
inputs: inputs,
|
inputs: inputs,
|
||||||
@@ -873,6 +917,9 @@ export class GraphManager extends EventEmitter<{
|
|||||||
nodes: [groupInputNode, ...nodes, groupOutputNode]
|
nodes: [groupInputNode, ...nodes, groupOutputNode]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Push before createNode so createNodeId() inside sees the allocated IDs.
|
||||||
|
this.graph.groups.push(groupDefinition);
|
||||||
|
|
||||||
const groupNode = this.createNode({
|
const groupNode = this.createNode({
|
||||||
type: '__internal/group/instance',
|
type: '__internal/group/instance',
|
||||||
position: [groupPosition[0], groupPosition[1]],
|
position: [groupPosition[0], groupPosition[1]],
|
||||||
@@ -883,20 +930,17 @@ export class GraphManager extends EventEmitter<{
|
|||||||
|
|
||||||
if (!groupNode) throw new Error('Failed to create group node');
|
if (!groupNode) throw new Error('Failed to create group node');
|
||||||
|
|
||||||
// Update the edges that are now inside
|
// Rewire external edges to/from the group node using the correct input socket.
|
||||||
// the group to be connected to that group node
|
|
||||||
const externalEdges = this.edges.map((edge) => {
|
const externalEdges = this.edges.map((edge) => {
|
||||||
if (ids.has(edge[2].id)) {
|
if (ids.has(edge[2].id)) {
|
||||||
// Edge going into the group
|
const idx = inputIndexByEdgeKey.get(`${edge[0].id}-${edge[1]}`) ?? 0;
|
||||||
return [edge[0], edge[1], groupNode, 'input_0'] as Edge;
|
return [edge[0], edge[1], groupNode, `input_${idx}`] as Edge;
|
||||||
} else if (ids.has(edge[0].id)) {
|
} else if (ids.has(edge[0].id)) {
|
||||||
// Edge going out of the group
|
|
||||||
return [groupNode, 0, edge[2], edge[3]] as Edge;
|
return [groupNode, 0, edge[2], edge[3]] as Edge;
|
||||||
}
|
}
|
||||||
return edge;
|
return edge;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.graph.groups.push(groupDefinition);
|
|
||||||
this.nodes.set(groupNode.id, groupNode);
|
this.nodes.set(groupNode.id, groupNode);
|
||||||
this.edges = externalEdges;
|
this.edges = externalEdges;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { assert, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { GraphManager } from './graph-manager.svelte';
|
||||||
|
import { GraphState } from './graph-state.svelte';
|
||||||
|
import {
|
||||||
|
createMockNodeRegistry,
|
||||||
|
mockFloatInputNode,
|
||||||
|
mockFloatOutputNode
|
||||||
|
} from './test-utils';
|
||||||
|
|
||||||
|
// GraphState constructor reads localStorage synchronously — mock before any instantiation
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
value: {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {},
|
||||||
|
removeItem: () => {},
|
||||||
|
clear: () => {},
|
||||||
|
length: 0,
|
||||||
|
key: () => null
|
||||||
|
} as Storage,
|
||||||
|
writable: true,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
function createFixture() {
|
||||||
|
const registry = createMockNodeRegistry([mockFloatOutputNode, mockFloatInputNode]);
|
||||||
|
const manager = new GraphManager(registry);
|
||||||
|
const state = new GraphState(manager);
|
||||||
|
return { manager, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('clearSelection', () => {
|
||||||
|
it('empties selectedNodes', () => {
|
||||||
|
const { state } = createFixture();
|
||||||
|
state.selectedNodes.add(1);
|
||||||
|
state.selectedNodes.add(2);
|
||||||
|
state.clearSelection();
|
||||||
|
expect(state.selectedNodes.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('projectScreenToWorld', () => {
|
||||||
|
it('maps the viewport centre to the camera position', () => {
|
||||||
|
const { state } = createFixture();
|
||||||
|
// cameraPosition default: [140, 100, 3.5], width=100, height=100
|
||||||
|
state.width = 100;
|
||||||
|
state.height = 100;
|
||||||
|
state.cameraPosition = [140, 100, 3.5];
|
||||||
|
const [wx, wy] = state.projectScreenToWorld(50, 50);
|
||||||
|
expect(wx).toBeCloseTo(140);
|
||||||
|
expect(wy).toBeCloseTo(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offsets correctly for a point not at centre', () => {
|
||||||
|
const { state } = createFixture();
|
||||||
|
state.width = 100;
|
||||||
|
state.height = 100;
|
||||||
|
state.cameraPosition = [0, 0, 2];
|
||||||
|
const [wx, wy] = state.projectScreenToWorld(100, 50);
|
||||||
|
// x: 0 + (100 - 50) / 2 = 25
|
||||||
|
expect(wx).toBeCloseTo(25);
|
||||||
|
expect(wy).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('groupSelectedNodes', () => {
|
||||||
|
it('delegates to graph.groupNodes with selected IDs and activeNodeId', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
|
||||||
|
const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} });
|
||||||
|
const nodeB = manager.createNode({ type: 'test/node/input', position: [100, 0], props: {} });
|
||||||
|
assert.isDefined(nodeA);
|
||||||
|
assert.isDefined(nodeB);
|
||||||
|
|
||||||
|
state.selectedNodes.add(nodeA!.id);
|
||||||
|
state.activeNodeId = nodeB!.id;
|
||||||
|
|
||||||
|
const groupNode = state.groupSelectedNodes();
|
||||||
|
assert.isDefined(groupNode);
|
||||||
|
|
||||||
|
const graph = manager.serialize();
|
||||||
|
expect(graph.groups.length).toBe(1);
|
||||||
|
expect(graph.nodes.map(n => n.id)).toContain(groupNode!.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works when only activeNodeId is set with no extra selection', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
|
||||||
|
const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} });
|
||||||
|
assert.isDefined(nodeA);
|
||||||
|
|
||||||
|
state.activeNodeId = nodeA!.id;
|
||||||
|
const groupNode = state.groupSelectedNodes();
|
||||||
|
assert.isDefined(groupNode);
|
||||||
|
|
||||||
|
expect(manager.graph.groups.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enterGroupNode', () => {
|
||||||
|
it('does nothing when activeNodeId is -1', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
state.activeNodeId = -1;
|
||||||
|
state.enterGroupNode();
|
||||||
|
expect(manager.graphStack.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when the active node is not a group instance', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
const node = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} });
|
||||||
|
assert.isDefined(node);
|
||||||
|
state.activeNodeId = node!.id;
|
||||||
|
state.enterGroupNode();
|
||||||
|
expect(manager.graphStack.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enters the group, pushes graphStack, and clears UI state', () => {
|
||||||
|
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.selectedNodes.add(nodeA!.id);
|
||||||
|
state.activeNodeId = groupNode!.id;
|
||||||
|
state.cameraPosition = [10, 20, 5];
|
||||||
|
|
||||||
|
state.enterGroupNode();
|
||||||
|
|
||||||
|
expect(manager.graphStack.length).toBe(1);
|
||||||
|
expect(state.activeNodeId).toBe(-1);
|
||||||
|
expect(state.selectedNodes.size).toBe(0);
|
||||||
|
expect(manager.isInsideGroup).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exitGroupNode', () => {
|
||||||
|
it('does nothing when not inside a group', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
const before = [...state.cameraPosition];
|
||||||
|
state.exitGroupNode();
|
||||||
|
expect(manager.graphStack.length).toBe(0);
|
||||||
|
expect(state.cameraPosition).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the camera position from before entry', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
|
||||||
|
const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} });
|
||||||
|
assert.isDefined(nodeA);
|
||||||
|
const groupNode = manager.groupNodes([nodeA!.id]);
|
||||||
|
assert.isDefined(groupNode);
|
||||||
|
|
||||||
|
state.activeNodeId = groupNode!.id;
|
||||||
|
state.cameraPosition = [77, 88, 4];
|
||||||
|
|
||||||
|
state.enterGroupNode();
|
||||||
|
// Simulate camera moving inside the group
|
||||||
|
state.cameraPosition = [0, 0, 1];
|
||||||
|
|
||||||
|
state.exitGroupNode();
|
||||||
|
|
||||||
|
expect(state.cameraPosition).toEqual([77, 88, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears activeNodeId and selection after exit', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
|
||||||
|
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.enterGroupNode();
|
||||||
|
state.activeNodeId = 99;
|
||||||
|
state.selectedNodes.add(99);
|
||||||
|
|
||||||
|
state.exitGroupNode();
|
||||||
|
|
||||||
|
// Group instance node is re-selected on exit; internal selection is cleared
|
||||||
|
expect(state.activeNodeId).toBe(groupNode!.id);
|
||||||
|
expect(state.selectedNodes.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores outer nodes to manager after exit', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
|
||||||
|
const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} });
|
||||||
|
const nodeB = manager.createNode({ type: 'test/node/input', position: [100, 0], props: {} });
|
||||||
|
assert.isDefined(nodeA);
|
||||||
|
assert.isDefined(nodeB);
|
||||||
|
|
||||||
|
manager.createEdge(nodeA!, 0, nodeB!, 'value');
|
||||||
|
const groupNode = manager.groupNodes([nodeA!.id]);
|
||||||
|
assert.isDefined(groupNode);
|
||||||
|
|
||||||
|
state.activeNodeId = groupNode!.id;
|
||||||
|
state.enterGroupNode();
|
||||||
|
|
||||||
|
// Inside the group: nodeA is an internal node so it IS active; the outer
|
||||||
|
// nodes (nodeB, groupNode) are saved and no longer in the active Map.
|
||||||
|
expect(manager.nodes.has(nodeA!.id)).toBe(true);
|
||||||
|
expect(manager.nodes.has(nodeB!.id)).toBe(false);
|
||||||
|
|
||||||
|
state.exitGroupNode();
|
||||||
|
|
||||||
|
// After exit: outer nodes are restored
|
||||||
|
expect(manager.nodes.has(nodeB!.id)).toBe(true);
|
||||||
|
expect(manager.nodes.has(groupNode!.id)).toBe(true);
|
||||||
|
expect(manager.isInsideGroup).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isInsideGroup is false after exiting the only group level', () => {
|
||||||
|
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.enterGroupNode();
|
||||||
|
expect(manager.isInsideGroup).toBe(true);
|
||||||
|
|
||||||
|
state.exitGroupNode();
|
||||||
|
expect(manager.isInsideGroup).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('copyNodes / pasteNodes', () => {
|
||||||
|
it('copies the active node into the clipboard', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
const node = manager.createNode({ type: 'test/node/output', position: [10, 20], props: {} });
|
||||||
|
assert.isDefined(node);
|
||||||
|
|
||||||
|
state.activeNodeId = node!.id;
|
||||||
|
state.mousePosition = [0, 0];
|
||||||
|
state.copyNodes();
|
||||||
|
|
||||||
|
assert.isNotNull(state.clipboard);
|
||||||
|
expect(state.clipboard!.nodes.map(n => n.id)).toContain(node!.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes edges between copied nodes', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} });
|
||||||
|
const nodeB = manager.createNode({ type: 'test/node/input', position: [100, 0], props: {} });
|
||||||
|
assert.isDefined(nodeA);
|
||||||
|
assert.isDefined(nodeB);
|
||||||
|
|
||||||
|
manager.createEdge(nodeA!, 0, nodeB!, 'value');
|
||||||
|
|
||||||
|
state.activeNodeId = nodeA!.id;
|
||||||
|
state.selectedNodes.add(nodeB!.id);
|
||||||
|
state.mousePosition = [0, 0];
|
||||||
|
state.copyNodes();
|
||||||
|
|
||||||
|
assert.isNotNull(state.clipboard);
|
||||||
|
expect(state.clipboard!.edges.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pastes nodes and adds them to the graph', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
const node = manager.createNode({ type: 'test/node/output', position: [10, 20], props: {} });
|
||||||
|
assert.isDefined(node);
|
||||||
|
|
||||||
|
state.activeNodeId = node!.id;
|
||||||
|
state.mousePosition = [0, 0];
|
||||||
|
state.copyNodes();
|
||||||
|
|
||||||
|
const countBefore = manager.nodes.size;
|
||||||
|
state.mousePosition = [50, 50];
|
||||||
|
state.pasteNodes();
|
||||||
|
|
||||||
|
expect(manager.nodes.size).toBe(countBefore + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when clipboard is empty', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} });
|
||||||
|
const countBefore = manager.nodes.size;
|
||||||
|
state.pasteNodes();
|
||||||
|
expect(manager.nodes.size).toBe(countBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -376,7 +376,7 @@ export class GraphState {
|
|||||||
const result = this.graph.exitGroup();
|
const result = this.graph.exitGroup();
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
this.cameraPosition = result.camera;
|
this.cameraPosition = result.camera;
|
||||||
this.activeNodeId = -1;
|
this.activeNodeId = result.nodeId;
|
||||||
this.clearSelection();
|
this.clearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,6 @@
|
|||||||
|
|
||||||
function getSocketType(node: NodeInstance, index: number | string): string {
|
function getSocketType(node: NodeInstance, index: number | string): string {
|
||||||
const nodeType = graph.getNodeType(node);
|
const nodeType = graph.getNodeType(node);
|
||||||
console.log({ nodeType, index });
|
|
||||||
if (typeof index === 'string') {
|
if (typeof index === 'string') {
|
||||||
return nodeType?.inputs?.[index].type || 'unknown';
|
return nodeType?.inputs?.[index].type || 'unknown';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
};
|
};
|
||||||
let { node = $bindable(), inView }: Props = $props();
|
let { node = $bindable(), inView }: Props = $props();
|
||||||
|
|
||||||
const nodeType = $derived(graph.getNodeType(node)!);
|
const nodeType = $derived(node ? graph.getNodeType(node) : undefined);
|
||||||
|
|
||||||
const isActive = $derived(graphState.activeNodeId === node.id);
|
const isActive = $derived(graphState.activeNodeId === node.id);
|
||||||
const isSelected = $derived(graphState.selectedNodes.has(node.id));
|
const isSelected = $derived(graphState.selectedNodes.has(node.id));
|
||||||
@@ -33,10 +33,12 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const sectionHeights = $derived(
|
const sectionHeights = $derived(
|
||||||
Object
|
nodeType
|
||||||
.keys(nodeType?.inputs || {})
|
? Object
|
||||||
.map(key => getParameterHeight(nodeType, key) / 10)
|
.keys(nodeType?.inputs || {})
|
||||||
.filter(b => !!b)
|
.map(key => getParameterHeight(nodeType, key) / 10)
|
||||||
|
.filter(b => !!b)
|
||||||
|
: [5]
|
||||||
);
|
);
|
||||||
|
|
||||||
let meshRef: Mesh | undefined = $state();
|
let meshRef: Mesh | undefined = $state();
|
||||||
|
|||||||
@@ -9,6 +9,21 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let { manager, node = $bindable() }: Props = $props();
|
let { manager, node = $bindable() }: Props = $props();
|
||||||
|
|
||||||
|
const isGroupInstance = $derived(node?.type === '__internal/group/instance');
|
||||||
|
const activeGroup = $derived(
|
||||||
|
isGroupInstance ? manager.getGroup(node!.props?.groupId as number) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
let groupName = $state('');
|
||||||
|
$effect(() => {
|
||||||
|
groupName = activeGroup?.name ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleRename(e: Event) {
|
||||||
|
const name = (e.target as HTMLInputElement).value;
|
||||||
|
if (activeGroup) manager.renameGroup(activeGroup.id, name);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<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'>
|
||||||
@@ -17,7 +32,18 @@
|
|||||||
|
|
||||||
{#if node}
|
{#if node}
|
||||||
{#key node.id}
|
{#key node.id}
|
||||||
{#if node}
|
{#if isGroupInstance && activeGroup}
|
||||||
|
<div class="group-settings">
|
||||||
|
<label for="group-name">Group name</label>
|
||||||
|
<input
|
||||||
|
id="group-name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Group {activeGroup.id}"
|
||||||
|
value={groupName}
|
||||||
|
oninput={handleRename}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else if node}
|
||||||
<ActiveNodeSelected {manager} bind:node />
|
<ActiveNodeSelected {manager} bind:node />
|
||||||
{/if}
|
{/if}
|
||||||
{/key}
|
{/key}
|
||||||
@@ -26,7 +52,59 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if manager?.graph.groups.length}
|
{#if manager?.graph.groups.length}
|
||||||
<button onclick={() => manager.removeUnusedGroups()}>
|
<div class="group-actions">
|
||||||
remove unused groups
|
<button onclick={() => manager.removeUnusedGroups()}>
|
||||||
</button>
|
Remove unused groups
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.group-settings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4em;
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-settings label {
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-settings input {
|
||||||
|
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.9em;
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-settings input:focus {
|
||||||
|
outline: 1px solid var(--color-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-actions {
|
||||||
|
padding: 0.75em 1em;
|
||||||
|
border-top: 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>
|
||||||
|
|||||||
@@ -61,8 +61,10 @@ export const NodeInputBooleanSchema = z.object({
|
|||||||
export const NodeInputSelectSchema = z.object({
|
export const NodeInputSelectSchema = z.object({
|
||||||
...DefaultOptionsSchema.shape,
|
...DefaultOptionsSchema.shape,
|
||||||
type: z.literal('select'),
|
type: z.literal('select'),
|
||||||
options: z.array(z.string()).optional(),
|
options: z.array(
|
||||||
value: z.string().optional()
|
z.union([z.string(), z.object({ value: z.number(), label: z.string() })])
|
||||||
|
).optional(),
|
||||||
|
value: z.union([z.string(), z.number()]).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const NodeInputSeedSchema = z.object({
|
export const NodeInputSeedSchema = z.object({
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export type Edge = [NodeInstance, number, NodeInstance, string];
|
|||||||
|
|
||||||
export const GroupSchema = z.object({
|
export const GroupSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
|
name: z.string().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(z.tuple([z.number(), z.number(), z.number(), z.string()])),
|
||||||
inputs: z.record(z.string(), NodeInputSchema).optional(),
|
inputs: z.record(z.string(), NodeInputSchema).optional(),
|
||||||
|
|||||||
@@ -88,7 +88,11 @@
|
|||||||
class:bg-layer-3={flashing}
|
class:bg-layer-3={flashing}
|
||||||
>
|
>
|
||||||
{#if key !== undefined}
|
{#if key !== undefined}
|
||||||
<span class="text-text">{key}</span><span class="text-text/40">: </span>
|
<button
|
||||||
|
class="text-text hover:bg-layer-3 cursor-pointer"
|
||||||
|
title="Copy value"
|
||||||
|
onclick={() => navigator.clipboard.writeText(JSON.stringify({[key]: value}, null, 2))}
|
||||||
|
>{key}</button><span class="text-text/40">: </span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isExpandable}
|
{#if isExpandable}
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
type SelectOption = string | { value: number; label: string };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
options?: string[];
|
options?: SelectOption[];
|
||||||
value?: number;
|
value?: number;
|
||||||
id?: string;
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { options = [], value = $bindable(0), id = '' }: Props = $props();
|
let { options = [], value = $bindable(0), id = '' }: Props = $props();
|
||||||
|
|
||||||
|
const normalized = $derived(
|
||||||
|
options.map((opt, i) =>
|
||||||
|
typeof opt === 'string' ? { value: i, label: opt } : opt
|
||||||
|
)
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<select {id} bind:value class="bg-layer-2 text-text">
|
<select {id} bind:value class="bg-layer-2 text-text">
|
||||||
{#each options as label, i (label)}
|
{#each normalized as opt (opt.value)}
|
||||||
<option value={i}>{label}</option>
|
<option value={opt.value}>{opt.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
let vecValue = $state([0.2, 0.3, 0.4]);
|
let vecValue = $state([0.2, 0.3, 0.4]);
|
||||||
const options = ['strawberry', 'raspberry', 'chickpeas'];
|
const options = ['strawberry', 'raspberry', 'chickpeas'];
|
||||||
let selectValue = $state(0);
|
let selectValue = $state(0);
|
||||||
const d = $derived(options[selectValue]);
|
let selectValue2 = $state(0);
|
||||||
let checked = $state(false);
|
let checked = $state(false);
|
||||||
let colorValue = $state<[number, number, number]>([59, 130, 246]);
|
let colorValue = $state<[number, number, number]>([59, 130, 246]);
|
||||||
let mirrorShape = $state(true);
|
let mirrorShape = $state(true);
|
||||||
@@ -82,9 +82,28 @@
|
|||||||
<InputVec3 bind:value={vecValue} />
|
<InputVec3 bind:value={vecValue} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Select" value={d}>
|
<Section title="Select">
|
||||||
<i>Select with simple values</i>
|
<p>
|
||||||
|
Select with simple values
|
||||||
|
<br>
|
||||||
|
<b>value={options[selectValue]}</b>
|
||||||
|
</p>
|
||||||
<InputSelect bind:value={selectValue} {options} />
|
<InputSelect bind:value={selectValue} {options} />
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<p>
|
||||||
|
Select with <i>{option: number, label: string}[]</i>
|
||||||
|
<br>
|
||||||
|
<b>value={selectValue2}</b>
|
||||||
|
</p>
|
||||||
|
<InputSelect
|
||||||
|
bind:value={selectValue2}
|
||||||
|
options={[
|
||||||
|
{ value: 0, label: 'Zero' },
|
||||||
|
{ value: 1, label: 'One' },
|
||||||
|
{ value: 2, label: 'Two' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Checkbox" value={checked}>
|
<Section title="Checkbox" value={checked}>
|
||||||
|
|||||||
Reference in New Issue
Block a user