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 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..6dc32de 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -1,5 +1,36 @@ -See /editor :) +
+ See /editor :) + +

How to use:

+ +
diff --git a/apps/web/src/routes/editor/+page.svelte b/apps/web/src/routes/editor/+page.svelte index bb9ed73..ff2e97e 100644 --- a/apps/web/src/routes/editor/+page.svelte +++ b/apps/web/src/routes/editor/+page.svelte @@ -1,19 +1,119 @@ + + +
+ + + Promise.resolve(false) : undefined} + onmove={(_event, viewport) => { + graph.activeDocument.viewport = { ...viewport }; + }} + onselectionchange={({ nodes: selectedNodes }) => { + graph.selectedNodeIds = selectedNodes.map((n) => n.id); + }} + > + + + + + + + {#each [...categoryMap.entries()] as [category, items] (category)} + + {CATEGORY_LABELS[category]} + + {#each items as item (item.id)} + { + if (item.id === "node-group") { + createGroup(); + } else if (item.fromLibrary) { + addFromLibrary(item.id); + } else { + addNode(item.id); + } + }} + > + {item.label} + + {/each} + + + {/each} + {#if userGroups.length > 0} + + Groups + + {#each userGroups as group (group.definitionRef)} + addFromLibrary(group.definitionRef)} + > + {group.label} + {#if !group.used} + + {/if} + + {/each} + + + {/if} + + +
+ + + + + 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/NodeGraph.svelte b/packages/editor/src/lib/NodeGraph.svelte new file mode 100644 index 0000000..caa5f81 --- /dev/null +++ b/packages/editor/src/lib/NodeGraph.svelte @@ -0,0 +1,87 @@ + + + +
+ {#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} + + {/each} + + +
+ {/if} + + +
+
diff --git a/packages/editor/src/lib/NodeShell.svelte b/packages/editor/src/lib/NodeShell.svelte new file mode 100644 index 0000000..c9487ea --- /dev/null +++ b/packages/editor/src/lib/NodeShell.svelte @@ -0,0 +1,203 @@ + + +{#snippet addHandleButton()} + + + {#snippet child({ props })} + + {/snippet} + + +
+ {#each WGSL_TYPES as t (t)} + + {/each} +
+
+
+{/snippet} + +{#if descriptor} +
+ {#if nodeType === "node-group"} +
+ + {#if nodeLocked} + + {/if} + {#if sheetTargetId} + + {/if} +
+ {/if} + {label} +
+ +
+
+ {#each outputs as outputDef (outputDef.id)} +
+ {outputDef.label}: {outputDef.type} + +
+ {/each} + + {#if nodeType === "group-input" && !isLocked} + {@render addHandleButton()} + {/if} +
+ +
+ {#each inputs as inputDef (inputDef.id)} +
+ + {#if inputDef.type !== "f32"} + {inputDef.label}: {inputDef.type} + {/if} + + {#if inputDef.type === "f32" && !inputDef.variadic} + {#if connectedHandles.has(inputDef.id)} + + {inputDef.label} + + {:else} + { + const val = parseFloat(e.currentTarget.value) || 0; + updateNodeData(id, { + ...data, + [inputDef.id]: val, + }); + }} + /> + {/if} + {/if} +
+ {/each} + + {#if nodeType === "group-output" && !isLocked} + {@render addHandleButton()} + {/if} +
+
+{:else} +
+ Unknown node type: {nodeType} +
+{/if} diff --git a/packages/editor/src/lib/codegen.ts b/packages/editor/src/lib/codegen.ts new file mode 100644 index 0000000..4296bb5 --- /dev/null +++ b/packages/editor/src/lib/codegen.ts @@ -0,0 +1,499 @@ +import type { Edge, Node } from "@xyflow/svelte"; + +import { getGraphManager } from "./graph-state.svelte"; +import type { GroupedNode, HandleDef, NodeDescriptor, WgslType } from "./types"; +import { WGSL_DEFAULTS } from "./types"; + +/** + * Safely convert an unknown value to a numeric WGSL literal component. + */ +function toNum(v: unknown, fallback: number): string { + const n = Number(v); + return String(Number.isFinite(n) ? n : fallback); +} + +/** + * Format a data value as a WGSL literal for a given type. + */ +function formatWgslLiteral(type: WgslType, value: unknown): string { + if (type === "f32") { + return `f32(${toNum(value, 0)})`; + } + if (typeof value === "object" && value !== null) { + const obj = value as Record; + switch (type) { + case "vec2f": + return `vec2f(f32(${toNum(obj.x, 0)}), f32(${toNum(obj.y, 0)}))`; + 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, 0)}))`; + } + } + return WGSL_DEFAULTS[type]; +} + +/** + * 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[], + seedType: string, +): Set { + const reverseAdj = new Map(); + for (const node of nodes.values()) { + reverseAdj.set(node.id, []); + } + for (const edge of edges) { + const sources = reverseAdj.get(edge.target); + if (sources) sources.push(edge.source); + } + + const reachable = new Set(); + const queue: string[] = []; + + for (const node of nodes.values()) { + if (node.type === seedType) { + reachable.add(node.id); + queue.push(node.id); + } + } + + while (queue.length > 0) { + const current = queue.shift(); + if (!current) break; + for (const source of reverseAdj.get(current) ?? []) { + if (!reachable.has(source)) { + reachable.add(source); + queue.push(source); + } + } + } + + return reachable; +} + +/** + * 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. + */ +function topologicalSort(nodes: Map, edges: Edge[]): string[] { + const adjacency = new Map(); + const inDegree = new Map(); + + for (const node of nodes.values()) { + adjacency.set(node.id, []); + inDegree.set(node.id, 0); + } + + for (const edge of edges) { + const targets = adjacency.get(edge.source); + if (targets) targets.push(edge.target); + inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1); + } + + const queue: string[] = []; + for (const [id, degree] of inDegree) { + if (degree === 0) queue.push(id); + } + + const sorted: string[] = []; + while (queue.length > 0) { + const nodeId = queue.shift(); + if (!nodeId) throw new Error("Unexpected empty queue"); + sorted.push(nodeId); + for (const neighbor of adjacency.get(nodeId) ?? []) { + const newDegree = (inDegree.get(neighbor) ?? 0) - 1; + inDegree.set(neighbor, newDegree); + if (newDegree === 0) queue.push(neighbor); + } + } + + if (sorted.length !== nodes.size) { + throw new Error("Cycle detected in the node graph - cannot generate shader"); + } + + return sorted; +} + +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) { + nodeMap.set(node.id, node); + } + return topologicalSort(nodeMap, edges); +} + +/** + * 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 + * 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, + 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) { + if (sourceNode.type === "node-group") { + return `${base}.${sanitizeIdent(edge.sourceHandle)}`; + } + const sourceDescriptor = resolveDescriptor(sourceNode); + if (sourceDescriptor.outputs.length > 1) { + return base + "_" + edge.sourceHandle; + } + } + return base; +} + +/** + * Compute a stable hash from the graph state that actually affects the shader output. + */ +export function shaderHash(nodes: Node[], edges: Edge[]): string { + return JSON.stringify({ + nodes: nodes.map((n) => ({ id: n.id, type: n.type, data: n.data })), + edges: edges.map((e) => ({ + source: e.source, + target: e.target, + sourceHandle: e.sourceHandle, + targetHandle: e.targetHandle, + })), + }); +} + +/** + * Hash the entire project (root + all definitions) so that edits inside any + * subgraph also trigger a re-generation of the shader. + */ +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: stripLabels(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: stripLabels(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); + } + + 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)); + + const targetEdgeMap = new Map(); + for (const edge of filteredEdges) { + const list = targetEdgeMap.get(edge.target) ?? []; + list.push(edge); + targetEdgeMap.set(edge.target, list); + } + + const lines: string[] = []; + let seedNodeVarName: string | null = null; + + for (const nodeId of sortedIds) { + const node = nodeMap.get(nodeId); + 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 inputArgs = descriptor.inputs.map((inputDef) => { + const edgesForHandle = incoming.filter( + (e) => + e.targetHandle === inputDef.id || + (descriptor.inputs.length === 1 && !e.targetHandle), + ); + const firstEdge = edgesForHandle[0]; + if (firstEdge) return getSourceVar(firstEdge, nodeMap, resolveDescriptor); + const dataVal = + node.data[inputDef.id] ?? descriptor.defaultData[inputDef.id]; + return formatWgslLiteral(inputDef.type, dataVal); + }); + const callArgs = inputArgs; + 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) { + outputVars[outputDef.id] = + descriptor.outputs.length === 1 ? varName : varName + "_" + outputDef.id; + } + + const resolvedInputs: Record = {}; + for (const inputDef of descriptor.inputs) { + const edgesForHandle = incoming.filter( + (e) => + e.targetHandle === inputDef.id || + (descriptor.inputs.length === 1 && !e.targetHandle), + ); + const upstreamVars: string[] = []; + + if (inputDef.variadic) { + for (const edge of edgesForHandle) { + upstreamVars.push(getSourceVar(edge, nodeMap, resolveDescriptor)); + } + } else if (edgesForHandle.length > 0) { + const firstEdge = edgesForHandle[0]; + if (firstEdge) + upstreamVars.push(getSourceVar(firstEdge, nodeMap, resolveDescriptor)); + } else { + const dataVal: unknown = + node.data[inputDef.id] ?? descriptor.defaultData[inputDef.id]; + upstreamVars.push(formatWgslLiteral(inputDef.type, dataVal)); + } + + resolvedInputs[inputDef.id] = upstreamVars; + } + + const wgslLines = descriptor.wgsl({ + varName, + outputVars, + data: node.data, + inputs: resolvedInputs, + }); + for (const line of wgslLines) lines.push(` ${line}`); + + if (node.type === seedType) seedNodeVarName = 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 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}`; + + 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: regular handles only + 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, ""); + } + + body.push( + "// ---------- Common libraries ----------", + "", + "fn safeDivF32(a: f32, b:f32) -> f32 {", + " return select(a / b, 0.0, b == 0.0);", + "}", + "", + "// ---------- User code ----------", + "", + "fn frag(uv: vec2f) -> vec4f {", + ...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/GraphContent.svelte b/packages/editor/src/lib/components/editor/GraphContent.svelte deleted file mode 100644 index ff481c1..0000000 --- a/packages/editor/src/lib/components/editor/GraphContent.svelte +++ /dev/null @@ -1,142 +0,0 @@ - - - -
- - - - - - - - - - {#each [...categoryMap.entries()] as [category, items] (category)} - - {categoryLabel(category)} - - {#each items as item (item.type)} - { - addNode(item.type); - }} - > - {item.label} - - {/each} - - - {/each} - - -
diff --git a/packages/editor/src/lib/components/editor/NodeGraph.svelte b/packages/editor/src/lib/components/editor/NodeGraph.svelte deleted file mode 100644 index 1681636..0000000 --- a/packages/editor/src/lib/components/editor/NodeGraph.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - - - diff --git a/packages/editor/src/lib/components/editor/NodeShell.svelte b/packages/editor/src/lib/components/editor/NodeShell.svelte deleted file mode 100644 index e55d14f..0000000 --- a/packages/editor/src/lib/components/editor/NodeShell.svelte +++ /dev/null @@ -1,95 +0,0 @@ - - -{#if descriptor} -
-
- - {label} - -
- -
-
- {#each outputs as outputDef (outputDef.name)} -
- {outputDef.name}: {outputDef.type} - -
- {/each} -
- -
- {#each inputs as inputDef (inputDef.name)} -
- - {#if inputDef.type !== "f32"} - {inputDef.name}: {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} -
- {/each} -
-
-
-{:else} -
- Unknown node type: {nodeType} -
-{/if} diff --git a/packages/editor/src/lib/components/editor/codegen.ts b/packages/editor/src/lib/components/editor/codegen.ts deleted file mode 100644 index aef655c..0000000 --- a/packages/editor/src/lib/components/editor/codegen.ts +++ /dev/null @@ -1,291 +0,0 @@ -import type { Edge, Node } from "@xyflow/svelte"; - -import { nodeRegistry } from "./registry"; -import type { WgslType } from "./types"; -import { WGSL_DEFAULTS } from "./types"; - -/** - * Safely convert an unknown value to a numeric WGSL literal component. - */ -function toNum(v: unknown, fallback: number): string { - const n = Number(v); - return String(Number.isFinite(n) ? n : fallback); -} - -/** - * Format a data value as a WGSL literal for a given type. - */ -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) { - case "vec2f": - return `vec2f(f32(${toNum(obj.x, 0)}), f32(${toNum(obj.y, 0)}))`; - 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 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. - */ -function findReachableNodeIds(nodes: Map, edges: Edge[]): Set { - // Build reverse adjacency: target -> sources feeding into it - const reverseAdj = new Map(); - for (const node of nodes.values()) { - reverseAdj.set(node.id, []); - } - for (const edge of edges) { - const sources = reverseAdj.get(edge.target); - if (sources) sources.push(edge.source); - } - - const reachable = new Set(); - const queue: string[] = []; - - // Seed the BFS from all output nodes - for (const node of nodes.values()) { - if (node.type === "frag-output") { - reachable.add(node.id); - queue.push(node.id); - } - } - - while (queue.length > 0) { - const current = queue.shift(); - if (!current) break; // should never happen given the length check - for (const source of reverseAdj.get(current) ?? []) { - if (!reachable.has(source)) { - reachable.add(source); - queue.push(source); - } - } - } - - return reachable; -} - -/** - * Topological sort of node IDs. - * Works on a DAG; throws if a cycle is detected. - */ -function topologicalSort(nodes: Map, edges: Edge[]): string[] { - const adjacency = new Map(); - const inDegree = new Map(); - - for (const node of nodes.values()) { - adjacency.set(node.id, []); - inDegree.set(node.id, 0); - } - - for (const edge of edges) { - const targets = adjacency.get(edge.source); - if (targets) targets.push(edge.target); - inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1); - } - - const queue: string[] = []; - for (const [id, degree] of inDegree) { - if (degree === 0) queue.push(id); - } - - const sorted: string[] = []; - while (queue.length > 0) { - const nodeId = queue.shift(); - if (!nodeId) throw new Error("Unexpected empty queue"); - sorted.push(nodeId); - for (const neighbor of adjacency.get(nodeId) ?? []) { - const newDegree = (inDegree.get(neighbor) ?? 0) - 1; - inDegree.set(neighbor, newDegree); - if (newDegree === 0) queue.push(neighbor); - } - } - - if (sorted.length !== nodes.size) { - throw new Error("Cycle detected in the node graph — cannot generate shader"); - } - - 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, "_")}`; -} - -export function debugSort(nodes: Node[], edges: Edge[]): string[] { - const nodeMap = new Map(); - for (const node of nodes) { - nodeMap.set(node.id, node); - } - return topologicalSort(nodeMap, edges); -} - -/** - * 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. - */ -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). - const sourceNode = nodes.get(edge.source); - if (edge.sourceHandle && sourceNode?.type) { - const sourceDescriptor = nodeRegistry[sourceNode.type]; - if (sourceDescriptor.outputs.length > 1) { - return base + "_" + edge.sourceHandle; - } - } - return base; -} - -/** - * 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({ - nodes: nodes.map((n) => ({ id: n.id, type: n.type, data: n.data })), - edges: edges.map((e) => ({ - source: e.source, - target: e.target, - sourceHandle: e.sourceHandle, - targetHandle: e.targetHandle, - })), - }); -} - -/** - * Emit the full WGSL fragment shader source from the graph. - */ -export function generateShader(nodes: Node[], edges: Edge[]): string { - 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 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) ?? []; - list.push(edge); - targetEdgeMap.set(edge.target, list); - } - - const lines: string[] = []; - let resultVariable: 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]; - - const varName = getVarName(nodeId); - const incoming = targetEdgeMap.get(nodeId) ?? []; - - 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; - } - } - - // 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 || - (descriptor.inputs.length === 1 && !e.targetHandle), - ); - const upstreamVars: string[] = []; - - if (inputDef.variadic) { - for (const edge of edgesForHandle) { - upstreamVars.push(getSourceVar(edge, nodeMap)); - } - } else if (edgesForHandle.length > 0) { - upstreamVars.push(getSourceVar(edgesForHandle[0], nodeMap)); - } 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); - } - - resolvedInputs[inputDef.name] = upstreamVars; - } - - const wgslLines = descriptor.wgsl({ - varName, - outputVars, - data: node.data, - inputs: resolvedInputs, - }); - - for (const line of wgslLines) { - lines.push(` ${line}`); - } - - // Track which node produces the output - if (node.type === "frag-output") { - resultVariable = varName; - } - } - - // Build the fragment body - const body: string[] = [ - "// ---------- Common libraries ----------", - "", - "fn safeDivF32(a: f32, b:f32) -> f32 {", - " 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("}"); - - return body.join("\n"); -} diff --git a/packages/editor/src/lib/components/editor/graph-state.svelte.ts b/packages/editor/src/lib/components/editor/graph-state.svelte.ts deleted file mode 100644 index 0889c0b..0000000 --- a/packages/editor/src/lib/components/editor/graph-state.svelte.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { Node, Edge } from "@xyflow/svelte"; -import { getContext, setContext } from "svelte"; - -import { nodeRegistry } from "./registry"; -import defaultScene from "./sizing_circle.json"; - -// ── Serialisable document types ────────────────────────────────────────────── - -export type GraphDocument = { - nodes: GraphDocumentNode[]; - edges: GraphDocumentEdge[]; -}; - -export type GraphDocumentNode = { - id: string; - type: string; - data: Record; - position: { x: number; y: number }; -}; - -export type GraphDocumentEdge = { - id: string; - source: string; - target: string; - sourceHandle?: string; - targetHandle?: string; -}; - -export function isGraphDocument(obj: unknown): obj is GraphDocument { - return typeof obj === "object" && obj !== null && "nodes" in obj && "edges" in obj; -} - -// ── Pure helpers ───────────────────────────────────────────────────────────── - -/** - * Normalise a document: fill in defaultData for any node that's missing fields, - * discard unknown node types, etc. - */ -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 - if (desc === undefined) { - console.warn(`[graph] unknown node type "${n.type}" — dropping`); - continue; - } - nodes.push({ - ...n, - data: { ...desc.defaultData, ...n.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, - })); - - const edges: Edge[] = normalised.edges.map((e) => ({ - id: e.id, - source: e.source, - target: e.target, - sourceHandle: e.sourceHandle ?? undefined, - targetHandle: e.targetHandle ?? undefined, - })); - - // 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; - } - - return { nodes, edges, nextId: maxId + 1 }; -} - -/** - * Serialise current nodes and edges to a GraphDocument (ready to JSON.stringify). - */ -export function serialiseDocument(nodes: Node[], edges: Edge[]): GraphDocument { - return { - nodes: nodes.map(({ id, type, data, position }) => ({ - id, - type: type ?? "", - data, - position, - })), - edges: edges.map((e) => ({ - id: e.id, - source: e.source, - target: e.target, - sourceHandle: e.sourceHandle ?? undefined, - targetHandle: e.targetHandle ?? undefined, - })), - }; -} - -// ── Reactive state manager ─────────────────────────────────────────────────── - -export class GraphManager { - nodes = $state([]); - edges = $state([]); - nodeIdCounter = $state(1); - - 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; - } - - loadDocument(doc: GraphDocument) { - const result = hydrateDocument(doc); - this.nodes = result.nodes; - this.edges = result.edges; - this.nodeIdCounter = result.nextId; - } - - /** - * Return the serialised document as a JSON text string. - */ - getDocument(): string { - const doc = serialiseDocument(this.nodes, this.edges); - return JSON.stringify(doc, null, "\t"); - } - - /** - * Load a document from a raw JSON string. - */ - loadFromString(text: string) { - try { - const doc = JSON.parse(text) as GraphDocument; - this.loadDocument(doc); - } 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 url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "graph.json"; - a.click(); - URL.revokeObjectURL(url); - } - - async upload(file: File) { - try { - const text = await file.text(); - const doc = JSON.parse(text) as GraphDocument; - this.loadDocument(doc); - } catch (err) { - console.error("[graph] failed to parse uploaded JSON", err); - } - } -} - -// ── Context helpers ────────────────────────────────────────────────────────── - -const KEY = Symbol("graph-manager"); - -export function setGraphManager(m: GraphManager) { - setContext(KEY, m); -} - -export function getGraphManager(): GraphManager { - return getContext(KEY); -} diff --git a/packages/editor/src/lib/components/editor/nodes/abs.node.ts b/packages/editor/src/lib/components/editor/nodes/abs.node.ts deleted file mode 100644 index 280188d..0000000 --- a/packages/editor/src/lib/components/editor/nodes/abs.node.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const absNode = { - type: "abs", - label: "Absolute", - category: "math", - inputs: [{ name: "e", type: "f32" }], - outputs: [{ name: "result", type: "f32" }], - defaultData: { e: 0 }, - wgsl: ({ varName, inputs }) => { - const e = inputs.e[0]; - 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 deleted file mode 100644 index 5502a5f..0000000 --- a/packages/editor/src/lib/components/editor/nodes/add.node.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const addNode = { - type: "add", - label: "Add", - category: "math", - inputs: [{ name: "operands", type: "f32", variadic: true }], - outputs: [{ name: "result", type: "f32" }], - defaultData: {}, - wgsl: ({ varName, inputs }) => { - const list = inputs.operands; - if (list.length === 0) { - return [`let ${varName} = f32(0.0);`]; - } - 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 deleted file mode 100644 index 1c4bf61..0000000 --- a/packages/editor/src/lib/components/editor/nodes/clamp.node.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const clampNode = { - type: "clamp", - label: "Clamp", - category: "math", - inputs: [ - { name: "e", type: "f32" }, - { name: "low", type: "f32" }, - { name: "high", type: "f32" }, - ], - outputs: [{ name: "result", type: "f32" }], - defaultData: { e: 0, low: 0, high: 1 }, - wgsl: ({ varName, inputs }) => { - const e = inputs.e[0]; - const low = inputs.low[0]; - const high = inputs.high[0]; - 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 deleted file mode 100644 index d2dba5c..0000000 --- a/packages/editor/src/lib/components/editor/nodes/cos.node.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const cosNode = { - type: "cos", - label: "Cosine", - category: "math", - inputs: [{ name: "e", type: "f32" }], - outputs: [{ name: "result", type: "f32" }], - defaultData: { e: 0 }, - wgsl: ({ varName, inputs }) => { - const e = inputs.e[0]; - 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 deleted file mode 100644 index b4d6fa5..0000000 --- a/packages/editor/src/lib/components/editor/nodes/delta.node.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const deltaNode = { - type: "delta", - label: "Delta", - category: "input", - inputs: [], - outputs: [{ name: "delta", type: "f32" }], - defaultData: {}, - 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 deleted file mode 100644 index 5646cd4..0000000 --- a/packages/editor/src/lib/components/editor/nodes/div.node.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const divNode = { - type: "div", - label: "Divide", - category: "math", - inputs: [ - { name: "a", type: "f32" }, - { name: "b", type: "f32" }, - ], - outputs: [{ name: "result", type: "f32" }], - defaultData: { a: 0, b: 0 }, - wgsl: ({ varName, inputs }) => { - const a = inputs.a[0]; - const b = inputs.b[0]; - 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 deleted file mode 100644 index 51fc4bf..0000000 --- a/packages/editor/src/lib/components/editor/nodes/float.node.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const floatNode = { - type: "float", - label: "f32", - category: "input", - inputs: [{ name: "value", type: "f32" }], - outputs: [{ name: "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/max.node.ts b/packages/editor/src/lib/components/editor/nodes/max.node.ts deleted file mode 100644 index ec430c5..0000000 --- a/packages/editor/src/lib/components/editor/nodes/max.node.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const maxNode = { - type: "max", - label: "Maximum", - category: "math", - inputs: [ - { name: "e1", type: "f32" }, - { name: "e2", type: "f32" }, - ], - outputs: [{ name: "result", type: "f32" }], - defaultData: { e1: 0, e2: 0 }, - wgsl: ({ varName, inputs }) => { - const e1 = inputs.e1[0]; - const e2 = inputs.e2[0]; - 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 deleted file mode 100644 index b08103e..0000000 --- a/packages/editor/src/lib/components/editor/nodes/min.node.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const minNode = { - type: "min", - label: "Minimum", - category: "math", - inputs: [ - { name: "e1", type: "f32" }, - { name: "e2", type: "f32" }, - ], - outputs: [{ name: "result", type: "f32" }], - defaultData: { e1: 0, e2: 0 }, - wgsl: ({ varName, inputs }) => { - const e1 = inputs.e1[0]; - const e2 = inputs.e2[0]; - 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 deleted file mode 100644 index f721528..0000000 --- a/packages/editor/src/lib/components/editor/nodes/mul.node.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const mulNode = { - type: "mul", - label: "Multiply", - category: "math", - inputs: [{ name: "operands", type: "f32", variadic: true }], - outputs: [{ name: "result", type: "f32" }], - defaultData: {}, - wgsl: ({ varName, inputs }) => { - const list = inputs.operands; - if (list.length === 0) { - return [`let ${varName} = f32(0.0);`]; - } - 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 deleted file mode 100644 index 8c3f489..0000000 --- a/packages/editor/src/lib/components/editor/nodes/output.node.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const outputNode = { - type: "frag-output", - label: "Output", - category: "output", - inputs: [{ name: "color", type: "vec4f" }], - outputs: [], - defaultData: {}, - wgsl: ({ varName, inputs }) => { - const color = inputs.color[0] ?? "vec4f(0.0, 0.0, 0.0, 1.0)"; - return [`let ${varName} = ${color};`]; - }, -} satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/pow.node.ts b/packages/editor/src/lib/components/editor/nodes/pow.node.ts deleted file mode 100644 index 2b68b97..0000000 --- a/packages/editor/src/lib/components/editor/nodes/pow.node.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const powNode = { - type: "pow", - label: "Power", - category: "math", - inputs: [ - { name: "e1", type: "f32" }, - { name: "e2", type: "f32" }, - ], - outputs: [{ name: "result", type: "f32" }], - defaultData: { e1: 0, e2: 0 }, - wgsl: ({ varName, inputs }) => { - const e1 = inputs.e1[0]; - const e2 = inputs.e2[0]; - return [ - `var ${varName}: f32;`, - `let _e2_${varName}: f32 = ${e2};`, - `if (_e2_${varName} == f32(0.0)) {`, - ` ${varName} = f32(0.0);`, - `} else {`, - ` ${varName} = pow(${e1}, _e2_${varName});`, - `}`, - ]; - }, -} satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/remap.node.ts b/packages/editor/src/lib/components/editor/nodes/remap.node.ts deleted file mode 100644 index 18e089e..0000000 --- a/packages/editor/src/lib/components/editor/nodes/remap.node.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const remapNode = { - type: "remap", - 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" }, - ], - outputs: [{ name: "result", type: "f32" }], - defaultData: { e: 0, in_min: 0, in_max: 1, out_min: 0, out_max: 1 }, - wgsl: ({ varName, inputs }) => { - const e = inputs.e[0]; - const in_min = inputs.in_min[0]; - const in_max = inputs.in_max[0]; - const out_min = inputs.out_min[0]; - const out_max = inputs.out_max[0]; - return [ - `let ${varName} = ${out_min} + (${e} - ${in_min}) * safeDivF32((${out_max} - ${out_min}), (${in_max} - ${in_min}));`, - ]; - }, -} 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 deleted file mode 100644 index 3fa3633..0000000 --- a/packages/editor/src/lib/components/editor/nodes/resolution.node.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const resolutionNode = { - type: "resolution", - label: "Resolution", - category: "input", - inputs: [], - outputs: [{ name: "resolution", type: "vec2f" }], - defaultData: {}, - 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 deleted file mode 100644 index 12c8caa..0000000 --- a/packages/editor/src/lib/components/editor/nodes/select.node.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const mixNode = { - type: "mix", - label: "Mix", - category: "math", - inputs: [ - { name: "e1", type: "f32" }, - { name: "e2", type: "f32" }, - { name: "e3", type: "f32" }, - ], - outputs: [{ name: "result", type: "f32" }], - defaultData: { e1: 0, e2: 0, e3: 0 }, - wgsl: ({ varName, inputs }) => { - const e1 = inputs.e1[0]; - const e2 = inputs.e2[0]; - const e3 = inputs.e3[0]; - 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 deleted file mode 100644 index 6d975a9..0000000 --- a/packages/editor/src/lib/components/editor/nodes/separateVec2f.node.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const separateVec2fNode = { - type: "separateVec2f", - label: "Separate Vec2f", - category: "vector", - inputs: [{ name: "xy", type: "vec2f" }], - outputs: [ - { name: "x", type: "f32" }, - { name: "y", type: "f32" }, - ], - defaultData: { x: 0, y: 0 }, - wgsl: ({ outputVars, inputs }) => { - const src = inputs.xy[0] ?? "vec2f(0.0)"; - 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 deleted file mode 100644 index 75911f7..0000000 --- a/packages/editor/src/lib/components/editor/nodes/separateVec3f.node.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const separateVec3fNode = { - type: "separateVec3f", - label: "Separate Vec3f", - category: "vector", - inputs: [{ name: "xyz", type: "vec3f" }], - outputs: [ - { name: "x", type: "f32" }, - { name: "y", type: "f32" }, - { name: "z", type: "f32" }, - ], - defaultData: { x: 0, y: 0, z: 0 }, - wgsl: ({ outputVars, inputs }) => { - const src = inputs.xyz[0] ?? "vec3f(0.0)"; - return [ - `let ${outputVars.x} = ${src}.x;`, - `let ${outputVars.y} = ${src}.y;`, - `let ${outputVars.z} = ${src}.z;`, - ]; - }, -} satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/separateVec4f.node.ts b/packages/editor/src/lib/components/editor/nodes/separateVec4f.node.ts deleted file mode 100644 index 12b65df..0000000 --- a/packages/editor/src/lib/components/editor/nodes/separateVec4f.node.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const separateVec4fNode = { - type: "separateVec4f", - label: "Separate Vec4f", - category: "vector", - inputs: [{ name: "xyzw", type: "vec4f" }], - outputs: [ - { name: "x", type: "f32" }, - { name: "y", type: "f32" }, - { name: "z", type: "f32" }, - { name: "w", type: "f32" }, - ], - defaultData: { x: 0, y: 0, z: 0, w: 0 }, - wgsl: ({ outputVars, inputs }) => { - const src = inputs.xyzw[0] ?? "vec4f(0.0)"; - return [ - `let ${outputVars.x} = ${src}.x;`, - `let ${outputVars.y} = ${src}.y;`, - `let ${outputVars.z} = ${src}.z;`, - `let ${outputVars.w} = ${src}.w;`, - ]; - }, -} satisfies NodeDescriptor; diff --git a/packages/editor/src/lib/components/editor/nodes/sin.node.ts b/packages/editor/src/lib/components/editor/nodes/sin.node.ts deleted file mode 100644 index da386f9..0000000 --- a/packages/editor/src/lib/components/editor/nodes/sin.node.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const sinNode = { - type: "sin", - label: "Sine", - category: "math", - inputs: [{ name: "e", type: "f32" }], - outputs: [{ name: "result", type: "f32" }], - defaultData: { e: 0 }, - wgsl: ({ varName, inputs }) => { - const e = inputs.e[0]; - 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 deleted file mode 100644 index b198876..0000000 --- a/packages/editor/src/lib/components/editor/nodes/sub.node.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const subNode = { - type: "sub", - label: "Subtract", - category: "math", - inputs: [ - { name: "a", type: "f32" }, - { name: "b", type: "f32" }, - ], - outputs: [{ name: "result", type: "f32" }], - defaultData: { a: 0, b: 0 }, - wgsl: ({ varName, inputs }) => { - const a = inputs.a[0]; - const b = inputs.b[0]; - 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 deleted file mode 100644 index 79a63e7..0000000 --- a/packages/editor/src/lib/components/editor/nodes/tan.node.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const tanNode = { - type: "tan", - label: "Tangeant", - category: "math", - inputs: [{ name: "e", type: "f32" }], - outputs: [{ name: "result", type: "f32" }], - defaultData: { e: 0 }, - wgsl: ({ varName, inputs }) => { - const e = inputs.e[0]; - 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 deleted file mode 100644 index c8e7fc4..0000000 --- a/packages/editor/src/lib/components/editor/nodes/time.node.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const timeNode = { - type: "time", - label: "Time", - category: "input", - inputs: [], - outputs: [{ name: "time", type: "f32" }], - defaultData: {}, - 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 deleted file mode 100644 index c6342cb..0000000 --- a/packages/editor/src/lib/components/editor/nodes/uv.node.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const uvNode = { - type: "uv", - label: "UV", - category: "input", - inputs: [], - outputs: [{ name: "uv", type: "vec2f" }], - defaultData: {}, - 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 deleted file mode 100644 index 974f524..0000000 --- a/packages/editor/src/lib/components/editor/nodes/vec2f.node.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const vec2fNode = { - type: "vec2f", - label: "Vec2f", - category: "vector", - inputs: [ - { name: "x", type: "f32" }, - { name: "y", type: "f32" }, - ], - outputs: [{ name: "value", type: "vec2f" }], - defaultData: { x: 0, y: 0 }, - wgsl: ({ varName, inputs }) => { - const x = inputs.x[0]; - const y = inputs.y[0]; - 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 deleted file mode 100644 index 387ae5f..0000000 --- a/packages/editor/src/lib/components/editor/nodes/vec3f.node.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const vec3fNode = { - type: "vec3f", - label: "Vec3f", - category: "vector", - inputs: [ - { name: "x", type: "f32" }, - { name: "y", type: "f32" }, - { name: "z", type: "f32" }, - ], - outputs: [{ name: "value", type: "vec3f" }], - defaultData: { x: 0, y: 0, z: 0 }, - wgsl: ({ varName, inputs }) => { - const x = inputs.x[0]; - const y = inputs.y[0]; - const z = inputs.z[0]; - 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 deleted file mode 100644 index e5c7e0d..0000000 --- a/packages/editor/src/lib/components/editor/nodes/vec4f.node.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { NodeDescriptor } from "../types"; - -export const vec4fNode = { - type: "vec4f", - label: "Vec4f", - category: "vector", - inputs: [ - { name: "x", type: "f32" }, - { name: "y", type: "f32" }, - { name: "z", type: "f32" }, - { name: "w", type: "f32" }, - ], - outputs: [{ name: "value", type: "vec4f" }], - defaultData: { x: 0, y: 0, z: 0, w: 1 }, - wgsl: ({ varName, inputs }) => { - const x = inputs.x[0]; - const y = inputs.y[0]; - const z = inputs.z[0]; - const w = inputs.w[0]; - 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 deleted file mode 100644 index 72b666c..0000000 --- a/packages/editor/src/lib/components/editor/registry.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { absNode } from "./nodes/abs.node"; -import { addNode } from "./nodes/add.node"; -import { clampNode } from "./nodes/clamp.node"; -import { cosNode } from "./nodes/cos.node"; -import { deltaNode } from "./nodes/delta.node"; -import { divNode } from "./nodes/div.node"; -import { floatNode } from "./nodes/float.node"; -import { maxNode } from "./nodes/max.node"; -import { minNode } from "./nodes/min.node"; -import { mulNode } from "./nodes/mul.node"; -import { outputNode } from "./nodes/output.node"; -import { powNode } from "./nodes/pow.node"; -import { remapNode } from "./nodes/remap.node"; -import { resolutionNode } from "./nodes/resolution.node"; -import { mixNode } from "./nodes/select.node"; -import { separateVec2fNode } from "./nodes/separateVec2f.node"; -import { separateVec3fNode } from "./nodes/separateVec3f.node"; -import { separateVec4fNode } from "./nodes/separateVec4f.node"; -import { sinNode } from "./nodes/sin.node"; -import { subNode } from "./nodes/sub.node"; -import { tanNode } from "./nodes/tan.node"; -import { timeNode } from "./nodes/time.node"; -import { uvNode } from "./nodes/uv.node"; -import { vec2fNode } from "./nodes/vec2f.node"; -import { vec3fNode } from "./nodes/vec3f.node"; -import { vec4fNode } from "./nodes/vec4f.node"; -import type { NodeDescriptor, NodeCategory } from "./types"; - -/** - * The global node registry. - * Built by hand right now; in the future this could auto-discover - * exported descriptors from a directory, or accept addon registrations. - */ -export const nodeRegistry: Record = { - // Input - float: floatNode, - uv: uvNode, - time: timeNode, - delta: deltaNode, - resolution: resolutionNode, - - // Math - add: addNode, - sub: subNode, - mul: mulNode, - div: divNode, - mix: mixNode, - abs: absNode, - min: minNode, - max: maxNode, - clamp: clampNode, - pow: powNode, - remap: remapNode, - sin: sinNode, - cos: cosNode, - tan: tanNode, - - // Vector - vec2f: vec2fNode, - vec3f: vec3fNode, - vec4f: vec4fNode, - separateVec2f: separateVec2fNode, - separateVec3f: separateVec3fNode, - separateVec4f: separateVec4fNode, - - // Output - "frag-output": outputNode, -}; - -/** Helper: produce default node data for a registered type. */ -export function defaultDataForType(type: string): Record { - return { ...nodeRegistry[type].defaultData }; -} - -/** Convenience: list all categories and their nodes for an AddNode menu. */ -export function nodesByCategory(): Map< - NodeCategory, - { type: string; label: string; category: NodeCategory }[] -> { - const map = new Map(); - for (const [type, desc] of Object.entries(nodeRegistry)) { - const entry = { type, label: desc.label, category: desc.category }; - const list = map.get(desc.category) ?? []; - list.push(entry); - map.set(desc.category, list); - } - 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 deleted file mode 100644 index 1215558..0000000 --- a/packages/editor/src/lib/components/editor/sizing_circle.json +++ /dev/null @@ -1,642 +0,0 @@ -{ - "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/debug.json b/packages/editor/src/lib/debug.json new file mode 100644 index 0000000..1f800be --- /dev/null +++ b/packages/editor/src/lib/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/graph-helpers.ts b/packages/editor/src/lib/graph-helpers.ts new file mode 100644 index 0000000..8c251ff --- /dev/null +++ b/packages/editor/src/lib/graph-helpers.ts @@ -0,0 +1,135 @@ +import type { Node, Edge, Viewport } from "@xyflow/svelte"; + +import type { + GraphDocument, + GraphDocumentNode, + GraphProject, + HydratedGraphDocument, + HydratedGraphProject, +} from "./graph-types"; +import { + buildGroupDescriptor, + buildGroupInputDescriptor, + buildGroupOutputDescriptor, +} from "./node-factories"; +import { nodeRegistry } from "./registry"; +import { type GroupedNode, type HandleDef, type NodeDescriptor } from "./types"; + +// ── Pure helpers ───────────────────────────────────────────────────────────── + +export 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); + } + // 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 { + const nodes: GraphDocumentNode[] = []; + for (const node of doc.nodes) { + if (node.group) { + nodes.push({ ...node, data: { ...node.data } }); + continue; + } + + const desc = resolveDescriptor(node, {}); + nodes.push({ ...node, data: { ...desc.defaultData, ...node.data } }); + } + 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"]); + +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) { + return { + ...base, + group: node.group, // just a string pointer, nothing to hydrate + }; + } + + return base; // not a group node at all - most nodes hit this line +} + +export 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, + label: normalised.label, + locked: normalised.locked, + viewport: normalised.viewport, + }; +} + +export 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 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 done by + * `GraphManager.getProjectJson()`. + */ +export function serialiseDocument( + nodes: Node[], + edges: Edge[], + label?: string, + locked?: boolean, + viewport?: Viewport, +): GraphDocument { + return { + nodes: nodes.map(({ id, type, data, position }) => ({ + id, + type: type ?? "", + data, + position, + })), + edges: edges.map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + sourceHandle: e.sourceHandle ?? undefined, + targetHandle: e.targetHandle ?? undefined, + })), + ...(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 new file mode 100644 index 0000000..1084bbc --- /dev/null +++ b/packages/editor/src/lib/graph-state.svelte.ts @@ -0,0 +1,1380 @@ +import type { Node, Edge } from "@xyflow/svelte"; +import { getContext, setContext } from "svelte"; + +import { + hydrateDocument, + hydrateProject, + normaliseDocument, + resolveDescriptor, + serialiseDocument, +} from "./graph-helpers"; +import { + type GraphDocument, + type GraphDocumentEdge, + type GraphDocumentNode, + type GraphProject, + type HydratedGraphDocument, + type HydratedGraphProject, + type StandardLibraryEntry, +} from "./graph-types"; +import { nodeRegistry } from "./registry"; +import defaultScene from "./sizing_circle.json"; +import { type GroupedNode, type HandleDef, type NodeDescriptor, type WgslType } from "./types"; + +// ── Standard library auto-import ────────────────────────────────────────────── + +const libraryModules = import.meta.glob<{ + default: { + id: string; + name: string; + category: string; + dependencies: string[]; + definition: GraphDocument; + }; +}>("./standard-library/*.json", { eager: true }); + +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, + }; +}); + +// ── Reactive state manager ─────────────────────────────────────────────────── + +export class GraphManager { + /** 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([]); + + /** Currently selected node IDs in SvelteFlow. */ + selectedNodeIds = $state([]); + + /** + * Group configuration sheet state. Opened via the "n" key (see GraphContent) + * or the faders button on group / group-input / group-output nodes (see NodeShell). + * `sheetGroupId` is the id of the group node *instance* whose definition is being edited. + */ + sheetOpen = $state(false); + sheetGroupId = $state(null); + + /** Open (or toggle closed) the group configuration sheet for a group node instance. */ + openGroupSheet(targetNodeId: string) { + if (this.sheetOpen && this.sheetGroupId === targetNodeId) { + this.sheetOpen = false; + } else { + this.sheetGroupId = targetNodeId; + this.sheetOpen = true; + } + } + + /** Read-only standard library entries for browsing. */ + library: StandardLibraryEntry[] = libraryEntries; + + 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); + def.label = entry.label; + this.project.definitions[entry.id] = hydrateDocument(def); + } + } + + /** + * Walk the breadcrumb path, yielding (node, definition) at each step. + * Stops early if a step is invalid (missing node or definition). + */ + private *walkPath(): Generator<{ + node: GroupedNode; + group: NonNullable; + definition: HydratedGraphDocument; + nodeId: 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) return; + const definition = this.project.definitions[node.group.definitionRef]; + if (!definition) return; + yield { node, group: node.group, definition, nodeId }; + current = definition; + } + } + + get activeDocument(): HydratedGraphDocument { + let current: HydratedGraphDocument = this.project.root; + let steps = 0; + for (const { definition } of this.walkPath()) { + current = definition; + steps++; + } + // If the walk didn't consume the entire path, the path is invalid — reset + if (steps !== this.path.length) { + this.path = []; + return this.project.root; + } + return current; + } + + 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; + } + + /** Whether the currently-viewed subgraph (or any of its ancestors) is locked. */ + get isLocked(): boolean { + for (const { group } of this.walkPath()) { + if (group.locked) return true; + } + 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; locked: boolean }[] { + const labels: { id: string; label: string; locked: boolean }[] = []; + for (const { node, definition, nodeId } of this.walkPath()) { + if (node.type === "node-group") { + labels.push({ + id: nodeId, + label: definition.label ?? "Group", + locked: node.group?.locked ?? false, + }); + } else { + const desc = nodeRegistry[node.type ?? ""]; + labels.push({ + id: nodeId, + label: desc?.label ?? node.type ?? "unknown", + locked: false, + }); + } + } + return labels; + } + + /** + * 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; + let steps = 0; + for (const { definition } of this.walkPath()) { + steps++; + if (steps >= this.path.length) break; + 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`); + return; + } + this.selectedNodeIds = []; + 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); + } + + /** + * 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 = []; + + // 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, + this.project.root.label, + this.project.root.locked, + this.project.root.viewport, + ); + const definitions = Object.fromEntries( + Object.entries(this.project.definitions).map(([key, definition]) => [ + key, + serialiseDocument( + definition.nodes, + definition.edges, + definition.label, + definition.locked, + definition.viewport, + ), + ]), + ); + const project = { root, definitions }; + return JSON.stringify(project, null, "\t"); + } + + loadFromString(text: string) { + try { + const project = JSON.parse(text) as GraphProject; + this.loadProject(project); + } catch (err) { + console.error("[graph] failed to parse JSON", err); + } + } + + save() { + const blob = new Blob([this.getProjectJson()], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "webGpuNodesProject.json"; + a.click(); + URL.revokeObjectURL(url); + } + + async upload(file: File) { + try { + const text = await file.text(); + const project = JSON.parse(text) as GraphProject; + this.loadProject(project); + } catch (err) { + console.error("[graph] failed to parse uploaded JSON", err); + } + } + + // ── 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 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(); + for (const { group } of this.walkPath()) { + refs.add(group.definitionRef); + } + 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). + */ + // 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; + label: string; + category: string; + dependencies: string[]; + locked: boolean | undefined; + 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, + label: def.label ?? entry?.label ?? "Group", + category: entry?.category ?? "group", + dependencies: deps, + locked: def.locked, + 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.label.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; + label?: 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); + if (file.label) normalised.label = file.label; + normalised.locked = true; + 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 }; + } + + groupSelection(selectedNodeIds: Set): void { + // 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) && !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( + 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); + const dedupedOutgoing = deduplicateBySource(outgoing); + + const OFFSET = 300; + + const groupInputNode = buildBoundaryNode( + "input", + dedupedIncoming, + this.activeDocument.nodes, + (node) => this.resolveDescriptor(node), + { x: bounds.minX - OFFSET, y: bounds.centerY }, + ); + const groupOutputNode = buildBoundaryNode( + "output", + 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 userGroupCount = Object.keys(this.project.definitions).filter( + (k) => !this.isStandardLibrary(k), + ).length; + + const definition: GraphDocument = { + nodes: [ + ...(selectedNodes as GraphDocumentNode[]), + groupInputNode.node, + groupOutputNode.node, + ], + edges: [...internal, ...allInnerEdges], + label: `Group ${String(userGroupCount)}`, + }; + + this.project.definitions[definitionRef] = hydrateDocument(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) => !filteredIds.has(n.id)), + groupNode, + ]; + + this.activeDocument.edges = [ + ...this.activeDocument.edges.filter((e) => !touchedEdgeIds.has(e.id)), + ...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; + } + } + + /** + * Ungroup the selected group node(s) in the active document: clone the group's + * inner nodes (excluding group-input / group-output boundary nodes) into the + * active document, centered where the group node was, and rewire the edges. + * + * Works on both locked and unlocked groups (the source definition is never + * mutated - inner nodes and edges are always cloned with fresh ids). + */ + ungroupSelection(selectedNodeIds: Set): void { + if (this.isLocked) return; + + const groupNodes = this.activeDocument.nodes.filter( + (n) => selectedNodeIds.has(n.id) && n.type === "node-group" && n.group, + ); + if (groupNodes.length === 0) return; + + const freshNodeIds: string[] = []; + for (const groupNode of groupNodes) { + freshNodeIds.push(...this.ungroupNode(groupNode)); + } + + // Deselect everything, then select the freshly ungrouped nodes so the user + // can immediately drag them all together. + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- local set, not reactive state + const freshIds = new Set(freshNodeIds); + for (const n of this.activeDocument.nodes) n.selected = freshIds.has(n.id); + for (const e of this.activeDocument.edges) e.selected = false; + this.selectedNodeIds = freshNodeIds; + } + + /** + * Ungroup a single group node instance living in the active document. + * Returns the ids of the freshly-created (cloned) inner nodes. + */ + private ungroupNode(groupNode: GroupedNode): string[] { + if (!groupNode.group) return []; + const def = this.project.definitions[groupNode.group.definitionRef]; + if (!def) return []; + + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const BOUNDARY = new Set(["group-input", "group-output"]); + const inputNode = def.nodes.find((n) => n.type === "group-input"); + const outputNode = def.nodes.find((n) => n.type === "group-output"); + const innerNodes = def.nodes.filter((n) => !BOUNDARY.has(n.type ?? "")); + + // Regenerate ids for every inner node. + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- local id map, not reactive state + const idMap = new Map(); + for (const n of innerNodes) idMap.set(n.id, crypto.randomUUID()); + + // Compute the center of the inner nodes so we can re-center them on the group node. + const groupCenterX = groupNode.position.x + (groupNode.width ?? 0) / 2; + const groupCenterY = groupNode.position.y + (groupNode.height ?? 0) / 2; + + const lefts = innerNodes.map((n) => n.position.x); + const tops = innerNodes.map((n) => n.position.y); + const rights = innerNodes.map((n) => n.position.x + (n.width ?? 0)); + const bottoms = innerNodes.map((n) => n.position.y + (n.height ?? 0)); + const innerCenterX = + innerNodes.length > 0 ? (Math.min(...lefts) + Math.max(...rights)) / 2 : 0; + const innerCenterY = + innerNodes.length > 0 ? (Math.min(...tops) + Math.max(...bottoms)) / 2 : 0; + const dx = groupCenterX - innerCenterX; + const dy = groupCenterY - innerCenterY; + + const clonedNodes: GraphDocumentNode[] = innerNodes.map((n) => ({ + id: idMap.get(n.id) ?? n.id, + type: n.type ?? "", + data: JSON.parse(JSON.stringify(n.data)) as Record, + position: { x: n.position.x + dx, y: n.position.y + dy }, + ...(n.group + ? { group: { definitionRef: n.group.definitionRef, locked: n.group.locked } } + : {}), + })); + + const remap = (id: string): string => idMap.get(id) ?? id; + const isBoundary = (id: string): boolean => id === inputNode?.id || id === outputNode?.id; + + // Internal edges (both endpoints are inner, non-boundary nodes) - clone directly. + const clonedInnerEdges: GraphDocumentEdge[] = def.edges + .filter((e) => !isBoundary(e.source) && !isBoundary(e.target)) + .map((e) => ({ + id: crypto.randomUUID(), + source: remap(e.source), + target: remap(e.target), + sourceHandle: e.sourceHandle ?? undefined, + targetHandle: e.targetHandle ?? undefined, + })); + + // Outer edges connected to this group node instance. + const outerEdges = this.activeDocument.edges as GraphDocumentEdge[]; + const incomingOuter = outerEdges.filter((e) => e.target === groupNode.id); + const outgoingOuter = outerEdges.filter((e) => e.source === groupNode.id); + + const boundaryEdges: GraphDocumentEdge[] = []; + + // group-input handle -> inner target. Splice: outer source -> inner target. + for (const innerEdge of def.edges) { + if (innerEdge.source !== inputNode?.id) continue; + const feed = incomingOuter.find((e) => e.targetHandle === innerEdge.sourceHandle); + if (!feed) continue; // handle not wired from outside - drop + boundaryEdges.push({ + id: crypto.randomUUID(), + source: feed.source, + sourceHandle: feed.sourceHandle ?? undefined, + target: remap(innerEdge.target), + targetHandle: innerEdge.targetHandle ?? undefined, + }); + } + + // inner source -> group-output handle. Splice: inner source -> outer target(s). + for (const innerEdge of def.edges) { + if (innerEdge.target !== outputNode?.id) continue; + const consumers = outgoingOuter.filter( + (e) => e.sourceHandle === innerEdge.targetHandle, + ); + for (const consumer of consumers) { + boundaryEdges.push({ + id: crypto.randomUUID(), + source: remap(innerEdge.source), + sourceHandle: innerEdge.sourceHandle ?? undefined, + target: consumer.target, + targetHandle: consumer.targetHandle ?? undefined, + }); + } + } + + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- local set, not reactive state + const removedEdgeIds = new Set([ + ...incomingOuter.map((e) => e.id), + ...outgoingOuter.map((e) => e.id), + ]); + + this.activeDocument.nodes = [ + ...this.activeDocument.nodes.filter((n) => n.id !== groupNode.id), + ...(clonedNodes as GroupedNode[]), + ]; + this.activeDocument.edges = [ + ...this.activeDocument.edges.filter((e) => !removedEdgeIds.has(e.id)), + ...clonedInnerEdges, + ...boundaryEdges, + ]; + + return clonedNodes.map((n) => n.id); + } + + /** + * 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 { + // Search the whole project: the node may live in the active document, + // an ancestor document (e.g. a breadcrumb segment), or the currently-viewed group. + const documents = [this.project.root, ...Object.values(this.project.definitions)]; + let node: GroupedNode | undefined; + for (const doc of documents) { + const found = doc.nodes.find((n) => n.id === lockedNodeGroupId); + if (found) { + node = found; + break; + } + } + 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] = 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 }; + + return newDefRef; + } + + /** + * 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]; + if (!sourceDef) { + console.warn(`[graph] addFromLibrary: unknown definition "${definitionRef}"`); + return undefined; + } + + if (this.wouldCreateCycle(definitionRef)) { + console.warn( + `[graph] addFromLibrary: adding "${definitionRef}" would create a circular reference`, + ); + return undefined; + } + + const isLocked = this.isStandardLibrary(definitionRef) || sourceDef.locked === true; + + const groupNodeId = crypto.randomUUID(); + const groupNode: GraphDocumentNode = { + id: groupNodeId, + type: "node-group", + data: {}, + position, + group: { definitionRef, locked: isLocked }, + }; + + this.activeDocument.nodes = [...this.activeDocument.nodes, groupNode]; + + return groupNodeId; + } + + /** + * Create a brand-new empty group (with one input handle and one output handle) + * and add an instance of it to the active document. Unlike addFromLibrary, this + * generates a fresh definitionRef so the node is immediately enterable and valid. + * Returns the new group node's id, or undefined if the active document is locked. + */ + createGroup(position: { x: number; y: number }): string | undefined { + if (this.isLocked) return undefined; + + const definitionRef = crypto.randomUUID(); + const inputNodeId = crypto.randomUUID(); + const outputNodeId = crypto.randomUUID(); + + const inputHandle: HandleDef = { + id: crypto.randomUUID(), + label: "Input 1", + type: "f32", + }; + const outputHandle: HandleDef = { + id: crypto.randomUUID(), + label: "Output 1", + type: "f32", + }; + + const definition: GraphDocument = { + nodes: [ + { + id: inputNodeId, + type: "group-input", + data: { handles: [inputHandle] }, + position: { x: -200, y: 0 }, + }, + { + id: outputNodeId, + type: "group-output", + data: { handles: [outputHandle] }, + position: { x: 200, y: 0 }, + }, + ], + edges: [ + { + id: crypto.randomUUID(), + source: inputNodeId, + sourceHandle: inputHandle.id, + target: outputNodeId, + targetHandle: outputHandle.id, + }, + ], + label: this.nextGroupLabel(), + }; + + this.project.definitions[definitionRef] = hydrateDocument(definition); + + const groupNodeId = crypto.randomUUID(); + const groupNode: GraphDocumentNode = { + id: groupNodeId, + type: "node-group", + data: {}, + position, + group: { definitionRef, locked: false }, + }; + + this.activeDocument.nodes = [...this.activeDocument.nodes, groupNode]; + + return groupNodeId; + } + + /** Generate the next "Group N" label based on existing user definitions. */ + private nextGroupLabel(): string { + const count = Object.keys(this.project.definitions).filter( + (k) => !this.isStandardLibrary(k), + ).length; + return `Group ${String(count)}`; + } + + /** Set the display label for a group definition (shared by all instances). */ + setGroupLabel(nodeId: string, label: string) { + let node: GroupedNode | undefined; + if (this.path.length > 0) { + node = this.parentDocument.nodes.find((n) => n.id === nodeId); + } + node ??= this.activeDocument.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; + } + + /** + * 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 = []; + } + + /** + * 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]; + 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 internal 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) + ), + ); + + // Remove all *outer* edges in any document that holds an instance of this + // definition: the now-deleted handle no longer exists on the group node, + // so any edge referencing it would be dangling and crash the renderer. + this.removeOuterEdgesForHandle(definitionRef, handleId); + } + + /** + * Delete every edge in the project that connects to a group node instance of + * `definitionRef` via the given `handleId`. Without this, deleting a handle + * from a group's definition leaves dangling edges in parent documents. + */ + private removeOuterEdgesForHandle(definitionRef: string, handleId: string) { + const documents = [this.project.root, ...Object.values(this.project.definitions)]; + for (const doc of documents) { + const instanceIds = doc.nodes + .filter((n) => n.type === "node-group" && n.group?.definitionRef === definitionRef) + .map((n) => n.id); + if (instanceIds.length === 0) continue; + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- local Set, never stored in reactive state + const ids = new Set(instanceIds); + doc.edges = doc.edges.filter( + (e) => + !( + (ids.has(e.source) && e.sourceHandle === handleId) || + (ids.has(e.target) && e.targetHandle === handleId) + ), + ); + } + } +} + +// ── Grouping helpers (extracted from groupSelection) ────────────────────────── + +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[]; +}; + +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[], + resolve: (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 = resolve(sourceNode); + const outputDef = descriptor.outputs.find( + (o) => o.id === edge.sourceHandle || descriptor.outputs.length === 1, + ); + + return outputDef?.type ?? "f32"; +} + +type BoundaryBuildResult = { + node: GraphDocumentNode; + handleFor: Map; +}; + +function buildBoundaryNode( + direction: "input" | "output", + deduped: GraphDocumentEdge[][], + activeDocumentNodes: GroupedNode[], + resolve: (node: GroupedNode) => NodeDescriptor, + position: { x: number; y: number }, +): BoundaryBuildResult { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const handleFor = new Map(); + const handles: HandleDef[] = []; + const prefix = direction === "input" ? "Input" : "Output"; + const nodeType = direction === "input" ? "group-input" : "group-output"; + + for (const [i, group] of deduped.entries()) { + const representative = group[0]; + if (!representative) { + console.warn("[graph] empty edge group - skipping"); + continue; + } + const handle: HandleDef = { + id: crypto.randomUUID(), + label: `${prefix} ${String(i + 1)}`, + type: inferHandleType(representative, activeDocumentNodes, resolve), + }; + handles.push(handle); + handleFor.set(group, handle); + } + + return { + node: { + id: crypto.randomUUID(), + type: nodeType, + data: { handles }, + position, + }, + handleFor, + }; +} + +function rewireIncomingGroup( + group: GraphDocumentEdge[], + handle: HandleDef, + groupInputNodeId: string, + groupNodeId: string, +): { outer: GraphDocumentEdge; inner: GraphDocumentEdge[] } { + const representative = group[0]; + if (!representative) throw new Error("empty group"); + + 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 }; +} + +function rewireOutgoingGroup( + group: GraphDocumentEdge[], + handle: HandleDef, + groupOutputNodeId: string, + groupNodeId: string, +): { outer: GraphDocumentEdge[]; inner: GraphDocumentEdge } { + const representative = group[0]; + if (!representative) throw new Error("empty group"); + + 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 }; +} + +// ── Context helpers ────────────────────────────────────────────────────────── + +const KEY = Symbol("graph-manager"); + +export function setGraphManager(m: GraphManager) { + setContext(KEY, m); +} + +export function getGraphManager(): GraphManager { + return getContext(KEY); +} diff --git a/packages/editor/src/lib/graph-types.ts b/packages/editor/src/lib/graph-types.ts new file mode 100644 index 0000000..6b4a007 --- /dev/null +++ b/packages/editor/src/lib/graph-types.ts @@ -0,0 +1,89 @@ +import type { Edge, Viewport } from "@xyflow/svelte"; + +import type { GroupedNode } 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[]; + /** Shared display label for all instances of this group. */ + 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 = { + id: string; + 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 = { + id: string; + source: string; + target: string; + sourceHandle?: string; + targetHandle?: string; +}; + +/** The whole project. Exactly one per file/save. */ +export type GraphProject = { + definitions: Record; + root: GraphDocument; +}; + +export type HydratedGraphDocument = { + nodes: GroupedNode[]; + 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 = { + definitions: Record; + root: HydratedGraphDocument; +}; + +export type StandardLibraryEntry = { + id: string; + label: string; + category: string; + dependencies: string[]; + definition: GraphDocument; +}; + +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) + ); +} diff --git a/packages/editor/src/lib/index.ts b/packages/editor/src/lib/index.ts index 18fb38e..07c76a8 100644 --- a/packages/editor/src/lib/index.ts +++ b/packages/editor/src/lib/index.ts @@ -1,8 +1,13 @@ -export { default as Editor } from "./components/editor/Editor.svelte"; -export { - GraphManager, - 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 { default as Editor } from "./Editor.svelte"; +export { default as NodeGraph } from "./NodeGraph.svelte"; +export { default as GraphContent } from "./GraphContent.svelte"; +export { default as NodeShell } from "./NodeShell.svelte"; + +export { GraphManager, setGraphManager, getGraphManager } from "./graph-state.svelte"; + +export * from "./graph-types"; +export * from "./graph-helpers"; +export * from "./types"; +export * from "./registry"; +export * from "./codegen"; +export * from "./utils"; diff --git a/packages/editor/src/lib/node-factories.ts b/packages/editor/src/lib/node-factories.ts new file mode 100644 index 0000000..645ad8c --- /dev/null +++ b/packages/editor/src/lib/node-factories.ts @@ -0,0 +1,390 @@ +import type { HydratedGraphDocument } from "./graph-types"; +import type { + GroupedNode, + HandleDef, + NodeDescriptor, + NodeInputDef, + WgslType, + WgslCtx, +} from "./types"; + +// ── Input helpers ───────────────────────────────────────────────────────────── + +/** Extract a single named input's upstream variable. Safe to call — codegen guarantees presence. */ +function input(ctx: WgslCtx, name: string): string { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guaranteed by codegen + return ctx.inputs[name]![0]!; +} + +// ── Factory: unary math function (sin, cos, tan, abs) ──────────────────────── + +function unaryFn(type: string, label: string, wgslFn: string): NodeDescriptor { + return { + type, + label, + category: "math", + inputs: [{ id: "e", label: "e", type: "f32" }], + outputs: [{ id: "result", label: "result", type: "f32" }], + defaultData: { e: 0 }, + wgsl: (ctx) => [`let ${ctx.varName} = ${wgslFn}(${input(ctx, "e")});`], + }; +} + +// ── Factory: variadic join operator (add, mul) ─────────────────────────────── + +function variadicOp(type: string, label: string, joiner: string): NodeDescriptor { + return { + type, + label, + category: "math", + inputs: [{ id: "operands", label: "operands", type: "f32", variadic: true }], + outputs: [{ id: "result", label: "result", type: "f32" }], + defaultData: {}, + wgsl: (ctx) => { + const list = ctx.inputs.operands ?? []; + if (list.length === 0) return [`let ${ctx.varName} = f32(0.0);`]; + return [`let ${ctx.varName} = ${list.join(joiner)};`]; + }, + }; +} + +// ── Factory: binary function call (min, max) ───────────────────────────────── + +function binaryFn(type: string, label: string, wgslFn: string): NodeDescriptor { + return { + type, + label, + category: "math", + inputs: [ + { id: "e1", label: "e1", type: "f32" }, + { id: "e2", label: "e2", type: "f32" }, + ], + outputs: [{ id: "result", label: "result", type: "f32" }], + defaultData: { e1: 0, e2: 0 }, + wgsl: (ctx) => [ + `let ${ctx.varName} = ${wgslFn}(${input(ctx, "e1")}, ${input(ctx, "e2")});`, + ], + }; +} + +// ── Factory: binary operator (sub, div) ────────────────────────────────────── + +function binaryOp( + type: string, + label: string, + op: (a: string, b: string) => string, +): NodeDescriptor { + return { + type, + label, + category: "math", + inputs: [ + { id: "a", label: "a", type: "f32" }, + { id: "b", label: "b", type: "f32" }, + ], + outputs: [{ id: "result", label: "result", type: "f32" }], + defaultData: { a: 0, b: 0 }, + wgsl: (ctx) => [`let ${ctx.varName} = ${op(input(ctx, "a"), input(ctx, "b"))};`], + }; +} + +// ── Factory: N-ary function call (clamp, mix) ──────────────────────────────── + +function nAryFn( + type: string, + label: string, + wgslFn: string, + inputDefs: NodeInputDef[], + defaults: Record, +): NodeDescriptor { + return { + type, + label, + category: "math", + inputs: inputDefs, + outputs: [{ id: "result", label: "result", type: "f32" }], + defaultData: defaults, + wgsl: (ctx) => { + const args = inputDefs.map((d) => input(ctx, d.id)).join(", "); + return [`let ${ctx.varName} = ${wgslFn}(${args});`]; + }, + }; +} + +// ── Factory: vector constructor (vec2f, vec3f, vec4f) ──────────────────────── + +function vecConstructor( + type: string, + components: string[], + defaults: Record, +): NodeDescriptor { + const outType = type as WgslType; + return { + type, + label: type.charAt(0).toUpperCase() + type.slice(1), + category: "vector", + inputs: components.map((c) => ({ id: c, label: c, type: "f32" as const })), + outputs: [{ id: "value", label: "value", type: outType }], + defaultData: defaults, + wgsl: (ctx) => { + const args = components.map((c) => input(ctx, c)).join(", "); + return [`let ${ctx.varName} = ${type}(${args});`]; + }, + }; +} + +// ── Factory: vector decompose (separateVec2f, separateVec3f, separateVec4f) ── + +function separateVec(type: string, dim: number): NodeDescriptor { + const components = ["x", "y", "z", "w"].slice(0, dim); + const inputName = components.join(""); + const vecType = `vec${String(dim)}f` as WgslType; + const vecDefault = `${vecType}(0.0)`; + const label = `Separate Vec${String(dim)}f`; + + return { + type, + label, + category: "vector", + inputs: [{ id: inputName, label: inputName, type: vecType }], + outputs: components.map((c) => ({ id: c, label: c, type: "f32" as const })), + defaultData: Object.fromEntries(components.map((c) => [c, 0])), + wgsl: (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const src = ctx.inputs[inputName]![0] ?? vecDefault; + return components.map( + (c) => + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `let ${ctx.outputVars[c]} = ${src}.${c};`, + ); + }, + }; +} + +// ── Factory: built-in uniform reader (time, delta, resolution, uv) ─────────── + +function uniformReader( + type: string, + label: string, + outputName: string, + outputType: WgslType, + wgslExpr: string, +): NodeDescriptor { + return { + type, + label, + category: "input", + inputs: [], + outputs: [{ id: outputName, label: outputName, type: outputType }], + defaultData: {}, + wgsl: ({ varName }) => [`let ${varName}: ${outputType} = ${wgslExpr};`], + }; +} + +// ── Node definitions ───────────────────────────────────────────────────────── + +// Math — unary +export const sinNode = unaryFn("sin", "Sine", "sin"); +export const cosNode = unaryFn("cos", "Cosine", "cos"); +export const tanNode = unaryFn("tan", "Tangeant", "tan"); +export const absNode = unaryFn("abs", "Absolute", "abs"); + +// Math — variadic +export const addNode = variadicOp("add", "Add", " + "); +export const mulNode = variadicOp("mul", "Multiply", " * "); + +// Math — binary function call +export const minNode = binaryFn("min", "Minimum", "min"); +export const maxNode = binaryFn("max", "Maximum", "max"); + +// Math — binary operator +export const subNode = binaryOp("sub", "Subtract", (a, b) => `${a} - ${b}`); +export const divNode = binaryOp("div", "Divide", (a, b) => `safeDivF32(${a}, ${b})`); + +// Math — N-ary function call +export const clampNode = nAryFn( + "clamp", + "Clamp", + "clamp", + [ + { id: "e", label: "e", type: "f32" }, + { id: "low", label: "low", type: "f32" }, + { id: "high", label: "high", type: "f32" }, + ], + { e: 0, low: 0, high: 1 }, +); +export const mixNode = nAryFn( + "mix", + "Mix", + "mix", + [ + { id: "e1", label: "e1", type: "f32" }, + { id: "e2", label: "e2", type: "f32" }, + { id: "e3", label: "e3", type: "f32" }, + ], + { e1: 0, e2: 0, e3: 0 }, +); + +// Vector — constructors +export const vec2fNode = vecConstructor("vec2f", ["x", "y"], { x: 0, y: 0 }); +export const vec3fNode = vecConstructor("vec3f", ["x", "y", "z"], { x: 0, y: 0, z: 0 }); +export const vec4fNode = vecConstructor("vec4f", ["x", "y", "z", "w"], { x: 0, y: 0, z: 0, w: 1 }); + +// Vector — decompose +export const separateVec2fNode = separateVec("separateVec2f", 2); +export const separateVec3fNode = separateVec("separateVec3f", 3); +export const separateVec4fNode = separateVec("separateVec4f", 4); + +// Input — uniforms +export const timeNode = uniformReader("time", "Time", "time", "f32", "motiongpuFrame.time"); +export const deltaNode = uniformReader("delta", "Delta", "delta", "f32", "motiongpuFrame.delta"); +export const resolutionNode = uniformReader( + "resolution", + "Resolution", + "resolution", + "vec2f", + "motiongpuFrame.resolution", +); +export const uvNode = uniformReader("uv", "UV", "uv", "vec2f", "uv"); + +// ── Standalone nodes (unique patterns) ─────────────────────────────────────── + +export const floatNode: NodeDescriptor = { + type: "float", + label: "f32", + category: "input", + inputs: [{ id: "value", label: "value", type: "f32" }], + outputs: [{ id: "value", label: "value", type: "f32" }], + defaultData: { value: 0.0 }, + wgsl: ({ varName, data }) => [`let ${varName} = f32(${String(data.value)});`], +}; + +export const outputNode: NodeDescriptor = { + type: "frag-output", + label: "Output", + category: "misc", + inputs: [{ id: "color", label: "color", type: "vec4f" }], + outputs: [], + defaultData: {}, + wgsl: (ctx) => [`let ${ctx.varName} = ${input(ctx, "color")};`], +}; + +export const powNode: NodeDescriptor = { + type: "pow", + label: "Power", + category: "math", + inputs: [ + { id: "e1", label: "e1", type: "f32" }, + { id: "e2", label: "e2", type: "f32" }, + ], + outputs: [{ id: "result", label: "result", type: "f32" }], + defaultData: { e1: 0, e2: 0 }, + wgsl: (ctx) => { + const e = input(ctx, "e1"); + const p = input(ctx, "e2"); + return [ + `var ${ctx.varName}: f32;`, + `if (${p} == 0.0) {`, + ` ${ctx.varName} = 1.0;`, + `} else {`, + ` ${ctx.varName} = pow(${e}, ${p});`, + `}`, + ]; + }, +}; + +export const remapNode: NodeDescriptor = { + type: "remap", + label: "Remap", + category: "math", + inputs: [ + { id: "e", label: "e", type: "f32" }, + { id: "in_min", label: "in_min", type: "f32" }, + { id: "in_max", label: "in_max", type: "f32" }, + { id: "out_min", label: "out_min", type: "f32" }, + { id: "out_max", label: "out_max", type: "f32" }, + ], + outputs: [{ id: "result", label: "result", type: "f32" }], + defaultData: { e: 0, in_min: 0, in_max: 1, out_min: 0, out_max: 1 }, + wgsl: (ctx) => { + const e = input(ctx, "e"); + const inMin = input(ctx, "in_min"); + const inMax = input(ctx, "in_max"); + const outMin = input(ctx, "out_min"); + const outMax = input(ctx, "out_max"); + return [ + `let ${ctx.varName} = ${outMin} + (${e} - ${inMin}) * safeDivF32((${outMax} - ${outMin}), (${inMax} - ${inMin}));`, + ]; + }, +}; + +// ── Group descriptor builders ───────────────────────────────────────────────── + +export function buildGroupInputDescriptor(handles: HandleDef[]): NodeDescriptor { + return { + type: "group-input", + label: "Group Input", + category: "input", + inputs: [], + outputs: handles.map((h) => ({ id: h.id, label: h.label, type: h.type })), + defaultData: { handles: [] }, + wgsl: () => { + throw new Error("unreachable"); + }, + }; +} + +export function buildGroupOutputDescriptor(handles: HandleDef[]): NodeDescriptor { + return { + type: "group-output", + label: "Group Output", + category: "misc", + inputs: handles.map((h) => ({ id: h.id, label: h.label, type: h.type })), + outputs: [], + defaultData: { handles: [] }, + wgsl: () => { + throw new Error("unreachable"); + }, + }; +} + +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: "misc", + 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: definition.label ?? definitionRef, + category: "misc", + inputs: handles.inputs.map((h) => ({ id: h.id, label: h.label, type: h.type })), + outputs: handles.outputs.map((h) => ({ id: h.id, label: h.label, type: h.type })), + defaultData: {}, + wgsl: () => [], + }; +} diff --git a/packages/editor/src/lib/registry.ts b/packages/editor/src/lib/registry.ts new file mode 100644 index 0000000..2d80aaa --- /dev/null +++ b/packages/editor/src/lib/registry.ts @@ -0,0 +1,109 @@ +import { + absNode, + addNode, + clampNode, + cosNode, + deltaNode, + divNode, + floatNode, + maxNode, + minNode, + mulNode, + outputNode, + powNode, + remapNode, + resolutionNode, + mixNode, + separateVec2fNode, + separateVec3fNode, + separateVec4fNode, + sinNode, + subNode, + tanNode, + timeNode, + uvNode, + vec2fNode, + vec3fNode, + vec4fNode, +} from "./node-factories"; +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: "misc", + inputs: [], + outputs: [], + defaultData: {}, + wgsl: () => [], +}; + +/** + * The global node registry. + * Built by hand right now; in the future this could auto-discover + * exported descriptors from a directory, or accept addon registrations. + */ +export const nodeRegistry: Record = { + // Input + float: floatNode, + uv: uvNode, + time: timeNode, + delta: deltaNode, + resolution: resolutionNode, + + // Math + add: addNode, + sub: subNode, + mul: mulNode, + div: divNode, + mix: mixNode, + abs: absNode, + min: minNode, + max: maxNode, + clamp: clampNode, + pow: powNode, + remap: remapNode, + sin: sinNode, + cos: cosNode, + tan: tanNode, + + // Vector + vec2f: vec2fNode, + vec3f: vec3fNode, + vec4f: vec4fNode, + separateVec2f: separateVec2fNode, + separateVec3f: separateVec3fNode, + separateVec4f: separateVec4fNode, + + // 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 }; +} + +/** Convenience: list all categories and their nodes for an AddNode menu. */ +export function nodesByCategory(): Map< + NodeCategory, + { type: string; label: string; category: NodeCategory }[] +> { + const map = new Map(); + for (const [type, desc] of Object.entries(nodeRegistry)) { + const entry = { type, label: desc.label, category: desc.category }; + const list = map.get(desc.category) ?? []; + list.push(entry); + map.set(desc.category, list); + } + return map; +} diff --git a/packages/editor/src/lib/sizing_circle.json b/packages/editor/src/lib/sizing_circle.json new file mode 100644 index 0000000..90d7710 --- /dev/null +++ b/packages/editor/src/lib/sizing_circle.json @@ -0,0 +1,645 @@ +{ + "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/standard-library/std-dummy.json b/packages/editor/src/lib/standard-library/std-dummy.json new file mode 100644 index 0000000..e1dfa2f --- /dev/null +++ b/packages/editor/src/lib/standard-library/std-dummy.json @@ -0,0 +1,47 @@ +{ + "id": "std-dummy", + "name": "Dummy standard library node group", + "category": "misc", + "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/types.ts b/packages/editor/src/lib/types.ts similarity index 55% rename from packages/editor/src/lib/components/editor/types.ts rename to packages/editor/src/lib/types.ts index 61e59e7..4f81447 100644 --- a/packages/editor/src/lib/components/editor/types.ts +++ b/packages/editor/src/lib/types.ts @@ -1,5 +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)", @@ -8,37 +12,58 @@ 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; + /** Stable port identifier. Doubles as the SvelteFlow Handle id and the key edges reference. */ + id: string; + label: string; type: WgslType; /** * If true, every edge connected to this handle is collected. - * The descriptor's wgsl receives `inputs[name]` as an array of upstream vars. + * The descriptor's wgsl receives `inputs[id]` as an array of upstream vars. * For non-variadic, it's always 0 or 1 elements. */ variadic?: boolean; }; export type NodeOutputDef = { - name: string; + /** Stable port identifier. Doubles as the SvelteFlow Handle id and the key edges reference. */ + id: string; + label: string; type: WgslType; }; -export type NodeCategory = "math" | "input" | "output" | "color" | "vector"; +/** Every node that's used by SvelteFlow */ +export type GroupedNode = Node & { + group?: { + definitionRef: string; + locked: boolean; + }; +}; + +export type NodeCategory = "math" | "input" | "misc" | "color" | "vector"; export type WgslCtx = { varName: string; /** - * Map from output name to its WGSL variable name. + * Map from output port id to its WGSL variable name. * For single-output nodes this always has one entry pointing at the same variable as `varName`. * For multi-output nodes each entry has a unique suffix: `{ x: "v_id_x", y: "v_id_y" }`. */ outputVars: Record; data: Record; - /** Resolved upstream variable names per input name. + /** Resolved upstream variable names per input port id. * - 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; }; diff --git a/packages/editor/src/lib/utils.ts b/packages/editor/src/lib/utils.ts new file mode 100644 index 0000000..c37ead4 --- /dev/null +++ b/packages/editor/src/lib/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/xy-theme.css similarity index 90% rename from packages/editor/src/lib/components/editor/xy-theme.css rename to packages/editor/src/lib/xy-theme.css index ee0fb8c..e13c82b 100644 --- a/packages/editor/src/lib/components/editor/xy-theme.css +++ b/packages/editor/src/lib/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,12 +9,16 @@ ============================================= */ .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); } .node-shell__label { - @apply border-b px-3 pt-3 pb-2; + @apply flow-root border-b px-3 pt-3 pb-2; +} + +.node-shell__label > svg { + @apply shrink-0; } .node-shell__body { @@ -48,6 +44,8 @@ .svelte-flow__node-group { @apply border-accent bg-accent/40; + min-width: 150px; + min-height: 100px; } .svelte-flow__handle { 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/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..15ed3f6 --- /dev/null +++ b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte @@ -0,0 +1,27 @@ + + + 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..00d82c0 --- /dev/null +++ b/packages/theme/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte @@ -0,0 +1,27 @@ + + + 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/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..206245f --- /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..3c8ee0c --- /dev/null +++ b/packages/theme/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte @@ -0,0 +1,24 @@ + + +
      + {@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/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 @@ , , etc.) +export { Root as Button, buttonVariants } from "./button/index"; +export { Root as Input } from "./input/index"; +export { Root as Label } from "./label/index"; +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 Input from "./input/index"; -export * as Label from "./label/index"; +export * as Kbd from "./kbd/index"; export * as Menubar from "./menubar/index"; +export * as Popover from "./popover/index"; export * as Resizable from "./resizable/index"; -export * as Scrubbable from "./scrubbable/index"; -export * as Separator from "./separator/index"; +export * as Select from "./select/index"; +export * as Sheet from "./sheet/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/src/lib/index.ts b/packages/theme/src/lib/index.ts new file mode 100644 index 0000000..40227b7 --- /dev/null +++ b/packages/theme/src/lib/index.ts @@ -0,0 +1,2 @@ +export * from "./components/ui"; +export * from "./utils"; 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/**"] 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 } }