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

View File

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

View File

@@ -50,6 +50,7 @@
let boxSelection = $state(false); let boxSelection = $state(false);
const cameraDown = [0, 0]; const cameraDown = [0, 0];
let cameraPosition: [number, number, number] = $state([0, 0, 4]); let cameraPosition: [number, number, number] = $state([0, 0, 4]);
let edgeEndPosition = $state<[number, number] | null>();
let addMenuPosition = $state<[number, number] | null>(null); let addMenuPosition = $state<[number, number] | null>(null);
let clipboard: null | { let clipboard: null | {
nodes: Node[]; nodes: Node[];
@@ -465,6 +466,8 @@
n.tmp.downY = n.position[1]; n.tmp.downY = n.position[1];
} }
} }
edgeEndPosition = null;
} }
function copyNodes() { function copyNodes() {
@@ -478,17 +481,15 @@
.filter(Boolean) as Node[]; .filter(Boolean) as Node[];
const _edges = graph.getEdgesBetweenNodes(_nodes); const _edges = graph.getEdgesBetweenNodes(_nodes);
_nodes = $state.snapshot(
_nodes = _nodes.map((_node) => { _nodes.map((_node) => ({
const node = globalThis.structuredClone({
..._node, ..._node,
tmp: { tmp: {
downX: mousePosition[0] - _node.position[0], downX: mousePosition[0] - _node.position[0],
downY: mousePosition[1] - _node.position[1], downY: mousePosition[1] - _node.position[1],
}, },
}); })),
return node; );
});
clipboard = { clipboard = {
nodes: _nodes, 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({ keymap.addShortcut({
key: "?", key: "?",
description: "Toggle Help", description: "Toggle Help",
@@ -561,6 +574,7 @@
callback: () => { callback: () => {
graphState.activeNodeId = -1; graphState.activeNodeId = -1;
graphState.clearSelection(); graphState.clearSelection();
edgeEndPosition = null;
(document.activeElement as HTMLElement)?.blur(); (document.activeElement as HTMLElement)?.blur();
}, },
}); });
@@ -778,6 +792,16 @@
); );
} }
graph.save(); 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 // check if camera moved
@@ -953,7 +977,7 @@
{#if graph.status === "idle"} {#if graph.status === "idle"}
{#if addMenuPosition} {#if addMenuPosition}
<AddMenu bind:position={addMenuPosition} {graph} /> <AddMenu bind:position={addMenuPosition} />
{/if} {/if}
{#if graphState.activeSocket} {#if graphState.activeSocket}
@@ -963,7 +987,10 @@
x: graphState.activeSocket.position[0], x: graphState.activeSocket.position[0],
y: graphState.activeSocket.position[1], y: graphState.activeSocket.position[1],
}} }}
to={{ x: mousePosition[0], y: mousePosition[1] }} to={{
x: edgeEndPosition?.[0] ?? mousePosition[0],
y: edgeEndPosition?.[1] ?? mousePosition[1],
}}
/> />
{/if} {/if}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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