feat: initial auto connect nodes
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m35s

This commit is contained in:
2025-11-26 17:27:32 +01:00
parent d3a9b3f056
commit e5658b8a7e
11 changed files with 173 additions and 99 deletions

View File

@@ -1,21 +1,26 @@
<script lang="ts">
import type { GraphManager } from "./graph-manager.svelte";
import { HTML } from "@threlte/extras";
import { onMount } from "svelte";
import type { NodeType } from "@nodes/types";
import type { Node, NodeType } from "@nodes/types";
import { getGraphState } from "./graph/state.svelte";
import { getGraphManager } from "./graph/context";
type Props = {
position: [x: number, y: number] | null;
graph: GraphManager;
};
let { position = $bindable(), graph }: Props = $props();
const graph = getGraphManager();
const graphState = getGraphState();
let { position = $bindable() }: Props = $props();
let input: HTMLInputElement;
let value = $state<string>();
let activeNodeId = $state<NodeType>();
const allNodes = graph.getNodeDefinitions();
const allNodes = graphState.activeSocket
? graph.getPossibleNodes(graphState.activeSocket)
: graph.getNodeDefinitions();
function filterNodes() {
return allNodes.filter((node) => node.id.includes(value ?? ""));
@@ -25,7 +30,7 @@
$effect(() => {
if (nodes) {
if (activeNodeId === undefined) {
activeNodeId = nodes[0].id;
activeNodeId = nodes?.[0]?.id;
} else if (nodes.length) {
const node = nodes.find((node) => node.id === activeNodeId);
if (!node) {
@@ -35,6 +40,28 @@
}
});
function handleNodeCreation(nodeType: Node["type"]) {
if (!position) return;
const newNode = graph.createNode({
type: nodeType,
position,
props: {},
});
const edgeInputSocket = graphState.activeSocket;
if (edgeInputSocket && newNode) {
if (typeof edgeInputSocket.index === "number") {
graph.smartConnect(edgeInputSocket.node, newNode);
} else {
graph.smartConnect(newNode, edgeInputSocket.node);
}
}
graphState.activeSocket = null;
position = null;
}
function handleKeyDown(event: KeyboardEvent) {
event.stopImmediatePropagation();
@@ -57,8 +84,7 @@
if (event.key === "Enter") {
if (activeNodeId && position) {
graph.createNode({ type: activeNodeId, position, props: {} });
position = null;
handleNodeCreation(activeNodeId);
}
return;
}
@@ -95,18 +121,10 @@
aria-selected={node.id === activeNodeId}
onkeydown={(event) => {
if (event.key === "Enter") {
if (position) {
graph.createNode({ type: node.id, position, props: {} });
position = null;
}
}
}}
onmousedown={() => {
if (position) {
graph.createNode({ type: node.id, position, props: {} });
position = null;
handleNodeCreation(node.id);
}
}}
onmousedown={() => handleNodeCreation(node.id)}
onfocus={() => {
activeNodeId = node.id;
}}

View File

@@ -2,6 +2,7 @@ import type {
Edge,
Graph,
Node,
NodeDefinition,
NodeInput,
NodeRegistry,
NodeType,
@@ -15,7 +16,7 @@ import throttle from "$lib/helpers/throttle";
import { HistoryManager } from "./history-manager";
const logger = createLogger("graph-manager");
// logger.mute();
logger.mute();
const clone =
"structuredClone" in self
@@ -361,6 +362,20 @@ export class GraphManager extends EventEmitter<{
this.save();
}
smartConnect(from: Node, to: Node): Edge | undefined {
const inputs = Object.entries(to.tmp?.type?.inputs ?? {});
const outputs = from.tmp?.type?.outputs ?? [];
for (let i = 0; i < inputs.length; i++) {
const [inputName, input] = inputs[0];
for (let o = 0; o < outputs.length; o++) {
const output = outputs[0];
if (input.type === output) {
return this.createEdge(from, o, to, inputName);
}
}
}
}
createNodeId() {
const max = Math.max(0, ...this.nodes.keys());
return max + 1;
@@ -370,10 +385,9 @@ export class GraphManager extends EventEmitter<{
// map old ids to new ids
const idMap = new Map<number, number>();
const startId = this.createNodeId();
nodes = nodes.map((node, i) => {
const id = startId + i;
const id = this.createNodeId();
idMap.set(node.id, id);
const type = this.registry.getNode(node.type);
if (!type) {
@@ -437,6 +451,8 @@ export class GraphManager extends EventEmitter<{
this.nodes.set(node.id, node);
this.save();
return node
}
createEdge(
@@ -445,7 +461,10 @@ export class GraphManager extends EventEmitter<{
to: Node,
toSocket: string,
{ applyUpdate = true } = {},
) {
): Edge | undefined {
console.log("Create Edge", from.type, fromSocket, to.type, toSocket)
const existingEdges = this.getEdgesToNode(to);
// check if this exact edge already exists
@@ -464,6 +483,8 @@ export class GraphManager extends EventEmitter<{
toSocketType.push(...(to?.tmp?.type?.inputs?.[toSocket]?.accepts || []));
}
console.log({ fromSocketType, toSocket, toType: to?.tmp?.type, toSocketType });
if (!areSocketsCompatible(fromSocketType, toSocketType)) {
logger.error(
`Socket types do not match: ${fromSocketType} !== ${toSocketType}`,
@@ -478,11 +499,9 @@ export class GraphManager extends EventEmitter<{
this.removeEdge(edgeToBeReplaced, { applyDeletion: false });
}
if (applyUpdate) {
this.edges.push([from, fromSocket, to, toSocket]);
} else {
this.edges.push([from, fromSocket, to, toSocket]);
}
const edge = [from, fromSocket, to, toSocket] as Edge;
this.edges.push(edge);
from.tmp = from.tmp || {};
from.tmp.children = from.tmp.children || [];
@@ -496,6 +515,8 @@ export class GraphManager extends EventEmitter<{
this.save();
}
this.execute();
return edge;
}
undo() {
@@ -547,6 +568,33 @@ export class GraphManager extends EventEmitter<{
return parents.reverse();
}
getPossibleNodes(socket: Socket): NodeDefinition[] {
const allDefinitions = this.getNodeDefinitions();
const nodeType = socket.node.tmp?.type;
if (!nodeType) {
return [];
}
if (typeof socket.index === "string") {
// if index is a string, we are an input looking for outputs
return allDefinitions.filter(s => {
return s.outputs?.find(_s => Object
.values(nodeType?.inputs || {})
.map(s => s.type)
.includes(_s as NodeInput["type"])
)
})
} else {
// if index is a number, we are an output looking for inputs
return allDefinitions.filter(s => Object
.values(s.inputs ?? {})
.map(s => s.type)
.find(s => nodeType?.outputs?.includes(s))
)
}
}
getPossibleSockets({ node, index }: Socket): [Node, string | number][] {
const nodeType = node?.tmp?.type;
if (!nodeType) return [];

View File

@@ -50,6 +50,7 @@
let boxSelection = $state(false);
const cameraDown = [0, 0];
let cameraPosition: [number, number, number] = $state([0, 0, 4]);
let edgeEndPosition = $state<[number, number] | null>();
let addMenuPosition = $state<[number, number] | null>(null);
let clipboard: null | {
nodes: Node[];
@@ -465,6 +466,8 @@
n.tmp.downY = n.position[1];
}
}
edgeEndPosition = null;
}
function copyNodes() {
@@ -478,17 +481,15 @@
.filter(Boolean) as Node[];
const _edges = graph.getEdgesBetweenNodes(_nodes);
_nodes = _nodes.map((_node) => {
const node = globalThis.structuredClone({
_nodes = $state.snapshot(
_nodes.map((_node) => ({
..._node,
tmp: {
downX: mousePosition[0] - _node.position[0],
downY: mousePosition[1] - _node.position[1],
},
});
return node;
});
})),
);
clipboard = {
nodes: _nodes,
@@ -532,6 +533,18 @@
},
});
keymap.addShortcut({
key: "f",
description: "Smart Connect Nodes",
callback: () => {
const nodes = [...graphState.selectedNodes.values()]
.map((g) => graph.getNode(g))
.filter((n) => !!n);
const edge = graph.smartConnect(nodes[0], nodes[1]);
if (!edge) graph.smartConnect(nodes[1], nodes[0]);
},
});
keymap.addShortcut({
key: "?",
description: "Toggle Help",
@@ -561,6 +574,7 @@
callback: () => {
graphState.activeNodeId = -1;
graphState.clearSelection();
edgeEndPosition = null;
(document.activeElement as HTMLElement)?.blur();
},
});
@@ -778,6 +792,16 @@
);
}
graph.save();
} else if (graphState.activeSocket && event.ctrlKey) {
// Handle automatic adding of nodes on ctrl+mouseUp
edgeEndPosition = [mousePosition[0], mousePosition[1]];
if (typeof graphState.activeSocket.index === "number") {
addMenuPosition = [mousePosition[0], mousePosition[1] - 3];
} else {
addMenuPosition = [mousePosition[0] - 20, mousePosition[1] - 3];
}
return;
}
// check if camera moved
@@ -953,7 +977,7 @@
{#if graph.status === "idle"}
{#if addMenuPosition}
<AddMenu bind:position={addMenuPosition} {graph} />
<AddMenu bind:position={addMenuPosition} />
{/if}
{#if graphState.activeSocket}
@@ -963,7 +987,10 @@
x: graphState.activeSocket.position[0],
y: graphState.activeSocket.position[1],
}}
to={{ x: mousePosition[0], y: mousePosition[1] }}
to={{
x: edgeEndPosition?.[0] ?? mousePosition[0],
y: edgeEndPosition?.[1] ?? mousePosition[1],
}}
/>
{/if}

View File

@@ -4,7 +4,7 @@ import { clone } from "./helpers/index.js";
import { createLogger } from "@nodes/utils";
const diff = create({
objectHash: function (obj, index) {
objectHash: function(obj, index) {
if (obj === null) return obj;
if ("id" in obj) return obj.id as string;
if ("_id" in obj) return obj._id as string;
@@ -16,7 +16,7 @@ const diff = create({
});
const log = createLogger("history");
// log.mute();
log.mute();
export class HistoryManager {
index: number = -1;

View File

@@ -25,7 +25,10 @@
<div class="wrapper">
<table>
<tbody>
<tr on:click={() => ($open.runtime = !$open.runtime)}>
<tr
style="cursor:pointer;"
on:click={() => ($open.runtime = !$open.runtime)}
>
<td>{$open.runtime ? "-" : "+"} runtime </td>
<td>{humanizeDuration(runtime || 1000)}</td>
</tr>
@@ -37,7 +40,7 @@
</tr>
{/if}
<tr on:click={() => ($open.fps = !$open.fps)}>
<tr style="cursor:pointer;" on:click={() => ($open.fps = !$open.fps)}>
<td>{$open.fps ? "-" : "+"} fps </td>
<td>
{Math.floor(fps[fps.length - 1])}fps
@@ -74,9 +77,6 @@
border: solid thin var(--outline);
border-collapse: collapse;
}
tr {
cursor: pointer;
}
td {
padding: 4px;
padding-inline: 8px;

View File

@@ -16,7 +16,7 @@ import {
} from "@nodes/utils";
const log = createLogger("runtime-executor");
// log.mute();
log.mute();
function getValue(input: NodeInput, value?: unknown) {
if (value === undefined && "value" in input) {
@@ -65,7 +65,7 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
constructor(
private registry: NodeRegistry,
private cache?: SyncCache<Int32Array>,
) {}
) { }
private async getNodeDefinitions(graph: Graph) {
if (this.registry.status !== "ready") {

View File

@@ -114,7 +114,7 @@
{type[key].label || key}
</button>
{:else}
{#if type[key]?.label !== false}
{#if type[key].label !== ""}
<label for={id}>{type[key].label || key}</label>
{/if}
<Input {id} input={type[key]} bind:value={internalValue} />
@@ -196,6 +196,10 @@
padding-bottom: 1px;
}
button {
cursor: pointer;
}
hr {
position: absolute;
margin: 0;

View File

@@ -1,6 +1,4 @@
import { localState } from "$lib/helpers/localState.svelte";
import type { NodeInput } from "@nodes/types";
import type { SettingsType } from ".";
const themes = [
"dark",
@@ -10,7 +8,7 @@ const themes = [
"high-contrast",
"nord",
"dracula",
];
] as const;
export const AppSettingTypes = {
theme: {
@@ -49,11 +47,6 @@ export const AppSettingTypes = {
},
debug: {
title: "Debug",
amount: {
type: "number",
label: "Amount",
value: 4,
},
wireframe: {
type: "boolean",
label: "Wireframe",
@@ -89,14 +82,6 @@ export const AppSettingTypes = {
label: "Show Stem Lines",
value: false,
},
logging: {
title: "Logging",
logLevel: {
type: "select",
label: false,
options: ["info","warning","error"]
}
},
stressTest: {
title: "Stress Test",
amount: {
@@ -127,32 +112,23 @@ export const AppSettingTypes = {
},
},
},
} as const satisfies SettingsType;
} as const;
type IsInputDefinition<T> = T extends NodeInput ? T : never;
type HasTitle = { title: string };
type Widen<T> = T extends boolean
? boolean
: T extends number
? number
: T extends string
? string
: T;
type ExtractSettingsValues<T> = {
-readonly [K in keyof T]: T[K] extends HasTitle
? ExtractSettingsValues<Omit<T[K], "title">>
: T[K] extends IsInputDefinition<T[K]>
? T[K] extends { value: infer V }
? Widen<V>
: never
: T[K] extends Record<string, any>
? ExtractSettingsValues<T[K]>
type SettingsToStore<T> =
T extends { value: infer V }
? V extends readonly string[]
? V[number]
: V
: T extends any[]
? {}
: T extends object
? {
[K in keyof T as T[K] extends object ? K : never]:
SettingsToStore<T[K]>
}
: never;
};
export function settingsToStore<T>(settings: T): ExtractSettingsValues<T> {
export function settingsToStore<T>(settings: T): SettingsToStore<T> {
const result = {} as any;
for (const key in settings) {
const value = settings[key];

View File

@@ -116,7 +116,7 @@
align-items: center;
border-bottom: solid thin var(--outline);
border-left: solid thin var(--outline);
background: var(--layer-0);
background: var(--layer-1);
}
.tabs > button > span {
@@ -124,7 +124,7 @@
}
.tabs > button.active {
background: var(--layer-1);
background: var(--layer-2);
}
.tabs > button.active span {

View File

@@ -49,6 +49,9 @@
? JSON.parse(localStorage.getItem("graph")!)
: templates.defaultPlant,
);
function handleSave(graph: Graph) {
localStorage.setItem("graph", JSON.stringify(graph));
}
let graphInterface = $state<ReturnType<typeof GraphInterface>>(null!);
let viewerComponent = $state<ReturnType<typeof Viewer>>();
@@ -122,32 +125,30 @@
$effect(() => {
//@ts-ignore
AppSettingTypes.debug.stressTest.loadGrid.callback = () => {
graph = templates.grid(
appSettings.value.debug.amount.value,
appSettings.value.debug.amount.value,
manager.load(
templates.grid(
appSettings.value.debug.stressTest.amount,
appSettings.value.debug.stressTest.amount,
),
);
};
//@ts-ignore
AppSettingTypes.debug.stressTest.loadTree.callback = () => {
graph = templates.tree(appSettings.value.debug.amount.value);
manager.load(templates.tree(appSettings.value.debug.stressTest.amount));
};
//@ts-ignore
AppSettingTypes.debug.stressTest.lottaFaces.callback = () => {
graph = templates.lottaFaces;
manager.load(templates.lottaFaces as unknown as Graph);
};
//@ts-ignore
AppSettingTypes.debug.stressTest.lottaNodes.callback = () => {
graph = templates.lottaNodes;
manager.load(templates.lottaNodes as unknown as Graph);
};
//@ts-ignore
AppSettingTypes.debug.stressTest.lottaNodesAndFaces.callback = () => {
graph = templates.lottaNodesAndFaces;
manager.load(templates.lottaNodesAndFaces as unknown as Graph);
};
});
function handleSave(graph: Graph) {
localStorage.setItem("graph", JSON.stringify(graph));
}
</script>
<svelte:document onkeydown={applicationKeymap.handleKeyboardEvent} />

View File

@@ -7,7 +7,7 @@ import {
import { createLogger, createWasmWrapper } from "@nodes/utils";
const log = createLogger("node-registry");
// log.mute();
log.mute();
export class RemoteNodeRegistry implements NodeRegistry {
status: "loading" | "ready" | "error" = "loading";