diff --git a/app/package.json b/app/package.json index ddcfadf..c509764 100644 --- a/app/package.json +++ b/app/package.json @@ -20,6 +20,7 @@ "dependencies": { "@nodarium/ui": "workspace:*", "@nodarium/utils": "workspace:*", + "@nodarium/planty": "workspace:*", "@sveltejs/kit": "^2.50.2", "@tailwindcss/vite": "^4.1.18", "@threlte/core": "8.3.1", diff --git a/app/src/app.css b/app/src/app.css index 933ccea..d8656cf 100644 --- a/app/src/app.css +++ b/app/src/app.css @@ -1,5 +1,7 @@ @import "tailwindcss"; @source "../../packages/ui/**/*.svelte"; +@source "../../packages/planty/src/lib/**/*.svelte"; + @plugin "@iconify/tailwind4" { prefix: "i"; icon-sets: from-folder("custom", "./src/lib/icons"); diff --git a/app/src/lib/graph-templates/index.ts b/app/src/lib/graph-templates/index.ts index 9984539..fe17eb4 100644 --- a/app/src/lib/graph-templates/index.ts +++ b/app/src/lib/graph-templates/index.ts @@ -6,3 +6,4 @@ export { default as lottaNodes } from './lotta-nodes.json'; export { plant } from './plant'; export { default as simple } from './simple.json'; export { tree } from './tree'; +export { default as tutorial } from './tutorial.json'; diff --git a/app/src/lib/graph-templates/simple.json b/app/src/lib/graph-templates/simple.json index 0834128..7c1cb94 100644 --- a/app/src/lib/graph-templates/simple.json +++ b/app/src/lib/graph-templates/simple.json @@ -3,7 +3,7 @@ "settings": { "resolution.circle": 54, "resolution.curve": 20, - "randomSeed": true + "randomSeed": false }, "meta": { "title": "New Project", @@ -27,9 +27,9 @@ ], "type": "max/plantarium/stem", "props": { - "amount": 50, + "amount": 4, "length": 4, - "thickness": 1 + "thickness": 0.2 } }, { diff --git a/app/src/lib/graph-templates/tutorial.json b/app/src/lib/graph-templates/tutorial.json new file mode 100644 index 0000000..614b2da --- /dev/null +++ b/app/src/lib/graph-templates/tutorial.json @@ -0,0 +1,24 @@ +{ + "id": 0, + "settings": { + "resolution.circle": 54, + "resolution.curve": 20, + "randomSeed": false + }, + "meta": { + "title": "New Project", + "lastModified": "2026-02-03T16:56:40.375Z" + }, + "nodes": [ + { + "id": 9, + "position": [ + 215, + 85 + ], + "type": "max/plantarium/output", + "props": {} + } + ], + "edges": [] +} diff --git a/app/src/lib/settings/NestedSettings.svelte b/app/src/lib/settings/NestedSettings.svelte index aa59a24..af2aa3c 100644 --- a/app/src/lib/settings/NestedSettings.svelte +++ b/app/src/lib/settings/NestedSettings.svelte @@ -28,13 +28,14 @@ key?: string; value: SettingsValue; type: SettingsType; + onButtonClick?: (id: string) => void; depth?: number; }; // Local persistent state for
sections const openSections = localState>('open-details', {}); - let { id, key = '', value = $bindable(), type, depth = 0 }: Props = $props(); + let { id, key = '', value = $bindable(), type, onButtonClick, depth = 0 }: Props = $props(); function isNodeInput(v: SettingsNode | undefined): v is InputType { return !!v && typeof v === 'object' && 'type' in v; @@ -107,11 +108,6 @@ } }); - function handleClick() { - const callback = value[key] as unknown as () => void; - callback(); - } - onMount(() => { open = openSections.value[id]; @@ -130,7 +126,7 @@ {@const inputType = type[key]}
{#if inputType.type === 'button'} - {:else} @@ -143,6 +139,7 @@ {:else if depth === 0} {#each Object.keys(type ?? {}).filter((k) => k !== 'title') as childKey (childKey)} {#each Object.keys(type[key] as SettingsGroup).filter((k) => k !== 'title') as childKey (childKey)} = T extends { type: 'button' } ? () => void - : T extends { value: infer V } ? V extends readonly string[] ? V[number] - : V +type SettingsToStore = T extends { value: infer V } ? V extends readonly string[] ? V[number] + : V : T extends object ? { -readonly [K in keyof T as T[K] extends object ? K : never]: SettingsToStore; } diff --git a/app/src/lib/tutorial/tutorial-config.ts b/app/src/lib/tutorial/tutorial-config.ts new file mode 100644 index 0000000..2c054a3 --- /dev/null +++ b/app/src/lib/tutorial/tutorial-config.ts @@ -0,0 +1,264 @@ +import type { PlantyConfig } from '@nodarium/planty'; + +export const tutorialConfig: PlantyConfig = { + id: 'nodarium-tutorial', + avatar: { + name: 'Planty', + defaultPosition: 'bottom-right' + }, + start: 'intro', + nodes: { + // โ”€โ”€ Entry โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + intro: { + position: 'center', + text: + "# Hi, I'm Planty! ๐ŸŒฑ\nI'll show you around Nodarium โ€” a tool for building 3D plants by connecting nodes together.\nHow much detail do you want?", + choices: [ + { label: '๐ŸŒฑ Show me the basics', next: 'tour_canvas' }, + { label: '๐Ÿค“ I want the technical details', next: 'tour_canvas_nerd' }, + { label: 'Skip the tour for now', next: null } + ] + }, + + // โ”€โ”€ Simple path โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + tour_canvas: { + position: 'bottom-left', + hook: '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' + }, + + tour_viewer: { + position: 'top-left', + 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' + }, + + try_params: { + position: 'bottom-right', + 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', + 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' + }, + + add_stem_node: { + position: 'bottom-right', + 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' + }, + + add_noise_node: { + position: 'bottom-right', + 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' + }, + + add_random_node: { + position: 'bottom-right', + 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' + }, + + prompt_regenerate: { + position: 'bottom-right', + 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' + }, + + tour_sidebar: { + position: 'right', + 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' + }, + + congrats: { + position: 'center', + text: + "# You're all set! ๐ŸŽ‰\nYou know how to build plants, tweak parameters, and save your work.\nWant to explore more?", + choices: [ + { label: '๐Ÿ”— How do node connections work?', next: 'connections_intro' }, + { label: '๐Ÿ’ก Ideas for improving this plant', next: 'improvements_hint' }, + { label: 'โŒจ๏ธ Keyboard shortcuts', next: 'shortcuts_tour' }, + { label: "I'm ready to build!", next: null } + ] + }, + + // โ”€โ”€ Technical / nerd path โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + tour_canvas_nerd: { + position: 'bottom-left', + hook: '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.", + choices: [ + { + label: '๐Ÿ” Explore Node Sourcecode', + onclick: () => { + window.open( + 'https://git.max-richter.dev/max/nodarium/src/branch/main/nodes/max/plantarium', + '__blank' + ); + } + } + ], + waitFor: 'click', + next: 'tour_viewer_nerd' + }, + + tour_viewer_nerd: { + position: 'top-left', + 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', + 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' + }, + + // โ”€โ”€ Deep dives (shared between paths) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + connections_intro: { + 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' + }, + + connections_rules: { + position: 'right', + text: + 'Drag from an output socket to an input socket to connect them.\nโ€ข Types must match (or use `*`)\nโ€ข No circular loops\nโ€ข Optional inputs can stay empty\nInvalid connections snap back automatically.', + choices: [ + { label: '๐Ÿ”ง Node parameters', next: 'params_intro' }, + { label: '๐Ÿ› Debug node', next: 'debug_intro' }, + { label: 'Start building!', next: null } + ] + }, + + params_intro: { + position: 'bottom-right', + 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' + }, + + params_tip: { + position: 'right', + text: + 'Pro tips:\nโ€ข Parameters can be connected from other nodes โ€” drag an edge to the input socket\nโ€ข The **Random Seed** in Graph Settings gives you the same result every run\nโ€ข **f** key smart-connects two selected nodes ยท **Ctrl+Delete** removes a node and restores its edges', + choices: [ + { label: '๐Ÿ”— How connections work', next: 'connections_intro' }, + { label: '๐Ÿ’ก Plant improvement ideas', next: 'improvements_hint' }, + { label: 'Start building!', next: null } + ] + }, + + debug_intro: { + 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' + }, + + debug_done: { + position: 'center', + text: 'The Debug node is your best friend when building complex graphs.\nAnything else?', + choices: [ + { label: '๐Ÿ”— Connection types', next: 'connections_intro' }, + { label: '๐Ÿ”ง Node parameters', next: 'params_intro' }, + { label: 'Start building!', next: null } + ] + }, + + shortcuts_tour: { + 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' + }, + + shortcuts_done: { + position: 'right', + text: + 'All shortcuts are also listed in the sidebar under the โŒจ๏ธ icon.\nReady to build something?', + choices: [ + { label: '๐Ÿ”— Node connections', next: 'connections_intro' }, + { label: '๐Ÿ”ง Parameters', next: 'params_intro' }, + { label: "Let's build! ๐ŸŒฟ", next: null } + ] + }, + + export_tour: { + 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' + }, + + improvements_hint: { + position: 'center', + text: + '# Ideas to grow your plant ๐ŸŒฟ\nโ€ข Add a **Vec3** node โ†’ connect to *origin* on the Stem to spread stems across 3D space\nโ€ข Use a **Random** node on a parameter so each run produces a unique shape\nโ€ข Chain **multiple Stem nodes** with different settings for complex branching\nโ€ข Add a **Gravity** or **Branch** node for even more organic results', + choices: [ + { label: 'โŒจ๏ธ Keyboard shortcuts', next: 'shortcuts_tour' }, + { label: "Let's build! ๐ŸŒฟ", next: null } + ] + } + } +}; diff --git a/app/src/routes/+page.svelte b/app/src/routes/+page.svelte index 515dd8e..c647298 100644 --- a/app/src/routes/+page.svelte +++ b/app/src/routes/+page.svelte @@ -22,13 +22,16 @@ import ExportSettings from '$lib/sidebar/panels/ExportSettings.svelte'; import GraphSource from '$lib/sidebar/panels/GraphSource.svelte'; import Keymap from '$lib/sidebar/panels/Keymap.svelte'; + import { panelState } from '$lib/sidebar/PanelState.svelte'; import Sidebar from '$lib/sidebar/Sidebar.svelte'; + import { tutorialConfig } from '$lib/tutorial/tutorial-config'; + import { Planty } from '@nodarium/planty'; import type { Graph, NodeInstance } from '@nodarium/types'; import { createPerformanceStore } from '@nodarium/utils'; - import { onMount } from 'svelte'; import type { Group } from 'three'; let performanceStore = createPerformanceStore(); + let planty = $state>(); const { data } = $props(); @@ -130,35 +133,65 @@ const handleUpdate = debounceAsyncFunction(update); - onMount(() => { - appSettings.value.debug.stressTest = { - ...appSettings.value.debug.stressTest, - loadGrid: () => { + function handleSettingsButton(id: string) { + switch (id) { + case 'general.clippy': + planty?.start(); + break; + case 'general.debug.stressTest.loadGrid': manager.load( templates.grid( appSettings.value.debug.stressTest.amount, appSettings.value.debug.stressTest.amount ) ); - }, - loadTree: () => { + break; + case 'general.debug.stressTest.loadTree': manager.load(templates.tree(appSettings.value.debug.stressTest.amount)); - }, - lottaFaces: () => { + break; + case 'general.debug.stressTest.lottaFaces': manager.load(templates.lottaFaces as unknown as Graph); - }, - lottaNodes: () => { + break; + case 'general.debug.stressTest.lottaNodes': manager.load(templates.lottaNodes as unknown as Graph); - }, - lottaNodesAndFaces: () => { + break; + case 'general.debug.stressTest.lottaNodesAndFaces': manager.load(templates.lottaNodesAndFaces as unknown as Graph); - } - }; - }); + break; + default: + } + } + { + const ts = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); + pm.handleCreateProject( + structuredClone(templates.defaultPlant) as unknown as Graph, + `Tutorial Project (${ts})` + ); + }, + 'load-tutorial-template': () => { + if (!pm.graph) return; + const g = structuredClone(templates.tutorial) as unknown as Graph; + g.id = pm.graph.id; + g.meta = { ...pm.graph.meta }; + pm.graph = g; + pm.saveGraph(g); + }, + 'before:save_project': () => panelState.setActivePanel('projects'), + 'before:export_tour': () => panelState.setActivePanel('exports'), + 'before:shortcuts_tour': () => panelState.setActivePanel('shortcuts'), + 'after:save_project': () => panelState.setActivePanel('graph-settings'), + 'before:tour_runtime_nerd': () => panelState.setActivePanel('general') + }} +/> +
@@ -192,6 +225,7 @@ @@ -211,13 +245,15 @@ - - - + {#if false} + + + + {/if} header { background-color: var(--color-layer-1); + display: flex; + align-items: center; + padding: 0 8px; + } + + .tutorial-btn { + background: none; + border: none; + cursor: pointer; + font-size: 18px; + padding: 4px 6px; + border-radius: 6px; + opacity: 0.7; + transition: opacity 0.15s, background 0.15s; + } + + .tutorial-btn:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.08); } .wrapper { @@ -281,7 +336,7 @@ width: 100vw; color: white; display: grid; - grid-template-rows: 0px 1fr; + grid-template-rows: 36px 1fr; } .wrapper :global(canvas) { diff --git a/app/vite.config.ts b/app/vite.config.ts index 2ad3ab2..4685f32 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -4,6 +4,7 @@ import { playwright } from '@vitest/browser-playwright'; import comlink from 'vite-plugin-comlink'; import glsl from 'vite-plugin-glsl'; import wasm from 'vite-plugin-wasm'; +import path from 'path'; import { defineConfig } from 'vitest/config'; export default defineConfig({ @@ -19,6 +20,11 @@ export default defineConfig({ comlink() ] }, + resolve: { + alias: { + '@nodarium/planty': path.resolve(__dirname, '../packages/planty/src/lib/index.ts') + } + }, ssr: { noExternal: ['three'] },