Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
82c2f08a56
|
|||
|
a00db400bb
|
|||
|
2d9eb0c087
|
|||
|
1e28ded99b
|
|||
|
5fae518392
|
|||
| 954f5726c3 | |||
|
63d5b8079d
|
|||
|
3e32ca419a
|
|||
|
f0cb12a088
|
|||
|
1d60090ffe
|
|||
|
5b55056fc1
|
|||
|
e2c2b1a4d7
|
|||
|
7f082ad8f6
|
|||
|
ed11195327
|
|||
|
8ad62cfc8e
|
|||
|
bff140a764
|
|||
|
85e2fd1a71
|
|||
|
5beb03196d
|
|||
|
83e0e47082
|
|||
|
106797de32
|
|||
|
1a56ba986d
|
|||
|
703f531cd3
|
|||
|
0ed22f20b9
|
|||
|
733b0a2ceb
|
|||
|
8f60816c78
|
|||
|
cd7b51d86a
|
|||
|
6c9cd1505d
|
|||
|
db5ee8ba29
|
|||
|
a6b9ca4315
|
|||
|
d4910aba8c
|
|||
|
e695c76490
|
|||
|
2a54fa7590
|
|||
|
6d5cac65e8
|
|||
|
3ee074b11c
|
|||
|
59a1e63396
|
|||
|
317d1552ce
|
|||
|
78439b19e9
|
|||
|
ef217b1c40
|
|||
|
7499b80789
|
|||
|
a5b663f6fc
|
|||
|
05506704bf
|
|||
|
63188e57fd
|
|||
|
4572d30005
|
|||
|
ccc376d158
|
|||
|
7e432e9033
|
|||
|
01f58377c2
|
|||
|
6ef5dc28ed
|
|||
|
3450d70047
|
|||
|
731b9e9b1e
|
|||
|
72f07d0a50
|
|||
|
a56e8f445e
|
|||
|
12572742eb
|
|||
|
7aa9979e35
|
|||
|
fc35a68826
|
|||
|
aba6f03bcc
|
|||
|
2d6fd00fd1
|
|||
|
d231946e50
|
|||
|
e2f4a24f75
|
|||
|
58d39cd101
|
|||
|
7ebb1297ac
|
|||
|
23f65a1c63
|
|||
|
acdc582e95
|
|||
|
7a3e9eb893
|
|||
|
be82312ea0
|
|||
|
84f67e9c33
|
|||
|
491e345c2f
|
|||
|
ba501b211d
|
|||
|
7d76b9e1f7
|
|||
|
5d4e2e9280
|
|||
|
4de15b19c8
|
|||
|
168e6fcc19
|
|||
|
c0eb75d53c
|
|||
|
2ec9bfc3c9
|
|||
|
c97520617a
|
|||
|
6475790176
|
|||
|
580ec73465
|
|||
|
fd98d457a3
|
@@ -0,0 +1,28 @@
|
|||||||
|
name: Setup
|
||||||
|
description: Restore caches and install pnpm dependencies (run after checkout)
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: 💾 Setup pnpm Cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .pnpm-store
|
||||||
|
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-
|
||||||
|
|
||||||
|
- name: 🦀 Cache Cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: 📦 Install Dependencies
|
||||||
|
shell: bash
|
||||||
|
run: pnpm install --frozen-lockfile --store-dir .pnpm-store
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
name: 📊 Benchmark the Runtime
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["*"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["*"]
|
||||||
|
|
||||||
|
env:
|
||||||
|
PNPM_CACHE_FOLDER: .pnpm-store
|
||||||
|
CARGO_HOME: .cargo
|
||||||
|
CARGO_TARGET_DIR: target
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
benchmark:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📑 Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
|
- name: 🔧 Setup
|
||||||
|
uses: ./.gitea/actions/setup
|
||||||
|
|
||||||
|
- name: 🛠️ Build Nodes
|
||||||
|
run: pnpm build:nodes
|
||||||
|
|
||||||
|
- name: 🏃 Execute Runtime
|
||||||
|
run: pnpm run --filter @nodarium/app bench
|
||||||
|
|
||||||
|
- name: 🔑 Setup SSH key
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.GIT_SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
cat >> ~/.ssh/config <<'EOF'
|
||||||
|
Host git.max-richter.dev
|
||||||
|
Port 2222
|
||||||
|
IdentityFile ~/.ssh/id_ed25519
|
||||||
|
IdentitiesOnly yes
|
||||||
|
EOF
|
||||||
|
ssh-keyscan -p 2222 -H git.max-richter.dev >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: 📤 Push Results
|
||||||
|
env:
|
||||||
|
BENCH_REPO: "git@git.max-richter.dev:max/nodarium-benchmarks.git"
|
||||||
|
run: |
|
||||||
|
git config --global user.name "nodarium-bot"
|
||||||
|
git config --global user.email "nodarium-bot@max-richter.dev"
|
||||||
|
|
||||||
|
git clone git@git.max-richter.dev:max/nodarium-benchmarks.git target_bench_repo
|
||||||
|
|
||||||
|
BRANCH="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}"
|
||||||
|
SAFE_PR_NAME=$(printf "%s" "$BRANCH" | tr '/' '-')
|
||||||
|
DEST_DIR="target_bench_repo/data/$SAFE_PR_NAME/$(date +%s)"
|
||||||
|
mkdir -p "$DEST_DIR"
|
||||||
|
|
||||||
|
cp app/benchmark/out/*.json "$DEST_DIR/"
|
||||||
|
|
||||||
|
cd target_bench_repo
|
||||||
|
git add .
|
||||||
|
git commit -m "Update benchmarks for $SAFE_PR_NAME: ${{ gitea.sha }}"
|
||||||
|
git push origin main
|
||||||
@@ -13,9 +13,9 @@ env:
|
|||||||
CARGO_TARGET_DIR: target
|
CARGO_TARGET_DIR: target
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
quality:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: git.max-richter.dev/max/nodarium-ci:bce06da456e3c008851ac006033cfff256015a47
|
container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📑 Checkout Code
|
- name: 📑 Checkout Code
|
||||||
@@ -24,27 +24,8 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
- name: 💾 Setup pnpm Cache
|
- name: 🔧 Setup
|
||||||
uses: actions/cache@v4
|
uses: ./.gitea/actions/setup
|
||||||
with:
|
|
||||||
path: ${{ env.PNPM_CACHE_FOLDER }}
|
|
||||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-pnpm-
|
|
||||||
|
|
||||||
- name: 🦀 Cache Cargo
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry
|
|
||||||
~/.cargo/git
|
|
||||||
target
|
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-
|
|
||||||
|
|
||||||
- name: 📦 Install Dependencies
|
|
||||||
run: pnpm install --frozen-lockfile --store-dir ${{ env.PNPM_CACHE_FOLDER }}
|
|
||||||
|
|
||||||
- name: 🧹 Quality Control
|
- name: 🧹 Quality Control
|
||||||
run: |
|
run: |
|
||||||
@@ -52,7 +33,61 @@ jobs:
|
|||||||
pnpm format:check
|
pnpm format:check
|
||||||
pnpm check
|
pnpm check
|
||||||
pnpm build
|
pnpm build
|
||||||
xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" pnpm test
|
|
||||||
|
test-unit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📑 Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
|
- name: 🔧 Setup
|
||||||
|
uses: ./.gitea/actions/setup
|
||||||
|
|
||||||
|
- name: 🧪 Run Tests
|
||||||
|
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" pnpm test:unit
|
||||||
|
|
||||||
|
test-e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📑 Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
|
- name: 🔧 Setup
|
||||||
|
uses: ./.gitea/actions/setup
|
||||||
|
|
||||||
|
- name: 🏗️ Build Web Assets
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: 🧪 Run Tests
|
||||||
|
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" pnpm test:e2e
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [quality, test-e2e, test-unit]
|
||||||
|
container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📑 Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
|
- name: 🔧 Setup
|
||||||
|
uses: ./.gitea/actions/setup
|
||||||
|
|
||||||
|
- name: 🏗️ Build Web Assets
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
- name: 🚀 Create Release Commit
|
- name: 🚀 Create Release Commit
|
||||||
if: gitea.ref_type == 'tag'
|
if: gitea.ref_type == 'tag'
|
||||||
|
|||||||
@@ -1,3 +1,68 @@
|
|||||||
|
# v0.0.5 (2026-02-13)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Implement debug node with full runtime integration, wildcard (`*`) inputs, variable-height nodes and parameters, and a quick-connect shortcut.
|
||||||
|
- Add color-coded node sockets and edges to visually indicate data types.
|
||||||
|
- Recursively merge `localState` with the initial state to safely handle outdated settings stored in `localStorage` when the settings schema changes.
|
||||||
|
- Clamp the Add Menu to the viewport.
|
||||||
|
- Add application favicon.
|
||||||
|
- Consolidate all developer settings into a single **Advanced Mode** setting.
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Fix InputNumber arrow visibility in the light theme.
|
||||||
|
- Correct changelog formatting issues.
|
||||||
|
|
||||||
|
## Chores
|
||||||
|
|
||||||
|
- Add `pnpm qa` pre-commit command.
|
||||||
|
- Run linting and type checks before build in CI.
|
||||||
|
- Sign release commits with a PGP key.
|
||||||
|
- General formatting, lint/type cleanup, test snapshot updates, and `.gitignore` maintenance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [f16ba26](https://git.max-richter.dev/max/nodarium/commit/f16ba2601ff0e8f0f4454e24689499112a2a257a) fix(ci): still trying to get gpg to work
|
||||||
|
- [cc6b832](https://git.max-richter.dev/max/nodarium/commit/cc6b832f1576356e5453ee4289b02f854152ff9a) fix(ci): trying to get gpg to work
|
||||||
|
- [dd5fd5b](https://git.max-richter.dev/max/nodarium/commit/dd5fd5bf1715d371566bd40419b72ca05e63401e) fix(ci): better add updates to package.json
|
||||||
|
- [38d0fff](https://git.max-richter.dev/max/nodarium/commit/38d0fffcf4ca0a346857c3658ccefdfcdf16e217) chore: update ci image
|
||||||
|
- [bce06da](https://git.max-richter.dev/max/nodarium/commit/bce06da456e3c008851ac006033cfff256015a47) ci: add gpg-agent to ci image
|
||||||
|
- [af585d5](https://git.max-richter.dev/max/nodarium/commit/af585d56ec825662961c8796226ed9d8cb900795) feat: use new ci image with gpg
|
||||||
|
- [0aa73a2](https://git.max-richter.dev/max/nodarium/commit/0aa73a27c1f23bea177ecc66034f8e0384c29a8e) feat: install gpg in ci image
|
||||||
|
- [c1ae702](https://git.max-richter.dev/max/nodarium/commit/c1ae70282cb5d58527180614a80823d80ca478c5) feat: add color to sockets
|
||||||
|
- [4c7b03d](https://git.max-richter.dev/max/nodarium/commit/4c7b03dfb82174317d8ba01f4725af804201154d) feat: add gradient mesh line
|
||||||
|
- [144e8cc](https://git.max-richter.dev/max/nodarium/commit/144e8cc797cfcc5a7a1fd9a0a2098dc99afb6170) fix: correctly highlight possible outputs
|
||||||
|
- [12ff9c1](https://git.max-richter.dev/max/nodarium/commit/12ff9c151873d253ed2e54dcf56aa9c9c4716c7c) Merge pull request 'feat/debug-node' (#41) from feat/debug-node into main
|
||||||
|
- [8d3ffe8](https://git.max-richter.dev/max/nodarium/commit/8d3ffe84ab9ca9e6d6d28333752e34da878fd3ea) Merge branch 'main' into feat/debug-node
|
||||||
|
- [95ec93e](https://git.max-richter.dev/max/nodarium/commit/95ec93eeada9bf062e01e1e77b67b8f0343a51bf) feat: better handle ctrl+shift clicks and selections
|
||||||
|
- [d39185e](https://git.max-richter.dev/max/nodarium/commit/d39185efafc360f49ab9437c0bad1f64665df167) feat: add "pnpm qa" command to check before commit
|
||||||
|
- [81580cc](https://git.max-richter.dev/max/nodarium/commit/81580ccd8c1db30ce83433c4c4df84bd660d3460) fix: cleanup some type errors
|
||||||
|
- [bf6f632](https://git.max-richter.dev/max/nodarium/commit/bf6f632d2772c3da812d5864c401f17e1aa8666a) feat: add shortcut to quick connect to debug
|
||||||
|
- [e098be6](https://git.max-richter.dev/max/nodarium/commit/e098be60135f57cf863904a58489e032ed27e8b4) fix: also execute all nodes before debug node
|
||||||
|
- [ec13850](https://git.max-richter.dev/max/nodarium/commit/ec13850e1c0ca5846da614d25887ff492cf8be04) fix: make debug node work with runtime
|
||||||
|
- [15e08a8](https://git.max-richter.dev/max/nodarium/commit/15e08a816339bdf9de9ecb6a57a7defff42dbe8c) feat: implement debug node
|
||||||
|
- [48cee58](https://git.max-richter.dev/max/nodarium/commit/48cee58ad337c1c6c59a0eb55bf9b5ecd16b99d0) chore: update test snapshots
|
||||||
|
- [3235cae](https://git.max-richter.dev/max/nodarium/commit/3235cae9049e193c242b6091cee9f01e67ee850e) chore: fix lint and typecheck errors
|
||||||
|
- [3f44072](https://git.max-richter.dev/max/nodarium/commit/3f440728fc8a94d59022bb545f418be049a1f1ba) feat: implement variable height for node shader
|
||||||
|
- [da09f8b](https://git.max-richter.dev/max/nodarium/commit/da09f8ba1eda5ed347433d37064a3b4ab49e627e) refactor: move debug node into runtime
|
||||||
|
- [ddc3b4c](https://git.max-richter.dev/max/nodarium/commit/ddc3b4ce357ef1c1e8066c0a52151713d0b6ed95) feat: allow variable height node parameters
|
||||||
|
- [2690fc8](https://git.max-richter.dev/max/nodarium/commit/2690fc871291e73d3d028df9668e8fffb1e77476) chore: gitignore pnpm-store
|
||||||
|
- [072ab90](https://git.max-richter.dev/max/nodarium/commit/072ab9063ba56df0673020eb639548f3a8601e04) feat: add initial debug node
|
||||||
|
- [e23cad2](https://git.max-richter.dev/max/nodarium/commit/e23cad254d610e00f196b7fdb4532f36fd735a4b) feat: add "*" datatype for inputs for debug node
|
||||||
|
- [5b5c63c](https://git.max-richter.dev/max/nodarium/commit/5b5c63c1a9c4ef757382bd4452149dc9777693ff) fix(ui): make arrows on inputnumber visible on lighttheme
|
||||||
|
- [c9021f2](https://git.max-richter.dev/max/nodarium/commit/c9021f2383828f2e2b5594d125165bbc8f70b8e7) refactor: merge all dev settings into one setting
|
||||||
|
- [9eecdd4](https://git.max-richter.dev/max/nodarium/commit/9eecdd4fb85dc60b8196101050334e26732c9a34) Merge pull request 'feat: merge localState recursively with initial' (#38) from feat/debug-node into main
|
||||||
|
- [7e71a41](https://git.max-richter.dev/max/nodarium/commit/7e71a41e5229126d404f56598c624709961dbf3b) feat: merge localState recursively with initial
|
||||||
|
- [07cd9e8](https://git.max-richter.dev/max/nodarium/commit/07cd9e84eb51bc02b7fed39c36cf83caba175ad7) feat: clamp AddMenu to viewport
|
||||||
|
- [a31a49a](https://git.max-richter.dev/max/nodarium/commit/a31a49ad503d69f92f2491dd685729060ea49896) ci: lint and typecheck before build
|
||||||
|
- [850d641](https://git.max-richter.dev/max/nodarium/commit/850d641a25cd0c781478c58c117feaf085bdbc62) chore: pnpm format
|
||||||
|
- [ee5ca81](https://git.max-richter.dev/max/nodarium/commit/ee5ca817573b83cacfa3709e0ae88c6263bc39c1) ci: sign release commits with pgp key
|
||||||
|
- [22a1183](https://git.max-richter.dev/max/nodarium/commit/22a11832b861ae8b44e2d374b55d12937ecab247) fix(ci): correctly format changelog
|
||||||
|
- [b5ce572](https://git.max-richter.dev/max/nodarium/commit/b5ce5723fa4a35443df39a9096d0997f808f0b4f) chore: format favicon svg
|
||||||
|
- [102130c](https://git.max-richter.dev/max/nodarium/commit/102130cc7777ceebcdb3de8466c90cef5b380111) feat: add favicon
|
||||||
|
- [1668a2e](https://git.max-richter.dev/max/nodarium/commit/1668a2e6d59db071ab3da45204c2b7bfcd2150a2) chore: format changelog.md
|
||||||
|
|
||||||
# v0.0.4 (2026-02-10)
|
# v0.0.4 (2026-02-10)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ ENV RUSTUP_HOME=/usr/local/rustup \
|
|||||||
PATH=/usr/local/cargo/bin:$PATH
|
PATH=/usr/local/cargo/bin:$PATH
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
|
openssh-client \
|
||||||
ca-certificates=20230311+deb12u1 \
|
ca-certificates=20230311+deb12u1 \
|
||||||
gpg=2.2.40-1.1+deb12u2 \
|
gpg=2.2.40-1.1+deb12u2 \
|
||||||
gpg-agent=2.2.40-1.1+deb12u2 \
|
gpg-agent=2.2.40-1.1+deb12u2 \
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
out/
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { NodeDefinition, NodeId, NodeRegistry } from '@nodarium/types';
|
||||||
|
import { createWasmWrapper } from '@nodarium/utils';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
export class BenchmarkRegistry implements NodeRegistry {
|
||||||
|
status: 'loading' | 'ready' | 'error' = 'loading';
|
||||||
|
|
||||||
|
private nodes = new Map<string, NodeDefinition>();
|
||||||
|
|
||||||
|
async load(nodeIds: NodeId[]): Promise<NodeDefinition[]> {
|
||||||
|
const nodes = await Promise.all(nodeIds.map(async id => {
|
||||||
|
const p = resolve('static/nodes/' + id + '.wasm');
|
||||||
|
const file = await readFile(p);
|
||||||
|
const node = createWasmWrapper(file as unknown as ArrayBuffer);
|
||||||
|
const d = node.get_definition();
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
execute: node.execute
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
for (const n of nodes) {
|
||||||
|
this.nodes.set(n.id, n);
|
||||||
|
}
|
||||||
|
this.status = 'ready';
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(id: string, wasmBuffer: ArrayBuffer): Promise<NodeDefinition> {
|
||||||
|
const wasm = createWasmWrapper(wasmBuffer);
|
||||||
|
const d = wasm.get_definition();
|
||||||
|
const node = {
|
||||||
|
...d,
|
||||||
|
execute: wasm.execute
|
||||||
|
};
|
||||||
|
this.nodes.set(id, node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
getNode(id: NodeId | string): NodeDefinition | undefined {
|
||||||
|
return this.nodes.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllNodes(): NodeDefinition[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import type { Graph, Graph as GraphType, NodeId } from '@nodarium/types';
|
||||||
|
import { createLogger, createPerformanceStore, splitNestedArray } from '@nodarium/utils';
|
||||||
|
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { freemem, loadavg, totalmem } from 'node:os';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { MemoryRuntimeExecutor } from '../src/lib/runtime/runtime-executor.ts';
|
||||||
|
import { BenchmarkRegistry } from './benchmarkRegistry.ts';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getMachineInfo,
|
||||||
|
measureCpuUsage,
|
||||||
|
readCgroupCpuStat,
|
||||||
|
readCpuSnapshot,
|
||||||
|
readProcMemInfo,
|
||||||
|
SystemSample
|
||||||
|
} from './systemStats.ts';
|
||||||
|
import defaultPlantTemplate from './templates/default.json' assert { type: 'json' };
|
||||||
|
import lottaFacesTemplate from './templates/lotta-faces.json' assert { type: 'json' };
|
||||||
|
import plantTemplate from './templates/plant.json' assert { type: 'json' };
|
||||||
|
|
||||||
|
const registry = new BenchmarkRegistry();
|
||||||
|
const r = new MemoryRuntimeExecutor(registry);
|
||||||
|
|
||||||
|
const log = createLogger('bench');
|
||||||
|
|
||||||
|
const templates: Record<string, Graph> = {
|
||||||
|
plant: plantTemplate as unknown as GraphType,
|
||||||
|
'lotta-faces': lottaFacesTemplate as unknown as GraphType,
|
||||||
|
default: defaultPlantTemplate as unknown as GraphType
|
||||||
|
};
|
||||||
|
|
||||||
|
function average(values: number[]) {
|
||||||
|
if (values.length === 0) return 0;
|
||||||
|
return values.reduce((a, b) => a + b, 0) / values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countGeometry(result: Int32Array): {
|
||||||
|
totalVertices: number;
|
||||||
|
totalFaces: number;
|
||||||
|
} {
|
||||||
|
const parts = splitNestedArray(result);
|
||||||
|
|
||||||
|
let totalVertices = 0;
|
||||||
|
let totalFaces = 0;
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const type = part[0];
|
||||||
|
|
||||||
|
const vertexCount = part[1] >>> 0;
|
||||||
|
const faceCount = part[2] >>> 0;
|
||||||
|
|
||||||
|
if (type === 2) {
|
||||||
|
const instanceCount = part[3] >>> 0;
|
||||||
|
|
||||||
|
totalVertices += vertexCount * instanceCount;
|
||||||
|
totalFaces += faceCount * instanceCount;
|
||||||
|
} else {
|
||||||
|
totalVertices += vertexCount;
|
||||||
|
totalFaces += faceCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalVertices,
|
||||||
|
totalFaces
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(g: GraphType, amount: number) {
|
||||||
|
await registry.load(g.nodes.map(n => n.type) as NodeId[]);
|
||||||
|
|
||||||
|
log.log('loaded ' + g.nodes.length + ' nodes');
|
||||||
|
|
||||||
|
log.log('warming up');
|
||||||
|
|
||||||
|
for (let index = 0; index < 10; index++) {
|
||||||
|
await r.execute(g, { randomSeed: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemSamples: SystemSample[] = [];
|
||||||
|
|
||||||
|
let previousCpuSnapshot = await readCpuSnapshot();
|
||||||
|
|
||||||
|
const sampler = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const cpu = await measureCpuUsage(previousCpuSnapshot);
|
||||||
|
|
||||||
|
previousCpuSnapshot = cpu.snapshot;
|
||||||
|
|
||||||
|
const [l1, l5, l15] = loadavg();
|
||||||
|
|
||||||
|
systemSamples.push({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
|
||||||
|
cpuUsagePercent: cpu.usagePercent,
|
||||||
|
cpuStealPercent: cpu.stealPercent,
|
||||||
|
|
||||||
|
load1: l1,
|
||||||
|
load5: l5,
|
||||||
|
load15: l15,
|
||||||
|
|
||||||
|
freeMemory: freemem(),
|
||||||
|
totalMemory: totalmem()
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
log.log('executing');
|
||||||
|
|
||||||
|
const perfStore = createPerformanceStore();
|
||||||
|
|
||||||
|
r.perf = perfStore;
|
||||||
|
|
||||||
|
let res: Int32Array | undefined;
|
||||||
|
|
||||||
|
const cgroupBefore = await readCgroupCpuStat();
|
||||||
|
|
||||||
|
for (let i = 0; i < amount; i++) {
|
||||||
|
r.perf?.startRun();
|
||||||
|
|
||||||
|
res = await r.execute(g, { randomSeed: true });
|
||||||
|
|
||||||
|
r.perf?.stopRun();
|
||||||
|
|
||||||
|
const { totalVertices, totalFaces } = countGeometry(res!);
|
||||||
|
|
||||||
|
r.perf?.addToLastRun('total-vertices', totalVertices);
|
||||||
|
r.perf?.addToLastRun('total-faces', totalFaces);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cgroupAfter = await readCgroupCpuStat();
|
||||||
|
|
||||||
|
clearInterval(sampler);
|
||||||
|
|
||||||
|
log.log('finished');
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: r.perf.get(),
|
||||||
|
metadata: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
|
||||||
|
machine: getMachineInfo(),
|
||||||
|
|
||||||
|
process: {
|
||||||
|
pid: process.pid,
|
||||||
|
uptime: process.uptime(),
|
||||||
|
|
||||||
|
memoryUsage: process.memoryUsage()
|
||||||
|
},
|
||||||
|
|
||||||
|
system: {
|
||||||
|
averages: {
|
||||||
|
cpuUsagePercent: average(
|
||||||
|
systemSamples.map(s => s.cpuUsagePercent)
|
||||||
|
),
|
||||||
|
|
||||||
|
cpuStealPercent: average(
|
||||||
|
systemSamples.map(s => s.cpuStealPercent)
|
||||||
|
),
|
||||||
|
|
||||||
|
load1: average(systemSamples.map(s => s.load1)),
|
||||||
|
load5: average(systemSamples.map(s => s.load5)),
|
||||||
|
load15: average(systemSamples.map(s => s.load15)),
|
||||||
|
|
||||||
|
freeMemory: average(
|
||||||
|
systemSamples.map(s => s.freeMemory)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
samples: systemSamples,
|
||||||
|
|
||||||
|
meminfo: await readProcMemInfo()
|
||||||
|
},
|
||||||
|
|
||||||
|
cgroup: {
|
||||||
|
before: cgroupBefore,
|
||||||
|
after: cgroupAfter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const outPath = resolve('benchmark/out/');
|
||||||
|
|
||||||
|
await mkdir(outPath, { recursive: true });
|
||||||
|
|
||||||
|
for (const key in templates) {
|
||||||
|
log.log('executing ' + key);
|
||||||
|
|
||||||
|
const perfData = await run(templates[key], 100);
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
resolve(outPath, key + '.json'),
|
||||||
|
JSON.stringify(perfData, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise(res => setTimeout(res, 200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { cpus, totalmem } from 'node:os';
|
||||||
|
|
||||||
|
export type CpuSnapshot = {
|
||||||
|
idle: number;
|
||||||
|
total: number;
|
||||||
|
steal: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SystemSample = {
|
||||||
|
timestamp: number;
|
||||||
|
cpuUsagePercent: number;
|
||||||
|
cpuStealPercent: number;
|
||||||
|
load1: number;
|
||||||
|
load5: number;
|
||||||
|
load15: number;
|
||||||
|
freeMemory: number;
|
||||||
|
totalMemory: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function readCpuSnapshot(): Promise<CpuSnapshot> {
|
||||||
|
const stat = await readFile('/proc/stat', 'utf8');
|
||||||
|
const line = stat.split('\n')[0];
|
||||||
|
|
||||||
|
const parts: number[] = line
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(1)
|
||||||
|
.map((v: unknown) => Number(v));
|
||||||
|
|
||||||
|
const idle = parts[3];
|
||||||
|
const iowait = parts[4];
|
||||||
|
const steal = parts[7];
|
||||||
|
|
||||||
|
return {
|
||||||
|
idle: idle + iowait,
|
||||||
|
total: parts.reduce((a, b) => a + b, 0),
|
||||||
|
steal: steal ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function measureCpuUsage(
|
||||||
|
previous: CpuSnapshot
|
||||||
|
): Promise<{
|
||||||
|
snapshot: CpuSnapshot;
|
||||||
|
usagePercent: number;
|
||||||
|
stealPercent: number;
|
||||||
|
}> {
|
||||||
|
const current = await readCpuSnapshot();
|
||||||
|
|
||||||
|
const idle = current.idle - previous.idle;
|
||||||
|
const total = current.total - previous.total;
|
||||||
|
const steal = current.steal - previous.steal;
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshot: current,
|
||||||
|
usagePercent: total === 0 ? 0 : 100 * (1 - idle / total),
|
||||||
|
stealPercent: total === 0 ? 0 : 100 * (steal / total)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readCgroupCpuStat() {
|
||||||
|
const possiblePaths = [
|
||||||
|
'/sys/fs/cgroup/cpu.stat',
|
||||||
|
'/sys/fs/cgroup/cpu/cpu.stat'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const path of possiblePaths) {
|
||||||
|
try {
|
||||||
|
const txt: string = await readFile(path, 'utf8');
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
txt
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map(line => {
|
||||||
|
const [k, v] = line.trim().split(/\s+/);
|
||||||
|
return [k, Number(v)];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readProcMemInfo() {
|
||||||
|
try {
|
||||||
|
const txt = await readFile('/proc/meminfo', 'utf8');
|
||||||
|
|
||||||
|
const result: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const line of txt.split('\n')) {
|
||||||
|
const match = line.match(/^(\w+):\s+(\d+)/);
|
||||||
|
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
result[match[1]] = Number(match[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMachineInfo() {
|
||||||
|
const cpuInfo = cpus();
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
nodeVersion: process.version,
|
||||||
|
|
||||||
|
cpuModel: cpuInfo[0]?.model ?? 'unknown',
|
||||||
|
cpuCount: cpuInfo.length,
|
||||||
|
|
||||||
|
totalMemory: totalmem(),
|
||||||
|
|
||||||
|
ci: {
|
||||||
|
githubActions: process.env.GITHUB_ACTIONS ?? false,
|
||||||
|
runnerName: process.env.RUNNER_NAME ?? null,
|
||||||
|
runnerOs: process.env.RUNNER_OS ?? null,
|
||||||
|
runnerArch: process.env.RUNNER_ARCH ?? null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"settings": { "resolution.circle": 26, "resolution.curve": 39 },
|
||||||
|
"nodes": [
|
||||||
|
{ "id": 9, "position": [220, 80], "type": "max/plantarium/output", "props": {} },
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"position": [95, 80],
|
||||||
|
"type": "max/plantarium/stem",
|
||||||
|
"props": { "amount": 5, "length": 11, "thickness": 0.1 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"position": [195, 80],
|
||||||
|
"type": "max/plantarium/gravity",
|
||||||
|
"props": {
|
||||||
|
"strength": 0.38,
|
||||||
|
"scale": 39,
|
||||||
|
"fixBottom": 0,
|
||||||
|
"directionalStrength": [1, 1, 1],
|
||||||
|
"depth": 1,
|
||||||
|
"curviness": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"position": [120, 80],
|
||||||
|
"type": "max/plantarium/noise",
|
||||||
|
"props": {
|
||||||
|
"strength": 4.9,
|
||||||
|
"scale": 2.2,
|
||||||
|
"fixBottom": 1,
|
||||||
|
"directionalStrength": [1, 1, 1],
|
||||||
|
"depth": 1,
|
||||||
|
"octaves": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"position": [70, 80],
|
||||||
|
"type": "max/plantarium/vec3",
|
||||||
|
"props": { "0": 0, "1": 0, "2": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"position": [45, 80],
|
||||||
|
"type": "max/plantarium/random",
|
||||||
|
"props": { "min": -2, "max": 2 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"position": [170, 80],
|
||||||
|
"type": "max/plantarium/branch",
|
||||||
|
"props": {
|
||||||
|
"length": 1.6,
|
||||||
|
"thickness": 0.69,
|
||||||
|
"amount": 36,
|
||||||
|
"offsetSingle": 0.5,
|
||||||
|
"lowestBranch": 0.46,
|
||||||
|
"highestBranch": 1,
|
||||||
|
"depth": 1,
|
||||||
|
"rotation": 180
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"position": [145, 80],
|
||||||
|
"type": "max/plantarium/gravity",
|
||||||
|
"props": {
|
||||||
|
"strength": 0.38,
|
||||||
|
"scale": 39,
|
||||||
|
"fixBottom": 0,
|
||||||
|
"directionalStrength": [1, 1, 1],
|
||||||
|
"depth": 1,
|
||||||
|
"curviness": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"position": [70, 120],
|
||||||
|
"type": "max/plantarium/random",
|
||||||
|
"props": { "min": 0.073, "max": 0.15 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
[14, 0, 9, "input"],
|
||||||
|
[10, 0, 15, "plant"],
|
||||||
|
[16, 0, 10, "origin"],
|
||||||
|
[17, 0, 16, "0"],
|
||||||
|
[17, 0, 16, "2"],
|
||||||
|
[18, 0, 14, "plant"],
|
||||||
|
[15, 0, 19, "plant"],
|
||||||
|
[19, 0, 18, "plant"],
|
||||||
|
[20, 0, 10, "thickness"]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"settings": { "resolution.circle": 64, "resolution.curve": 64, "randomSeed": false },
|
||||||
|
"nodes": [
|
||||||
|
{ "id": 9, "position": [260, 0], "type": "max/plantarium/output", "props": {} },
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"position": [185, 0],
|
||||||
|
"type": "max/plantarium/stem",
|
||||||
|
"props": { "amount": 64, "length": 12, "thickness": 0.15 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"position": [210, 0],
|
||||||
|
"type": "max/plantarium/noise",
|
||||||
|
"props": { "scale": 1.3, "strength": 5.4 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"position": [235, 0],
|
||||||
|
"type": "max/plantarium/branch",
|
||||||
|
"props": { "length": 0.8, "thickness": 0.8, "amount": 3 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"position": [160, 0],
|
||||||
|
"type": "max/plantarium/vec3",
|
||||||
|
"props": { "0": 0.39, "1": 0, "2": 0.41 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"position": [130, 0],
|
||||||
|
"type": "max/plantarium/random",
|
||||||
|
"props": { "min": -2, "max": 2 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
[18, 0, 19, "plant"],
|
||||||
|
[19, 0, 20, "plant"],
|
||||||
|
[20, 0, 9, "input"],
|
||||||
|
[21, 0, 18, "origin"],
|
||||||
|
[22, 0, 21, "0"],
|
||||||
|
[22, 0, 21, "2"]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"settings": { "resolution.circle": 26, "resolution.curve": 39 },
|
||||||
|
"nodes": [
|
||||||
|
{ "id": 9, "position": [180, 80], "type": "max/plantarium/output", "props": {} },
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"position": [55, 80],
|
||||||
|
"type": "max/plantarium/stem",
|
||||||
|
"props": { "amount": 1, "length": 11, "thickness": 0.71 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"position": [80, 80],
|
||||||
|
"type": "max/plantarium/noise",
|
||||||
|
"props": {
|
||||||
|
"strength": 35,
|
||||||
|
"scale": 4.6,
|
||||||
|
"fixBottom": 1,
|
||||||
|
"directionalStrength": [1, 0.74, 0.083],
|
||||||
|
"depth": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"position": [105, 80],
|
||||||
|
"type": "max/plantarium/branch",
|
||||||
|
"props": {
|
||||||
|
"length": 3,
|
||||||
|
"thickness": 0.6,
|
||||||
|
"amount": 10,
|
||||||
|
"rotation": 180,
|
||||||
|
"offsetSingle": 0.34,
|
||||||
|
"lowestBranch": 0.53,
|
||||||
|
"highestBranch": 1,
|
||||||
|
"depth": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"position": [130, 80],
|
||||||
|
"type": "max/plantarium/noise",
|
||||||
|
"props": {
|
||||||
|
"strength": 8,
|
||||||
|
"scale": 7.7,
|
||||||
|
"fixBottom": 1,
|
||||||
|
"directionalStrength": [1, 0, 1],
|
||||||
|
"depth": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"position": [155, 80],
|
||||||
|
"type": "max/plantarium/gravity",
|
||||||
|
"props": {
|
||||||
|
"strength": 0.11,
|
||||||
|
"scale": 39,
|
||||||
|
"fixBottom": 0,
|
||||||
|
"directionalStrength": [1, 1, 1],
|
||||||
|
"depth": 1,
|
||||||
|
"curviness": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
[10, 0, 11, "plant"],
|
||||||
|
[11, 0, 12, "plant"],
|
||||||
|
[12, 0, 13, "plant"],
|
||||||
|
[13, 0, 14, "plant"],
|
||||||
|
[14, 0, 9, "input"]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -8,9 +8,6 @@ test('test', async ({ page }) => {
|
|||||||
|
|
||||||
await page.goto('http://localhost:4173', { waitUntil: 'load' });
|
await page.goto('http://localhost:4173', { waitUntil: 'load' });
|
||||||
|
|
||||||
// await expect(page).toHaveScreenshot();
|
|
||||||
await expect(page.locator('.graph-wrapper')).toHaveScreenshot();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'projects' }).click();
|
await page.getByRole('button', { name: 'projects' }).click();
|
||||||
await page.getByRole('button', { name: 'New', exact: true }).click();
|
await page.getByRole('button', { name: 'New', exact: true }).click();
|
||||||
await page.getByRole('combobox').selectOption('2');
|
await page.getByRole('combobox').selectOption('2');
|
||||||
@@ -23,9 +20,9 @@ test('test', async ({ page }) => {
|
|||||||
id: '10',
|
id: '10',
|
||||||
type: 'max/plantarium/stem',
|
type: 'max/plantarium/stem',
|
||||||
props: {
|
props: {
|
||||||
amount: 50,
|
amount: 4,
|
||||||
length: 4,
|
length: 4,
|
||||||
thickness: 1
|
thickness: 0.2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB |
+34
-31
@@ -1,63 +1,66 @@
|
|||||||
{
|
{
|
||||||
"name": "@nodarium/app",
|
"name": "@nodarium/app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.4",
|
"version": "0.0.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"predev": "rm static/CHANGELOG.md && ln -s ../../CHANGELOG.md static/CHANGELOG.md",
|
"predev": "rm static/CHANGELOG.md && ln -s ../../CHANGELOG.md static/CHANGELOG.md",
|
||||||
"build": "svelte-kit sync && vite build",
|
"build": "svelte-kit sync && vite build",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest --browser=false",
|
||||||
"test": "npm run test:unit -- --run && npm run test:e2e",
|
"test": "npm run test:unit -- --run && npm run test:e2e",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"format": "dprint fmt -c '../.dprint.jsonc' .",
|
"format": "dprint fmt -c '../.dprint.jsonc' .",
|
||||||
"format:check": "dprint check -c '../.dprint.jsonc' .",
|
"format:check": "dprint check -c '../.dprint.jsonc' .",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"bench": "tsx ./benchmark/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nodarium/planty": "workspace:*",
|
||||||
"@nodarium/ui": "workspace:*",
|
"@nodarium/ui": "workspace:*",
|
||||||
"@nodarium/utils": "workspace:*",
|
"@nodarium/utils": "workspace:*",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.59.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
"@threlte/core": "8.3.1",
|
"@threlte/core": "8.5.11",
|
||||||
"@threlte/extras": "9.7.1",
|
"@threlte/extras": "9.15.1",
|
||||||
"comlink": "^4.4.2",
|
"comlink": "^4.4.2",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"jsondiffpatch": "^0.7.3",
|
"jsondiffpatch": "^0.7.3",
|
||||||
"micromark": "^4.0.2",
|
"micromark": "^4.0.2",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.2.4",
|
||||||
"three": "^0.182.0"
|
"three": "^0.184.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^2.0.2",
|
"@eslint/compat": "^2.0.5",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^10.0.1",
|
||||||
"@iconify-json/tabler": "^1.2.26",
|
"@iconify-json/tabler": "^1.2.33",
|
||||||
"@iconify/tailwind4": "^1.2.1",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@nodarium/types": "workspace:^",
|
"@nodarium/types": "workspace:^",
|
||||||
"@playwright/test": "^1.58.1",
|
"@playwright/test": "^1.59.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tsconfig/svelte": "^5.0.7",
|
"@tsconfig/svelte": "^5.0.8",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/three": "^0.182.0",
|
"@types/three": "^0.184.0",
|
||||||
"@vitest/browser-playwright": "^4.0.18",
|
"@vitest/browser-playwright": "^4.1.5",
|
||||||
"dprint": "^0.51.1",
|
"dprint": "^0.54.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^10.3.0",
|
||||||
"eslint-plugin-svelte": "^3.14.0",
|
"eslint-plugin-svelte": "^3.17.1",
|
||||||
"globals": "^17.3.0",
|
"globals": "^17.6.0",
|
||||||
"svelte": "^5.49.2",
|
"svelte": "^5.55.5",
|
||||||
"svelte-check": "^4.3.6",
|
"svelte-check": "^4.4.7",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"typescript": "^5.9.3",
|
"tsx": "^4.21.0",
|
||||||
"typescript-eslint": "^8.54.0",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^7.3.1",
|
"typescript-eslint": "^8.59.1",
|
||||||
|
"vite": "^8.0.10",
|
||||||
"vite-plugin-comlink": "^5.3.0",
|
"vite-plugin-comlink": "^5.3.0",
|
||||||
"vite-plugin-glsl": "^1.5.5",
|
"vite-plugin-glsl": "^1.6.0",
|
||||||
"vite-plugin-wasm": "^3.5.0",
|
"vite-plugin-wasm": "^3.6.0",
|
||||||
"vitest": "^4.0.18",
|
"vitest": "^4.1.5",
|
||||||
"vitest-browser-svelte": "^2.0.2"
|
"vitest-browser-svelte": "^2.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@source "../../packages/ui/**/*.svelte";
|
@source "../../packages/ui/**/*.svelte";
|
||||||
|
@source "../../packages/planty/src/lib/**/*.svelte";
|
||||||
|
|
||||||
@plugin "@iconify/tailwind4" {
|
@plugin "@iconify/tailwind4" {
|
||||||
prefix: "i";
|
prefix: "i";
|
||||||
icon-sets: from-folder("custom", "./src/lib/icons");
|
icon-sets: from-folder("custom", "./src/lib/icons");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
<title>Nodes</title>
|
<title>Nodes</title>
|
||||||
|
|||||||
@@ -183,7 +183,7 @@
|
|||||||
activeNodeId = node.id;
|
activeNodeId = node.id;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{node.id.split('/').at(-1)}
|
{node.meta?.title ?? node.id.split('/').at(-1)}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getGraphManager } from '../graph-state.svelte';
|
||||||
|
const graph = getGraphManager();
|
||||||
|
|
||||||
|
function getGroupName(groupId: number) {
|
||||||
|
const group = graph.getGroup(groupId);
|
||||||
|
return group?.name || `Group#${groupId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitToGroup(targetId?: number) {
|
||||||
|
while (graph.currentGroupId !== (targetId ?? null)) {
|
||||||
|
graph.exitGroup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intermediate groups: parent stack entries that are groups (not the root graph).
|
||||||
|
const intermediateGroups = $derived(
|
||||||
|
graph.parentStack.filter(e => e.id !== graph.id)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="shadow" class:is-inside-group={graph.isInsideGroup}></div>
|
||||||
|
|
||||||
|
{#if graph.isInsideGroup}
|
||||||
|
<div class="group-name flex gap-1 items-center">
|
||||||
|
<button
|
||||||
|
class="bg-layer-2 opacity-75 hover:opacity-100 cursor-pointer rounded-sm p-1 px-2"
|
||||||
|
onclick={() => exitToGroup()}
|
||||||
|
>
|
||||||
|
Root
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#each intermediateGroups as entry (entry.id)}
|
||||||
|
<span class="i-[tabler--arrow-right]"></span>
|
||||||
|
<button
|
||||||
|
class="bg-layer-2 opacity-75 hover:opacity-100 cursor-pointer rounded-sm p-1 px-2"
|
||||||
|
onclick={() => exitToGroup(entry.id)}
|
||||||
|
>
|
||||||
|
{getGroupName(entry.id)}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<span class="i-[tabler--arrow-right]"></span>
|
||||||
|
<button class="bg-layer-2 opacity-100 cursor-pointer rounded-sm p-1 px-2">
|
||||||
|
{getGroupName(graph.currentGroupId!)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.shadow {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
left: -5px;
|
||||||
|
right: calc(var(--padding-right) - 5px);
|
||||||
|
bottom: -5px;
|
||||||
|
z-index: 1;
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
box-shadow: 0 0 0px 0px var(--color-layer-2) inset;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow.is-inside-group {
|
||||||
|
box-shadow: 0 0 0px 8px var(--color-layer-2) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(50% - var(--padding-right) / 2);
|
||||||
|
transition: left 0.3s ease;
|
||||||
|
top: 12px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { assert, describe, expect, it } from 'vitest';
|
||||||
import { GraphManager } from './graph-manager.svelte';
|
import { GraphManager } from './graph-manager.svelte';
|
||||||
import {
|
import {
|
||||||
createMockNodeRegistry,
|
createMockNodeRegistry,
|
||||||
@@ -9,7 +9,150 @@ import {
|
|||||||
mockVec3OutputNode
|
mockVec3OutputNode
|
||||||
} from './test-utils';
|
} from './test-utils';
|
||||||
|
|
||||||
describe('GraphManager', () => {
|
describe('groupNodes', () => {
|
||||||
|
it('should not do anything if no nodes are selected', () => {
|
||||||
|
const registry = createMockNodeRegistry([
|
||||||
|
mockFloatOutputNode,
|
||||||
|
mockFloatInputNode,
|
||||||
|
mockGeometryOutputNode,
|
||||||
|
mockPathInputNode
|
||||||
|
]);
|
||||||
|
|
||||||
|
const manager = new GraphManager(registry);
|
||||||
|
|
||||||
|
const floatInputNode = manager.createNode({
|
||||||
|
type: 'test/node/input',
|
||||||
|
position: [100, 100],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.isDefined(floatInputNode);
|
||||||
|
|
||||||
|
const floatOutputNode = manager.createNode({
|
||||||
|
type: 'test/node/output',
|
||||||
|
position: [0, 0],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
assert.isDefined(floatOutputNode);
|
||||||
|
|
||||||
|
const edge = manager.createEdge(floatInputNode, 0, floatOutputNode, 'input');
|
||||||
|
assert.isDefined(edge);
|
||||||
|
manager.save();
|
||||||
|
|
||||||
|
manager.groupNodes([]);
|
||||||
|
|
||||||
|
const graph = manager.serialize();
|
||||||
|
expect(graph.nodes.length).toBe(2);
|
||||||
|
expect(graph.edges.length).toBe(1);
|
||||||
|
expect(graph.groups.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should group selected nodes and create a group node', () => {
|
||||||
|
const registry = createMockNodeRegistry([
|
||||||
|
mockFloatOutputNode,
|
||||||
|
mockFloatInputNode,
|
||||||
|
mockGeometryOutputNode,
|
||||||
|
mockPathInputNode
|
||||||
|
]);
|
||||||
|
|
||||||
|
const manager = new GraphManager(registry);
|
||||||
|
|
||||||
|
const floatInputNode = manager.createNode({
|
||||||
|
type: 'test/node/input',
|
||||||
|
position: [100, 100],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.isDefined(floatInputNode);
|
||||||
|
|
||||||
|
const floatOutputNode = manager.createNode({
|
||||||
|
type: 'test/node/output',
|
||||||
|
position: [0, 0],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
assert.isDefined(floatOutputNode);
|
||||||
|
|
||||||
|
const edge = manager.createEdge(floatInputNode, 0, floatOutputNode, 'input');
|
||||||
|
assert.isDefined(edge);
|
||||||
|
manager.save();
|
||||||
|
|
||||||
|
const groupNode = manager.groupNodes([floatInputNode.id]);
|
||||||
|
assert.isDefined(groupNode);
|
||||||
|
|
||||||
|
const graph = manager.serialize();
|
||||||
|
|
||||||
|
expect(graph.nodes.map(n => n.id), 'graph to contain group node').to.contain(groupNode.id);
|
||||||
|
expect(graph.groups[0].nodes.map(n => n.id), 'group graph to contain float node').to.contain(
|
||||||
|
floatInputNode.id
|
||||||
|
);
|
||||||
|
expect(graph.nodes.map(n => n.id)).not.to.contain(floatInputNode.id);
|
||||||
|
|
||||||
|
expect(graph.nodes.length).toBe(2);
|
||||||
|
expect(graph.edges.length).toBe(1);
|
||||||
|
expect(graph.groups.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rewire external edges when grouping a middle node in a chain', () => {
|
||||||
|
const registry = createMockNodeRegistry([mockFloatOutputNode, mockFloatInputNode]);
|
||||||
|
const manager = new GraphManager(registry);
|
||||||
|
|
||||||
|
// A → B → C (float chain: output → middle → input)
|
||||||
|
const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} });
|
||||||
|
const nodeB = manager.createNode({ type: 'test/node/output', position: [100, 0], props: {} });
|
||||||
|
const nodeC = manager.createNode({ type: 'test/node/input', position: [200, 0], props: {} });
|
||||||
|
|
||||||
|
assert.isDefined(nodeA);
|
||||||
|
assert.isDefined(nodeB);
|
||||||
|
assert.isDefined(nodeC);
|
||||||
|
|
||||||
|
manager.createEdge(nodeA, 0, nodeB, 'input');
|
||||||
|
manager.createEdge(nodeB, 0, nodeC, 'value');
|
||||||
|
|
||||||
|
const groupNode = manager.groupNodes([nodeB.id]);
|
||||||
|
assert.isDefined(groupNode);
|
||||||
|
|
||||||
|
const graph = manager.serialize();
|
||||||
|
|
||||||
|
// Top-level: A, C, groupNode — B is gone
|
||||||
|
expect(graph.nodes.length, 'top-level node count').toBe(3);
|
||||||
|
const topLevelIds = graph.nodes.map(n => n.id);
|
||||||
|
expect(topLevelIds).toContain(nodeA.id);
|
||||||
|
expect(topLevelIds).toContain(nodeC.id);
|
||||||
|
expect(topLevelIds).toContain(groupNode.id);
|
||||||
|
expect(topLevelIds).not.toContain(nodeB.id);
|
||||||
|
|
||||||
|
// Both original edges survive, now routing through the group node
|
||||||
|
expect(graph.edges.length, 'edge count unchanged').toBe(2);
|
||||||
|
const edgeSources = graph.edges.map(e => e[0]);
|
||||||
|
const edgeTargets = graph.edges.map(e => e[2]);
|
||||||
|
expect(edgeTargets).toContain(groupNode.id); // A → groupNode
|
||||||
|
expect(edgeSources).toContain(groupNode.id); // groupNode → C
|
||||||
|
|
||||||
|
// One group definition was created
|
||||||
|
expect(graph.groups.length).toBe(1);
|
||||||
|
const group = graph.groups[0];
|
||||||
|
|
||||||
|
// Group contains B plus the two boundary nodes
|
||||||
|
const groupNodeIds = group.nodes.map(n => n.id);
|
||||||
|
expect(groupNodeIds).toContain(nodeB.id);
|
||||||
|
const inputBoundary = group.nodes.find(n => n.type === '__internal/group/input');
|
||||||
|
const outputBoundary = group.nodes.find(n => n.type === '__internal/group/output');
|
||||||
|
expect(inputBoundary, 'group input boundary node').toBeDefined();
|
||||||
|
expect(outputBoundary, 'group output boundary node').toBeDefined();
|
||||||
|
|
||||||
|
// Group declares one input slot and one output slot
|
||||||
|
expect(Object.keys(group.inputs ?? {}).length, 'group input count').toBe(1);
|
||||||
|
expect(group.outputs?.length, 'group output count').toBe(1);
|
||||||
|
|
||||||
|
// Internal edges wire: inputBoundary → B → outputBoundary
|
||||||
|
expect(group.edges.length, 'internal edge count').toBe(2);
|
||||||
|
const internalSources = group.edges.map(e => e[0]);
|
||||||
|
const internalTargets = group.edges.map(e => e[2]);
|
||||||
|
expect(internalTargets).toContain(nodeB.id);
|
||||||
|
expect(internalSources).toContain(nodeB.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getPossibleSockets', () => {
|
describe('getPossibleSockets', () => {
|
||||||
describe('when dragging an output socket', () => {
|
describe('when dragging an output socket', () => {
|
||||||
it('should return compatible input sockets based on type', () => {
|
it('should return compatible input sockets based on type', () => {
|
||||||
@@ -262,4 +405,3 @@ describe('GraphManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,262 @@
|
|||||||
|
import { assert, describe, expect, it } from 'vitest';
|
||||||
|
import { GraphManager } from './graph-manager.svelte';
|
||||||
|
import { GraphState } from './graph-state.svelte';
|
||||||
|
import { createMockNodeRegistry, mockFloatInputNode, mockFloatOutputNode } from './test-utils';
|
||||||
|
|
||||||
|
// GraphState constructor reads localStorage synchronously — mock before any instantiation
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
value: {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {},
|
||||||
|
removeItem: () => {},
|
||||||
|
clear: () => {},
|
||||||
|
length: 0,
|
||||||
|
key: () => null
|
||||||
|
} as Storage,
|
||||||
|
writable: true,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
function createFixture() {
|
||||||
|
const registry = createMockNodeRegistry([mockFloatOutputNode, mockFloatInputNode]);
|
||||||
|
const manager = new GraphManager(registry);
|
||||||
|
const state = new GraphState(manager);
|
||||||
|
return { manager, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('clearSelection', () => {
|
||||||
|
it('empties selectedNodes', () => {
|
||||||
|
const { state } = createFixture();
|
||||||
|
state.selectedNodes.add(1);
|
||||||
|
state.selectedNodes.add(2);
|
||||||
|
state.clearSelection();
|
||||||
|
expect(state.selectedNodes.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('projectScreenToWorld', () => {
|
||||||
|
it('maps the viewport centre to the camera position', () => {
|
||||||
|
const { state } = createFixture();
|
||||||
|
// cameraPosition default: [140, 100, 3.5], width=100, height=100
|
||||||
|
state.width = 100;
|
||||||
|
state.height = 100;
|
||||||
|
state.cameraPosition = [140, 100, 3.5];
|
||||||
|
const [wx, wy] = state.projectScreenToWorld(50, 50);
|
||||||
|
expect(wx).toBeCloseTo(140);
|
||||||
|
expect(wy).toBeCloseTo(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offsets correctly for a point not at centre', () => {
|
||||||
|
const { state } = createFixture();
|
||||||
|
state.width = 100;
|
||||||
|
state.height = 100;
|
||||||
|
state.cameraPosition = [0, 0, 2];
|
||||||
|
const [wx, wy] = state.projectScreenToWorld(100, 50);
|
||||||
|
// x: 0 + (100 - 50) / 2 = 25
|
||||||
|
expect(wx).toBeCloseTo(25);
|
||||||
|
expect(wy).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('groupSelectedNodes', () => {
|
||||||
|
it('delegates to graph.groupNodes with selected IDs and activeNodeId', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
|
||||||
|
const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} });
|
||||||
|
const nodeB = manager.createNode({ type: 'test/node/input', position: [100, 0], props: {} });
|
||||||
|
assert.isDefined(nodeA);
|
||||||
|
assert.isDefined(nodeB);
|
||||||
|
|
||||||
|
state.selectedNodes.add(nodeA!.id);
|
||||||
|
state.activeNodeId = nodeB!.id;
|
||||||
|
|
||||||
|
const groupNode = state.groupSelectedNodes();
|
||||||
|
assert.isDefined(groupNode);
|
||||||
|
|
||||||
|
const graph = manager.serialize();
|
||||||
|
expect(graph.groups.length).toBe(1);
|
||||||
|
expect(graph.nodes.map(n => n.id)).toContain(groupNode!.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works when only activeNodeId is set with no extra selection', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
|
||||||
|
const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} });
|
||||||
|
assert.isDefined(nodeA);
|
||||||
|
|
||||||
|
state.activeNodeId = nodeA!.id;
|
||||||
|
const groupNode = state.groupSelectedNodes();
|
||||||
|
assert.isDefined(groupNode);
|
||||||
|
|
||||||
|
expect(manager.groups.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enterGroupNode', () => {
|
||||||
|
it('does nothing when activeNodeId is -1', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
state.activeNodeId = -1;
|
||||||
|
state.enterGroupNode();
|
||||||
|
expect(manager.parentStack.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when the active node is not a group instance', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
const node = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} });
|
||||||
|
assert.isDefined(node);
|
||||||
|
state.activeNodeId = node!.id;
|
||||||
|
state.enterGroupNode();
|
||||||
|
expect(manager.parentStack.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enters the group, pushes graphStack, and clears UI state', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
|
||||||
|
const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} });
|
||||||
|
assert.isDefined(nodeA);
|
||||||
|
const groupNode = manager.groupNodes([nodeA!.id]);
|
||||||
|
assert.isDefined(groupNode);
|
||||||
|
|
||||||
|
state.selectedNodes.add(nodeA!.id);
|
||||||
|
state.activeNodeId = groupNode!.id;
|
||||||
|
state.cameraPosition = [10, 20, 5];
|
||||||
|
|
||||||
|
state.enterGroupNode();
|
||||||
|
|
||||||
|
expect(manager.parentStack.length).toBe(1);
|
||||||
|
expect(state.activeNodeId).toBe(-1);
|
||||||
|
expect(state.selectedNodes.size).toBe(0);
|
||||||
|
expect(manager.isInsideGroup).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exitGroupNode', () => {
|
||||||
|
it('does nothing when not inside a group', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
const before = [...state.cameraPosition];
|
||||||
|
state.exitGroupNode();
|
||||||
|
expect(manager.parentStack.length).toBe(0);
|
||||||
|
expect(state.cameraPosition).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears activeNodeId and selection after exit', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
|
||||||
|
const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} });
|
||||||
|
assert.isDefined(nodeA);
|
||||||
|
const groupNode = manager.groupNodes([nodeA!.id]);
|
||||||
|
assert.isDefined(groupNode);
|
||||||
|
|
||||||
|
state.activeNodeId = groupNode!.id;
|
||||||
|
state.enterGroupNode();
|
||||||
|
state.activeNodeId = 99;
|
||||||
|
state.selectedNodes.add(99);
|
||||||
|
|
||||||
|
state.exitGroupNode();
|
||||||
|
|
||||||
|
// Group instance node is re-selected on exit; internal selection is cleared
|
||||||
|
expect(state.activeNodeId).toBe(groupNode!.id);
|
||||||
|
expect(state.selectedNodes.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores outer nodes to manager after exit', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
|
||||||
|
const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} });
|
||||||
|
const nodeB = manager.createNode({ type: 'test/node/input', position: [100, 0], props: {} });
|
||||||
|
assert.isDefined(nodeA);
|
||||||
|
assert.isDefined(nodeB);
|
||||||
|
|
||||||
|
manager.createEdge(nodeA!, 0, nodeB!, 'value');
|
||||||
|
const groupNode = manager.groupNodes([nodeA!.id]);
|
||||||
|
assert.isDefined(groupNode);
|
||||||
|
|
||||||
|
state.activeNodeId = groupNode!.id;
|
||||||
|
state.enterGroupNode();
|
||||||
|
|
||||||
|
// Inside the group: nodeA is an internal node so it IS active; the outer
|
||||||
|
// nodes (nodeB, groupNode) are saved and no longer in the active Map.
|
||||||
|
expect(manager.nodes.has(nodeA!.id)).toBe(true);
|
||||||
|
expect(manager.nodes.has(nodeB!.id)).toBe(false);
|
||||||
|
|
||||||
|
state.exitGroupNode();
|
||||||
|
|
||||||
|
// After exit: outer nodes are restored
|
||||||
|
expect(manager.nodes.has(nodeB!.id)).toBe(true);
|
||||||
|
expect(manager.nodes.has(groupNode!.id)).toBe(true);
|
||||||
|
expect(manager.isInsideGroup).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isInsideGroup is false after exiting the only group level', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
|
||||||
|
const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} });
|
||||||
|
assert.isDefined(nodeA);
|
||||||
|
const groupNode = manager.groupNodes([nodeA!.id]);
|
||||||
|
assert.isDefined(groupNode);
|
||||||
|
|
||||||
|
state.activeNodeId = groupNode!.id;
|
||||||
|
state.enterGroupNode();
|
||||||
|
expect(manager.isInsideGroup).toBe(true);
|
||||||
|
|
||||||
|
state.exitGroupNode();
|
||||||
|
expect(manager.isInsideGroup).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('copyNodes / pasteNodes', () => {
|
||||||
|
it('copies the active node into the clipboard', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
const node = manager.createNode({ type: 'test/node/output', position: [10, 20], props: {} });
|
||||||
|
assert.isDefined(node);
|
||||||
|
|
||||||
|
state.activeNodeId = node!.id;
|
||||||
|
state.mousePosition = [0, 0];
|
||||||
|
state.copyNodes();
|
||||||
|
|
||||||
|
assert.isNotNull(state.clipboard);
|
||||||
|
expect(state.clipboard!.nodes.map(n => n.id)).toContain(node!.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes edges between copied nodes', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
const nodeA = manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} });
|
||||||
|
const nodeB = manager.createNode({ type: 'test/node/input', position: [100, 0], props: {} });
|
||||||
|
assert.isDefined(nodeA);
|
||||||
|
assert.isDefined(nodeB);
|
||||||
|
|
||||||
|
manager.createEdge(nodeA!, 0, nodeB!, 'value');
|
||||||
|
|
||||||
|
state.activeNodeId = nodeA!.id;
|
||||||
|
state.selectedNodes.add(nodeB!.id);
|
||||||
|
state.mousePosition = [0, 0];
|
||||||
|
state.copyNodes();
|
||||||
|
|
||||||
|
assert.isNotNull(state.clipboard);
|
||||||
|
expect(state.clipboard!.edges.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pastes nodes and adds them to the graph', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
const node = manager.createNode({ type: 'test/node/output', position: [10, 20], props: {} });
|
||||||
|
assert.isDefined(node);
|
||||||
|
|
||||||
|
state.activeNodeId = node!.id;
|
||||||
|
state.mousePosition = [0, 0];
|
||||||
|
state.copyNodes();
|
||||||
|
|
||||||
|
const countBefore = manager.nodes.size;
|
||||||
|
state.mousePosition = [50, 50];
|
||||||
|
state.pasteNodes();
|
||||||
|
|
||||||
|
expect(manager.nodes.size).toBe(countBefore + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when clipboard is empty', () => {
|
||||||
|
const { manager, state } = createFixture();
|
||||||
|
manager.createNode({ type: 'test/node/output', position: [0, 0], props: {} });
|
||||||
|
const countBefore = manager.nodes.size;
|
||||||
|
state.pasteNodes();
|
||||||
|
expect(manager.nodes.size).toBe(countBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { animate, lerp } from '$lib/helpers';
|
||||||
import type { NodeInstance, Socket } from '@nodarium/types';
|
import type { NodeInstance, Socket } from '@nodarium/types';
|
||||||
import { getContext, setContext } from 'svelte';
|
import { getContext, setContext } from 'svelte';
|
||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import type { OrthographicCamera, Vector3 } from 'three';
|
import type { OrthographicCamera, Vector3 } from 'three';
|
||||||
import type { GraphManager } from './graph-manager.svelte';
|
import type { GraphManager } from './graph-manager.svelte';
|
||||||
import { ColorGenerator } from './graph/colors';
|
import { ColorGenerator } from './graph/colors';
|
||||||
import { getNodeHeight, getSocketPosition } from './helpers/nodeHelpers';
|
import { getNodeHeight, getParameterHeight } from './helpers/nodeHelpers';
|
||||||
|
|
||||||
const graphStateKey = Symbol('graph-state');
|
const graphStateKey = Symbol('graph-state');
|
||||||
export function getGraphState() {
|
export function getGraphState() {
|
||||||
@@ -124,6 +125,9 @@ export class GraphState {
|
|||||||
activeNodeId = $state(-1);
|
activeNodeId = $state(-1);
|
||||||
selectedNodes = new SvelteSet<number>();
|
selectedNodes = new SvelteSet<number>();
|
||||||
activeSocket = $state<Socket | null>(null);
|
activeSocket = $state<Socket | null>(null);
|
||||||
|
safePadding = $state<{ left?: number; right?: number; bottom?: number; top?: number } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
hoveredSocket = $state<Socket | null>(null);
|
hoveredSocket = $state<Socket | null>(null);
|
||||||
possibleSockets = $state<Socket[]>([]);
|
possibleSockets = $state<Socket[]>([]);
|
||||||
possibleSocketIds = $derived(
|
possibleSocketIds = $derived(
|
||||||
@@ -148,10 +152,6 @@ export class GraphState {
|
|||||||
this.edges.delete(edgeId);
|
this.edges.delete(edgeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
getEdgeData() {
|
|
||||||
return this.edges;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateNodePosition(node: NodeInstance) {
|
updateNodePosition(node: NodeInstance) {
|
||||||
if (
|
if (
|
||||||
node.state.x === node.position[0]
|
node.state.x === node.position[0]
|
||||||
@@ -186,29 +186,6 @@ export class GraphState {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
tryConnectToDebugNode(nodeId: number) {
|
|
||||||
const node = this.graph.nodes.get(nodeId);
|
|
||||||
if (!node) return;
|
|
||||||
if (node.type.endsWith('/debug')) return;
|
|
||||||
if (!node.state.type?.outputs?.length) return;
|
|
||||||
for (const _node of this.graph.nodes.values()) {
|
|
||||||
if (_node.type.endsWith('/debug')) {
|
|
||||||
this.graph.createEdge(node, 0, _node, 'input');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const debugNode = this.graph.createNode({
|
|
||||||
type: 'max/plantarium/debug',
|
|
||||||
position: [node.position[0] + 30, node.position[1]],
|
|
||||||
props: {}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (debugNode) {
|
|
||||||
this.graph.createEdge(node, 0, debugNode, 'input');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
copyNodes() {
|
copyNodes() {
|
||||||
if (this.activeNodeId === -1 && !this.selectedNodes?.size) {
|
if (this.activeNodeId === -1 && !this.selectedNodes?.size) {
|
||||||
return;
|
return;
|
||||||
@@ -236,6 +213,45 @@ export class GraphState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unGroupSelectedNodes() {
|
||||||
|
return this.graph.ungroupNode(this.activeNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
groupSelectedNodes() {
|
||||||
|
return this.graph.groupNodes([...this.selectedNodes.keys(), this.activeNodeId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
pasteNodes() {
|
||||||
if (!this.clipboard) return;
|
if (!this.clipboard) return;
|
||||||
|
|
||||||
@@ -266,7 +282,7 @@ export class GraphState {
|
|||||||
if (edge[3] === index) {
|
if (edge[3] === index) {
|
||||||
node = edge[0];
|
node = edge[0];
|
||||||
index = edge[1];
|
index = edge[1];
|
||||||
position = getSocketPosition(node, index);
|
position = this.getSocketPosition(node, index);
|
||||||
this.graph.removeEdge(edge);
|
this.graph.removeEdge(edge);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -286,7 +302,7 @@ export class GraphState {
|
|||||||
return {
|
return {
|
||||||
node,
|
node,
|
||||||
index,
|
index,
|
||||||
position: getSocketPosition(node, index)
|
position: this.getSocketPosition(node, index)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -323,7 +339,8 @@ export class GraphState {
|
|||||||
for (const node of this.graph.nodes.values()) {
|
for (const node of this.graph.nodes.values()) {
|
||||||
const x = node.position[0];
|
const x = node.position[0];
|
||||||
const y = node.position[1];
|
const y = node.position[1];
|
||||||
const height = getNodeHeight(node.state.type!);
|
const nodeType = this.graph.getNodeType(node);
|
||||||
|
const height = nodeType ? getNodeHeight(nodeType) : 20;
|
||||||
if (downX > x && downX < x + 20 && downY > y && downY < y + height) {
|
if (downX > x && downX < x + 20 && downY > y && downY < y + height) {
|
||||||
clickedNodeId = node.id;
|
clickedNodeId = node.id;
|
||||||
break;
|
break;
|
||||||
@@ -335,7 +352,8 @@ export class GraphState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isNodeInView(node: NodeInstance) {
|
isNodeInView(node: NodeInstance) {
|
||||||
const height = getNodeHeight(node.state.type!);
|
if (!node) return false;
|
||||||
|
const height = getNodeHeight(this.graph.getNodeType(node)!);
|
||||||
const width = 20;
|
const width = 20;
|
||||||
return node.position[0] > this.cameraBounds[0] - width
|
return node.position[0] > this.cameraBounds[0] - width
|
||||||
&& node.position[0] < this.cameraBounds[1]
|
&& node.position[0] < this.cameraBounds[1]
|
||||||
@@ -346,4 +364,57 @@ export class GraphState {
|
|||||||
openNodePalette() {
|
openNodePalette() {
|
||||||
this.addMenuPosition = [this.mousePosition[0], this.mousePosition[1]];
|
this.addMenuPosition = [this.mousePosition[0], this.mousePosition[1]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enterGroupNode() {
|
||||||
|
if (this.activeNodeId === -1) return;
|
||||||
|
const node = this.graph.getNode(this.activeNodeId);
|
||||||
|
if (!node || node.type !== '__internal/group/instance') return;
|
||||||
|
const ok = this.graph.enterGroup(this.activeNodeId);
|
||||||
|
if (ok) {
|
||||||
|
this.activeNodeId = -1;
|
||||||
|
this.clearSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exitGroupNode() {
|
||||||
|
const result = this.graph.exitGroup();
|
||||||
|
if (!result) return;
|
||||||
|
this.activeNodeId = result.nodeId;
|
||||||
|
this.clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
getSocketPosition(
|
||||||
|
node: NodeInstance,
|
||||||
|
index: string | number
|
||||||
|
): [number, number] {
|
||||||
|
if (node.type === '__internal/group/input' && typeof index === 'number') {
|
||||||
|
return [
|
||||||
|
(node?.state?.x ?? node.position[0]) + 20,
|
||||||
|
(node?.state?.y ?? node.position[1]) + 2.5 + 5 * index + 5
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof index === 'number') {
|
||||||
|
return [
|
||||||
|
(node?.state?.x ?? node.position[0]) + 20,
|
||||||
|
(node?.state?.y ?? node.position[1]) + 2.5 + 10 * index
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
let height = 5;
|
||||||
|
const nodeType = this.graph.getNodeType(node)!;
|
||||||
|
const inputs = nodeType.inputs || {};
|
||||||
|
for (const inputKey in inputs) {
|
||||||
|
const h = getParameterHeight(nodeType, inputKey) / 10;
|
||||||
|
if (inputKey === index) {
|
||||||
|
height += h / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
height += h;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
node?.state?.x ?? node.position[0],
|
||||||
|
(node?.state?.y ?? node.position[1]) + height
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
import AddMenu from '../components/AddMenu.svelte';
|
import AddMenu from '../components/AddMenu.svelte';
|
||||||
import BoxSelection from '../components/BoxSelection.svelte';
|
import BoxSelection from '../components/BoxSelection.svelte';
|
||||||
import Camera from '../components/Camera.svelte';
|
import Camera from '../components/Camera.svelte';
|
||||||
|
import GroupBreadcrumps from '../components/GroupBreadcrumps.svelte';
|
||||||
import HelpView from '../components/HelpView.svelte';
|
import HelpView from '../components/HelpView.svelte';
|
||||||
import Debug from '../debug/Debug.svelte';
|
import Debug from '../debug/Debug.svelte';
|
||||||
import EdgeEl from '../edges/Edge.svelte';
|
import EdgeEl from '../edges/Edge.svelte';
|
||||||
import { getGraphManager, getGraphState } from '../graph-state.svelte';
|
import { getGraphManager, getGraphState } from '../graph-state.svelte';
|
||||||
import { getSocketPosition } from '../helpers/nodeHelpers';
|
|
||||||
import NodeEl from '../node/Node.svelte';
|
import NodeEl from '../node/Node.svelte';
|
||||||
import { maxZoom, minZoom } from './constants';
|
import { maxZoom, minZoom } from './constants';
|
||||||
import { FileDropEventManager } from './drop.events';
|
import { FileDropEventManager } from './drop.events';
|
||||||
@@ -19,10 +19,10 @@
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
keymap,
|
keymap,
|
||||||
addMenuPadding
|
safePadding
|
||||||
}: {
|
}: {
|
||||||
keymap: ReturnType<typeof createKeyMap>;
|
keymap: ReturnType<typeof createKeyMap>;
|
||||||
addMenuPadding?: { left?: number; right?: number; bottom?: number; top?: number };
|
safePadding?: { left?: number; right?: number; bottom?: number; top?: number };
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const graph = getGraphManager();
|
const graph = getGraphManager();
|
||||||
@@ -39,8 +39,8 @@
|
|||||||
return [0, 0, 0, 0];
|
return [0, 0, 0, 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
const pos1 = getSocketPosition(fromNode, edge[1]);
|
const pos1 = graphState.getSocketPosition(fromNode, edge[1]);
|
||||||
const pos2 = getSocketPosition(toNode, edge[3]);
|
const pos2 = graphState.getSocketPosition(toNode, edge[3]);
|
||||||
return [pos1[0], pos1[1], pos2[0], pos2[1]];
|
return [pos1[0], pos1[1], pos2[0], pos2[1]];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,10 +97,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSocketType(node: NodeInstance, index: number | string): string {
|
function getSocketType(node: NodeInstance, index: number | string): string {
|
||||||
|
const nodeType = graph.getNodeType(node);
|
||||||
if (typeof index === 'string') {
|
if (typeof index === 'string') {
|
||||||
return node.state.type?.inputs?.[index].type || 'unknown';
|
return nodeType?.inputs?.[index].type || 'unknown';
|
||||||
}
|
}
|
||||||
return node.state.type?.outputs?.[index] || 'unknown';
|
|
||||||
|
if (node.type === '__internal/group/input') {
|
||||||
|
const key = Object.keys(nodeType?.inputs || {})[index];
|
||||||
|
return nodeType?.inputs?.[key].type || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodeType?.outputs?.[index] || 'unknown';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -114,6 +121,7 @@
|
|||||||
bind:this={graphState.wrapper}
|
bind:this={graphState.wrapper}
|
||||||
class="graph-wrapper"
|
class="graph-wrapper"
|
||||||
style="height: 100%"
|
style="height: 100%"
|
||||||
|
class:is-inside-group={graph.isInsideGroup}
|
||||||
class:is-panning={graphState.isPanning}
|
class:is-panning={graphState.isPanning}
|
||||||
class:is-hovering={graphState.hoveredNodeId !== -1}
|
class:is-hovering={graphState.hoveredNodeId !== -1}
|
||||||
aria-label="Graph"
|
aria-label="Graph"
|
||||||
@@ -121,6 +129,7 @@
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
bind:clientWidth={graphState.width}
|
bind:clientWidth={graphState.width}
|
||||||
bind:clientHeight={graphState.height}
|
bind:clientHeight={graphState.height}
|
||||||
|
style:--padding-right="{safePadding?.right || 0}px"
|
||||||
onkeydown={(ev) => keymap.handleKeyboardEvent(ev)}
|
onkeydown={(ev) => keymap.handleKeyboardEvent(ev)}
|
||||||
onmousedown={(ev) => mouseEvents.handleMouseDown(ev)}
|
onmousedown={(ev) => mouseEvents.handleMouseDown(ev)}
|
||||||
oncontextmenu={(ev) => mouseEvents.handleContextMenu(ev)}
|
oncontextmenu={(ev) => mouseEvents.handleContextMenu(ev)}
|
||||||
@@ -136,6 +145,8 @@
|
|||||||
/>
|
/>
|
||||||
<label for="drop-zone"></label>
|
<label for="drop-zone"></label>
|
||||||
|
|
||||||
|
<GroupBreadcrumps />
|
||||||
|
|
||||||
<Canvas shadows={false} renderMode="on-demand" colorManagementEnabled={false}>
|
<Canvas shadows={false} renderMode="on-demand" colorManagementEnabled={false}>
|
||||||
<Camera
|
<Camera
|
||||||
bind:camera={graphState.camera}
|
bind:camera={graphState.camera}
|
||||||
@@ -172,10 +183,10 @@
|
|||||||
{#if graphState.addMenuPosition}
|
{#if graphState.addMenuPosition}
|
||||||
<AddMenu
|
<AddMenu
|
||||||
onnode={handleNodeCreation}
|
onnode={handleNodeCreation}
|
||||||
paddingTop={addMenuPadding?.top}
|
paddingTop={safePadding?.top}
|
||||||
paddingRight={addMenuPadding?.right}
|
paddingRight={safePadding?.right}
|
||||||
paddingBottom={addMenuPadding?.bottom}
|
paddingBottom={safePadding?.bottom}
|
||||||
paddingLeft={addMenuPadding?.left}
|
paddingLeft={safePadding?.left}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -216,10 +227,10 @@
|
|||||||
style:transform={`scale(${graphState.cameraPosition[2] * 0.1})`}
|
style:transform={`scale(${graphState.cameraPosition[2] * 0.1})`}
|
||||||
class:hovering-sockets={graphState.activeSocket}
|
class:hovering-sockets={graphState.activeSocket}
|
||||||
>
|
>
|
||||||
{#each graph.nodes.values() as node (node.id)}
|
{#each graph.nodeArray as node, index (node.id)}
|
||||||
<NodeEl
|
<NodeEl
|
||||||
{node}
|
bind:node={graph.nodeArray[index]}
|
||||||
inView={graphState.isNodeInView(node)}
|
inView={node ? graphState.isNodeInView(node) : false}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createKeyMap } from '$lib/helpers/createKeyMap';
|
import { createKeyMap } from '$lib/helpers/createKeyMap';
|
||||||
import type { Graph, NodeInstance, NodeRegistry } from '@nodarium/types';
|
import type { Graph, NodeInstance, NodeRegistry } from '@nodarium/types';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { GraphManager } from '../graph-manager.svelte';
|
import { GraphManager } from '../graph-manager.svelte';
|
||||||
import { GraphState, setGraphManager, setGraphState } from '../graph-state.svelte';
|
import { GraphState, setGraphManager, setGraphState } from '../graph-state.svelte';
|
||||||
import { setupKeymaps } from '../keymaps';
|
import { setupKeymaps } from '../keymaps';
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
showHelp?: boolean;
|
showHelp?: boolean;
|
||||||
settingTypes?: Record<string, unknown>;
|
settingTypes?: Record<string, unknown>;
|
||||||
|
|
||||||
addMenuPadding?: { left?: number; right?: number; bottom?: number; top?: number };
|
safePadding?: { left?: number; right?: number; bottom?: number; top?: number };
|
||||||
|
|
||||||
onsave?: (save: Graph) => void;
|
onsave?: (save: Graph) => void;
|
||||||
onresult?: (result: unknown) => void;
|
onresult?: (result: unknown) => void;
|
||||||
@@ -27,7 +28,8 @@
|
|||||||
let {
|
let {
|
||||||
graph,
|
graph,
|
||||||
registry,
|
registry,
|
||||||
addMenuPadding,
|
safePadding,
|
||||||
|
// eslint-disable-next-line no-useless-assignment
|
||||||
settings = $bindable(),
|
settings = $bindable(),
|
||||||
activeNode = $bindable(),
|
activeNode = $bindable(),
|
||||||
backgroundType = $bindable('grid'),
|
backgroundType = $bindable('grid'),
|
||||||
@@ -44,29 +46,32 @@
|
|||||||
export const manager = new GraphManager(registry);
|
export const manager = new GraphManager(registry);
|
||||||
setGraphManager(manager);
|
setGraphManager(manager);
|
||||||
|
|
||||||
const graphState = new GraphState(manager);
|
export const state = new GraphState(manager);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
graphState.backgroundType = backgroundType;
|
if (safePadding) {
|
||||||
graphState.snapToGrid = snapToGrid;
|
state.safePadding = safePadding;
|
||||||
graphState.showHelp = showHelp;
|
}
|
||||||
|
state.backgroundType = backgroundType;
|
||||||
|
state.snapToGrid = snapToGrid;
|
||||||
|
state.showHelp = showHelp;
|
||||||
});
|
});
|
||||||
|
|
||||||
setGraphState(graphState);
|
setGraphState(state);
|
||||||
|
|
||||||
setupKeymaps(keymap, manager, graphState);
|
setupKeymaps(keymap, manager, state);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (graphState.activeNodeId !== -1) {
|
if (state.activeNodeId !== -1) {
|
||||||
activeNode = manager.getNode(graphState.activeNodeId);
|
activeNode = manager.getNode(state.activeNodeId);
|
||||||
} else if (activeNode) {
|
} else if (activeNode) {
|
||||||
activeNode = undefined;
|
activeNode = undefined;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!graphState.addMenuPosition) {
|
if (!state.addMenuPosition) {
|
||||||
graphState.edgeEndPosition = null;
|
state.edgeEndPosition = null;
|
||||||
graphState.activeSocket = null;
|
state.activeSocket = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,11 +84,11 @@
|
|||||||
|
|
||||||
manager.on('save', (save) => onsave?.(save));
|
manager.on('save', (save) => onsave?.(save));
|
||||||
|
|
||||||
$effect(() => {
|
onMount(() => {
|
||||||
if (graph) {
|
if (graph) {
|
||||||
manager.load(graph);
|
manager.load(graph);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<GraphEl {keymap} {addMenuPadding} />
|
<GraphEl {keymap} {safePadding} />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ type Color = { hue: number; saturation: number; lightness: number };
|
|||||||
|
|
||||||
export class ColorGenerator {
|
export class ColorGenerator {
|
||||||
private colors: Map<string, Color> = new Map();
|
private colors: Map<string, Color> = new Map();
|
||||||
private lightnessLevels = [10, 60];
|
// private lightnessLevels = [10, 60];
|
||||||
|
|
||||||
constructor(predefined: Record<string, Color>) {
|
constructor(predefined: Record<string, Color>) {
|
||||||
for (const [id, colorStr] of Object.entries(predefined)) {
|
for (const [id, colorStr] of Object.entries(predefined)) {
|
||||||
@@ -10,6 +10,14 @@ export class ColorGenerator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getColors() {
|
||||||
|
return Object.fromEntries(
|
||||||
|
this.colors.entries().map(([key, col]) => {
|
||||||
|
return [key, this.colorToHsl(col)];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public getColor(id: string): string {
|
public getColor(id: string): string {
|
||||||
if (this.colors.has(id)) {
|
if (this.colors.has(id)) {
|
||||||
return this.colorToHsl(this.colors.get(id)!);
|
return this.colorToHsl(this.colors.get(id)!);
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export class MouseEventManager {
|
|||||||
// if we clicked on a node
|
// if we clicked on a node
|
||||||
if (clickedNodeId !== -1) {
|
if (clickedNodeId !== -1) {
|
||||||
if (event.ctrlKey && event.shiftKey) {
|
if (event.ctrlKey && event.shiftKey) {
|
||||||
this.state.tryConnectToDebugNode(clickedNodeId);
|
this.graph.tryConnectToDebugNode(clickedNodeId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.state.activeNodeId === -1) {
|
if (this.state.activeNodeId === -1) {
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import type { NodeDefinition, NodeInstance } from '@nodarium/types';
|
import type {
|
||||||
|
Edge,
|
||||||
|
NodeDefinition,
|
||||||
|
NodeInstance,
|
||||||
|
SerializedEdge,
|
||||||
|
SerializedNode
|
||||||
|
} from '@nodarium/types';
|
||||||
|
|
||||||
export function getParameterHeight(node: NodeDefinition, inputKey: string) {
|
export function getParameterHeight(node: NodeDefinition, inputKey: string) {
|
||||||
|
if (node.id === '__internal/group/input') {
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
|
||||||
const input = node.inputs?.[inputKey];
|
const input = node.inputs?.[inputKey];
|
||||||
if (!input) {
|
if (!input) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -23,42 +33,31 @@ export function getParameterHeight(node: NodeDefinition, inputKey: string) {
|
|||||||
return 50;
|
return 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSocketPosition(
|
export function serializeNode(node: SerializedNode | NodeInstance): SerializedNode {
|
||||||
node: NodeInstance,
|
return {
|
||||||
index: string | number
|
id: node.id,
|
||||||
): [number, number] {
|
position: [...node.position],
|
||||||
if (typeof index === 'number') {
|
type: node.type,
|
||||||
return [
|
props: node.props
|
||||||
(node?.state?.x ?? node.position[0]) + 20,
|
};
|
||||||
(node?.state?.y ?? node.position[1]) + 2.5 + 10 * index
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
let height = 5;
|
|
||||||
const nodeType = node.state.type!;
|
|
||||||
const inputs = nodeType.inputs || {};
|
|
||||||
for (const inputKey in inputs) {
|
|
||||||
const h = getParameterHeight(nodeType, inputKey) / 10;
|
|
||||||
if (inputKey === index) {
|
|
||||||
height += h / 2;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
height += h;
|
|
||||||
}
|
export function serializeEdge(edge: SerializedEdge | Edge): SerializedEdge {
|
||||||
return [
|
if (typeof edge[0] === 'number' && typeof edge[2] === 'number') {
|
||||||
node?.state?.x ?? node.position[0],
|
return [edge[0], edge[1], edge[2], edge[3]];
|
||||||
(node?.state?.y ?? node.position[1]) + height
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
const e = edge as Edge;
|
||||||
|
return [e[0].id, e[1], e[2].id, e[3]];
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeHeightCache: Record<string, number> = {};
|
const nodeHeightCache: Record<string, number> = {};
|
||||||
export function getNodeHeight(node: NodeDefinition) {
|
export function getNodeHeight(node: NodeDefinition) {
|
||||||
|
if (!node || !('inputs' in node)) {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
if (node.id in nodeHeightCache) {
|
if (node.id in nodeHeightCache) {
|
||||||
return nodeHeightCache[node.id];
|
return nodeHeightCache[node.id];
|
||||||
}
|
}
|
||||||
if (!node?.inputs) {
|
|
||||||
return 5;
|
|
||||||
}
|
|
||||||
let height = 5;
|
let height = 5;
|
||||||
|
|
||||||
for (const key in node.inputs) {
|
for (const key in node.inputs) {
|
||||||
@@ -69,3 +68,34 @@ export function getNodeHeight(node: NodeDefinition) {
|
|||||||
nodeHeightCache[node.id] = height;
|
nodeHeightCache[node.id] = height;
|
||||||
return height;
|
return height;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function areSocketsCompatible(
|
||||||
|
output: string | undefined,
|
||||||
|
inputs: string | (string | undefined)[] | undefined
|
||||||
|
) {
|
||||||
|
if (output === '*') return true;
|
||||||
|
if (Array.isArray(inputs) && output) {
|
||||||
|
return inputs.includes('*') || inputs.includes(output);
|
||||||
|
}
|
||||||
|
return inputs === output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) {
|
||||||
|
if (firstEdge[0].id !== secondEdge[0].id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstEdge[1] !== secondEdge[1]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstEdge[2].id !== secondEdge[2].id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstEdge[3] !== secondEdge[3]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { animate, lerp } from '$lib/helpers';
|
|
||||||
import type { createKeyMap } from '$lib/helpers/createKeyMap';
|
import type { createKeyMap } from '$lib/helpers/createKeyMap';
|
||||||
import { panelState } from '$lib/sidebar/PanelState.svelte';
|
import { panelState } from '$lib/sidebar/PanelState.svelte';
|
||||||
import FileSaver from 'file-saver';
|
import FileSaver from 'file-saver';
|
||||||
@@ -48,6 +47,10 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
|
|||||||
key: 'Escape',
|
key: 'Escape',
|
||||||
description: 'Deselect nodes',
|
description: 'Deselect nodes',
|
||||||
callback: () => {
|
callback: () => {
|
||||||
|
if (graph.isInsideGroup) {
|
||||||
|
graphState.exitGroupNode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
graphState.activeNodeId = -1;
|
graphState.activeNodeId = -1;
|
||||||
graphState.clearSelection();
|
graphState.clearSelection();
|
||||||
graphState.edgeEndPosition = null;
|
graphState.edgeEndPosition = null;
|
||||||
@@ -55,6 +58,29 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
keymap.addShortcut({
|
||||||
|
key: 'g',
|
||||||
|
ctrl: true,
|
||||||
|
preventDefault: true,
|
||||||
|
description: 'Group selected nodes',
|
||||||
|
callback: () => graphState.groupSelectedNodes()
|
||||||
|
});
|
||||||
|
|
||||||
|
keymap.addShortcut({
|
||||||
|
key: 'g',
|
||||||
|
alt: true,
|
||||||
|
preventDefault: true,
|
||||||
|
description: 'Ungroup selected nodes',
|
||||||
|
callback: () => graphState.unGroupSelectedNodes()
|
||||||
|
});
|
||||||
|
|
||||||
|
keymap.addShortcut({
|
||||||
|
key: 'Tab',
|
||||||
|
preventDefault: true,
|
||||||
|
description: 'Enter selected node group',
|
||||||
|
callback: () => graphState.enterGroupNode()
|
||||||
|
});
|
||||||
|
|
||||||
keymap.addShortcut({
|
keymap.addShortcut({
|
||||||
key: 'A',
|
key: 'A',
|
||||||
shift: true,
|
shift: true,
|
||||||
@@ -67,27 +93,7 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
|
|||||||
description: 'Center camera',
|
description: 'Center camera',
|
||||||
callback: () => {
|
callback: () => {
|
||||||
if (!graphState.isBodyFocused()) return;
|
if (!graphState.isBodyFocused()) return;
|
||||||
|
graphState.centerNode(graph.getNode(graphState.activeNodeId));
|
||||||
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] ? average[0] / graph.nodes.size : 0;
|
|
||||||
average[1] = average[1] ? average[1] / graph.nodes.size : 0;
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
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, 2, ease(a));
|
|
||||||
if (graphState.mouseDown) return false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
import type { NodeInstance } from '@nodarium/types';
|
import type { NodeInstance } from '@nodarium/types';
|
||||||
import { T } from '@threlte/core';
|
import { T } from '@threlte/core';
|
||||||
import { type Mesh } from 'three';
|
import { type Mesh } from 'three';
|
||||||
import { getGraphState } from '../graph-state.svelte';
|
import { getGraphManager, getGraphState } from '../graph-state.svelte';
|
||||||
import { colors } from '../graph/colors.svelte';
|
import { colors } from '../graph/colors.svelte';
|
||||||
import { getNodeHeight, getParameterHeight } from '../helpers/nodeHelpers';
|
import { getNodeHeight, getParameterHeight } from '../helpers/nodeHelpers';
|
||||||
import NodeFrag from './Node.frag';
|
import NodeFrag from './Node.frag';
|
||||||
import NodeVert from './Node.vert';
|
import NodeVert from './Node.vert';
|
||||||
import NodeHtml from './NodeHTML.svelte';
|
import NodeHtml from './NodeHTML.svelte';
|
||||||
|
|
||||||
|
const graph = getGraphManager();
|
||||||
const graphState = getGraphState();
|
const graphState = getGraphState();
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
};
|
};
|
||||||
let { node = $bindable(), inView }: Props = $props();
|
let { node = $bindable(), inView }: Props = $props();
|
||||||
|
|
||||||
const nodeType = $derived(node.state.type!);
|
const nodeType = $derived(node ? graph.getNodeType(node) : undefined);
|
||||||
|
|
||||||
const isActive = $derived(graphState.activeNodeId === node.id);
|
const isActive = $derived(graphState.activeNodeId === node.id);
|
||||||
const isSelected = $derived(graphState.selectedNodes.has(node.id));
|
const isSelected = $derived(graphState.selectedNodes.has(node.id));
|
||||||
@@ -32,15 +33,17 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const sectionHeights = $derived(
|
const sectionHeights = $derived(
|
||||||
Object
|
nodeType
|
||||||
.keys(nodeType.inputs || {})
|
? Object
|
||||||
|
.keys(nodeType?.inputs || {})
|
||||||
.map(key => getParameterHeight(nodeType, key) / 10)
|
.map(key => getParameterHeight(nodeType, key) / 10)
|
||||||
.filter(b => !!b)
|
.filter(b => !!b)
|
||||||
|
: [5]
|
||||||
);
|
);
|
||||||
|
|
||||||
let meshRef: Mesh | undefined = $state();
|
let meshRef: Mesh | undefined = $state();
|
||||||
|
|
||||||
const height = getNodeHeight(node.state.type!);
|
const height = $derived(nodeType ? getNodeHeight(nodeType) : 20);
|
||||||
|
|
||||||
const zoom = $derived(graphState.cameraPosition[2]);
|
const zoom = $derived(graphState.cameraPosition[2]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { NodeInstance } from '@nodarium/types';
|
import type { NodeInstance } from '@nodarium/types';
|
||||||
import { getGraphState } from '../graph-state.svelte';
|
import { getGraphManager, getGraphState } from '../graph-state.svelte';
|
||||||
import NodeHeader from './NodeHeader.svelte';
|
import NodeHeader from './NodeHeader.svelte';
|
||||||
import NodeParameter from './NodeParameter.svelte';
|
import NodeParameter from './NodeParameter.svelte';
|
||||||
|
|
||||||
let ref: HTMLDivElement;
|
let ref: HTMLDivElement;
|
||||||
|
|
||||||
|
const graph = getGraphManager();
|
||||||
const graphState = getGraphState();
|
const graphState = getGraphState();
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -30,8 +31,12 @@
|
|||||||
const zOffset = Math.random() - 0.5;
|
const zOffset = Math.random() - 0.5;
|
||||||
const zLimit = 2 - zOffset;
|
const zLimit = 2 - zOffset;
|
||||||
|
|
||||||
const parameters = Object.entries(node.state?.type?.inputs || {}).filter(
|
const nodeType = $derived(graph.getNodeType(node));
|
||||||
|
|
||||||
|
const parameters = $derived(
|
||||||
|
Object.entries(nodeType?.inputs || {}).filter(
|
||||||
(p) => p[1].type !== 'seed' && !('setting' in p[1]) && p[1]?.hidden !== true
|
(p) => p[1].type !== 'seed' && !('setting' in p[1]) && p[1]?.hidden !== true
|
||||||
|
) || {}
|
||||||
);
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { appSettings } from '$lib/settings/app-settings.svelte';
|
import { appSettings } from '$lib/settings/app-settings.svelte';
|
||||||
import type { NodeInstance, Socket } from '@nodarium/types';
|
import type { NodeInstance, Socket } from '@nodarium/types';
|
||||||
import { getGraphState } from '../graph-state.svelte';
|
import { getGraphManager, getGraphState } from '../graph-state.svelte';
|
||||||
import { createNodePath } from '../helpers/index.js';
|
import { createNodePath } from '../helpers/index.js';
|
||||||
import { getSocketPosition } from '../helpers/nodeHelpers';
|
|
||||||
|
|
||||||
const graphState = getGraphState();
|
const graphState = getGraphState();
|
||||||
|
const graph = getGraphManager();
|
||||||
|
|
||||||
const { node }: { node: NodeInstance } = $props();
|
const { node }: { node: NodeInstance } = $props();
|
||||||
|
|
||||||
@@ -16,13 +16,24 @@
|
|||||||
graphState.setDownSocket?.({
|
graphState.setDownSocket?.({
|
||||||
node,
|
node,
|
||||||
index: 0,
|
index: 0,
|
||||||
position: getSocketPosition?.(node, 0)
|
position: graphState.getSocketPosition?.(node, 0)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cornerTop = 10;
|
const cornerTop = 10;
|
||||||
const rightBump = $derived(!!node?.state?.type?.outputs?.length);
|
const nodeType = $derived(graph.getNodeType(node));
|
||||||
|
const rightBump = $derived(
|
||||||
|
!!nodeType?.outputs?.length && node.type !== '__internal/group/input'
|
||||||
|
);
|
||||||
|
const cornerBottom = $derived(
|
||||||
|
node.type === '__internal/group/input'
|
||||||
|
? (Object.keys(nodeType?.inputs ?? {}).length ? 0 : 10)
|
||||||
|
: node.type === '__internal/group/output'
|
||||||
|
? (nodeType?.outputs?.length ? 0 : 10)
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
|
||||||
const aspectRatio = 0.25;
|
const aspectRatio = 0.25;
|
||||||
|
|
||||||
const path = $derived(
|
const path = $derived(
|
||||||
@@ -31,6 +42,7 @@
|
|||||||
height: 34,
|
height: 34,
|
||||||
y: 49,
|
y: 49,
|
||||||
cornerTop,
|
cornerTop,
|
||||||
|
cornerBottom,
|
||||||
rightBump,
|
rightBump,
|
||||||
aspectRatio
|
aspectRatio
|
||||||
})
|
})
|
||||||
@@ -41,6 +53,7 @@
|
|||||||
height: 40,
|
height: 40,
|
||||||
y: 49,
|
y: 49,
|
||||||
cornerTop,
|
cornerTop,
|
||||||
|
cornerBottom,
|
||||||
rightBump,
|
rightBump,
|
||||||
aspectRatio
|
aspectRatio
|
||||||
})
|
})
|
||||||
@@ -70,8 +83,9 @@
|
|||||||
{#if appSettings.value.debug.advancedMode}
|
{#if appSettings.value.debug.advancedMode}
|
||||||
<span class="bg-white text-black! mr-2 px-1 rounded-sm opacity-30">{node.id}</span>
|
<span class="bg-white text-black! mr-2 px-1 rounded-sm opacity-30">{node.id}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{node.type.split('/').pop()}
|
{nodeType?.meta?.title || node.type?.split('/').pop()}
|
||||||
</div>
|
</div>
|
||||||
|
{#if rightBump}
|
||||||
<div
|
<div
|
||||||
class="target"
|
class="target"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -79,6 +93,7 @@
|
|||||||
onmousedown={handleMouseDown}
|
onmousedown={handleMouseDown}
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 100 100"
|
viewBox="0 0 100 100"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { NodeInput, NodeInstance, Socket } from '@nodarium/types';
|
import type { NodeInput, NodeInstance, Socket } from '@nodarium/types';
|
||||||
import { getGraphManager, getGraphState } from '../graph-state.svelte';
|
import { getGraphManager, getGraphState } from '../graph-state.svelte';
|
||||||
import { createNodePath } from '../helpers';
|
import { createNodePath } from '../helpers';
|
||||||
import { getParameterHeight, getSocketPosition } from '../helpers/nodeHelpers';
|
import { getParameterHeight } from '../helpers/nodeHelpers';
|
||||||
import NodeInputEl from './NodeInput.svelte';
|
import NodeInputEl from './NodeInput.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
let { node = $bindable(), input, id, isLast }: Props = $props();
|
let { node = $bindable(), input, id, isLast }: Props = $props();
|
||||||
|
|
||||||
const nodeType = $derived(node.state.type!);
|
let nodeType = $derived(graph.getNodeType(node)!);
|
||||||
|
|
||||||
const inputType = $derived(nodeType.inputs?.[id]);
|
const inputType = $derived(nodeType.inputs?.[id]);
|
||||||
|
|
||||||
@@ -29,14 +29,27 @@
|
|||||||
function handleMouseDown(ev: MouseEvent) {
|
function handleMouseDown(ev: MouseEvent) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
if (node.type === '__internal/group/input') {
|
||||||
|
const outputIndex = Object.entries(nodeType?.inputs ?? {}).findIndex(([key]) => key === id);
|
||||||
|
graphState.setDownSocket({
|
||||||
|
node,
|
||||||
|
index: outputIndex,
|
||||||
|
position: graphState.getSocketPosition(node, outputIndex)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
graphState.setDownSocket({
|
graphState.setDownSocket({
|
||||||
node,
|
node,
|
||||||
index: id,
|
index: id,
|
||||||
position: getSocketPosition(node, id)
|
position: graphState.getSocketPosition(node, id)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const leftBump = $derived(nodeType.inputs?.[id].internal !== true);
|
const leftBump = $derived(
|
||||||
|
nodeType.inputs?.[id].internal !== true && node.type !== '__internal/group/input'
|
||||||
|
);
|
||||||
|
const rightBump = $derived(node.type === '__internal/group/input');
|
||||||
const cornerBottom = $derived(isLast ? 5 : 0);
|
const cornerBottom = $derived(isLast ? 5 : 0);
|
||||||
const aspectRatio = 0.5;
|
const aspectRatio = 0.5;
|
||||||
|
|
||||||
@@ -46,6 +59,7 @@
|
|||||||
height: 2000 / height,
|
height: 2000 / height,
|
||||||
y: 50.5,
|
y: 50.5,
|
||||||
cornerBottom,
|
cornerBottom,
|
||||||
|
rightBump,
|
||||||
leftBump,
|
leftBump,
|
||||||
aspectRatio
|
aspectRatio
|
||||||
})
|
})
|
||||||
@@ -55,6 +69,7 @@
|
|||||||
depth: 7,
|
depth: 7,
|
||||||
height: 2200 / height,
|
height: 2200 / height,
|
||||||
y: 50.5,
|
y: 50.5,
|
||||||
|
rightBump,
|
||||||
cornerBottom,
|
cornerBottom,
|
||||||
leftBump,
|
leftBump,
|
||||||
aspectRatio
|
aspectRatio
|
||||||
@@ -76,6 +91,7 @@
|
|||||||
<div
|
<div
|
||||||
class="wrapper"
|
class="wrapper"
|
||||||
data-node-type={node.type}
|
data-node-type={node.type}
|
||||||
|
class:is-group-input={node.type === '__internal/group/input'}
|
||||||
data-node-input={id}
|
data-node-input={id}
|
||||||
style:height="{height}px"
|
style:height="{height}px"
|
||||||
style:--socket-color={hoverColor}
|
style:--socket-color={hoverColor}
|
||||||
@@ -130,6 +146,11 @@
|
|||||||
transform: translateY(-50%) translateX(-50%);
|
transform: translateY(-50%) translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.is-group-input .target {
|
||||||
|
right: 0px;
|
||||||
|
transform: translateY(-50%) translateX(50%);
|
||||||
|
}
|
||||||
|
|
||||||
.possible-socket .target::before {
|
.possible-socket .target::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ export function createMockNodeRegistry(nodes: NodeDefinition[]): NodeRegistry {
|
|||||||
|
|
||||||
export const mockFloatOutputNode: NodeDefinition = {
|
export const mockFloatOutputNode: NodeDefinition = {
|
||||||
id: 'test/node/output',
|
id: 'test/node/output',
|
||||||
inputs: {},
|
inputs: {
|
||||||
|
'input': {
|
||||||
|
type: 'float'
|
||||||
|
}
|
||||||
|
},
|
||||||
outputs: ['float'],
|
outputs: ['float'],
|
||||||
meta: { title: 'Float Output' },
|
meta: { title: 'Float Output' },
|
||||||
execute: () => new Int32Array()
|
execute: () => new Int32Array()
|
||||||
@@ -32,7 +36,7 @@ export const mockFloatOutputNode: NodeDefinition = {
|
|||||||
export const mockFloatInputNode: NodeDefinition = {
|
export const mockFloatInputNode: NodeDefinition = {
|
||||||
id: 'test/node/input',
|
id: 'test/node/input',
|
||||||
inputs: { value: { type: 'float' } },
|
inputs: { value: { type: 'float' } },
|
||||||
outputs: [],
|
outputs: ['float'],
|
||||||
meta: { title: 'Float Input' },
|
meta: { title: 'Float Input' },
|
||||||
execute: () => new Int32Array()
|
execute: () => new Int32Array()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ export function grid(width: number, height: number) {
|
|||||||
const graph: Graph = {
|
const graph: Graph = {
|
||||||
id: Math.floor(Math.random() * 100000),
|
id: Math.floor(Math.random() * 100000),
|
||||||
edges: [],
|
edges: [],
|
||||||
nodes: []
|
nodes: [],
|
||||||
|
groups: []
|
||||||
};
|
};
|
||||||
|
|
||||||
const amount = width * height;
|
const amount = width * height;
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export { default as lottaNodes } from './lotta-nodes.json';
|
|||||||
export { plant } from './plant';
|
export { plant } from './plant';
|
||||||
export { default as simple } from './simple.json';
|
export { default as simple } from './simple.json';
|
||||||
export { tree } from './tree';
|
export { tree } from './tree';
|
||||||
|
export { default as tutorial } from './tutorial.json';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"resolution.circle": 54,
|
"resolution.circle": 54,
|
||||||
"resolution.curve": 20,
|
"resolution.curve": 20,
|
||||||
"randomSeed": true
|
"randomSeed": false
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "New Project",
|
"title": "New Project",
|
||||||
@@ -27,9 +27,9 @@
|
|||||||
],
|
],
|
||||||
"type": "max/plantarium/stem",
|
"type": "max/plantarium/stem",
|
||||||
"props": {
|
"props": {
|
||||||
"amount": 50,
|
"amount": 4,
|
||||||
"length": 4,
|
"length": 4,
|
||||||
"thickness": 1
|
"thickness": 0.2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function tree(depth: number): Graph {
|
|||||||
return {
|
return {
|
||||||
id: Math.floor(Math.random() * 100000),
|
id: Math.floor(Math.random() * 100000),
|
||||||
nodes,
|
nodes,
|
||||||
edges
|
edges,
|
||||||
|
groups: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
export const debugNode = {
|
export const debugNode = {
|
||||||
id: 'max/plantarium/debug',
|
id: '__internal/node/debug',
|
||||||
|
meta: {
|
||||||
|
title: 'Debug'
|
||||||
|
},
|
||||||
inputs: {
|
inputs: {
|
||||||
input: {
|
input: {
|
||||||
type: '*'
|
type: '*',
|
||||||
|
label: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
execute(_data: Int32Array): Int32Array {
|
execute(_data: Int32Array): Int32Array {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export const groupNode = {
|
||||||
|
id: '__internal/group/instance',
|
||||||
|
meta: { title: 'Group' },
|
||||||
|
inputs: {
|
||||||
|
groupId: {
|
||||||
|
label: '',
|
||||||
|
type: 'select',
|
||||||
|
values: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
execute(_data: Int32Array): Int32Array {
|
||||||
|
return _data;
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
@@ -88,6 +88,7 @@ export class RemoteNodeRegistry implements NodeRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fetchNodeDefinition(nodeId: `${string}/${string}/${string}`) {
|
async fetchNodeDefinition(nodeId: `${string}/${string}/${string}`) {
|
||||||
|
if (nodeId.startsWith('__internal/')) return;
|
||||||
return this.fetchJson(`nodes/${nodeId}.json`);
|
return this.fetchJson(`nodes/${nodeId}.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +110,8 @@ export class RemoteNodeRegistry implements NodeRegistry {
|
|||||||
return this.nodes.get(id)!;
|
return this.nodes.get(id)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (id.startsWith('__internal/')) return;
|
||||||
|
|
||||||
const wasmBuffer = await this.fetchNodeWasm(id);
|
const wasmBuffer = await this.fetchNodeWasm(id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
let geometryPool: ReturnType<typeof createGeometryPool>;
|
let geometryPool: ReturnType<typeof createGeometryPool>;
|
||||||
let instancePool: ReturnType<typeof createInstancedGeometryPool>;
|
let instancePool: ReturnType<typeof createInstancedGeometryPool>;
|
||||||
|
|
||||||
|
export function invalidate() {
|
||||||
|
sceneComponent?.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
export function updateGeometries(inputs: Int32Array[], group: Group) {
|
export function updateGeometries(inputs: Int32Array[], group: Group) {
|
||||||
geometryPool = geometryPool || createGeometryPool(group, material);
|
geometryPool = geometryPool || createGeometryPool(group, material);
|
||||||
instancePool = instancePool || createInstancedGeometryPool(group, material);
|
instancePool = instancePool || createInstancedGeometryPool(group, material);
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ export function createGeometryPool(parentScene: Group, material: Material) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normals = new Float32Array(data.buffer, index * 4, vertexCount * 3);
|
const normals = new Float32Array(data.buffer, index * 4, vertexCount * 3);
|
||||||
index = index + vertexCount * 3;
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
geometry.userData?.faceCount !== faceCount
|
geometry.userData?.faceCount !== faceCount
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import type { Graph } from '@nodarium/types';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { expandGroups } from './runtime-executor';
|
||||||
|
|
||||||
|
// Helpers to build minimal serialized nodes/edges
|
||||||
|
function node(id: number, type: string, props?: Record<string, number>) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: type as Graph['nodes'][0]['type'],
|
||||||
|
position: [0, 0] as [number, number],
|
||||||
|
...(props ? { props } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function edge(
|
||||||
|
from: number,
|
||||||
|
fromSocket: number,
|
||||||
|
to: number,
|
||||||
|
toSocket: string
|
||||||
|
): [number, number, number, string] {
|
||||||
|
return [from, fromSocket, to, toSocket];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('expandGroups', () => {
|
||||||
|
it('returns graph unchanged when there are no groups', () => {
|
||||||
|
const graph: Graph = {
|
||||||
|
id: 1,
|
||||||
|
nodes: [node(0, 'test/node/output'), node(1, 'test/node/input')],
|
||||||
|
edges: [edge(0, 0, 1, 'value')],
|
||||||
|
groups: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = expandGroups(graph);
|
||||||
|
|
||||||
|
expect(result.nodes.length).toBe(2);
|
||||||
|
expect(result.edges.length).toBe(1);
|
||||||
|
expect(result).toBe(graph); // same reference — no copy needed
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands a simple group: A → [B] → C rewires to A → B → C', () => {
|
||||||
|
// IDs: A=1, B=2, C=3, groupNode=4, group.id=5, inputBoundary=6, outputBoundary=7
|
||||||
|
const groupId = 5;
|
||||||
|
const groupNodeId = 4;
|
||||||
|
const remappedB = (groupNodeId + 1) * 1_000_000 + 2; // 5_000_002
|
||||||
|
|
||||||
|
const graph: Graph = {
|
||||||
|
id: 1,
|
||||||
|
nodes: [
|
||||||
|
node(1, 'test/node/output'),
|
||||||
|
node(groupNodeId, '__internal/group/instance', { groupId }),
|
||||||
|
node(3, 'test/node/input')
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
edge(1, 0, groupNodeId, 'input_0'), // A → group
|
||||||
|
edge(groupNodeId, 0, 3, 'value') // group → C
|
||||||
|
],
|
||||||
|
groups: [{
|
||||||
|
id: groupId,
|
||||||
|
nodes: [
|
||||||
|
node(6, '__internal/group/input'),
|
||||||
|
node(2, 'test/node/output'),
|
||||||
|
node(7, '__internal/group/output')
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
edge(6, 0, 2, 'input'), // inputBoundary → B
|
||||||
|
edge(2, 0, 7, 'Out') // B → outputBoundary
|
||||||
|
],
|
||||||
|
inputs: { input_0: { type: 'float' } },
|
||||||
|
outputs: [{ type: 'float', label: 'Output 0' }]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = expandGroups(graph);
|
||||||
|
|
||||||
|
const ids = result.nodes.map(n => n.id);
|
||||||
|
expect(ids).not.toContain(groupNodeId);
|
||||||
|
expect(ids).toContain(remappedB);
|
||||||
|
expect(ids).toContain(1); // A
|
||||||
|
expect(ids).toContain(3); // C
|
||||||
|
expect(result.nodes.length).toBe(3); // A, B(remapped), C
|
||||||
|
|
||||||
|
expect(result.edges).toContainEqual(edge(1, 0, remappedB, 'input')); // A → B
|
||||||
|
expect(result.edges).toContainEqual(edge(remappedB, 0, 3, 'value')); // B → C
|
||||||
|
expect(result.edges.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands a group with two internal nodes (B→D) and preserves the internal edge', () => {
|
||||||
|
// A → [B → D] → C
|
||||||
|
const groupId = 10;
|
||||||
|
const groupNodeId = 5;
|
||||||
|
const remappedB = (groupNodeId + 1) * 1_000_000 + 1; // 6_000_001
|
||||||
|
const remappedD = (groupNodeId + 1) * 1_000_000 + 2; // 6_000_002
|
||||||
|
|
||||||
|
const graph: Graph = {
|
||||||
|
id: 1,
|
||||||
|
nodes: [
|
||||||
|
node(0, 'test/node/output'),
|
||||||
|
node(groupNodeId, '__internal/group/instance', { groupId }),
|
||||||
|
node(9, 'test/node/input')
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
edge(0, 0, groupNodeId, 'input_0'),
|
||||||
|
edge(groupNodeId, 0, 9, 'value')
|
||||||
|
],
|
||||||
|
groups: [{
|
||||||
|
id: groupId,
|
||||||
|
nodes: [
|
||||||
|
node(3, '__internal/group/input'),
|
||||||
|
node(1, 'test/node/output'), // B
|
||||||
|
node(2, 'test/node/output'), // D
|
||||||
|
node(4, '__internal/group/output')
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
edge(3, 0, 1, 'input'), // inputBoundary → B
|
||||||
|
edge(1, 0, 2, 'input'), // B → D (internal)
|
||||||
|
edge(2, 0, 4, 'Out') // D → outputBoundary
|
||||||
|
],
|
||||||
|
inputs: { input_0: { type: 'float' } },
|
||||||
|
outputs: [{ type: 'float' }]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = expandGroups(graph);
|
||||||
|
|
||||||
|
expect(result.nodes.map(n => n.id)).not.toContain(groupNodeId);
|
||||||
|
expect(result.nodes.map(n => n.id)).toContain(remappedB);
|
||||||
|
expect(result.nodes.map(n => n.id)).toContain(remappedD);
|
||||||
|
|
||||||
|
expect(result.edges).toContainEqual(edge(0, 0, remappedB, 'input')); // A → B
|
||||||
|
expect(result.edges).toContainEqual(edge(remappedB, 0, remappedD, 'input')); // B → D (internal)
|
||||||
|
expect(result.edges).toContainEqual(edge(remappedD, 0, 9, 'value')); // D → C
|
||||||
|
expect(result.edges.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands a group with no external connections (isolated)', () => {
|
||||||
|
const groupId = 20;
|
||||||
|
const groupNodeId = 1;
|
||||||
|
const remappedB = (groupNodeId + 1) * 1_000_000 + 2; // 2_000_002
|
||||||
|
|
||||||
|
const graph: Graph = {
|
||||||
|
id: 1,
|
||||||
|
nodes: [node(groupNodeId, '__internal/group/instance', { groupId })],
|
||||||
|
edges: [],
|
||||||
|
groups: [{
|
||||||
|
id: groupId,
|
||||||
|
nodes: [
|
||||||
|
node(3, '__internal/group/input'),
|
||||||
|
node(2, 'test/node/output'),
|
||||||
|
node(4, '__internal/group/output')
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
edge(3, 0, 2, 'input'),
|
||||||
|
edge(2, 0, 4, 'Out')
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = expandGroups(graph);
|
||||||
|
|
||||||
|
expect(result.nodes.map(n => n.id)).not.toContain(groupNodeId);
|
||||||
|
expect(result.nodes.map(n => n.id)).toContain(remappedB);
|
||||||
|
expect(result.edges.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,113 @@ import type { RuntimeNode } from './types';
|
|||||||
const log = createLogger('runtime-executor');
|
const log = createLogger('runtime-executor');
|
||||||
log.mute();
|
log.mute();
|
||||||
|
|
||||||
|
export function expandGroups(graph: Graph): Graph {
|
||||||
|
if (!graph.groups || graph.groups.length === 0) return graph;
|
||||||
|
|
||||||
|
function groupContainsSelf(groupId: number, visited = new Set<number>()): boolean {
|
||||||
|
if (visited.has(groupId)) return true;
|
||||||
|
visited.add(groupId);
|
||||||
|
const group = graph.groups!.find(g => g.id === groupId);
|
||||||
|
if (!group) return false;
|
||||||
|
for (const n of group.nodes) {
|
||||||
|
if (n.type === '__internal/group/instance') {
|
||||||
|
const nestedId = n.props?.groupId as number | undefined;
|
||||||
|
if (nestedId !== undefined && groupContainsSelf(nestedId, visited)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of graph.groups) {
|
||||||
|
if (groupContainsSelf(group.id)) {
|
||||||
|
throw new Error(`Circular group reference: group ${group.id} contains itself`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = [...graph.nodes];
|
||||||
|
let edges = [...graph.edges];
|
||||||
|
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
const node = nodes[i];
|
||||||
|
if (node.type !== '__internal/group/instance') continue;
|
||||||
|
|
||||||
|
const groupId = node.props?.groupId as number | undefined;
|
||||||
|
if (groupId === undefined) continue;
|
||||||
|
|
||||||
|
const group = graph.groups.find(g => g.id === groupId);
|
||||||
|
if (!group) continue;
|
||||||
|
|
||||||
|
changed = true;
|
||||||
|
|
||||||
|
const ID_OFFSET = (node.id + 1) * 1_000_000;
|
||||||
|
const idMap = new Map<number, number>();
|
||||||
|
|
||||||
|
const inputBoundary = group.nodes.find(n => n.type === '__internal/group/input');
|
||||||
|
const outputBoundary = group.nodes.find(n => n.type === '__internal/group/output');
|
||||||
|
|
||||||
|
const realNodes = group.nodes.filter(
|
||||||
|
n => n.type !== '__internal/group/input' && n.type !== '__internal/group/output'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const n of realNodes) idMap.set(n.id, ID_OFFSET + n.id);
|
||||||
|
|
||||||
|
const incomingExternal = edges.filter(e => e[2] === node.id);
|
||||||
|
const outgoingExternal = edges.filter(e => e[0] === node.id);
|
||||||
|
const newEdges: Graph['edges'] = [];
|
||||||
|
|
||||||
|
// external_source → [inputBoundary →] internal_target
|
||||||
|
//
|
||||||
|
// External socket names are "input_N" where N equals the input boundary's
|
||||||
|
// output index. Match each external edge only to the internal edges that
|
||||||
|
// originate from that specific output slot — not a cartesian product of all.
|
||||||
|
if (inputBoundary) {
|
||||||
|
const fromInput = group.edges.filter(e => e[0] === inputBoundary.id);
|
||||||
|
for (const extEdge of incomingExternal) {
|
||||||
|
const inputIndex = parseInt((extEdge[3] as string).replace('input_', ''), 10);
|
||||||
|
const matchingIntEdges = fromInput.filter(e => e[1] === inputIndex);
|
||||||
|
for (const intEdge of matchingIntEdges) {
|
||||||
|
const toId = idMap.get(intEdge[2]);
|
||||||
|
if (toId !== undefined) newEdges.push([extEdge[0], extEdge[1], toId, intEdge[3]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// internal_source → [outputBoundary →] external_target
|
||||||
|
if (outputBoundary) {
|
||||||
|
const toOutput = group.edges.filter(e => e[2] === outputBoundary.id);
|
||||||
|
for (const extEdge of outgoingExternal) {
|
||||||
|
for (const intEdge of toOutput) {
|
||||||
|
const fromId = idMap.get(intEdge[0]);
|
||||||
|
if (fromId !== undefined) newEdges.push([fromId, intEdge[1], extEdge[2], extEdge[3]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// internal-to-internal edges (skip boundary edges)
|
||||||
|
for (const e of group.edges) {
|
||||||
|
if (e[0] === inputBoundary?.id || e[2] === outputBoundary?.id) continue;
|
||||||
|
const fromId = idMap.get(e[0]);
|
||||||
|
const toId = idMap.get(e[2]);
|
||||||
|
if (fromId !== undefined && toId !== undefined) newEdges.push([fromId, e[1], toId, e[3]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.splice(i, 1);
|
||||||
|
for (const n of realNodes) nodes.push({ ...n, id: idMap.get(n.id)! });
|
||||||
|
|
||||||
|
edges = edges.filter(e => e[0] !== node.id && e[2] !== node.id);
|
||||||
|
edges.push(...newEdges);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...graph, nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
function getValue(input: NodeInput, value?: unknown) {
|
function getValue(input: NodeInput, value?: unknown) {
|
||||||
if (value === undefined && 'value' in input) {
|
if (value === undefined && 'value' in input) {
|
||||||
value = input.value;
|
value = input.value;
|
||||||
@@ -75,7 +182,11 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
|
|||||||
throw new Error('Node registry is not ready');
|
throw new Error('Node registry is not ready');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.registry.load(graph.nodes.map((node) => node.type));
|
// Only load non-virtual types (virtual nodes are resolved locally)
|
||||||
|
const nonVirtualTypes = graph.nodes
|
||||||
|
.map(node => node.type)
|
||||||
|
.filter(t => !t.startsWith('__internal/'));
|
||||||
|
await this.registry.load(nonVirtualTypes);
|
||||||
|
|
||||||
const typeMap = new Map<string, NodeDefinition>();
|
const typeMap = new Map<string, NodeDefinition>();
|
||||||
for (const node of graph.nodes) {
|
for (const node of graph.nodes) {
|
||||||
@@ -163,6 +274,9 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
|
|||||||
let a = performance.now();
|
let a = performance.now();
|
||||||
this.debugData = {};
|
this.debugData = {};
|
||||||
|
|
||||||
|
// Expand group nodes into a flat graph before execution
|
||||||
|
graph = expandGroups(graph);
|
||||||
|
|
||||||
// Then we add some metadata to the graph
|
// Then we add some metadata to the graph
|
||||||
const [outputNode, nodes] = await this.addMetaData(graph);
|
const [outputNode, nodes] = await this.addMetaData(graph);
|
||||||
let b = performance.now();
|
let b = performance.now();
|
||||||
@@ -219,7 +333,7 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
|
|||||||
if (inputNode) {
|
if (inputNode) {
|
||||||
if (results[inputNode.id] === undefined) {
|
if (results[inputNode.id] === undefined) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Node ${node.type} is missing input from node ${inputNode.type}`
|
`Node ${node.type} is missing input from node ${inputNode.type}#${inputNode.id}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return results[inputNode.id];
|
return results[inputNode.id];
|
||||||
|
|||||||
@@ -28,13 +28,14 @@
|
|||||||
key?: string;
|
key?: string;
|
||||||
value: SettingsValue;
|
value: SettingsValue;
|
||||||
type: SettingsType;
|
type: SettingsType;
|
||||||
|
onButtonClick?: (id: string) => void;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Local persistent state for <details> sections
|
// Local persistent state for <details> sections
|
||||||
const openSections = localState<Record<string, boolean>>('open-details', {});
|
const openSections = localState<Record<string, boolean>>('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 {
|
function isNodeInput(v: SettingsNode | undefined): v is InputType {
|
||||||
return !!v && typeof v === 'object' && 'type' in v;
|
return !!v && typeof v === 'object' && 'type' in v;
|
||||||
@@ -107,11 +108,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleClick() {
|
|
||||||
const callback = value[key] as unknown as () => void;
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
open = openSections.value[id];
|
open = openSections.value[id];
|
||||||
|
|
||||||
@@ -130,7 +126,7 @@
|
|||||||
{@const inputType = type[key]}
|
{@const inputType = type[key]}
|
||||||
<div class="input input-{inputType.type}" class:first-level={depth === 1}>
|
<div class="input input-{inputType.type}" class:first-level={depth === 1}>
|
||||||
{#if inputType.type === 'button'}
|
{#if inputType.type === 'button'}
|
||||||
<button onclick={handleClick}>
|
<button onclick={() => onButtonClick?.(id)}>
|
||||||
{inputType.label || key}
|
{inputType.label || key}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -143,6 +139,7 @@
|
|||||||
{:else if depth === 0}
|
{:else if depth === 0}
|
||||||
{#each Object.keys(type ?? {}).filter((k) => k !== 'title') as childKey (childKey)}
|
{#each Object.keys(type ?? {}).filter((k) => k !== 'title') as childKey (childKey)}
|
||||||
<NestedSettings
|
<NestedSettings
|
||||||
|
{onButtonClick}
|
||||||
id={`${id}.${childKey}`}
|
id={`${id}.${childKey}`}
|
||||||
key={childKey}
|
key={childKey}
|
||||||
bind:value
|
bind:value
|
||||||
@@ -160,6 +157,7 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
{#each Object.keys(type[key] as SettingsGroup).filter((k) => k !== 'title') as childKey (childKey)}
|
{#each Object.keys(type[key] as SettingsGroup).filter((k) => k !== 'title') as childKey (childKey)}
|
||||||
<NestedSettings
|
<NestedSettings
|
||||||
|
{onButtonClick}
|
||||||
id={`${id}.${childKey}`}
|
id={`${id}.${childKey}`}
|
||||||
key={childKey}
|
key={childKey}
|
||||||
bind:value={value[key] as SettingsValue}
|
bind:value={value[key] as SettingsValue}
|
||||||
@@ -206,6 +204,13 @@
|
|||||||
|
|
||||||
.input-boolean > label {
|
.input-boolean > label {
|
||||||
order: 2;
|
order: 2;
|
||||||
|
font-size: 1em;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.first-level.input {
|
.first-level.input {
|
||||||
@@ -221,6 +226,9 @@
|
|||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
background: var(--color-layer-2);
|
||||||
|
padding-block: 5px;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
hr {
|
hr {
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export const AppSettingTypes = {
|
|||||||
label: 'Center Camera',
|
label: 'Center Camera',
|
||||||
value: true
|
value: true
|
||||||
},
|
},
|
||||||
|
clippy: {
|
||||||
|
type: 'button',
|
||||||
|
label: '🌱 Open Planty'
|
||||||
|
},
|
||||||
nodeInterface: {
|
nodeInterface: {
|
||||||
title: 'Node Interface',
|
title: 'Node Interface',
|
||||||
backgroundType: {
|
backgroundType: {
|
||||||
@@ -109,8 +113,7 @@ export const AppSettingTypes = {
|
|||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type SettingsToStore<T> = T extends { type: 'button' } ? () => void
|
type SettingsToStore<T> = T extends { value: infer V } ? V extends readonly string[] ? V[number]
|
||||||
: T extends { value: infer V } ? V extends readonly string[] ? V[number]
|
|
||||||
: V
|
: V
|
||||||
: T extends object ? {
|
: T extends object ? {
|
||||||
-readonly [K in keyof T as T[K] extends object ? K : never]: SettingsToStore<T[K]>;
|
-readonly [K in keyof T as T[K] extends object ? K : never]: SettingsToStore<T[K]>;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { type Snippet } from 'svelte';
|
import { type Snippet } from 'svelte';
|
||||||
import { panelState as state } from './PanelState.svelte';
|
import { panelState as state } from './PanelState.svelte';
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-useless-assignment
|
||||||
let { children, open = $bindable(false) } = $props<{ children?: Snippet; open?: boolean }>();
|
let { children, open = $bindable(false) } = $props<{ children?: Snippet; open?: boolean }>();
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
|
|
||||||
import NestedSettings from '$lib/settings/NestedSettings.svelte';
|
|
||||||
import type { NodeId, NodeInput, NodeInstance } from '@nodarium/types';
|
|
||||||
|
|
||||||
type InternalNodeInput = NodeInput & {
|
|
||||||
__node_type?: NodeId;
|
|
||||||
__node_input: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
manager: GraphManager;
|
|
||||||
node: NodeInstance;
|
|
||||||
};
|
|
||||||
|
|
||||||
const { manager, node = $bindable() }: Props = $props();
|
|
||||||
|
|
||||||
function filterInputs(inputs?: Record<string, NodeInput>) {
|
|
||||||
const _inputs = $state.snapshot(
|
|
||||||
inputs as Record<string, InternalNodeInput>
|
|
||||||
);
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(structuredClone(_inputs ?? {}))
|
|
||||||
.filter(([, value]) => {
|
|
||||||
return value.hidden === true;
|
|
||||||
})
|
|
||||||
.map(([key, value]) => {
|
|
||||||
value.__node_type = node.state.type?.id;
|
|
||||||
value.__node_input = key;
|
|
||||||
return [key, value];
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const nodeDefinition = filterInputs(node.state.type?.inputs);
|
|
||||||
|
|
||||||
type Store = Record<string, number | number[]>;
|
|
||||||
let store = $state<Store>(createStore(node?.props, nodeDefinition));
|
|
||||||
function createStore(
|
|
||||||
props: NodeInstance['props'],
|
|
||||||
inputs: Record<string, NodeInput>
|
|
||||||
): Store {
|
|
||||||
const store: Store = {};
|
|
||||||
Object.keys(inputs).forEach((key) => {
|
|
||||||
if (props) {
|
|
||||||
const value = props[key] !== undefined ? props[key] : inputs[key].value;
|
|
||||||
if (Array.isArray(value) || typeof value === 'number') {
|
|
||||||
store[key] = value;
|
|
||||||
} else if (typeof value === 'boolean') {
|
|
||||||
store[key] = value ? 1 : 0;
|
|
||||||
} else {
|
|
||||||
console.error('Wrong error', { value });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return store;
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastPropsHash = '';
|
|
||||||
function updateNode() {
|
|
||||||
if (!node || !store) return;
|
|
||||||
let needsUpdate = false;
|
|
||||||
Object.keys(store).forEach((_key: string) => {
|
|
||||||
node.props = node.props || {};
|
|
||||||
const key = _key as keyof typeof store;
|
|
||||||
if (node && store) {
|
|
||||||
needsUpdate = true;
|
|
||||||
const value = store[key];
|
|
||||||
if (value !== undefined) {
|
|
||||||
node.props[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let propsHash = JSON.stringify(node.props);
|
|
||||||
if (propsHash === lastPropsHash) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastPropsHash = propsHash;
|
|
||||||
|
|
||||||
if (needsUpdate) {
|
|
||||||
manager.save();
|
|
||||||
manager.execute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (store) {
|
|
||||||
updateNode();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if Object.keys(nodeDefinition).length}
|
|
||||||
<NestedSettings
|
|
||||||
id="activeNodeSettings"
|
|
||||||
bind:value={store}
|
|
||||||
type={nodeDefinition}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<p class="mx-4 mt-4">Node has no settings</p>
|
|
||||||
{/if}
|
|
||||||
@@ -1,26 +1,103 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
|
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
|
||||||
import type { NodeInstance } from '@nodarium/types';
|
import NestedSettings from '$lib/settings/NestedSettings.svelte';
|
||||||
import ActiveNodeSelected from './ActiveNodeSelected.svelte';
|
import type { NodeId, NodeInput, NodeInstance } from '@nodarium/types';
|
||||||
|
|
||||||
|
type InternalNodeInput = NodeInput & {
|
||||||
|
__node_type?: NodeId;
|
||||||
|
__node_input: string;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
manager: GraphManager;
|
manager: GraphManager;
|
||||||
node: NodeInstance | undefined;
|
node: NodeInstance | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { manager, node = $bindable() }: Props = $props();
|
const { manager, node = $bindable() }: Props = $props();
|
||||||
|
|
||||||
|
function filterInputs(inputs?: Record<string, NodeInput>) {
|
||||||
|
if (!node) return {};
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(inputs ?? {})
|
||||||
|
.filter(([, value]) => {
|
||||||
|
return value.hidden === true;
|
||||||
|
})
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const v = value as InternalNodeInput;
|
||||||
|
v.__node_type = node.state.type?.id;
|
||||||
|
v.__node_input = key;
|
||||||
|
return [key, v];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const nodeDefinition = node ? filterInputs(node.state.type?.inputs) : {};
|
||||||
|
|
||||||
|
type Store = Record<string, number | number[]>;
|
||||||
|
let store = $state<Store>(createStore(node?.props, nodeDefinition));
|
||||||
|
function createStore(
|
||||||
|
props: NodeInstance['props'],
|
||||||
|
inputs: Record<string, NodeInput>
|
||||||
|
): Store {
|
||||||
|
const store: Store = {};
|
||||||
|
Object.keys(inputs).forEach((key) => {
|
||||||
|
if (props) {
|
||||||
|
const value = props[key] !== undefined ? props[key] : inputs[key].value;
|
||||||
|
if (Array.isArray(value) || typeof value === 'number') {
|
||||||
|
store[key] = value;
|
||||||
|
} else if (typeof value === 'boolean') {
|
||||||
|
store[key] = value ? 1 : 0;
|
||||||
|
} else {
|
||||||
|
console.error('Wrong error', { value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastPropsHash = '';
|
||||||
|
function updateNode() {
|
||||||
|
if (!node || !store) return;
|
||||||
|
let needsUpdate = false;
|
||||||
|
Object.keys(store).forEach((_key: string) => {
|
||||||
|
node.props = node.props || {};
|
||||||
|
const key = _key as keyof typeof store;
|
||||||
|
if (node && store) {
|
||||||
|
needsUpdate = true;
|
||||||
|
const value = store[key];
|
||||||
|
if (value !== undefined) {
|
||||||
|
node.props[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let propsHash = JSON.stringify(node.props);
|
||||||
|
if (propsHash === lastPropsHash) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastPropsHash = propsHash;
|
||||||
|
|
||||||
|
if (needsUpdate) {
|
||||||
|
manager.save();
|
||||||
|
manager.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGroupInstance = $derived(node?.type === '__internal/group/instance');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (store) {
|
||||||
|
updateNode();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if !isGroupInstance && Object.keys(nodeDefinition).length}
|
||||||
<div class='{node?"border-l-2 pl-3.5!":""} bg-layer-2 flex items-center h-[70px] border-b-1 border-l-selected border-b-outline pl-4'>
|
<div class='{node?"border-l-2 pl-3.5!":""} bg-layer-2 flex items-center h-[70px] border-b-1 border-l-selected border-b-outline pl-4'>
|
||||||
<h3>Node Settings</h3>
|
<h3>Node Settings</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<NestedSettings
|
||||||
{#if node}
|
id="activeNodeSettings"
|
||||||
{#key node.id}
|
bind:value={store}
|
||||||
{#if node}
|
type={nodeDefinition}
|
||||||
<ActiveNodeSelected {manager} bind:node />
|
/>
|
||||||
{/if}
|
|
||||||
{/key}
|
|
||||||
{:else}
|
|
||||||
<p class="mx-4 mt-4">No node selected</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Graph } from '$lib/types';
|
import type { Graph } from '$lib/types';
|
||||||
|
import { JsonViewer } from '@nodarium/ui';
|
||||||
|
|
||||||
const { graph }: { graph?: Graph } = $props();
|
const { graph }: { graph?: Graph } = $props();
|
||||||
|
|
||||||
function convert(g: Graph): string {
|
const data = $derived(
|
||||||
return JSON.stringify(
|
graph
|
||||||
{
|
? {
|
||||||
...g,
|
...graph,
|
||||||
nodes: g.nodes.map((n: object) => ({ ...n, tmp: undefined, state: undefined }))
|
nodes: graph.nodes.map((n: object) => ({ ...n, tmp: undefined, state: undefined }))
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<pre>
|
<div class="overflow-auto p-2">
|
||||||
{graph ? convert(graph) : "No graph loaded"}
|
{#if data}
|
||||||
</pre>
|
<JsonViewer value={data} path="graph" />
|
||||||
|
{:else}
|
||||||
|
<span class="font-mono text-xs text-neutral-500">No graph loaded</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
|
||||||
|
import { GraphState } from '$lib/graph-interface/graph-state.svelte';
|
||||||
|
import type { NodeInstance } from '@nodarium/types';
|
||||||
|
import { SocketTable } from '@nodarium/ui';
|
||||||
|
import UnusedGroupsPanel from './UnusedGroupsPanel.svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
manager: GraphManager;
|
||||||
|
graphState: GraphState;
|
||||||
|
node?: NodeInstance;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { manager, graphState, node = $bindable() }: Props = $props();
|
||||||
|
|
||||||
|
const activeGroup = $derived.by(() => {
|
||||||
|
if (node?.type === '__internal/group/instance') {
|
||||||
|
let group = manager.getGroup(node.props?.groupId as number);
|
||||||
|
if (group) return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manager?.isInsideGroup && manager.currentGroupId !== null) {
|
||||||
|
return manager.getGroup(manager.currentGroupId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupName = $derived(activeGroup?.name ?? '');
|
||||||
|
function handleRename(e: Event) {
|
||||||
|
const name = (e.target as HTMLInputElement).value;
|
||||||
|
if (activeGroup) manager.renameGroup(activeGroup.id, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveInput(key: string) {
|
||||||
|
if (!activeGroup) return;
|
||||||
|
const group = manager.getGroup(activeGroup?.id);
|
||||||
|
const inputs = $state.snapshot(group?.inputs ?? {});
|
||||||
|
delete inputs[key];
|
||||||
|
activeGroup.inputs = inputs;
|
||||||
|
manager.nodes = manager.nodes;
|
||||||
|
manager.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = $derived(
|
||||||
|
Array.from(
|
||||||
|
new Set(
|
||||||
|
manager?.registry
|
||||||
|
? manager.registry.getAllNodes()
|
||||||
|
.flatMap(n =>
|
||||||
|
Object.values(n.inputs ?? {})
|
||||||
|
.map(v => v.type)
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
let outputType = $derived(activeGroup?.outputs?.[0]?.type ?? 'unknown');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!activeGroup) return;
|
||||||
|
const group = manager.getGroup(activeGroup?.id);
|
||||||
|
const outputs = $state.snapshot(group?.outputs ?? []);
|
||||||
|
if (outputs?.[0]?.type === outputType) return;
|
||||||
|
activeGroup.outputs = [
|
||||||
|
{
|
||||||
|
label: outputs[0]?.label ?? 'Output',
|
||||||
|
type: outputType
|
||||||
|
}
|
||||||
|
];
|
||||||
|
manager.nodes = manager.nodes;
|
||||||
|
manager.save();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if activeGroup}
|
||||||
|
<div class='{node?"border-l-2 pl-3.5!":""} bg-layer-2 flex items-center h-[70px] border-b-1 border-l-selected border-b-outline pl-4'>
|
||||||
|
<h3>Group Settings</h3>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if activeGroup}
|
||||||
|
{#key activeGroup.id}
|
||||||
|
<div class="p-4 group-settings">
|
||||||
|
<label for="group-name">Group name</label>
|
||||||
|
<input
|
||||||
|
id="group-name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Group {activeGroup.id}"
|
||||||
|
value={groupName}
|
||||||
|
oninput={handleRename}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label for="group-name">Group Inputs</label>
|
||||||
|
<div>
|
||||||
|
<SocketTable
|
||||||
|
{types}
|
||||||
|
onremove={handleRemoveInput}
|
||||||
|
bind:inputs={activeGroup.inputs}
|
||||||
|
colors={graphState?.colors?.getColors()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="group-name mb-2">Group output</label>
|
||||||
|
<div class="flex bg-layer-2 rounded-sm outline outline-outline w-min">
|
||||||
|
<span
|
||||||
|
style:background={graphState?.colors?.getColor(outputType)}
|
||||||
|
class="block opacity-50 min-w-2 ml-2 w-2 h-2 my-auto rounded-sm"
|
||||||
|
></span>
|
||||||
|
<select
|
||||||
|
class="text-[0.9em] shrink-0 px-2 py-1 border-outline"
|
||||||
|
bind:value={outputType}
|
||||||
|
>
|
||||||
|
{#each types as type (type)}
|
||||||
|
<option>
|
||||||
|
<span
|
||||||
|
style="background: {graphState?.colors?.getColor(type)}; width: 5px; height: 5px; border-radius: 5px;"
|
||||||
|
></span>
|
||||||
|
{type}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if manager && !manager.isInsideGroup}
|
||||||
|
<UnusedGroupsPanel {manager} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.group-settings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-settings input {
|
||||||
|
background: var(--color-layer-1);
|
||||||
|
border: 1px solid var(--color-outline);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-settings input:focus {
|
||||||
|
outline: 1px solid var(--color-active);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
|
||||||
|
import type { GroupDefinition } from '@nodarium/types';
|
||||||
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
type Props = { manager: GraphManager };
|
||||||
|
const { manager }: Props = $props();
|
||||||
|
|
||||||
|
type GroupNode = { group: GroupDefinition; children: GroupNode[] };
|
||||||
|
|
||||||
|
const unusedTree = $derived.by((): GroupNode[] => {
|
||||||
|
const unused = manager.getUnusedGroups();
|
||||||
|
if (!unused.length) return [];
|
||||||
|
|
||||||
|
const unusedIds = new Set(unused.map(g => g.id));
|
||||||
|
|
||||||
|
// Build child map: which unused groups reference which other unused groups
|
||||||
|
const childrenOf = new SvelteMap<number, number[]>();
|
||||||
|
const referencedBy = new SvelteSet<number>();
|
||||||
|
|
||||||
|
for (const group of unused) {
|
||||||
|
const refs: number[] = [];
|
||||||
|
for (const node of group.nodes) {
|
||||||
|
if (node.type === '__internal/group/instance' && node.props?.groupId !== undefined) {
|
||||||
|
const childId = node.props.groupId as number;
|
||||||
|
if (unusedIds.has(childId)) {
|
||||||
|
refs.push(childId);
|
||||||
|
referencedBy.add(childId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
childrenOf.set(group.id, refs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = new Map(unused.map(g => [g.id, g]));
|
||||||
|
|
||||||
|
function buildNode(g: GroupDefinition): GroupNode {
|
||||||
|
return {
|
||||||
|
group: g,
|
||||||
|
children: (childrenOf.get(g.id) ?? []).map(id => buildNode(byId.get(id)!))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return unused
|
||||||
|
.filter(g => !referencedBy.has(g.id))
|
||||||
|
.map(buildNode);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if unusedTree.length}
|
||||||
|
<div class="panel p-4">
|
||||||
|
<div class="header">
|
||||||
|
<span>Unused groups</span>
|
||||||
|
<button class="remove-all" onclick={() => manager.removeUnusedGroups()}>
|
||||||
|
Remove all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="tree">
|
||||||
|
{#snippet treeNode(node: GroupNode)}
|
||||||
|
<li>
|
||||||
|
<span class="group-name">{node.group.name || `Group #${node.group.id}`}</span>
|
||||||
|
{#if node.children.length}
|
||||||
|
<ul>
|
||||||
|
{#each node.children as child (child.group.id)}
|
||||||
|
{@render treeNode(child)}
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/snippet}
|
||||||
|
{#each unusedTree as node (node.group.id)}
|
||||||
|
{@render treeNode(node)}
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.panel {
|
||||||
|
border-top: 1px solid var(--color-outline);
|
||||||
|
margin-top: -1px;
|
||||||
|
border-bottom: 1px solid var(--color-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-all {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-outline);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 0.2em 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-all:hover {
|
||||||
|
border-color: var(--color-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.2em;
|
||||||
|
border-left: 1px solid var(--color-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree li {
|
||||||
|
padding: 0.15em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree ul .group-name::before {
|
||||||
|
content: '└ ';
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
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',
|
||||||
|
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.',
|
||||||
|
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.',
|
||||||
|
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.',
|
||||||
|
next: 'start_building'
|
||||||
|
},
|
||||||
|
|
||||||
|
start_building: {
|
||||||
|
position: 'center',
|
||||||
|
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.",
|
||||||
|
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.",
|
||||||
|
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.',
|
||||||
|
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.",
|
||||||
|
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!',
|
||||||
|
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.',
|
||||||
|
next: 'save_project'
|
||||||
|
},
|
||||||
|
|
||||||
|
save_project: {
|
||||||
|
position: 'right',
|
||||||
|
text:
|
||||||
|
'Your work is saved in the **Projects panel** — rename it, create new projects, or switch between them anytime.',
|
||||||
|
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',
|
||||||
|
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.",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
label: '🔍 Explore Node Sourcecode',
|
||||||
|
action: 'open-github-nodes'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
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.',
|
||||||
|
next: 'tour_runtime_nerd'
|
||||||
|
},
|
||||||
|
|
||||||
|
tour_runtime_nerd: {
|
||||||
|
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.',
|
||||||
|
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)',
|
||||||
|
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',
|
||||||
|
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.',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
+135
-20
@@ -4,7 +4,8 @@
|
|||||||
import Grid from '$lib/grid';
|
import Grid from '$lib/grid';
|
||||||
import { debounceAsyncFunction } from '$lib/helpers';
|
import { debounceAsyncFunction } from '$lib/helpers';
|
||||||
import { createKeyMap } from '$lib/helpers/createKeyMap';
|
import { createKeyMap } from '$lib/helpers/createKeyMap';
|
||||||
import { debugNode } from '$lib/node-registry/debugNode.js';
|
import { debugNode } from '$lib/node-registry/debugNode';
|
||||||
|
import { groupNode } from '$lib/node-registry/groupNode.js';
|
||||||
import { IndexDBCache, RemoteNodeRegistry } from '$lib/node-registry/index';
|
import { IndexDBCache, RemoteNodeRegistry } from '$lib/node-registry/index';
|
||||||
import NodeStore from '$lib/node-store/NodeStore.svelte';
|
import NodeStore from '$lib/node-store/NodeStore.svelte';
|
||||||
import PerformanceViewer from '$lib/performance/PerformanceViewer.svelte';
|
import PerformanceViewer from '$lib/performance/PerformanceViewer.svelte';
|
||||||
@@ -21,20 +22,24 @@
|
|||||||
import Changelog from '$lib/sidebar/panels/Changelog.svelte';
|
import Changelog from '$lib/sidebar/panels/Changelog.svelte';
|
||||||
import ExportSettings from '$lib/sidebar/panels/ExportSettings.svelte';
|
import ExportSettings from '$lib/sidebar/panels/ExportSettings.svelte';
|
||||||
import GraphSource from '$lib/sidebar/panels/GraphSource.svelte';
|
import GraphSource from '$lib/sidebar/panels/GraphSource.svelte';
|
||||||
|
import GroupSettings from '$lib/sidebar/panels/GroupSettings.svelte';
|
||||||
import Keymap from '$lib/sidebar/panels/Keymap.svelte';
|
import Keymap from '$lib/sidebar/panels/Keymap.svelte';
|
||||||
|
import { panelState } from '$lib/sidebar/PanelState.svelte';
|
||||||
import Sidebar from '$lib/sidebar/Sidebar.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 type { Graph, NodeInstance } from '@nodarium/types';
|
||||||
import { createPerformanceStore } from '@nodarium/utils';
|
import { createPerformanceStore } from '@nodarium/utils';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import type { Group } from 'three';
|
import type { Group } from 'three';
|
||||||
|
|
||||||
let performanceStore = createPerformanceStore();
|
let performanceStore = createPerformanceStore();
|
||||||
|
let planty = $state<ReturnType<typeof Planty>>();
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
const registryCache = new IndexDBCache('node-registry');
|
const registryCache = new IndexDBCache('node-registry');
|
||||||
|
|
||||||
const nodeRegistry = new RemoteNodeRegistry('', registryCache, [debugNode]);
|
const nodeRegistry = new RemoteNodeRegistry('', registryCache, [debugNode, groupNode]);
|
||||||
const workerRuntime = new WorkerRuntimeExecutor();
|
const workerRuntime = new WorkerRuntimeExecutor();
|
||||||
const runtimeCache = new MemoryRuntimeCache();
|
const runtimeCache = new MemoryRuntimeCache();
|
||||||
const memoryRuntime = new MemoryRuntimeExecutor(nodeRegistry, runtimeCache);
|
const memoryRuntime = new MemoryRuntimeExecutor(nodeRegistry, runtimeCache);
|
||||||
@@ -91,7 +96,7 @@
|
|||||||
randomSeed: { type: 'boolean', value: false }
|
randomSeed: { type: 'boolean', value: false }
|
||||||
});
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (graphSettings && graphSettingTypes) {
|
if (graphSettings && graphSettingTypes && manager?.loaded) {
|
||||||
manager?.setSettings($state.snapshot(graphSettings));
|
manager?.setSettings($state.snapshot(graphSettings));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -130,35 +135,115 @@
|
|||||||
|
|
||||||
const handleUpdate = debounceAsyncFunction(update);
|
const handleUpdate = debounceAsyncFunction(update);
|
||||||
|
|
||||||
onMount(() => {
|
function handleSettingsButton(id: string) {
|
||||||
appSettings.value.debug.stressTest = {
|
switch (id) {
|
||||||
...appSettings.value.debug.stressTest,
|
case 'general.clippy':
|
||||||
loadGrid: () => {
|
planty?.start();
|
||||||
|
break;
|
||||||
|
case 'general.debug.stressTest.loadGrid':
|
||||||
manager.load(
|
manager.load(
|
||||||
templates.grid(
|
templates.grid(
|
||||||
appSettings.value.debug.stressTest.amount,
|
appSettings.value.debug.stressTest.amount,
|
||||||
appSettings.value.debug.stressTest.amount
|
appSettings.value.debug.stressTest.amount
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
},
|
break;
|
||||||
loadTree: () => {
|
case 'general.debug.stressTest.loadTree':
|
||||||
manager.load(templates.tree(appSettings.value.debug.stressTest.amount));
|
manager.load(templates.tree(appSettings.value.debug.stressTest.amount));
|
||||||
},
|
break;
|
||||||
lottaFaces: () => {
|
case 'general.debug.stressTest.lottaFaces':
|
||||||
manager.load(templates.lottaFaces as unknown as Graph);
|
manager.load(templates.lottaFaces as unknown as Graph);
|
||||||
},
|
break;
|
||||||
lottaNodes: () => {
|
case 'general.debug.stressTest.lottaNodes':
|
||||||
manager.load(templates.lottaNodes as unknown as Graph);
|
manager.load(templates.lottaNodes as unknown as Graph);
|
||||||
},
|
break;
|
||||||
lottaNodesAndFaces: () => {
|
case 'general.debug.stressTest.lottaNodesAndFaces':
|
||||||
manager.load(templates.lottaNodesAndFaces as unknown as Graph);
|
manager.load(templates.lottaNodesAndFaces as unknown as Graph);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document onkeydown={applicationKeymap.handleKeyboardEvent} />
|
<svelte:document onkeydown={applicationKeymap.handleKeyboardEvent} />
|
||||||
|
|
||||||
|
<Planty
|
||||||
|
bind:this={planty}
|
||||||
|
config={tutorialConfig}
|
||||||
|
actions={{
|
||||||
|
'setup-default': () => {
|
||||||
|
console.log('setup-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': () => {
|
||||||
|
console.log('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 };
|
||||||
|
manager.load(g);
|
||||||
|
graphInterface.state.centerNode(graphInterface.manager.getAllNodes()[0]);
|
||||||
|
},
|
||||||
|
'open-github-nodes': () => {
|
||||||
|
console.log('open-github-nodes');
|
||||||
|
window.open(
|
||||||
|
'https://github.com/jim-fx/nodarium/tree/main/nodes/max/plantarium',
|
||||||
|
'__blank'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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'),
|
||||||
|
'before:shortcuts_tour': () => panelState.setActivePanel('shortcuts'),
|
||||||
|
'after:save_project': () => panelState.setActivePanel('graph-settings'),
|
||||||
|
'before:tour_runtime_nerd': () => panelState.setActivePanel('general')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="wrapper manager-{manager?.status}">
|
<div class="wrapper manager-{manager?.status}">
|
||||||
<header></header>
|
<header></header>
|
||||||
<Grid.Row>
|
<Grid.Row>
|
||||||
@@ -173,11 +258,12 @@
|
|||||||
</Grid.Cell>
|
</Grid.Cell>
|
||||||
<Grid.Cell>
|
<Grid.Cell>
|
||||||
{#if pm.graph}
|
{#if pm.graph}
|
||||||
|
{#key pm.graph.id}
|
||||||
<GraphInterface
|
<GraphInterface
|
||||||
graph={pm.graph}
|
graph={pm.graph}
|
||||||
bind:this={graphInterface}
|
bind:this={graphInterface}
|
||||||
registry={nodeRegistry}
|
registry={nodeRegistry}
|
||||||
addMenuPadding={{ right: sidebarOpen ? 330 : undefined }}
|
safePadding={{ right: sidebarOpen ? 321 : undefined }}
|
||||||
backgroundType={appSettings.value.nodeInterface.backgroundType}
|
backgroundType={appSettings.value.nodeInterface.backgroundType}
|
||||||
snapToGrid={appSettings.value.nodeInterface.snapToGrid}
|
snapToGrid={appSettings.value.nodeInterface.snapToGrid}
|
||||||
bind:activeNode
|
bind:activeNode
|
||||||
@@ -187,11 +273,13 @@
|
|||||||
onsave={(g) => pm.saveGraph(g)}
|
onsave={(g) => pm.saveGraph(g)}
|
||||||
onresult={(result) => handleUpdate(result as Graph)}
|
onresult={(result) => handleUpdate(result as Graph)}
|
||||||
/>
|
/>
|
||||||
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
<Sidebar bind:open={sidebarOpen}>
|
<Sidebar bind:open={sidebarOpen}>
|
||||||
<Panel id="general" title="General" icon="i-[tabler--settings]">
|
<Panel id="general" title="General" icon="i-[tabler--settings]">
|
||||||
<NestedSettings
|
<NestedSettings
|
||||||
id="general"
|
id="general"
|
||||||
|
onButtonClick={handleSettingsButton}
|
||||||
bind:value={appSettings.value}
|
bind:value={appSettings.value}
|
||||||
type={AppSettingTypes}
|
type={AppSettingTypes}
|
||||||
/>
|
/>
|
||||||
@@ -211,6 +299,7 @@
|
|||||||
<Panel id="exports" title="Exporter" icon="i-[tabler--package-export]">
|
<Panel id="exports" title="Exporter" icon="i-[tabler--package-export]">
|
||||||
<ExportSettings {scene} />
|
<ExportSettings {scene} />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
{#if 0 > 1}
|
||||||
<Panel
|
<Panel
|
||||||
id="node-store"
|
id="node-store"
|
||||||
title="Node Store"
|
title="Node Store"
|
||||||
@@ -218,6 +307,7 @@
|
|||||||
>
|
>
|
||||||
<NodeStore registry={nodeRegistry} />
|
<NodeStore registry={nodeRegistry} />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
{/if}
|
||||||
<Panel
|
<Panel
|
||||||
id="performance"
|
id="performance"
|
||||||
title="Performance"
|
title="Performance"
|
||||||
@@ -237,7 +327,9 @@
|
|||||||
hidden={!appSettings.value.debug.advancedMode}
|
hidden={!appSettings.value.debug.advancedMode}
|
||||||
icon="i-[tabler--code]"
|
icon="i-[tabler--code]"
|
||||||
>
|
>
|
||||||
<GraphSource graph={pm.graph ?? manager?.serialize()} />
|
{#if manager?.status === 'idle'}
|
||||||
|
<GraphSource graph={manager.serialize()} />
|
||||||
|
{/if}
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel
|
<Panel
|
||||||
id="benchmark"
|
id="benchmark"
|
||||||
@@ -252,12 +344,16 @@
|
|||||||
title="Graph Settings"
|
title="Graph Settings"
|
||||||
icon="i-[custom--graph] bg-blue-400"
|
icon="i-[custom--graph] bg-blue-400"
|
||||||
>
|
>
|
||||||
|
<span class="block h-[1px]"></span>
|
||||||
<NestedSettings
|
<NestedSettings
|
||||||
id="graph-settings"
|
id="graph-settings"
|
||||||
type={graphSettingTypes}
|
type={graphSettingTypes}
|
||||||
bind:value={graphSettings}
|
bind:value={graphSettings}
|
||||||
/>
|
/>
|
||||||
|
{#key activeNode}
|
||||||
<ActiveNodeSettings {manager} bind:node={activeNode} />
|
<ActiveNodeSettings {manager} bind:node={activeNode} />
|
||||||
|
<GroupSettings graphState={graphInterface?.state} {manager} bind:node={activeNode} />
|
||||||
|
{/key}
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel
|
<Panel
|
||||||
id="changelog"
|
id="changelog"
|
||||||
@@ -274,6 +370,25 @@
|
|||||||
<style>
|
<style>
|
||||||
header {
|
header {
|
||||||
background-color: var(--color-layer-1);
|
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 {
|
.wrapper {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { playwright } from '@vitest/browser-playwright';
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
|
import path from 'path';
|
||||||
import comlink from 'vite-plugin-comlink';
|
import comlink from 'vite-plugin-comlink';
|
||||||
import glsl from 'vite-plugin-glsl';
|
import glsl from 'vite-plugin-glsl';
|
||||||
import wasm from 'vite-plugin-wasm';
|
import wasm from 'vite-plugin-wasm';
|
||||||
@@ -19,6 +20,11 @@ export default defineConfig({
|
|||||||
comlink()
|
comlink()
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@nodarium/planty': path.resolve(__dirname, '../packages/planty/src/lib/index.ts')
|
||||||
|
}
|
||||||
|
},
|
||||||
ssr: {
|
ssr: {
|
||||||
noExternal: ['three']
|
noExternal: ['three']
|
||||||
},
|
},
|
||||||
|
|||||||
+216
@@ -0,0 +1,216 @@
|
|||||||
|
# Nodarium — LLM Reference
|
||||||
|
|
||||||
|
## What It Is
|
||||||
|
|
||||||
|
Nodarium is a **node-based visual programming editor**. Users wire together nodes on a 2D canvas; each node is a WebAssembly module that receives typed inputs and produces typed outputs. A live Three.js viewer renders the resulting 3D geometry/paths/instances.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── app/ # SvelteKit web app
|
||||||
|
│ └── src/
|
||||||
|
│ ├── routes/+page.svelte # App entry point
|
||||||
|
│ └── lib/
|
||||||
|
│ ├── graph-interface/ # Canvas editor (UI + state)
|
||||||
|
│ ├── runtime/ # WASM execution engine
|
||||||
|
│ ├── node-registry/ # Fetch & cache node definitions
|
||||||
|
│ ├── project-manager/ # IndexDB persistence
|
||||||
|
│ ├── result-viewer/ # Three.js 3D output
|
||||||
|
│ ├── sidebar/ # UI panels
|
||||||
|
│ └── settings/ # App + graph settings
|
||||||
|
├── packages/
|
||||||
|
│ ├── types/ # Shared TypeScript types + Zod schemas
|
||||||
|
│ ├── utils/ # Logging, hashing, WASM wrapping, perf
|
||||||
|
│ ├── ui/ # Reusable Svelte UI components
|
||||||
|
│ ├── planty/ # Tutorial system
|
||||||
|
│ └── macros/ # Build-time macros
|
||||||
|
└── docs/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
User Interaction
|
||||||
|
└── GraphInterface
|
||||||
|
├── GraphState ← UI: selection, camera, mouse, clipboard
|
||||||
|
└── GraphManager ← Logic: nodes, edges, history, serialization
|
||||||
|
├── NodeRegistry ← fetches WASM definitions (remote API + IndexDB cache)
|
||||||
|
├── HistoryManager ← undo/redo (jsondiffpatch deltas)
|
||||||
|
└── emit('result') → RuntimeExecutor
|
||||||
|
└── node.execute(Int32Array) per node
|
||||||
|
└── ResultViewer (Three.js/Threlte)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Event flow:**
|
||||||
|
|
||||||
|
1. User edits graph → GraphManager mutates state
|
||||||
|
2. GraphManager emits `save` → ProjectManager persists to IndexDB
|
||||||
|
3. GraphManager emits `result` → Runtime executes graph → Viewer updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Files
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
| ------------------------------------------------------ | --------------------------------------------------------------------- |
|
||||||
|
| `app/src/routes/+page.svelte` | Wires all systems; creates GraphManager, runtime, registry |
|
||||||
|
| `app/src/lib/graph-interface/graph-manager.svelte.ts` | Central graph logic: createNode, createEdge, serialize, load, history |
|
||||||
|
| `app/src/lib/graph-interface/graph-state.svelte.ts` | UI state: camera, selection, mouse, clipboard, groupSelectedNodes |
|
||||||
|
| `app/src/lib/graph-interface/graph/Graph.svelte` | Canvas renderer |
|
||||||
|
| `app/src/lib/graph-interface/node/Node.svelte` | 3D mesh node (Three.js shader) |
|
||||||
|
| `app/src/lib/graph-interface/node/NodeHTML.svelte` | HTML overlay: labels + parameters |
|
||||||
|
| `app/src/lib/graph-interface/node/NodeHeader.svelte` | Node title bar |
|
||||||
|
| `app/src/lib/graph-interface/keymaps.ts` | Keyboard shortcuts |
|
||||||
|
| `app/src/lib/graph-interface/helpers/nodeHelpers.ts` | Node height calculations |
|
||||||
|
| `app/src/lib/graph-interface/graph/colors.svelte.ts` | Socket type → color mapping |
|
||||||
|
| `app/src/lib/runtime/runtime-executor.ts` | Executes nodes in DAG order; expandGroups() |
|
||||||
|
| `app/src/lib/node-registry/index.ts` | RemoteNodeRegistry entry |
|
||||||
|
| `app/src/lib/node-registry/groupNode.ts` | Built-in group node definition |
|
||||||
|
| `app/src/lib/node-registry/debugNode.ts` | Built-in debug node |
|
||||||
|
| `app/src/lib/sidebar/panels/ActiveNodeSettings.svelte` | Per-node settings panel |
|
||||||
|
| `packages/types/src/types.ts` | Graph, NodeInstance, NodeDefinition, Edge, GroupDefinition |
|
||||||
|
| `packages/types/src/inputs.ts` | NodeInput union types (float, vec3, geometry, path, …) |
|
||||||
|
| `packages/utils/src/wasm.ts` | createWasmWrapper() — wraps WASM bytes into a NodeDefinition |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/types/src/types.ts
|
||||||
|
|
||||||
|
type NodeId = `${string}/${string}/${string}`; // e.g. "max/plantarium/stem"
|
||||||
|
|
||||||
|
type NodeInstance = {
|
||||||
|
id: number;
|
||||||
|
type: NodeId;
|
||||||
|
position: [number, number];
|
||||||
|
props?: Record<string, number | number[]>; // current parameter values
|
||||||
|
meta?: { title?: string; lastModified?: string };
|
||||||
|
state: NodeRuntimeState; // runtime-only, NOT serialized
|
||||||
|
};
|
||||||
|
|
||||||
|
type NodeRuntimeState = {
|
||||||
|
type?: NodeDefinition; // resolved definition
|
||||||
|
parents?: NodeInstance[];
|
||||||
|
children?: NodeInstance[];
|
||||||
|
x?: number;
|
||||||
|
y?: number; // interpolated position
|
||||||
|
mesh?: Mesh; // Three.js mesh reference
|
||||||
|
ref?: HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NodeDefinition = {
|
||||||
|
id: NodeId;
|
||||||
|
inputs?: Record<string, NodeInput>;
|
||||||
|
outputs?: string[]; // output type names
|
||||||
|
meta?: { title?: string; description?: string };
|
||||||
|
execute(input: Int32Array): Int32Array; // WASM function
|
||||||
|
};
|
||||||
|
|
||||||
|
// Edge: [fromNode, outputIndex, toNode, inputSocketName]
|
||||||
|
type Edge = [NodeInstance, number, NodeInstance, string];
|
||||||
|
|
||||||
|
type Graph = {
|
||||||
|
nodes: NodeInstance[];
|
||||||
|
edges: [number, number, number, string][]; // serialized (IDs, not refs)
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
groups: GroupDefinition[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type GroupDefinition = {
|
||||||
|
id: number;
|
||||||
|
nodes: NodeInstance[];
|
||||||
|
edges: Edge[];
|
||||||
|
inputs?: Record<string, NodeInput>;
|
||||||
|
outputs?: string[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### NodeInput socket types
|
||||||
|
|
||||||
|
`float` | `integer` | `boolean` | `select` | `seed` | `vec3` | `geometry` | `path` | `shape` | `color` | `*` (wildcard)
|
||||||
|
|
||||||
|
Each input can have: `value` (default), `label`, `hidden`, `external`, `setting` (link to graph setting), `accepts` (extra compatible types).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Patterns & Conventions
|
||||||
|
|
||||||
|
### Svelte 5 reactivity
|
||||||
|
|
||||||
|
The codebase uses Svelte 5 runes throughout — `$state`, `$derived`, `$effect`. Collections use `SvelteMap<K,V>` and `SvelteSet<T>` (from `svelte/reactivity`) instead of plain Map/Set so that mutations trigger reactive updates.
|
||||||
|
|
||||||
|
### Context API
|
||||||
|
|
||||||
|
`GraphManager` and `GraphState` are provided via Svelte context (`setContext` / `getContext`) inside `GraphInterface`. All child components (Node, Edge, etc.) consume them via context rather than props.
|
||||||
|
|
||||||
|
### Edge representation
|
||||||
|
|
||||||
|
In memory, edges are `[NodeInstance, outputIndex, NodeInstance, inputSocketName]` — direct object references for fast traversal. On serialization (`Graph.edges`), they become `[nodeId, outputIndex, nodeId, inputSocketName]` (plain IDs).
|
||||||
|
|
||||||
|
### Socket compatibility
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
areSocketsCompatible(outputType: string, inputType: string | string[]): boolean
|
||||||
|
// '*' wildcard matches any type; 'geometry' accepts ['geometry', 'instances']
|
||||||
|
```
|
||||||
|
|
||||||
|
### WASM execution interface
|
||||||
|
|
||||||
|
Every node exposes a single function: `execute(input: Int32Array): Int32Array`.
|
||||||
|
Data encoding (Plantarium):
|
||||||
|
|
||||||
|
- `[0, stemDepth, ...x,y,z,thickness]` — path
|
||||||
|
- `[1, vertexCount, faceCount, ...faces, ...vertices, ...normals]` — geometry
|
||||||
|
- `[2, vertexCount, faceCount, instanceCount, stemDepth, ...]` — instances
|
||||||
|
|
||||||
|
### Event emitter
|
||||||
|
|
||||||
|
`GraphManager extends EventEmitter<{ save, result, settings }>`. Subscribe with `manager.on('result', cb)`. Used to decouple the editor UI from runtime execution and persistence.
|
||||||
|
|
||||||
|
### History
|
||||||
|
|
||||||
|
Every mutation goes through `HistoryManager`. Call `this.history.save(this.serialize())` before mutations; undo/redo replays jsondiffpatch deltas.
|
||||||
|
|
||||||
|
### Internal node IDs
|
||||||
|
|
||||||
|
Built-in nodes use the `__internal/` namespace: `__internal/group/instance`, `__internal/node/debug`. Virtual boundary nodes use `__virtual/`: `__virtual/group/input`, `__virtual/group/output`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## In-Progress: Node Groups (`feat/group-node-own`)
|
||||||
|
|
||||||
|
Group selected nodes with **Ctrl+G**. A `GroupDefinition` is stored in `Graph.groups[]`; a group instance node (`__internal/group/instance`) referencing it by `props.groupId` replaces the selected nodes.
|
||||||
|
|
||||||
|
**Known gaps as of 2026-05-03:**
|
||||||
|
|
||||||
|
| Issue | Location |
|
||||||
|
| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||||
|
| `createGroupNode()` called but not defined | `graph-state.svelte.ts:334` → missing in `graph-manager.svelte.ts` |
|
||||||
|
| Runtime expects `group.graph.nodes/edges`; schema has flat `nodes/edges` | `runtime-executor.ts` vs `types.ts` |
|
||||||
|
| Runtime expects `group.inputs` as array; schema defines it as `Record<string, NodeInput>` | Same mismatch |
|
||||||
|
| `enterGroupNode()` is a stub — no group navigation | `graph-state.svelte.ts` |
|
||||||
|
| `serialize()` writes parent-graph edges into group instead of group-internal edges | `graph-manager.svelte.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dev Commands
|
||||||
|
|
||||||
|
Run from `app/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # start dev server (Vite)
|
||||||
|
npm run build # production build
|
||||||
|
npm run check # svelte-check + tsc
|
||||||
|
npm run lint # eslint
|
||||||
|
npm run test # unit (vitest) + e2e (playwright)
|
||||||
|
npm run test:unit # vitest only
|
||||||
|
npm run test:e2e # playwright only
|
||||||
|
npm run bench # benchmark runner
|
||||||
|
```
|
||||||
@@ -10,12 +10,14 @@
|
|||||||
"scale": {
|
"scale": {
|
||||||
"type": "float",
|
"type": "float",
|
||||||
"min": 0.1,
|
"min": 0.1,
|
||||||
"max": 10
|
"max": 10,
|
||||||
|
"value": 1
|
||||||
},
|
},
|
||||||
"strength": {
|
"strength": {
|
||||||
"type": "float",
|
"type": "float",
|
||||||
"min": 0.1,
|
"min": 0.1,
|
||||||
"max": 10
|
"max": 10,
|
||||||
|
"value": 2
|
||||||
},
|
},
|
||||||
"fixBottom": {
|
"fixBottom": {
|
||||||
"type": "float",
|
"type": "float",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"inputs": {
|
"inputs": {
|
||||||
"min": {
|
"min": {
|
||||||
"type": "float",
|
"type": "float",
|
||||||
"value": 2
|
"value": 1
|
||||||
},
|
},
|
||||||
"max": {
|
"max": {
|
||||||
"type": "float",
|
"type": "float",
|
||||||
|
|||||||
+5
-4
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": "0.0.4",
|
"version": "0.0.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "pnpm run -r --filter 'ui' build",
|
"_postinstall": "pnpm run -r --filter 'ui' build && pnpm run -r --filter 'planty' build",
|
||||||
"lint": "pnpm run -r --parallel lint",
|
"lint": "pnpm run -r --parallel lint",
|
||||||
"qa": "pnpm lint && pnpm check && pnpm test",
|
"qa": "pnpm lint && pnpm check && pnpm test",
|
||||||
"format": "pnpm dprint fmt",
|
"format": "pnpm dprint fmt",
|
||||||
"format:check": "pnpm dprint check",
|
"format:check": "pnpm dprint check",
|
||||||
"test": "pnpm run -r --parallel test",
|
"test:e2e": "pnpm run -r --parallel test:e2e",
|
||||||
|
"test:unit": "pnpm run -r --parallel test:unit",
|
||||||
"check": "pnpm run -r --parallel check",
|
"check": "pnpm run -r --parallel check",
|
||||||
"build": "pnpm build:nodes && pnpm build:app",
|
"build": "pnpm build:nodes && pnpm build:app",
|
||||||
"build:app": "BASE_PATH=/ui pnpm -r --filter 'ui' build && pnpm -r --filter 'app' build",
|
"build:app": "BASE_PATH=/ui pnpm -r --filter 'ui' build && pnpm -r --filter 'app'... build",
|
||||||
"build:nodes": "cargo build --workspace --target wasm32-unknown-unknown --release && rm -rf ./app/static/nodes/max/plantarium/ && mkdir -p ./app/static/nodes/max/plantarium/ && cp -R ./target/wasm32-unknown-unknown/release/*.wasm ./app/static/nodes/max/plantarium/",
|
"build:nodes": "cargo build --workspace --target wasm32-unknown-unknown --release && rm -rf ./app/static/nodes/max/plantarium/ && mkdir -p ./app/static/nodes/max/plantarium/ && cp -R ./target/wasm32-unknown-unknown/release/*.wasm ./app/static/nodes/max/plantarium/",
|
||||||
"dev:nodes": "chokidar './nodes/**' --initial -i '/pkg/' -c 'pnpm build:nodes'",
|
"dev:nodes": "chokidar './nodes/**' --initial -i '/pkg/' -c 'pnpm build:nodes'",
|
||||||
"dev:app_ui": "pnpm -r --parallel --filter 'app' --filter './packages/ui' dev",
|
"dev:app_ui": "pnpm -r --parallel --filter 'app' --filter './packages/ui' dev",
|
||||||
@@ -19,6 +20,6 @@
|
|||||||
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
|
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"chokidar-cli": "catalog:",
|
"chokidar-cli": "catalog:",
|
||||||
"dprint": "^0.51.1"
|
"dprint": "^0.54.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
/dist
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Svelte library
|
||||||
|
|
||||||
|
Everything you need to build a Svelte library, powered by [`sv`](https://npmjs.com/package/sv).
|
||||||
|
|
||||||
|
Read more about creating a library [in the docs](https://svelte.dev/docs/kit/packaging).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# create a new project in the current directory
|
||||||
|
npx sv create
|
||||||
|
|
||||||
|
# create a new project in my-app
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
To recreate this project with the same configuration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# recreate this project
|
||||||
|
pnpm dlx sv@0.15.1 create --template library --types ts --add prettier eslint tailwindcss="plugins:none" --install pnpm planty
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything inside `src/lib` is part of your library, everything inside `src/routes` can be used as a showcase or preview app.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To build your library:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm pack
|
||||||
|
```
|
||||||
|
|
||||||
|
To create a production version of your showcase app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
Go into the `package.json` and give your package the desired name through the `"name"` option. Also consider adding a `"license"` field and point it to a `LICENSE` file which you can create from a template (one popular option is the [MIT license](https://opensource.org/license/mit/)).
|
||||||
|
|
||||||
|
To publish your library to [npm](https://www.npmjs.com):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm publish
|
||||||
|
```
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { includeIgnoreFile } from '@eslint/compat';
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import { defineConfig } from 'eslint/config';
|
||||||
|
import globals from 'globals';
|
||||||
|
import path from 'node:path';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
import svelteConfig from './svelte.config.js';
|
||||||
|
|
||||||
|
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
includeIgnoreFile(gitignorePath),
|
||||||
|
js.configs.recommended,
|
||||||
|
ts.configs.recommended,
|
||||||
|
svelte.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
svelte.configs.prettier,
|
||||||
|
{
|
||||||
|
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||||
|
rules: {
|
||||||
|
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||||
|
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||||
|
'no-undef': 'off'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
extraFileExtensions: ['.svelte'],
|
||||||
|
parser: ts.parser,
|
||||||
|
svelteConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Override or add rule settings here, such as:
|
||||||
|
// 'svelte/button-has-type': 'error'
|
||||||
|
rules: {}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"name": "@nodarium/planty",
|
||||||
|
"version": "0.0.6",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"prepack": "svelte-kit sync && svelte-package && publint",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "dprint fmt -c '../../.dprint.jsonc' .",
|
||||||
|
"format:check": "dprint check -c '../../.dprint.jsonc' ."
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"!dist/**/*.test.*",
|
||||||
|
"!dist/**/*.spec.*"
|
||||||
|
],
|
||||||
|
"sideEffects": [
|
||||||
|
"**/*.css"
|
||||||
|
],
|
||||||
|
"svelte": "./src/lib/index.ts",
|
||||||
|
"types": "./src/lib/index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/lib/index.ts",
|
||||||
|
"svelte": "./src/lib/index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"svelte": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/compat": "^2.0.5",
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@nodarium/ui": "workspace:*",
|
||||||
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
|
"@sveltejs/kit": "^2.59.0",
|
||||||
|
"@sveltejs/package": "^2.5.7",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"eslint": "^10.3.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-svelte": "^3.17.1",
|
||||||
|
"globals": "^17.6.0",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"prettier-plugin-svelte": "^3.5.1",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||||
|
"publint": "^0.3.18",
|
||||||
|
"svelte": "^5.55.5",
|
||||||
|
"svelte-check": "^4.4.7",
|
||||||
|
"tailwindcss": "^4.2.4",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"typescript-eslint": "^8.59.1",
|
||||||
|
"vite": "^8.0.10"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"svelte"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="theme-dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="text-scale" content="scale" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PlantyHook } from '../types.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selector?: string;
|
||||||
|
hookName?: string;
|
||||||
|
hooks?: Record<string, PlantyHook>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { selector, hookName, hooks = {} }: Props = $props();
|
||||||
|
|
||||||
|
let rect = $state<{ top: number; left: number; width: number; height: number } | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
let el: Element | null = null;
|
||||||
|
let ro: ResizeObserver | null = null;
|
||||||
|
let mo: MutationObserver | null = null;
|
||||||
|
|
||||||
|
function resolveEl(): Element | null {
|
||||||
|
if (selector) return document.querySelector(selector);
|
||||||
|
if (hookName && hooks[hookName]) {
|
||||||
|
const result = hooks[hookName]();
|
||||||
|
if (result instanceof Element) return result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRect() {
|
||||||
|
if (!el) {
|
||||||
|
rect = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = el.getBoundingClientRect();
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const p = 4;
|
||||||
|
const top = Math.max(p, raw.top - p);
|
||||||
|
const left = Math.max(p, raw.left - p);
|
||||||
|
const right = Math.min(vw - p, raw.right + p);
|
||||||
|
const bottom = Math.min(vh - p, raw.bottom + p);
|
||||||
|
if (right <= left || bottom <= top) {
|
||||||
|
rect = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rect = { top, left, width: right - left, height: bottom - top };
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachEl(newEl: Element | null) {
|
||||||
|
if (newEl === el) return;
|
||||||
|
ro?.disconnect();
|
||||||
|
el = newEl;
|
||||||
|
if (!el) {
|
||||||
|
rect = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateRect();
|
||||||
|
ro = new ResizeObserver(updateRect);
|
||||||
|
ro.observe(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
attachEl(resolveEl());
|
||||||
|
|
||||||
|
window.addEventListener('scroll', updateRect, { passive: true, capture: true });
|
||||||
|
window.addEventListener('resize', updateRect, { passive: true });
|
||||||
|
|
||||||
|
// For hook-based highlights, watch the DOM so we catch dynamically added elements
|
||||||
|
if (hookName) {
|
||||||
|
mo = new MutationObserver(() => attachEl(resolveEl()));
|
||||||
|
mo.observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ro?.disconnect();
|
||||||
|
mo?.disconnect();
|
||||||
|
window.removeEventListener('scroll', updateRect, true);
|
||||||
|
window.removeEventListener('resize', updateRect);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if rect}
|
||||||
|
<div
|
||||||
|
class="highlight pointer-events-none fixed z-99999 rounded-md"
|
||||||
|
style:top="{rect.top}px"
|
||||||
|
style:left="{rect.left}px"
|
||||||
|
style:width="{rect.width}px"
|
||||||
|
style:height="{rect.height}px"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 9999px rgba(0, 0, 0, 0.45),
|
||||||
|
0 0 0 2px rgba(255, 255, 255, 0.9),
|
||||||
|
0 0 16px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 9999px rgba(0, 0, 0, 0.45),
|
||||||
|
0 0 0 2px rgba(255, 255, 255, 1),
|
||||||
|
0 0 28px rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.highlight {
|
||||||
|
animation: pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DialogRunner } from '../dialog-runner.js';
|
||||||
|
import type { AvatarPosition, DialogNode, PlantyConfig, PlantyHook } from '../types.js';
|
||||||
|
import Highlight from './Highlight.svelte';
|
||||||
|
import PlantyAvatar from './PlantyAvatar.svelte';
|
||||||
|
import type { Mood } from './PlantyAvatar.svelte';
|
||||||
|
import SpeechBubble from './SpeechBubble.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
config: PlantyConfig;
|
||||||
|
hooks?: Record<string, PlantyHook>;
|
||||||
|
actions?: Record<string, PlantyHook>;
|
||||||
|
onStepChange?: (nodeId: string, node: DialogNode) => void;
|
||||||
|
onComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { config, actions = {}, hooks = {}, onStepChange, onComplete }: Props = $props();
|
||||||
|
|
||||||
|
const AVATAR_SIZE = 80;
|
||||||
|
const SCREEN_PADDING = 20;
|
||||||
|
|
||||||
|
// ── State ────────────────────────────────────────────────────────────
|
||||||
|
let isActive = $state(false);
|
||||||
|
let currentNodeId = $state<string | null>(null);
|
||||||
|
let bubbleVisible = $state(false);
|
||||||
|
let avatar = $state<PlantyAvatar>(null!);
|
||||||
|
let avatarX = $state(0);
|
||||||
|
let avatarY = $state(0);
|
||||||
|
let mood = $state<Mood>('idle');
|
||||||
|
let autoAdvanceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let actionCleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
// ── Derived ──────────────────────────────────────────────────────────
|
||||||
|
const runner = $derived(new DialogRunner(config));
|
||||||
|
const nextNode = $derived(
|
||||||
|
runner.getNextNode(currentNodeId ?? '')
|
||||||
|
);
|
||||||
|
const mainPath = $derived(runner.getMainPath());
|
||||||
|
const currentNode = $derived<DialogNode | null>(
|
||||||
|
currentNodeId ? runner.getNode(currentNodeId) : null
|
||||||
|
);
|
||||||
|
const showBubble = $derived(
|
||||||
|
isActive && bubbleVisible && currentNode !== null && !!currentNode.text
|
||||||
|
);
|
||||||
|
const highlight = $derived(currentNode?.highlight ?? null);
|
||||||
|
const stepIndex = $derived(currentNodeId ? mainPath.indexOf(currentNodeId) : -1);
|
||||||
|
const totalSteps = $derived(mainPath.length);
|
||||||
|
|
||||||
|
// ── Position helpers ─────────────────────────────────────────────────
|
||||||
|
function anchorToCoords(anchor: string): { x: number; y: number } {
|
||||||
|
const w = window.innerWidth;
|
||||||
|
const h = window.innerHeight;
|
||||||
|
switch (anchor) {
|
||||||
|
case 'top-left':
|
||||||
|
return { x: SCREEN_PADDING, y: SCREEN_PADDING };
|
||||||
|
case 'top-right':
|
||||||
|
return { x: w - AVATAR_SIZE - SCREEN_PADDING, y: SCREEN_PADDING };
|
||||||
|
case 'bottom-left':
|
||||||
|
return { x: SCREEN_PADDING, y: h - AVATAR_SIZE - SCREEN_PADDING };
|
||||||
|
case 'center':
|
||||||
|
return { x: (w - AVATAR_SIZE) / 2, y: (h - AVATAR_SIZE) / 2 };
|
||||||
|
case 'right':
|
||||||
|
return { x: w - AVATAR_SIZE - SCREEN_PADDING, y: (h - AVATAR_SIZE) / 2 };
|
||||||
|
case 'bottom-right':
|
||||||
|
default:
|
||||||
|
return { x: w - AVATAR_SIZE - SCREEN_PADDING, y: h - AVATAR_SIZE - SCREEN_PADDING };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePosition(pos: AvatarPosition): { x: number; y: number } {
|
||||||
|
return typeof pos === 'string' ? anchorToCoords(pos) : pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API (exposed via bind:this) ───────────────────────────────
|
||||||
|
export function start() {
|
||||||
|
const defaultPos = config.avatar?.defaultPosition ?? 'bottom-right';
|
||||||
|
const pos = resolvePosition(defaultPos);
|
||||||
|
avatarX = pos.x;
|
||||||
|
avatarY = pos.y;
|
||||||
|
isActive = true;
|
||||||
|
|
||||||
|
const start = runner.getStartNode();
|
||||||
|
if (start) _enterNode(start.id, start.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stop() {
|
||||||
|
_clearAutoAdvance();
|
||||||
|
isActive = false;
|
||||||
|
bubbleVisible = false;
|
||||||
|
currentNodeId = null;
|
||||||
|
mood = 'idle';
|
||||||
|
onComplete?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function next() {
|
||||||
|
if (!currentNodeId) return;
|
||||||
|
await _runAfter(currentNodeId, currentNode);
|
||||||
|
const next = runner.getNextNode(currentNodeId);
|
||||||
|
if (next) _enterNode(next.id, next.node);
|
||||||
|
else stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerHook(name: string, fn: PlantyHook) {
|
||||||
|
hooks = { ...hooks, [name]: fn };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _runAfter(nodeId: string, node: DialogNode | null) {
|
||||||
|
if (!node) return;
|
||||||
|
if (actionCleanup) {
|
||||||
|
actionCleanup();
|
||||||
|
actionCleanup = null;
|
||||||
|
}
|
||||||
|
await node.after?.(nodeId, node);
|
||||||
|
await hooks[`after:${nodeId}`]?.(nodeId, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _enterNode(id: string, node: DialogNode) {
|
||||||
|
_clearAutoAdvance();
|
||||||
|
bubbleVisible = false;
|
||||||
|
currentNodeId = id;
|
||||||
|
onStepChange?.(id, node);
|
||||||
|
|
||||||
|
// Before hooks — run before movement starts
|
||||||
|
await node.before?.(id, node);
|
||||||
|
await hooks[`before:${id}`]?.(id, node);
|
||||||
|
|
||||||
|
// Fly to position first, then talk
|
||||||
|
if (node.position) {
|
||||||
|
mood = 'moving';
|
||||||
|
const pos = resolvePosition(node.position);
|
||||||
|
const hasChanges = pos.x !== avatarX || pos.y !== avatarY;
|
||||||
|
avatarX = pos.x;
|
||||||
|
avatarY = pos.y;
|
||||||
|
if (hasChanges) await _wait(900);
|
||||||
|
}
|
||||||
|
|
||||||
|
mood = 'talking';
|
||||||
|
bubbleVisible = true;
|
||||||
|
|
||||||
|
// App hook
|
||||||
|
if (node.action && actions[node.action]) {
|
||||||
|
const result = await actions[node.action]();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stay in talking mood until the typewriter finishes (26 ms/char + buffer)
|
||||||
|
const talkMs = (node.text?.length ?? 0) * 26 + 200;
|
||||||
|
setTimeout(() => {
|
||||||
|
mood = 'idle';
|
||||||
|
}, talkMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wait(ms: number) {
|
||||||
|
return new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _clearAutoAdvance() {
|
||||||
|
if (autoAdvanceTimer !== null) {
|
||||||
|
clearTimeout(autoAdvanceTimer);
|
||||||
|
autoAdvanceTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isActive}
|
||||||
|
<div class="pointer-events-none fixed inset-0 z-99999">
|
||||||
|
{#if highlight}
|
||||||
|
<Highlight selector={highlight.selector} hookName={highlight.hookName} {hooks} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<PlantyAvatar bind:this={avatar} bind:x={avatarX} bind:y={avatarY} {mood} />
|
||||||
|
|
||||||
|
{#if showBubble && currentNode}
|
||||||
|
<SpeechBubble
|
||||||
|
text={currentNode.text ?? ''}
|
||||||
|
{avatarX}
|
||||||
|
{avatarY}
|
||||||
|
choices={currentNode.choices || []}
|
||||||
|
showNext={nextNode !== null}
|
||||||
|
{stepIndex}
|
||||||
|
{totalSteps}
|
||||||
|
onNext={next}
|
||||||
|
onClose={stop}
|
||||||
|
onChoose={async (choice) => {
|
||||||
|
await _runAfter(currentNodeId!, currentNode);
|
||||||
|
if (choice && choice.action) {
|
||||||
|
if (choice.action in actions) {
|
||||||
|
actions[choice.action]();
|
||||||
|
} else {
|
||||||
|
console.warn(`Planty: No action found for ${choice.action}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!choice.next) {
|
||||||
|
stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = runner.followChoice(choice);
|
||||||
|
if (n) _enterNode(n.id, n.node);
|
||||||
|
else stop();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { scale } from 'svelte/transition';
|
||||||
|
export type Mood = 'idle' | 'talking' | 'happy' | 'thinking' | 'moving';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
mood?: Mood;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { x = $bindable(0), y = $bindable(0), mood = 'idle' }: Props = $props();
|
||||||
|
|
||||||
|
// ── Drag ─────────────────────────────────────────────────────────────
|
||||||
|
let dragging = $state(false);
|
||||||
|
let dragOffsetX = 0;
|
||||||
|
let dragOffsetY = 0;
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
dragging = true;
|
||||||
|
dragOffsetX = e.clientX - x;
|
||||||
|
dragOffsetY = e.clientY - y;
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!dragging) return;
|
||||||
|
x = Math.max(Math.min(e.clientX - dragOffsetX, window.innerWidth - 45), 5);
|
||||||
|
y = Math.max(Math.min(e.clientY - dragOffsetY, window.innerHeight - 75), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
dragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayMood = $derived(dragging ? 'moving' : mood);
|
||||||
|
|
||||||
|
let mouthOpen = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (displayMood !== 'talking') {
|
||||||
|
mouthOpen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = setInterval(() => {
|
||||||
|
mouthOpen = !mouthOpen;
|
||||||
|
}, 180);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const MOUTH_DOWN =
|
||||||
|
'M29.5 55L28 63L23 68.5L14 70.5L6.5 66L4 58.5L10.5 29L15 24H24L28 29.5L28.5 34L23 58L16.5 61.5L10.5 59.5L8.5 53.5';
|
||||||
|
const MOUTH_UP =
|
||||||
|
'M29.5 55L28 63L23 68.5L14 70.5L6.5 66L4 58.5L10.5 29L15 24H24L28 29.5L28.5 34L24 56.5L17.5 60L11.5 58L9.5 52';
|
||||||
|
|
||||||
|
const bodyPath = $derived(
|
||||||
|
(displayMood === 'talking' && mouthOpen) || displayMood === 'happy' ? MOUTH_DOWN : MOUTH_UP
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Cursor-tracking pupils ────────────────────────────────────────────
|
||||||
|
// Avatar screen positions of each eye centre (SVG natural size 46×74)
|
||||||
|
let cursorX = $state(-9999);
|
||||||
|
let cursorY = $state(-9999);
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
cursorX = e.clientX;
|
||||||
|
cursorY = e.clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flash(flashMood: Mood, duration = 500) {
|
||||||
|
const prev = displayMood;
|
||||||
|
mood = flashMood;
|
||||||
|
setTimeout(() => (mood = prev), duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pupilOffset(cx: number, cy: number, eyeSvgX: number, eyeSvgY: number, maxPx = 2.8) {
|
||||||
|
const ex = x + eyeSvgX;
|
||||||
|
const ey = y + eyeSvgY;
|
||||||
|
const dx = cx - ex;
|
||||||
|
const dy = cy - ey;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist < 1) return { px: 0, py: 0 };
|
||||||
|
// Ramp up to full offset over 120px of distance
|
||||||
|
const t = Math.min(dist, 120) / 120;
|
||||||
|
return { px: (dx / dist) * maxPx * t, py: (dy / dist) * maxPx * t };
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = $derived(
|
||||||
|
displayMood === 'talking' ? { px: 0, py: 0 } : pupilOffset(cursorX, cursorY, 9.5, 30.5)
|
||||||
|
);
|
||||||
|
const right = $derived(
|
||||||
|
displayMood === 'talking' ? { px: 0, py: 0 } : pupilOffset(cursorX, cursorY, 31.5, 35.5)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onmousemove={onMouseMove} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="avatar"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
in:scale={{ duration: 400, delay: 300 }}
|
||||||
|
class:mood-idle={displayMood === 'idle'}
|
||||||
|
class:mood-thinking={displayMood === 'thinking'}
|
||||||
|
class:mood-talking={displayMood === 'talking'}
|
||||||
|
class:mood-happy={displayMood === 'happy'}
|
||||||
|
class:mood-moving={displayMood === 'moving'}
|
||||||
|
class:dragging
|
||||||
|
style:left="{x}px"
|
||||||
|
style:top="{y}px"
|
||||||
|
onpointerdown={onPointerDown}
|
||||||
|
onpointermove={onPointerMove}
|
||||||
|
onpointerup={onPointerUp}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="46"
|
||||||
|
height="74"
|
||||||
|
viewBox="0 0 46 74"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
overflow="visible"
|
||||||
|
>
|
||||||
|
<!--
|
||||||
|
Leaf hinge points (transform-box: fill-box):
|
||||||
|
leave-right → origin 0% 100% (bottom-left of bbox)
|
||||||
|
leave-left → origin 100% 100% (bottom-right of bbox)
|
||||||
|
-->
|
||||||
|
<g class="leave-right">
|
||||||
|
<path
|
||||||
|
d="M26.9781 16.5596L22.013 23.2368L22.8082 25.306L35.2985 25.3849L43.7783 20.6393L45.8723 14.8213L35.7374 14.0864L26.9781 16.5596Z"
|
||||||
|
fill="#4F7B41"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M27 16.5L22.013 23.2368L22.8082 25.306L29 21L36.5 17L45.8723 14.8213L36 14L27 16.5Z"
|
||||||
|
fill="#406634"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="leave-left">
|
||||||
|
<path
|
||||||
|
d="M11.3107 19.2204L17.7636 24.7215L20.3207 25.3703L22.8257 13.0024L19.0993 2.99176L12.5794 1.95314e-05L10.0997 9.77364L11.3107 19.2204Z"
|
||||||
|
fill="#4F7B41"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M11.3107 19.2204L17.7636 24.7215L20.3207 25.3703L16 17L13.5 8L12.5794 1.95314e-05L10.0997 9.77364L11.3107 19.2204Z"
|
||||||
|
fill="#5E8751"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<path class="body" d={bodyPath} stroke="#4F7B41" stroke-width="3" />
|
||||||
|
|
||||||
|
<!-- Left eye — pupils translated toward cursor -->
|
||||||
|
<g class="eye-left">
|
||||||
|
<circle cx="9.5" cy="30.5" r="9.5" fill="white" />
|
||||||
|
<g transform="translate({left.px} {left.py})">
|
||||||
|
<circle class="pupil" cx="9.5" cy="30.5" r="6.5" fill="black" />
|
||||||
|
<circle cx="10.5" cy="27.5" r="2.5" fill="white" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Right eye — pupils translated toward cursor -->
|
||||||
|
<g class="eye-right">
|
||||||
|
<circle cx="31.5" cy="35.5" r="9.5" fill="white" />
|
||||||
|
<g transform="translate({right.px} {right.py})">
|
||||||
|
<circle class="pupil" cx="30.5" cy="34.5" r="6.5" fill="black" />
|
||||||
|
<circle cx="30.5" cy="31.5" r="2.5" fill="white" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ── Wrapper ─────────────────────────────────────────────────────── */
|
||||||
|
.avatar {
|
||||||
|
position: absolute;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
filter: drop-shadow(0px 0px 10px black);
|
||||||
|
transition:
|
||||||
|
left 0.85s cubic-bezier(0.33, 1, 0.68, 1),
|
||||||
|
top 0.85s cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* idle: steady vertical bob */
|
||||||
|
@keyframes bob {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mood-idle {
|
||||||
|
animation: bob 2.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.mood-happy {
|
||||||
|
animation: bob 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* thinking: head tilted to the side — clearly different from idle */
|
||||||
|
@keyframes think {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(-12deg) translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(-12deg) translateY(-3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mood-thinking {
|
||||||
|
animation: think 2.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* talking: subtle head waggle */
|
||||||
|
@keyframes waggle {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: rotate(-2deg) translateY(-1px);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: rotate(2deg) translateY(1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mood-talking {
|
||||||
|
animation: waggle 0.3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* moving: forward-lean glide */
|
||||||
|
@keyframes glide {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0) rotate(-6deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-8px) rotate(-4deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mood-moving {
|
||||||
|
animation: glide 0.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Drop shadows ────────────────────────────────────────────────── */
|
||||||
|
.body {
|
||||||
|
filter: drop-shadow(1px 1px 0 rgba(0, 0, 0, 0.5));
|
||||||
|
transition: d 0.12s ease-in-out;
|
||||||
|
}
|
||||||
|
.eye-left,
|
||||||
|
.eye-right {
|
||||||
|
filter: drop-shadow(1px 1px 0 rgba(0, 0, 0, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood-talking {
|
||||||
|
.eye-left,
|
||||||
|
.eye-right {
|
||||||
|
> g {
|
||||||
|
transition: transform 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Leaves ──────────────────────────────────────────────────────── */
|
||||||
|
.leave-right {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: 0% 100%;
|
||||||
|
}
|
||||||
|
.leave-left {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: 100% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* idle: slow gentle breathing wave */
|
||||||
|
@keyframes idle-right {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(-9deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes idle-left {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(7deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mood-idle .leave-right {
|
||||||
|
animation: idle-right 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.mood-idle .leave-left {
|
||||||
|
animation: idle-left 3s ease-in-out infinite 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* thinking: wings held raised, minimal drift */
|
||||||
|
@keyframes think-right {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(-14deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(-10deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes think-left {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(10deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(7deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mood-thinking .leave-right {
|
||||||
|
animation: think-right 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.mood-thinking .leave-left {
|
||||||
|
animation: think-left 4s ease-in-out infinite 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* talking: nearly still — tiny passive counter-sway */
|
||||||
|
@keyframes talk-right {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(-2deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(2deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes talk-left {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(2deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(-2deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mood-talking .leave-right {
|
||||||
|
animation: talk-right 0.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.mood-talking .leave-left {
|
||||||
|
animation: talk-left 0.6s ease-in-out infinite 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* happy: light casual flap */
|
||||||
|
@keyframes happy-right {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(-18deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes happy-left {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(13deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mood-happy .leave-right {
|
||||||
|
animation: happy-right 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.mood-happy .leave-left {
|
||||||
|
animation: happy-left 1.4s ease-in-out infinite 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* moving: vigorous wing flap — full range, fast */
|
||||||
|
@keyframes flap-right {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: rotate(-40deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes flap-left {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: rotate(26deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mood-moving .leave-right {
|
||||||
|
animation: flap-right 0.34s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.mood-moving .leave-left {
|
||||||
|
animation: flap-left 0.34s ease-in-out infinite 0.04s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Eye blink (on pupil so it doesn't fight cursor translate) ───── */
|
||||||
|
@keyframes blink {
|
||||||
|
0%,
|
||||||
|
93%,
|
||||||
|
100% {
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
96% {
|
||||||
|
transform: scaleY(0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pupil {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: blink 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.eye-left .pupil {
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
.eye-right .pupil {
|
||||||
|
animation-delay: 0.07s;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import type { Choice } from '../types.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string;
|
||||||
|
avatarX: number;
|
||||||
|
avatarY: number;
|
||||||
|
choices?: Choice[];
|
||||||
|
showNext?: boolean;
|
||||||
|
stepIndex?: number;
|
||||||
|
totalSteps?: number;
|
||||||
|
onNext?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
onChoose?: (choice: Choice) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
text,
|
||||||
|
avatarX,
|
||||||
|
avatarY,
|
||||||
|
choices = [],
|
||||||
|
showNext = false,
|
||||||
|
stepIndex = -1,
|
||||||
|
totalSteps = 0,
|
||||||
|
onNext,
|
||||||
|
onClose,
|
||||||
|
onChoose
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const showProgress = $derived(stepIndex >= 0 && totalSteps > 0);
|
||||||
|
|
||||||
|
const BUBBLE_WIDTH = 268;
|
||||||
|
const AVATAR_SIZE = 80;
|
||||||
|
const GAP = 10;
|
||||||
|
|
||||||
|
const isAvatarNearTop = $derived(avatarY < BUBBLE_WIDTH + GAP + 8);
|
||||||
|
|
||||||
|
const left = $derived(Math.max(8, Math.min(avatarX, window.innerWidth - BUBBLE_WIDTH - 8)));
|
||||||
|
const bottom = $derived(isAvatarNearTop ? null : `${window.innerHeight - avatarY + GAP}px`);
|
||||||
|
const top = $derived(isAvatarNearTop ? `${avatarY + AVATAR_SIZE + GAP}px` : null);
|
||||||
|
|
||||||
|
// Typewriter
|
||||||
|
let displayed = $state('');
|
||||||
|
const finished = $derived(displayed.length === text.length);
|
||||||
|
let typeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function renderMarkdown(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replaceAll(/^# (.+)$/gm, '<strong class="block text-sm font-bold mb-1">$1</strong>')
|
||||||
|
.replaceAll(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replaceAll(/\*(.+?)\*/g, '<em>$1</em>')
|
||||||
|
.replaceAll(
|
||||||
|
/`(.+?)`/g,
|
||||||
|
'<code class="text-[11px] rounded px-1 font-mono" style="background: var(--color-layer-3); color: var(--color-text);">$1</code>'
|
||||||
|
)
|
||||||
|
.replaceAll(/\*/g, '')
|
||||||
|
.replaceAll(/_/g, '')
|
||||||
|
.replaceAll(/\n+/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Track only `text` as a dependency.
|
||||||
|
// Never read `displayed` inside the effect — += would add it as a dep
|
||||||
|
// and cause an infinite loop. Use slice(0, i) for pure writes instead.
|
||||||
|
const target = text;
|
||||||
|
|
||||||
|
displayed = '';
|
||||||
|
if (typeTimer) clearTimeout(typeTimer);
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
function tick() {
|
||||||
|
if (i < target.length) {
|
||||||
|
displayed = target.slice(0, ++i);
|
||||||
|
typeTimer = setTimeout(tick, 26);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Defer first tick so no reads happen during the synchronous effect body
|
||||||
|
typeTimer = setTimeout(tick, 0);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (typeTimer) clearTimeout(typeTimer);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="pointer-events-auto fixed z-99999 rounded-md border p-2"
|
||||||
|
style:width="{BUBBLE_WIDTH}px"
|
||||||
|
style:left="{left}px"
|
||||||
|
style:bottom
|
||||||
|
style:top
|
||||||
|
style:background="var(--color-layer-0)"
|
||||||
|
style:border-color="var(--color-outline)"
|
||||||
|
>
|
||||||
|
{#if isAvatarNearTop}
|
||||||
|
<!-- Tail pointing up toward avatar -->
|
||||||
|
<div
|
||||||
|
class="absolute -top-2 h-3.5 w-3.5 rotate-45 border-t border-l"
|
||||||
|
style:left="{Math.min(
|
||||||
|
Math.max(avatarX - left + AVATAR_SIZE / 2 - 25, 12),
|
||||||
|
BUBBLE_WIDTH - 28
|
||||||
|
)}px"
|
||||||
|
style:background="var(--color-layer-0)"
|
||||||
|
style:border-color="var(--color-outline)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Tail pointing down toward avatar -->
|
||||||
|
<div
|
||||||
|
class="absolute -bottom-2 h-3.5 w-3.5 rotate-45 border-r border-b"
|
||||||
|
style:left="{Math.min(
|
||||||
|
Math.max(avatarX - left + AVATAR_SIZE / 2 - 25, 12),
|
||||||
|
BUBBLE_WIDTH - 28
|
||||||
|
)}px"
|
||||||
|
style:background="var(--color-layer-0)"
|
||||||
|
style:border-color="var(--color-outline)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mb-2 min-h-[1.4em] text-sm leading-relaxed" style="color: var(--color-text)">
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
|
{@html renderMarkdown(displayed)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if choices.length > 0}
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
{#each choices as choice, i (choice.label)}
|
||||||
|
{#if finished}
|
||||||
|
<button
|
||||||
|
in:fade={{ duration: 200, delay: i * 250 }}
|
||||||
|
class="cursor-pointer rounded-lg px-3 py-1.5 text-left text-sm font-medium transition-colors"
|
||||||
|
style:background="var(--color-layer-1)"
|
||||||
|
style:border-color="var(--color-outline)"
|
||||||
|
style:color="var(--color-text)"
|
||||||
|
onclick={() => onChoose?.(choice)}
|
||||||
|
>
|
||||||
|
{choice.label}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-2 flex items-center justify-between gap-2">
|
||||||
|
<button
|
||||||
|
class="cursor-pointer text-xs transition-colors"
|
||||||
|
style="color: var(--color-outline)"
|
||||||
|
onclick={onClose}
|
||||||
|
>
|
||||||
|
✕ close
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if showProgress}
|
||||||
|
<span class="text-xs tabular-nums" style="color: var(--color-outline)">
|
||||||
|
{stepIndex + 1} / {totalSteps}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if showNext && finished}
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded-lg px-3 py-1 text-xs font-semibold transition-colors"
|
||||||
|
style:background="var(--color-outline)"
|
||||||
|
style:color="var(--color-layer-0)"
|
||||||
|
onclick={onNext}
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Choice, DialogNode, PlantyConfig } from './types.js';
|
||||||
|
|
||||||
|
export class DialogRunner {
|
||||||
|
private config: PlantyConfig;
|
||||||
|
|
||||||
|
constructor(config: PlantyConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
getNode(id: string): DialogNode | null {
|
||||||
|
return this.config.nodes[id] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStartNode(): { id: string; node: DialogNode } | null {
|
||||||
|
const node = this.getNode(this.config.start);
|
||||||
|
if (!node) return null;
|
||||||
|
return { id: this.config.start, node };
|
||||||
|
}
|
||||||
|
|
||||||
|
getNextNode(currentId: string): { id: string; node: DialogNode } | null {
|
||||||
|
const current = this.getNode(currentId);
|
||||||
|
if (!current) return null;
|
||||||
|
if (!current.next) return null;
|
||||||
|
const next = this.getNode(current.next);
|
||||||
|
if (!next) return null;
|
||||||
|
return { id: current.next, node: next };
|
||||||
|
}
|
||||||
|
|
||||||
|
followChoice(choice: Choice): { id: string; node: DialogNode } | null {
|
||||||
|
if (!choice.next) return null;
|
||||||
|
const node = this.getNode(choice.next);
|
||||||
|
if (!node) return null;
|
||||||
|
return { id: choice.next, node };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk the main path (first choice for choice nodes) and return all node IDs. */
|
||||||
|
getMainPath(): string[] {
|
||||||
|
const path: string[] = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
let id: string | null = this.config.start;
|
||||||
|
while (id && !visited.has(id)) {
|
||||||
|
visited.add(id);
|
||||||
|
path.push(id);
|
||||||
|
const node = this.getNode(id);
|
||||||
|
if (!node) break;
|
||||||
|
const next = node.choices?.[0]?.next ?? node.next;
|
||||||
|
if (next) id = next;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export { default as Planty } from './components/Planty.svelte';
|
||||||
|
export type {
|
||||||
|
AvatarAnchor,
|
||||||
|
AvatarPosition,
|
||||||
|
Choice,
|
||||||
|
DialogNode,
|
||||||
|
HighlightTarget,
|
||||||
|
PlantyConfig,
|
||||||
|
PlantyHook,
|
||||||
|
StepCallback
|
||||||
|
} from './types.js';
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { DialogNode, StepCallback } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-module step hook registry.
|
||||||
|
*
|
||||||
|
* Create one shared instance and import it wherever you need to react to
|
||||||
|
* Planty steps — no reference to the <Planty> component required.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // tutorial-steps.ts
|
||||||
|
* export const steps = createPlantySteps();
|
||||||
|
*
|
||||||
|
* // graph-editor.ts
|
||||||
|
* steps.before('highlight_graph', () => graphEditor.setHighlight(true));
|
||||||
|
* steps.after ('highlight_graph', () => graphEditor.setHighlight(false));
|
||||||
|
*
|
||||||
|
* // +page.svelte
|
||||||
|
* <Planty {config} {steps} />
|
||||||
|
*/
|
||||||
|
export class PlantySteps {
|
||||||
|
private _before = new Map<string, StepCallback[]>();
|
||||||
|
private _after = new Map<string, StepCallback[]>();
|
||||||
|
|
||||||
|
/** Register a handler to run before `nodeId` becomes active. Chainable. */
|
||||||
|
before(nodeId: string, fn: StepCallback): this {
|
||||||
|
const list = this._before.get(nodeId) ?? [];
|
||||||
|
this._before.set(nodeId, [...list, fn]);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a handler to run after the user leaves `nodeId`. Chainable. */
|
||||||
|
after(nodeId: string, fn: StepCallback): this {
|
||||||
|
const list = this._after.get(nodeId) ?? [];
|
||||||
|
this._after.set(nodeId, [...list, fn]);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all handlers for a node (or all nodes if omitted). */
|
||||||
|
clear(nodeId?: string) {
|
||||||
|
if (nodeId) {
|
||||||
|
this._before.delete(nodeId);
|
||||||
|
this._after.delete(nodeId);
|
||||||
|
} else {
|
||||||
|
this._before.clear();
|
||||||
|
this._after.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal — called by Planty */
|
||||||
|
async runBefore(nodeId: string, node: DialogNode): Promise<void> {
|
||||||
|
for (const fn of this._before.get(nodeId) ?? []) {
|
||||||
|
await fn(nodeId, node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal — called by Planty */
|
||||||
|
async runAfter(nodeId: string, node: DialogNode): Promise<void> {
|
||||||
|
for (const fn of this._after.get(nodeId) ?? []) {
|
||||||
|
await fn(nodeId, node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlantySteps(): PlantySteps {
|
||||||
|
return new PlantySteps();
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
export type AvatarAnchor =
|
||||||
|
| 'top-left'
|
||||||
|
| 'top-right'
|
||||||
|
| 'bottom-left'
|
||||||
|
| 'bottom-right'
|
||||||
|
| 'center'
|
||||||
|
| 'right';
|
||||||
|
|
||||||
|
export type AvatarPosition = { x: number; y: number } | AvatarAnchor;
|
||||||
|
|
||||||
|
export interface HighlightTarget {
|
||||||
|
/** CSS selector for the element to highlight */
|
||||||
|
selector?: string;
|
||||||
|
/** Name of an app-registered hook that returns Element | null */
|
||||||
|
hookName?: string;
|
||||||
|
/** Extra space around the element in px */
|
||||||
|
padding?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialogNode {
|
||||||
|
text?: string;
|
||||||
|
position?: AvatarPosition;
|
||||||
|
highlight?: HighlightTarget;
|
||||||
|
/** App hook to call on entering this node */
|
||||||
|
action?: string;
|
||||||
|
next?: string | null;
|
||||||
|
choices?: Choice[];
|
||||||
|
/** Called (and awaited) just before the avatar starts moving to this node */
|
||||||
|
before?: StepCallback;
|
||||||
|
/** Called (and awaited) just before the user leaves this node */
|
||||||
|
after?: StepCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Choice {
|
||||||
|
label: string;
|
||||||
|
next?: string | null;
|
||||||
|
action?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlantyConfig {
|
||||||
|
id: string;
|
||||||
|
avatar?: {
|
||||||
|
name?: string;
|
||||||
|
defaultPosition?: AvatarPosition;
|
||||||
|
};
|
||||||
|
start: string;
|
||||||
|
nodes: Record<string, DialogNode>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlantyHook = (
|
||||||
|
...args: unknown[]
|
||||||
|
) => void | Element | null | Promise<void> | (() => void);
|
||||||
|
|
||||||
|
/** Called before/after a node becomes active. Async-safe. */
|
||||||
|
export type StepCallback = (nodeId: string, node: DialogNode) => void | Promise<void>;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '@nodarium/ui/app.css';
|
||||||
|
import './layout.css';
|
||||||
|
const { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Planty from '$lib/components/Planty.svelte';
|
||||||
|
import PlantyAvatar, { type Mood } from '$lib/components/PlantyAvatar.svelte';
|
||||||
|
import type { PlantyConfig } from '$lib/types.js';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import ThemeSelector from './ThemeSelector.svelte';
|
||||||
|
|
||||||
|
let plantyConfig = $state<PlantyConfig | null>(null);
|
||||||
|
let planty: ReturnType<typeof Planty> | undefined = $state();
|
||||||
|
let started = $state(false);
|
||||||
|
|
||||||
|
// Avatar preview state
|
||||||
|
const moods: Mood[] = ['idle', 'talking', 'happy', 'thinking', 'moving'];
|
||||||
|
let previewMood = $state<Mood>('idle');
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const res = await fetch('/demo-tutorial.json');
|
||||||
|
plantyConfig = await res.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
function startTour() {
|
||||||
|
planty?.start();
|
||||||
|
started = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Planty — Demo</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid min-h-screen grid-rows-[auto_1fr]"
|
||||||
|
style="background-color: var(--color-layer-0); color: var(--color-text);"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<header
|
||||||
|
class="flex h-12 items-center gap-4 px-8 py-5"
|
||||||
|
style="border-color: var(--color-outline);"
|
||||||
|
>
|
||||||
|
<h1 class="text-xl font-semibold">🌿 Planty</h1>
|
||||||
|
<span
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs font-bold"
|
||||||
|
style="background: var(--color-layer-3); color: var(--color-layer-0);"
|
||||||
|
>demo</span>
|
||||||
|
|
||||||
|
<ThemeSelector />
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="ml-auto rounded-xl px-5 py-2 text-sm font-bold transition hover:scale-95 active:scale-95"
|
||||||
|
style="background: var(--color-layer-3); color: var(--color-layer-0);"
|
||||||
|
onclick={startTour}
|
||||||
|
disabled={started || !plantyConfig}
|
||||||
|
>
|
||||||
|
{started ? 'Tour running…' : 'Start tutorial'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- App layout -->
|
||||||
|
<main class="grid grid-cols-[1fr_280px]">
|
||||||
|
<!-- Graph canvas -->
|
||||||
|
<div
|
||||||
|
id="graph-canvas"
|
||||||
|
class="relative flex min-h-125 items-center justify-center"
|
||||||
|
style="background-color: var(--color-layer-1); background-image: radial-gradient(circle, var(--color-outline) 1px, transparent 1px); background-size: 24px 24px;"
|
||||||
|
>
|
||||||
|
<p class="text-center text-sm" style="color: var(--color-outline);">
|
||||||
|
Node graph canvas<br />
|
||||||
|
<span style="opacity: 0.6;">(click "Start tutorial" above)</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Avatar mood preview (bottom of canvas) -->
|
||||||
|
<div class="absolute bottom-6 left-1/2 flex -translate-x-1/2 flex-col items-center gap-4">
|
||||||
|
<!-- Static preview at fixed position inside the canvas -->
|
||||||
|
<div class="relative h-20 w-12">
|
||||||
|
<PlantyAvatar x={0} y={0} mood={previewMood} />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#each moods as m (m)}
|
||||||
|
<button
|
||||||
|
class="rounded-lg border px-3 py-1 text-xs transition"
|
||||||
|
onclick={() => (previewMood = m)}
|
||||||
|
style="border-color: {previewMood === m
|
||||||
|
? 'var(--color-selected)'
|
||||||
|
: 'var(--color-outline)'}; color: {previewMood === m
|
||||||
|
? 'var(--color-selected)'
|
||||||
|
: 'var(--color-text)'}; background: {previewMood === m
|
||||||
|
? 'var(--color-layer-2)'
|
||||||
|
: 'transparent'};"
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside
|
||||||
|
id="sidebar"
|
||||||
|
class="flex flex-col gap-3 p-5"
|
||||||
|
style="border-color: var(--color-outline); background-color: var(--color-layer-0);"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-xs font-semibold tracking-widest uppercase"
|
||||||
|
style="color: var(--color-outline);"
|
||||||
|
>Parameters</span>
|
||||||
|
<div
|
||||||
|
class="rounded-lg px-3 py-2 text-sm"
|
||||||
|
style="background: var(--color-layer-1); color: var(--color-text);"
|
||||||
|
>
|
||||||
|
Branch length: 1.0
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-lg px-3 py-2 text-sm"
|
||||||
|
style="background: var(--color-layer-1); color: var(--color-text);"
|
||||||
|
>
|
||||||
|
Segments: 8
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-lg px-3 py-2 text-sm"
|
||||||
|
style="background: var(--color-layer-1); color: var(--color-text);"
|
||||||
|
>
|
||||||
|
Leaf density: 0.6
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="mt-2 text-xs font-semibold tracking-widest uppercase"
|
||||||
|
style="color: var(--color-outline);"
|
||||||
|
>Export</span>
|
||||||
|
<div
|
||||||
|
class="rounded-lg px-3 py-2 text-sm"
|
||||||
|
style="background: var(--color-layer-1); color: var(--color-text);"
|
||||||
|
>
|
||||||
|
.obj / .glb
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if plantyConfig}
|
||||||
|
<Planty
|
||||||
|
bind:this={planty}
|
||||||
|
config={plantyConfig}
|
||||||
|
onComplete={() => {
|
||||||
|
started = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { InputSelect } from '@nodarium/ui';
|
||||||
|
const themes = [
|
||||||
|
'dark',
|
||||||
|
'light',
|
||||||
|
'solarized',
|
||||||
|
'catppuccin',
|
||||||
|
'high-contrast',
|
||||||
|
'high-contrast-light',
|
||||||
|
'nord',
|
||||||
|
'dracula',
|
||||||
|
'custom'
|
||||||
|
];
|
||||||
|
|
||||||
|
let themeIndex = $state(0);
|
||||||
|
$effect(() => {
|
||||||
|
const classList = document.documentElement.classList;
|
||||||
|
for (const c of classList) {
|
||||||
|
if (c.startsWith('theme-')) document.documentElement.classList.remove(c);
|
||||||
|
}
|
||||||
|
document.documentElement.classList.add(`theme-${themes[themeIndex]}`);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<InputSelect bind:value={themeIndex} options={themes}></InputSelect>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-layer-0);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"id": "demo-tutorial",
|
||||||
|
"avatar": {
|
||||||
|
"name": "Planty",
|
||||||
|
"defaultPosition": "bottom-right"
|
||||||
|
},
|
||||||
|
"start": "welcome",
|
||||||
|
"nodes": {
|
||||||
|
"welcome": {
|
||||||
|
"type": "choice",
|
||||||
|
"position": "bottom-right",
|
||||||
|
"text": "👋 Hey! I'm Planty — your guide to this app. How would you like me to explain things?",
|
||||||
|
"choices": [
|
||||||
|
{ "label": "🤓 Technical — give me the details", "next": "intro_nerd" },
|
||||||
|
{ "label": "🌱 Simple — keep it friendly", "next": "intro_simple" },
|
||||||
|
{ "label": "No thanks, skip the tour", "next": null }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"intro_nerd": {
|
||||||
|
"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.",
|
||||||
|
"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!",
|
||||||
|
"next": "highlight_graph_simple"
|
||||||
|
},
|
||||||
|
|
||||||
|
"highlight_graph_nerd": {
|
||||||
|
"type": "step",
|
||||||
|
"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.",
|
||||||
|
"next": "highlight_sidebar_nerd"
|
||||||
|
},
|
||||||
|
"highlight_graph_simple": {
|
||||||
|
"type": "step",
|
||||||
|
"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!",
|
||||||
|
"next": "highlight_sidebar_simple"
|
||||||
|
},
|
||||||
|
|
||||||
|
"highlight_sidebar_nerd": {
|
||||||
|
"type": "step",
|
||||||
|
"position": "bottom-right",
|
||||||
|
"highlight": { "selector": "#sidebar", "padding": 8 },
|
||||||
|
"text": "The sidebar exposes node parameters, export settings, and the raw graph JSON for debugging.",
|
||||||
|
"next": "tip_nerd"
|
||||||
|
},
|
||||||
|
"highlight_sidebar_simple": {
|
||||||
|
"type": "step",
|
||||||
|
"position": "bottom-right",
|
||||||
|
"highlight": { "selector": "#sidebar", "padding": 8 },
|
||||||
|
"text": "The sidebar lets you tweak settings and export your creation.",
|
||||||
|
"next": "tip_simple"
|
||||||
|
},
|
||||||
|
|
||||||
|
"tip_nerd": {
|
||||||
|
"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.",
|
||||||
|
"next": "done_nerd"
|
||||||
|
},
|
||||||
|
"tip_simple": {
|
||||||
|
"type": "step",
|
||||||
|
"position": "center",
|
||||||
|
"text": "Press Space anywhere on the canvas to add a new block — try it!",
|
||||||
|
"next": "done_simple"
|
||||||
|
},
|
||||||
|
|
||||||
|
"done_nerd": {
|
||||||
|
"type": "end",
|
||||||
|
"position": "bottom-right",
|
||||||
|
"text": "You're all set. Check the docs for the full NodeDefinition interface. Happy hacking! 🌿"
|
||||||
|
},
|
||||||
|
"done_simple": {
|
||||||
|
"type": "end",
|
||||||
|
"position": "bottom-right",
|
||||||
|
"text": "That's the tour! Have fun building your plant. 🌱"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128">
|
||||||
|
<title>svelte-logo</title><path
|
||||||
|
d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116"
|
||||||
|
style="fill:#ff3e00"
|
||||||
|
/><path
|
||||||
|
d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328"
|
||||||
|
style="fill:#fff"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,17 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-auto';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
compilerOptions: {
|
||||||
|
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||||
|
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
||||||
|
},
|
||||||
|
kit: {
|
||||||
|
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||||
|
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||||
|
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||||
|
adapter: adapter()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@nodarium/types",
|
"name": "@nodarium/types",
|
||||||
"version": "0.0.4",
|
"version": "0.0.6",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "dprint fmt -c '../../.dprint.jsonc' .",
|
"format": "dprint fmt -c '../../.dprint.jsonc' .",
|
||||||
@@ -17,9 +18,9 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"dprint": "^0.51.1"
|
"dprint": "^0.54.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ export type {
|
|||||||
Box,
|
Box,
|
||||||
Edge,
|
Edge,
|
||||||
Graph,
|
Graph,
|
||||||
|
GroupDefinition,
|
||||||
NodeDefinition,
|
NodeDefinition,
|
||||||
NodeId,
|
NodeId,
|
||||||
NodeInstance,
|
NodeInstance,
|
||||||
|
SerializedEdge,
|
||||||
SerializedNode,
|
SerializedNode,
|
||||||
Socket
|
Socket
|
||||||
} from './types';
|
} from './types';
|
||||||
export { GraphSchema, NodeSchema } from './types';
|
export { GraphSchema, GroupSchema, NodeSchema } from './types';
|
||||||
export { NodeDefinitionSchema } from './types';
|
export { NodeDefinitionSchema } from './types';
|
||||||
|
|||||||
@@ -61,8 +61,10 @@ export const NodeInputBooleanSchema = z.object({
|
|||||||
export const NodeInputSelectSchema = z.object({
|
export const NodeInputSelectSchema = z.object({
|
||||||
...DefaultOptionsSchema.shape,
|
...DefaultOptionsSchema.shape,
|
||||||
type: z.literal('select'),
|
type: z.literal('select'),
|
||||||
options: z.array(z.string()).optional(),
|
options: z.array(
|
||||||
value: z.string().optional()
|
z.union([z.string(), z.object({ value: z.number(), label: z.string() })])
|
||||||
|
).optional(),
|
||||||
|
value: z.union([z.string(), z.number()]).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const NodeInputSeedSchema = z.object({
|
export const NodeInputSeedSchema = z.object({
|
||||||
|
|||||||
@@ -76,6 +76,24 @@ export type Socket = {
|
|||||||
|
|
||||||
export type Edge = [NodeInstance, number, NodeInstance, string];
|
export type Edge = [NodeInstance, number, NodeInstance, string];
|
||||||
|
|
||||||
|
const SerializedEdgeSchema = z.tuple([z.number(), z.number(), z.number(), z.string()]);
|
||||||
|
|
||||||
|
export type SerializedEdge = z.infer<typeof SerializedEdgeSchema>;
|
||||||
|
|
||||||
|
export const GroupSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
nodes: z.array(NodeSchema),
|
||||||
|
edges: z.array(z.tuple([z.number(), z.number(), z.number(), z.string()])),
|
||||||
|
inputs: z.record(z.string(), NodeInputSchema).optional(),
|
||||||
|
outputs: z.array(z.object({
|
||||||
|
type: z.string(),
|
||||||
|
label: z.string().optional()
|
||||||
|
})).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GroupDefinition = z.infer<typeof GroupSchema>;
|
||||||
|
|
||||||
export const GraphSchema = z.object({
|
export const GraphSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
meta: z
|
meta: z
|
||||||
@@ -86,7 +104,8 @@ export const GraphSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
settings: z.record(z.string(), z.any()).optional(),
|
settings: z.record(z.string(), z.any()).optional(),
|
||||||
nodes: z.array(NodeSchema),
|
nodes: z.array(NodeSchema),
|
||||||
edges: z.array(z.tuple([z.number(), z.number(), z.number(), z.string()]))
|
edges: z.array(SerializedEdgeSchema),
|
||||||
|
groups: z.array(GroupSchema)
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Graph = z.infer<typeof GraphSchema>;
|
export type Graph = z.infer<typeof GraphSchema>;
|
||||||
|
|||||||
+36
-34
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@nodarium/ui",
|
"name": "@nodarium/ui",
|
||||||
"version": "0.0.4",
|
"version": "0.0.6",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build && npm run package",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"package": "svelte-kit sync && svelte-package && publint",
|
"package": "svelte-kit sync && svelte-package && publint",
|
||||||
"prepublishOnly": "npm run package",
|
"prepublishOnly": "npm run package",
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./src/lib/index.ts",
|
||||||
"svelte": "./dist/index.js"
|
"svelte": "./src/lib/index.ts"
|
||||||
},
|
},
|
||||||
"./app.css": "./dist/app.css"
|
"./app.css": "./src/lib/app.css"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -30,45 +30,47 @@
|
|||||||
"svelte": "^4.0.0"
|
"svelte": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^2.0.2",
|
"@eslint/compat": "^2.0.5",
|
||||||
"@eslint/eslintrc": "^3.3.3",
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^10.0.1",
|
||||||
"@nodarium/types": "workspace:^",
|
"@nodarium/types": "workspace:^",
|
||||||
"@playwright/test": "^1.58.1",
|
"@playwright/test": "^1.59.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.59.0",
|
||||||
"@sveltejs/package": "^2.5.7",
|
"@sveltejs/package": "^2.5.7",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/three": "^0.182.0",
|
"@types/node": "^25.6.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
"@types/three": "^0.184.0",
|
||||||
"@typescript-eslint/parser": "^8.54.0",
|
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||||
"@vitest/browser-playwright": "^4.0.18",
|
"@typescript-eslint/parser": "^8.59.1",
|
||||||
"dprint": "^0.51.1",
|
"@vitest/browser-playwright": "^4.1.5",
|
||||||
"eslint": "^9.39.2",
|
"dprint": "^0.54.0",
|
||||||
"eslint-plugin-svelte": "^3.14.0",
|
"eslint": "^10.3.0",
|
||||||
"globals": "^17.3.0",
|
"eslint-plugin-svelte": "^3.17.1",
|
||||||
"publint": "^0.3.17",
|
"globals": "^17.6.0",
|
||||||
"svelte": "^5.49.2",
|
"publint": "^0.3.18",
|
||||||
"svelte-check": "^4.3.6",
|
"svelte": "^5.55.5",
|
||||||
"svelte-eslint-parser": "^1.4.1",
|
"svelte-check": "^4.4.7",
|
||||||
|
"svelte-eslint-parser": "^1.6.0",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.54.0",
|
"typescript-eslint": "^8.59.1",
|
||||||
"vite": "^7.3.1",
|
"vite": "^8.0.10",
|
||||||
"vitest": "^4.0.18",
|
"vitest": "^4.1.5",
|
||||||
"vitest-browser-svelte": "^2.0.2"
|
"vitest-browser-svelte": "^2.1.1"
|
||||||
},
|
},
|
||||||
"svelte": "./dist/index.js",
|
"svelte": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iconify-json/tabler": "^1.2.26",
|
"@iconify-json/tabler": "^1.2.33",
|
||||||
"@iconify/tailwind4": "^1.2.1",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@nodarium/ui": "workspace:*",
|
||||||
"@threlte/core": "^8.3.1",
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
"@threlte/extras": "^9.7.1",
|
"@threlte/core": "^8.5.11",
|
||||||
"tailwindcss": "^4.1.18"
|
"@threlte/extras": "^9.15.1",
|
||||||
|
"tailwindcss": "^4.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<script module lang="ts">
|
||||||
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||||
|
const cache = new Map<string, Record<string, boolean>>();
|
||||||
|
|
||||||
|
function getStore(root: string): Record<string, boolean> {
|
||||||
|
if (!cache.has(root)) {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(`json_viewer:${root}`);
|
||||||
|
cache.set(root, raw ? JSON.parse(raw) : {});
|
||||||
|
} catch {
|
||||||
|
cache.set(root, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cache.get(root)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOpen(path: string, fallback: boolean): boolean {
|
||||||
|
const root = path.split('/')[0];
|
||||||
|
const store = getStore(root);
|
||||||
|
return path in store ? store[path] : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeOpen(path: string, value: boolean) {
|
||||||
|
const root = path.split('/')[0];
|
||||||
|
const store = getStore(root);
|
||||||
|
store[path] = value;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`json_viewer:${root}`, JSON.stringify(store));
|
||||||
|
} catch { /* quota exceeded etc */ }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import JsonViewer from './JsonViewer.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value,
|
||||||
|
key,
|
||||||
|
depth = 0,
|
||||||
|
path = ''
|
||||||
|
}: { value: unknown; key?: string; depth?: number; path?: string } = $props();
|
||||||
|
|
||||||
|
const defaultOpen = $derived(depth < 4);
|
||||||
|
let open = $derived(browser && path ? readOpen(path, defaultOpen) : defaultOpen);
|
||||||
|
let flashing = $state(false);
|
||||||
|
|
||||||
|
const isArr = $derived(Array.isArray(value));
|
||||||
|
const isExpandable = $derived(value !== null && typeof value === 'object');
|
||||||
|
const open_bracket = $derived(isArr ? '[' : '{');
|
||||||
|
const close_bracket = $derived(isArr ? ']' : '}');
|
||||||
|
const items = $derived.by(() => {
|
||||||
|
if (isArr) {
|
||||||
|
return (value as unknown[]).map((v, i) => [String(i), v] as [string, unknown]);
|
||||||
|
}
|
||||||
|
if (value !== null && typeof value === 'object') {
|
||||||
|
return Object.entries(value as Record<string, unknown>).filter(
|
||||||
|
([, v]) => v !== undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [] as [string, unknown][];
|
||||||
|
});
|
||||||
|
const showKeys = $derived(!isArr || typeof items[0]?.[1] === 'object');
|
||||||
|
|
||||||
|
function toggle(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (browser && path) writeOpen(path, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
let prevJson = '';
|
||||||
|
let flashTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const json = JSON.stringify(value);
|
||||||
|
if (prevJson && json !== prevJson) {
|
||||||
|
if (flashTimeout) clearTimeout(flashTimeout);
|
||||||
|
flashing = true;
|
||||||
|
flashTimeout = setTimeout(() => {
|
||||||
|
flashing = false;
|
||||||
|
flashTimeout = null;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
prevJson = json;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="font-mono text-xs leading-[1.6] rounded transition-[background-color] duration-500"
|
||||||
|
class:bg-layer-3={flashing}
|
||||||
|
>
|
||||||
|
{#if key !== undefined}
|
||||||
|
<button
|
||||||
|
class="text-text hover:bg-layer-3 cursor-pointer"
|
||||||
|
title="Copy value"
|
||||||
|
onclick={() => navigator.clipboard.writeText(JSON.stringify({ [key]: value }, null, 2))}
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</button><span class="text-text/40">: </span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isExpandable}
|
||||||
|
{#if items.length === 0}
|
||||||
|
<span class="text-text/50">{open_bracket}{close_bracket}</span>
|
||||||
|
{:else if open}
|
||||||
|
{#if depth > 0}
|
||||||
|
<button class="w-3 text-text/50 hover:text-text" onclick={() => toggle(false)}>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<span class="text-text/50">{open_bracket}</span>
|
||||||
|
<div class="pl-4 border-l border-outline">
|
||||||
|
{#each items as [k, v], i (k)}
|
||||||
|
<div>
|
||||||
|
<JsonViewer
|
||||||
|
value={v}
|
||||||
|
key={showKeys ? k : undefined}
|
||||||
|
depth={depth + 1}
|
||||||
|
path={path ? `${path}/${k}` : k}
|
||||||
|
/>{#if i < items.length - 1}<span class="text-text/20">,</span>{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<span class="text-text/50">{close_bracket}</span>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="inline text-text/50 hover:text-text"
|
||||||
|
onclick={() => toggle(true)}
|
||||||
|
>
|
||||||
|
<span class="w-3 inline-block">▶</span>
|
||||||
|
{open_bracket}<span class="text-text/40 mx-1">{items.length}</span>{close_bracket}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{:else if value === null}
|
||||||
|
<span class="text-emerald-500!">null</span>
|
||||||
|
{:else if typeof value === 'boolean'}
|
||||||
|
<span class="text-blue-500!">{value}</span>
|
||||||
|
{:else if typeof value === 'number'}
|
||||||
|
<span class="text-orange-400!">{value}</span>
|
||||||
|
{:else if typeof value === 'string'}
|
||||||
|
<span class="text-emerald-500!">"{value}"</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-text/70">{String(value)}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
@source inline("{hover:,}{bg-,outline-,text-,}layer-3{/30,/50,}");
|
@source inline("{hover:,}{bg-,outline-,text-,}layer-3{/30,/50,}");
|
||||||
@source inline("{hover:,}{bg-,outline-,text-,}active");
|
@source inline("{hover:,}{bg-,outline-,text-,}active");
|
||||||
@source inline("{hover:,}{bg-,outline-,text-,}selected");
|
@source inline("{hover:,}{bg-,outline-,text-,}selected");
|
||||||
@source inline("{hover:,}{bg-,outline-,text-,}outline{!,}");
|
@source inline("{hover:,}{bg-,outline-,text-,border-,divide-}outline{!,}");
|
||||||
@source inline("{hover:,}{bg-,outline-,text-,}connection");
|
@source inline("{hover:,}{bg-,outline-,text-,}connection");
|
||||||
@source inline("{hover:,}{bg-,outline-,text-,}text");
|
@source inline("{hover:,}{bg-,outline-,text-,}text");
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ export { default as InputNumber } from './inputs/InputNumber.svelte';
|
|||||||
export { default as InputSelect } from './inputs/InputSelect.svelte';
|
export { default as InputSelect } from './inputs/InputSelect.svelte';
|
||||||
export { default as InputShape } from './inputs/InputShape.svelte';
|
export { default as InputShape } from './inputs/InputShape.svelte';
|
||||||
export { default as InputVec3 } from './inputs/InputVec3.svelte';
|
export { default as InputVec3 } from './inputs/InputVec3.svelte';
|
||||||
|
export { default as SocketTable } from './inputs/SocketTable.svelte';
|
||||||
|
|
||||||
export { default as Details } from './Details.svelte';
|
export { default as Details } from './Details.svelte';
|
||||||
|
export { default as JsonViewer } from './JsonViewer.svelte';
|
||||||
export { default as ShortCut } from './ShortCut.svelte';
|
export { default as ShortCut } from './ShortCut.svelte';
|
||||||
|
|
||||||
import Input from './Input.svelte';
|
import Input from './Input.svelte';
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
type SelectOption = string | { value: number; label: string };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
options?: string[];
|
options?: SelectOption[];
|
||||||
value?: number;
|
value?: number;
|
||||||
id?: string;
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { options = [], value = $bindable(0), id = '' }: Props = $props();
|
let { options = [], value = $bindable(0), id = '' }: Props = $props();
|
||||||
|
|
||||||
|
const normalized = $derived(
|
||||||
|
options.map((opt, i) => typeof opt === 'string' ? { value: i, label: opt } : opt)
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<select {id} bind:value class="bg-layer-2 text-text">
|
<select {id} bind:value class="bg-layer-2 text-text">
|
||||||
{#each options as label, i (label)}
|
{#each normalized as opt (opt.value)}
|
||||||
<option value={i}>{label}</option>
|
<option value={opt.value}>{opt.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { NodeInput } from '@nodarium/types';
|
||||||
|
type Props = {
|
||||||
|
inputs?: Record<string, NodeInput>;
|
||||||
|
colors: Record<string, string>;
|
||||||
|
onremove?: (key: string) => void;
|
||||||
|
types: string[];
|
||||||
|
};
|
||||||
|
let { inputs = $bindable(), onremove, colors = {}, types = ['seed', 'float', 'path'] }: Props =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
let potentialRow = $state<
|
||||||
|
{
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
} | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
|
function showPotentialRow() {
|
||||||
|
potentialRow = {
|
||||||
|
type: types[0],
|
||||||
|
label: 'Input ' + Object.keys(inputs ?? {}).length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function realizePotentialRow() {
|
||||||
|
if (inputs) inputs[`input_${Object.keys(inputs).length}`] = potentialRow as NodeInput;
|
||||||
|
potentialRow = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(key?: string) {
|
||||||
|
if (!key) {
|
||||||
|
potentialRow = undefined;
|
||||||
|
} else if (inputs) {
|
||||||
|
onremove?.(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColor(type: string) {
|
||||||
|
if (type in colors) {
|
||||||
|
return colors[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
return '#f00';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet row(input: { type: string; label?: string }, remove: () => void, add?: () => void)}
|
||||||
|
<div class="flex min-w-0">
|
||||||
|
<span
|
||||||
|
style:background={getColor(input.type)}
|
||||||
|
data-type={input.type}
|
||||||
|
class="block opacity-50 min-w-2 ml-2 w-2 h-2 my-auto rounded-sm"
|
||||||
|
></span>
|
||||||
|
<select
|
||||||
|
class="text-[0.9em] border-r w-19 shrink-0 px-2 py-1 border-outline"
|
||||||
|
bind:value={input.type}
|
||||||
|
>
|
||||||
|
{#each types as type (type)}
|
||||||
|
<option>
|
||||||
|
<span
|
||||||
|
style="background: {getColor(type)}; width: 5px; height: 5px; border-radius: 5px;"
|
||||||
|
></span>
|
||||||
|
{type}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
class="px-2 grow min-w-30 border-r border-outline text-[0.9em]"
|
||||||
|
type="text"
|
||||||
|
bind:value={input.label}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="px-2 cursor-pointer opacity-50 hover:opacity-100 hover:bg-red-400"
|
||||||
|
onclick={remove}
|
||||||
|
aria-label="remove"
|
||||||
|
>
|
||||||
|
{#if add}
|
||||||
|
<span class="py-1 block i-[tabler--cancel]"></span>
|
||||||
|
{:else}
|
||||||
|
<span class="py-1 block i-[tabler--trash]"></span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{#if add}
|
||||||
|
<button
|
||||||
|
class="px-2 border-l hover:bg-green-300 opacity-50 hover:opacity-100 hover:text-layer-1 border-outline cursor-pointer"
|
||||||
|
onclick={add}
|
||||||
|
aria-label="add"
|
||||||
|
>
|
||||||
|
<span class="py-1 block i-[tabler--circle-plus]"></span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="rounded-sm overflow-hidden bg-layer-2 divide-y divide-outline outline-1 outline-outline">
|
||||||
|
{#each Object.entries(inputs ?? {}) as [key, input] (key)}
|
||||||
|
{@render row(input, () => removeRow(key))}
|
||||||
|
{/each}
|
||||||
|
{#if potentialRow}
|
||||||
|
<div class="opacity-80">
|
||||||
|
{@render row(potentialRow, () => removeRow(), () => realizePotentialRow())}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="opacity-40">
|
||||||
|
<div class="flex h-[27px]">
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button
|
||||||
|
class="border-l hover:bg-green-300 hover:text-layer-1 border-outline py-1 px-2 cursor-pointer"
|
||||||
|
onclick={() => showPotentialRow()}
|
||||||
|
aria-label="remove"
|
||||||
|
>
|
||||||
|
<span class="block i-[tabler--circle-plus]"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { NodeInput } from '@nodarium/types';
|
||||||
import '$lib/app.css';
|
import '$lib/app.css';
|
||||||
import {
|
import {
|
||||||
Details,
|
Details,
|
||||||
@@ -8,8 +9,10 @@
|
|||||||
InputSelect,
|
InputSelect,
|
||||||
InputShape,
|
InputShape,
|
||||||
InputVec3,
|
InputVec3,
|
||||||
|
JsonViewer,
|
||||||
ShortCut
|
ShortCut
|
||||||
} from '$lib';
|
} from '$lib';
|
||||||
|
import SocketTable from '$lib/inputs/SocketTable.svelte';
|
||||||
import Section from './Section.svelte';
|
import Section from './Section.svelte';
|
||||||
import Theme from './Theme.svelte';
|
import Theme from './Theme.svelte';
|
||||||
import ThemeSelector from './ThemeSelector.svelte';
|
import ThemeSelector from './ThemeSelector.svelte';
|
||||||
@@ -20,11 +23,48 @@
|
|||||||
let vecValue = $state([0.2, 0.3, 0.4]);
|
let vecValue = $state([0.2, 0.3, 0.4]);
|
||||||
const options = ['strawberry', 'raspberry', 'chickpeas'];
|
const options = ['strawberry', 'raspberry', 'chickpeas'];
|
||||||
let selectValue = $state(0);
|
let selectValue = $state(0);
|
||||||
const d = $derived(options[selectValue]);
|
let selectValue2 = $state(0);
|
||||||
let checked = $state(false);
|
let checked = $state(false);
|
||||||
let colorValue = $state<[number, number, number]>([59, 130, 246]);
|
let colorValue = $state<[number, number, number]>([59, 130, 246]);
|
||||||
let mirrorShape = $state(true);
|
let mirrorShape = $state(true);
|
||||||
let detailsOpen = $state(false);
|
let detailsOpen = $state(false);
|
||||||
|
let jsonValue = $state({
|
||||||
|
id: 1,
|
||||||
|
nodes: [{ id: 0, type: 'max/test/node', position: [0, 0] }, {
|
||||||
|
id: 1,
|
||||||
|
type: 'max/test/other',
|
||||||
|
position: [100, 50]
|
||||||
|
}],
|
||||||
|
edges: [[0, 0, 1, 'input']],
|
||||||
|
groups: [],
|
||||||
|
settings: { seed: 42, enabled: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
let socketTypes: Record<string, NodeInput> = $state({
|
||||||
|
input_0: {
|
||||||
|
'label': 'Input 0',
|
||||||
|
'type': 'path'
|
||||||
|
},
|
||||||
|
input_1: {
|
||||||
|
'label': 'Input 1',
|
||||||
|
'type': 'float'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function randomlyUpdateJson() {
|
||||||
|
const rand = Math.floor(Math.random() * 5);
|
||||||
|
if (rand === 0) {
|
||||||
|
jsonValue.nodes[0].position[0] += 1;
|
||||||
|
} else if (rand === 1) {
|
||||||
|
jsonValue.nodes[0].position[1] += 1;
|
||||||
|
} else if (rand === 2) {
|
||||||
|
jsonValue.settings.seed += 1;
|
||||||
|
} else if (rand === 3) {
|
||||||
|
jsonValue.settings.enabled = !jsonValue.settings.enabled;
|
||||||
|
} else if (rand === 4) {
|
||||||
|
jsonValue.id += Math.floor(Math.random() * 10 - 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let points = $state([]);
|
let points = $state([]);
|
||||||
let theme = $state('dark');
|
let theme = $state('dark');
|
||||||
@@ -55,8 +95,28 @@
|
|||||||
<InputVec3 bind:value={vecValue} />
|
<InputVec3 bind:value={vecValue} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Select" value={d}>
|
<Section title="Select">
|
||||||
|
<p>
|
||||||
|
Select with simple values
|
||||||
|
<br>
|
||||||
|
<b>value={options[selectValue]}</b>
|
||||||
|
</p>
|
||||||
<InputSelect bind:value={selectValue} {options} />
|
<InputSelect bind:value={selectValue} {options} />
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<p>
|
||||||
|
Select with <i>{option: number, label: string}[]</i>
|
||||||
|
<br>
|
||||||
|
<b>value={selectValue2}</b>
|
||||||
|
</p>
|
||||||
|
<InputSelect
|
||||||
|
bind:value={selectValue2}
|
||||||
|
options={[
|
||||||
|
{ value: 0, label: 'Zero' },
|
||||||
|
{ value: 1, label: 'One' },
|
||||||
|
{ value: 2, label: 'Two' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Checkbox" value={checked}>
|
<Section title="Checkbox" value={checked}>
|
||||||
@@ -86,6 +146,35 @@
|
|||||||
</Details>
|
</Details>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title="JsonViewer">
|
||||||
|
{#snippet header()}
|
||||||
|
<button
|
||||||
|
onclick={() => randomlyUpdateJson()}
|
||||||
|
class="-mt-1 bg-layer-2 p-1 px-2 rounded-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
update
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
<div class="w-64 bg-layer-1 p-2 rounded">
|
||||||
|
<JsonViewer
|
||||||
|
value={jsonValue}
|
||||||
|
path="demo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Socket Table">
|
||||||
|
<SocketTable
|
||||||
|
colors={{
|
||||||
|
seed: '#f00',
|
||||||
|
float: '#0f0',
|
||||||
|
path: '#00f'
|
||||||
|
}}
|
||||||
|
types={['seed', 'float', 'path']}
|
||||||
|
bind:inputs={socketTypes}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section title="Shortcut">
|
<Section title="Shortcut">
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<ShortCut ctrl key="S" />
|
<ShortCut ctrl key="S" />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
'custom'
|
'custom'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-useless-assignment
|
||||||
let { theme = $bindable() } = $props();
|
let { theme = $bindable() } = $props();
|
||||||
|
|
||||||
let themeIndex = $state(0);
|
let themeIndex = $state(0);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@nodarium/utils",
|
"name": "@nodarium/utils",
|
||||||
"version": "0.0.4",
|
"version": "0.0.6",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "src/index.ts",
|
"main": "./src/index.ts",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"format": "dprint fmt -c '../../.dprint.jsonc' .",
|
"format": "dprint fmt -c '../../.dprint.jsonc' .",
|
||||||
@@ -15,8 +16,8 @@
|
|||||||
"@nodarium/types": "workspace:^"
|
"@nodarium/types": "workspace:^"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"dprint": "^0.51.1",
|
"dprint": "^0.54.0",
|
||||||
"vite": "^7.3.1",
|
"vite": "^8.0.10",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,60 @@
|
|||||||
|
interface LogEntry {
|
||||||
|
time: string;
|
||||||
|
scope: string;
|
||||||
|
level: string;
|
||||||
|
args: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const logBuffer: LogEntry[] = [];
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
function formatTime(): string {
|
||||||
|
const ms = Date.now() - startTime;
|
||||||
|
const h = Math.floor(ms / 3600000).toString().padStart(2, '0');
|
||||||
|
const m = Math.floor((ms % 3600000) / 60000).toString().padStart(2, '0');
|
||||||
|
const s = Math.floor((ms % 60000) / 1000).toString().padStart(2, '0');
|
||||||
|
const mss = (ms % 1000).toString().padStart(3, '0');
|
||||||
|
return `${h}:${m}:${s}.${mss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialize(arg: unknown): string {
|
||||||
|
if (typeof arg === 'string') return arg;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(arg);
|
||||||
|
} catch {
|
||||||
|
return String(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEntry(entry: LogEntry, scopeWidth: number): string {
|
||||||
|
const scope = `[${entry.scope}]`.padEnd(scopeWidth + 2);
|
||||||
|
const level = entry.level.toUpperCase().padEnd(5);
|
||||||
|
const msg = entry.args.map(serialize).join(' ');
|
||||||
|
return `${entry.time} ${scope} ${level} ${msg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
(globalThis as Record<string, unknown>).copyLogs = () => {
|
||||||
|
if (logBuffer.length === 0) {
|
||||||
|
console.log('%c[logger] No log entries to copy', 'color: #888');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scopeWidth = logBuffer.reduce((max, e) => Math.max(max, e.scope.length), 0);
|
||||||
|
const lines = [
|
||||||
|
`=== Log Export (${logBuffer.length} entries) ===`,
|
||||||
|
'',
|
||||||
|
...logBuffer.map(e => formatEntry(e, scopeWidth))
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(lines).then(() => {
|
||||||
|
console.log(`%c[logger] Copied ${logBuffer.length} entries to clipboard`, 'color: #4f4');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
(globalThis as Record<string, unknown>).clearLogs = () => {
|
||||||
|
logBuffer.length = 0;
|
||||||
|
console.log('%c[logger] Log buffer cleared', 'color: #888');
|
||||||
|
};
|
||||||
|
|
||||||
export const createLogger = (() => {
|
export const createLogger = (() => {
|
||||||
let maxLength = 5;
|
let maxLength = 5;
|
||||||
return (scope: string) => {
|
return (scope: string) => {
|
||||||
@@ -6,18 +63,35 @@ export const createLogger = (() => {
|
|||||||
|
|
||||||
let isGrouped = false;
|
let isGrouped = false;
|
||||||
|
|
||||||
function s(color: string, ...args: any) {
|
function s(color: string, ...args: unknown[]) {
|
||||||
return isGrouped
|
return isGrouped
|
||||||
? [...args]
|
? [...args]
|
||||||
: [`[%c${scope.padEnd(maxLength, ' ')}]:`, `color: ${color}`, ...args];
|
: [`[%c${scope.padEnd(maxLength, ' ')}]:`, `color: ${color}`, ...args];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function record(level: string, args: unknown[]) {
|
||||||
|
logBuffer.push({ time: formatTime(), scope, level, args });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
log: (...args: any[]) => !muted && console.log(...s('#888', ...args)),
|
log: (...args: unknown[]) => {
|
||||||
info: (...args: any[]) => !muted && console.info(...s('#888', ...args)),
|
record('log', args);
|
||||||
warn: (...args: any[]) => !muted && console.warn(...s('#888', ...args)),
|
!muted && console.log(...s('#888', ...args));
|
||||||
error: (...args: any[]) => console.error(...s('#f88', ...args)),
|
},
|
||||||
group: (...args: any[]) => {
|
info: (...args: unknown[]) => {
|
||||||
|
record('info', args);
|
||||||
|
!muted && console.info(...s('#888', ...args));
|
||||||
|
},
|
||||||
|
warn: (...args: unknown[]) => {
|
||||||
|
record('warn', args);
|
||||||
|
!muted && console.warn(...s('#888', ...args));
|
||||||
|
},
|
||||||
|
error: (...args: unknown[]) => {
|
||||||
|
record('error', args);
|
||||||
|
console.error(...s('#f88', ...args));
|
||||||
|
},
|
||||||
|
group: (...args: unknown[]) => {
|
||||||
|
record('group', args);
|
||||||
if (!muted) {
|
if (!muted) {
|
||||||
console.groupCollapsed(...s('#888', ...args));
|
console.groupCollapsed(...s('#888', ...args));
|
||||||
isGrouped = true;
|
isGrouped = true;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface PerformanceStore {
|
|||||||
startRun(): void;
|
startRun(): void;
|
||||||
stopRun(): void;
|
stopRun(): void;
|
||||||
addPoint(name: string, value?: number): void;
|
addPoint(name: string, value?: number): void;
|
||||||
|
addToLastRun(name: string, value: number): void;
|
||||||
endPoint(name?: string): void;
|
endPoint(name?: string): void;
|
||||||
mergeData(data: PerformanceData[number]): void;
|
mergeData(data: PerformanceData[number]): void;
|
||||||
get: () => PerformanceData;
|
get: () => PerformanceData;
|
||||||
@@ -63,6 +64,13 @@ export function createPerformanceStore(): PerformanceStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addToLastRun(name: string, value: number) {
|
||||||
|
const last = data[data.length - 1];
|
||||||
|
if (!last) return;
|
||||||
|
last[name] = last[name] || [];
|
||||||
|
last[name].push(value);
|
||||||
|
}
|
||||||
|
|
||||||
function get() {
|
function get() {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -94,6 +102,7 @@ export function createPerformanceStore(): PerformanceStore {
|
|||||||
startRun,
|
startRun,
|
||||||
stopRun,
|
stopRun,
|
||||||
addPoint,
|
addPoint,
|
||||||
|
addToLastRun,
|
||||||
endPoint,
|
endPoint,
|
||||||
mergeData,
|
mergeData,
|
||||||
get
|
get
|
||||||
|
|||||||
Generated
+1598
-1535
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user