From ad05ab43fcb7227dd7ecc65fbfa57bc6c0ad95fc Mon Sep 17 00:00:00 2001 From: glypse Date: Sat, 11 Jul 2026 18:14:10 +0200 Subject: [PATCH 01/15] Quick save --- apps/web/src/routes/editor/+page.svelte | 4 +- .../lib/components/editor/GraphContent.svelte | 79 +- .../lib/components/editor/NodeGraph.svelte | 93 +- .../lib/components/editor/NodeShell.svelte | 138 +- .../src/lib/components/editor/codegen.ts | 321 ++++- .../src/lib/components/editor/debug.json | 64 + .../components/editor/graph-state.svelte.ts | 639 +++++++- .../lib/components/editor/nodes/group.node.ts | 43 + .../editor/nodes/groupInput.node.ts | 18 + .../editor/nodes/groupOutput.node.ts | 18 + .../src/lib/components/editor/registry.ts | 29 +- .../lib/components/editor/sizing_circle.json | 1283 +++++++++-------- .../editor/src/lib/components/editor/types.ts | 16 + .../editor/src/lib/components/editor/utils.ts | 3 + .../src/lib/components/editor/xy-theme.css | 12 +- packages/editor/src/lib/index.ts | 3 +- .../ui/breadcrumb/breadcrumb-ellipsis.svelte | 24 + .../ui/breadcrumb/breadcrumb-item.svelte | 21 + .../ui/breadcrumb/breadcrumb-link.svelte | 32 + .../ui/breadcrumb/breadcrumb-list.svelte | 21 + .../ui/breadcrumb/breadcrumb-page.svelte | 24 + .../ui/breadcrumb/breadcrumb-separator.svelte | 28 + .../ui/breadcrumb/breadcrumb.svelte | 22 + .../src/lib/components/ui/breadcrumb/index.ts | 25 + packages/theme/src/lib/components/ui/index.ts | 1 + tsconfig.base.json | 3 +- 26 files changed, 2070 insertions(+), 894 deletions(-) create mode 100644 packages/editor/src/lib/components/editor/debug.json create mode 100644 packages/editor/src/lib/components/editor/nodes/group.node.ts create mode 100644 packages/editor/src/lib/components/editor/nodes/groupInput.node.ts create mode 100644 packages/editor/src/lib/components/editor/nodes/groupOutput.node.ts create mode 100644 packages/editor/src/lib/components/editor/utils.ts create mode 100644 packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte create mode 100644 packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte create mode 100644 packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte create mode 100644 packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte create mode 100644 packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte create mode 100644 packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte create mode 100644 packages/theme/src/lib/components/ui/breadcrumb/breadcrumb.svelte create mode 100644 packages/theme/src/lib/components/ui/breadcrumb/index.ts diff --git a/apps/web/src/routes/editor/+page.svelte b/apps/web/src/routes/editor/+page.svelte index bb9ed73..3161870 100644 --- a/apps/web/src/routes/editor/+page.svelte +++ b/apps/web/src/routes/editor/+page.svelte @@ -1,11 +1,13 @@ -
+
- + diff --git a/packages/editor/src/lib/components/editor/NodeGraph.svelte b/packages/editor/src/lib/components/editor/NodeGraph.svelte index 1681636..4e7f44a 100644 --- a/packages/editor/src/lib/components/editor/NodeGraph.svelte +++ b/packages/editor/src/lib/components/editor/NodeGraph.svelte @@ -1,4 +1,5 @@ - + {#if graph.path.length > 0} +
+ + + + + { + e.preventDefault(); + graph.jumpToPath([]); + }} + > + Root + + + {#each graph.pathLabels as segment, i (segment.id)} + + + {#if i === graph.pathLabels.length - 1} + {segment.label} + {:else} + { + e.preventDefault(); + graph.jumpToPath(graph.path.slice(0, i + 1)); + }} + > + {segment.label} + + {/if} + + {/each} + + +
+ {/if} + +
+ + + {#if graph.path.length > 0} +
+ + + + { + e.preventDefault(); + graph.jumpToPath([]); + }} + > + Root + + + {#each graph.pathLabels as segment, i (segment.id)} + + + {#if i === graph.pathLabels.length - 1} + {segment.label} + {:else} + { + e.preventDefault(); + graph.jumpToPath(graph.path.slice(0, i + 1)); + }} + > + {segment.label} + + {/if} + + {/each} + + +
+ {/if} +
diff --git a/packages/editor/src/lib/components/editor/NodeShell.svelte b/packages/editor/src/lib/components/editor/NodeShell.svelte index e55d14f..4601ae9 100644 --- a/packages/editor/src/lib/components/editor/NodeShell.svelte +++ b/packages/editor/src/lib/components/editor/NodeShell.svelte @@ -1,9 +1,10 @@ +{#snippet addHandleButton()} + +{/snippet} + {#if descriptor} -
-
- - {label} - +
+ + {label} + +
+ +
+
+ {#each outputs as outputDef (outputDef.name)} +
+ {outputDef.name}: {outputDef.type} + +
+ {/each} + + {#if nodeType === "group-input"} + {@render addHandleButton()} + {/if}
-
-
- {#each outputs as outputDef (outputDef.name)} -
+
+ {#each inputs as inputDef (inputDef.name)} +
+ + {#if inputDef.type !== "f32"} {outputDef.name}: {outputDef.type}{inputDef.name}: {inputDef.type} - -
- {/each} -
+ {/if} -
- {#each inputs as inputDef (inputDef.name)} -
- { + const val = parseFloat(e.currentTarget.value) || 0; + updateNodeData(id, { + ...data, + [inputDef.name]: val, + }); + }} /> - {#if inputDef.type !== "f32"} - {inputDef.name}: {inputDef.type} - {/if} + {/if} +
+ {/each} - {#if inputDef.type === "f32" && !inputDef.variadic && data[inputDef.name] !== undefined} - { - const val = parseFloat(e.currentTarget.value) || 0; - updateNodeData(id, { - ...data, - [inputDef.name]: val, - }); - }} - /> - {/if} -
- {/each} -
+ {#if nodeType === "group-output"} + {@render addHandleButton()} + {/if}
{:else} diff --git a/packages/editor/src/lib/components/editor/codegen.ts b/packages/editor/src/lib/components/editor/codegen.ts index aef655c..2939967 100644 --- a/packages/editor/src/lib/components/editor/codegen.ts +++ b/packages/editor/src/lib/components/editor/codegen.ts @@ -1,7 +1,7 @@ import type { Edge, Node } from "@xyflow/svelte"; -import { nodeRegistry } from "./registry"; -import type { WgslType } from "./types"; +import { getGraphManager } from "./graph-state.svelte"; +import type { GroupedNode, HandleDef, NodeDescriptor, WgslType } from "./types"; import { WGSL_DEFAULTS } from "./types"; /** @@ -19,7 +19,6 @@ function formatWgslLiteral(type: WgslType, value: unknown): string { if (type === "f32") { return `f32(${toNum(value, 0)})`; } - // For vector types, try to read individual components from the data object if (typeof value === "object" && value !== null) { const obj = value as Record; switch (type) { @@ -28,19 +27,23 @@ function formatWgslLiteral(type: WgslType, value: unknown): string { case "vec3f": return `vec3f(f32(${toNum(obj.x, 0)}), f32(${toNum(obj.y, 0)}), f32(${toNum(obj.z, 0)}))`; case "vec4f": - return `vec4f(f32(${toNum(obj.x, 0)}), f32(${toNum(obj.y, 0)}), f32(${toNum(obj.z, 0)}), f32(${toNum(obj.w, 1)}))`; + return `vec4f(f32(${toNum(obj.x, 0)}), f32(${toNum(obj.y, 0)}), f32(${toNum(obj.z, 0)}), f32(${toNum(obj.w, 0)}))`; } } return WGSL_DEFAULTS[type]; } /** - * Walk backwards from output nodes through edges to find all node IDs - * that contribute to the final shader output. - * Nodes not connected (directly or transitively) to any output node are excluded. + * Walk backwards from seed-type nodes through edges to find all node IDs + * that contribute to the final output. `seedType` is "frag-output" at root, + * "group-output" inside any group's own subgraph — that's the only thing + * that differs between generating the root shader and generating one group's function. */ -function findReachableNodeIds(nodes: Map, edges: Edge[]): Set { - // Build reverse adjacency: target -> sources feeding into it +function findReachableNodeIds( + nodes: Map, + edges: Edge[], + seedType: string, +): Set { const reverseAdj = new Map(); for (const node of nodes.values()) { reverseAdj.set(node.id, []); @@ -53,9 +56,8 @@ function findReachableNodeIds(nodes: Map, edges: Edge[]): Set(); const queue: string[] = []; - // Seed the BFS from all output nodes for (const node of nodes.values()) { - if (node.type === "frag-output") { + if (node.type === seedType) { reachable.add(node.id); queue.push(node.id); } @@ -63,7 +65,7 @@ function findReachableNodeIds(nodes: Map, edges: Edge[]): Set 0) { const current = queue.shift(); - if (!current) break; // should never happen given the length check + if (!current) break; for (const source of reverseAdj.get(current) ?? []) { if (!reachable.has(source)) { reachable.add(source); @@ -76,8 +78,8 @@ function findReachableNodeIds(nodes: Map, edges: Edge[]): Set, edges: Edge[]): string[] { const adjacency = new Map(); @@ -118,14 +120,15 @@ function topologicalSort(nodes: Map, edges: Edge[]): string[] { return sorted; } -/** - * Produce the name of a variable holding the output of a node. - * Each variable is a valid WGSL identifier. - */ function getVarName(nodeId: string): string { return `v_${nodeId.replace(/[.-]/g, "_")}`; } +/** WGSL identifiers can't contain characters like "-" from a UUID's dashes. */ +function sanitizeIdent(id: string): string { + return `h_${id.replace(/[.-]/g, "_")}`; +} + export function debugSort(nodes: Node[], edges: Edge[]): string[] { const nodeMap = new Map(); for (const node of nodes) { @@ -136,16 +139,27 @@ export function debugSort(nodes: Node[], edges: Edge[]): string[] { /** * Resolve the WGSL variable name for a given edge's source. - * Single-output sources: just `v_nodeId`. - * Multi-output sources: `v_nodeId_outputName` — the sourceHandle tells us which output. + * - Single-output sources: `v_nodeId`. + * - Multi-output sources (ordinary nodes): `v_nodeId_outputName`. + * - Group-node sources: `v_nodeId.outputHandleId` — a field access on the + * struct the group's generated function returns, since a group's "outputs" + * are all produced by ONE function call, not separate `let` statements. */ -function getSourceVar(edge: Edge, nodes: Map): string { - const base = getVarName(edge.source); - // Only append a suffix if the source node has multiple outputs. - // Single-output nodes get no suffix even if sourceHandle is set (SvelteFlow always sets it). +function getSourceVar( + edge: Edge, + nodes: Map, + resolveDescriptor: (node: GroupedNode) => NodeDescriptor, +): string { const sourceNode = nodes.get(edge.source); + if (sourceNode?.type === "group-input" && edge.sourceHandle) { + return sanitizeIdent(edge.sourceHandle); // it's a function parameter, not a let-var + } + const base = getVarName(edge.source); if (edge.sourceHandle && sourceNode?.type) { - const sourceDescriptor = nodeRegistry[sourceNode.type]; + if (sourceNode.type === "node-group") { + return `${base}.${sanitizeIdent(edge.sourceHandle)}`; + } + const sourceDescriptor = resolveDescriptor(sourceNode); if (sourceDescriptor.outputs.length > 1) { return base + "_" + edge.sourceHandle; } @@ -155,7 +169,6 @@ function getSourceVar(edge: Edge, nodes: Map): string { /** * Compute a stable hash from the graph state that actually affects the shader output. - * Position changes, for example, produce the same hash — skipping rebuilds on drag. */ export function shaderHash(nodes: Node[], edges: Edge[]): string { return JSON.stringify({ @@ -170,23 +183,80 @@ export function shaderHash(nodes: Node[], edges: Edge[]): string { } /** - * Emit the full WGSL fragment shader source from the graph. + * Hash the entire project (root + all definitions) so that edits inside any + * subgraph also trigger a re-generation of the shader. */ -export function generateShader(nodes: Node[], edges: Edge[]): string { +export function projectHash(project: { + root: { nodes: Node[]; edges: Edge[] }; + definitions: Record; +}): string { + return JSON.stringify({ + root: { + nodes: project.root.nodes.map((n) => ({ id: n.id, type: n.type, data: n.data })), + edges: project.root.edges.map((e) => ({ + source: e.source, + target: e.target, + sourceHandle: e.sourceHandle, + targetHandle: e.targetHandle, + })), + }, + definitions: Object.fromEntries( + Object.entries(project.definitions).map(([key, doc]) => [ + key, + { + nodes: doc.nodes.map((n) => ({ id: n.id, type: n.type, data: n.data })), + edges: doc.edges.map((e) => ({ + source: e.source, + target: e.target, + sourceHandle: e.sourceHandle, + targetHandle: e.targetHandle, + })), + }, + ]), + ), + }); +} + +/** + * Every group's generated `struct` + `fn` text, collected once per distinct + * definitionRef while walking the whole graph (root + every nested group). + * Keying by definitionRef is what gives you memoization for free: two locked + * instances of the same group ask for the same key, so the second lookup + * short-circuits `emitGroupFunction` instead of generating a duplicate. + */ +type GroupDecls = Map< + string, + { structDecl: string; fnDecl: string; fnName: string; structName: string } +>; + +/** + * The reusable "walk one subgraph and emit its body lines" logic — this is the + * part of the old generateShader that had nothing to do with being the root. + * Used both for the root fragment body and for every group's function body. + * + * Returns the emitted lines AND the variable name that should be returned — + * for root that's whatever feeds frag-output; for a group, we build a struct + * literal from whatever feeds group-output instead (handled by the caller). + */ +function generateBody( + nodes: Node[], + edges: Edge[], + seedType: string, + groupDecls: GroupDecls, + resolveDescriptor: (node: GroupedNode) => NodeDescriptor, + getGroupDefinition: (definitionRef: string) => { nodes: Node[]; edges: Edge[] } | undefined, +): { lines: string[]; seedNodeVarName: string | null } { const nodeMap = new Map(); for (const node of nodes) { nodeMap.set(node.id, node); } - // Only keep nodes that contribute (directly or transitively) to an output node - const reachableIds = findReachableNodeIds(nodeMap, edges); + const reachableIds = findReachableNodeIds(nodeMap, edges, seedType); const filteredEdges = edges.filter( (e) => reachableIds.has(e.source) && reachableIds.has(e.target), ); - const sortedIds = topologicalSort(nodeMap, filteredEdges).filter((id) => reachableIds.has(id)); - // Build reverse map: target -> incoming edges (only from the reachable subgraph) const targetEdgeMap = new Map(); for (const edge of filteredEdges) { const list = targetEdgeMap.get(edge.target) ?? []; @@ -195,32 +265,63 @@ export function generateShader(nodes: Node[], edges: Edge[]): string { } const lines: string[] = []; - let resultVariable: string | null = null; + let seedNodeVarName: string | null = null; for (const nodeId of sortedIds) { const node = nodeMap.get(nodeId); - if (!node) continue; - - if (!node.type) continue; - const descriptor = nodeRegistry[node.type]; + if (!node?.type) continue; const varName = getVarName(nodeId); const incoming = targetEdgeMap.get(nodeId) ?? []; + // Group nodes don't call descriptor.wgsl() the normal way — they need + // their own function generated first (recursively), THEN a one-line + // call to it. Every other node type falls through to the shared path below. + if (node.type === "node-group") { + const groupedNode = node as GroupedNode; + const definitionRef = groupedNode.group?.definitionRef; + if (definitionRef) { + emitGroupFunctionIfNeeded( + definitionRef, + groupDecls, + resolveDescriptor, + getGroupDefinition, + ); + const decl = groupDecls.get(definitionRef); + if (decl) { + const descriptor = resolveDescriptor(groupedNode); + const callArgs = descriptor.inputs.map((inputDef) => { + const edgesForHandle = incoming.filter( + (e) => + e.targetHandle === inputDef.name || + (descriptor.inputs.length === 1 && !e.targetHandle), + ); + const firstEdge = edgesForHandle[0]; + if (firstEdge) return getSourceVar(firstEdge, nodeMap, resolveDescriptor); + const dataVal = + node.data[inputDef.name] ?? descriptor.defaultData[inputDef.name]; + return formatWgslLiteral(inputDef.type, dataVal); + }); + lines.push(` let ${varName} = ${decl.fnName}(${callArgs.join(", ")});`); + } + } + continue; + } + + if (node.type === "group-input" || node.type === "group-output") { + continue; // sinks/sources handled structurally by emitGroupFunctionIfNeeded, not via .wgsl() + } + + const descriptor = resolveDescriptor(node); + const outputVars: Record = {}; for (const outputDef of descriptor.outputs) { - if (descriptor.outputs.length === 1) { - outputVars[outputDef.name] = varName; // single output: no suffix (backwards compat) - } else { - outputVars[outputDef.name] = varName + "_" + outputDef.name; - } + outputVars[outputDef.name] = + descriptor.outputs.length === 1 ? varName : varName + "_" + outputDef.name; } - // Resolve inputs per descriptor definition const resolvedInputs: Record = {}; for (const inputDef of descriptor.inputs) { - // If a node has only one input, SvelteFlow omits targetHandle on edges. - // Match unlabeled edges to the sole input definition. const edgesForHandle = incoming.filter( (e) => e.targetHandle === inputDef.name || @@ -230,16 +331,16 @@ export function generateShader(nodes: Node[], edges: Edge[]): string { if (inputDef.variadic) { for (const edge of edgesForHandle) { - upstreamVars.push(getSourceVar(edge, nodeMap)); + upstreamVars.push(getSourceVar(edge, nodeMap, resolveDescriptor)); } } else if (edgesForHandle.length > 0) { - upstreamVars.push(getSourceVar(edgesForHandle[0], nodeMap)); + const firstEdge = edgesForHandle[0]; + if (firstEdge) + upstreamVars.push(getSourceVar(firstEdge, nodeMap, resolveDescriptor)); } else { - // No connection → emit the node's own data value as a WGSL literal const dataVal: unknown = node.data[inputDef.name] ?? descriptor.defaultData[inputDef.name]; - const literal = formatWgslLiteral(inputDef.type, dataVal); - upstreamVars.push(literal); + upstreamVars.push(formatWgslLiteral(inputDef.type, dataVal)); } resolvedInputs[inputDef.name] = upstreamVars; @@ -251,41 +352,115 @@ export function generateShader(nodes: Node[], edges: Edge[]): string { data: node.data, inputs: resolvedInputs, }); + for (const line of wgslLines) lines.push(` ${line}`); - for (const line of wgslLines) { - lines.push(` ${line}`); - } + if (node.type === seedType) seedNodeVarName = varName; + } - // Track which node produces the output - if (node.type === "frag-output") { - resultVariable = varName; - } + return { lines, seedNodeVarName }; +} + +/** + * Generate one group's `struct` + `fn`, recursively (a group can contain + * another group — this just calls generateBody again, unchanged). + * No-ops if this definitionRef is already in groupDecls — that's the memoization. + */ +function emitGroupFunctionIfNeeded( + definitionRef: string, + groupDecls: GroupDecls, + resolveDescriptor: (node: GroupedNode) => NodeDescriptor, + getGroupDefinition: (definitionRef: string) => { nodes: Node[]; edges: Edge[] } | undefined, +): void { + if (groupDecls.has(definitionRef)) return; // memoized — already generated + + const definition = getGroupDefinition(definitionRef); + if (!definition) return; + + const groupOutputNode = definition.nodes.find((n) => n.type === "group-output"); + const handles = (groupOutputNode?.data.handles ?? []) as HandleDef[]; + + const ident = sanitizeIdent(definitionRef); + const fnName = `group_${ident}`; + const structName = `GroupOutputs_${ident}`; + + const { lines } = generateBody( + definition.nodes, + definition.edges, + "group-output", + groupDecls, + resolveDescriptor, + getGroupDefinition, + ); + + // The group-output node itself never emits a `let` (it's a sink, not computed) — + // build the struct literal directly from whatever feeds each of its handles. + const outputEdges = definition.edges.filter((e) => e.target === groupOutputNode?.id); + const nodeMap = new Map(); + for (const n of definition.nodes) nodeMap.set(n.id, n); + + const fieldAssignments = handles.map((h) => { + const edge = outputEdges.find((e) => e.targetHandle === h.id); + return edge ? getSourceVar(edge, nodeMap, resolveDescriptor) : WGSL_DEFAULTS[h.type]; + }); + + const structFields = handles.map((h) => ` ${sanitizeIdent(h.id)}: ${h.type},`).join("\n"); + const structDecl = `struct ${structName} {\n${structFields}\n}`; + + // Group's inputs (its parameters) come from the group-input node's handles. + const groupInputNode = definition.nodes.find((n) => n.type === "group-input"); + const inputHandles = (groupInputNode?.data.handles ?? []) as HandleDef[]; + const params = inputHandles.map((h) => `${sanitizeIdent(h.id)}: ${h.type}`).join(", "); + + const fnDecl = [ + `fn ${fnName}(${params}) -> ${structName} {`, + ...lines, + ` return ${structName}(${fieldAssignments.join(", ")});`, + `}`, + ].join("\n"); + + groupDecls.set(definitionRef, { structDecl, fnDecl, fnName, structName }); +} + +/** + * Emit the full WGSL fragment shader source from the graph. + */ +export function generateShader(nodes: Node[], edges: Edge[]): string { + const graph = getGraphManager(); + const groupDecls: GroupDecls = new Map(); + + const resolveDescriptor = (node: GroupedNode) => graph.resolveDescriptor(node); + const getGroupDefinition = (definitionRef: string) => graph.project.definitions[definitionRef]; + + const { lines, seedNodeVarName } = generateBody( + nodes, + edges, + "frag-output", + groupDecls, + resolveDescriptor, + getGroupDefinition, + ); + + const body: string[] = []; + + // Every group's struct + fn, emitted once each, before frag() itself. + for (const decl of groupDecls.values()) { + body.push(decl.structDecl, "", decl.fnDecl, ""); } - // Build the fragment body - const body: string[] = [ + body.push( "// ---------- Common libraries ----------", "", "fn safeDivF32(a: f32, b:f32) -> f32 {", - " return select(a / b, 0.0, b == 0.0);", + " return select(a / b, 0.0, b == 0.0);", "}", "", "// ---------- User code ----------", "", "fn frag(uv: vec2f) -> vec4f {", - ]; - - for (const line of lines) { - body.push(line); - } - - if (resultVariable) { - body.push(` return ${resultVariable};`); - } else { - body.push(" return vec4f(0.0, 0.0, 0.0, 1.0);"); - } - - body.push("}"); + ...lines, + seedNodeVarName ? ` return ${seedNodeVarName};` : " return vec4f(0.0, 0.0, 0.0, 1.0);", + "}", + ); return body.join("\n"); } diff --git a/packages/editor/src/lib/components/editor/debug.json b/packages/editor/src/lib/components/editor/debug.json new file mode 100644 index 0000000..1f800be --- /dev/null +++ b/packages/editor/src/lib/components/editor/debug.json @@ -0,0 +1,64 @@ +{ + "definitions": { + "test-group": { + "nodes": [ + { + "id": "group-input-1", + "type": "group-input", + "data": { + "handles": [{ "id": "handle-a", "label": "Input 1", "type": "f32" }] + }, + "position": { "x": -200, "y": 0 } + }, + { + "id": "float-1", + "type": "float", + "data": { "value": 0.5 }, + "position": { "x": 0, "y": 0 } + }, + { + "id": "group-output-1", + "type": "group-output", + "data": { + "handles": [{ "id": "handle-b", "label": "Output 1", "type": "f32" }] + }, + "position": { "x": 200, "y": 0 } + } + ], + "edges": [ + { + "id": "e-group-input-float", + "source": "group-input-1", + "target": "float-1", + "sourceHandle": "handle-a", + "targetHandle": "value" + }, + { + "id": "e-float-group-output", + "source": "float-1", + "target": "group-output-1", + "sourceHandle": "value", + "targetHandle": "handle-b" + } + ] + } + }, + "root": { + "nodes": [ + { + "id": "test-group-node", + "type": "node-group", + "data": {}, + "position": { + "x": 0, + "y": 0 + }, + "group": { + "definitionRef": "test-group", + "locked": false + } + } + ], + "edges": [] + } +} diff --git a/packages/editor/src/lib/components/editor/graph-state.svelte.ts b/packages/editor/src/lib/components/editor/graph-state.svelte.ts index 0889c0b..70a6349 100644 --- a/packages/editor/src/lib/components/editor/graph-state.svelte.ts +++ b/packages/editor/src/lib/components/editor/graph-state.svelte.ts @@ -1,11 +1,16 @@ import type { Node, Edge } from "@xyflow/svelte"; import { getContext, setContext } from "svelte"; +import { buildGroupDescriptor } from "./nodes/group.node"; +import { buildGroupInputDescriptor } from "./nodes/groupInput.node"; +import { buildGroupOutputDescriptor } from "./nodes/groupOutput.node"; import { nodeRegistry } from "./registry"; import defaultScene from "./sizing_circle.json"; +import { type GroupedNode, type HandleDef, type NodeDescriptor, type WgslType } from "./types"; // ── Serialisable document types ────────────────────────────────────────────── +/** A single subgraph: what used to be the whole document is now one level of it. */ export type GraphDocument = { nodes: GraphDocumentNode[]; edges: GraphDocumentEdge[]; @@ -16,6 +21,19 @@ export type GraphDocumentNode = { type: string; data: Record; position: { x: number; y: number }; + /** + * Present only on group-type nodes. + */ + group?: { + /** + * The subgraph the group is pointing to + */ + definitionRef: string; + /** + * Whether the subgraph is editable or not + */ + locked: boolean; + }; }; export type GraphDocumentEdge = { @@ -26,71 +44,129 @@ export type GraphDocumentEdge = { targetHandle?: string; }; +/** The whole project. Exactly one per file/save. */ +export type GraphProject = { + definitions: Record; + root: GraphDocument; +}; + +export type HydratedGraphDocument = { + nodes: GroupedNode[]; + edges: Edge[]; +}; + +export type HydratedGraphProject = { + definitions: Record; + root: HydratedGraphDocument; +}; + export function isGraphDocument(obj: unknown): obj is GraphDocument { return typeof obj === "object" && obj !== null && "nodes" in obj && "edges" in obj; } +export function isGraphProject(obj: unknown): obj is GraphProject { + return ( + typeof obj === "object" && + obj !== null && + "root" in obj && + "definitions" in obj && + isGraphDocument(obj.root) + ); +} + // ── Pure helpers ───────────────────────────────────────────────────────────── -/** - * Normalise a document: fill in defaultData for any node that's missing fields, - * discard unknown node types, etc. - */ +function resolveDescriptor( + node: GroupedNode | Node, + definitions: Record, +): NodeDescriptor { + if (node.type === "group-input") { + return buildGroupInputDescriptor((node.data.handles ?? []) as HandleDef[]); + } + if (node.type === "group-output") { + return buildGroupOutputDescriptor((node.data.handles ?? []) as HandleDef[]); + } + if (node.type === "node-group") { + return buildGroupDescriptor(node, definitions); + } + // TODO: properly handle undefined here + return nodeRegistry[node.type]; +} + export function normaliseDocument(doc: GraphDocument): GraphDocument { const nodes: GraphDocumentNode[] = []; - for (const n of doc.nodes) { - const desc = nodeRegistry[n.type]; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + for (const node of doc.nodes) { + if (node.group) { + nodes.push({ ...node, data: { ...node.data } }); + continue; + } + + const desc = resolveDescriptor(node, {}); + // TODO: this check should happen higher up in the chain if (desc === undefined) { - console.warn(`[graph] unknown node type "${n.type}" — dropping`); + console.warn(`[graph] unknown node type "${node.type}" — dropping`); continue; } - nodes.push({ - ...n, - data: { ...desc.defaultData, ...n.data }, - }); + nodes.push({ ...node, data: { ...desc.defaultData, ...node.data } }); } return { nodes, edges: doc.edges }; } -/** - * Hydrate a document into the SvelteFlow Node/Edge arrays used by GraphContent. - * Assigns a new id counter starting after the highest node id. - */ -export function hydrateDocument(doc: GraphDocument): { - nodes: Node[]; - edges: Edge[]; - nextId: number; -} { - const normalised = normaliseDocument(doc); - - const nodes: Node[] = normalised.nodes.map((n) => ({ - id: n.id, - type: n.type, - data: n.data, - position: n.position, - })); +// Unused for now, since normaliseDocument() is in turn recursively called in hydrateProject() +/* function normaliseProject(project: GraphProject): GraphProject { + const root = normaliseDocument(project.root); + const definitions = Object.fromEntries( + Object.entries(project.definitions).map(([key, doc]) => [key, normaliseDocument(doc)]), + ); + return { root, definitions }; +} */ - const edges: Edge[] = normalised.edges.map((e) => ({ - id: e.id, - source: e.source, - target: e.target, - sourceHandle: e.sourceHandle ?? undefined, - targetHandle: e.targetHandle ?? undefined, - })); +function hydrateNode(node: GraphDocumentNode): GroupedNode { + const base = { + id: node.id, + type: node.type, + data: node.data, + position: node.position, + }; - // Find the next id counter - let maxId = 0; - for (const n of normalised.nodes) { - const num = parseInt(n.id.replace(/^node-/, ""), 10); - if (Number.isFinite(num) && num > maxId) maxId = num; + if (node.group) { + return { + ...base, + group: node.group, // just a string pointer, nothing to hydrate + }; } - return { nodes, edges, nextId: maxId + 1 }; + return base; // not a group node at all — most nodes hit this line +} + +function hydrateDocument(document: GraphDocument): HydratedGraphDocument { + const normalised = normaliseDocument(document); + + const nodes: GroupedNode[] = normalised.nodes.map(hydrateNode); + + const edges: Edge[] = normalised.edges.map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + })); + + return { nodes, edges }; +} + +function hydrateProject(project: GraphProject): HydratedGraphProject { + const root = hydrateDocument(project.root); + const definitions = Object.fromEntries( + Object.entries(project.definitions).map(([key, doc]) => [key, hydrateDocument(doc)]), + ); + return { root, definitions }; } /** - * Serialise current nodes and edges to a GraphDocument (ready to JSON.stringify). + * Serialise a single subgraph's live SvelteFlow Node[]/Edge[] back to a GraphDocument. + * NOTE: like normaliseDocument, this is per-subgraph only. A `serialiseProject` wrapping + * this for root + definitions + recursing into owned content is still TODO. */ export function serialiseDocument(nodes: Node[], edges: Edge[]): GraphDocument { return { @@ -99,6 +175,11 @@ export function serialiseDocument(nodes: Node[], edges: Edge[]): GraphDocument { type: type ?? "", data, position, + // TODO: a live SvelteFlow Node doesn't carry `group` today — that lives only in + // your GraphProject tree, not in the flat Node/Edge shape SvelteFlow binds to. + // serialiseDocument as called from GraphManager will need the *source* GraphDocument + // (to pull `group` back out per node) alongside the live nodes/edges, since SvelteFlow's + // bound arrays only ever reflect ONE subgraph's worth of nodes at a time anyway. })), edges: edges.map((e) => ({ id: e.id, @@ -113,55 +194,133 @@ export function serialiseDocument(nodes: Node[], edges: Edge[]): GraphDocument { // ── Reactive state manager ─────────────────────────────────────────────────── export class GraphManager { - nodes = $state([]); - edges = $state([]); - nodeIdCounter = $state(1); + /** The entire project tree. */ + project = $state({ definitions: {}, root: { nodes: [], edges: [] } }); + + /** + * Breadcrumb path of group-node ids from root down to the currently-viewed subgraph. + * [] means "viewing root". ["node-7"] means "viewing node-7's content". + * ["node-7", "node-12"] means nested two levels deep. + */ + path = $state([]); - constructor(defaults?: GraphDocument) { - const doc = - defaults ?? (isGraphDocument(defaultScene) ? defaultScene : { nodes: [], edges: [] }); - const result = hydrateDocument(doc); - this.nodes = result.nodes; - this.edges = result.edges; - this.nodeIdCounter = result.nextId; + constructor(defaults?: GraphProject) { + const project: GraphProject = defaults ?? defaultScene; + this.project = hydrateProject(project); } - loadDocument(doc: GraphDocument) { - const result = hydrateDocument(doc); - this.nodes = result.nodes; - this.edges = result.edges; - this.nodeIdCounter = result.nextId; + get activeDocument(): HydratedGraphDocument { + let current: HydratedGraphDocument = this.project.root; + for (const nodeId of this.path) { + const node = current.nodes.find((n) => n.id === nodeId); + if (!node?.group) { + console.warn(`[graph] invalid path segment "${nodeId}" — resetting to root`); + this.path = []; + return this.project.root; + } + const definition = this.project.definitions[node.group.definitionRef]; + if (!definition) { + console.warn( + `[graph] missing definition "${node.group.definitionRef}" — resetting to root`, + ); + this.path = []; + return this.project.root; + } + current = definition; + } + return current; } - /** - * Return the serialised document as a JSON text string. - */ - getDocument(): string { - const doc = serialiseDocument(this.nodes, this.edges); - return JSON.stringify(doc, null, "\t"); + resolveDescriptor(node: GroupedNode | Node): NodeDescriptor { + return resolveDescriptor(node, this.project.definitions); + } + + get nodes(): GroupedNode[] { + return this.activeDocument.nodes; + } + set nodes(value: GroupedNode[]) { + this.activeDocument.nodes = value; + } + get edges(): Edge[] { + return this.activeDocument.edges; + } + set edges(value: Edge[]) { + this.activeDocument.edges = value; + } + + /** Labels for each segment of the breadcrumb path, suitable for rendering. */ + get pathLabels(): { id: string; label: string }[] { + const labels: { id: string; label: string }[] = []; + let current: HydratedGraphDocument = this.project.root; + for (const nodeId of this.path) { + const node = current.nodes.find((n) => n.id === nodeId); + if (!node?.group) break; + const definition = this.project.definitions[node.group.definitionRef]; + if (!definition) break; + const nodeType = node.type ?? "unknown"; + const desc = nodeRegistry[nodeType]; + labels.push({ id: nodeId, label: desc?.label ?? nodeType }); + current = definition; + } + return labels; + } + + /** Push a group node's id onto the breadcrumb path — "enter" it. */ + enterGroup(groupNodeId: string) { + const node = this.activeDocument.nodes.find((n) => n.id === groupNodeId); + if (!node?.group) { + console.warn(`[graph] cannot enter "${groupNodeId}" — not a group node`); + return; + } + this.path = [...this.path, groupNodeId]; + } + + /** Pop one level off the breadcrumb path — "exit" the current group. */ + exitGroup() { + if (this.path.length === 0) return; + this.path = this.path.slice(0, -1); } /** - * Load a document from a raw JSON string. + * Jump directly to a specific depth in the breadcrumb (e.g. clicking a breadcrumb + * UI element rather than stepping back one at a time). */ + jumpToPath(newPath: string[]) { + this.path = [...newPath]; + } + + loadProject(project: GraphProject) { + this.project = hydrateProject(project); + this.path = []; + } + + getProjectJson(): string { + const root = serialiseDocument(this.project.root.nodes, this.project.root.edges); + const definitions = Object.fromEntries( + Object.entries(this.project.definitions).map(([key, definition]) => [ + key, + serialiseDocument(definition.nodes, definition.edges), + ]), + ); + const project = { root, definitions }; + return JSON.stringify(project, null, "\t"); + } + loadFromString(text: string) { try { - const doc = JSON.parse(text) as GraphDocument; - this.loadDocument(doc); + const project = JSON.parse(text) as GraphProject; + this.loadProject(project); } catch (err) { console.error("[graph] failed to parse JSON", err); } } - /** - * Trigger a browser download of the graph document. - */ save() { - const blob = new Blob([this.getDocument()], { type: "application/json" }); + const blob = new Blob([this.getProjectJson()], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = "graph.json"; + a.download = "webGpuNodesProject.json"; a.click(); URL.revokeObjectURL(url); } @@ -169,12 +328,344 @@ export class GraphManager { async upload(file: File) { try { const text = await file.text(); - const doc = JSON.parse(text) as GraphDocument; - this.loadDocument(doc); + const project = JSON.parse(text) as GraphProject; + this.loadProject(project); } catch (err) { console.error("[graph] failed to parse uploaded JSON", err); } } + + // TODO: groupSelection(nodeIds: string[]): void + // The Ctrl+G operation. Not stubbed here since you said you want to design the + // edge-rewiring logic yourself — but it belongs on this class, since it needs to + // mutate `project` (remove nodes from activeDocument, create a new group node in + // their place, move them into that new node's `group.content`, and rewrite any edge + // that crossed the selection boundary to instead terminate on a new Group Input/Output + // node inside the new content). + // + groupSelection(selectedNodeIds: Set): void { + console.log("hit! Selected nodes: " + [...selectedNodeIds].join(", ")); + + const selectedNodes = this.activeDocument.nodes.filter((node) => + selectedNodeIds.has(node.id), + ); + const bounds = computeSelectionBounds(selectedNodes); + + const { internal, incoming, outgoing } = classifyEdges( + selectedNodeIds, + this.activeDocument.edges as GraphDocumentEdge[], // We could pick only the GraphDocumentEdge properites of each Edge, but passing the raw object is more memory-efficient + ); + const dedupedIncoming = deduplicateBySource(incoming); + const dedupedOutgoing = deduplicateBySource(outgoing); + + const OFFSET = 300; + + const groupInputNode = buildGroupInputNode( + dedupedIncoming, + this.activeDocument.nodes, + (node) => this.resolveDescriptor(node), + { x: bounds.minX - OFFSET, y: bounds.centerY }, + ); + const groupOutputNode = buildGroupOutputNode( + dedupedOutgoing, + this.activeDocument.nodes, + (node) => this.resolveDescriptor(node), + { x: bounds.maxX + OFFSET, y: bounds.centerY }, + ); + + const definitionRef = crypto.randomUUID(); + const groupNodeId = crypto.randomUUID(); + + const allOuterEdges: GraphDocumentEdge[] = []; + const allInnerEdges: GraphDocumentEdge[] = []; + + dedupedIncoming.forEach((group) => { + const handle = groupInputNode.handleFor.get(group); + if (!handle) return; // Shouldn't happen + + const { outer, inner } = rewireIncomingGroup( + group, + handle, + groupInputNode.node.id, + groupNodeId, + ); + allOuterEdges.push(outer); + allInnerEdges.push(...inner); + }); + + dedupedOutgoing.forEach((group) => { + const handle = groupOutputNode.handleFor.get(group); + if (!handle) return; // Shouldn't happen + + const { outer, inner } = rewireOutgoingGroup( + group, + handle, + groupOutputNode.node.id, + groupNodeId, + ); + allOuterEdges.push(...outer); + allInnerEdges.push(inner); + }); + + const definition: GraphDocument = { + nodes: [...selectedNodes, groupInputNode.node, groupOutputNode.node], + edges: [...internal, ...allInnerEdges], + }; + + this.project.definitions[definitionRef] = definition; + + const groupNode: GraphDocumentNode = { + id: groupNodeId, + type: "node-group", + data: {}, + position: { x: bounds.centerX, y: bounds.centerY }, + group: { definitionRef, locked: false }, + }; + + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const touchedEdgeIds = new Set([ + ...internal.map((e) => e.id), + ...incoming.map((e) => e.id), + ...outgoing.map((e) => e.id), + ]); + + this.activeDocument.nodes = [ + ...this.activeDocument.nodes.filter((n) => !selectedNodeIds.has(n.id)), + groupNode, + ]; + + this.activeDocument.edges = [ + ...this.activeDocument.edges.filter((e) => !touchedEdgeIds.has(e.id)), + ...allOuterEdges, + ]; + + function computeSelectionBounds(selectedNodes: GroupedNode[]) { + const lefts = selectedNodes.map((n) => n.position.x); + const tops = selectedNodes.map((n) => n.position.y); + const rights = selectedNodes.map((n) => n.position.x + (n.width ?? 0)); + const bottoms = selectedNodes.map((n) => n.position.y + (n.height ?? 0)); + + const minX = Math.min(...lefts); + const maxX = Math.max(...rights); + const minY = Math.min(...tops); + const maxY = Math.max(...bottoms); + + return { + centerX: (minX + maxX) / 2, + centerY: (minY + maxY) / 2, + minX, + maxX, + }; + } + + type ClassifiedEdges = { + internal: GraphDocumentEdge[]; + incoming: GraphDocumentEdge[]; + outgoing: GraphDocumentEdge[]; + }; + + /** Only returns those three sets of edges, discards the rest */ + function classifyEdges( + selectedNodeIds: Set, + edges: GraphDocumentEdge[], + ): ClassifiedEdges { + const internal = edges.filter( + (edge) => selectedNodeIds.has(edge.source) && selectedNodeIds.has(edge.target), + ); + const incoming = edges.filter( + (edge) => !selectedNodeIds.has(edge.source) && selectedNodeIds.has(edge.target), + ); + const outgoing = edges.filter( + (edge) => selectedNodeIds.has(edge.source) && !selectedNodeIds.has(edge.target), + ); + + return { internal, incoming, outgoing }; + } + + function deduplicateBySource(edgesToGroup: GraphDocumentEdge[]): GraphDocumentEdge[][] { + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- local/temporary Map, never stored in reactive state + const groups = new Map(); + + for (const edge of edgesToGroup) { + const key = `${edge.source}::${edge.sourceHandle ?? ""}`; + + const existing = groups.get(key); + if (existing) { + existing.push(edge); + } else { + groups.set(key, [edge]); + } + } + + return [...groups.values()]; + } + + function inferHandleType( + edge: GraphDocumentEdge, + nodes: GroupedNode[], + resolveDescriptor: (node: GroupedNode) => NodeDescriptor, + ): WgslType { + const sourceNode = nodes.find((n) => n.id === edge.source); + if (!sourceNode) return "f32"; // shouldn't happen, but fail soft + + const descriptor = resolveDescriptor(sourceNode); + const outputDef = descriptor.outputs.find( + (o) => o.name === edge.sourceHandle || descriptor.outputs.length === 1, + ); + + return outputDef?.type ?? "f32"; + } + + type BoundaryBuildResult = { + node: GraphDocumentNode; + handleFor: Map; + }; + + function buildGroupInputNode( + dedupedIncoming: GraphDocumentEdge[][], + activeDocumentNodes: GroupedNode[], + resolveDescriptor: (node: GroupedNode) => NodeDescriptor, + position: { x: number; y: number }, + ): BoundaryBuildResult { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const handleFor = new Map(); + const handles: HandleDef[] = []; + + for (const [i, group] of dedupedIncoming.entries()) { + const representative = group[0]; + if (!representative) { + console.warn("[graph] empty edge group — skipping"); + continue; + } + const handle: HandleDef = { + id: crypto.randomUUID(), + label: `Input ${String(i + 1)}`, + type: inferHandleType(representative, activeDocumentNodes, resolveDescriptor), + }; + handles.push(handle); + handleFor.set(group, handle); + } + + return { + node: { + id: crypto.randomUUID(), + type: "group-input", + data: { handles }, + position, + }, + handleFor, + }; + } + + function buildGroupOutputNode( + dedupedOutgoing: GraphDocumentEdge[][], + activeDocumentNodes: GroupedNode[], + resolveDescriptor: (node: GroupedNode) => NodeDescriptor, + position: { x: number; y: number }, + ): BoundaryBuildResult { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const handleFor = new Map(); + const handles: HandleDef[] = []; + + for (const [i, group] of dedupedOutgoing.entries()) { + const representative = group[0]; + if (!representative) { + console.warn("[graph] empty edge group — skipping"); + continue; + } + const handle: HandleDef = { + id: crypto.randomUUID(), + label: `Output ${String(i + 1)}`, + type: inferHandleType(representative, activeDocumentNodes, resolveDescriptor), + }; + handles.push(handle); + handleFor.set(group, handle); + } + + return { + node: { + id: crypto.randomUUID(), + type: "group-output", + data: { handles }, + position, + }, + handleFor, + }; + } + + type RewireIncomingResult = { + outer: GraphDocumentEdge; // one edge, replacing all originals from this source + inner: GraphDocumentEdge[]; // one edge per original target, now sourced from group-input + }; + + function rewireIncomingGroup( + group: GraphDocumentEdge[], + handle: HandleDef, + groupInputNodeId: string, + groupNodeId: string, + ): RewireIncomingResult { + const representative = group[0]; + if (!representative) throw new Error("empty group"); // shouldn't happen, per earlier guard + + const outer: GraphDocumentEdge = { + id: crypto.randomUUID(), + source: representative.source, + sourceHandle: representative.sourceHandle, + target: groupNodeId, + targetHandle: handle.id, + }; + + const inner: GraphDocumentEdge[] = group.map((edge) => ({ + id: crypto.randomUUID(), + source: groupInputNodeId, + sourceHandle: handle.id, + target: edge.target, + targetHandle: edge.targetHandle, + })); + + return { outer, inner }; + } + + type RewireOutgoingResult = { + outer: GraphDocumentEdge[]; + inner: GraphDocumentEdge; + }; + + function rewireOutgoingGroup( + group: GraphDocumentEdge[], + handle: HandleDef, + groupOutputNodeId: string, + groupNodeId: string, + ): RewireOutgoingResult { + const representative = group[0]; + if (!representative) throw new Error("empty group"); // shouldn't happen, per earlier guard + + const inner: GraphDocumentEdge = { + id: crypto.randomUUID(), + source: representative.source, + sourceHandle: representative.sourceHandle, + target: groupOutputNodeId, + targetHandle: handle.id, + }; + + const outer: GraphDocumentEdge[] = group.map((edge) => ({ + id: crypto.randomUUID(), + source: groupNodeId, + sourceHandle: handle.id, + target: edge.target, + targetHandle: edge.targetHandle, + })); + + return { outer, inner }; + } + } + + // TODO: lockGroup(nodeId: string, definitionRef: string): void + // TODO: unlockGroup(nodeId: string): void + // unlockGroup needs a deep clone of project.definitions[definitionRef] — make sure + // it's a REAL deep clone (structuredClone works fine here) so mutating the unlocked + // copy can never reach back and mutate the shared definition. + // Careful: each node's ID when unlocking a node group MUST be regenerated } // ── Context helpers ────────────────────────────────────────────────────────── diff --git a/packages/editor/src/lib/components/editor/nodes/group.node.ts b/packages/editor/src/lib/components/editor/nodes/group.node.ts new file mode 100644 index 0000000..2725e29 --- /dev/null +++ b/packages/editor/src/lib/components/editor/nodes/group.node.ts @@ -0,0 +1,43 @@ +import type { HydratedGraphDocument } from "../graph-state.svelte"; +import type { GroupedNode, HandleDef, NodeDescriptor } from "../types"; + +export function buildGroupDescriptor( + node: GroupedNode, + definitions: Record, +): NodeDescriptor { + const definitionRef = node.group?.definitionRef; + const definition = definitionRef ? definitions[definitionRef] : undefined; + + if (!definition || !definitionRef) { + console.warn( + `[graph] group node "${node.id}" references missing definition "${String(definitionRef)}"`, + ); + return { + type: "node-group", + label: "⚠ Missing group", + category: "output", + inputs: [], + outputs: [], + defaultData: {}, + wgsl: () => [], + }; + } + + const inputNode = definition.nodes.find((n) => n.type === "group-input"); + const outputNode = definition.nodes.find((n) => n.type === "group-output"); + + const handles = { + inputs: (inputNode?.data.handles ?? []) as HandleDef[], + outputs: (outputNode?.data.handles ?? []) as HandleDef[], + }; + + return { + type: "node-group", + label: definitionRef, // TODO Placeholder - allow to change group label + category: "output", + inputs: handles.inputs.map((h) => ({ name: h.id, type: h.type })), + outputs: handles.outputs.map((h) => ({ name: h.id, type: h.type })), + defaultData: {}, + wgsl: () => [], // stubbed, same as group-input/output — group codegen is future work + }; +} diff --git a/packages/editor/src/lib/components/editor/nodes/groupInput.node.ts b/packages/editor/src/lib/components/editor/nodes/groupInput.node.ts new file mode 100644 index 0000000..86f67ea --- /dev/null +++ b/packages/editor/src/lib/components/editor/nodes/groupInput.node.ts @@ -0,0 +1,18 @@ +import type { HandleDef, NodeDescriptor } from "../types"; + +export function buildGroupInputDescriptor(handles: HandleDef[]): NodeDescriptor { + return { + type: "group-input", + label: "Group Input", + category: "input", + inputs: [], + outputs: handles.map((h) => ({ name: h.id, type: h.type })), + defaultData: { handles: [] }, + wgsl: () => { + // unreachable — group-input/group-output are handled by type name in + // codegen's generateBody, never via this generic path. Kept only because + // NodeDescriptor requires the field. + throw new Error("unreachable"); + }, + }; +} diff --git a/packages/editor/src/lib/components/editor/nodes/groupOutput.node.ts b/packages/editor/src/lib/components/editor/nodes/groupOutput.node.ts new file mode 100644 index 0000000..7cb14c7 --- /dev/null +++ b/packages/editor/src/lib/components/editor/nodes/groupOutput.node.ts @@ -0,0 +1,18 @@ +import type { HandleDef, NodeDescriptor } from "../types"; + +export function buildGroupOutputDescriptor(handles: HandleDef[]): NodeDescriptor { + return { + type: "group-output", + label: "Group Output", + category: "output", + inputs: handles.map((h) => ({ name: h.id, type: h.type })), + outputs: [], + defaultData: { handles: [] }, + wgsl: () => { + // unreachable — group-input/group-output are handled by type name in + // codegen's generateBody, never via this generic path. Kept only because + // NodeDescriptor requires the field. + throw new Error("unreachable"); + }, + }; +} diff --git a/packages/editor/src/lib/components/editor/registry.ts b/packages/editor/src/lib/components/editor/registry.ts index 72b666c..77f6ea3 100644 --- a/packages/editor/src/lib/components/editor/registry.ts +++ b/packages/editor/src/lib/components/editor/registry.ts @@ -26,6 +26,20 @@ import { vec3fNode } from "./nodes/vec3f.node"; import { vec4fNode } from "./nodes/vec4f.node"; import type { NodeDescriptor, NodeCategory } from "./types"; +/** + * Placeholder descriptor for group nodes. + * The actual shape is derived from the subgraph at runtime. + */ +const groupNode: NodeDescriptor = { + type: "node-group", + label: "Group", + category: "output", + inputs: [], + outputs: [], + defaultData: {}, + wgsl: () => [], +}; + /** * The global node registry. * Built by hand right now; in the future this could auto-discover @@ -65,8 +79,13 @@ export const nodeRegistry: Record = { // Output "frag-output": outputNode, + + // Groups (placeholder — shape derived from subgraph at runtime) + "node-group": groupNode, }; +export const DYNAMIC_NODE_TYPES = ["group-input", "group-output"]; + /** Helper: produce default node data for a registered type. */ export function defaultDataForType(type: string): Record { return { ...nodeRegistry[type].defaultData }; @@ -86,13 +105,3 @@ export function nodesByCategory(): Map< } return map; } - -/** Resolve input definitions from a descriptor. */ -export function inputDefs(type: string): NodeDescriptor["inputs"] { - return nodeRegistry[type].inputs; -} - -/** Resolve output definitions from a descriptor. */ -export function outputDefs(type: string): NodeDescriptor["outputs"] { - return nodeRegistry[type].outputs; -} diff --git a/packages/editor/src/lib/components/editor/sizing_circle.json b/packages/editor/src/lib/components/editor/sizing_circle.json index 1215558..90d7710 100644 --- a/packages/editor/src/lib/components/editor/sizing_circle.json +++ b/packages/editor/src/lib/components/editor/sizing_circle.json @@ -1,642 +1,645 @@ { - "nodes": [ - { - "id": "separateUvs", - "type": "separateVec2f", - "data": { - "x": 0, - "y": 0 - }, - "position": { - "x": -303.75006043968585, - "y": -239.05269179388642 - } - }, - { - "id": "vec4f1", - "type": "vec4f", - "data": { - "x": 1, - "y": 1, - "z": 1, - "w": 1 - }, - "position": { - "x": 1155.6899556313651, - "y": -55.595713465409005 - } - }, - { - "id": "out1", - "type": "frag-output", - "data": {}, - "position": { - "x": 1375.7357311503742, - "y": -48.96944047032002 - } - }, - { - "id": "node-2", - "type": "pow", - "data": { - "e1": 0, - "e2": 2 - }, - "position": { - "x": -59.407697002206234, - "y": -358.8584787446738 - } - }, - { - "id": "node-3", - "type": "pow", - "data": { - "e1": 0, - "e2": 2 - }, - "position": { - "x": -69.47461669207212, - "y": -152.12734742820936 - } - }, - { - "id": "node-4", - "type": "add", - "data": {}, - "position": { - "x": 165.49914485309483, - "y": -221.78736765676794 - } - }, - { - "id": "node-5", - "type": "pow", - "data": { - "e1": 0, - "e2": 0.5 - }, - "position": { - "x": 399.29379345226795, - "y": -235.4730772096017 - } - }, - { - "id": "node-8", - "type": "remap", - "data": { - "e": 0, - "in_min": 0.499, - "in_max": 0.501, - "out_min": 1, - "out_max": 0 - }, - "position": { - "x": 692.1244342075336, - "y": -88.22905575491284 - } - }, - { - "id": "node-9", - "type": "clamp", - "data": { - "e": 0, - "low": 0, - "high": 1 - }, - "position": { - "x": 915.5537618866938, - "y": -51.83920429790382 - } - }, - { - "id": "node-10", - "type": "time", - "data": {}, - "position": { - "x": -797.6049657846668, - "y": 242.73855889338316 - } - }, - { - "id": "node-11", - "type": "sin", - "data": { - "e": 0 - }, - "position": { - "x": -277.0693967656557, - "y": 224.61444097098936 - } - }, - { - "id": "node-12", - "type": "remap", - "data": { - "e": 0, - "in_min": -1, - "in_max": 1, - "out_min": 0.05, - "out_max": 0.405 - }, - "position": { - "x": -5.185429596891694, - "y": 209.33047336098778 - } - }, - { - "id": "node-13", - "type": "add", - "data": {}, - "position": { - "x": 404.92163648110886, - "y": 145.47856848731743 - } - }, - { - "id": "node-14", - "type": "float", - "data": { - "value": 0.0005 - }, - "position": { - "x": -1.2004973250476496, - "y": 29.75570992201213 - } - }, - { - "id": "node-15", - "type": "sub", - "data": { - "a": 0, - "b": 0 - }, - "position": { - "x": 404.32302554495897, - "y": -42.132893653008296 - } - }, - { - "id": "node-16", - "type": "mul", - "data": {}, - "position": { - "x": -538.8275825407148, - "y": 265.7517008108812 - } - }, - { - "id": "node-17", - "type": "float", - "data": { - "value": 0.5 - }, - "position": { - "x": -826.2812486756197, - "y": 376.13618928427223 - } - }, - { - "id": "node-18", - "type": "resolution", - "data": {}, - "position": { - "x": -2269.426547127526, - "y": -645.2054347507909 - } - }, - { - "id": "node-19", - "type": "div", - "data": { - "a": 0, - "b": 0 - }, - "position": { - "x": -1619.8272070534458, - "y": -726.9445910250517 - } - }, - { - "id": "node-20", - "type": "separateVec2f", - "data": { - "x": 0, - "y": 0 - }, - "position": { - "x": -2069.4335249248134, - "y": -695.4958618425023 - } - }, - { - "id": "node-21", - "type": "max", - "data": { - "e1": 0, - "e2": 1 - }, - "position": { - "x": -1849.7901800405057, - "y": -581.1332384120589 - } - }, - { - "id": "node-24", - "type": "mix", - "data": { - "e1": 0, - "e2": 1, - "e3": 0 - }, - "position": { - "x": -1059.13903314223, - "y": -771.3519434872421 - } - }, - { - "id": "node-25", - "type": "max", - "data": { - "e1": 0, - "e2": 1 - }, - "position": { - "x": -1339.0009855904636, - "y": -601.1469564877134 - } - }, - { - "id": "node-27", - "type": "mix", - "data": { - "e1": 1, - "e2": 0, - "e3": 0 - }, - "position": { - "x": -1070.6144133000953, - "y": -547.7780064474518 - } - }, - { - "id": "node-28", - "type": "div", - "data": { - "a": 1, - "b": 0 - }, - "position": { - "x": -1324.6643172024567, - "y": -416.3129479207945 - } - }, - { - "id": "node-29", - "type": "sub", - "data": { - "a": 0, - "b": 0.5 - }, - "position": { - "x": -1044.7810525000905, - "y": -948.3690268281516 - } - }, - { - "id": "node-30", - "type": "sub", - "data": { - "a": 0, - "b": 0.5 - }, - "position": { - "x": -1080.6509875644324, - "y": -312.10684951642577 - } - }, - { - "id": "node-31", - "type": "mul", - "data": {}, - "position": { - "x": -837.2791926400846, - "y": -654.272932536356 - } - }, - { - "id": "node-34", - "type": "mul", - "data": {}, - "position": { - "x": -847.1242089616887, - "y": -432.0932546703629 - } - }, - { - "id": "node-36", - "type": "vec2f", - "data": { - "x": 0, - "y": 0 - }, - "position": { - "x": -594.5218148766417, - "y": -566.5136147959828 - } - }, - { - "id": "node-37", - "type": "uv", - "data": {}, - "position": { - "x": -2269.0984471540523, - "y": -374.87435081897354 - } - }, - { - "id": "node-38", - "type": "separateVec2f", - "data": { - "x": 0, - "y": 0 - }, - "position": { - "x": -2029.2509399249955, - "y": -412.20773063323446 - } - } - ], - "edges": [ - { - "id": "e-vec4f1-out1", - "source": "vec4f1", - "target": "out1" - }, - { - "id": "xy-edge__node-2result-node-4operands", - "source": "node-2", - "target": "node-4", - "sourceHandle": "result", - "targetHandle": "operands" - }, - { - "id": "xy-edge__node-3result-node-4operands", - "source": "node-3", - "target": "node-4", - "sourceHandle": "result", - "targetHandle": "operands" - }, - { - "id": "xy-edge__node-4result-node-5e1", - "source": "node-4", - "target": "node-5", - "sourceHandle": "result", - "targetHandle": "e1" - }, - { - "id": "xy-edge__node-5result-node-8e", - "source": "node-5", - "target": "node-8", - "sourceHandle": "result", - "targetHandle": "e" - }, - { - "id": "xy-edge__node-8result-node-9e", - "source": "node-8", - "target": "node-9", - "sourceHandle": "result", - "targetHandle": "e" - }, - { - "id": "xy-edge__node-11result-node-12e", - "source": "node-11", - "target": "node-12", - "sourceHandle": "result", - "targetHandle": "e" - }, - { - "id": "xy-edge__node-12result-node-13operands", - "source": "node-12", - "target": "node-13", - "sourceHandle": "result", - "targetHandle": "operands" - }, - { - "id": "xy-edge__node-14value-node-13operands", - "source": "node-14", - "target": "node-13", - "sourceHandle": "value", - "targetHandle": "operands" - }, - { - "id": "xy-edge__node-12result-node-15a", - "source": "node-12", - "target": "node-15", - "sourceHandle": "result", - "targetHandle": "a" - }, - { - "id": "xy-edge__node-14value-node-15b", - "source": "node-14", - "target": "node-15", - "sourceHandle": "value", - "targetHandle": "b" - }, - { - "id": "xy-edge__node-15result-node-8in_min", - "source": "node-15", - "target": "node-8", - "sourceHandle": "result", - "targetHandle": "in_min" - }, - { - "id": "xy-edge__node-13result-node-8in_max", - "source": "node-13", - "target": "node-8", - "sourceHandle": "result", - "targetHandle": "in_max" - }, - { - "id": "xy-edge__node-10time-node-16operands", - "source": "node-10", - "target": "node-16", - "sourceHandle": "time", - "targetHandle": "operands" - }, - { - "id": "xy-edge__node-16result-node-11e", - "source": "node-16", - "target": "node-11", - "sourceHandle": "result", - "targetHandle": "e" - }, - { - "id": "xy-edge__node-17value-node-16operands", - "source": "node-17", - "target": "node-16", - "sourceHandle": "value", - "targetHandle": "operands" - }, - { - "id": "xy-edge__node-18resolution-node-20xy", - "source": "node-18", - "target": "node-20", - "sourceHandle": "resolution", - "targetHandle": "xy" - }, - { - "id": "xy-edge__node-20x-node-19a", - "source": "node-20", - "target": "node-19", - "sourceHandle": "x", - "targetHandle": "a" - }, - { - "id": "xy-edge__node-20y-node-21e1", - "source": "node-20", - "target": "node-21", - "sourceHandle": "y", - "targetHandle": "e1" - }, - { - "id": "xy-edge__node-21result-node-19b", - "source": "node-21", - "target": "node-19", - "sourceHandle": "result", - "targetHandle": "b" - }, - { - "id": "xy-edge__node-19result-node-24e1", - "source": "node-19", - "target": "node-24", - "sourceHandle": "result", - "targetHandle": "e1" - }, - { - "id": "xy-edge__node-19result-node-25e1", - "source": "node-19", - "target": "node-25", - "sourceHandle": "result", - "targetHandle": "e1" - }, - { - "id": "xy-edge__node-25result-node-24e3", - "source": "node-25", - "target": "node-24", - "sourceHandle": "result", - "targetHandle": "e3" - }, - { - "id": "xy-edge__node-19result-node-28b", - "source": "node-19", - "target": "node-28", - "sourceHandle": "result", - "targetHandle": "b" - }, - { - "id": "xy-edge__node-28result-node-27e2", - "source": "node-28", - "target": "node-27", - "sourceHandle": "result", - "targetHandle": "e2" - }, - { - "id": "xy-edge__node-25result-node-27e3", - "source": "node-25", - "target": "node-27", - "sourceHandle": "result", - "targetHandle": "e3" - }, - { - "id": "xy-edge__node-24result-node-31operands", - "source": "node-24", - "target": "node-31", - "sourceHandle": "result", - "targetHandle": "operands" - }, - { - "id": "xy-edge__node-29result-node-31operands", - "source": "node-29", - "target": "node-31", - "sourceHandle": "result", - "targetHandle": "operands" - }, - { - "id": "xy-edge__node-27result-node-34operands", - "source": "node-27", - "target": "node-34", - "sourceHandle": "result", - "targetHandle": "operands" - }, - { - "id": "xy-edge__node-30result-node-34operands", - "source": "node-30", - "target": "node-34", - "sourceHandle": "result", - "targetHandle": "operands" - }, - { - "id": "xy-edge__node-36value-separateUvsxy", - "source": "node-36", - "target": "separateUvs", - "sourceHandle": "value", - "targetHandle": "xy" - }, - { - "id": "xy-edge__node-37uv-node-38xy", - "source": "node-37", - "target": "node-38", - "sourceHandle": "uv", - "targetHandle": "xy" - }, - { - "id": "xy-edge__node-38x-node-29a", - "source": "node-38", - "target": "node-29", - "sourceHandle": "x", - "targetHandle": "a" - }, - { - "id": "xy-edge__node-38y-node-30a", - "source": "node-38", - "target": "node-30", - "sourceHandle": "y", - "targetHandle": "a" - }, - { - "id": "xy-edge__separateUvsx-node-2e1", - "source": "separateUvs", - "target": "node-2", - "sourceHandle": "x", - "targetHandle": "e1" - }, - { - "id": "xy-edge__separateUvsy-node-3e1", - "source": "separateUvs", - "target": "node-3", - "sourceHandle": "y", - "targetHandle": "e1" - }, - { - "id": "xy-edge__node-31result-node-36x", - "source": "node-31", - "target": "node-36", - "sourceHandle": "result", - "targetHandle": "x" - }, - { - "id": "xy-edge__node-34result-node-36y", - "source": "node-34", - "target": "node-36", - "sourceHandle": "result", - "targetHandle": "y" - }, - { - "id": "xy-edge__node-9result-vec4f1w", - "source": "node-9", - "target": "vec4f1", - "sourceHandle": "result", - "targetHandle": "w" - } - ] + "definitions": {}, + "root": { + "nodes": [ + { + "id": "separateUvs", + "type": "separateVec2f", + "data": { + "x": 0, + "y": 0 + }, + "position": { + "x": -303.75006043968585, + "y": -239.05269179388642 + } + }, + { + "id": "vec4f1", + "type": "vec4f", + "data": { + "x": 1, + "y": 1, + "z": 1, + "w": 1 + }, + "position": { + "x": 1155.6899556313651, + "y": -55.595713465409005 + } + }, + { + "id": "out1", + "type": "frag-output", + "data": {}, + "position": { + "x": 1375.7357311503742, + "y": -48.96944047032002 + } + }, + { + "id": "node-2", + "type": "pow", + "data": { + "e1": 0, + "e2": 2 + }, + "position": { + "x": -59.407697002206234, + "y": -358.8584787446738 + } + }, + { + "id": "node-3", + "type": "pow", + "data": { + "e1": 0, + "e2": 2 + }, + "position": { + "x": -69.47461669207212, + "y": -152.12734742820936 + } + }, + { + "id": "node-4", + "type": "add", + "data": {}, + "position": { + "x": 165.49914485309483, + "y": -221.78736765676794 + } + }, + { + "id": "node-5", + "type": "pow", + "data": { + "e1": 0, + "e2": 0.5 + }, + "position": { + "x": 399.29379345226795, + "y": -235.4730772096017 + } + }, + { + "id": "node-8", + "type": "remap", + "data": { + "e": 0, + "in_min": 0.499, + "in_max": 0.501, + "out_min": 1, + "out_max": 0 + }, + "position": { + "x": 692.1244342075336, + "y": -88.22905575491284 + } + }, + { + "id": "node-9", + "type": "clamp", + "data": { + "e": 0, + "low": 0, + "high": 1 + }, + "position": { + "x": 915.5537618866938, + "y": -51.83920429790382 + } + }, + { + "id": "node-10", + "type": "time", + "data": {}, + "position": { + "x": -797.6049657846668, + "y": 242.73855889338316 + } + }, + { + "id": "node-11", + "type": "sin", + "data": { + "e": 0 + }, + "position": { + "x": -277.0693967656557, + "y": 224.61444097098936 + } + }, + { + "id": "node-12", + "type": "remap", + "data": { + "e": 0, + "in_min": -1, + "in_max": 1, + "out_min": 0.05, + "out_max": 0.405 + }, + "position": { + "x": -5.185429596891694, + "y": 209.33047336098778 + } + }, + { + "id": "node-13", + "type": "add", + "data": {}, + "position": { + "x": 404.92163648110886, + "y": 145.47856848731743 + } + }, + { + "id": "node-14", + "type": "float", + "data": { + "value": 0.0005 + }, + "position": { + "x": -1.2004973250476496, + "y": 29.75570992201213 + } + }, + { + "id": "node-15", + "type": "sub", + "data": { + "a": 0, + "b": 0 + }, + "position": { + "x": 404.32302554495897, + "y": -42.132893653008296 + } + }, + { + "id": "node-16", + "type": "mul", + "data": {}, + "position": { + "x": -538.8275825407148, + "y": 265.7517008108812 + } + }, + { + "id": "node-17", + "type": "float", + "data": { + "value": 0.5 + }, + "position": { + "x": -826.2812486756197, + "y": 376.13618928427223 + } + }, + { + "id": "node-18", + "type": "resolution", + "data": {}, + "position": { + "x": -2269.426547127526, + "y": -645.2054347507909 + } + }, + { + "id": "node-19", + "type": "div", + "data": { + "a": 0, + "b": 0 + }, + "position": { + "x": -1619.8272070534458, + "y": -726.9445910250517 + } + }, + { + "id": "node-20", + "type": "separateVec2f", + "data": { + "x": 0, + "y": 0 + }, + "position": { + "x": -2069.4335249248134, + "y": -695.4958618425023 + } + }, + { + "id": "node-21", + "type": "max", + "data": { + "e1": 0, + "e2": 1 + }, + "position": { + "x": -1849.7901800405057, + "y": -581.1332384120589 + } + }, + { + "id": "node-24", + "type": "mix", + "data": { + "e1": 0, + "e2": 1, + "e3": 0 + }, + "position": { + "x": -1059.13903314223, + "y": -771.3519434872421 + } + }, + { + "id": "node-25", + "type": "max", + "data": { + "e1": 0, + "e2": 1 + }, + "position": { + "x": -1339.0009855904636, + "y": -601.1469564877134 + } + }, + { + "id": "node-27", + "type": "mix", + "data": { + "e1": 1, + "e2": 0, + "e3": 0 + }, + "position": { + "x": -1070.6144133000953, + "y": -547.7780064474518 + } + }, + { + "id": "node-28", + "type": "div", + "data": { + "a": 1, + "b": 0 + }, + "position": { + "x": -1324.6643172024567, + "y": -416.3129479207945 + } + }, + { + "id": "node-29", + "type": "sub", + "data": { + "a": 0, + "b": 0.5 + }, + "position": { + "x": -1044.7810525000905, + "y": -948.3690268281516 + } + }, + { + "id": "node-30", + "type": "sub", + "data": { + "a": 0, + "b": 0.5 + }, + "position": { + "x": -1080.6509875644324, + "y": -312.10684951642577 + } + }, + { + "id": "node-31", + "type": "mul", + "data": {}, + "position": { + "x": -837.2791926400846, + "y": -654.272932536356 + } + }, + { + "id": "node-34", + "type": "mul", + "data": {}, + "position": { + "x": -847.1242089616887, + "y": -432.0932546703629 + } + }, + { + "id": "node-36", + "type": "vec2f", + "data": { + "x": 0, + "y": 0 + }, + "position": { + "x": -594.5218148766417, + "y": -566.5136147959828 + } + }, + { + "id": "node-37", + "type": "uv", + "data": {}, + "position": { + "x": -2269.0984471540523, + "y": -374.87435081897354 + } + }, + { + "id": "node-38", + "type": "separateVec2f", + "data": { + "x": 0, + "y": 0 + }, + "position": { + "x": -2029.2509399249955, + "y": -412.20773063323446 + } + } + ], + "edges": [ + { + "id": "e-vec4f1-out1", + "source": "vec4f1", + "target": "out1" + }, + { + "id": "xy-edge__node-2result-node-4operands", + "source": "node-2", + "target": "node-4", + "sourceHandle": "result", + "targetHandle": "operands" + }, + { + "id": "xy-edge__node-3result-node-4operands", + "source": "node-3", + "target": "node-4", + "sourceHandle": "result", + "targetHandle": "operands" + }, + { + "id": "xy-edge__node-4result-node-5e1", + "source": "node-4", + "target": "node-5", + "sourceHandle": "result", + "targetHandle": "e1" + }, + { + "id": "xy-edge__node-5result-node-8e", + "source": "node-5", + "target": "node-8", + "sourceHandle": "result", + "targetHandle": "e" + }, + { + "id": "xy-edge__node-8result-node-9e", + "source": "node-8", + "target": "node-9", + "sourceHandle": "result", + "targetHandle": "e" + }, + { + "id": "xy-edge__node-11result-node-12e", + "source": "node-11", + "target": "node-12", + "sourceHandle": "result", + "targetHandle": "e" + }, + { + "id": "xy-edge__node-12result-node-13operands", + "source": "node-12", + "target": "node-13", + "sourceHandle": "result", + "targetHandle": "operands" + }, + { + "id": "xy-edge__node-14value-node-13operands", + "source": "node-14", + "target": "node-13", + "sourceHandle": "value", + "targetHandle": "operands" + }, + { + "id": "xy-edge__node-12result-node-15a", + "source": "node-12", + "target": "node-15", + "sourceHandle": "result", + "targetHandle": "a" + }, + { + "id": "xy-edge__node-14value-node-15b", + "source": "node-14", + "target": "node-15", + "sourceHandle": "value", + "targetHandle": "b" + }, + { + "id": "xy-edge__node-15result-node-8in_min", + "source": "node-15", + "target": "node-8", + "sourceHandle": "result", + "targetHandle": "in_min" + }, + { + "id": "xy-edge__node-13result-node-8in_max", + "source": "node-13", + "target": "node-8", + "sourceHandle": "result", + "targetHandle": "in_max" + }, + { + "id": "xy-edge__node-10time-node-16operands", + "source": "node-10", + "target": "node-16", + "sourceHandle": "time", + "targetHandle": "operands" + }, + { + "id": "xy-edge__node-16result-node-11e", + "source": "node-16", + "target": "node-11", + "sourceHandle": "result", + "targetHandle": "e" + }, + { + "id": "xy-edge__node-17value-node-16operands", + "source": "node-17", + "target": "node-16", + "sourceHandle": "value", + "targetHandle": "operands" + }, + { + "id": "xy-edge__node-18resolution-node-20xy", + "source": "node-18", + "target": "node-20", + "sourceHandle": "resolution", + "targetHandle": "xy" + }, + { + "id": "xy-edge__node-20x-node-19a", + "source": "node-20", + "target": "node-19", + "sourceHandle": "x", + "targetHandle": "a" + }, + { + "id": "xy-edge__node-20y-node-21e1", + "source": "node-20", + "target": "node-21", + "sourceHandle": "y", + "targetHandle": "e1" + }, + { + "id": "xy-edge__node-21result-node-19b", + "source": "node-21", + "target": "node-19", + "sourceHandle": "result", + "targetHandle": "b" + }, + { + "id": "xy-edge__node-19result-node-24e1", + "source": "node-19", + "target": "node-24", + "sourceHandle": "result", + "targetHandle": "e1" + }, + { + "id": "xy-edge__node-19result-node-25e1", + "source": "node-19", + "target": "node-25", + "sourceHandle": "result", + "targetHandle": "e1" + }, + { + "id": "xy-edge__node-25result-node-24e3", + "source": "node-25", + "target": "node-24", + "sourceHandle": "result", + "targetHandle": "e3" + }, + { + "id": "xy-edge__node-19result-node-28b", + "source": "node-19", + "target": "node-28", + "sourceHandle": "result", + "targetHandle": "b" + }, + { + "id": "xy-edge__node-28result-node-27e2", + "source": "node-28", + "target": "node-27", + "sourceHandle": "result", + "targetHandle": "e2" + }, + { + "id": "xy-edge__node-25result-node-27e3", + "source": "node-25", + "target": "node-27", + "sourceHandle": "result", + "targetHandle": "e3" + }, + { + "id": "xy-edge__node-24result-node-31operands", + "source": "node-24", + "target": "node-31", + "sourceHandle": "result", + "targetHandle": "operands" + }, + { + "id": "xy-edge__node-29result-node-31operands", + "source": "node-29", + "target": "node-31", + "sourceHandle": "result", + "targetHandle": "operands" + }, + { + "id": "xy-edge__node-27result-node-34operands", + "source": "node-27", + "target": "node-34", + "sourceHandle": "result", + "targetHandle": "operands" + }, + { + "id": "xy-edge__node-30result-node-34operands", + "source": "node-30", + "target": "node-34", + "sourceHandle": "result", + "targetHandle": "operands" + }, + { + "id": "xy-edge__node-36value-separateUvsxy", + "source": "node-36", + "target": "separateUvs", + "sourceHandle": "value", + "targetHandle": "xy" + }, + { + "id": "xy-edge__node-37uv-node-38xy", + "source": "node-37", + "target": "node-38", + "sourceHandle": "uv", + "targetHandle": "xy" + }, + { + "id": "xy-edge__node-38x-node-29a", + "source": "node-38", + "target": "node-29", + "sourceHandle": "x", + "targetHandle": "a" + }, + { + "id": "xy-edge__node-38y-node-30a", + "source": "node-38", + "target": "node-30", + "sourceHandle": "y", + "targetHandle": "a" + }, + { + "id": "xy-edge__separateUvsx-node-2e1", + "source": "separateUvs", + "target": "node-2", + "sourceHandle": "x", + "targetHandle": "e1" + }, + { + "id": "xy-edge__separateUvsy-node-3e1", + "source": "separateUvs", + "target": "node-3", + "sourceHandle": "y", + "targetHandle": "e1" + }, + { + "id": "xy-edge__node-31result-node-36x", + "source": "node-31", + "target": "node-36", + "sourceHandle": "result", + "targetHandle": "x" + }, + { + "id": "xy-edge__node-34result-node-36y", + "source": "node-34", + "target": "node-36", + "sourceHandle": "result", + "targetHandle": "y" + }, + { + "id": "xy-edge__node-9result-vec4f1w", + "source": "node-9", + "target": "vec4f1", + "sourceHandle": "result", + "targetHandle": "w" + } + ] + } } diff --git a/packages/editor/src/lib/components/editor/types.ts b/packages/editor/src/lib/components/editor/types.ts index 61e59e7..59d0d32 100644 --- a/packages/editor/src/lib/components/editor/types.ts +++ b/packages/editor/src/lib/components/editor/types.ts @@ -1,3 +1,5 @@ +import type { Node } from "@xyflow/svelte"; + // ─── WGSL type system ──────────────────────────────────────── export type WgslType = "f32" | "vec2f" | "vec3f" | "vec4f"; @@ -8,6 +10,12 @@ export const WGSL_DEFAULTS: Record = { vec4f: "vec4f(0.0, 0.0, 0.0, 1.0)", }; +export type HandleDef = { + id: string; // stable, generated once, never changes — this is what edges/targetHandle reference + label: string; // user-facing, editable later, defaults to something like "Input 1" + type: WgslType; // hardcode "f32" for now, per your call +}; + // ─── Descriptor types ──────────────────────────────────────── export type NodeInputDef = { name: string; @@ -25,6 +33,14 @@ export type NodeOutputDef = { type: WgslType; }; +/** Every node that's used by SvelteFlow */ +export type GroupedNode = Node & { + group?: { + definitionRef: string; + locked: boolean; + }; +}; + export type NodeCategory = "math" | "input" | "output" | "color" | "vector"; export type WgslCtx = { diff --git a/packages/editor/src/lib/components/editor/utils.ts b/packages/editor/src/lib/components/editor/utils.ts new file mode 100644 index 0000000..c37ead4 --- /dev/null +++ b/packages/editor/src/lib/components/editor/utils.ts @@ -0,0 +1,3 @@ +import { createContext } from "svelte"; + +export const [getDevContext, setDevContext] = createContext(); diff --git a/packages/editor/src/lib/components/editor/xy-theme.css b/packages/editor/src/lib/components/editor/xy-theme.css index ee0fb8c..fad199a 100644 --- a/packages/editor/src/lib/components/editor/xy-theme.css +++ b/packages/editor/src/lib/components/editor/xy-theme.css @@ -2,14 +2,6 @@ @import "@xyflow/svelte/dist/style.css" layer(base); -:root { - --xy-background-color-default: transparent; -} - -.dark { - --xy-background-color-default: transparent; -} - @reference "$theme/css"; /* ============================================= @@ -17,7 +9,7 @@ ============================================= */ .svelte-flow__node { - @apply bg-card justify-center rounded-sm; + @apply bg-card w-45 justify-center rounded-sm; box-shadow: 0 0 10px var(--background); } @@ -48,6 +40,8 @@ .svelte-flow__node-group { @apply border-accent bg-accent/40; + min-width: 150px; + min-height: 100px; } .svelte-flow__handle { diff --git a/packages/editor/src/lib/index.ts b/packages/editor/src/lib/index.ts index 18fb38e..5a5683a 100644 --- a/packages/editor/src/lib/index.ts +++ b/packages/editor/src/lib/index.ts @@ -4,5 +4,4 @@ export { setGraphManager, getGraphManager, } from "./components/editor/graph-state.svelte"; -export { isGraphDocument } from "./components/editor/graph-state.svelte"; -export type { GraphDocument } from "./components/editor/graph-state.svelte"; +export * from "./components/editor/utils"; diff --git a/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte new file mode 100644 index 0000000..34da9c7 --- /dev/null +++ b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte @@ -0,0 +1,24 @@ + + + diff --git a/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte new file mode 100644 index 0000000..0b34367 --- /dev/null +++ b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte new file mode 100644 index 0000000..8ca015c --- /dev/null +++ b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte @@ -0,0 +1,32 @@ + + +{#if child} + {@render child({ props: attrs })} +{:else} + + {@render children?.()} + +{/if} diff --git a/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte new file mode 100644 index 0000000..5133b0f --- /dev/null +++ b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte @@ -0,0 +1,21 @@ + + +
      + {@render children?.()} +
    diff --git a/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte new file mode 100644 index 0000000..c3ccf5a --- /dev/null +++ b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte @@ -0,0 +1,24 @@ + + + + {@render children?.()} + diff --git a/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte new file mode 100644 index 0000000..26e8a4c --- /dev/null +++ b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte @@ -0,0 +1,28 @@ + + + diff --git a/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb.svelte b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb.svelte new file mode 100644 index 0000000..fe1f92f --- /dev/null +++ b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb.svelte @@ -0,0 +1,22 @@ + + + diff --git a/packages/theme/src/lib/components/ui/breadcrumb/index.ts b/packages/theme/src/lib/components/ui/breadcrumb/index.ts new file mode 100644 index 0000000..2f550b2 --- /dev/null +++ b/packages/theme/src/lib/components/ui/breadcrumb/index.ts @@ -0,0 +1,25 @@ +import Ellipsis from "./breadcrumb-ellipsis.svelte"; +import Item from "./breadcrumb-item.svelte"; +import Link from "./breadcrumb-link.svelte"; +import List from "./breadcrumb-list.svelte"; +import Page from "./breadcrumb-page.svelte"; +import Separator from "./breadcrumb-separator.svelte"; +import Root from "./breadcrumb.svelte"; + +export { + Root, + Ellipsis, + Item, + Separator, + Link, + List, + Page, + // + Root as Breadcrumb, + Ellipsis as BreadcrumbEllipsis, + Item as BreadcrumbItem, + Separator as BreadcrumbSeparator, + Link as BreadcrumbLink, + List as BreadcrumbList, + Page as BreadcrumbPage, +}; diff --git a/packages/theme/src/lib/components/ui/index.ts b/packages/theme/src/lib/components/ui/index.ts index f2da619..80475cf 100644 --- a/packages/theme/src/lib/components/ui/index.ts +++ b/packages/theme/src/lib/components/ui/index.ts @@ -1,3 +1,4 @@ +export * as Breadcrumb from "./breadcrumb/index"; export * as Button from "./button/index"; export * as ContextMenu from "./context-menu/index"; export * as Input from "./input/index"; diff --git a/tsconfig.base.json b/tsconfig.base.json index 4e2cd1d..9ee619e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -17,6 +17,7 @@ "strict": true, "target": "esnext", "verbatimModuleSyntax": true, - "customConditions": ["development"] + "customConditions": ["development"], + "noUncheckedIndexedAccess": true } } From cd897d46bf07a616e05cacb43b66de302446cc6b Mon Sep 17 00:00:00 2001 From: glypse Date: Sat, 11 Jul 2026 19:29:03 +0200 Subject: [PATCH 02/15] Group inports and exports, standard library --- apps/web/src/routes/editor/+page.svelte | 45 +++ .../lib/components/editor/GraphContent.svelte | 105 ++++++ .../lib/components/editor/NodeGraph.svelte | 41 --- .../lib/components/editor/NodeShell.svelte | 8 +- .../src/lib/components/editor/codegen.ts | 40 +- .../components/editor/graph-state.svelte.ts | 345 +++++++++++++++++- .../lib/components/editor/nodes/delta.node.ts | 6 +- .../editor/nodes/resolution.node.ts | 6 +- .../lib/components/editor/nodes/time.node.ts | 6 +- .../lib/components/editor/nodes/uv.node.ts | 6 +- .../components/editor/standard-library.json | 6 + .../standard-library/std-passthrough.json | 47 +++ .../editor/standard-library/std-remap-01.json | 65 ++++ .../editor/src/lib/components/editor/types.ts | 16 + 14 files changed, 681 insertions(+), 61 deletions(-) create mode 100644 packages/editor/src/lib/components/editor/standard-library.json create mode 100644 packages/editor/src/lib/components/editor/standard-library/std-passthrough.json create mode 100644 packages/editor/src/lib/components/editor/standard-library/std-remap-01.json diff --git a/apps/web/src/routes/editor/+page.svelte b/apps/web/src/routes/editor/+page.svelte index 3161870..924b672 100644 --- a/apps/web/src/routes/editor/+page.svelte +++ b/apps/web/src/routes/editor/+page.svelte @@ -18,6 +18,46 @@ input.click(); } + function handleExportGroup() { + const ref = graph.selectedGroupRef; + if (!ref) return; + graph.exportGroup(ref); + } + + function handleImportGroups() { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json"; + input.multiple = true; + input.onchange = async () => { + if (!input.files) return; + const files = Array.from(input.files); + const entries: { + id?: string; + name?: string; + category?: string; + dependencies?: string[]; + definition?: { nodes: unknown[]; edges: unknown[] }; + }[] = await Promise.all( + files.map(async (file) => { + const text = await file.text(); + return JSON.parse(text) as { + id?: string; + name?: string; + category?: string; + dependencies?: string[]; + definition?: { nodes: unknown[]; edges: unknown[] }; + }; + }), + ); + const result = graph.importGroups(entries); + if (result.missing.length > 0) { + console.warn("Import completed with missing dependencies:", result.missing); + } + }; + input.click(); + } + function handleKeydown(event: KeyboardEvent) { if (event.key === "m" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); @@ -41,6 +81,11 @@ Save Upload + + + Export selected group + + Import groups diff --git a/packages/editor/src/lib/components/editor/GraphContent.svelte b/packages/editor/src/lib/components/editor/GraphContent.svelte index 5966249..ab10123 100644 --- a/packages/editor/src/lib/components/editor/GraphContent.svelte +++ b/packages/editor/src/lib/components/editor/GraphContent.svelte @@ -22,6 +22,7 @@ nodesByCategory, DYNAMIC_NODE_TYPES, } from "./registry"; + import standardLibrary from "./standard-library.json"; import { type NodeCategory } from "./types"; import { getDevContext } from "./utils"; @@ -47,9 +48,57 @@ const categoryMap = $derived(nodesByCategory()); + const libraryByCategory = $derived.by(() => { + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- static data, never mutated after creation + const map = new Map(); + for (const entry of standardLibrary.library) { + const list = map.get(entry.category) ?? []; + list.push(entry); + map.set(entry.category, list); + } + return map; + }); + const { screenToFlowPosition, getNodes } = useSvelteFlow(); const graph = getGraphManager(); + const userGroups = $derived.by(() => { + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- static data, never mutated after creation + const seen = new Set(); + const stdIds = new Set(graph.library.map((e) => e.id)); + const groups: { definitionRef: string; label: string }[] = []; + let idx = 0; + + function collectFromDoc(doc: { + nodes: { type?: string; group?: { definitionRef: string } }[]; + }) { + for (const node of doc.nodes) { + if ( + node.type === "node-group" && + node.group && + !seen.has(node.group.definitionRef) + ) { + seen.add(node.group.definitionRef); + if (stdIds.has(node.group.definitionRef)) continue; + idx += 1; + groups.push({ + definitionRef: node.group.definitionRef, + label: `Group ${String(idx)}`, + }); + } + } + } + + collectFromDoc(graph.project.root); + for (const def of Object.values(graph.project.definitions)) { + collectFromDoc(def); + } + + return groups; + }); + + const isLocked = $derived(graph.isLocked); + let contextMenuPosition = $state({ x: 0, y: 0 }); function addNode(type: string) { @@ -69,6 +118,14 @@ ]; } + function addFromLibrary(definitionRef: string) { + const position = screenToFlowPosition({ + x: contextMenuPosition.x, + y: contextMenuPosition.y, + }); + graph.addFromLibrary(definitionRef, position); + } + function handleKeydown(event: KeyboardEvent) { if (event.key === "Enter") { const allNodes = getNodes(); @@ -83,6 +140,7 @@ event.preventDefault(); graph.exitGroup(); } else if (event.key === "k" && (event.metaKey || event.ctrlKey)) { + if (isLocked) return; event.preventDefault(); const selectedIds = new Set( getNodes() @@ -92,6 +150,10 @@ if (selectedIds.size >= 1) { graph.groupSelection(selectedIds); } + } else if (event.key === "Delete" || event.key === "Backspace") { + if (isLocked) { + event.preventDefault(); + } } } @@ -151,6 +213,14 @@ maxZoom={3} class={cn("", className)} proOptions={{ hideAttribution: !debug }} + nodesDraggable={!isLocked} + nodesConnectable={!isLocked} + elementsSelectable={!isLocked} + deleteKey={isLocked ? null : undefined} + onbeforedelete={isLocked ? () => false : undefined} + onselectionchange={({ nodes: selectedNodes }) => { + graph.selectedNodeIds = selectedNodes.map((n) => n.id); + }} > @@ -174,6 +244,41 @@ {/each} + {#if standardLibrary.library.length > 0} + + Library + + {#each [...libraryByCategory.entries()] as [cat, entries] (cat)} + + {cat} + + {#each entries as entry (entry.id)} + { + addFromLibrary(entry.id); + }} + > + {entry.name} + + {/each} + + + {/each} + + + {/if} + {#if userGroups.length > 0} + + Groups + + {#each userGroups as group (group.definitionRef)} + addFromLibrary(group.definitionRef)}> + {group.label} + + {/each} + + + {/if}
    diff --git a/packages/editor/src/lib/components/editor/NodeGraph.svelte b/packages/editor/src/lib/components/editor/NodeGraph.svelte index 4e7f44a..0e5c1c3 100644 --- a/packages/editor/src/lib/components/editor/NodeGraph.svelte +++ b/packages/editor/src/lib/components/editor/NodeGraph.svelte @@ -56,44 +56,3 @@ {/if} - - - {#if graph.path.length > 0} -
    - - - - { - e.preventDefault(); - graph.jumpToPath([]); - }} - > - Root - - - {#each graph.pathLabels as segment, i (segment.id)} - - - {#if i === graph.pathLabels.length - 1} - {segment.label} - {:else} - { - e.preventDefault(); - graph.jumpToPath(graph.path.slice(0, i + 1)); - }} - > - {segment.label} - - {/if} - - {/each} - - -
    - {/if} - -
    diff --git a/packages/editor/src/lib/components/editor/NodeShell.svelte b/packages/editor/src/lib/components/editor/NodeShell.svelte index 4601ae9..7292737 100644 --- a/packages/editor/src/lib/components/editor/NodeShell.svelte +++ b/packages/editor/src/lib/components/editor/NodeShell.svelte @@ -21,6 +21,8 @@ const { updateNodeData } = useSvelteFlow(); const graph = getGraphManager(); + const isLocked = $derived(graph.isLocked); + const fullNode = $derived(graph.activeDocument.nodes.find((n) => n.id === id)); const descriptor = $derived(fullNode ? graph.resolveDescriptor(fullNode) : undefined); @@ -71,7 +73,7 @@
    {/each} - {#if nodeType === "group-input"} + {#if nodeType === "group-input" && !isLocked} {@render addHandleButton()} {/if}
    @@ -95,7 +97,7 @@ { const val = parseFloat(e.currentTarget.value) || 0; @@ -109,7 +111,7 @@
    {/each} - {#if nodeType === "group-output"} + {#if nodeType === "group-output" && !isLocked} {@render addHandleButton()} {/if}
    diff --git a/packages/editor/src/lib/components/editor/codegen.ts b/packages/editor/src/lib/components/editor/codegen.ts index 2939967..0144a9c 100644 --- a/packages/editor/src/lib/components/editor/codegen.ts +++ b/packages/editor/src/lib/components/editor/codegen.ts @@ -226,7 +226,13 @@ export function projectHash(project: { */ type GroupDecls = Map< string, - { structDecl: string; fnDecl: string; fnName: string; structName: string } + { + structDecl: string; + fnDecl: string; + fnName: string; + structName: string; + globals?: { name: string; type: WgslType; wgslRef: string }[]; + } >; /** @@ -245,6 +251,7 @@ function generateBody( groupDecls: GroupDecls, resolveDescriptor: (node: GroupedNode) => NodeDescriptor, getGroupDefinition: (definitionRef: string) => { nodes: Node[]; edges: Edge[] } | undefined, + globalOverrides?: Record, ): { lines: string[]; seedNodeVarName: string | null } { const nodeMap = new Map(); for (const node of nodes) { @@ -290,7 +297,7 @@ function generateBody( const decl = groupDecls.get(definitionRef); if (decl) { const descriptor = resolveDescriptor(groupedNode); - const callArgs = descriptor.inputs.map((inputDef) => { + const inputArgs = descriptor.inputs.map((inputDef) => { const edgesForHandle = incoming.filter( (e) => e.targetHandle === inputDef.name || @@ -302,6 +309,9 @@ function generateBody( node.data[inputDef.name] ?? descriptor.defaultData[inputDef.name]; return formatWgslLiteral(inputDef.type, dataVal); }); + // Append global arguments (e.g. uv, motiongpuFrame.time) + const globalArgs = (decl.globals ?? []).map((g) => g.wgslRef); + const callArgs = [...inputArgs, ...globalArgs]; lines.push(` let ${varName} = ${decl.fnName}(${callArgs.join(", ")});`); } } @@ -351,6 +361,7 @@ function generateBody( outputVars, data: node.data, inputs: resolvedInputs, + globalOverrides, }); for (const line of wgslLines) lines.push(` ${line}`); @@ -383,6 +394,20 @@ function emitGroupFunctionIfNeeded( const fnName = `group_${ident}`; const structName = `GroupOutputs_${ident}`; + // Read globals from the group-input node's metadata + const groupInputNode = definition.nodes.find((n) => n.type === "group-input"); + const inputHandles = (groupInputNode?.data.handles ?? []) as HandleDef[]; + const globals = + (groupInputNode?.data.globals as + | { name: string; type: WgslType; wgslRef: string }[] + | undefined) ?? []; + + // Build globalOverrides: maps raw WGSL ref (e.g. "uv") to parameter name (e.g. "h_") + const globalOverrides: Record = {}; + for (const g of globals) { + globalOverrides[g.wgslRef] = sanitizeIdent(g.name); + } + const { lines } = generateBody( definition.nodes, definition.edges, @@ -390,6 +415,7 @@ function emitGroupFunctionIfNeeded( groupDecls, resolveDescriptor, getGroupDefinition, + Object.keys(globalOverrides).length > 0 ? globalOverrides : undefined, ); // The group-output node itself never emits a `let` (it's a sink, not computed) — @@ -406,10 +432,10 @@ function emitGroupFunctionIfNeeded( const structFields = handles.map((h) => ` ${sanitizeIdent(h.id)}: ${h.type},`).join("\n"); const structDecl = `struct ${structName} {\n${structFields}\n}`; - // Group's inputs (its parameters) come from the group-input node's handles. - const groupInputNode = definition.nodes.find((n) => n.type === "group-input"); - const inputHandles = (groupInputNode?.data.handles ?? []) as HandleDef[]; - const params = inputHandles.map((h) => `${sanitizeIdent(h.id)}: ${h.type}`).join(", "); + // Group's inputs: regular handles + global handles + const regularParams = inputHandles.map((h) => `${sanitizeIdent(h.id)}: ${h.type}`); + const globalParams = globals.map((g) => `${sanitizeIdent(g.name)}: ${g.type}`); + const params = [...regularParams, ...globalParams].join(", "); const fnDecl = [ `fn ${fnName}(${params}) -> ${structName} {`, @@ -418,7 +444,7 @@ function emitGroupFunctionIfNeeded( `}`, ].join("\n"); - groupDecls.set(definitionRef, { structDecl, fnDecl, fnName, structName }); + groupDecls.set(definitionRef, { structDecl, fnDecl, fnName, structName, globals }); } /** diff --git a/packages/editor/src/lib/components/editor/graph-state.svelte.ts b/packages/editor/src/lib/components/editor/graph-state.svelte.ts index 70a6349..a537b9a 100644 --- a/packages/editor/src/lib/components/editor/graph-state.svelte.ts +++ b/packages/editor/src/lib/components/editor/graph-state.svelte.ts @@ -6,6 +6,7 @@ import { buildGroupInputDescriptor } from "./nodes/groupInput.node"; import { buildGroupOutputDescriptor } from "./nodes/groupOutput.node"; import { nodeRegistry } from "./registry"; import defaultScene from "./sizing_circle.json"; +import standardLibraryIndex from "./standard-library.json"; import { type GroupedNode, type HandleDef, type NodeDescriptor, type WgslType } from "./types"; // ── Serialisable document types ────────────────────────────────────────────── @@ -16,6 +17,24 @@ export type GraphDocument = { edges: GraphDocumentEdge[]; }; +// ── Standard library per-file imports ──────────────────────────────────────── + +const libraryModules = import.meta.glob<{ + default: { + id: string; + name: string; + category: string; + dependencies: string[]; + definition: GraphDocument; + }; +}>("./standard-library/*.json", { eager: true }); + +const libraryDefinitions: Record = {}; +for (const [, mod] of Object.entries(libraryModules)) { + const entry = mod.default; + libraryDefinitions[entry.id] = entry.definition; +} + export type GraphDocumentNode = { id: string; type: string; @@ -193,6 +212,14 @@ export function serialiseDocument(nodes: Node[], edges: Edge[]): GraphDocument { // ── Reactive state manager ─────────────────────────────────────────────────── +export type StandardLibraryEntry = { + id: string; + name: string; + category: string; + dependencies: string[]; + definition: GraphDocument; +}; + export class GraphManager { /** The entire project tree. */ project = $state({ definitions: {}, root: { nodes: [], edges: [] } }); @@ -204,9 +231,24 @@ export class GraphManager { */ path = $state([]); + /** Currently selected node IDs in SvelteFlow. */ + selectedNodeIds = $state([]); + + /** Read-only standard library entries for browsing. */ + library: StandardLibraryEntry[] = standardLibraryIndex.library.map((meta) => ({ + ...meta, + definition: libraryDefinitions[meta.id] ?? { nodes: [], edges: [] }, + })); + constructor(defaults?: GraphProject) { const project: GraphProject = defaults ?? defaultScene; this.project = hydrateProject(project); + + // Register standard library definitions so they can be referenced by group nodes + for (const entry of this.library) { + const def = normaliseDocument(entry.definition); + this.project.definitions[entry.id] = hydrateDocument(def); + } } get activeDocument(): HydratedGraphDocument { @@ -248,6 +290,31 @@ export class GraphManager { this.activeDocument.edges = value; } + /** Whether the currently-viewed subgraph (or any of its ancestors) is locked. */ + get isLocked(): boolean { + let current: HydratedGraphDocument = this.project.root; + for (const nodeId of this.path) { + const node = current.nodes.find((n) => n.id === nodeId); + if (!node?.group) return false; + if (node.group.locked) return true; + const definition = this.project.definitions[node.group.definitionRef]; + if (!definition) return false; + current = definition; + } + return false; + } + + /** The definitionRef of the first selected group node, if any. */ + get selectedGroupRef(): string | undefined { + for (const id of this.selectedNodeIds) { + const node = this.activeDocument.nodes.find((n) => n.id === id); + if (node?.type === "node-group" && node.group) { + return node.group.definitionRef; + } + } + return undefined; + } + /** Labels for each segment of the breadcrumb path, suitable for rendering. */ get pathLabels(): { id: string; label: string }[] { const labels: { id: string; label: string }[] = []; @@ -292,6 +359,12 @@ export class GraphManager { loadProject(project: GraphProject) { this.project = hydrateProject(project); this.path = []; + + // Re-register standard library definitions after loading + for (const entry of this.library) { + const def = normaliseDocument(entry.definition); + this.project.definitions[entry.id] = hydrateDocument(def); + } } getProjectJson(): string { @@ -335,6 +408,148 @@ export class GraphManager { } } + // ── Export / Import ─────────────────────────────────────────────────────── + + /** Check if a definitionRef belongs to the standard library. */ + private isStandardLibrary(definitionRef: string): boolean { + return this.library.some((e) => e.id === definitionRef); + } + + /** + * Collect all nested group definitionRefs referenced by a definition. + * Does NOT include standard library refs (they're always available). + */ + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- pure algorithm helper, not reactive state + private collectNestedRefs(definitionRef: string, visited = new Set()): string[] { + if (visited.has(definitionRef) || this.isStandardLibrary(definitionRef)) return []; + visited.add(definitionRef); + + const def = this.project.definitions[definitionRef]; + if (!def) return []; + + const refs: string[] = []; + for (const node of def.nodes) { + if (node.type === "node-group" && node.group) { + refs.push(node.group.definitionRef); + refs.push(...this.collectNestedRefs(node.group.definitionRef, visited)); + } + } + return refs; + } + + /** + * Build the metadata for a single group definition export. + * The definitionRef is used as the stable id. + */ + private buildExportEntry(definitionRef: string): + | { + id: string; + name: string; + category: string; + dependencies: string[]; + definition: GraphDocument; + } + | undefined { + const def = this.project.definitions[definitionRef]; + if (!def) return undefined; + + const deps = this.collectNestedRefs(definitionRef); + const entry = this.library.find((e) => e.id === definitionRef); + + return { + id: definitionRef, + name: entry?.name ?? "Group", + category: entry?.category ?? "group", + dependencies: deps, + definition: { + nodes: def.nodes.map((n) => ({ + id: n.id, + type: n.type ?? "", + data: JSON.parse(JSON.stringify(n.data)) as Record, + position: { ...n.position }, + ...(n.group ? { group: { ...n.group } } : {}), + })), + edges: def.edges.map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + sourceHandle: e.sourceHandle ?? undefined, + targetHandle: e.targetHandle ?? undefined, + })), + }, + }; + } + + /** Trigger a browser download of a single JSON file. */ + private downloadJson(filename: string, data: unknown) { + const blob = new Blob([JSON.stringify(data, null, "\t")], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + } + + /** + * Export a single group (and all its nested dependencies) to downloadable JSON files. + * Nested groups are exported as separate files. Standard library deps are skipped. + */ + exportGroup(definitionRef: string) { + const refs = [definitionRef, ...this.collectNestedRefs(definitionRef)]; + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- local dedup, not reactive state + const uniqueRefs = [...new Set(refs)]; + + for (const ref of uniqueRefs) { + const entry = this.buildExportEntry(ref); + if (!entry) continue; + const slug = entry.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + this.downloadJson(`${slug}.json`, entry); + } + } + + /** + * Import group definitions from parsed JSON files. + * All files are loaded first, then dependencies are checked. + * Missing dependencies trigger a console warning but don't block the import. + */ + importGroups( + jsonFiles: { + id?: string; + name?: string; + category?: string; + dependencies?: string[]; + definition?: GraphDocument; + }[], + ) { + // Phase 1: Load all definitions + for (const file of jsonFiles) { + if (!file.id || !file.definition) { + console.warn("[graph] skipping malformed import entry", file); + continue; + } + const normalised = normaliseDocument(file.definition); + this.project.definitions[file.id] = hydrateDocument(normalised); + } + + // Phase 2: Check dependencies + const missing: string[] = []; + for (const file of jsonFiles) { + if (!file.id || !file.dependencies) continue; + for (const dep of file.dependencies) { + if (!this.project.definitions[dep] && !this.isStandardLibrary(dep)) { + missing.push(`${file.id} depends on ${dep}`); + } + } + } + + if (missing.length > 0) { + console.warn("[graph] imported groups have missing dependencies:", missing); + } + + return { imported: jsonFiles.length, missing }; + } + // TODO: groupSelection(nodeIds: string[]): void // The Ctrl+G operation. Not stubbed here since you said you want to design the // edge-rewiring logic yourself — but it belongs on this class, since it needs to @@ -373,6 +588,25 @@ export class GraphManager { { x: bounds.maxX + OFFSET, y: bounds.centerY }, ); + // Detect globals from selected nodes — store as metadata only, no handles + // (globals are implicit inputs, not user-visible connections) + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const seenGlobalRefs = new Set(); + const allGlobals: { name: string; type: WgslType; wgslRef: string }[] = []; + for (const node of selectedNodes) { + const descriptor = this.resolveDescriptor(node); + if (!descriptor.globals) continue; + for (const g of descriptor.globals) { + if (!seenGlobalRefs.has(g.wgslRef)) { + seenGlobalRefs.add(g.wgslRef); + allGlobals.push(g); + } + } + } + if (allGlobals.length > 0) { + (groupInputNode.node.data as Record).globals = allGlobals; + } + const definitionRef = crypto.randomUUID(); const groupNodeId = crypto.randomUUID(); @@ -660,12 +894,111 @@ export class GraphManager { } } - // TODO: lockGroup(nodeId: string, definitionRef: string): void - // TODO: unlockGroup(nodeId: string): void - // unlockGroup needs a deep clone of project.definitions[definitionRef] — make sure - // it's a REAL deep clone (structuredClone works fine here) so mutating the unlocked - // copy can never reach back and mutate the shared definition. - // Careful: each node's ID when unlocking a node group MUST be regenerated + /** + * Deep-clone a locked group's definition so the user gets their own editable copy. + * Every node and edge ID inside the clone is regenerated to prevent collisions. + * Returns the new definitionRef pointing to the cloned definition. + */ + deepClone(lockedNodeGroupId: string): string | undefined { + const node = this.activeDocument.nodes.find((n) => n.id === lockedNodeGroupId); + if (!node?.group) { + console.warn(`[graph] deepClone: "${lockedNodeGroupId}" is not a group node`); + return undefined; + } + + const sourceDef = this.project.definitions[node.group.definitionRef]; + if (!sourceDef) { + console.warn(`[graph] deepClone: missing definition "${node.group.definitionRef}"`); + return undefined; + } + + const newDefRef = crypto.randomUUID(); + + // Build oldId → newId map for every node + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const idMap = new Map(); + for (const n of sourceDef.nodes) { + idMap.set(n.id, crypto.randomUUID()); + } + + const clonedNodes: GraphDocumentNode[] = sourceDef.nodes.map((n) => ({ + id: idMap.get(n.id) ?? n.id, + type: n.type ?? "", + data: JSON.parse(JSON.stringify(n.data)) as Record, + position: { ...n.position }, + ...(n.group + ? { group: { definitionRef: n.group.definitionRef, locked: n.group.locked } } + : {}), + })); + + const clonedEdges: GraphDocumentEdge[] = sourceDef.edges.map((e) => ({ + id: crypto.randomUUID(), + source: idMap.get(e.source) ?? e.source, + target: idMap.get(e.target) ?? e.target, + sourceHandle: e.sourceHandle ?? undefined, + targetHandle: e.targetHandle ?? undefined, + })); + + this.project.definitions[newDefRef] = { nodes: clonedNodes, edges: clonedEdges }; + + // Point the group node at the new (unlocked) clone + node.group = { definitionRef: newDefRef, locked: false }; + + return newDefRef; + } + + /** + * Add a node-group to the active document from a standard-library definition. + * The definition is deep-cloned so the original is never mutated. + */ + addFromLibrary(definitionRef: string, position: { x: number; y: number }): string | undefined { + const sourceDef = this.project.definitions[definitionRef]; + if (!sourceDef) { + console.warn(`[graph] addFromLibrary: unknown definition "${definitionRef}"`); + return undefined; + } + + const newDefRef = crypto.randomUUID(); + + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const idMap = new Map(); + for (const n of sourceDef.nodes) { + idMap.set(n.id, crypto.randomUUID()); + } + + const clonedNodes: GraphDocumentNode[] = sourceDef.nodes.map((n) => ({ + id: idMap.get(n.id) ?? n.id, + type: n.type ?? "", + data: JSON.parse(JSON.stringify(n.data)) as Record, + position: { ...n.position }, + ...(n.group + ? { group: { definitionRef: n.group.definitionRef, locked: n.group.locked } } + : {}), + })); + + const clonedEdges: GraphDocumentEdge[] = sourceDef.edges.map((e) => ({ + id: crypto.randomUUID(), + source: idMap.get(e.source) ?? e.source, + target: idMap.get(e.target) ?? e.target, + sourceHandle: e.sourceHandle ?? undefined, + targetHandle: e.targetHandle ?? undefined, + })); + + this.project.definitions[newDefRef] = { nodes: clonedNodes, edges: clonedEdges }; + + const groupNodeId = crypto.randomUUID(); + const groupNode: GraphDocumentNode = { + id: groupNodeId, + type: "node-group", + data: {}, + position, + group: { definitionRef: newDefRef, locked: true }, + }; + + this.activeDocument.nodes = [...this.activeDocument.nodes, groupNode]; + + return groupNodeId; + } } // ── Context helpers ────────────────────────────────────────────────────────── diff --git a/packages/editor/src/lib/components/editor/nodes/delta.node.ts b/packages/editor/src/lib/components/editor/nodes/delta.node.ts index b4d6fa5..f4b643e 100644 --- a/packages/editor/src/lib/components/editor/nodes/delta.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/delta.node.ts @@ -7,5 +7,9 @@ export const deltaNode = { inputs: [], outputs: [{ name: "delta", type: "f32" }], defaultData: {}, - wgsl: ({ varName }) => [`let ${varName}: f32 = motiongpuFrame.delta;`], + globals: [{ name: "delta", type: "f32", wgslRef: "motiongpuFrame.delta" }], + wgsl: ({ varName, globalOverrides }) => { + const ref = globalOverrides?.delta ?? "motiongpuFrame.delta"; + return [`let ${varName}: f32 = ${ref};`]; + }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/resolution.node.ts b/packages/editor/src/lib/components/editor/nodes/resolution.node.ts index 3fa3633..3b3fa48 100644 --- a/packages/editor/src/lib/components/editor/nodes/resolution.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/resolution.node.ts @@ -7,5 +7,9 @@ export const resolutionNode = { inputs: [], outputs: [{ name: "resolution", type: "vec2f" }], defaultData: {}, - wgsl: ({ varName }) => [`let ${varName}: vec2f = motiongpuFrame.resolution;`], + globals: [{ name: "resolution", type: "vec2f", wgslRef: "motiongpuFrame.resolution" }], + wgsl: ({ varName, globalOverrides }) => { + const ref = globalOverrides?.resolution ?? "motiongpuFrame.resolution"; + return [`let ${varName}: vec2f = ${ref};`]; + }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/time.node.ts b/packages/editor/src/lib/components/editor/nodes/time.node.ts index c8e7fc4..664f4bb 100644 --- a/packages/editor/src/lib/components/editor/nodes/time.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/time.node.ts @@ -7,5 +7,9 @@ export const timeNode = { inputs: [], outputs: [{ name: "time", type: "f32" }], defaultData: {}, - wgsl: ({ varName }) => [`let ${varName}: f32 = motiongpuFrame.time;`], + globals: [{ name: "time", type: "f32", wgslRef: "motiongpuFrame.time" }], + wgsl: ({ varName, globalOverrides }) => { + const ref = globalOverrides?.time ?? "motiongpuFrame.time"; + return [`let ${varName}: f32 = ${ref};`]; + }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/uv.node.ts b/packages/editor/src/lib/components/editor/nodes/uv.node.ts index c6342cb..a2096c1 100644 --- a/packages/editor/src/lib/components/editor/nodes/uv.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/uv.node.ts @@ -7,5 +7,9 @@ export const uvNode = { inputs: [], outputs: [{ name: "uv", type: "vec2f" }], defaultData: {}, - wgsl: ({ varName }) => [`let ${varName} = uv;`], + globals: [{ name: "uv", type: "vec2f", wgslRef: "uv" }], + wgsl: ({ varName, globalOverrides }) => { + const ref = globalOverrides?.uv ?? "uv"; + return [`let ${varName} = ${ref};`]; + }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/standard-library.json b/packages/editor/src/lib/components/editor/standard-library.json new file mode 100644 index 0000000..d9e16e9 --- /dev/null +++ b/packages/editor/src/lib/components/editor/standard-library.json @@ -0,0 +1,6 @@ +{ + "library": [ + { "id": "std-passthrough", "name": "Passthrough", "category": "math", "dependencies": [] }, + { "id": "std-remap-01", "name": "Remap 0-1", "category": "math", "dependencies": [] } + ] +} diff --git a/packages/editor/src/lib/components/editor/standard-library/std-passthrough.json b/packages/editor/src/lib/components/editor/standard-library/std-passthrough.json new file mode 100644 index 0000000..a320aed --- /dev/null +++ b/packages/editor/src/lib/components/editor/standard-library/std-passthrough.json @@ -0,0 +1,47 @@ +{ + "id": "std-passthrough", + "name": "Passthrough", + "category": "math", + "dependencies": [], + "definition": { + "nodes": [ + { + "id": "input-1", + "type": "group-input", + "data": { + "handles": [ + { + "id": "h-in-1", + "label": "Value", + "type": "f32" + } + ] + }, + "position": { "x": -200, "y": 0 } + }, + { + "id": "output-1", + "type": "group-output", + "data": { + "handles": [ + { + "id": "h-out-1", + "label": "Result", + "type": "f32" + } + ] + }, + "position": { "x": 200, "y": 0 } + } + ], + "edges": [ + { + "id": "e-passthrough", + "source": "input-1", + "sourceHandle": "h-in-1", + "target": "output-1", + "targetHandle": "h-out-1" + } + ] + } +} diff --git a/packages/editor/src/lib/components/editor/standard-library/std-remap-01.json b/packages/editor/src/lib/components/editor/standard-library/std-remap-01.json new file mode 100644 index 0000000..76d21a8 --- /dev/null +++ b/packages/editor/src/lib/components/editor/standard-library/std-remap-01.json @@ -0,0 +1,65 @@ +{ + "id": "std-remap-01", + "name": "Remap 0-1", + "category": "math", + "dependencies": [], + "definition": { + "nodes": [ + { + "id": "input-1", + "type": "group-input", + "data": { + "handles": [ + { + "id": "h-value", + "label": "Value", + "type": "f32" + } + ] + }, + "position": { "x": -400, "y": 0 } + }, + { + "id": "remap-1", + "type": "remap", + "data": { + "in_min": 0, + "in_max": 1, + "out_min": 0, + "out_max": 1 + }, + "position": { "x": -100, "y": 0 } + }, + { + "id": "output-1", + "type": "group-output", + "data": { + "handles": [ + { + "id": "h-result", + "label": "Result", + "type": "f32" + } + ] + }, + "position": { "x": 200, "y": 0 } + } + ], + "edges": [ + { + "id": "e-in", + "source": "input-1", + "sourceHandle": "h-value", + "target": "remap-1", + "targetHandle": "e" + }, + { + "id": "e-out", + "source": "remap-1", + "sourceHandle": "result", + "target": "output-1", + "targetHandle": "h-result" + } + ] + } +} diff --git a/packages/editor/src/lib/components/editor/types.ts b/packages/editor/src/lib/components/editor/types.ts index 59d0d32..b2b1247 100644 --- a/packages/editor/src/lib/components/editor/types.ts +++ b/packages/editor/src/lib/components/editor/types.ts @@ -56,6 +56,14 @@ export type WgslCtx = { * - Non-variadic: `inputs["x"]` is `[varName]` (1 element) if connected, `[]` if not. * - Variadic: `inputs["operands"]` has N elements (one per connected edge), `[]` if none. */ inputs: Record; + /** + * When a node is inside a group, globals like `uv` or `motiongpuFrame.time` + * must be replaced with the group function's parameter names. + * Maps the raw WGSL reference (e.g. `"uv"`) to the parameter name + * (e.g. `"h_"`). If a global is NOT in this map, it means + * the node is at root scope and the raw reference is fine. + */ + globalOverrides?: Record; }; export type NodeDescriptor = { @@ -65,6 +73,14 @@ export type NodeDescriptor = { inputs: NodeInputDef[]; outputs: NodeOutputDef[]; defaultData: Record; + /** + * Root-level WGSL identifiers this node references that are NOT defined + * inside any group (e.g. `uv`, `motiongpuFrame.time`). + * When a node with globals is placed inside a group, the grouping logic + * must create group-input handles for them, and the codegen must forward + * them as function arguments. + */ + globals?: { name: string; type: WgslType; wgslRef: string }[]; /** Emit WGSL line(s) that define `varName`. * Each returned string becomes one line inside `fn frag()`. * The `let ` / `var ` prefix is the node's own choice. */ From 64a3f4e172d60ec5836f4604b35f2f15a1f2b732 Mon Sep 17 00:00:00 2001 From: glypse Date: Sun, 12 Jul 2026 21:53:01 +0200 Subject: [PATCH 03/15] lots of polish, needs cleaning up --- apps/web/package.json | 1 + apps/web/src/routes/+page.svelte | 31 +- apps/web/src/routes/editor/+page.svelte | 12 +- apps/web/tsconfig.json | 1 + bun.lock | 6 +- package.json | 1 + packages/editor/package.json | 3 +- .../lib/components/editor/GraphContent.svelte | 389 ++++++++++++++--- .../lib/components/editor/NodeShell.svelte | 73 +++- .../src/lib/components/editor/codegen.ts | 85 ++-- .../components/editor/graph-state.svelte.ts | 390 +++++++++++++----- .../lib/components/editor/nodes/abs.node.ts | 6 +- .../lib/components/editor/nodes/add.node.ts | 6 +- .../lib/components/editor/nodes/clamp.node.ts | 12 +- .../lib/components/editor/nodes/cos.node.ts | 6 +- .../lib/components/editor/nodes/delta.node.ts | 8 +- .../lib/components/editor/nodes/div.node.ts | 9 +- .../lib/components/editor/nodes/float.node.ts | 4 +- .../lib/components/editor/nodes/group.node.ts | 8 +- .../editor/nodes/groupInput.node.ts | 4 +- .../editor/nodes/groupOutput.node.ts | 4 +- .../lib/components/editor/nodes/max.node.ts | 9 +- .../lib/components/editor/nodes/min.node.ts | 9 +- .../lib/components/editor/nodes/mul.node.ts | 6 +- .../components/editor/nodes/output.node.ts | 3 +- .../lib/components/editor/nodes/pow.node.ts | 10 +- .../lib/components/editor/nodes/remap.node.ts | 18 +- .../editor/nodes/resolution.node.ts | 8 +- .../components/editor/nodes/select.node.ts | 12 +- .../editor/nodes/separateVec2f.node.ts | 8 +- .../editor/nodes/separateVec3f.node.ts | 12 +- .../editor/nodes/separateVec4f.node.ts | 15 +- .../lib/components/editor/nodes/sin.node.ts | 6 +- .../lib/components/editor/nodes/sub.node.ts | 9 +- .../lib/components/editor/nodes/tan.node.ts | 6 +- .../lib/components/editor/nodes/time.node.ts | 8 +- .../lib/components/editor/nodes/uv.node.ts | 8 +- .../lib/components/editor/nodes/vec2f.node.ts | 9 +- .../lib/components/editor/nodes/vec3f.node.ts | 12 +- .../lib/components/editor/nodes/vec4f.node.ts | 15 +- .../src/lib/components/editor/registry.ts | 4 +- .../components/editor/standard-library.json | 6 - .../editor/src/lib/components/editor/types.ts | 29 +- .../src/lib/components/editor/xy-theme.css | 6 +- packages/editor/src/lib/index.ts | 1 + packages/theme/package.json | 3 +- packages/theme/src/lib/components/ui/index.ts | 4 + .../theme/src/lib/components/ui/kbd/index.ts | 10 + .../lib/components/ui/kbd/kbd-group.svelte | 20 + .../src/lib/components/ui/kbd/kbd.svelte | 23 ++ .../src/lib/components/ui/popover/index.ts | 28 ++ .../ui/popover/popover-close.svelte | 7 + .../ui/popover/popover-content.svelte | 32 ++ .../ui/popover/popover-description.svelte | 21 + .../ui/popover/popover-header.svelte | 21 + .../ui/popover/popover-portal.svelte | 7 + .../ui/popover/popover-title.svelte | 16 + .../ui/popover/popover-trigger.svelte | 18 + .../lib/components/ui/popover/popover.svelte | 7 + .../ui/scrubbable/scrubbable.svelte | 53 ++- .../src/lib/components/ui/select/index.ts | 37 ++ .../ui/select/select-content.svelte | 46 +++ .../ui/select/select-group-heading.svelte | 22 + .../components/ui/select/select-group.svelte | 18 + .../components/ui/select/select-item.svelte | 41 ++ .../components/ui/select/select-label.svelte | 21 + .../components/ui/select/select-portal.svelte | 7 + .../select/select-scroll-down-button.svelte | 24 ++ .../ui/select/select-scroll-up-button.svelte | 24 ++ .../ui/select/select-separator.svelte | 19 + .../ui/select/select-trigger.svelte | 30 ++ .../lib/components/ui/select/select.svelte | 11 + .../src/lib/components/ui/sheet/index.ts | 34 ++ .../components/ui/sheet/sheet-close.svelte | 7 + .../components/ui/sheet/sheet-content.svelte | 61 +++ .../ui/sheet/sheet-description.svelte | 18 + .../components/ui/sheet/sheet-footer.svelte | 21 + .../components/ui/sheet/sheet-header.svelte | 21 + .../components/ui/sheet/sheet-overlay.svelte | 21 + .../components/ui/sheet/sheet-portal.svelte | 7 + .../components/ui/sheet/sheet-title.svelte | 18 + .../components/ui/sheet/sheet-trigger.svelte | 7 + .../src/lib/components/ui/sheet/sheet.svelte | 7 + packages/theme/tsconfig.json | 6 +- 84 files changed, 1704 insertions(+), 382 deletions(-) delete mode 100644 packages/editor/src/lib/components/editor/standard-library.json create mode 100644 packages/theme/src/lib/components/ui/kbd/index.ts create mode 100644 packages/theme/src/lib/components/ui/kbd/kbd-group.svelte create mode 100644 packages/theme/src/lib/components/ui/kbd/kbd.svelte create mode 100644 packages/theme/src/lib/components/ui/popover/index.ts create mode 100644 packages/theme/src/lib/components/ui/popover/popover-close.svelte create mode 100644 packages/theme/src/lib/components/ui/popover/popover-content.svelte create mode 100644 packages/theme/src/lib/components/ui/popover/popover-description.svelte create mode 100644 packages/theme/src/lib/components/ui/popover/popover-header.svelte create mode 100644 packages/theme/src/lib/components/ui/popover/popover-portal.svelte create mode 100644 packages/theme/src/lib/components/ui/popover/popover-title.svelte create mode 100644 packages/theme/src/lib/components/ui/popover/popover-trigger.svelte create mode 100644 packages/theme/src/lib/components/ui/popover/popover.svelte create mode 100644 packages/theme/src/lib/components/ui/select/index.ts create mode 100644 packages/theme/src/lib/components/ui/select/select-content.svelte create mode 100644 packages/theme/src/lib/components/ui/select/select-group-heading.svelte create mode 100644 packages/theme/src/lib/components/ui/select/select-group.svelte create mode 100644 packages/theme/src/lib/components/ui/select/select-item.svelte create mode 100644 packages/theme/src/lib/components/ui/select/select-label.svelte create mode 100644 packages/theme/src/lib/components/ui/select/select-portal.svelte create mode 100644 packages/theme/src/lib/components/ui/select/select-scroll-down-button.svelte create mode 100644 packages/theme/src/lib/components/ui/select/select-scroll-up-button.svelte create mode 100644 packages/theme/src/lib/components/ui/select/select-separator.svelte create mode 100644 packages/theme/src/lib/components/ui/select/select-trigger.svelte create mode 100644 packages/theme/src/lib/components/ui/select/select.svelte create mode 100644 packages/theme/src/lib/components/ui/sheet/index.ts create mode 100644 packages/theme/src/lib/components/ui/sheet/sheet-close.svelte create mode 100644 packages/theme/src/lib/components/ui/sheet/sheet-content.svelte create mode 100644 packages/theme/src/lib/components/ui/sheet/sheet-description.svelte create mode 100644 packages/theme/src/lib/components/ui/sheet/sheet-footer.svelte create mode 100644 packages/theme/src/lib/components/ui/sheet/sheet-header.svelte create mode 100644 packages/theme/src/lib/components/ui/sheet/sheet-overlay.svelte create mode 100644 packages/theme/src/lib/components/ui/sheet/sheet-portal.svelte create mode 100644 packages/theme/src/lib/components/ui/sheet/sheet-title.svelte create mode 100644 packages/theme/src/lib/components/ui/sheet/sheet-trigger.svelte create mode 100644 packages/theme/src/lib/components/ui/sheet/sheet.svelte diff --git a/apps/web/package.json b/apps/web/package.json index 3945d04..c59559e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,6 +18,7 @@ "eslint": "catalog:", "globals": "catalog:", "mode-watcher": "catalog:", + "phosphor-svelte": "catalog:", "svelte": "catalog:", "svelte-check": "catalog:", "typescript-eslint": "catalog:", diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index db47146..0c47e15 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -1,5 +1,34 @@ -See /editor :) +
    + See /editor :) + +

    How to use:

    +
      +
    • + + MAJ + + + DRAG + + to select multiple nodes, then G to group them. +
    • +
    • UV nodes CANNOT be grouped at this time
    • +
    • + When selecting a node group or when inside it, press N to bring up its + settings. +
    • +
    • + You can create your own, editable copy of any node group by opening its settings sheet + and pressing "Copy & Edit". +
    • +
    • + Nodes with are a node group. +
    • +
    • Select a node group and press Enter to enter it.
    • +
    +
    diff --git a/apps/web/src/routes/editor/+page.svelte b/apps/web/src/routes/editor/+page.svelte index 924b672..d4d28ef 100644 --- a/apps/web/src/routes/editor/+page.svelte +++ b/apps/web/src/routes/editor/+page.svelte @@ -1,5 +1,11 @@ -
    +
    false : undefined} + onbeforedelete={isLocked ? () => Promise.resolve(false) : undefined} onselectionchange={({ nodes: selectedNodes }) => { graph.selectedNodeIds = selectedNodes.map((n) => n.id); }} @@ -232,10 +388,15 @@ {categoryLabel(category)} - {#each items as item (item.type)} + {#each items as item (item.id)} { - addNode(item.type); + if (item.fromLibrary) { + addFromLibrary(item.id); + } else { + addNode(item.id); + } }} > {item.label} @@ -244,35 +405,15 @@ {/each} - {#if standardLibrary.library.length > 0} - - Library - - {#each [...libraryByCategory.entries()] as [cat, entries] (cat)} - - {cat} - - {#each entries as entry (entry.id)} - { - addFromLibrary(entry.id); - }} - > - {entry.name} - - {/each} - - - {/each} - - - {/if} {#if userGroups.length > 0} Groups {#each userGroups as group (group.definitionRef)} - addFromLibrary(group.definitionRef)}> + addFromLibrary(group.definitionRef)} + > {group.label} {/each} @@ -282,3 +423,145 @@
    + + + + + e.preventDefault()} + escapeKeydownBehavior="ignore" + > + + Group Settings + + {#if sheetGroupNode && sheetDefinition} +
    + + +
    + Group Label + { + groupLabelBuffer = e.currentTarget.value; + }} + onblur={commitGroupLabel} + onkeydown={handleEnterBlur} + /> +
    + + {#if sheetInputHandles.length > 0} +
    + Inputs + {#each sheetInputHandles as handle (handle.id)} +
    + {handle.type} + { + handleLabelBuffer[handle.id] = e.currentTarget.value; + }} + onblur={() => { + if (sheetGroupNode.group && sheetInputNodeId) { + commitHandleLabel( + sheetGroupNode.group.definitionRef, + sheetInputNodeId, + handle.id, + handleLabelBuffer[handle.id] ?? "", + ); + } + }} + onkeydown={handleEnterBlur} + /> + {#if !sheetLocked} + + {/if} +
    + {/each} +
    + {/if} + + {#if sheetOutputHandles.length > 0} +
    + Outputs + {#each sheetOutputHandles as handle (handle.id)} +
    + {handle.type} + { + handleLabelBuffer[handle.id] = e.currentTarget.value; + }} + onblur={() => { + if (sheetGroupNode.group && sheetOutputNodeId) { + commitHandleLabel( + sheetGroupNode.group.definitionRef, + sheetOutputNodeId, + handle.id, + handleLabelBuffer[handle.id] ?? "", + ); + } + }} + onkeydown={handleEnterBlur} + /> + {#if !sheetLocked} + + {/if} +
    + {/each} +
    + {/if} +
    + {/if} +
    +
    diff --git a/packages/editor/src/lib/components/editor/NodeShell.svelte b/packages/editor/src/lib/components/editor/NodeShell.svelte index 7292737..2ad1016 100644 --- a/packages/editor/src/lib/components/editor/NodeShell.svelte +++ b/packages/editor/src/lib/components/editor/NodeShell.svelte @@ -1,10 +1,12 @@ {#snippet addHandleButton()} - + + + {#snippet child({ props })} + + {/snippet} + + +
    + {#each WGSL_TYPES as t (t)} + + {/each} +
    +
    +
    {/snippet} {#if descriptor} @@ -57,13 +80,17 @@ {label} + {#if nodeType === "node-group"} + + {/if}
    {#each outputs as outputDef (outputDef.name)}
    - {outputDef.name}: {outputDef.type} + {outputDef.label}: {outputDef.type} {#if inputDef.type !== "f32"} {inputDef.name}: {inputDef.type}{inputDef.label}: {inputDef.type} {/if} {#if inputDef.type === "f32" && !inputDef.variadic && data[inputDef.name] !== undefined} - { - const val = parseFloat(e.currentTarget.value) || 0; - updateNodeData(id, { - ...data, - [inputDef.name]: val, - }); - }} - /> + {#if connectedHandles.has(inputDef.name)} + + {inputDef.label} + + {:else} + { + const val = parseFloat(e.currentTarget.value) || 0; + updateNodeData(id, { + ...data, + [inputDef.name]: val, + }); + }} + /> + {/if} {/if}
    {/each} diff --git a/packages/editor/src/lib/components/editor/codegen.ts b/packages/editor/src/lib/components/editor/codegen.ts index 0144a9c..f8627d7 100644 --- a/packages/editor/src/lib/components/editor/codegen.ts +++ b/packages/editor/src/lib/components/editor/codegen.ts @@ -36,7 +36,7 @@ function formatWgslLiteral(type: WgslType, value: unknown): string { /** * Walk backwards from seed-type nodes through edges to find all node IDs * that contribute to the final output. `seedType` is "frag-output" at root, - * "group-output" inside any group's own subgraph — that's the only thing + * "group-output" inside any group's own subgraph - that's the only thing * that differs between generating the root shader and generating one group's function. */ function findReachableNodeIds( @@ -79,7 +79,7 @@ function findReachableNodeIds( /** * Topological sort of node IDs. Works on a DAG; throws if a cycle is detected. - * Unchanged from before — this never needed to know about groups at all. + * Unchanged from before - this never needed to know about groups at all. */ function topologicalSort(nodes: Map, edges: Edge[]): string[] { const adjacency = new Map(); @@ -114,7 +114,7 @@ function topologicalSort(nodes: Map, edges: Edge[]): string[] { } if (sorted.length !== nodes.size) { - throw new Error("Cycle detected in the node graph — cannot generate shader"); + throw new Error("Cycle detected in the node graph - cannot generate shader"); } return sorted; @@ -141,7 +141,7 @@ export function debugSort(nodes: Node[], edges: Edge[]): string[] { * Resolve the WGSL variable name for a given edge's source. * - Single-output sources: `v_nodeId`. * - Multi-output sources (ordinary nodes): `v_nodeId_outputName`. - * - Group-node sources: `v_nodeId.outputHandleId` — a field access on the + * - Group-node sources: `v_nodeId.outputHandleId` - a field access on the * struct the group's generated function returns, since a group's "outputs" * are all produced by ONE function call, not separate `let` statements. */ @@ -190,9 +190,31 @@ export function projectHash(project: { root: { nodes: Node[]; edges: Edge[] }; definitions: Record; }): string { + function stripLabels(data: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(data)) { + if (k === "handles" && Array.isArray(v)) { + out[k] = v.map((h): { id: string; type: string } => { + if (typeof h === "object" && h !== null && "id" in h && "type" in h) { + const handle = h as { id: string; label: string; type: string }; + return { id: handle.id, type: handle.type }; + } + return { id: "", type: "" }; + }); + } else { + out[k] = v; + } + } + return out; + } + return JSON.stringify({ root: { - nodes: project.root.nodes.map((n) => ({ id: n.id, type: n.type, data: n.data })), + nodes: project.root.nodes.map((n) => ({ + id: n.id, + type: n.type, + data: stripLabels(n.data), + })), edges: project.root.edges.map((e) => ({ source: e.source, target: e.target, @@ -204,7 +226,11 @@ export function projectHash(project: { Object.entries(project.definitions).map(([key, doc]) => [ key, { - nodes: doc.nodes.map((n) => ({ id: n.id, type: n.type, data: n.data })), + nodes: doc.nodes.map((n) => ({ + id: n.id, + type: n.type, + data: stripLabels(n.data), + })), edges: doc.edges.map((e) => ({ source: e.source, target: e.target, @@ -231,16 +257,15 @@ type GroupDecls = Map< fnDecl: string; fnName: string; structName: string; - globals?: { name: string; type: WgslType; wgslRef: string }[]; } >; /** - * The reusable "walk one subgraph and emit its body lines" logic — this is the + * The reusable "walk one subgraph and emit its body lines" logic - this is the * part of the old generateShader that had nothing to do with being the root. * Used both for the root fragment body and for every group's function body. * - * Returns the emitted lines AND the variable name that should be returned — + * Returns the emitted lines AND the variable name that should be returned - * for root that's whatever feeds frag-output; for a group, we build a struct * literal from whatever feeds group-output instead (handled by the caller). */ @@ -251,7 +276,6 @@ function generateBody( groupDecls: GroupDecls, resolveDescriptor: (node: GroupedNode) => NodeDescriptor, getGroupDefinition: (definitionRef: string) => { nodes: Node[]; edges: Edge[] } | undefined, - globalOverrides?: Record, ): { lines: string[]; seedNodeVarName: string | null } { const nodeMap = new Map(); for (const node of nodes) { @@ -281,7 +305,7 @@ function generateBody( const varName = getVarName(nodeId); const incoming = targetEdgeMap.get(nodeId) ?? []; - // Group nodes don't call descriptor.wgsl() the normal way — they need + // Group nodes don't call descriptor.wgsl() the normal way - they need // their own function generated first (recursively), THEN a one-line // call to it. Every other node type falls through to the shared path below. if (node.type === "node-group") { @@ -309,9 +333,7 @@ function generateBody( node.data[inputDef.name] ?? descriptor.defaultData[inputDef.name]; return formatWgslLiteral(inputDef.type, dataVal); }); - // Append global arguments (e.g. uv, motiongpuFrame.time) - const globalArgs = (decl.globals ?? []).map((g) => g.wgslRef); - const callArgs = [...inputArgs, ...globalArgs]; + const callArgs = inputArgs; lines.push(` let ${varName} = ${decl.fnName}(${callArgs.join(", ")});`); } } @@ -361,7 +383,6 @@ function generateBody( outputVars, data: node.data, inputs: resolvedInputs, - globalOverrides, }); for (const line of wgslLines) lines.push(` ${line}`); @@ -373,8 +394,8 @@ function generateBody( /** * Generate one group's `struct` + `fn`, recursively (a group can contain - * another group — this just calls generateBody again, unchanged). - * No-ops if this definitionRef is already in groupDecls — that's the memoization. + * another group - this just calls generateBody again, unchanged). + * No-ops if this definitionRef is already in groupDecls - that's the memoization. */ function emitGroupFunctionIfNeeded( definitionRef: string, @@ -382,7 +403,7 @@ function emitGroupFunctionIfNeeded( resolveDescriptor: (node: GroupedNode) => NodeDescriptor, getGroupDefinition: (definitionRef: string) => { nodes: Node[]; edges: Edge[] } | undefined, ): void { - if (groupDecls.has(definitionRef)) return; // memoized — already generated + if (groupDecls.has(definitionRef)) return; // memoized - already generated const definition = getGroupDefinition(definitionRef); if (!definition) return; @@ -390,24 +411,13 @@ function emitGroupFunctionIfNeeded( const groupOutputNode = definition.nodes.find((n) => n.type === "group-output"); const handles = (groupOutputNode?.data.handles ?? []) as HandleDef[]; + const groupInputNode = definition.nodes.find((n) => n.type === "group-input"); + const inputHandles = (groupInputNode?.data.handles ?? []) as HandleDef[]; + const ident = sanitizeIdent(definitionRef); const fnName = `group_${ident}`; const structName = `GroupOutputs_${ident}`; - // Read globals from the group-input node's metadata - const groupInputNode = definition.nodes.find((n) => n.type === "group-input"); - const inputHandles = (groupInputNode?.data.handles ?? []) as HandleDef[]; - const globals = - (groupInputNode?.data.globals as - | { name: string; type: WgslType; wgslRef: string }[] - | undefined) ?? []; - - // Build globalOverrides: maps raw WGSL ref (e.g. "uv") to parameter name (e.g. "h_") - const globalOverrides: Record = {}; - for (const g of globals) { - globalOverrides[g.wgslRef] = sanitizeIdent(g.name); - } - const { lines } = generateBody( definition.nodes, definition.edges, @@ -415,10 +425,9 @@ function emitGroupFunctionIfNeeded( groupDecls, resolveDescriptor, getGroupDefinition, - Object.keys(globalOverrides).length > 0 ? globalOverrides : undefined, ); - // The group-output node itself never emits a `let` (it's a sink, not computed) — + // The group-output node itself never emits a `let` (it's a sink, not computed) - // build the struct literal directly from whatever feeds each of its handles. const outputEdges = definition.edges.filter((e) => e.target === groupOutputNode?.id); const nodeMap = new Map(); @@ -432,10 +441,8 @@ function emitGroupFunctionIfNeeded( const structFields = handles.map((h) => ` ${sanitizeIdent(h.id)}: ${h.type},`).join("\n"); const structDecl = `struct ${structName} {\n${structFields}\n}`; - // Group's inputs: regular handles + global handles - const regularParams = inputHandles.map((h) => `${sanitizeIdent(h.id)}: ${h.type}`); - const globalParams = globals.map((g) => `${sanitizeIdent(g.name)}: ${g.type}`); - const params = [...regularParams, ...globalParams].join(", "); + // Group's inputs: regular handles only + const params = inputHandles.map((h) => `${sanitizeIdent(h.id)}: ${h.type}`).join(", "); const fnDecl = [ `fn ${fnName}(${params}) -> ${structName} {`, @@ -444,7 +451,7 @@ function emitGroupFunctionIfNeeded( `}`, ].join("\n"); - groupDecls.set(definitionRef, { structDecl, fnDecl, fnName, structName, globals }); + groupDecls.set(definitionRef, { structDecl, fnDecl, fnName, structName }); } /** diff --git a/packages/editor/src/lib/components/editor/graph-state.svelte.ts b/packages/editor/src/lib/components/editor/graph-state.svelte.ts index a537b9a..7daacce 100644 --- a/packages/editor/src/lib/components/editor/graph-state.svelte.ts +++ b/packages/editor/src/lib/components/editor/graph-state.svelte.ts @@ -6,7 +6,6 @@ import { buildGroupInputDescriptor } from "./nodes/groupInput.node"; import { buildGroupOutputDescriptor } from "./nodes/groupOutput.node"; import { nodeRegistry } from "./registry"; import defaultScene from "./sizing_circle.json"; -import standardLibraryIndex from "./standard-library.json"; import { type GroupedNode, type HandleDef, type NodeDescriptor, type WgslType } from "./types"; // ── Serialisable document types ────────────────────────────────────────────── @@ -15,9 +14,13 @@ import { type GroupedNode, type HandleDef, type NodeDescriptor, type WgslType } export type GraphDocument = { nodes: GraphDocumentNode[]; edges: GraphDocumentEdge[]; + /** Shared display label for all instances of this group. */ + label?: string; + /** Whether instances of this group are locked (non-editable) by default. */ + locked?: boolean; }; -// ── Standard library per-file imports ──────────────────────────────────────── +// ── Standard library auto-import ────────────────────────────────────────────── const libraryModules = import.meta.glob<{ default: { @@ -29,9 +32,19 @@ const libraryModules = import.meta.glob<{ }; }>("./standard-library/*.json", { eager: true }); -const libraryDefinitions: Record = {}; -for (const [, mod] of Object.entries(libraryModules)) { +const libraryEntries: StandardLibraryEntry[] = Object.values(libraryModules).map((mod) => { const entry = mod.default; + return { + id: entry.id, + label: entry.name, + category: entry.category, + dependencies: entry.dependencies, + definition: entry.definition, + }; +}); + +const libraryDefinitions: Record = {}; +for (const entry of libraryEntries) { libraryDefinitions[entry.id] = entry.definition; } @@ -72,6 +85,8 @@ export type GraphProject = { export type HydratedGraphDocument = { nodes: GroupedNode[]; edges: Edge[]; + label?: string; + locked?: boolean; }; export type HydratedGraphProject = { @@ -108,8 +123,8 @@ function resolveDescriptor( if (node.type === "node-group") { return buildGroupDescriptor(node, definitions); } - // TODO: properly handle undefined here - return nodeRegistry[node.type]; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- fallback is safe + return (nodeRegistry[node.type ?? ""] ?? nodeRegistry.float)!; } export function normaliseDocument(doc: GraphDocument): GraphDocument { @@ -121,14 +136,9 @@ export function normaliseDocument(doc: GraphDocument): GraphDocument { } const desc = resolveDescriptor(node, {}); - // TODO: this check should happen higher up in the chain - if (desc === undefined) { - console.warn(`[graph] unknown node type "${node.type}" — dropping`); - continue; - } nodes.push({ ...node, data: { ...desc.defaultData, ...node.data } }); } - return { nodes, edges: doc.edges }; + return { nodes, edges: doc.edges, label: doc.label, locked: doc.locked }; } // Unused for now, since normaliseDocument() is in turn recursively called in hydrateProject() @@ -140,12 +150,15 @@ export function normaliseDocument(doc: GraphDocument): GraphDocument { return { root, definitions }; } */ +const NON_DELETABLE_TYPES = new Set(["frag-output", "group-input", "group-output"]); + function hydrateNode(node: GraphDocumentNode): GroupedNode { const base = { id: node.id, type: node.type, data: node.data, position: node.position, + deletable: NON_DELETABLE_TYPES.has(node.type) ? false : undefined, }; if (node.group) { @@ -155,7 +168,7 @@ function hydrateNode(node: GraphDocumentNode): GroupedNode { }; } - return base; // not a group node at all — most nodes hit this line + return base; // not a group node at all - most nodes hit this line } function hydrateDocument(document: GraphDocument): HydratedGraphDocument { @@ -171,7 +184,7 @@ function hydrateDocument(document: GraphDocument): HydratedGraphDocument { targetHandle: edge.targetHandle, })); - return { nodes, edges }; + return { nodes, edges, label: normalised.label, locked: normalised.locked }; } function hydrateProject(project: GraphProject): HydratedGraphProject { @@ -185,20 +198,21 @@ function hydrateProject(project: GraphProject): HydratedGraphProject { /** * Serialise a single subgraph's live SvelteFlow Node[]/Edge[] back to a GraphDocument. * NOTE: like normaliseDocument, this is per-subgraph only. A `serialiseProject` wrapping - * this for root + definitions + recursing into owned content is still TODO. + * this for root + definitions + recursing into owned content is done by + * `GraphManager.getProjectJson()`. */ -export function serialiseDocument(nodes: Node[], edges: Edge[]): GraphDocument { +export function serialiseDocument( + nodes: Node[], + edges: Edge[], + label?: string, + locked?: boolean, +): GraphDocument { return { nodes: nodes.map(({ id, type, data, position }) => ({ id, type: type ?? "", data, position, - // TODO: a live SvelteFlow Node doesn't carry `group` today — that lives only in - // your GraphProject tree, not in the flat Node/Edge shape SvelteFlow binds to. - // serialiseDocument as called from GraphManager will need the *source* GraphDocument - // (to pull `group` back out per node) alongside the live nodes/edges, since SvelteFlow's - // bound arrays only ever reflect ONE subgraph's worth of nodes at a time anyway. })), edges: edges.map((e) => ({ id: e.id, @@ -207,6 +221,8 @@ export function serialiseDocument(nodes: Node[], edges: Edge[]): GraphDocument { sourceHandle: e.sourceHandle ?? undefined, targetHandle: e.targetHandle ?? undefined, })), + ...(label !== undefined ? { label } : {}), + ...(locked !== undefined ? { locked } : {}), }; } @@ -214,7 +230,7 @@ export function serialiseDocument(nodes: Node[], edges: Edge[]): GraphDocument { export type StandardLibraryEntry = { id: string; - name: string; + label: string; category: string; dependencies: string[]; definition: GraphDocument; @@ -235,10 +251,7 @@ export class GraphManager { selectedNodeIds = $state([]); /** Read-only standard library entries for browsing. */ - library: StandardLibraryEntry[] = standardLibraryIndex.library.map((meta) => ({ - ...meta, - definition: libraryDefinitions[meta.id] ?? { nodes: [], edges: [] }, - })); + library: StandardLibraryEntry[] = libraryEntries; constructor(defaults?: GraphProject) { const project: GraphProject = defaults ?? defaultScene; @@ -247,6 +260,7 @@ export class GraphManager { // Register standard library definitions so they can be referenced by group nodes for (const entry of this.library) { const def = normaliseDocument(entry.definition); + def.label = entry.label; this.project.definitions[entry.id] = hydrateDocument(def); } } @@ -256,14 +270,14 @@ export class GraphManager { for (const nodeId of this.path) { const node = current.nodes.find((n) => n.id === nodeId); if (!node?.group) { - console.warn(`[graph] invalid path segment "${nodeId}" — resetting to root`); + console.warn(`[graph] invalid path segment "${nodeId}" - resetting to root`); this.path = []; return this.project.root; } const definition = this.project.definitions[node.group.definitionRef]; if (!definition) { console.warn( - `[graph] missing definition "${node.group.definitionRef}" — resetting to root`, + `[graph] missing definition "${node.group.definitionRef}" - resetting to root`, ); this.path = []; return this.project.root; @@ -324,25 +338,46 @@ export class GraphManager { if (!node?.group) break; const definition = this.project.definitions[node.group.definitionRef]; if (!definition) break; - const nodeType = node.type ?? "unknown"; - const desc = nodeRegistry[nodeType]; - labels.push({ id: nodeId, label: desc?.label ?? nodeType }); + if (node.type === "node-group") { + labels.push({ id: nodeId, label: definition.label ?? "Group" }); + } else { + const desc = nodeRegistry[node.type ?? ""]; + labels.push({ id: nodeId, label: desc?.label ?? node.type ?? "unknown" }); + } current = definition; } return labels; } - /** Push a group node's id onto the breadcrumb path — "enter" it. */ + /** + * The document that *contains* the current group node (one level up from the + * activeDocument). Returns the root when at the top level. + */ + get parentDocument(): HydratedGraphDocument { + if (this.path.length === 0) return this.project.root; + let current: HydratedGraphDocument = this.project.root; + for (let i = 0; i < this.path.length - 1; i++) { + const node = current.nodes.find((n) => n.id === this.path[i]); + if (!node?.group) return current; + const definition = this.project.definitions[node.group.definitionRef]; + if (!definition) return current; + current = definition; + } + return current; + } + + /** Push a group node's id onto the breadcrumb path - "enter" it. */ enterGroup(groupNodeId: string) { const node = this.activeDocument.nodes.find((n) => n.id === groupNodeId); if (!node?.group) { - console.warn(`[graph] cannot enter "${groupNodeId}" — not a group node`); + console.warn(`[graph] cannot enter "${groupNodeId}" - not a group node`); return; } + this.selectedNodeIds = []; this.path = [...this.path, groupNodeId]; } - /** Pop one level off the breadcrumb path — "exit" the current group. */ + /** Pop one level off the breadcrumb path - "exit" the current group. */ exitGroup() { if (this.path.length === 0) return; this.path = this.path.slice(0, -1); @@ -363,16 +398,27 @@ export class GraphManager { // Re-register standard library definitions after loading for (const entry of this.library) { const def = normaliseDocument(entry.definition); + def.label = entry.label; this.project.definitions[entry.id] = hydrateDocument(def); } } getProjectJson(): string { - const root = serialiseDocument(this.project.root.nodes, this.project.root.edges); + const root = serialiseDocument( + this.project.root.nodes, + this.project.root.edges, + this.project.root.label, + this.project.root.locked, + ); const definitions = Object.fromEntries( Object.entries(this.project.definitions).map(([key, definition]) => [ key, - serialiseDocument(definition.nodes, definition.edges), + serialiseDocument( + definition.nodes, + definition.edges, + definition.label, + definition.locked, + ), ]), ); const project = { root, definitions }; @@ -415,6 +461,81 @@ export class GraphManager { return this.library.some((e) => e.id === definitionRef); } + /** + * Collect all definitionRefs that are "in scope" - i.e. the definitions + * the user is currently inside of, as determined by the breadcrumb path. + */ + private collectInScopeRefs(): Set { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const refs = new Set(); + let current: HydratedGraphDocument = this.project.root; + for (const nodeId of this.path) { + const node = current.nodes.find((n) => n.id === nodeId); + if (!node?.group) break; + refs.add(node.group.definitionRef); + const def = this.project.definitions[node.group.definitionRef]; + if (!def) break; + current = def; + } + return refs; + } + + /** + * Collect all definitionRefs transitively referenced by a definition + * (excluding standard library refs, which can't create cycles). + */ + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- pure algorithm helper + private transitiveRefs(definitionRef: string, visited = new Set()): Set { + if (visited.has(definitionRef) || this.isStandardLibrary(definitionRef)) return visited; + visited.add(definitionRef); + const def = this.project.definitions[definitionRef]; + if (!def) return visited; + for (const node of def.nodes) { + if (node.type === "node-group" && node.group) { + this.transitiveRefs(node.group.definitionRef, visited); + } + } + return visited; + } + + /** + * Check whether adding an instance of `definitionRef` at the current path + * would create a circular reference (directly or transitively). + */ + wouldCreateCycle(definitionRef: string): boolean { + const inScope = this.collectInScopeRefs(); + if (inScope.size === 0) return false; + const refs = this.transitiveRefs(definitionRef); + for (const ref of refs) { + if (inScope.has(ref)) return true; + } + return false; + } + + /** Collect every label currently in use (standard library + all definitions). */ + private usedLabels(): Set { + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- pure algorithm helper + const labels = new Set(); + for (const entry of this.library) { + if (entry.label) labels.add(entry.label); + } + for (const def of Object.values(this.project.definitions)) { + if (def.label) labels.add(def.label); + } + return labels; + } + + /** Generate a unique "X - Copy" / "X - Copy 2" / … label for a cloned definition. */ + private uniqueCopyLabel(sourceLabel: string | undefined): string { + if (!sourceLabel) return "Group"; + const existing = this.usedLabels(); + const base = `${sourceLabel} - Copy`; + if (!existing.has(base)) return base; + let n = 2; + while (existing.has(`${base} ${String(n)}`)) n++; + return `${base} ${String(n)}`; + } + /** * Collect all nested group definitionRefs referenced by a definition. * Does NOT include standard library refs (they're always available). @@ -444,9 +565,10 @@ export class GraphManager { private buildExportEntry(definitionRef: string): | { id: string; - name: string; + label: string; category: string; dependencies: string[]; + locked: boolean | undefined; definition: GraphDocument; } | undefined { @@ -458,9 +580,10 @@ export class GraphManager { return { id: definitionRef, - name: entry?.name ?? "Group", + label: def.label ?? entry?.label ?? "Group", category: entry?.category ?? "group", dependencies: deps, + locked: def.locked, definition: { nodes: def.nodes.map((n) => ({ id: n.id, @@ -503,7 +626,7 @@ export class GraphManager { for (const ref of uniqueRefs) { const entry = this.buildExportEntry(ref); if (!entry) continue; - const slug = entry.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + const slug = entry.label.toLowerCase().replace(/[^a-z0-9]+/g, "-"); this.downloadJson(`${slug}.json`, entry); } } @@ -516,7 +639,7 @@ export class GraphManager { importGroups( jsonFiles: { id?: string; - name?: string; + label?: string; category?: string; dependencies?: string[]; definition?: GraphDocument; @@ -529,6 +652,8 @@ export class GraphManager { continue; } const normalised = normaliseDocument(file.definition); + if (file.label) normalised.label = file.label; + normalised.locked = true; this.project.definitions[file.id] = hydrateDocument(normalised); } @@ -550,24 +675,25 @@ export class GraphManager { return { imported: jsonFiles.length, missing }; } - // TODO: groupSelection(nodeIds: string[]): void - // The Ctrl+G operation. Not stubbed here since you said you want to design the - // edge-rewiring logic yourself — but it belongs on this class, since it needs to - // mutate `project` (remove nodes from activeDocument, create a new group node in - // their place, move them into that new node's `group.content`, and rewrite any edge - // that crossed the selection boundary to instead terminate on a new Group Input/Output - // node inside the new content). - // groupSelection(selectedNodeIds: Set): void { - console.log("hit! Selected nodes: " + [...selectedNodeIds].join(", ")); + // TODO: temporarily exclude uv nodes from grouping - revisit once subgraph wiring supports them + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const UNGROUPABLE_TYPES = new Set(["frag-output", "uv", "group-input", "group-output"]); - const selectedNodes = this.activeDocument.nodes.filter((node) => - selectedNodeIds.has(node.id), + const selectedNodes = this.activeDocument.nodes.filter( + (node) => selectedNodeIds.has(node.id) && !UNGROUPABLE_TYPES.has(node.type ?? ""), ); + + if (selectedNodes.length === 0 || selectedNodes.every((n) => n.type === "node-group")) { + return; + } + + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const filteredIds = new Set(selectedNodes.map((n) => n.id)); const bounds = computeSelectionBounds(selectedNodes); const { internal, incoming, outgoing } = classifyEdges( - selectedNodeIds, + filteredIds, this.activeDocument.edges as GraphDocumentEdge[], // We could pick only the GraphDocumentEdge properites of each Edge, but passing the raw object is more memory-efficient ); const dedupedIncoming = deduplicateBySource(incoming); @@ -588,25 +714,6 @@ export class GraphManager { { x: bounds.maxX + OFFSET, y: bounds.centerY }, ); - // Detect globals from selected nodes — store as metadata only, no handles - // (globals are implicit inputs, not user-visible connections) - // eslint-disable-next-line svelte/prefer-svelte-reactivity - const seenGlobalRefs = new Set(); - const allGlobals: { name: string; type: WgslType; wgslRef: string }[] = []; - for (const node of selectedNodes) { - const descriptor = this.resolveDescriptor(node); - if (!descriptor.globals) continue; - for (const g of descriptor.globals) { - if (!seenGlobalRefs.has(g.wgslRef)) { - seenGlobalRefs.add(g.wgslRef); - allGlobals.push(g); - } - } - } - if (allGlobals.length > 0) { - (groupInputNode.node.data as Record).globals = allGlobals; - } - const definitionRef = crypto.randomUUID(); const groupNodeId = crypto.randomUUID(); @@ -641,12 +748,21 @@ export class GraphManager { allInnerEdges.push(inner); }); + const userGroupCount = Object.keys(this.project.definitions).filter( + (k) => !this.isStandardLibrary(k), + ).length; + const definition: GraphDocument = { - nodes: [...selectedNodes, groupInputNode.node, groupOutputNode.node], + nodes: [ + ...(selectedNodes as GraphDocumentNode[]), + groupInputNode.node, + groupOutputNode.node, + ], edges: [...internal, ...allInnerEdges], + label: `Group ${String(userGroupCount)}`, }; - this.project.definitions[definitionRef] = definition; + this.project.definitions[definitionRef] = hydrateDocument(definition); const groupNode: GraphDocumentNode = { id: groupNodeId, @@ -664,7 +780,7 @@ export class GraphManager { ]); this.activeDocument.nodes = [ - ...this.activeDocument.nodes.filter((n) => !selectedNodeIds.has(n.id)), + ...this.activeDocument.nodes.filter((n) => !filteredIds.has(n.id)), groupNode, ]; @@ -673,6 +789,16 @@ export class GraphManager { ...allOuterEdges, ]; + // Clear SvelteFlow's internal selection state so moved nodes/edges + // don't appear selected inside the newly created group. + this.selectedNodeIds = []; + for (const n of this.activeDocument.nodes) { + n.selected = false; + } + for (const e of this.activeDocument.edges) { + e.selected = false; + } + function computeSelectionBounds(selectedNodes: GroupedNode[]) { const lefts = selectedNodes.map((n) => n.position.x); const tops = selectedNodes.map((n) => n.position.y); @@ -768,7 +894,7 @@ export class GraphManager { for (const [i, group] of dedupedIncoming.entries()) { const representative = group[0]; if (!representative) { - console.warn("[graph] empty edge group — skipping"); + console.warn("[graph] empty edge group - skipping"); continue; } const handle: HandleDef = { @@ -804,7 +930,7 @@ export class GraphManager { for (const [i, group] of dedupedOutgoing.entries()) { const representative = group[0]; if (!representative) { - console.warn("[graph] empty edge group — skipping"); + console.warn("[graph] empty edge group - skipping"); continue; } const handle: HandleDef = { @@ -939,7 +1065,11 @@ export class GraphManager { targetHandle: e.targetHandle ?? undefined, })); - this.project.definitions[newDefRef] = { nodes: clonedNodes, edges: clonedEdges }; + this.project.definitions[newDefRef] = hydrateDocument({ + nodes: clonedNodes, + edges: clonedEdges, + label: this.uniqueCopyLabel(sourceDef.label), + }); // Point the group node at the new (unlocked) clone node.group = { definitionRef: newDefRef, locked: false }; @@ -948,8 +1078,10 @@ export class GraphManager { } /** - * Add a node-group to the active document from a standard-library definition. - * The definition is deep-cloned so the original is never mutated. + * Add a node-group to the active document from an existing definition. + * Standard library definitions are referenced directly (locked). + * User-created definitions are shared across all instances (unlocked). + * Returns undefined if the addition would create a circular reference. */ addFromLibrary(definitionRef: string, position: { x: number; y: number }): string | undefined { const sourceDef = this.project.definitions[definitionRef]; @@ -958,33 +1090,14 @@ export class GraphManager { return undefined; } - const newDefRef = crypto.randomUUID(); - - // eslint-disable-next-line svelte/prefer-svelte-reactivity - const idMap = new Map(); - for (const n of sourceDef.nodes) { - idMap.set(n.id, crypto.randomUUID()); + if (this.wouldCreateCycle(definitionRef)) { + console.warn( + `[graph] addFromLibrary: adding "${definitionRef}" would create a circular reference`, + ); + return undefined; } - const clonedNodes: GraphDocumentNode[] = sourceDef.nodes.map((n) => ({ - id: idMap.get(n.id) ?? n.id, - type: n.type ?? "", - data: JSON.parse(JSON.stringify(n.data)) as Record, - position: { ...n.position }, - ...(n.group - ? { group: { definitionRef: n.group.definitionRef, locked: n.group.locked } } - : {}), - })); - - const clonedEdges: GraphDocumentEdge[] = sourceDef.edges.map((e) => ({ - id: crypto.randomUUID(), - source: idMap.get(e.source) ?? e.source, - target: idMap.get(e.target) ?? e.target, - sourceHandle: e.sourceHandle ?? undefined, - targetHandle: e.targetHandle ?? undefined, - })); - - this.project.definitions[newDefRef] = { nodes: clonedNodes, edges: clonedEdges }; + const isLocked = this.isStandardLibrary(definitionRef) || sourceDef.locked === true; const groupNodeId = crypto.randomUUID(); const groupNode: GraphDocumentNode = { @@ -992,13 +1105,80 @@ export class GraphManager { type: "node-group", data: {}, position, - group: { definitionRef: newDefRef, locked: true }, + group: { definitionRef, locked: isLocked }, }; this.activeDocument.nodes = [...this.activeDocument.nodes, groupNode]; return groupNodeId; } + + /** Set the display label for a group definition (shared by all instances). */ + setGroupLabel(nodeId: string, label: string) { + const doc = this.path.length > 0 ? this.parentDocument : this.activeDocument; + const node = doc.nodes.find((n) => n.id === nodeId); + if (!node?.group) return; + const def = this.project.definitions[node.group.definitionRef]; + if (!def) return; + const trimmed = label.trim(); + if (!trimmed) return; + if (!this.isLabelAvailable(trimmed, node.group.definitionRef)) return; + def.label = trimmed; + } + + /** Check whether a label is available (not used by any node type, the standard library, or other definitions). */ + isLabelAvailable(label: string, excludeDefRef?: string): boolean { + const trimmed = label.trim(); + if (!trimmed) return false; + for (const desc of Object.values(nodeRegistry)) { + if (desc.label === trimmed) return false; + } + for (const entry of this.library) { + if (entry.label && entry.label === trimmed) return false; + } + for (const [key, def] of Object.entries(this.project.definitions)) { + if (key === excludeDefRef) continue; + if (def.label === trimmed) return false; + } + return true; + } + + /** Update the label of a handle on a group-input or group-output node inside a definition. */ + setHandleLabel(definitionRef: string, handleNodeId: string, handleId: string, label: string) { + const def = this.project.definitions[definitionRef]; + if (!def) return; + const handleNode = def.nodes.find((n) => n.id === handleNodeId); + if (!handleNode) return; + const handles = handleNode.data.handles as HandleDef[] | undefined; + if (!handles) return; + const handle = handles.find((h) => h.id === handleId); + if (!handle) return; + handle.label = label; + } + + /** Remove a handle from a group-input/group-output node and delete all connected edges. */ + removeHandle(definitionRef: string, handleNodeId: string, handleId: string) { + const def = this.project.definitions[definitionRef]; + if (!def) return; + + // Remove the handle from the node's data + const handleNode = def.nodes.find((n) => n.id === handleNodeId); + if (!handleNode) return; + const handles = handleNode.data.handles as HandleDef[] | undefined; + if (!handles) return; + const idx = handles.findIndex((h) => h.id === handleId); + if (idx === -1) return; + handles.splice(idx, 1); + + // Remove all edges connected to this handle on this node + def.edges = def.edges.filter( + (e) => + !( + (e.source === handleNodeId && e.sourceHandle === handleId) || + (e.target === handleNodeId && e.targetHandle === handleId) + ), + ); + } } // ── Context helpers ────────────────────────────────────────────────────────── diff --git a/packages/editor/src/lib/components/editor/nodes/abs.node.ts b/packages/editor/src/lib/components/editor/nodes/abs.node.ts index 280188d..b4fcbfe 100644 --- a/packages/editor/src/lib/components/editor/nodes/abs.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/abs.node.ts @@ -4,11 +4,13 @@ export const absNode = { type: "abs", label: "Absolute", category: "math", - inputs: [{ name: "e", type: "f32" }], - outputs: [{ name: "result", type: "f32" }], + inputs: [{ name: "e", label: "e", type: "f32" }], + outputs: [{ name: "result", label: "result", type: "f32" }], defaultData: { e: 0 }, wgsl: ({ varName, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const e = inputs.e[0]; + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions return [`let ${varName} = abs(${e});`]; }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/add.node.ts b/packages/editor/src/lib/components/editor/nodes/add.node.ts index 5502a5f..b3ad547 100644 --- a/packages/editor/src/lib/components/editor/nodes/add.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/add.node.ts @@ -4,14 +4,16 @@ export const addNode = { type: "add", label: "Add", category: "math", - inputs: [{ name: "operands", type: "f32", variadic: true }], - outputs: [{ name: "result", type: "f32" }], + inputs: [{ name: "operands", label: "operands", type: "f32", variadic: true }], + outputs: [{ name: "result", label: "result", type: "f32" }], defaultData: {}, wgsl: ({ varName, inputs }) => { const list = inputs.operands; + // @ts-expect-error Inputs are guaranteed present by codegen if (list.length === 0) { return [`let ${varName} = f32(0.0);`]; } + // @ts-expect-error Inputs are guaranteed present by codegen return [`let ${varName} = ${list.join(" + ")};`]; }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/clamp.node.ts b/packages/editor/src/lib/components/editor/nodes/clamp.node.ts index 1c4bf61..d171ff8 100644 --- a/packages/editor/src/lib/components/editor/nodes/clamp.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/clamp.node.ts @@ -5,16 +5,20 @@ export const clampNode = { label: "Clamp", category: "math", inputs: [ - { name: "e", type: "f32" }, - { name: "low", type: "f32" }, - { name: "high", type: "f32" }, + { name: "e", label: "e", type: "f32" }, + { name: "low", label: "low", type: "f32" }, + { name: "high", label: "high", type: "f32" }, ], - outputs: [{ name: "result", type: "f32" }], + outputs: [{ name: "result", label: "result", type: "f32" }], defaultData: { e: 0, low: 0, high: 1 }, wgsl: ({ varName, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const e = inputs.e[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const low = inputs.low[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const high = inputs.high[0]; + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions return [`let ${varName} = clamp(${e}, ${low}, ${high});`]; }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/cos.node.ts b/packages/editor/src/lib/components/editor/nodes/cos.node.ts index d2dba5c..4daff4c 100644 --- a/packages/editor/src/lib/components/editor/nodes/cos.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/cos.node.ts @@ -4,11 +4,13 @@ export const cosNode = { type: "cos", label: "Cosine", category: "math", - inputs: [{ name: "e", type: "f32" }], - outputs: [{ name: "result", type: "f32" }], + inputs: [{ name: "e", label: "e", type: "f32" }], + outputs: [{ name: "result", label: "result", type: "f32" }], defaultData: { e: 0 }, wgsl: ({ varName, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const e = inputs.e[0]; + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions return [`let ${varName} = cos(${e});`]; }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/delta.node.ts b/packages/editor/src/lib/components/editor/nodes/delta.node.ts index f4b643e..26269ba 100644 --- a/packages/editor/src/lib/components/editor/nodes/delta.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/delta.node.ts @@ -5,11 +5,7 @@ export const deltaNode = { label: "Delta", category: "input", inputs: [], - outputs: [{ name: "delta", type: "f32" }], + outputs: [{ name: "delta", label: "delta", type: "f32" }], defaultData: {}, - globals: [{ name: "delta", type: "f32", wgslRef: "motiongpuFrame.delta" }], - wgsl: ({ varName, globalOverrides }) => { - const ref = globalOverrides?.delta ?? "motiongpuFrame.delta"; - return [`let ${varName}: f32 = ${ref};`]; - }, + wgsl: ({ varName }) => [`let ${varName}: f32 = motiongpuFrame.delta;`], } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/div.node.ts b/packages/editor/src/lib/components/editor/nodes/div.node.ts index 5646cd4..2d92b92 100644 --- a/packages/editor/src/lib/components/editor/nodes/div.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/div.node.ts @@ -5,14 +5,17 @@ export const divNode = { label: "Divide", category: "math", inputs: [ - { name: "a", type: "f32" }, - { name: "b", type: "f32" }, + { name: "a", label: "a", type: "f32" }, + { name: "b", label: "b", type: "f32" }, ], - outputs: [{ name: "result", type: "f32" }], + outputs: [{ name: "result", label: "result", type: "f32" }], defaultData: { a: 0, b: 0 }, wgsl: ({ varName, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const a = inputs.a[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const b = inputs.b[0]; + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions return [`let ${varName} = safeDivF32(${a}, ${b});`]; }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/float.node.ts b/packages/editor/src/lib/components/editor/nodes/float.node.ts index 51fc4bf..e6b53a2 100644 --- a/packages/editor/src/lib/components/editor/nodes/float.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/float.node.ts @@ -4,8 +4,8 @@ export const floatNode = { type: "float", label: "f32", category: "input", - inputs: [{ name: "value", type: "f32" }], - outputs: [{ name: "value", type: "f32" }], + inputs: [{ name: "value", label: "value", type: "f32" }], + outputs: [{ name: "value", label: "value", type: "f32" }], defaultData: { value: 0.0 }, wgsl: ({ varName, data }) => [`let ${varName} = f32(${String(data.value)});`], } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/group.node.ts b/packages/editor/src/lib/components/editor/nodes/group.node.ts index 2725e29..3f11f88 100644 --- a/packages/editor/src/lib/components/editor/nodes/group.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/group.node.ts @@ -33,11 +33,11 @@ export function buildGroupDescriptor( return { type: "node-group", - label: definitionRef, // TODO Placeholder - allow to change group label + label: definition.label ?? definitionRef, category: "output", - inputs: handles.inputs.map((h) => ({ name: h.id, type: h.type })), - outputs: handles.outputs.map((h) => ({ name: h.id, type: h.type })), + inputs: handles.inputs.map((h) => ({ name: h.id, label: h.label, type: h.type })), + outputs: handles.outputs.map((h) => ({ name: h.id, label: h.label, type: h.type })), defaultData: {}, - wgsl: () => [], // stubbed, same as group-input/output — group codegen is future work + wgsl: () => [], }; } diff --git a/packages/editor/src/lib/components/editor/nodes/groupInput.node.ts b/packages/editor/src/lib/components/editor/nodes/groupInput.node.ts index 86f67ea..bfb25b6 100644 --- a/packages/editor/src/lib/components/editor/nodes/groupInput.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/groupInput.node.ts @@ -6,10 +6,10 @@ export function buildGroupInputDescriptor(handles: HandleDef[]): NodeDescriptor label: "Group Input", category: "input", inputs: [], - outputs: handles.map((h) => ({ name: h.id, type: h.type })), + outputs: handles.map((h) => ({ name: h.id, label: h.label, type: h.type })), defaultData: { handles: [] }, wgsl: () => { - // unreachable — group-input/group-output are handled by type name in + // unreachable - group-input/group-output are handled by type name in // codegen's generateBody, never via this generic path. Kept only because // NodeDescriptor requires the field. throw new Error("unreachable"); diff --git a/packages/editor/src/lib/components/editor/nodes/groupOutput.node.ts b/packages/editor/src/lib/components/editor/nodes/groupOutput.node.ts index 7cb14c7..a599366 100644 --- a/packages/editor/src/lib/components/editor/nodes/groupOutput.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/groupOutput.node.ts @@ -5,11 +5,11 @@ export function buildGroupOutputDescriptor(handles: HandleDef[]): NodeDescriptor type: "group-output", label: "Group Output", category: "output", - inputs: handles.map((h) => ({ name: h.id, type: h.type })), + inputs: handles.map((h) => ({ name: h.id, label: h.label, type: h.type })), outputs: [], defaultData: { handles: [] }, wgsl: () => { - // unreachable — group-input/group-output are handled by type name in + // unreachable - group-input/group-output are handled by type name in // codegen's generateBody, never via this generic path. Kept only because // NodeDescriptor requires the field. throw new Error("unreachable"); diff --git a/packages/editor/src/lib/components/editor/nodes/max.node.ts b/packages/editor/src/lib/components/editor/nodes/max.node.ts index ec430c5..48c4123 100644 --- a/packages/editor/src/lib/components/editor/nodes/max.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/max.node.ts @@ -5,14 +5,17 @@ export const maxNode = { label: "Maximum", category: "math", inputs: [ - { name: "e1", type: "f32" }, - { name: "e2", type: "f32" }, + { name: "e1", label: "e1", type: "f32" }, + { name: "e2", label: "e2", type: "f32" }, ], - outputs: [{ name: "result", type: "f32" }], + outputs: [{ name: "result", label: "result", type: "f32" }], defaultData: { e1: 0, e2: 0 }, wgsl: ({ varName, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const e1 = inputs.e1[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const e2 = inputs.e2[0]; + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions return [`let ${varName} = max(${e1}, ${e2});`]; }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/min.node.ts b/packages/editor/src/lib/components/editor/nodes/min.node.ts index b08103e..4fc369f 100644 --- a/packages/editor/src/lib/components/editor/nodes/min.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/min.node.ts @@ -5,14 +5,17 @@ export const minNode = { label: "Minimum", category: "math", inputs: [ - { name: "e1", type: "f32" }, - { name: "e2", type: "f32" }, + { name: "e1", label: "e1", type: "f32" }, + { name: "e2", label: "e2", type: "f32" }, ], - outputs: [{ name: "result", type: "f32" }], + outputs: [{ name: "result", label: "result", type: "f32" }], defaultData: { e1: 0, e2: 0 }, wgsl: ({ varName, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const e1 = inputs.e1[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const e2 = inputs.e2[0]; + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions return [`let ${varName} = min(${e1}, ${e2});`]; }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/mul.node.ts b/packages/editor/src/lib/components/editor/nodes/mul.node.ts index f721528..3e8f059 100644 --- a/packages/editor/src/lib/components/editor/nodes/mul.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/mul.node.ts @@ -4,14 +4,16 @@ export const mulNode = { type: "mul", label: "Multiply", category: "math", - inputs: [{ name: "operands", type: "f32", variadic: true }], - outputs: [{ name: "result", type: "f32" }], + inputs: [{ name: "operands", label: "operands", type: "f32", variadic: true }], + outputs: [{ name: "result", label: "result", type: "f32" }], defaultData: {}, wgsl: ({ varName, inputs }) => { const list = inputs.operands; + // @ts-expect-error Inputs are guaranteed present by codegen if (list.length === 0) { return [`let ${varName} = f32(0.0);`]; } + // @ts-expect-error Inputs are guaranteed present by codegen return [`let ${varName} = ${list.join(" * ")};`]; }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/output.node.ts b/packages/editor/src/lib/components/editor/nodes/output.node.ts index 8c3f489..d24a9c0 100644 --- a/packages/editor/src/lib/components/editor/nodes/output.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/output.node.ts @@ -4,10 +4,11 @@ export const outputNode = { type: "frag-output", label: "Output", category: "output", - inputs: [{ name: "color", type: "vec4f" }], + inputs: [{ name: "color", label: "color", type: "vec4f" }], outputs: [], defaultData: {}, wgsl: ({ varName, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const color = inputs.color[0] ?? "vec4f(0.0, 0.0, 0.0, 1.0)"; return [`let ${varName} = ${color};`]; }, diff --git a/packages/editor/src/lib/components/editor/nodes/pow.node.ts b/packages/editor/src/lib/components/editor/nodes/pow.node.ts index 2b68b97..3668852 100644 --- a/packages/editor/src/lib/components/editor/nodes/pow.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/pow.node.ts @@ -5,20 +5,24 @@ export const powNode = { label: "Power", category: "math", inputs: [ - { name: "e1", type: "f32" }, - { name: "e2", type: "f32" }, + { name: "e1", label: "e1", type: "f32" }, + { name: "e2", label: "e2", type: "f32" }, ], - outputs: [{ name: "result", type: "f32" }], + outputs: [{ name: "result", label: "result", type: "f32" }], defaultData: { e1: 0, e2: 0 }, wgsl: ({ varName, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const e1 = inputs.e1[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const e2 = inputs.e2[0]; return [ `var ${varName}: f32;`, + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `let _e2_${varName}: f32 = ${e2};`, `if (_e2_${varName} == f32(0.0)) {`, ` ${varName} = f32(0.0);`, `} else {`, + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions ` ${varName} = pow(${e1}, _e2_${varName});`, `}`, ]; diff --git a/packages/editor/src/lib/components/editor/nodes/remap.node.ts b/packages/editor/src/lib/components/editor/nodes/remap.node.ts index 18e089e..0a01c2c 100644 --- a/packages/editor/src/lib/components/editor/nodes/remap.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/remap.node.ts @@ -5,21 +5,27 @@ export const remapNode = { label: "Remap", category: "math", inputs: [ - { name: "e", type: "f32" }, - { name: "in_min", type: "f32" }, - { name: "in_max", type: "f32" }, - { name: "out_min", type: "f32" }, - { name: "out_max", type: "f32" }, + { name: "e", label: "e", type: "f32" }, + { name: "in_min", label: "in_min", type: "f32" }, + { name: "in_max", label: "in_max", type: "f32" }, + { name: "out_min", label: "out_min", type: "f32" }, + { name: "out_max", label: "out_max", type: "f32" }, ], - outputs: [{ name: "result", type: "f32" }], + outputs: [{ name: "result", label: "result", type: "f32" }], defaultData: { e: 0, in_min: 0, in_max: 1, out_min: 0, out_max: 1 }, wgsl: ({ varName, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const e = inputs.e[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const in_min = inputs.in_min[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const in_max = inputs.in_max[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const out_min = inputs.out_min[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const out_max = inputs.out_max[0]; return [ + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `let ${varName} = ${out_min} + (${e} - ${in_min}) * safeDivF32((${out_max} - ${out_min}), (${in_max} - ${in_min}));`, ]; }, diff --git a/packages/editor/src/lib/components/editor/nodes/resolution.node.ts b/packages/editor/src/lib/components/editor/nodes/resolution.node.ts index 3b3fa48..2e733ce 100644 --- a/packages/editor/src/lib/components/editor/nodes/resolution.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/resolution.node.ts @@ -5,11 +5,7 @@ export const resolutionNode = { label: "Resolution", category: "input", inputs: [], - outputs: [{ name: "resolution", type: "vec2f" }], + outputs: [{ name: "resolution", label: "resolution", type: "vec2f" }], defaultData: {}, - globals: [{ name: "resolution", type: "vec2f", wgslRef: "motiongpuFrame.resolution" }], - wgsl: ({ varName, globalOverrides }) => { - const ref = globalOverrides?.resolution ?? "motiongpuFrame.resolution"; - return [`let ${varName}: vec2f = ${ref};`]; - }, + wgsl: ({ varName }) => [`let ${varName}: vec2f = motiongpuFrame.resolution;`], } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/select.node.ts b/packages/editor/src/lib/components/editor/nodes/select.node.ts index 12c8caa..5150641 100644 --- a/packages/editor/src/lib/components/editor/nodes/select.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/select.node.ts @@ -5,16 +5,20 @@ export const mixNode = { label: "Mix", category: "math", inputs: [ - { name: "e1", type: "f32" }, - { name: "e2", type: "f32" }, - { name: "e3", type: "f32" }, + { name: "e1", label: "e1", type: "f32" }, + { name: "e2", label: "e2", type: "f32" }, + { name: "e3", label: "e3", type: "f32" }, ], - outputs: [{ name: "result", type: "f32" }], + outputs: [{ name: "result", label: "result", type: "f32" }], defaultData: { e1: 0, e2: 0, e3: 0 }, wgsl: ({ varName, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const e1 = inputs.e1[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const e2 = inputs.e2[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const e3 = inputs.e3[0]; + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions return [`let ${varName} = mix(${e1}, ${e2}, ${e3});`]; }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/separateVec2f.node.ts b/packages/editor/src/lib/components/editor/nodes/separateVec2f.node.ts index 6d975a9..ac07c9a 100644 --- a/packages/editor/src/lib/components/editor/nodes/separateVec2f.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/separateVec2f.node.ts @@ -4,14 +4,16 @@ export const separateVec2fNode = { type: "separateVec2f", label: "Separate Vec2f", category: "vector", - inputs: [{ name: "xy", type: "vec2f" }], + inputs: [{ name: "xy", label: "xy", type: "vec2f" }], outputs: [ - { name: "x", type: "f32" }, - { name: "y", type: "f32" }, + { name: "x", label: "x", type: "f32" }, + { name: "y", label: "y", type: "f32" }, ], defaultData: { x: 0, y: 0 }, wgsl: ({ outputVars, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const src = inputs.xy[0] ?? "vec2f(0.0)"; + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions return [`let ${outputVars.x} = ${src}.x;`, `let ${outputVars.y} = ${src}.y;`]; }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/separateVec3f.node.ts b/packages/editor/src/lib/components/editor/nodes/separateVec3f.node.ts index 75911f7..a30f9ae 100644 --- a/packages/editor/src/lib/components/editor/nodes/separateVec3f.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/separateVec3f.node.ts @@ -4,18 +4,22 @@ export const separateVec3fNode = { type: "separateVec3f", label: "Separate Vec3f", category: "vector", - inputs: [{ name: "xyz", type: "vec3f" }], + inputs: [{ name: "xyz", label: "xyz", type: "vec3f" }], outputs: [ - { name: "x", type: "f32" }, - { name: "y", type: "f32" }, - { name: "z", type: "f32" }, + { name: "x", label: "x", type: "f32" }, + { name: "y", label: "y", type: "f32" }, + { name: "z", label: "z", type: "f32" }, ], defaultData: { x: 0, y: 0, z: 0 }, wgsl: ({ outputVars, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const src = inputs.xyz[0] ?? "vec3f(0.0)"; return [ + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `let ${outputVars.x} = ${src}.x;`, + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `let ${outputVars.y} = ${src}.y;`, + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `let ${outputVars.z} = ${src}.z;`, ]; }, diff --git a/packages/editor/src/lib/components/editor/nodes/separateVec4f.node.ts b/packages/editor/src/lib/components/editor/nodes/separateVec4f.node.ts index 12b65df..32cc272 100644 --- a/packages/editor/src/lib/components/editor/nodes/separateVec4f.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/separateVec4f.node.ts @@ -4,20 +4,25 @@ export const separateVec4fNode = { type: "separateVec4f", label: "Separate Vec4f", category: "vector", - inputs: [{ name: "xyzw", type: "vec4f" }], + inputs: [{ name: "xyzw", label: "xyzw", type: "vec4f" }], outputs: [ - { name: "x", type: "f32" }, - { name: "y", type: "f32" }, - { name: "z", type: "f32" }, - { name: "w", type: "f32" }, + { name: "x", label: "x", type: "f32" }, + { name: "y", label: "y", type: "f32" }, + { name: "z", label: "z", type: "f32" }, + { name: "w", label: "w", type: "f32" }, ], defaultData: { x: 0, y: 0, z: 0, w: 0 }, wgsl: ({ outputVars, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const src = inputs.xyzw[0] ?? "vec4f(0.0)"; return [ + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `let ${outputVars.x} = ${src}.x;`, + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `let ${outputVars.y} = ${src}.y;`, + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `let ${outputVars.z} = ${src}.z;`, + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `let ${outputVars.w} = ${src}.w;`, ]; }, diff --git a/packages/editor/src/lib/components/editor/nodes/sin.node.ts b/packages/editor/src/lib/components/editor/nodes/sin.node.ts index da386f9..cbec6bb 100644 --- a/packages/editor/src/lib/components/editor/nodes/sin.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/sin.node.ts @@ -4,11 +4,13 @@ export const sinNode = { type: "sin", label: "Sine", category: "math", - inputs: [{ name: "e", type: "f32" }], - outputs: [{ name: "result", type: "f32" }], + inputs: [{ name: "e", label: "e", type: "f32" }], + outputs: [{ name: "result", label: "result", type: "f32" }], defaultData: { e: 0 }, wgsl: ({ varName, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const e = inputs.e[0]; + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions return [`let ${varName} = sin(${e});`]; }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/sub.node.ts b/packages/editor/src/lib/components/editor/nodes/sub.node.ts index b198876..4eb779c 100644 --- a/packages/editor/src/lib/components/editor/nodes/sub.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/sub.node.ts @@ -5,14 +5,17 @@ export const subNode = { label: "Subtract", category: "math", inputs: [ - { name: "a", type: "f32" }, - { name: "b", type: "f32" }, + { name: "a", label: "a", type: "f32" }, + { name: "b", label: "b", type: "f32" }, ], - outputs: [{ name: "result", type: "f32" }], + outputs: [{ name: "result", label: "result", type: "f32" }], defaultData: { a: 0, b: 0 }, wgsl: ({ varName, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const a = inputs.a[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const b = inputs.b[0]; + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions return [`let ${varName} = ${a} - ${b};`]; }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/tan.node.ts b/packages/editor/src/lib/components/editor/nodes/tan.node.ts index 79a63e7..84d7f24 100644 --- a/packages/editor/src/lib/components/editor/nodes/tan.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/tan.node.ts @@ -4,11 +4,13 @@ export const tanNode = { type: "tan", label: "Tangeant", category: "math", - inputs: [{ name: "e", type: "f32" }], - outputs: [{ name: "result", type: "f32" }], + inputs: [{ name: "e", label: "e", type: "f32" }], + outputs: [{ name: "result", label: "result", type: "f32" }], defaultData: { e: 0 }, wgsl: ({ varName, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const e = inputs.e[0]; + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions return [`let ${varName} = tan(${e});`]; }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/time.node.ts b/packages/editor/src/lib/components/editor/nodes/time.node.ts index 664f4bb..c4a280b 100644 --- a/packages/editor/src/lib/components/editor/nodes/time.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/time.node.ts @@ -5,11 +5,7 @@ export const timeNode = { label: "Time", category: "input", inputs: [], - outputs: [{ name: "time", type: "f32" }], + outputs: [{ name: "time", label: "time", type: "f32" }], defaultData: {}, - globals: [{ name: "time", type: "f32", wgslRef: "motiongpuFrame.time" }], - wgsl: ({ varName, globalOverrides }) => { - const ref = globalOverrides?.time ?? "motiongpuFrame.time"; - return [`let ${varName}: f32 = ${ref};`]; - }, + wgsl: ({ varName }) => [`let ${varName}: f32 = motiongpuFrame.time;`], } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/uv.node.ts b/packages/editor/src/lib/components/editor/nodes/uv.node.ts index a2096c1..b8644e8 100644 --- a/packages/editor/src/lib/components/editor/nodes/uv.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/uv.node.ts @@ -5,11 +5,7 @@ export const uvNode = { label: "UV", category: "input", inputs: [], - outputs: [{ name: "uv", type: "vec2f" }], + outputs: [{ name: "uv", label: "uv", type: "vec2f" }], defaultData: {}, - globals: [{ name: "uv", type: "vec2f", wgslRef: "uv" }], - wgsl: ({ varName, globalOverrides }) => { - const ref = globalOverrides?.uv ?? "uv"; - return [`let ${varName} = ${ref};`]; - }, + wgsl: ({ varName }) => [`let ${varName} = uv;`], } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/vec2f.node.ts b/packages/editor/src/lib/components/editor/nodes/vec2f.node.ts index 974f524..d664885 100644 --- a/packages/editor/src/lib/components/editor/nodes/vec2f.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/vec2f.node.ts @@ -5,14 +5,17 @@ export const vec2fNode = { label: "Vec2f", category: "vector", inputs: [ - { name: "x", type: "f32" }, - { name: "y", type: "f32" }, + { name: "x", label: "x", type: "f32" }, + { name: "y", label: "y", type: "f32" }, ], - outputs: [{ name: "value", type: "vec2f" }], + outputs: [{ name: "value", label: "value", type: "vec2f" }], defaultData: { x: 0, y: 0 }, wgsl: ({ varName, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const x = inputs.x[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const y = inputs.y[0]; + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions return [`let ${varName} = vec2f(${x}, ${y});`]; }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/vec3f.node.ts b/packages/editor/src/lib/components/editor/nodes/vec3f.node.ts index 387ae5f..98140b8 100644 --- a/packages/editor/src/lib/components/editor/nodes/vec3f.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/vec3f.node.ts @@ -5,16 +5,20 @@ export const vec3fNode = { label: "Vec3f", category: "vector", inputs: [ - { name: "x", type: "f32" }, - { name: "y", type: "f32" }, - { name: "z", type: "f32" }, + { name: "x", label: "x", type: "f32" }, + { name: "y", label: "y", type: "f32" }, + { name: "z", label: "z", type: "f32" }, ], - outputs: [{ name: "value", type: "vec3f" }], + outputs: [{ name: "value", label: "value", type: "vec3f" }], defaultData: { x: 0, y: 0, z: 0 }, wgsl: ({ varName, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const x = inputs.x[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const y = inputs.y[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const z = inputs.z[0]; + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions return [`let ${varName} = vec3f(${x}, ${y}, ${z});`]; }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/vec4f.node.ts b/packages/editor/src/lib/components/editor/nodes/vec4f.node.ts index e5c7e0d..2e4cd39 100644 --- a/packages/editor/src/lib/components/editor/nodes/vec4f.node.ts +++ b/packages/editor/src/lib/components/editor/nodes/vec4f.node.ts @@ -5,18 +5,23 @@ export const vec4fNode = { label: "Vec4f", category: "vector", inputs: [ - { name: "x", type: "f32" }, - { name: "y", type: "f32" }, - { name: "z", type: "f32" }, - { name: "w", type: "f32" }, + { name: "x", label: "x", type: "f32" }, + { name: "y", label: "y", type: "f32" }, + { name: "z", label: "z", type: "f32" }, + { name: "w", label: "w", type: "f32" }, ], - outputs: [{ name: "value", type: "vec4f" }], + outputs: [{ name: "value", label: "value", type: "vec4f" }], defaultData: { x: 0, y: 0, z: 0, w: 1 }, wgsl: ({ varName, inputs }) => { + // @ts-expect-error Inputs are guaranteed present by codegen const x = inputs.x[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const y = inputs.y[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const z = inputs.z[0]; + // @ts-expect-error Inputs are guaranteed present by codegen const w = inputs.w[0]; + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions return [`let ${varName} = vec4f(${x}, ${y}, ${z}, ${w});`]; }, } satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/registry.ts b/packages/editor/src/lib/components/editor/registry.ts index 77f6ea3..fa7cfbb 100644 --- a/packages/editor/src/lib/components/editor/registry.ts +++ b/packages/editor/src/lib/components/editor/registry.ts @@ -80,7 +80,7 @@ export const nodeRegistry: Record = { // Output "frag-output": outputNode, - // Groups (placeholder — shape derived from subgraph at runtime) + // Groups (placeholder - shape derived from subgraph at runtime) "node-group": groupNode, }; @@ -88,7 +88,7 @@ export const DYNAMIC_NODE_TYPES = ["group-input", "group-output"]; /** Helper: produce default node data for a registered type. */ export function defaultDataForType(type: string): Record { - return { ...nodeRegistry[type].defaultData }; + return { ...nodeRegistry[type]?.defaultData }; } /** Convenience: list all categories and their nodes for an AddNode menu. */ diff --git a/packages/editor/src/lib/components/editor/standard-library.json b/packages/editor/src/lib/components/editor/standard-library.json deleted file mode 100644 index d9e16e9..0000000 --- a/packages/editor/src/lib/components/editor/standard-library.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "library": [ - { "id": "std-passthrough", "name": "Passthrough", "category": "math", "dependencies": [] }, - { "id": "std-remap-01", "name": "Remap 0-1", "category": "math", "dependencies": [] } - ] -} diff --git a/packages/editor/src/lib/components/editor/types.ts b/packages/editor/src/lib/components/editor/types.ts index b2b1247..655569e 100644 --- a/packages/editor/src/lib/components/editor/types.ts +++ b/packages/editor/src/lib/components/editor/types.ts @@ -1,7 +1,9 @@ import type { Node } from "@xyflow/svelte"; // ─── WGSL type system ──────────────────────────────────────── -export type WgslType = "f32" | "vec2f" | "vec3f" | "vec4f"; +export const WGSL_TYPES = ["f32", "vec2f", "vec3f", "vec4f"] as const; + +export type WgslType = (typeof WGSL_TYPES)[number]; export const WGSL_DEFAULTS: Record = { f32: "f32(0.0)", @@ -11,7 +13,7 @@ export const WGSL_DEFAULTS: Record = { }; export type HandleDef = { - id: string; // stable, generated once, never changes — this is what edges/targetHandle reference + id: string; // stable, generated once, never changes - this is what edges/targetHandle reference label: string; // user-facing, editable later, defaults to something like "Input 1" type: WgslType; // hardcode "f32" for now, per your call }; @@ -19,6 +21,7 @@ export type HandleDef = { // ─── Descriptor types ──────────────────────────────────────── export type NodeInputDef = { name: string; + label: string; type: WgslType; /** * If true, every edge connected to this handle is collected. @@ -30,6 +33,7 @@ export type NodeInputDef = { export type NodeOutputDef = { name: string; + label: string; type: WgslType; }; @@ -54,16 +58,11 @@ export type WgslCtx = { data: Record; /** Resolved upstream variable names per input name. * - Non-variadic: `inputs["x"]` is `[varName]` (1 element) if connected, `[]` if not. - * - Variadic: `inputs["operands"]` has N elements (one per connected edge), `[]` if none. */ + * - Variadic: `inputs["operands"]` has N elements (one per connected edge), `[]` if none. + * + * The codegen guarantees every input declared in the node's descriptor is present, + * so direct property access like `inputs.e` is always safe. */ inputs: Record; - /** - * When a node is inside a group, globals like `uv` or `motiongpuFrame.time` - * must be replaced with the group function's parameter names. - * Maps the raw WGSL reference (e.g. `"uv"`) to the parameter name - * (e.g. `"h_"`). If a global is NOT in this map, it means - * the node is at root scope and the raw reference is fine. - */ - globalOverrides?: Record; }; export type NodeDescriptor = { @@ -73,14 +72,6 @@ export type NodeDescriptor = { inputs: NodeInputDef[]; outputs: NodeOutputDef[]; defaultData: Record; - /** - * Root-level WGSL identifiers this node references that are NOT defined - * inside any group (e.g. `uv`, `motiongpuFrame.time`). - * When a node with globals is placed inside a group, the grouping logic - * must create group-input handles for them, and the codegen must forward - * them as function arguments. - */ - globals?: { name: string; type: WgslType; wgslRef: string }[]; /** Emit WGSL line(s) that define `varName`. * Each returned string becomes one line inside `fn frag()`. * The `let ` / `var ` prefix is the node's own choice. */ diff --git a/packages/editor/src/lib/components/editor/xy-theme.css b/packages/editor/src/lib/components/editor/xy-theme.css index fad199a..e211bc7 100644 --- a/packages/editor/src/lib/components/editor/xy-theme.css +++ b/packages/editor/src/lib/components/editor/xy-theme.css @@ -14,7 +14,11 @@ } .node-shell__label { - @apply border-b px-3 pt-3 pb-2; + @apply flex items-start justify-between gap-2 border-b px-3 pt-3 pb-2; +} + +.node-shell__label > svg { + @apply shrink-0; } .node-shell__body { diff --git a/packages/editor/src/lib/index.ts b/packages/editor/src/lib/index.ts index 5a5683a..ba4cd62 100644 --- a/packages/editor/src/lib/index.ts +++ b/packages/editor/src/lib/index.ts @@ -3,5 +3,6 @@ export { GraphManager, setGraphManager, getGraphManager, + type GraphDocument, } from "./components/editor/graph-state.svelte"; export * from "./components/editor/utils"; diff --git a/packages/theme/package.json b/packages/theme/package.json index faf215b..546eb49 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -21,13 +21,14 @@ "bits-ui": "^2.16.3", "clsx": "^2.1.1", "paneforge": "^1.0.2", - "phosphor-svelte": "^3.1.0", + "phosphor-svelte": "catalog:", "shadcn-svelte": "^1.3.0", "tailwind-merge": "^3.6.0", "tailwind-variants": "^3.2.2", "tw-animate-css": "^1.4.0" }, "devDependencies": { + "@internationalized/date": "^3.12.0", "@sveltejs/package": "catalog:", "@sveltejs/vite-plugin-svelte": "catalog:", "@tailwindcss/vite": "catalog:", diff --git a/packages/theme/src/lib/components/ui/index.ts b/packages/theme/src/lib/components/ui/index.ts index 80475cf..11790e2 100644 --- a/packages/theme/src/lib/components/ui/index.ts +++ b/packages/theme/src/lib/components/ui/index.ts @@ -7,3 +7,7 @@ export * as Menubar from "./menubar/index"; export * as Resizable from "./resizable/index"; export * as Scrubbable from "./scrubbable/index"; export * as Separator from "./separator/index"; +export * as Sheet from "./sheet/index"; +export * as Select from "./select/index"; +export * as Popover from "./popover/index"; +export * as Kbd from "./kbd/index"; diff --git a/packages/theme/src/lib/components/ui/kbd/index.ts b/packages/theme/src/lib/components/ui/kbd/index.ts new file mode 100644 index 0000000..d588b1c --- /dev/null +++ b/packages/theme/src/lib/components/ui/kbd/index.ts @@ -0,0 +1,10 @@ +import Group from "./kbd-group.svelte"; +import Root from "./kbd.svelte"; + +export { + Root, + Group, + // + Root as Kbd, + Group as KbdGroup, +}; diff --git a/packages/theme/src/lib/components/ui/kbd/kbd-group.svelte b/packages/theme/src/lib/components/ui/kbd/kbd-group.svelte new file mode 100644 index 0000000..78beccd --- /dev/null +++ b/packages/theme/src/lib/components/ui/kbd/kbd-group.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/packages/theme/src/lib/components/ui/kbd/kbd.svelte b/packages/theme/src/lib/components/ui/kbd/kbd.svelte new file mode 100644 index 0000000..95deecb --- /dev/null +++ b/packages/theme/src/lib/components/ui/kbd/kbd.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/packages/theme/src/lib/components/ui/popover/index.ts b/packages/theme/src/lib/components/ui/popover/index.ts new file mode 100644 index 0000000..2e9d0f1 --- /dev/null +++ b/packages/theme/src/lib/components/ui/popover/index.ts @@ -0,0 +1,28 @@ +import Close from "./popover-close.svelte"; +import Content from "./popover-content.svelte"; +import Description from "./popover-description.svelte"; +import Header from "./popover-header.svelte"; +import Portal from "./popover-portal.svelte"; +import Title from "./popover-title.svelte"; +import Trigger from "./popover-trigger.svelte"; +import Root from "./popover.svelte"; + +export { + Root, + Content, + Description, + Header, + Title, + Trigger, + Close, + Portal, + // + Root as Popover, + Content as PopoverContent, + Description as PopoverDescription, + Header as PopoverHeader, + Title as PopoverTitle, + Trigger as PopoverTrigger, + Close as PopoverClose, + Portal as PopoverPortal, +}; diff --git a/packages/theme/src/lib/components/ui/popover/popover-close.svelte b/packages/theme/src/lib/components/ui/popover/popover-close.svelte new file mode 100644 index 0000000..c360925 --- /dev/null +++ b/packages/theme/src/lib/components/ui/popover/popover-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/theme/src/lib/components/ui/popover/popover-content.svelte b/packages/theme/src/lib/components/ui/popover/popover-content.svelte new file mode 100644 index 0000000..f2a53ab --- /dev/null +++ b/packages/theme/src/lib/components/ui/popover/popover-content.svelte @@ -0,0 +1,32 @@ + + + + + diff --git a/packages/theme/src/lib/components/ui/popover/popover-description.svelte b/packages/theme/src/lib/components/ui/popover/popover-description.svelte new file mode 100644 index 0000000..b031b52 --- /dev/null +++ b/packages/theme/src/lib/components/ui/popover/popover-description.svelte @@ -0,0 +1,21 @@ + + +
    + {@render children?.()} +
    diff --git a/packages/theme/src/lib/components/ui/popover/popover-header.svelte b/packages/theme/src/lib/components/ui/popover/popover-header.svelte new file mode 100644 index 0000000..8dc4a44 --- /dev/null +++ b/packages/theme/src/lib/components/ui/popover/popover-header.svelte @@ -0,0 +1,21 @@ + + +
    + {@render children?.()} +
    diff --git a/packages/theme/src/lib/components/ui/popover/popover-portal.svelte b/packages/theme/src/lib/components/ui/popover/popover-portal.svelte new file mode 100644 index 0000000..dd8265f --- /dev/null +++ b/packages/theme/src/lib/components/ui/popover/popover-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/theme/src/lib/components/ui/popover/popover-title.svelte b/packages/theme/src/lib/components/ui/popover/popover-title.svelte new file mode 100644 index 0000000..0433ac3 --- /dev/null +++ b/packages/theme/src/lib/components/ui/popover/popover-title.svelte @@ -0,0 +1,16 @@ + + +
    + {@render children?.()} +
    diff --git a/packages/theme/src/lib/components/ui/popover/popover-trigger.svelte b/packages/theme/src/lib/components/ui/popover/popover-trigger.svelte new file mode 100644 index 0000000..13d697c --- /dev/null +++ b/packages/theme/src/lib/components/ui/popover/popover-trigger.svelte @@ -0,0 +1,18 @@ + + + diff --git a/packages/theme/src/lib/components/ui/popover/popover.svelte b/packages/theme/src/lib/components/ui/popover/popover.svelte new file mode 100644 index 0000000..6b1aa5f --- /dev/null +++ b/packages/theme/src/lib/components/ui/popover/popover.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/theme/src/lib/components/ui/scrubbable/scrubbable.svelte b/packages/theme/src/lib/components/ui/scrubbable/scrubbable.svelte index 15337eb..19d08be 100644 --- a/packages/theme/src/lib/components/ui/scrubbable/scrubbable.svelte +++ b/packages/theme/src/lib/components/ui/scrubbable/scrubbable.svelte @@ -217,39 +217,32 @@ data-slot={dataSlot} > {#if !readonly} - {@render sideButton("left")} + {/if} - - - + + {#if !readonly} {@render sideButton("right")} {/if}
    diff --git a/packages/theme/src/lib/components/ui/select/index.ts b/packages/theme/src/lib/components/ui/select/index.ts new file mode 100644 index 0000000..f06c60a --- /dev/null +++ b/packages/theme/src/lib/components/ui/select/index.ts @@ -0,0 +1,37 @@ +import Content from "./select-content.svelte"; +import GroupHeading from "./select-group-heading.svelte"; +import Group from "./select-group.svelte"; +import Item from "./select-item.svelte"; +import Label from "./select-label.svelte"; +import Portal from "./select-portal.svelte"; +import ScrollDownButton from "./select-scroll-down-button.svelte"; +import ScrollUpButton from "./select-scroll-up-button.svelte"; +import Separator from "./select-separator.svelte"; +import Trigger from "./select-trigger.svelte"; +import Root from "./select.svelte"; + +export { + Root, + Group, + Label, + Item, + Content, + Trigger, + Separator, + ScrollDownButton, + ScrollUpButton, + GroupHeading, + Portal, + // + Root as Select, + Group as SelectGroup, + Label as SelectLabel, + Item as SelectItem, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton as SelectScrollUpButton, + GroupHeading as SelectGroupHeading, + Portal as SelectPortal, +}; diff --git a/packages/theme/src/lib/components/ui/select/select-content.svelte b/packages/theme/src/lib/components/ui/select/select-content.svelte new file mode 100644 index 0000000..994ecf8 --- /dev/null +++ b/packages/theme/src/lib/components/ui/select/select-content.svelte @@ -0,0 +1,46 @@ + + + + + + + {@render children?.()} + + + + diff --git a/packages/theme/src/lib/components/ui/select/select-group-heading.svelte b/packages/theme/src/lib/components/ui/select/select-group-heading.svelte new file mode 100644 index 0000000..8736603 --- /dev/null +++ b/packages/theme/src/lib/components/ui/select/select-group-heading.svelte @@ -0,0 +1,22 @@ + + + + {@render children?.()} + diff --git a/packages/theme/src/lib/components/ui/select/select-group.svelte b/packages/theme/src/lib/components/ui/select/select-group.svelte new file mode 100644 index 0000000..b63c4ac --- /dev/null +++ b/packages/theme/src/lib/components/ui/select/select-group.svelte @@ -0,0 +1,18 @@ + + + diff --git a/packages/theme/src/lib/components/ui/select/select-item.svelte b/packages/theme/src/lib/components/ui/select/select-item.svelte new file mode 100644 index 0000000..0e2bbd5 --- /dev/null +++ b/packages/theme/src/lib/components/ui/select/select-item.svelte @@ -0,0 +1,41 @@ + + + + {#snippet children({ selected, highlighted }: { selected: boolean; highlighted: boolean })} + + {#if selected} + + {/if} + + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label ?? value} + {/if} + + {/snippet} + diff --git a/packages/theme/src/lib/components/ui/select/select-label.svelte b/packages/theme/src/lib/components/ui/select/select-label.svelte new file mode 100644 index 0000000..0595abc --- /dev/null +++ b/packages/theme/src/lib/components/ui/select/select-label.svelte @@ -0,0 +1,21 @@ + + +
    + {@render children?.()} +
    diff --git a/packages/theme/src/lib/components/ui/select/select-portal.svelte b/packages/theme/src/lib/components/ui/select/select-portal.svelte new file mode 100644 index 0000000..424bcdd --- /dev/null +++ b/packages/theme/src/lib/components/ui/select/select-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/theme/src/lib/components/ui/select/select-scroll-down-button.svelte b/packages/theme/src/lib/components/ui/select/select-scroll-down-button.svelte new file mode 100644 index 0000000..62f24a0 --- /dev/null +++ b/packages/theme/src/lib/components/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/theme/src/lib/components/ui/select/select-scroll-up-button.svelte b/packages/theme/src/lib/components/ui/select/select-scroll-up-button.svelte new file mode 100644 index 0000000..9a3aab9 --- /dev/null +++ b/packages/theme/src/lib/components/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/theme/src/lib/components/ui/select/select-separator.svelte b/packages/theme/src/lib/components/ui/select/select-separator.svelte new file mode 100644 index 0000000..0a75aad --- /dev/null +++ b/packages/theme/src/lib/components/ui/select/select-separator.svelte @@ -0,0 +1,19 @@ + + + diff --git a/packages/theme/src/lib/components/ui/select/select-trigger.svelte b/packages/theme/src/lib/components/ui/select/select-trigger.svelte new file mode 100644 index 0000000..f342fa0 --- /dev/null +++ b/packages/theme/src/lib/components/ui/select/select-trigger.svelte @@ -0,0 +1,30 @@ + + + + {@render children?.()} + + diff --git a/packages/theme/src/lib/components/ui/select/select.svelte b/packages/theme/src/lib/components/ui/select/select.svelte new file mode 100644 index 0000000..05eb663 --- /dev/null +++ b/packages/theme/src/lib/components/ui/select/select.svelte @@ -0,0 +1,11 @@ + + + diff --git a/packages/theme/src/lib/components/ui/sheet/index.ts b/packages/theme/src/lib/components/ui/sheet/index.ts new file mode 100644 index 0000000..12a55b2 --- /dev/null +++ b/packages/theme/src/lib/components/ui/sheet/index.ts @@ -0,0 +1,34 @@ +import Close from "./sheet-close.svelte"; +import Content from "./sheet-content.svelte"; +import Description from "./sheet-description.svelte"; +import Footer from "./sheet-footer.svelte"; +import Header from "./sheet-header.svelte"; +import Overlay from "./sheet-overlay.svelte"; +import Portal from "./sheet-portal.svelte"; +import Title from "./sheet-title.svelte"; +import Trigger from "./sheet-trigger.svelte"; +import Root from "./sheet.svelte"; + +export { + Root, + Close, + Trigger, + Portal, + Overlay, + Content, + Header, + Footer, + Title, + Description, + // + Root as Sheet, + Close as SheetClose, + Trigger as SheetTrigger, + Portal as SheetPortal, + Overlay as SheetOverlay, + Content as SheetContent, + Header as SheetHeader, + Footer as SheetFooter, + Title as SheetTitle, + Description as SheetDescription, +}; diff --git a/packages/theme/src/lib/components/ui/sheet/sheet-close.svelte b/packages/theme/src/lib/components/ui/sheet/sheet-close.svelte new file mode 100644 index 0000000..ae382c1 --- /dev/null +++ b/packages/theme/src/lib/components/ui/sheet/sheet-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/theme/src/lib/components/ui/sheet/sheet-content.svelte b/packages/theme/src/lib/components/ui/sheet/sheet-content.svelte new file mode 100644 index 0000000..46828e3 --- /dev/null +++ b/packages/theme/src/lib/components/ui/sheet/sheet-content.svelte @@ -0,0 +1,61 @@ + + + + + + + + {@render children()} + {#if showCloseButton} + + {#snippet child({ props })} + + {/snippet} + + {/if} + + diff --git a/packages/theme/src/lib/components/ui/sheet/sheet-description.svelte b/packages/theme/src/lib/components/ui/sheet/sheet-description.svelte new file mode 100644 index 0000000..015eb64 --- /dev/null +++ b/packages/theme/src/lib/components/ui/sheet/sheet-description.svelte @@ -0,0 +1,18 @@ + + + diff --git a/packages/theme/src/lib/components/ui/sheet/sheet-footer.svelte b/packages/theme/src/lib/components/ui/sheet/sheet-footer.svelte new file mode 100644 index 0000000..dbe516b --- /dev/null +++ b/packages/theme/src/lib/components/ui/sheet/sheet-footer.svelte @@ -0,0 +1,21 @@ + + +
    + {@render children?.()} +
    diff --git a/packages/theme/src/lib/components/ui/sheet/sheet-header.svelte b/packages/theme/src/lib/components/ui/sheet/sheet-header.svelte new file mode 100644 index 0000000..ca8986a --- /dev/null +++ b/packages/theme/src/lib/components/ui/sheet/sheet-header.svelte @@ -0,0 +1,21 @@ + + +
    + {@render children?.()} +
    diff --git a/packages/theme/src/lib/components/ui/sheet/sheet-overlay.svelte b/packages/theme/src/lib/components/ui/sheet/sheet-overlay.svelte new file mode 100644 index 0000000..04a972d --- /dev/null +++ b/packages/theme/src/lib/components/ui/sheet/sheet-overlay.svelte @@ -0,0 +1,21 @@ + + + diff --git a/packages/theme/src/lib/components/ui/sheet/sheet-portal.svelte b/packages/theme/src/lib/components/ui/sheet/sheet-portal.svelte new file mode 100644 index 0000000..f3085a3 --- /dev/null +++ b/packages/theme/src/lib/components/ui/sheet/sheet-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/theme/src/lib/components/ui/sheet/sheet-title.svelte b/packages/theme/src/lib/components/ui/sheet/sheet-title.svelte new file mode 100644 index 0000000..a0827f7 --- /dev/null +++ b/packages/theme/src/lib/components/ui/sheet/sheet-title.svelte @@ -0,0 +1,18 @@ + + + diff --git a/packages/theme/src/lib/components/ui/sheet/sheet-trigger.svelte b/packages/theme/src/lib/components/ui/sheet/sheet-trigger.svelte new file mode 100644 index 0000000..e266975 --- /dev/null +++ b/packages/theme/src/lib/components/ui/sheet/sheet-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/theme/src/lib/components/ui/sheet/sheet.svelte b/packages/theme/src/lib/components/ui/sheet/sheet.svelte new file mode 100644 index 0000000..5bf9783 --- /dev/null +++ b/packages/theme/src/lib/components/ui/sheet/sheet.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/theme/tsconfig.json b/packages/theme/tsconfig.json index 14b232b..c4d3a89 100644 --- a/packages/theme/tsconfig.json +++ b/packages/theme/tsconfig.json @@ -1,7 +1,11 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "types": ["svelte", "vite/client"] + "types": ["svelte", "vite/client"], + "paths": { + "$theme": ["./src/lib"], + "$theme/*": ["./src/lib/*"] + } }, "include": ["src/**/*", "svelte.config.ts", "vite.config.ts", "eslint.config.ts"], "exclude": ["../node_modules/**"] From b00e9a1b555382526d225664edd236513bc30fa2 Mon Sep 17 00:00:00 2001 From: glypse Date: Mon, 13 Jul 2026 21:13:32 +0200 Subject: [PATCH 04/15] Files re-organisation and nearly all features --- apps/web/src/routes/+page.svelte | 2 +- apps/web/src/routes/editor/+page.svelte | 65 +- .../lib/{components/editor => }/Editor.svelte | 2 +- .../editor => }/GraphContent.svelte | 52 +- .../{components/editor => }/NodeGraph.svelte | 16 +- .../{components/editor => }/NodeShell.svelte | 38 +- .../lib/{components/editor => }/codegen.ts | 14 +- .../lib/components/editor/nodes/abs.node.ts | 16 - .../lib/components/editor/nodes/add.node.ts | 19 - .../lib/components/editor/nodes/clamp.node.ts | 24 - .../lib/components/editor/nodes/cos.node.ts | 16 - .../lib/components/editor/nodes/delta.node.ts | 11 - .../lib/components/editor/nodes/div.node.ts | 21 - .../lib/components/editor/nodes/float.node.ts | 11 - .../lib/components/editor/nodes/group.node.ts | 43 - .../editor/nodes/groupInput.node.ts | 18 - .../editor/nodes/groupOutput.node.ts | 18 - .../lib/components/editor/nodes/max.node.ts | 21 - .../lib/components/editor/nodes/min.node.ts | 21 - .../lib/components/editor/nodes/mul.node.ts | 19 - .../components/editor/nodes/output.node.ts | 15 - .../lib/components/editor/nodes/pow.node.ts | 30 - .../lib/components/editor/nodes/remap.node.ts | 32 - .../editor/nodes/resolution.node.ts | 11 - .../components/editor/nodes/select.node.ts | 24 - .../editor/nodes/separateVec2f.node.ts | 19 - .../editor/nodes/separateVec3f.node.ts | 26 - .../editor/nodes/separateVec4f.node.ts | 29 - .../lib/components/editor/nodes/sin.node.ts | 16 - .../lib/components/editor/nodes/sub.node.ts | 21 - .../lib/components/editor/nodes/tan.node.ts | 16 - .../lib/components/editor/nodes/time.node.ts | 11 - .../lib/components/editor/nodes/uv.node.ts | 11 - .../lib/components/editor/nodes/vec2f.node.ts | 21 - .../lib/components/editor/nodes/vec3f.node.ts | 24 - .../lib/components/editor/nodes/vec4f.node.ts | 27 - .../lib/{components/editor => }/debug.json | 0 packages/editor/src/lib/graph-helpers.ts | 125 +++ .../editor => }/graph-state.svelte.ts | 841 ++++++++---------- packages/editor/src/lib/graph-types.ts | 83 ++ packages/editor/src/lib/index.ts | 21 +- packages/editor/src/lib/node-factories.ts | 390 ++++++++ .../lib/{components/editor => }/registry.ts | 56 +- .../editor => }/sizing_circle.json | 0 .../standard-library/std-passthrough.json | 0 .../standard-library/std-remap-01.json | 0 .../src/lib/{components/editor => }/types.ts | 14 +- .../src/lib/{components/editor => }/utils.ts | 0 .../lib/{components/editor => }/xy-theme.css | 2 +- packages/theme/src/lib/components/ui/index.ts | 19 +- packages/theme/src/lib/index.ts | 2 + 51 files changed, 1133 insertions(+), 1200 deletions(-) rename packages/editor/src/lib/{components/editor => }/Editor.svelte (94%) rename packages/editor/src/lib/{components/editor => }/GraphContent.svelte (94%) rename packages/editor/src/lib/{components/editor => }/NodeGraph.svelte (76%) rename packages/editor/src/lib/{components/editor => }/NodeShell.svelte (80%) rename packages/editor/src/lib/{components/editor => }/codegen.ts (97%) delete mode 100644 packages/editor/src/lib/components/editor/nodes/abs.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/add.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/clamp.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/cos.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/delta.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/div.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/float.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/group.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/groupInput.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/groupOutput.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/max.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/min.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/mul.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/output.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/pow.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/remap.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/resolution.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/select.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/separateVec2f.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/separateVec3f.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/separateVec4f.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/sin.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/sub.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/tan.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/time.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/uv.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/vec2f.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/vec3f.node.ts delete mode 100644 packages/editor/src/lib/components/editor/nodes/vec4f.node.ts rename packages/editor/src/lib/{components/editor => }/debug.json (100%) create mode 100644 packages/editor/src/lib/graph-helpers.ts rename packages/editor/src/lib/{components/editor => }/graph-state.svelte.ts (62%) create mode 100644 packages/editor/src/lib/graph-types.ts create mode 100644 packages/editor/src/lib/node-factories.ts rename packages/editor/src/lib/{components/editor => }/registry.ts (60%) rename packages/editor/src/lib/{components/editor => }/sizing_circle.json (100%) rename packages/editor/src/lib/{components/editor => }/standard-library/std-passthrough.json (100%) rename packages/editor/src/lib/{components/editor => }/standard-library/std-remap-01.json (100%) rename packages/editor/src/lib/{components/editor => }/types.ts (83%) rename packages/editor/src/lib/{components/editor => }/utils.ts (100%) rename packages/editor/src/lib/{components/editor => }/xy-theme.css (96%) create mode 100644 packages/theme/src/lib/index.ts diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index 0c47e15..dd18aba 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -1,6 +1,6 @@ diff --git a/apps/web/src/routes/editor/+page.svelte b/apps/web/src/routes/editor/+page.svelte index d4d28ef..f284dea 100644 --- a/apps/web/src/routes/editor/+page.svelte +++ b/apps/web/src/routes/editor/+page.svelte @@ -6,7 +6,7 @@ setGraphManager, type GraphDocument, } from "$editor"; - import { Menubar } from "$theme/components/ui"; + import { Menubar } from "$theme"; import { toggleMode } from "mode-watcher"; const graph = new GraphManager(); @@ -14,14 +14,20 @@ setDevContext(true); // TODO: Replace with `dev` from `$app/env` past 1.0 release - function handleUpload() { - const input = document.createElement("input"); - input.type = "file"; - input.accept = ".json"; - input.onchange = () => { - if (input.files?.[0]) void graph.upload(input.files[0]); - }; - input.click(); + function pickFiles(opts: { accept: string; multiple?: boolean }): Promise { + return new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = opts.accept; + if (opts.multiple) input.multiple = true; + input.onchange = () => resolve(Array.from(input.files ?? [])); + input.click(); + }); + } + + async function handleUpload() { + const [file] = await pickFiles({ accept: ".json" }); + if (file) await graph.upload(file); } function handleExportGroup() { @@ -30,38 +36,25 @@ graph.exportGroup(ref); } - function handleImportGroups() { - const input = document.createElement("input"); - input.type = "file"; - input.accept = ".json"; - input.multiple = true; - input.onchange = async () => { - if (!input.files) return; - const files = Array.from(input.files); - const entries: { - id?: string; - name?: string; - category?: string; - dependencies?: string[]; - definition?: GraphDocument; - }[] = await Promise.all( - files.map(async (file) => { - const text = await file.text(); - return JSON.parse(text) as { + async function handleImportGroups() { + const files = await pickFiles({ accept: ".json", multiple: true }); + if (files.length === 0) return; + const entries = await Promise.all( + files.map( + async (file) => + JSON.parse(await file.text()) as { id?: string; name?: string; category?: string; dependencies?: string[]; definition?: GraphDocument; - }; - }), - ); - const result = graph.importGroups(entries); - if (result.missing.length > 0) { - console.warn("Import completed with missing dependencies:", result.missing); - } - }; - input.click(); + }, + ), + ); + const result = graph.importGroups(entries); + if (result.missing.length > 0) { + console.warn("Import completed with missing dependencies:", result.missing); + } } function handleKeydown(event: KeyboardEvent) { diff --git a/packages/editor/src/lib/components/editor/Editor.svelte b/packages/editor/src/lib/Editor.svelte similarity index 94% rename from packages/editor/src/lib/components/editor/Editor.svelte rename to packages/editor/src/lib/Editor.svelte index aaf1e21..e37bf41 100644 --- a/packages/editor/src/lib/components/editor/Editor.svelte +++ b/packages/editor/src/lib/Editor.svelte @@ -1,5 +1,5 @@ -
    +
    - {#if graph.path.length > 0} -
    - - - - - { - e.preventDefault(); - graph.jumpToPath([]); - }} - > - Root - - - {#each graph.pathLabels as segment, i (segment.id)} - - - {#if i === graph.pathLabels.length - 1} - {segment.label} - {:else} - { - e.preventDefault(); - graph.jumpToPath(graph.path.slice(0, i + 1)); - }} - > - {segment.label} - - {/if} - {#if segment.locked} - - {/if} +
    + {#if graph.path.length > 0} +
    + + + + + { + e.preventDefault(); + graph.jumpToPath([]); + }} + > + Root + - {/each} - - -
    - {/if} - + {#each graph.pathLabels as segment, i (segment.id)} + + + {#if i === graph.pathLabels.length - 1} + {segment.label} + {:else} + { + e.preventDefault(); + graph.jumpToPath(graph.path.slice(0, i + 1)); + }} + > + {segment.label} + + {/if} + {#if segment.locked} + + {/if} + + {/each} + + +
    + {/if} + +
    From 50df75567e6352d1a377c12b53f6454797b1f797 Mon Sep 17 00:00:00 2001 From: glypse Date: Wed, 15 Jul 2026 10:40:01 +0200 Subject: [PATCH 06/15] Save viewport pos per document instance --- packages/editor/src/lib/GraphContent.svelte | 44 ++++++++++++++++++- packages/editor/src/lib/graph-helpers.ts | 16 +++++-- packages/editor/src/lib/graph-state.svelte.ts | 2 + packages/editor/src/lib/graph-types.ts | 8 +++- 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/packages/editor/src/lib/GraphContent.svelte b/packages/editor/src/lib/GraphContent.svelte index cfead9f..c4e23db 100644 --- a/packages/editor/src/lib/GraphContent.svelte +++ b/packages/editor/src/lib/GraphContent.svelte @@ -11,9 +11,11 @@ type Node, } from "@xyflow/svelte"; import { mode } from "mode-watcher"; + import { tick } from "svelte"; import { generateShader, debugSort, projectHash } from "./codegen"; import { getGraphManager } from "./graph-state.svelte"; + import { type HydratedGraphDocument, type HydratedGraphProject } from "./graph-types"; import NodeShell from "./NodeShell.svelte"; import { nodeRegistry, defaultDataForType, DYNAMIC_NODE_TYPES } from "./registry"; import { type HandleDef, type NodeCategory } from "./types"; @@ -59,9 +61,46 @@ return map; }); - const { screenToFlowPosition, getNodes } = useSvelteFlow(); + const { screenToFlowPosition, getNodes, getViewport, setViewport, fitView } = useSvelteFlow(); const graph = getGraphManager(); + // ── Per-document viewport (pan/zoom) persistence ────────────────────────── + // Each graph document (root + every group definition) stores its own camera so + // navigating between subgraphs restores the previous view instead of sharing a + // single global viewport. A document with no stored viewport falls back to + // fitView the first time it is entered, then keeps whatever the user leaves it at. + let prevPathKey = $state(""); + let prevProject: HydratedGraphProject | null = null; + let prevDoc: HydratedGraphDocument | null = null; + + async function applyActiveViewport() { + const doc = graph.activeDocument; + // Wait for the DOM to swap in the new document's nodes, then give SvelteFlow + // a frame to measure them before restoring the camera or fitting. + await tick(); + requestAnimationFrame(() => { + if (doc.viewport) { + void setViewport(doc.viewport); + } else { + void fitView(); + } + }); + } + + $effect(() => { + const pathKey = graph.path.join("|"); + const project = graph.project; + const projectChanged = prevProject !== project; + if (prevDoc && prevPathKey !== pathKey && !projectChanged) { + // Capture the camera of the document we're leaving before it's lost. + prevDoc.viewport = { ...getViewport() }; + } + prevPathKey = pathKey; + prevProject = project; + prevDoc = graph.activeDocument; + void applyActiveViewport(); + }); + const userGroups = $derived.by(() => { // eslint-disable-next-line svelte/prefer-svelte-reactivity -- static data, never mutated after creation const seen = new Set(); @@ -380,6 +419,9 @@ elementsSelectable={!isLocked} deleteKey={isLocked ? null : undefined} onbeforedelete={isLocked ? () => Promise.resolve(false) : undefined} + onmove={(_event, viewport) => { + graph.activeDocument.viewport = { ...viewport }; + }} onselectionchange={({ nodes: selectedNodes }) => { graph.selectedNodeIds = selectedNodes.map((n) => n.id); }} diff --git a/packages/editor/src/lib/graph-helpers.ts b/packages/editor/src/lib/graph-helpers.ts index a295c00..8c251ff 100644 --- a/packages/editor/src/lib/graph-helpers.ts +++ b/packages/editor/src/lib/graph-helpers.ts @@ -1,4 +1,4 @@ -import type { Node, Edge } from "@xyflow/svelte"; +import type { Node, Edge, Viewport } from "@xyflow/svelte"; import type { GraphDocument, @@ -45,7 +45,9 @@ export function normaliseDocument(doc: GraphDocument): GraphDocument { const desc = resolveDescriptor(node, {}); nodes.push({ ...node, data: { ...desc.defaultData, ...node.data } }); } - return { nodes, edges: doc.edges, label: doc.label, locked: doc.locked }; + const result: GraphDocument = { nodes, edges: doc.edges, label: doc.label, locked: doc.locked }; + if (doc.viewport) result.viewport = doc.viewport; + return result; } const NON_DELETABLE_TYPES = new Set(["frag-output", "group-input", "group-output"]); @@ -82,7 +84,13 @@ export function hydrateDocument(document: GraphDocument): HydratedGraphDocument targetHandle: edge.targetHandle, })); - return { nodes, edges, label: normalised.label, locked: normalised.locked }; + return { + nodes, + edges, + label: normalised.label, + locked: normalised.locked, + viewport: normalised.viewport, + }; } export function hydrateProject(project: GraphProject): HydratedGraphProject { @@ -104,6 +112,7 @@ export function serialiseDocument( edges: Edge[], label?: string, locked?: boolean, + viewport?: Viewport, ): GraphDocument { return { nodes: nodes.map(({ id, type, data, position }) => ({ @@ -121,5 +130,6 @@ export function serialiseDocument( })), ...(label !== undefined ? { label } : {}), ...(locked !== undefined ? { locked } : {}), + ...(viewport !== undefined ? { viewport } : {}), }; } diff --git a/packages/editor/src/lib/graph-state.svelte.ts b/packages/editor/src/lib/graph-state.svelte.ts index 1e32be2..d9cfcd4 100644 --- a/packages/editor/src/lib/graph-state.svelte.ts +++ b/packages/editor/src/lib/graph-state.svelte.ts @@ -228,6 +228,7 @@ export class GraphManager { this.project.root.edges, this.project.root.label, this.project.root.locked, + this.project.root.viewport, ); const definitions = Object.fromEntries( Object.entries(this.project.definitions).map(([key, definition]) => [ @@ -237,6 +238,7 @@ export class GraphManager { definition.edges, definition.label, definition.locked, + definition.viewport, ), ]), ); diff --git a/packages/editor/src/lib/graph-types.ts b/packages/editor/src/lib/graph-types.ts index 2f2192a..6b4a007 100644 --- a/packages/editor/src/lib/graph-types.ts +++ b/packages/editor/src/lib/graph-types.ts @@ -1,4 +1,4 @@ -import type { Edge } from "@xyflow/svelte"; +import type { Edge, Viewport } from "@xyflow/svelte"; import type { GroupedNode } from "./types"; @@ -12,6 +12,9 @@ export type GraphDocument = { label?: string; /** Whether instances of this group are locked (non-editable) by default. */ locked?: boolean; + /** Per-document camera (pan + zoom), persisted so navigating between + * subgraphs restores the previous view instead of sharing one global viewport. */ + viewport?: Viewport; }; export type GraphDocumentNode = { @@ -53,6 +56,9 @@ export type HydratedGraphDocument = { edges: Edge[]; label?: string; locked?: boolean; + /** Per-document camera (pan + zoom). Undefined until the user has interacted + * or a saved viewport has been loaded. */ + viewport?: Viewport; }; export type HydratedGraphProject = { From 820f62766c60225ccc3b79880cf1569a55fcb5c7 Mon Sep 17 00:00:00 2001 From: glypse Date: Wed, 15 Jul 2026 14:16:52 +0200 Subject: [PATCH 07/15] Styling the nodes and breadcrumbs UNFINISHED --- packages/editor/src/lib/GraphContent.svelte | 38 ++++++------- packages/editor/src/lib/NodeGraph.svelte | 11 ++-- packages/editor/src/lib/NodeShell.svelte | 53 +++++++++++++------ packages/editor/src/lib/graph-state.svelte.ts | 18 +++++++ packages/editor/src/lib/xy-theme.css | 2 +- 5 files changed, 77 insertions(+), 45 deletions(-) diff --git a/packages/editor/src/lib/GraphContent.svelte b/packages/editor/src/lib/GraphContent.svelte index c4e23db..62eec89 100644 --- a/packages/editor/src/lib/GraphContent.svelte +++ b/packages/editor/src/lib/GraphContent.svelte @@ -151,9 +151,6 @@ let contextMenuPosition = $state({ x: 0, y: 0 }); - let sheetOpen = $state(false); - let sheetGroupId = $state(null); - function addNode(type: string) { const id = crypto.randomUUID(); const position = screenToFlowPosition({ @@ -201,9 +198,9 @@ graph.enterGroup(selected.id); } } else if (event.key === "Escape") { - if (sheetOpen) { + if (graph.sheetOpen) { event.preventDefault(); - sheetOpen = false; + graph.sheetOpen = false; } else if (graph.path.length > 0) { event.preventDefault(); graph.exitGroup(); @@ -233,14 +230,9 @@ if (targetNodeId) { event.preventDefault(); - if (sheetOpen && sheetGroupId === targetNodeId) { - sheetOpen = false; - } else { - sheetGroupId = targetNodeId; - sheetOpen = true; - } - } else if (sheetOpen) { - sheetOpen = false; + graph.openGroupSheet(targetNodeId); + } else if (graph.sheetOpen) { + graph.sheetOpen = false; } } else if (event.key === "g") { if (isLocked) return; @@ -289,7 +281,7 @@ // Close the group config sheet when selection no longer contains a group node. $effect(() => { const _ids = graph.selectedNodeIds; - if (!sheetOpen) return; + if (!graph.sheetOpen) return; // Keep the sheet open when we're inside a group (viewing its contents). if (graph.path.length > 0) return; const hasGroup = _ids.some((id) => { @@ -297,7 +289,7 @@ return node?.type === "node-group" && node.group; }); if (!hasGroup) { - sheetOpen = false; + graph.sheetOpen = false; } }); @@ -314,12 +306,12 @@ } const sheetGroupNode = $derived.by(() => { - if (!sheetGroupId) return undefined; + if (!graph.sheetGroupId) return undefined; if (graph.path.length > 0) { - const inParent = graph.parentDocument.nodes.find((n) => n.id === sheetGroupId); + const inParent = graph.parentDocument.nodes.find((n) => n.id === graph.sheetGroupId); if (inParent) return inParent; } - return graph.activeDocument.nodes.find((n) => n.id === sheetGroupId); + return graph.activeDocument.nodes.find((n) => n.id === graph.sheetGroupId); }); const sheetDefinition = $derived( sheetGroupNode?.group @@ -381,8 +373,8 @@ }); function commitGroupLabel() { - if (sheetGroupId && groupLabelBuffer.trim()) { - graph.setGroupLabel(sheetGroupId, groupLabelBuffer); + if (graph.sheetGroupId && groupLabelBuffer.trim()) { + graph.setGroupLabel(graph.sheetGroupId, groupLabelBuffer); } } @@ -476,7 +468,7 @@ - + e.preventDefault()} @@ -490,8 +482,8 @@ - - + + {#each graph.pathLabels as segment, i (segment.id)} - + {#if i === graph.pathLabels.length - 1} {segment.label} {:else} diff --git a/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte index 8ca015c..206245f 100644 --- a/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte +++ b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte @@ -17,7 +17,7 @@ const attrs = $derived({ "data-slot": "breadcrumb-link", - class: cn("cn-breadcrumb-link", className), + class: cn("hover:text-foreground transition-colors", className), href, ...restProps, }); diff --git a/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte index 5133b0f..3c8ee0c 100644 --- a/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte +++ b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte @@ -14,7 +14,10 @@
      {@render children?.()} diff --git a/packages/theme/src/lib/components/ui/button/button.svelte b/packages/theme/src/lib/components/ui/button/button.svelte index 3150d67..af457a2 100644 --- a/packages/theme/src/lib/components/ui/button/button.svelte +++ b/packages/theme/src/lib/components/ui/button/button.svelte @@ -66,7 +66,7 @@ Date: Sat, 18 Jul 2026 13:37:37 +0200 Subject: [PATCH 09/15] Ungroup selection --- packages/editor/src/lib/GraphContent.svelte | 7 +- packages/editor/src/lib/NodeGraph.svelte | 4 +- packages/editor/src/lib/NodeShell.svelte | 2 +- packages/editor/src/lib/graph-state.svelte.ts | 146 ++++++++++++++++++ 4 files changed, 155 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/lib/GraphContent.svelte b/packages/editor/src/lib/GraphContent.svelte index 62eec89..ce8cf66 100644 --- a/packages/editor/src/lib/GraphContent.svelte +++ b/packages/editor/src/lib/GraphContent.svelte @@ -234,7 +234,7 @@ } else if (graph.sheetOpen) { graph.sheetOpen = false; } - } else if (event.key === "g") { + } else if (event.code === "KeyG" && !event.metaKey && !event.ctrlKey && !event.altKey) { if (isLocked) return; event.preventDefault(); const selectedIds = new Set( @@ -242,7 +242,10 @@ .filter((node) => node.selected) .map((node) => node.id), ); - if (selectedIds.size >= 1) { + if (selectedIds.size < 1) return; + if (event.shiftKey) { + graph.ungroupSelection(selectedIds); + } else { graph.groupSelection(selectedIds); } } else if (event.key === "Delete" || event.key === "Backspace") { diff --git a/packages/editor/src/lib/NodeGraph.svelte b/packages/editor/src/lib/NodeGraph.svelte index 4a08f75..caa5f81 100644 --- a/packages/editor/src/lib/NodeGraph.svelte +++ b/packages/editor/src/lib/NodeGraph.svelte @@ -42,7 +42,9 @@ {#each graph.pathLabels as segment, i (segment.id)} - + {#if i === graph.pathLabels.length - 1} {segment.label} {:else} diff --git a/packages/editor/src/lib/NodeShell.svelte b/packages/editor/src/lib/NodeShell.svelte index 2d0a08c..923dab6 100644 --- a/packages/editor/src/lib/NodeShell.svelte +++ b/packages/editor/src/lib/NodeShell.svelte @@ -93,7 +93,7 @@ {#if descriptor}
      {#if nodeType === "node-group"} -
      +
      {#if nodeLocked} + {/if} {/each} diff --git a/packages/editor/src/lib/graph-state.svelte.ts b/packages/editor/src/lib/graph-state.svelte.ts index dad3eb6..da35673 100644 --- a/packages/editor/src/lib/graph-state.svelte.ts +++ b/packages/editor/src/lib/graph-state.svelte.ts @@ -992,6 +992,43 @@ export class GraphManager { return true; } + /** + * Permanently delete a group definition and every instance that references it + * across the whole project. Standard library definitions cannot be deleted. + * No-op if the definition is still referenced anywhere (use after confirming + * it's an orphaned entry). + */ + deleteDefinition(definitionRef: string) { + if (this.isStandardLibrary(definitionRef)) return; + if (!(definitionRef in this.project.definitions)) return; + + const documents = [this.project.root, ...Object.values(this.project.definitions)]; + let stillReferenced = false; + for (const doc of documents) { + if ( + doc.nodes.some( + (n) => n.type === "node-group" && n.group?.definitionRef === definitionRef, + ) + ) { + stillReferenced = true; + break; + } + } + if (stillReferenced) return; + + // Reassign via rest-spread so the $state proxy tracks the removal. + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- only the remaining keys matter + const { [definitionRef]: _removed, ...rest } = this.project.definitions; + this.project.definitions = rest; + + // If we're currently viewing this (now-deleted) definition, bail out to root. + const inPath = this.path.some((nodeId) => { + const node = this.project.root.nodes.find((n) => n.id === nodeId); + return node?.group?.definitionRef === definitionRef; + }); + if (inPath) this.path = []; + } + /** Update the label of a handle on a group-input or group-output node inside a definition. */ setHandleLabel(definitionRef: string, handleNodeId: string, handleId: string, label: string) { const def = this.project.definitions[definitionRef]; From 11a252aae5dbfda2d1c5e652f999d6351c1d8509 Mon Sep 17 00:00:00 2001 From: glypse Date: Sun, 19 Jul 2026 12:24:06 +0200 Subject: [PATCH 11/15] Make TreeViewIcon on nodeshell a button to enter the node group --- apps/web/src/routes/+page.svelte | 2 ++ packages/editor/src/lib/NodeShell.svelte | 10 +++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index dd18aba..6dc32de 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -28,6 +28,8 @@
    1. Nodes with are a node group. + Click the button to enter said + node group.
    2. Select a node group and press Enter to enter it.
    3. diff --git a/packages/editor/src/lib/NodeShell.svelte b/packages/editor/src/lib/NodeShell.svelte index 923dab6..c9487ea 100644 --- a/packages/editor/src/lib/NodeShell.svelte +++ b/packages/editor/src/lib/NodeShell.svelte @@ -94,7 +94,15 @@
      {#if nodeType === "node-group"}
      - + {#if nodeLocked}
      diff --git a/packages/editor/src/lib/graph-state.svelte.ts b/packages/editor/src/lib/graph-state.svelte.ts index da35673..1084bbc 100644 --- a/packages/editor/src/lib/graph-state.svelte.ts +++ b/packages/editor/src/lib/graph-state.svelte.ts @@ -1029,6 +1029,102 @@ export class GraphManager { if (inPath) this.path = []; } + /** + * Compute every user-created (non-standard-library) group definition that is + * not reachable from any "live" part of the project. A definition is live if + * it is a standard-library entry, or if it is referenced (directly or + * transitively) by a node-group instance that lives in the root document or + * in another live definition. + * + * This is a *deep* reachability check: if group A is only referenced by group + * B, and group B itself is unused, then A is also unreachable and prunable. + * + * Returns the definition refs that *would* be removed (and are therefore safe + * to delete without leaving dangling references). Does not mutate anything. + */ + getPrunableRefs(): string[] { + // Live = standard library + anything reachable from a live node instance. + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- local set, not reactive state + const live = new Set(); + for (const entry of this.library) live.add(entry.id); + + // The root document is always live; seed with refs it references directly. + const queue: string[] = []; + for (const node of this.project.root.nodes) { + if (node.type === "node-group" && node.group) { + if (!live.has(node.group.definitionRef)) { + live.add(node.group.definitionRef); + queue.push(node.group.definitionRef); + } + } + } + + // Flood-fill: every ref reachable through nested group nodes of a live def. + while (queue.length > 0) { + const ref = queue.pop(); + if (ref === undefined) break; + const def = this.project.definitions[ref]; + if (!def) continue; + for (const node of def.nodes) { + if (node.type === "node-group" && node.group) { + const child = node.group.definitionRef; + if (!live.has(child)) { + live.add(child); + queue.push(child); + } + } + } + } + + const prunable: string[] = []; + for (const key of Object.keys(this.project.definitions)) { + if (!live.has(key)) prunable.push(key); + } + return prunable; + } + + /** A preview of groups that `pruneUnusedDefinitions` would delete. */ + getPrunableGroups(): { ref: string; label: string; nodeCount: number }[] { + const BOUNDARY = ["group-input", "group-output"]; + return this.getPrunableRefs().map((ref) => { + const def = this.project.definitions[ref]; + const nodeCount = def + ? def.nodes.filter((n) => !BOUNDARY.includes(n.type ?? "")).length + : 0; + return { ref, label: def?.label ?? "Group", nodeCount }; + }); + } + + /** + * Remove every user-created (non-standard-library) group definition that is + * no longer referenced by any group node instance anywhere in the project, + * including definitions that only reference each other in a cycle of orphans. + * Returns the list of definition refs that were removed. + */ + pruneUnusedDefinitions(): string[] { + if (this.isLocked) return []; + + const removed = this.getPrunableRefs(); + if (removed.length === 0) return []; + + // Reassign via rest-spread so the $state proxy tracks the removal. + const removedLookup: Record = {}; + for (const key of removed) removedLookup[key] = true; + const rest = Object.fromEntries( + Object.entries(this.project.definitions).filter(([key]) => !removedLookup[key]), + ); + this.project.definitions = rest; + + // If we're currently viewing a now-deleted definition, bail out to root. + const inPath = this.path.some((nodeId) => { + const node = this.project.root.nodes.find((n) => n.id === nodeId); + return node?.group && removed.includes(node.group.definitionRef); + }); + if (inPath) this.path = []; + + return removed; + } + /** Update the label of a handle on a group-input or group-output node inside a definition. */ setHandleLabel(definitionRef: string, handleNodeId: string, handleId: string, label: string) { const def = this.project.definitions[definitionRef]; diff --git a/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte new file mode 100644 index 0000000..7f10384 --- /dev/null +++ b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte @@ -0,0 +1,24 @@ + + + diff --git a/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte new file mode 100644 index 0000000..f1bec9b --- /dev/null +++ b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte @@ -0,0 +1,24 @@ + + + diff --git a/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte new file mode 100644 index 0000000..b8774b3 --- /dev/null +++ b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte @@ -0,0 +1,33 @@ + + + + + + diff --git a/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte new file mode 100644 index 0000000..225b207 --- /dev/null +++ b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte @@ -0,0 +1,21 @@ + + + diff --git a/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte new file mode 100644 index 0000000..52677a9 --- /dev/null +++ b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte @@ -0,0 +1,24 @@ + + +
      + {@render children?.()} +
      diff --git a/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte new file mode 100644 index 0000000..ce54bf7 --- /dev/null +++ b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte @@ -0,0 +1,24 @@ + + +
      + {@render children?.()} +
      diff --git a/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-media.svelte b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-media.svelte new file mode 100644 index 0000000..53d527e --- /dev/null +++ b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-media.svelte @@ -0,0 +1,24 @@ + + +
      + {@render children?.()} +
      diff --git a/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte new file mode 100644 index 0000000..1b839f9 --- /dev/null +++ b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte @@ -0,0 +1,21 @@ + + + diff --git a/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte new file mode 100644 index 0000000..f0a19a8 --- /dev/null +++ b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte new file mode 100644 index 0000000..a5eb80b --- /dev/null +++ b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte @@ -0,0 +1,21 @@ + + + diff --git a/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte new file mode 100644 index 0000000..b22d1d5 --- /dev/null +++ b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog.svelte b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog.svelte new file mode 100644 index 0000000..7ea78bb --- /dev/null +++ b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/theme/src/lib/components/ui/alert-dialog/index.ts b/packages/theme/src/lib/components/ui/alert-dialog/index.ts new file mode 100644 index 0000000..f009c26 --- /dev/null +++ b/packages/theme/src/lib/components/ui/alert-dialog/index.ts @@ -0,0 +1,40 @@ +import Action from "./alert-dialog-action.svelte"; +import Cancel from "./alert-dialog-cancel.svelte"; +import Content from "./alert-dialog-content.svelte"; +import Description from "./alert-dialog-description.svelte"; +import Footer from "./alert-dialog-footer.svelte"; +import Header from "./alert-dialog-header.svelte"; +import Media from "./alert-dialog-media.svelte"; +import Overlay from "./alert-dialog-overlay.svelte"; +import Portal from "./alert-dialog-portal.svelte"; +import Title from "./alert-dialog-title.svelte"; +import Trigger from "./alert-dialog-trigger.svelte"; +import Root from "./alert-dialog.svelte"; + +export { + Root, + Title, + Action, + Cancel, + Portal, + Footer, + Header, + Trigger, + Overlay, + Content, + Description, + Media, + // + Root as AlertDialog, + Title as AlertDialogTitle, + Action as AlertDialogAction, + Cancel as AlertDialogCancel, + Portal as AlertDialogPortal, + Footer as AlertDialogFooter, + Header as AlertDialogHeader, + Trigger as AlertDialogTrigger, + Overlay as AlertDialogOverlay, + Content as AlertDialogContent, + Description as AlertDialogDescription, + Media as AlertDialogMedia, +}; diff --git a/packages/theme/src/lib/components/ui/index.ts b/packages/theme/src/lib/components/ui/index.ts index d6ea8e6..5bc1c7f 100644 --- a/packages/theme/src/lib/components/ui/index.ts +++ b/packages/theme/src/lib/components/ui/index.ts @@ -6,6 +6,7 @@ export { Root as Separator } from "./separator/index"; export { Root as Scrubbable } from "./scrubbable/index"; // Multi-component: exported as namespaces (use as , , etc.) +export * as AlertDialog from "./alert-dialog/index"; export * as Breadcrumb from "./breadcrumb/index"; export * as ContextMenu from "./context-menu/index"; export * as Kbd from "./kbd/index"; From 655d2f688027e019e1c12560ace12228d61a25ae Mon Sep 17 00:00:00 2001 From: glypse Date: Sun, 19 Jul 2026 13:09:03 +0200 Subject: [PATCH 13/15] Add changeset --- .changeset/ninety-regions-love.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ninety-regions-love.md diff --git a/.changeset/ninety-regions-love.md b/.changeset/ninety-regions-love.md new file mode 100644 index 0000000..e6de288 --- /dev/null +++ b/.changeset/ninety-regions-love.md @@ -0,0 +1,5 @@ +--- +"$editor": minor +--- + +feat: node groups From 22e17cc43b2be2e4c287ca4a5de65ceb686b2f6a Mon Sep 17 00:00:00 2001 From: glypse Date: Sun, 19 Jul 2026 13:15:00 +0200 Subject: [PATCH 14/15] Cleanup standard library --- .../{std-passthrough.json => std-dummy.json} | 6 +- .../lib/standard-library/std-remap-01.json | 65 ------------------- 2 files changed, 3 insertions(+), 68 deletions(-) rename packages/editor/src/lib/standard-library/{std-passthrough.json => std-dummy.json} (88%) delete mode 100644 packages/editor/src/lib/standard-library/std-remap-01.json diff --git a/packages/editor/src/lib/standard-library/std-passthrough.json b/packages/editor/src/lib/standard-library/std-dummy.json similarity index 88% rename from packages/editor/src/lib/standard-library/std-passthrough.json rename to packages/editor/src/lib/standard-library/std-dummy.json index a320aed..e1dfa2f 100644 --- a/packages/editor/src/lib/standard-library/std-passthrough.json +++ b/packages/editor/src/lib/standard-library/std-dummy.json @@ -1,7 +1,7 @@ { - "id": "std-passthrough", - "name": "Passthrough", - "category": "math", + "id": "std-dummy", + "name": "Dummy standard library node group", + "category": "misc", "dependencies": [], "definition": { "nodes": [ diff --git a/packages/editor/src/lib/standard-library/std-remap-01.json b/packages/editor/src/lib/standard-library/std-remap-01.json deleted file mode 100644 index 76d21a8..0000000 --- a/packages/editor/src/lib/standard-library/std-remap-01.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "id": "std-remap-01", - "name": "Remap 0-1", - "category": "math", - "dependencies": [], - "definition": { - "nodes": [ - { - "id": "input-1", - "type": "group-input", - "data": { - "handles": [ - { - "id": "h-value", - "label": "Value", - "type": "f32" - } - ] - }, - "position": { "x": -400, "y": 0 } - }, - { - "id": "remap-1", - "type": "remap", - "data": { - "in_min": 0, - "in_max": 1, - "out_min": 0, - "out_max": 1 - }, - "position": { "x": -100, "y": 0 } - }, - { - "id": "output-1", - "type": "group-output", - "data": { - "handles": [ - { - "id": "h-result", - "label": "Result", - "type": "f32" - } - ] - }, - "position": { "x": 200, "y": 0 } - } - ], - "edges": [ - { - "id": "e-in", - "source": "input-1", - "sourceHandle": "h-value", - "target": "remap-1", - "targetHandle": "e" - }, - { - "id": "e-out", - "source": "remap-1", - "sourceHandle": "result", - "target": "output-1", - "targetHandle": "h-result" - } - ] - } -} From 12e1d98f5171ead4e15aac084ee8256c927c4397 Mon Sep 17 00:00:00 2001 From: glypse Date: Sun, 19 Jul 2026 13:28:25 +0200 Subject: [PATCH 15/15] Mute incorrect eslint warnings --- .../lib/components/ui/alert-dialog/alert-dialog-action.svelte | 3 +++ .../lib/components/ui/alert-dialog/alert-dialog-cancel.svelte | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte index 7f10384..15ed3f6 100644 --- a/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte +++ b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte @@ -7,7 +7,9 @@ let { ref = $bindable(null), class: className, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment variant = "default", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment size = "default", ...restProps }: AlertDialogPrimitive.ActionProps & { @@ -19,6 +21,7 @@ diff --git a/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte index f1bec9b..00d82c0 100644 --- a/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte +++ b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte @@ -7,7 +7,9 @@ let { ref = $bindable(null), class: className, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment variant = "outline", + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment size = "default", ...restProps }: AlertDialogPrimitive.CancelProps & { @@ -19,6 +21,7 @@