6 Commits

Author SHA1 Message Date
max c42bc93174 feat: make it work
📊 Benchmark the Runtime / release (pull_request) Successful in 1m5s
🚀 Lint & Test & Deploy / release (pull_request) Failing after 59s
2026-04-24 21:45:56 +02:00
max 6457c9db0b fix(ci): correct subsitute stuff
📊 Benchmark the Runtime / release (pull_request) Successful in 1m29s
🚀 Lint & Test & Deploy / release (pull_request) Failing after 1m1s
2026-04-24 21:39:42 +02:00
max 3dba3c2b39 feat: count total-vertices and faces in benchmark
📊 Benchmark the Runtime / release (pull_request) Failing after 1m21s
🚀 Lint & Test & Deploy / release (pull_request) Failing after 56s
2026-04-24 21:35:33 +02:00
max 09a9f8ce2c feat: initial app code 2026-04-24 17:34:10 +02:00
max 0b48740a85 feat: correctly create benchmark folder 2026-04-24 17:32:37 +02:00
max 985b5179af chore: remove debug logs from ci 2026-04-24 14:55:29 +02:00
16 changed files with 293 additions and 69 deletions
+4 -5
View File
@@ -57,7 +57,6 @@ jobs:
echo "${{ secrets.GIT_SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 echo "${{ secrets.GIT_SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p 2222 -H git.max-richter.dev >> ~/.ssh/known_hosts ssh-keyscan -p 2222 -H git.max-richter.dev >> ~/.ssh/known_hosts
ssh -vvv -p 2222 -i ~/.ssh/id_ed25519 -T git@git.max-richter.dev
- name: 📤 Push Results - name: 📤 Push Results
env: env:
@@ -67,13 +66,13 @@ jobs:
git config --global user.email "nodarium-bot@max-richter.dev" git config --global user.email "nodarium-bot@max-richter.dev"
# 2. Clone the benchmarks repo into a temp folder # 2. Clone the benchmarks repo into a temp folder
git config --global core.sshCommand "ssh -vv -p 2222 -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes" git config --global core.sshCommand "ssh -p 2222 -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes"
git clone git@git.max-richter.dev:max/nodarium-benchmarks.git target_bench_repo git clone git@git.max-richter.dev:max/nodarium-benchmarks.git target_bench_repo
# 3. Create a directory structure based on the branch # 3. Create a directory structure based on the branch
# This allows the UI to "switch between branches" # This allows the UI to "switch between branches"
BRANCH_NAME="${{ github.ref_name }}" SAFE_PR_NAME=$(printf "%s" "$GITHUB_HEAD_REF" | tr '/' '-')
DEST_DIR="target_bench_repo/data/$BRANCH_NAME/$(date +%s)" DEST_DIR="target_bench_repo/data/$SAFE_PR_NAME/$(date +%s)"
mkdir -p "$DEST_DIR" mkdir -p "$DEST_DIR"
# 4. Copy the new results # 4. Copy the new results
@@ -83,5 +82,5 @@ jobs:
# 5. Commit and Push # 5. Commit and Push
cd target_bench_repo cd target_bench_repo
git add . git add .
git commit -m "Update benchmarks for $BRANCH_NAME: ${{ github.sha }}" git commit -m "Update benchmarks for $SAFE_PR_NAME: ${{ github.sha }}"
git push origin main git push origin main
+26 -2
View File
@@ -1,5 +1,5 @@
import type { Graph, Graph as GraphType, NodeId } from '@nodarium/types'; import type { Graph, Graph as GraphType, NodeId } from '@nodarium/types';
import { createLogger, createPerformanceStore } from '@nodarium/utils'; import { createLogger, createPerformanceStore, splitNestedArray } from '@nodarium/utils';
import { mkdir, writeFile } from 'node:fs/promises'; import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { MemoryRuntimeExecutor } from '../src/lib/runtime/runtime-executor.ts'; import { MemoryRuntimeExecutor } from '../src/lib/runtime/runtime-executor.ts';
@@ -20,6 +20,26 @@ const templates: Record<string, Graph> = {
'default': defaultPlantTemplate as unknown as GraphType 'default': defaultPlantTemplate as unknown as GraphType
}; };
function countGeometry(result: Int32Array): { totalVertices: number; totalFaces: number } {
const parts = splitNestedArray(result);
let totalVertices = 0;
let totalFaces = 0;
for (const part of parts) {
const type = part[0];
const vertexCount = part[1];
const faceCount = part[2];
if (type === 2) {
const instanceCount = part[3];
totalVertices += vertexCount * instanceCount;
totalFaces += faceCount * instanceCount;
} else {
totalVertices += vertexCount;
totalFaces += faceCount;
}
}
return { totalVertices, 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(plantTemplate.nodes.map(n => n.type) as NodeId[]);
log.log('loaded ' + g.nodes.length + ' nodes'); log.log('loaded ' + g.nodes.length + ' nodes');
@@ -33,10 +53,14 @@ async function run(g: GraphType, amount: number) {
log.log('executing'); log.log('executing');
r.perf = perfStore; r.perf = perfStore;
let res;
for (let i = 0; i < amount; i++) { for (let i = 0; i < amount; i++) {
r.perf?.startRun(); r.perf?.startRun();
await r.execute(g, { randomSeed: true }); res = await r.execute(g, { randomSeed: true });
r.perf?.stopRun(); r.perf?.stopRun();
const { totalVertices, totalFaces } = countGeometry(res!);
r.perf?.addToLastRun('total-vertices', totalVertices);
r.perf?.addToLastRun('total-faces', totalFaces);
} }
log.log('finished'); log.log('finished');
return r.perf.get(); return r.perf.get();
@@ -91,10 +91,10 @@ export class GraphManager extends EventEmitter<{
currentUndoGroup: number | null = null; currentUndoGroup: number | null = null;
// Group-related state // Group-related state
groups: Map<string, NodeGroupDefinition> = new Map(); groups = new SvelteMap<string, NodeGroupDefinition>();
groupNodeDefinitions: Map<string, NodeDefinition> = new Map(); groupNodeDefinitions: Map<string, NodeDefinition> = new Map();
currentGroupContext: string | null = null; currentGroupContext: string | null = null;
graphStack: { rootGraph: Graph; groupId: string; cameraPosition: [number, number, number] }[] = $state([]); graphStack: { rootGraph: Graph; groupId: string; nodeId: number; cameraPosition: [number, number, number] }[] = $state([]);
inputSockets = $derived.by(() => { inputSockets = $derived.by(() => {
const s = new SvelteSet<string>(); const s = new SvelteSet<string>();
@@ -113,12 +113,15 @@ export class GraphManager extends EventEmitter<{
let merged: Graph = this.serialize(); let merged: Graph = this.serialize();
for (let i = this.graphStack.length - 1; i >= 0; i--) { for (let i = this.graphStack.length - 1; i >= 0; i--) {
const { rootGraph, groupId } = $state.snapshot(this.graphStack[i]); const { rootGraph, groupId } = $state.snapshot(this.graphStack[i]);
// Prefer the live definition (may have been updated via addGroupSocket/rename)
// over the snapshot taken when we entered the group.
const currentDef = $state.snapshot((this.graph.groups ?? rootGraph.groups)?.[groupId]);
merged = { merged = {
...rootGraph, ...rootGraph,
groups: { groups: {
...rootGraph.groups, ...rootGraph.groups,
[groupId]: { [groupId]: {
...rootGraph.groups?.[groupId]!, ...(currentDef as NodeGroupDefinition),
graph: { nodes: merged.nodes, edges: merged.edges } graph: { nodes: merged.nodes, edges: merged.edges }
} }
} }
@@ -142,9 +145,13 @@ export class GraphManager extends EventEmitter<{
return { return {
id: `__virtual/group/${group.id}` as NodeId, id: `__virtual/group/${group.id}` as NodeId,
meta: { title: group.name }, meta: { title: group.name },
inputs: Object.fromEntries( inputs: {
// Placeholder for the group-selector dropdown — counted in height/socket math
'__virtual/groupId': { type: 'select' as const, internal: true, label: '' } as NodeInput,
...Object.fromEntries(
group.inputs.map(s => [s.name, { type: s.type, external: true } as NodeInput]) group.inputs.map(s => [s.name, { type: s.type, external: true } as NodeInput])
), )
},
outputs: group.outputs.map(s => s.type), outputs: group.outputs.map(s => s.type),
execute(input: Int32Array): Int32Array { return input; } execute(input: Int32Array): Int32Array { return input; }
}; };
@@ -153,7 +160,12 @@ export class GraphManager extends EventEmitter<{
buildGroupInputNodeDef(group: NodeGroupDefinition): NodeDefinition { buildGroupInputNodeDef(group: NodeGroupDefinition): NodeDefinition {
return { return {
id: '__virtual/group/input' as NodeId, id: '__virtual/group/input' as NodeId,
inputs: {}, meta: { title: 'Input' },
// Each group input socket gets a labeled row (external = no control widget,
// internal = no left-side socket dot; it's an output-only node).
inputs: Object.fromEntries(
group.inputs.map(s => [s.name, { type: s.type, external: true, internal: true }])
) as Record<string, NodeInput>,
outputs: group.inputs.map(s => s.type), outputs: group.inputs.map(s => s.type),
execute(input: Int32Array): Int32Array { return input; } execute(input: Int32Array): Int32Array { return input; }
}; };
@@ -434,11 +446,19 @@ export class GraphManager extends EventEmitter<{
const group = this.groups.get(this.currentGroupContext); const group = this.groups.get(this.currentGroupContext);
if (!group) return; if (!group) return;
const arr = kind === 'input' ? group.inputs : group.outputs; const oldArr = kind === 'input' ? group.inputs : group.outputs;
const name = `${kind}_${arr.length}`; const name = `${kind}_${oldArr.length}`;
arr.push({ name, type: socketType }); const updatedGroup: NodeGroupDefinition = {
...group,
inputs: kind === 'input' ? [...oldArr, { name, type: socketType }] : group.inputs,
outputs: kind === 'output' ? [...oldArr, { name, type: socketType }] : group.outputs
};
this._refreshGroupContext(group); if (!this.graph.groups) this.graph.groups = {};
this.graph.groups[this.currentGroupContext] = updatedGroup;
// Reinitialize so virtual group input/output nodes pick up the new socket reactively
this._init(this.serialize());
this.save(); this.save();
} }
@@ -447,10 +467,17 @@ export class GraphManager extends EventEmitter<{
const group = this.groups.get(this.currentGroupContext); const group = this.groups.get(this.currentGroupContext);
if (!group) return; if (!group) return;
const arr = kind === 'input' ? group.inputs : group.outputs; const oldArr = kind === 'input' ? group.inputs : group.outputs;
arr.splice(index, 1); const updatedGroup: NodeGroupDefinition = {
...group,
inputs: kind === 'input' ? oldArr.filter((_, i) => i !== index) : group.inputs,
outputs: kind === 'output' ? oldArr.filter((_, i) => i !== index) : group.outputs
};
this._refreshGroupContext(group); if (!this.graph.groups) this.graph.groups = {};
this.graph.groups[this.currentGroupContext] = updatedGroup;
this._init(this.serialize());
this.save(); this.save();
} }
@@ -479,6 +506,51 @@ export class GraphManager extends EventEmitter<{
} }
} }
pruneUnusedGroups() {
const usedIds = new Set<string>();
// Scan nodes in the current graph
for (const node of this.nodes.values()) {
if (node.type === '__virtual/group/instance') {
const gid = node.props?.groupId as string | undefined;
if (gid) usedIds.add(gid);
}
}
// Scan nodes in every stacked (parent) graph
for (const { rootGraph } of this.graphStack) {
for (const node of rootGraph.nodes) {
if (node.type === '__virtual/group/instance' && node.props?.groupId) {
usedIds.add(node.props.groupId as string);
}
}
}
// Scan internal graphs of all known groups (nested group nodes)
for (const group of this.groups.values()) {
for (const node of group.graph.nodes) {
if (node.type === '__virtual/group/instance' && node.props?.groupId) {
usedIds.add(node.props.groupId as string);
}
}
}
let changed = false;
for (const groupId of [...this.groups.keys()]) {
if (!usedIds.has(groupId)) {
this.groups.delete(groupId);
this.groupNodeDefinitions.delete(`__virtual/group/${groupId}`);
if (this.graph.groups) delete this.graph.groups[groupId];
changed = true;
}
}
if (changed) {
this.execute();
this.save();
}
}
// --- Group navigation --- // --- Group navigation ---
enterGroup(nodeId: number, cameraPosition: [number, number, number] = [0, 0, 4]): boolean { enterGroup(nodeId: number, cameraPosition: [number, number, number] = [0, 0, 4]): boolean {
@@ -491,7 +563,7 @@ export class GraphManager extends EventEmitter<{
if (!group) return false; if (!group) return false;
const currentSerialized = this.serialize(); const currentSerialized = this.serialize();
this.graphStack.push({ rootGraph: currentSerialized, groupId, cameraPosition }); this.graphStack.push({ rootGraph: currentSerialized, groupId, nodeId, cameraPosition });
this.currentGroupContext = groupId; this.currentGroupContext = groupId;
@@ -509,10 +581,10 @@ export class GraphManager extends EventEmitter<{
return true; return true;
} }
exitGroup(): [number, number, number] | false { exitGroup(): { camera: [number, number, number]; nodeId: number } | false {
if (this.graphStack.length === 0) return false; if (this.graphStack.length === 0) return false;
const { rootGraph, groupId, cameraPosition } = this.graphStack[this.graphStack.length - 1]; const { rootGraph, groupId, nodeId, cameraPosition } = this.graphStack[this.graphStack.length - 1];
this.graphStack.pop(); this.graphStack.pop();
// Serialize current internal graph state // Serialize current internal graph state
@@ -542,7 +614,7 @@ export class GraphManager extends EventEmitter<{
this.history.reset(); this.history.reset();
this.save(); this.save();
return cameraPosition; return { camera: cameraPosition, nodeId };
} }
get isInsideGroup(): boolean { get isInsideGroup(): boolean {
@@ -984,6 +1056,7 @@ export class GraphManager extends EventEmitter<{
} }
removeNode(node: NodeInstance, { restoreEdges = false } = {}) { removeNode(node: NodeInstance, { restoreEdges = false } = {}) {
if (node.type === '__virtual/group/input' || node.type === '__virtual/group/output') return;
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]) {
@@ -1211,11 +1284,9 @@ export class GraphManager extends EventEmitter<{
return; return;
} }
// Don't emit save event while navigating inside a group const fullState = this.graphStack.length > 0 ? this.serializeFullGraph() : state;
if (this.graphStack.length > 0) return; this.emit('save', fullState);
logger.log('saving graphs', fullState);
this.emit('save', state);
logger.log('saving graphs', state);
} }
getParentsOfNode(node: NodeInstance) { getParentsOfNode(node: NodeInstance) {
@@ -91,21 +91,23 @@
function navigateToBreadcrumb(index: number) { function navigateToBreadcrumb(index: number) {
const crumbs = manager.breadcrumbs; const crumbs = manager.breadcrumbs;
const depth = crumbs.length - 1 - index; const depth = crumbs.length - 1 - index;
let restoredCamera: [number, number, number] | false = false; let result: { camera: [number, number, number]; nodeId: number } | false = false;
for (let i = 0; i < depth; i++) { for (let i = 0; i < depth; i++) {
const groupId = manager.currentGroupContext; const groupId = manager.currentGroupContext;
if (groupId) { if (groupId) {
state.groupCameras.set(groupId, [...state.cameraPosition] as [number, number, number]); state.groupCameras.set(groupId, [...state.cameraPosition] as [number, number, number]);
} }
restoredCamera = manager.exitGroup(); result = manager.exitGroup();
} }
if (result !== false) {
state.activeNodeId = result.nodeId;
state.clearSelection();
state.cameraPosition[0] = result.camera[0];
state.cameraPosition[1] = result.camera[1];
state.cameraPosition[2] = result.camera[2];
} else {
state.activeNodeId = -1; state.activeNodeId = -1;
state.clearSelection(); state.clearSelection();
if (restoredCamera !== false) {
state.cameraPosition[0] = restoredCamera[0];
state.cameraPosition[1] = restoredCamera[1];
state.cameraPosition[2] = restoredCamera[2];
} else {
state.centerNode(); state.centerNode();
} }
} }
@@ -31,6 +31,20 @@ export function getSocketPosition(
index: string | number index: string | number
): [number, number] { ): [number, number] {
if (typeof index === 'number') { if (typeof index === 'number') {
if (node.type === '__virtual/group/input') {
const nodeType = node.state.type;
const keys = Object.keys(nodeType?.inputs || {});
let height = 5;
for (let i = 0; i < keys.length; i++) {
const h = getParameterHeight(nodeType!, keys[i]) / 10;
if (i === index) { height += h / 2; break; }
height += h;
}
return [
(node?.state?.x ?? node.position[0]) + 20,
(node?.state?.y ?? node.position[1]) + height
];
}
return [ return [
(node?.state?.x ?? node.position[0]) + 20, (node?.state?.x ?? node.position[0]) + 20,
(node?.state?.y ?? node.position[1]) + 2.5 + 10 * index (node?.state?.y ?? node.position[1]) + 2.5 + 10 * index
+6 -6
View File
@@ -55,13 +55,13 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
[...graphState.cameraPosition] as [number, number, number] [...graphState.cameraPosition] as [number, number, number]
); );
} }
const savedCamera = graph.exitGroup(); const result = graph.exitGroup();
if (savedCamera !== false) { if (result !== false) {
graphState.activeNodeId = -1; graphState.activeNodeId = result.nodeId;
graphState.clearSelection(); graphState.clearSelection();
graphState.cameraPosition[0] = savedCamera[0]; graphState.cameraPosition[0] = result.camera[0];
graphState.cameraPosition[1] = savedCamera[1]; graphState.cameraPosition[1] = result.camera[1];
graphState.cameraPosition[2] = savedCamera[2]; graphState.cameraPosition[2] = result.camera[2];
return; return;
} }
} }
+1 -1
View File
@@ -40,7 +40,7 @@
let meshRef: Mesh | undefined = $state(); let meshRef: Mesh | undefined = $state();
const height = getNodeHeight(node.state.type!); const height = $derived(getNodeHeight(node.state.type!));
const zoom = $derived(graphState.cameraPosition[2]); const zoom = $derived(graphState.cameraPosition[2]);
@@ -37,16 +37,38 @@
); );
if (node.type === '__virtual/group/instance') { if (node.type === '__virtual/group/instance') {
const groupOptions = [...(manager?.groups?.entries() ?? [])].map(([id, g]) => ({
label: g.name,
value: id
}));
// Remove the static placeholder from the definition (height-only) and replace
// with a fully dynamic version that carries current names + value.
parameters = parameters.filter(([key]) => key !== '__virtual/groupId');
parameters = [['__virtual/groupId', { parameters = [['__virtual/groupId', {
type: 'select', type: 'select',
value: node.props?.groupId as string, value: node.props?.groupId as string,
options: [...manager?.groups?.keys()] options: groupOptions
}], ...parameters]; }], ...parameters];
} }
return parameters; return parameters;
} }
$effect(() => {
const props = node.props as Record<string, unknown> | undefined;
const virtualGroupId = props?.['__virtual/groupId'] as string | undefined;
if (!virtualGroupId) return;
const activeGroupId = props?.groupId as string | undefined;
if (virtualGroupId === activeGroupId) return;
const newGroupDef = manager?.groupNodeDefinitions.get(`__virtual/group/${virtualGroupId}`);
if (!newGroupDef) return;
const { children, parents, ref } = node.state;
node.props = { ...props, groupId: virtualGroupId, '__virtual/groupId': virtualGroupId };
node.state = { type: newGroupDef, children, parents, ref };
manager?.execute();
manager?.save();
});
const parameters = $derived(buildParameters(node, node?.state?.type?.inputs || {})); const parameters = $derived(buildParameters(node, node?.state?.type?.inputs || {}));
const currentGroupId = $derived((node.props?.groupId as string) ?? ''); const currentGroupId = $derived((node.props?.groupId as string) ?? '');
@@ -106,6 +128,7 @@
id={key} id={key}
input={value} input={value}
isLast={i == parameters.length - 1} isLast={i == parameters.length - 1}
outputIndex={node.type === '__virtual/group/input' ? i : undefined}
/> />
{/each} {/each}
</div> </div>
@@ -22,7 +22,7 @@
} }
const cornerTop = 10; const cornerTop = 10;
const rightBump = $derived(!!node?.state?.type?.outputs?.length); const rightBump = $derived(!!node?.state?.type?.outputs?.length && node.type !== '__virtual/group/input');
const aspectRatio = 0.25; const aspectRatio = 0.25;
const path = $derived( const path = $derived(
@@ -72,6 +72,7 @@
{/if} {/if}
{node.state?.type?.meta?.title ?? node.type.split('/').pop()} {node.state?.type?.meta?.title ?? node.type.split('/').pop()}
</div> </div>
{#if node.type !== '__virtual/group/input'}
<div <div
class="target" class="target"
role="button" role="button"
@@ -79,6 +80,7 @@
onmousedown={handleMouseDown} onmousedown={handleMouseDown}
> >
</div> </div>
{/if}
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100" viewBox="0 0 100 100"
@@ -10,6 +10,7 @@
input: NodeInput; input: NodeInput;
id: string; id: string;
isLast?: boolean; isLast?: boolean;
outputIndex?: number;
}; };
const graph = getGraphManager(); const graph = getGraphManager();
@@ -17,13 +18,14 @@
const graphId = graph?.id; const graphId = graph?.id;
const elementId = `input-${Math.random().toString(36).substring(7)}`; const elementId = `input-${Math.random().toString(36).substring(7)}`;
let { node = $bindable(), input, id, isLast }: Props = $props(); let { node = $bindable(), input, id, isLast, outputIndex = undefined }: Props = $props();
const nodeType = $derived(node.state.type!); const nodeType = $derived(node.state.type!);
const inputType = $derived(nodeType.inputs?.[id]); const inputType = $derived(nodeType.inputs?.[id]);
const socketId = $derived(`${node.id}-${id}`); const socketId = $derived(`${node.id}-${id}`);
const outputSocketId = $derived(outputIndex !== undefined ? `${node.id}-${outputIndex}` : '');
const height = $derived(getParameterHeight(nodeType, id)); const height = $derived(getParameterHeight(nodeType, id));
function handleMouseDown(ev: MouseEvent) { function handleMouseDown(ev: MouseEvent) {
@@ -36,7 +38,19 @@
}); });
} }
const leftBump = $derived(!id.startsWith('__virtual') && nodeType.inputs?.[id].internal !== true); function handleOutputMouseDown(ev: MouseEvent) {
ev.preventDefault();
ev.stopPropagation();
if (outputIndex === undefined) return;
graphState.setDownSocket({
node,
index: outputIndex,
position: getSocketPosition(node, outputIndex)
});
}
const leftBump = $derived(!id.startsWith('__virtual') && nodeType.inputs?.[id].internal !== true && outputIndex === undefined);
const rightBump = $derived(outputIndex !== undefined);
const cornerBottom = $derived(isLast ? 5 : 0); const cornerBottom = $derived(isLast ? 5 : 0);
const aspectRatio = 0.5; const aspectRatio = 0.5;
@@ -47,6 +61,7 @@
y: 50.5, y: 50.5,
cornerBottom, cornerBottom,
leftBump, leftBump,
rightBump,
aspectRatio aspectRatio
}) })
); );
@@ -57,6 +72,7 @@
y: 50.5, y: 50.5,
cornerBottom, cornerBottom,
leftBump, leftBump,
rightBump,
aspectRatio aspectRatio
}) })
); );
@@ -79,7 +95,9 @@
data-node-input={id} data-node-input={id}
style:height="{height}px" style:height="{height}px"
style:--socket-color={hoverColor} style:--socket-color={hoverColor}
class:possible-socket={graphState?.possibleSocketIds.has(socketId)} class:possible-socket={outputIndex !== undefined
? graphState?.possibleSocketIds.has(outputSocketId)
: graphState?.possibleSocketIds.has(socketId)}
> >
{#key id && graphId} {#key id && graphId}
<div class="content" class:disabled={graph?.inputSockets?.has(socketId)}> <div class="content" class:disabled={graph?.inputSockets?.has(socketId)}>
@@ -91,7 +109,7 @@
{/if} {/if}
</div> </div>
{#if node?.state?.type?.inputs?.[id]?.internal !== true} {#if outputIndex === undefined && node?.state?.type?.inputs?.[id]?.internal !== true}
<div <div
data-node-socket data-node-socket
class="target" class="target"
@@ -103,6 +121,17 @@
{/if} {/if}
{/key} {/key}
{#if outputIndex !== undefined}
<div
data-node-socket
class="target target-right"
onmousedown={handleOutputMouseDown}
role="button"
tabindex="0"
>
</div>
{/if}
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100" viewBox="0 0 100 100"
@@ -130,6 +159,16 @@
transform: translateY(-50%) translateX(-50%); transform: translateY(-50%) translateX(-50%);
} }
.target-right {
right: 0;
left: auto;
transform: translateY(-50%) translateX(50%);
}
.target-right:hover ~ svg path {
d: var(--hover-path);
}
.possible-socket .target::before { .possible-socket .target::before {
content: ""; content: "";
position: absolute; position: absolute;
+1 -1
View File
@@ -52,7 +52,7 @@
// select input: use index into options // select input: use index into options
if ('options' in node && Array.isArray(node.options)) { if ('options' in node && Array.isArray(node.options)) {
if (typeof inputValue === 'string') { if (typeof inputValue === 'string') {
return node.options.indexOf(inputValue); return (node.options as string[]).indexOf(inputValue);
} }
return 0; return 0;
} }
@@ -5,8 +5,6 @@
type Props = { manager: GraphManager; groupId: string }; type Props = { manager: GraphManager; groupId: string };
const { manager, groupId }: Props = $props(); const { manager, groupId }: Props = $props();
$inspect({ groupId });
const group = $derived(manager.groups.get(groupId)); const group = $derived(manager.groups.get(groupId));
const COMMON_TYPES = ['plant', 'float', 'int', 'vec3', 'bool']; const COMMON_TYPES = ['plant', 'float', 'int', 'vec3', 'bool'];
@@ -34,6 +32,10 @@
function removeSocket(index: number) { function removeSocket(index: number) {
manager.removeGroupSocket('input', index); manager.removeGroupSocket('input', index);
} }
function prune() {
manager.pruneUnusedGroups();
}
</script> </script>
<div class="bg-layer-2 flex items-center h-[70px] border-b-1 border-outline pl-4"> <div class="bg-layer-2 flex items-center h-[70px] border-b-1 border-outline pl-4">
@@ -85,6 +87,11 @@
<button class="add-btn" onclick={addSocket}>+ Add</button> <button class="add-btn" onclick={addSocket}>+ Add</button>
</div> </div>
</div> </div>
<div class="flex flex-col gap-1.5">
<span class="section-label">Maintenance</span>
<button class="danger-btn" onclick={prune}>Prune unused groups</button>
</div>
</div> </div>
<style> <style>
@@ -145,4 +152,24 @@
.add-btn:hover { .add-btn:hover {
outline-color: var(--color-selected); outline-color: var(--color-selected);
} }
.danger-btn {
background: var(--color-layer-2);
color: var(--color-text);
border: none;
outline: 1px solid var(--color-outline);
border-radius: 5px;
padding: 0.4em 0.7em;
font-size: 0.8em;
cursor: pointer;
font-family: var(--font-family);
opacity: 0.7;
width: 100%;
text-align: left;
}
.danger-btn:hover {
outline-color: #e05050;
opacity: 1;
}
</style> </style>
+4 -1
View File
@@ -61,7 +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.union([
z.array(z.string()),
z.array(z.object({ label: z.string(), value: z.string() }))
]).optional(),
value: z.string().optional() value: z.string().optional()
}); });
+1 -1
View File
@@ -40,7 +40,7 @@
{:else if input.type === 'boolean'} {:else if input.type === 'boolean'}
<InputCheckbox bind:value={value as boolean} {id} /> <InputCheckbox bind:value={value as boolean} {id} />
{:else if input.type === 'select'} {:else if input.type === 'select'}
<InputSelect bind:value={value as number} options={input.options} {id} /> <InputSelect bind:value={value as number | string} options={input.options} {id} />
{:else if input.type === 'vec3'} {:else if input.type === 'vec3'}
<InputVec3 bind:value={value as [number, number, number]} {id} /> <InputVec3 bind:value={value as [number, number, number]} {id} />
{/if} {/if}
+16 -5
View File
@@ -1,17 +1,28 @@
<script lang="ts"> <script lang="ts">
type StringOption = string;
type LabeledOption = { label: string; value: string };
interface Props { interface Props {
options?: string[]; options?: StringOption[] | LabeledOption[];
value?: number; value?: number | string;
id?: string; id?: string;
} }
let { options = [], value = $bindable(0), id = '' }: Props = $props(); let { options = [], value = $bindable<number | string>(0), id = '' }: Props = $props();
const isLabeled = $derived(options.length > 0 && typeof options[0] === 'object');
</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)} {#if isLabeled}
<option value={i}>{label}</option> {#each options as opt ((opt as LabeledOption).value)}
<option value={(opt as LabeledOption).value}>{(opt as LabeledOption).label}</option>
{/each} {/each}
{:else}
{#each options as label, i (label)}
<option value={i}>{label as string}</option>
{/each}
{/if}
</select> </select>
<style> <style>
+9
View File
@@ -4,6 +4,7 @@ export interface PerformanceStore {
startRun(): void; startRun(): void;
stopRun(): void; stopRun(): void;
addPoint(name: string, value?: number): void; addPoint(name: string, value?: number): void;
addToLastRun(name: string, value: number): void;
endPoint(name?: string): void; endPoint(name?: string): void;
mergeData(data: PerformanceData[number]): void; mergeData(data: PerformanceData[number]): void;
get: () => PerformanceData; get: () => PerformanceData;
@@ -63,6 +64,13 @@ export function createPerformanceStore(): PerformanceStore {
} }
} }
function addToLastRun(name: string, value: number) {
const last = data[data.length - 1];
if (!last) return;
last[name] = last[name] || [];
last[name].push(value);
}
function get() { function get() {
return data; return data;
} }
@@ -94,6 +102,7 @@ export function createPerformanceStore(): PerformanceStore {
startRun, startRun,
stopRun, stopRun,
addPoint, addPoint,
addToLastRun,
endPoint, endPoint,
mergeData, mergeData,
get get