diff --git a/app/src/lib/graph-interface/graph-state.svelte.ts b/app/src/lib/graph-interface/graph-state.svelte.ts index 50018d0..7127bb1 100644 --- a/app/src/lib/graph-interface/graph-state.svelte.ts +++ b/app/src/lib/graph-interface/graph-state.svelte.ts @@ -1,3 +1,4 @@ +import { animate, lerp } from '$lib/helpers'; import type { NodeInstance, Socket } from '@nodarium/types'; import { getContext, setContext } from 'svelte'; import { SvelteMap, SvelteSet } from 'svelte/reactivity'; @@ -124,6 +125,9 @@ export class GraphState { activeNodeId = $state(-1); selectedNodes = new SvelteSet(); activeSocket = $state(null); + safePadding = $state<{ left?: number; right?: number; bottom?: number; top?: number } | null>( + null + ); hoveredSocket = $state(null); possibleSockets = $state([]); possibleSocketIds = $derived( @@ -236,6 +240,37 @@ export class GraphState { }; } + centerNode(node?: NodeInstance) { + const average = [0, 0, 4]; + if (node) { + average[0] = node.position[0] + (this.safePadding?.right || 0) / 10; + average[1] = node.position[1]; + average[2] = 10; + } else { + for (const node of this.graph.nodes.values()) { + average[0] += node.position[0]; + average[1] += node.position[1]; + } + average[0] = (average[0] / this.graph.nodes.size) + + (this.safePadding?.right || 0) / (average[2] * 2); + average[1] /= this.graph.nodes.size; + } + + const camX = this.cameraPosition[0]; + const camY = this.cameraPosition[1]; + const camZ = this.cameraPosition[2]; + + const ease = (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t); + const easeZoom = (t: number) => t * t * (3 - 2 * t); + + animate(500, (a: number) => { + this.cameraPosition[0] = lerp(camX, average[0], ease(a)); + this.cameraPosition[1] = lerp(camY, average[1], ease(a)); + this.cameraPosition[2] = lerp(camZ, average[2], easeZoom(a)); + if (this.mouseDown) return false; + }); + } + pasteNodes() { if (!this.clipboard) return; diff --git a/app/src/lib/graph-interface/graph/Graph.svelte b/app/src/lib/graph-interface/graph/Graph.svelte index 2ecaea5..2460265 100644 --- a/app/src/lib/graph-interface/graph/Graph.svelte +++ b/app/src/lib/graph-interface/graph/Graph.svelte @@ -19,10 +19,10 @@ const { keymap, - addMenuPadding + safePadding }: { keymap: ReturnType; - addMenuPadding?: { left?: number; right?: number; bottom?: number; top?: number }; + safePadding?: { left?: number; right?: number; bottom?: number; top?: number }; } = $props(); const graph = getGraphManager(); @@ -172,10 +172,10 @@ {#if graphState.addMenuPosition} {/if} diff --git a/app/src/lib/graph-interface/graph/Wrapper.svelte b/app/src/lib/graph-interface/graph/Wrapper.svelte index 73a08ac..9e13f91 100644 --- a/app/src/lib/graph-interface/graph/Wrapper.svelte +++ b/app/src/lib/graph-interface/graph/Wrapper.svelte @@ -18,7 +18,7 @@ showHelp?: boolean; settingTypes?: Record; - addMenuPadding?: { left?: number; right?: number; bottom?: number; top?: number }; + safePadding?: { left?: number; right?: number; bottom?: number; top?: number }; onsave?: (save: Graph) => void; onresult?: (result: unknown) => void; @@ -27,7 +27,7 @@ let { graph, registry, - addMenuPadding, + safePadding, settings = $bindable(), activeNode = $bindable(), backgroundType = $bindable('grid'), @@ -44,29 +44,32 @@ export const manager = new GraphManager(registry); setGraphManager(manager); - const graphState = new GraphState(manager); + export const state = new GraphState(manager); $effect(() => { - graphState.backgroundType = backgroundType; - graphState.snapToGrid = snapToGrid; - graphState.showHelp = showHelp; + if (safePadding) { + state.safePadding = safePadding; + } + state.backgroundType = backgroundType; + state.snapToGrid = snapToGrid; + state.showHelp = showHelp; }); - setGraphState(graphState); + setGraphState(state); - setupKeymaps(keymap, manager, graphState); + setupKeymaps(keymap, manager, state); $effect(() => { - if (graphState.activeNodeId !== -1) { - activeNode = manager.getNode(graphState.activeNodeId); + if (state.activeNodeId !== -1) { + activeNode = manager.getNode(state.activeNodeId); } else if (activeNode) { activeNode = undefined; } }); $effect(() => { - if (!graphState.addMenuPosition) { - graphState.edgeEndPosition = null; - graphState.activeSocket = null; + if (!state.addMenuPosition) { + state.edgeEndPosition = null; + state.activeSocket = null; } }); @@ -86,4 +89,4 @@ }); - + diff --git a/app/src/lib/graph-interface/keymaps.ts b/app/src/lib/graph-interface/keymaps.ts index 027c4d1..c10f79a 100644 --- a/app/src/lib/graph-interface/keymaps.ts +++ b/app/src/lib/graph-interface/keymaps.ts @@ -67,28 +67,7 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr description: 'Center camera', callback: () => { if (!graphState.isBodyFocused()) return; - - const average = [0, 0]; - for (const node of graph.nodes.values()) { - average[0] += node.position[0]; - average[1] += node.position[1]; - } - average[0] = (average[0] / graph.nodes.size) + 10; - average[1] /= graph.nodes.size; - - const camX = graphState.cameraPosition[0]; - const camY = graphState.cameraPosition[1]; - const camZ = graphState.cameraPosition[2]; - - const ease = (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t); - const easeZoom = (t: number) => t * t * (3 - 2 * t); - - animate(500, (a: number) => { - graphState.cameraPosition[0] = lerp(camX, average[0], ease(a)); - graphState.cameraPosition[1] = lerp(camY, average[1], ease(a)); - graphState.cameraPosition[2] = lerp(camZ, 4, easeZoom(a)); - if (graphState.mouseDown) return false; - }); + graphState.centerNode(graph.getNode(graphState.activeNodeId)); } }); diff --git a/app/src/lib/result-viewer/Viewer.svelte b/app/src/lib/result-viewer/Viewer.svelte index c7a370d..a5c578f 100644 --- a/app/src/lib/result-viewer/Viewer.svelte +++ b/app/src/lib/result-viewer/Viewer.svelte @@ -24,6 +24,10 @@ let geometryPool: ReturnType; let instancePool: ReturnType; + export function invalidate() { + sceneComponent?.invalidate(); + } + export function updateGeometries(inputs: Int32Array[], group: Group) { geometryPool = geometryPool || createGeometryPool(group, material); instancePool = instancePool || createInstancedGeometryPool(group, material); diff --git a/app/src/lib/tutorial/tutorial-config.ts b/app/src/lib/tutorial/tutorial-config.ts index 2c054a3..e930e4c 100644 --- a/app/src/lib/tutorial/tutorial-config.ts +++ b/app/src/lib/tutorial/tutorial-config.ts @@ -24,11 +24,10 @@ export const tutorialConfig: PlantyConfig = { tour_canvas: { position: 'bottom-left', - hook: 'setup-default', + action: 'setup-default', highlight: { selector: '.graph-wrapper', padding: 12 }, text: 'This is the **graph canvas**. Nodes connect together to build a plant — the 3D model updates automatically whenever you make a change.\nEach node does one specific job: grow stems, add noise, produce output.', - waitFor: 'click', next: 'tour_viewer' }, @@ -37,7 +36,6 @@ export const tutorialConfig: PlantyConfig = { highlight: { selector: '.cell:first-child', padding: 8 }, text: 'This is the **3D viewer** — the live preview of your plant.\nLeft-click to rotate · right-click to pan · scroll to zoom.', - waitFor: 'click', next: 'try_params' }, @@ -46,16 +44,14 @@ export const tutorialConfig: PlantyConfig = { highlight: { selector: '.graph-wrapper', padding: 12 }, text: 'Click a node to select it. Its settings appear **directly on the node** — try changing a value and watch the plant update.\nThe sidebar shows extra hidden settings for the selected node.', - waitFor: 'click', next: 'start_building' }, start_building: { position: 'center', - hook: 'load-tutorial-template', + action: 'load-tutorial-template', text: "Now let's build your own plant from scratch!\nI've loaded a blank project — it only has an **Output** node. Your goal: connect nodes to make a plant.", - waitFor: 'click', next: 'add_stem_node' }, @@ -64,7 +60,6 @@ export const tutorialConfig: PlantyConfig = { highlight: { selector: '.graph-wrapper', padding: 12 }, text: "Open the **Add Menu** with **Shift+A** or **right-click** on the canvas.\nAdd a **Stem** node, then connect its output socket to the Output node's input.", - waitFor: 'click', next: 'add_noise_node' }, @@ -73,7 +68,6 @@ export const tutorialConfig: PlantyConfig = { highlight: { selector: '.graph-wrapper', padding: 12 }, text: 'Add a **Noise** node the same way.\nConnect: Stem → Noise input, then Noise output → Output.\nThis makes the stems grow in organic, curved shapes.', - waitFor: 'click', next: 'add_random_node' }, @@ -82,7 +76,6 @@ export const tutorialConfig: PlantyConfig = { highlight: { selector: '.graph-wrapper', padding: 12 }, text: "Let's add some randomness! Add a **Random** node and connect its output to the **thickness** or **length** input of the Stem node.\nThe default min/max range is small — **Ctrl+drag** any number field to exceed its normal limits.", - waitFor: 'click', next: 'prompt_regenerate' }, @@ -91,7 +84,6 @@ export const tutorialConfig: PlantyConfig = { highlight: { selector: '.graph-wrapper', padding: 12 }, text: 'Now press **R** to regenerate. Each press gives the Random node a new value — your plant changes every run!', - waitFor: 'click', next: 'tour_sidebar' }, @@ -100,16 +92,13 @@ export const tutorialConfig: PlantyConfig = { highlight: { selector: '.tabs', padding: 4 }, text: 'The **sidebar** holds all your tools:\n⚙️ Settings · ⌨️ Shortcuts · 📦 Export · 📁 Projects · 📊 Graph Settings\nEnable **Advanced Mode** in Settings to unlock performance and benchmark panels.', - waitFor: 'click', next: 'save_project' }, save_project: { position: 'right', - highlight: { selector: '.tabs', padding: 4 }, text: 'Your work is saved in the **Projects panel** — rename it, create new projects, or switch between them anytime.', - waitFor: 'click', next: 'congrats' }, @@ -129,7 +118,7 @@ export const tutorialConfig: PlantyConfig = { tour_canvas_nerd: { position: 'bottom-left', - hook: 'setup-default', + action: 'setup-default', highlight: { selector: '.graph-wrapper', padding: 12 }, text: "The **graph canvas** renders a directed acyclic graph. Each node is an individual **WASM module** executed in isolation — inputs in, output out. The 3D model updates automatically on every change.\nI've loaded a starter graph so you can see it in action.", @@ -144,7 +133,6 @@ export const tutorialConfig: PlantyConfig = { } } ], - waitFor: 'click', next: 'tour_viewer_nerd' }, @@ -153,15 +141,13 @@ export const tutorialConfig: PlantyConfig = { highlight: { selector: '.cell:first-child', padding: 8 }, text: 'The **3D viewer** uses `@threlte/core` (Svelte + Three.js). Mesh data streams from WASM execution results. OrbitControls: left-drag rotate, right-drag pan, scroll zoom.', - waitFor: 'click', next: 'tour_runtime_nerd' }, tour_runtime_nerd: { - position: 'right', + position: 'bottom-right', text: 'By default, nodes execute in a **WebWorker** for better performance. You can switch to main-thread execution by disabling **Debug → Execute in WebWorker** in Settings.\nEnable **Advanced Mode** to unlock the Performance and Benchmark panels.', - waitFor: 'click', next: 'start_building' }, @@ -171,7 +157,6 @@ export const tutorialConfig: PlantyConfig = { position: 'bottom-right', text: 'Node sockets are **type-checked**. The coloured dots tell you what kind of data flows through:\n🔵 `number` · 🟢 `vec3` · 🟣 `shape` · ⚪ `*` (wildcard)', - waitFor: 'click', next: 'connections_rules' }, @@ -191,7 +176,6 @@ export const tutorialConfig: PlantyConfig = { highlight: { selector: '.graph-wrapper', padding: 12 }, text: 'Click any node to select it. Basic settings are shown **on the node itself**.\nThe sidebar under *Graph Settings → Active Node* shows the full list:\n**Number** — drag or type · **Vec3** — X/Y/Z · **Select** — dropdown · **Color** — picker', - waitFor: 'click', next: 'params_tip' }, @@ -210,7 +194,6 @@ export const tutorialConfig: PlantyConfig = { position: 'bottom-right', text: 'Add a **Debug node** from the Add Menu (Shift+A or right-click). It accepts `*` wildcard inputs — connect any socket to inspect the data flowing through.\nEnable **Advanced Mode** in Settings to also see Performance and Graph Source panels.', - waitFor: 'click', next: 'debug_done' }, @@ -228,7 +211,6 @@ export const tutorialConfig: PlantyConfig = { position: 'bottom-right', text: '**Essential shortcuts:**\n`R` — Regenerate\n`Shift+A` / right-click — Add node\n`f` — Smart-connect selected nodes\n`.` — Center camera\n`Ctrl+Z` / `Ctrl+Y` — Undo / Redo\n`Delete` — Remove selected · `Ctrl+Delete` — Remove and restore edges', - waitFor: 'click', next: 'shortcuts_done' }, @@ -247,7 +229,6 @@ export const tutorialConfig: PlantyConfig = { position: 'right', text: 'Export your 3D model from the **📦 Export** panel:\n**GLB** — standard for 3D apps (Blender, Three.js)\n**OBJ** — legacy format · **STL** — 3D printing · **PNG** — screenshot', - waitFor: 'click', next: 'congrats' }, diff --git a/app/src/routes/+page.svelte b/app/src/routes/+page.svelte index 5fbd16f..afc710b 100644 --- a/app/src/routes/+page.svelte +++ b/app/src/routes/+page.svelte @@ -168,7 +168,7 @@ { const ts = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); pm.handleCreateProject( @@ -183,6 +183,48 @@ g.meta = { ...pm.graph.meta }; pm.graph = g; pm.saveGraph(g); + graphInterface.state.centerNode(graphInterface.manager.getAllNodes()[0]); + } + }} + hooks={{ + 'action:add_stem_node': (cb) => { + const unsub = manager.on('save', () => { + const allNodes = graphInterface.manager.getAllNodes(); + const stemNode = allNodes.find(n => n.type === 'max/plantarium/stem'); + if (stemNode && graphInterface.manager.edges.length) { + unsub(); + (cb as () => void)(); + } + }); + }, + 'action:add_noise_node': (cb) => { + const unsub = manager.on('save', () => { + const allNodes = graphInterface.manager.getAllNodes(); + const noiseNode = allNodes.find(n => n.type === 'max/plantarium/noise'); + if (noiseNode && graphInterface.manager.edges.length > 1) { + unsub(); + (cb as () => void)(); + } + }); + }, + 'action:add_random_node': (cb) => { + const unsub = manager.on('save', () => { + const allNodes = graphInterface.manager.getAllNodes(); + const noiseNode = allNodes.find(n => n.type === 'max/plantarium/random'); + if (noiseNode && graphInterface.manager.edges.length > 2) { + unsub(); + (cb as () => void)(); + } + }); + }, + 'action:prompt_regenerate': (cb) => { + function handleKeydown(e: KeyboardEvent) { + if (e.key === 'r') { + window.removeEventListener('keydown', handleKeydown); + (cb as () => void)(); + } + } + window.addEventListener('keydown', handleKeydown); }, 'before:save_project': () => panelState.setActivePanel('projects'), 'before:export_tour': () => panelState.setActivePanel('exports'), @@ -210,7 +252,7 @@ graph={pm.graph} bind:this={graphInterface} registry={nodeRegistry} - addMenuPadding={{ right: sidebarOpen ? 330 : undefined }} + safePadding={{ right: sidebarOpen ? 330 : undefined }} backgroundType={appSettings.value.nodeInterface.backgroundType} snapToGrid={appSettings.value.nodeInterface.snapToGrid} bind:activeNode @@ -245,7 +287,7 @@ - {#if false} + {#if 0 > 1} ; + actions?: Record; onStepChange?: (nodeId: string, node: DialogNode) => void; onComplete?: () => void; } - let { config, hooks = {}, onStepChange, onComplete }: Props = $props(); + let { config, actions = {}, hooks = {}, onStepChange, onComplete }: Props = $props(); const AVATAR_SIZE = 80; const SCREEN_PADDING = 20; @@ -22,6 +23,7 @@ let isActive = $state(false); let currentNodeId = $state(null); let bubbleVisible = $state(false); + let avatar = $state(null!); let avatarX = $state(0); let avatarY = $state(0); let mood = $state('idle'); @@ -30,6 +32,9 @@ // ── Derived ────────────────────────────────────────────────────────── const runner = $derived(new DialogRunner(config)); + const nextNode = $derived( + runner.getNextNode(currentNodeId ?? '') + ); const mainPath = $derived(runner.getMainPath()); const currentNode = $derived( currentNodeId ? runner.getNode(currentNodeId) : null @@ -125,32 +130,31 @@ if (node.position) { mood = 'moving'; const pos = resolvePosition(node.position); + const hasChanges = pos.x !== avatarX || pos.y !== avatarY; avatarX = pos.x; avatarY = pos.y; - await _wait(900); + if (hasChanges) await _wait(900); } mood = 'talking'; bubbleVisible = true; // App hook - if (node.hook && hooks[node.hook]) { - const result = await hooks[node.hook](...(node.hookArgs ?? [])); + if (node.action && actions[node.action]) { + const result = await actions[node.action](); if (typeof result === 'function') actionCleanup = result as () => void; } - // Auto-advance - if (typeof node.waitFor === 'number') { - autoAdvanceTimer = setTimeout(() => next(), node.waitFor); - } - if (node.waitFor === 'action') { - const actionHook = hooks[`action:${id}`]; - if (actionHook) { - const advance = () => next(); - const result = await actionHook(advance, ...(node.hookArgs ?? [])); - if (typeof result === 'function') actionCleanup = result as () => void; - } + const actionHook = hooks[`action:${id}`]; + if (actionHook) { + const advance = () => { + avatar.flash('happy', 2000); + next(); + }; + const result = await actionHook(advance); + if (typeof result === 'function') actionCleanup = result as () => void; } + if (!node.choices && !node.next) { setTimeout(() => stop(), 3000); } @@ -176,11 +180,12 @@ {#if isActive}
+ {currentNodeId} {#if highlight} {/if} - + {#if showBubble && currentNode} (mood = prev), duration); + } + function pupilOffset(cx: number, cy: number, eyeSvgX: number, eyeSvgY: number, maxPx = 2.8) { const ex = x + eyeSvgX; const ey = y + eyeSvgY; diff --git a/packages/planty/src/lib/types.ts b/packages/planty/src/lib/types.ts index 8bda752..d23405c 100644 --- a/packages/planty/src/lib/types.ts +++ b/packages/planty/src/lib/types.ts @@ -22,12 +22,9 @@ export interface DialogNode { position?: AvatarPosition; highlight?: HighlightTarget; /** App hook to call on entering this node */ - hook?: string; - hookArgs?: unknown[]; + action?: string; next?: string | null; choices?: Choice[]; - /** 'click' = wait for user click, number = auto-advance after N ms, 'action' = wait for hook to call advance() */ - waitFor?: 'click' | 'action' | number; /** Called (and awaited) just before the avatar starts moving to this node */ before?: StepCallback; /** Called (and awaited) just before the user leaves this node */ diff --git a/packages/planty/src/routes/+layout.svelte b/packages/planty/src/routes/+layout.svelte index 5d63a24..724301d 100644 --- a/packages/planty/src/routes/+layout.svelte +++ b/packages/planty/src/routes/+layout.svelte @@ -1,7 +1,6 @@ diff --git a/packages/planty/static/demo-tutorial.json b/packages/planty/static/demo-tutorial.json index 74cf6ff..ea0f6dc 100644 --- a/packages/planty/static/demo-tutorial.json +++ b/packages/planty/static/demo-tutorial.json @@ -21,14 +21,12 @@ "type": "step", "position": "bottom-right", "text": "This is a WebAssembly-powered node graph. Each node is a compiled .wasm module executed in a sandboxed runtime.", - "waitFor": "click", "next": "highlight_graph_nerd" }, "intro_simple": { "type": "step", "position": "bottom-right", "text": "Think of this like a recipe card — each block does one thing, and you connect them to build a plant!", - "waitFor": "click", "next": "highlight_graph_simple" }, @@ -37,7 +35,6 @@ "position": "bottom-left", "highlight": { "selector": "#graph-canvas", "padding": 12 }, "text": "The graph canvas renders edges as Bézier curves. Node execution is topologically sorted before each WASM call.", - "waitFor": "click", "next": "highlight_sidebar_nerd" }, "highlight_graph_simple": { @@ -45,7 +42,6 @@ "position": "bottom-left", "highlight": { "selector": "#graph-canvas", "padding": 12 }, "text": "This is the main canvas — drag nodes around and connect them to create your plant!", - "waitFor": "click", "next": "highlight_sidebar_simple" }, @@ -54,7 +50,6 @@ "position": "bottom-right", "highlight": { "selector": "#sidebar", "padding": 8 }, "text": "The sidebar exposes node parameters, export settings, and the raw graph JSON for debugging.", - "waitFor": "click", "next": "tip_nerd" }, "highlight_sidebar_simple": { @@ -62,7 +57,6 @@ "position": "bottom-right", "highlight": { "selector": "#sidebar", "padding": 8 }, "text": "The sidebar lets you tweak settings and export your creation.", - "waitFor": "click", "next": "tip_simple" }, @@ -70,14 +64,12 @@ "type": "step", "position": "center", "text": "Press Space or double-click the canvas to open node search. Nodes are fetched from the WASM registry at runtime.", - "waitFor": "click", "next": "done_nerd" }, "tip_simple": { "type": "step", "position": "center", "text": "Press Space anywhere on the canvas to add a new block — try it!", - "waitFor": "click", "next": "done_simple" },