9 Commits

Author SHA1 Message Date
max be8161ec8d wip
📊 Benchmark the Runtime / release (pull_request) Failing after 1m16s
🚀 Lint & Test & Deploy / release (pull_request) Failing after 1m19s
2026-02-17 13:49:10 +01:00
max 4cb24e8ff9 feat: some shit 2026-02-17 13:49:10 +01:00
Max Richter 30afb30341 feat: add debug node 2026-02-17 13:49:10 +01:00
Max Richter 1d1a44324e feat: some shit 2026-02-17 13:49:10 +01:00
Max Richter 343eca02b5 chore: make sure to specify wasm flags only on wasm build 2026-02-17 13:49:10 +01:00
Max Richter 45a9800e6a chore: cargo fix 2026-02-17 13:49:10 +01:00
Max Richter b384348e70 feat: make all nodes work with new runtime 2026-02-17 13:49:10 +01:00
Max Richter 25ceb6e94f feat: first working version of new allocator 2026-02-17 13:49:09 +01:00
Max Richter ff8c6637f8 feat: add "*"/any type input for dev page 2026-02-17 13:49:09 +01:00
161 changed files with 4774 additions and 9561 deletions
+9
View File
@@ -0,0 +1,9 @@
[target.wasm32-unknown-unknown]
rustflags = [
"-C",
"link-arg=--import-memory",
"-C",
"link-arg=--initial-memory=67108864", # 64 MiB
"-C",
"link-arg=--max-memory=536870912", # 512 MiB
]
-28
View File
@@ -1,28 +0,0 @@
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
+30 -38
View File
@@ -12,9 +12,9 @@ env:
CARGO_TARGET_DIR: target CARGO_TARGET_DIR: target
jobs: jobs:
benchmark: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69 container: git.max-richter.dev/max/nodarium-ci:bce06da456e3c008851ac006033cfff256015a47
steps: steps:
- name: 📑 Checkout Code - name: 📑 Checkout Code
@@ -23,45 +23,37 @@ jobs:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.GITEA_TOKEN }} token: ${{ secrets.GITEA_TOKEN }}
- name: 🔧 Setup - name: 💾 Setup pnpm Cache
uses: ./.gitea/actions/setup uses: actions/cache@v4
with:
path: ${{ env.PNPM_CACHE_FOLDER }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: 🛠️ Build Nodes - 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: 🛠️Build Nodes
run: pnpm build:nodes run: pnpm build:nodes
- name: 🏃 Execute Runtime - name: 🏃 Execute Runtime
run: pnpm run --filter @nodarium/app bench run: pnpm run --filter @nodarium/app bench
- name: 🔑 Setup SSH key - name: 📤 Upload Benchmark Results
run: | uses: actions/upload-artifact@v3
mkdir -p ~/.ssh with:
echo "${{ secrets.GIT_SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 name: benchmark-data
chmod 600 ~/.ssh/id_ed25519 path: app/benchmark/out/
cat >> ~/.ssh/config <<'EOF' compression: 9
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
+24 -59
View File
@@ -13,9 +13,9 @@ env:
CARGO_TARGET_DIR: target CARGO_TARGET_DIR: target
jobs: jobs:
quality: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69 container: git.max-richter.dev/max/nodarium-ci:bce06da456e3c008851ac006033cfff256015a47
steps: steps:
- name: 📑 Checkout Code - name: 📑 Checkout Code
@@ -24,8 +24,27 @@ jobs:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.GITEA_TOKEN }} token: ${{ secrets.GITEA_TOKEN }}
- name: 🔧 Setup - name: 💾 Setup pnpm Cache
uses: ./.gitea/actions/setup uses: actions/cache@v4
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: |
@@ -33,61 +52,7 @@ 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'
-131
View File
@@ -1,134 +1,3 @@
# v0.0.6 (2026-05-05)
## Features
- Upgrade graph source panel and JSON viewer with click-to-copy and improved select handling.
- Capture system stats in benchmark runs.
- Split CI into unit and end-to-end pipelines.
- Add `LLM.md` documentation.
- Add 🍀 Planty Tutorial Helper
- Add a way to group nodes
## Node Groups
Node Groups introduce a way to structure graphs into nested, navigable subgraphs for better organization and reuse.
- Nested graph workflows with full runtime support
- Group input and output nodes defining clear boundaries
- Named groups for organization and identification
- Breadcrumb navigation for navigating nested levels
- Context-aware UI when editing inside groups
- Reliable enter/exit transitions with state restoration
- Ability to ungroup nodes back into the main graph
## Planty
Planty is a Clippy-inspired in-app tutorial and guidance system that helps users understand and use the graph editor through contextual assistance.
- Context-aware hints based on user actions in the graph
- Step-by-step onboarding flows for core features
- Inline guidance tied to nodes, sockets, and interactions
- Lightweight runtime integration without external build requirements
## Fixes
- Fix benchmark execution and CI integration issues
- Resolve debug node ID mismatch
- Fix ESLint, TypeScript, Playwright, and test synchronization issues
- Fix packaging issues in `@nodarium/planty` and UI library
- Restore correct graph references after exiting node groups
## Refactors
- Remove graph element handling from `graphManager`
- Restrict panel rendering to selected nodes only
- Move JSON viewer into shared UI package
- Clean up CI workflows
## Chores
- Upgrade dependencies via `pnpm upgrade`
- Add SvelteKit sync before E2E tests
- Remove flaky screenshot artifacts
- General formatting, linting, and test maintenance
---
- [82c2f08](https://git.max-richter.dev/max/nodarium/commit/82c2f08a5653ccd596c6982a1d9efa4b20a0b624) chore: cleanup changelog
- [a00db40](https://git.max-richter.dev/max/nodarium/commit/a00db400bba909db5da499e8484d6ed5541c4ad7) fix: dont crash when no groups exist
- [2d9eb0c](https://git.max-richter.dev/max/nodarium/commit/2d9eb0c0879611c2355e52100b150dd39b924684) fix: make planty work
- [954f572](https://git.max-richter.dev/max/nodarium/commit/954f5726c305508329856b6707a41b77f297c4bf) Merge pull request 'feat: initial node groups' (#44) from feat/group-node-own into main
- [63d5b80](https://git.max-richter.dev/max/nodarium/commit/63d5b8079d785b4fe1e689611cd38e5dd4d3ecb6) chore: pnpm format
- [3e32ca4](https://git.max-richter.dev/max/nodarium/commit/3e32ca419a1b914cbe64d98d887a89f4f5abb0e2) feat: ungroup nodes
- [f0cb12a](https://git.max-richter.dev/max/nodarium/commit/f0cb12a088609f5bd56096f7c1f244af0a1f91d8) chore: fix some type issues
- [1d60090](https://git.max-richter.dev/max/nodarium/commit/1d60090ffe1a93af4c0df6f36741957ac13b1a73) chore: fixup graph manager tests
- [5b55056](https://git.max-richter.dev/max/nodarium/commit/5b55056fc12271b87393cf9ef3b61cdf518a9857) chore: remove graph element in graphManager
- [e2c2b1a](https://git.max-richter.dev/max/nodarium/commit/e2c2b1a4d73e0953ddd0125cabe83649b58c8996) chore: remove e2e test screenshots (too flaky)
- [7f082ad](https://git.max-richter.dev/max/nodarium/commit/7f082ad8f6f144b92947000b91ceddce3c52704b) feat: implement node sockets ui
- [ed11195](https://git.max-richter.dev/max/nodarium/commit/ed1119532750d47a1395ced92fe310a6aa5e070e) chore: refactor graphStack to be simpler
- [8ad62cf](https://git.max-richter.dev/max/nodarium/commit/8ad62cfc8e8c60e4e72fdaa3d8caca75a7472da6) feat: add node group breadcrumbs
- [bff140a](https://git.max-richter.dev/max/nodarium/commit/bff140a764ff0b6ed2c6e42814a088ca9e2ef1ee) feat: show different ui when inside group
- [85e2fd1](https://git.max-richter.dev/max/nodarium/commit/85e2fd1a7126aab4a544eea3d185cfc63754cb73) fix: use correct id for debug node
- [5beb031](https://git.max-richter.dev/max/nodarium/commit/5beb03196d46434f617442cc55d721c7d3eebe33) fix: broken format command for @nodarium/planty
- [83e0e47](https://git.max-richter.dev/max/nodarium/commit/83e0e47082ed0d9fbd60e67a6d6c9ed65a9f4f24) refactor: only show group/node panel when selected
- [106797d](https://git.max-richter.dev/max/nodarium/commit/106797de32f0d57059c481770e734b555900421d) feat: make group input/output node work
- [1a56ba9](https://git.max-richter.dev/max/nodarium/commit/1a56ba986d761be545c2cf0236f9c374222da6b1) damn dude
- [703f531](https://git.max-richter.dev/max/nodarium/commit/703f531cd370c2d440a696a68413ea6d5f54019f) chore: make eslint and playwright happy
- [0ed22f2](https://git.max-richter.dev/max/nodarium/commit/0ed22f20b913837b8e90d45aa1a4ada1a90a9313) chore: pnpm upgrade
- [733b0a2](https://git.max-richter.dev/max/nodarium/commit/733b0a2ceb3be6fac1eda5bbf856a4bd1f724e36) chore: sync sveltekit app before e2e
- [8f60816](https://git.max-richter.dev/max/nodarium/commit/8f60816c7841c845120e71590e1c4672d218703e) chore: sync sveltekit app before e2e
- [cd7b51d](https://git.max-richter.dev/max/nodarium/commit/cd7b51d86a1243e0714d9be73588db56c844086c) chore: sync sveltekit app before e2e
- [6c9cd15](https://git.max-richter.dev/max/nodarium/commit/6c9cd1505d72c1c0600910f56ee158d07b7e4811) chore: sync sveltekit app before e2e
- [db5ee8b](https://git.max-richter.dev/max/nodarium/commit/db5ee8ba29812e6865706966ff690ef335ff5e4f) fix: make eslint happy
- [a6b9ca4](https://git.max-richter.dev/max/nodarium/commit/a6b9ca43155b5e9357570705b6f45ba4fc3490a8) feat: capture system stats in benchmark
- [d4910ab](https://git.max-richter.dev/max/nodarium/commit/d4910aba8c5408fb9321b1a22d4c35eae8142309) chore: pnpm format
- [e695c76](https://git.max-richter.dev/max/nodarium/commit/e695c7649015b88d8ca6662bfee5dc0329dd89ac) chore: make eslint happy
- [2a54fa7](https://git.max-richter.dev/max/nodarium/commit/2a54fa7590c1834904a45fb0fc1fd0aa371f0120) feat: add name to groups
- [6d5cac6](https://git.max-richter.dev/max/nodarium/commit/6d5cac65e8acf013ac0a4a61baf3bbc423252d53) feat(ui): click-to-copy on node values in jsonviewer
- [3ee074b](https://git.max-richter.dev/max/nodarium/commit/3ee074b11c9bc216ce7221f5611c8177cef9b313) feat(ui): make inputselect also handle value+label options
- [59a1e63](https://git.max-richter.dev/max/nodarium/commit/59a1e63396635d05543fa4636de5800ce4899a2a) feat: add unit tests for graph state
- [317d155](https://git.max-richter.dev/max/nodarium/commit/317d1552cea5a234ab1ab87684be9a3724b1976f) fix: graph correctly restore html refs after exiting node group
- [78439b1](https://git.max-richter.dev/max/nodarium/commit/78439b19e96e6dbdfb31cc794f5b6e1ff005e26e) fix: make benchmark work
- [ef217b1](https://git.max-richter.dev/max/nodarium/commit/ef217b1c409c25d6054515e1f705831ddbf5a24b) feat: some updates
- [7499b80](https://git.max-richter.dev/max/nodarium/commit/7499b80789e99b87df54d6891597fac7edb56b0f) fix: make the runtime work with groups
- [a5b663f](https://git.max-richter.dev/max/nodarium/commit/a5b663f6fc5e67d5ef67b128c22cdc7363232de5) feat(ci): split e2e and unit tests
- [0550670](https://git.max-richter.dev/max/nodarium/commit/05506704bf68dfd289a1c5130d5d86ddd98954b1) feat: let claude fix ci
- [63188e5](https://git.max-richter.dev/max/nodarium/commit/63188e57fd2cf055161508ee88cbef0cd941e662) feat: let claude fix ci
- [4572d30](https://git.max-richter.dev/max/nodarium/commit/4572d30005d3538f357d4e9f1fb637c1a6559fb1) feat: let claude refactor ci
- [ccc376d](https://git.max-richter.dev/max/nodarium/commit/ccc376d158f209b85a62d98919ac716e46e3e253) feat: store total vertices/faces in benchmarkl
- [7e432e9](https://git.max-richter.dev/max/nodarium/commit/7e432e9033f7fdc73c21bb363cf502c1d8085407) chore: update ci workflow
- [01f5837](https://git.max-richter.dev/max/nodarium/commit/01f58377c21048f95c839504a3e8c46c27ba12ae) feat: make more node group features work
- [6ef5dc2](https://git.max-richter.dev/max/nodarium/commit/6ef5dc28ed9a4874a7b5fb2a9dca73efd1632519) chore: move jsonviewer into ui package
- [3450d70](https://git.max-richter.dev/max/nodarium/commit/3450d7004781ea58f61d563967441a251c817a9b) docs: add LLM.md
- [731b9e9](https://git.max-richter.dev/max/nodarium/commit/731b9e9b1e52598e11044874a46976e517a1150c) feat: upgrade graph source panel
- [72f07d0](https://git.max-richter.dev/max/nodarium/commit/72f07d0a501fa715c40cf0e7c17527ef3f98e96b) feat: initial node groups
- [a56e8f4](https://git.max-richter.dev/max/nodarium/commit/a56e8f445edb6064ae7a7b3b783fb7445f1b4e69) feat(ci): install openssh client
- [1257274](https://git.max-richter.dev/max/nodarium/commit/12572742eb3ba1641cc744a18d330e88df50e9d0) fix(planty): remove debug span
- [7aa9979](https://git.max-richter.dev/max/nodarium/commit/7aa9979e355fcf90342e8b5f1d233b879bb6c71f) chore: update e2e tests
- [fc35a68](https://git.max-richter.dev/max/nodarium/commit/fc35a68826885ac7e9c624b39a5c0fe7d1cb83f0) fix: dont package ui library
- [aba6f03](https://git.max-richter.dev/max/nodarium/commit/aba6f03bcce3e4363f0f22337d0000083bfff9a9) fix: dont package ui library
- [2d6fd00](https://git.max-richter.dev/max/nodarium/commit/2d6fd00fd1ba31bfa943b6d3a4a628bd5132f668) fix: dont package ui library
- [d231946](https://git.max-richter.dev/max/nodarium/commit/d231946e50975dfa1b41696cefbbc2f742480ea8) fix: remove unused imports
- [e2f4a24](https://git.max-richter.dev/max/nodarium/commit/e2f4a24f759b917d3c7c1ca0b8347312785a03e5) fix(planty): make sure config is completely static
- [58d39cd](https://git.max-richter.dev/max/nodarium/commit/58d39cd101298e6a41922d18466899f7bf4a0f97) feat: improve planty ux
- [7ebb129](https://git.max-richter.dev/max/nodarium/commit/7ebb1297ac75987b3348dd81a57e0d25ed0a7405) feat(app): make zoom in nicer
- [23f65a1](https://git.max-richter.dev/max/nodarium/commit/23f65a1c63650faba2051a2c87f7625378b4b0c6) fix: remove unused header div
- [acdc582](https://git.max-richter.dev/max/nodarium/commit/acdc582e957df149ab723d268ac2e205595db199) feat: use ui and planty without build
- [7a3e9eb](https://git.max-richter.dev/max/nodarium/commit/7a3e9eb893182e46e72e203df1eb7532a4652ddd) chore: update test screenshot
- [be82312](https://git.max-richter.dev/max/nodarium/commit/be82312ea049b21e5ff859163e54ba6da88328a0) chore: update test screenshot
- [84f67e9](https://git.max-richter.dev/max/nodarium/commit/84f67e9c33a1141d7e0c3332375576cd0898a47a) fix: update planty types
- [491e345](https://git.max-richter.dev/max/nodarium/commit/491e345c2ff1893916848380e8941fa77dff44ec) feat: build planty in post install
- [ba501b2](https://git.max-richter.dev/max/nodarium/commit/ba501b211db1d70930fa461e1fd82abb5b639c00) fix: correct tsconfig for planty
- [7d76b9e](https://git.max-richter.dev/max/nodarium/commit/7d76b9e1f77ab934289bb18df6197bfd58ce3eeb) fix: mark planty as type:module
- [5d4e2e9](https://git.max-richter.dev/max/nodarium/commit/5d4e2e928093cac39192960d9de21a0e1710904e) fix: make formatter happy
- [4de15b1](https://git.max-richter.dev/max/nodarium/commit/4de15b19c8bdb35e53ba0d3e3e459cdbb12aee9d) feat: wire up planty with nodarium/app
- [168e6fc](https://git.max-richter.dev/max/nodarium/commit/168e6fcc19dc55383b0b21d2b3ebab733058fb94) feat: update some node default settings
- [c0eb75d](https://git.max-richter.dev/max/nodarium/commit/c0eb75d53c4251d041744b19a37c44d0d4a1728c) feat: new planty package
- [2ec9bfc](https://git.max-richter.dev/max/nodarium/commit/2ec9bfc3c96a97aaf29557c70f76b7bf08156e15) feat(ci): compress benchmark data
- [c975206](https://git.max-richter.dev/max/nodarium/commit/c97520617a0d48a765544feebf2d510400db8fb8) fix(ci): use older upload-artifact action
- [6475790](https://git.max-richter.dev/max/nodarium/commit/64757901766efb7ccbd3693ebc63ba1367fe6d88) fix(ci): build nodes before benchmarking
- [580ec73](https://git.max-richter.dev/max/nodarium/commit/580ec7346599e5d538ff53f31808ff770b4a8095) ci: run benchmark in ci
# v0.0.5 (2026-02-13) # v0.0.5 (2026-02-13)
## Features ## Features
Generated
+8 -2
View File
@@ -24,6 +24,14 @@ dependencies = [
"nodarium_utils", "nodarium_utils",
] ]
[[package]]
name = "debug"
version = "0.1.0"
dependencies = [
"nodarium_macros",
"nodarium_utils",
]
[[package]] [[package]]
name = "float" name = "float"
version = "0.1.0" version = "0.1.0"
@@ -66,7 +74,6 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
name = "leaf" name = "leaf"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"glam",
"nodarium_macros", "nodarium_macros",
"nodarium_utils", "nodarium_utils",
] ]
@@ -118,7 +125,6 @@ dependencies = [
name = "noise" name = "noise"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"glam",
"nodarium_macros", "nodarium_macros",
"nodarium_utils", "nodarium_utils",
"noise 0.9.0", "noise 0.9.0",
-1
View File
@@ -5,7 +5,6 @@ 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 \
-1
View File
@@ -27,7 +27,6 @@ Currently this visual programming language is used to develop <https://nodes.max
- [Node.js](https://nodejs.org/en/download) - [Node.js](https://nodejs.org/en/download)
- [pnpm](https://pnpm.io/installation) - [pnpm](https://pnpm.io/installation)
- [rust](https://www.rust-lang.org/tools/install) - [rust](https://www.rust-lang.org/tools/install)
- wasm-pack
### Install dependencies ### Install dependencies
+783
View File
@@ -0,0 +1,783 @@
# Shared Memory Refactor Plan
## Executive Summary
Migrate to a single shared `WebAssembly.Memory` instance imported by all nodes using `--import-memory`. The `#[nodarium_execute]` macro writes the function's return value directly to shared memory at the specified offset.
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────────┐
│ Shared WebAssembly.Memory │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ [Node A output] [Node B output] [Node C output] ... │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │
│ │ │ Vec<i32> │ │ Vec<i32> │ │ Vec<i32> │ │ │
│ │ │ 4 bytes │ │ 12 bytes │ │ 2KB │ │ │
│ │ └────────────┘ └────────────┘ └────────────┘ │ │
│ │ │ │
│ │ offset: 0 ────────────────────────────────────────────────► │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│ import { memory } from "env"
┌─────────────────────────┼─────────────────────────┐
│ │ │
┌────┴────┐ ┌────┴────┐ ┌────┴────┐
│ Node A │ │ Node B │ │ Node C │
│ WASM │ │ WASM │ │ WASM │
└─────────┘ └─────────┘ └─────────┘
```
## Phase 1: Compilation Configuration
### 1.1 Cargo Config
```toml
# nodes/max/plantarium/box/.cargo/config.toml
[build]
rustflags = ["-C", "link-arg=--import-memory"]
```
Or globally in `Cargo.toml`:
```toml
[profile.release]
rustflags = ["-C", "link-arg=--import-memory"]
```
### 1.2 Import Memory Semantics
With `--import-memory`:
- Nodes **import** memory from the host (not export their own)
- All nodes receive the same `WebAssembly.Memory` instance
- Memory is read/write accessible from all modules
- No `memory.grow` needed (host manages allocation)
## Phase 2: Macro Design
### 2.1 Clean Node API
```rust
// input.json has 3 inputs: op_type, a, b
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(op_type: *i32, a: *i32, b: *i32) -> Vec<i32> {
// Read inputs directly from shared memory
let op = unsafe { *op_type };
let a_val = f32::from_bits(unsafe { *a } as u32);
let b_val = f32::from_bits(unsafe { *b } as u32);
let result = match op {
0 => a_val + b_val,
1 => a_val - b_val,
2 => a_val * b_val,
3 => a_val / b_val,
_ => 0.0,
};
// Return Vec<i32>, macro handles writing to shared memory
vec![result.to_bits()]
}
```
### 2.2 Macro Implementation
```rust
// packages/macros/src/lib.rs
#[proc_macro_attribute]
pub fn nodarium_execute(_attr: TokenStream, item: TokenStream) -> TokenStream {
let input_fn = parse_macro_input!(item as syn::ItemFn);
let fn_name = &input_fn.sig.ident;
// Parse definition to get input count
let project_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
let def: NodeDefinition = serde_json::from_str(&fs::read_to_string(
Path::new(&project_dir).join("src/input.json")
).unwrap()).unwrap();
let input_count = def.inputs.as_ref().map(|i| i.len()).unwrap_or(0);
// Validate signature
validate_signature(&input_fn, input_count);
// Generate wrapper
generate_execute_wrapper(input_fn, fn_name, input_count)
}
fn validate_signature(fn_sig: &syn::Signature, expected_inputs: usize) {
let param_count = fn_sig.inputs.len();
if param_count != expected_inputs {
panic!(
"Execute function has {} parameters but definition has {} inputs\n\
Definition inputs: {:?}\n\
Expected signature:\n\
pub fn execute({}) -> Vec<i32>",
param_count,
expected_inputs,
def.inputs.as_ref().map(|i| i.keys().collect::<Vec<_>>()),
(0..expected_inputs)
.map(|i| format!("arg{}: *const i32", i))
.collect::<Vec<_>>()
.join(", ")
);
}
// Verify return type is Vec<i32>
match &fn_sig.output {
syn::ReturnType::Type(_, ty) => {
if !matches!(&**ty, syn::Type::Path(tp) if tp.path.is_ident("Vec")) {
panic!("Execute function must return Vec<i32>");
}
}
syn::ReturnType::Default => {
panic!("Execute function must return Vec<i32>");
}
}
}
fn generate_execute_wrapper(
input_fn: syn::ItemFn,
fn_name: &syn::Ident,
input_count: usize,
) -> TokenStream {
let arg_names: Vec<_> = (0..input_count)
.map(|i| syn::Ident::new(&format!("arg{}", i), proc_macro2::Span::call_site()))
.collect();
let expanded = quote! {
#input_fn
#[no_mangle]
pub extern "C" fn execute(
output_pos: i32,
#( #arg_names: i32 ),*
) -> i32 {
extern "C" {
fn __nodarium_log(ptr: *const u8, len: usize);
fn __nodarium_log_panic(ptr: *const u8, len: usize);
}
// Setup panic hook
static SET_HOOK: std::sync::Once = std::sync::Once::new();
SET_HOOK.call_once(|| {
std::panic::set_hook(Box::new(|info| {
let msg = info.to_string();
unsafe { __nodarium_log_panic(msg.as_ptr(), msg.len()); }
}));
});
// Call user function
let result = #fn_name(
#( #arg_names as *const i32 ),*
);
// Write result directly to shared memory at output_pos
let len_bytes = result.len() * 4;
unsafe {
let src = result.as_ptr() as *const u8;
let dst = output_pos as *mut u8;
dst.copy_from_nonoverlapping(src, len_bytes);
}
// Forget the Vec to prevent deallocation (data is in shared memory now)
core::mem::forget(result);
len_bytes as i32
}
};
TokenStream::from(expanded)
}
```
### 2.3 Generated Assembly
The macro generates:
```asm
; Input: output_pos in register r0, arg0 in r1, arg1 in r2, arg2 in r3
execute:
; Call user function
bl user_execute ; returns pointer to Vec<i32> in r0
; Calculate byte length
ldr r4, [r0, #8] ; Vec::len field
lsl r4, r4, #2 ; len * 4 (i32 = 4 bytes)
; Copy Vec data to shared memory at output_pos
ldr r5, [r0, #0] ; Vec::ptr field
ldr r6, [r0, #4] ; capacity (unused)
; memcpy(dst=output_pos, src=r5, len=r4)
; (implemented via copy_from_nonoverlapping)
; Return length
mov r0, r4
bx lr
```
## Phase 3: Input Reading Helpers
```rust
// packages/utils/src/accessor.rs
/// Read i32 from shared memory
#[inline]
pub unsafe fn read_i32(ptr: *const i32) -> i32 {
*ptr
}
/// Read f32 from shared memory (stored as i32 bits)
#[inline]
pub unsafe fn read_f32(ptr: *const i32) -> f32 {
f32::from_bits(*ptr as u32)
}
/// Read boolean from shared memory
#[inline]
pub unsafe fn read_bool(ptr: *const i32) -> bool {
*ptr != 0
}
/// Read vec3 (3 f32s) from shared memory
#[inline]
pub unsafe fn read_vec3(ptr: *const i32) -> [f32; 3] {
let p = ptr as *const f32;
[p.read(), p.add(1).read(), p.add(2).read()]
}
/// Read slice from shared memory
#[inline]
pub unsafe fn read_i32_slice(ptr: *const i32, len: usize) -> &[i32] {
std::slice::from_raw_parts(ptr, len)
}
/// Read f32 slice from shared memory
#[inline]
pub unsafe fn read_f32_slice(ptr: *const i32, len: usize) -> &[f32] {
std::slice::from_raw_parts(ptr as *const f32, len)
}
/// Read with default value
#[inline]
pub unsafe fn read_f32_default(ptr: *const i32, default: f32) -> f32 {
if ptr.is_null() { default } else { read_f32(ptr) }
}
#[inline]
pub unsafe fn read_i32_default(ptr: *const i32, default: i32) -> i32 {
if ptr.is_null() { default } else { read_i32(ptr) }
}
```
## Phase 4: Node Implementation Examples
### 4.1 Math Node
```rust
// nodes/max/plantarium/math/src/lib.rs
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(op_type: *const i32, a: *const i32, b: *const i32) -> Vec<i32> {
use nodarium_utils::{read_i32, read_f32};
let op = unsafe { read_i32(op_type) };
let a_val = unsafe { read_f32(a) };
let b_val = unsafe { read_f32(b) };
let result = match op {
0 => a_val + b_val, // add
1 => a_val - b_val, // subtract
2 => a_val * b_val, // multiply
3 => a_val / b_val, // divide
_ => 0.0,
};
vec![result.to_bits()]
}
```
### 4.2 Vec3 Node
```rust
// nodes/max/plantarium/vec3/src/lib.rs
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(x: *const i32, y: *const i32, z: *const i32) -> Vec<i32> {
use nodarium_utils::read_f32;
let x_val = unsafe { read_f32(x) };
let y_val = unsafe { read_f32(y) };
let z_val = unsafe { read_f32(z) };
vec![x_val.to_bits(), y_val.to_bits(), z_val.to_bits()]
}
```
### 4.3 Box Node
```rust
// nodes/max/plantarium/box/src/lib.rs
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(size: *const i32) -> Vec<i32> {
use nodarium_utils::{read_f32, encode_float, calculate_normals};
let size = unsafe { read_f32(size) };
let p = encode_float(size);
let n = encode_float(-size);
let mut cube_geometry = vec![
1, // 1: geometry
8, // 8 vertices
12, // 12 faces
// Face indices
0, 1, 2, 0, 2, 3,
0, 3, 4, 4, 5, 0,
6, 1, 0, 5, 6, 0,
7, 2, 1, 6, 7, 1,
2, 7, 3, 3, 7, 4,
7, 6, 4, 4, 6, 5,
// Bottom plate
p, n, n, p, n, p, n, n, p, n, n, n,
// Top plate
n, p, n, p, p, n, p, p, p, n, p, p,
// Normals
0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0,
];
calculate_normals(&mut cube_geometry);
cube_geometry
}
```
### 4.4 Stem Node
```rust
// nodes/max/plantarium/stem/src/lib.rs
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(
origin: *const i32,
amount: *const i32,
length: *const i32,
thickness: *const i32,
resolution: *const i32,
) -> Vec<i32> {
use nodarium_utils::{
read_vec3, read_i32, read_f32,
geometry::{create_multiple_paths, wrap_multiple_paths},
};
let origin = unsafe { read_vec3(origin) };
let amount = unsafe { read_i32(amount) } as usize;
let length = unsafe { read_f32(length) };
let thickness = unsafe { read_f32(thickness) };
let resolution = unsafe { read_i32(resolution) } as usize;
let mut stem_data = create_multiple_paths(amount, resolution, 1);
let mut stems = wrap_multiple_paths(&mut stem_data);
for stem in stems.iter_mut() {
let points = stem.get_points_mut();
for (i, point) in points.iter_mut().enumerate() {
let t = i as f32 / (resolution as f32 - 1.0);
point.x = origin[0];
point.y = origin[1] + t * length;
point.z = origin[2];
point.w = thickness * (1.0 - t);
}
}
stem_data
}
```
## Phase 5: Runtime Implementation
```typescript
// app/src/lib/runtime/memory-manager.ts
export const SHARED_MEMORY = new WebAssembly.Memory({
initial: 1024, // 64MB initial
maximum: 4096, // 256MB maximum
});
export class MemoryManager {
private offset: number = 0;
private readonly start: number = 0;
reset() {
this.offset = this.start;
}
alloc(bytes: number): number {
const pos = this.offset;
this.offset += bytes;
return pos;
}
readInt32(pos: number): number {
return new Int32Array(SHARED_MEMORY.buffer)[pos / 4];
}
readFloat32(pos: number): number {
return new Float32Array(SHARED_MEMORY.buffer)[pos / 4];
}
readBytes(pos: number, length: number): Uint8Array {
return new Uint8Array(SHARED_MEMORY.buffer, pos, length);
}
getInt32View(): Int32Array {
return new Int32Array(SHARED_MEMORY.buffer);
}
getFloat32View(): Float32Array {
return new Float32Array(SHARED_MEMORY.buffer);
}
getRemaining(): number {
return SHARED_MEMORY.buffer.byteLength - this.offset;
}
}
```
```typescript
// app/src/lib/runtime/imports.ts
import { SHARED_MEMORY } from "./memory-manager";
export function createImportObject(nodeId: string): WebAssembly.Imports {
return {
env: {
// Import shared memory
memory: SHARED_MEMORY,
// Logging
__nodarium_log: (ptr: number, len: number) => {
const msg = new TextDecoder().decode(
new Uint8Array(SHARED_MEMORY.buffer, ptr, len),
);
console.log(`[${nodeId}] ${msg}`);
},
__nodarium_log_panic: (ptr: number, len: number) => {
const msg = new TextDecoder().decode(
new Uint8Array(SHARED_MEMORY.buffer, ptr, len),
);
console.error(`[${nodeId}] PANIC: ${msg}`);
},
},
};
}
```
```typescript
// app/src/lib/runtime/executor.ts
import { SHARED_MEMORY } from "./memory-manager";
import { createImportObject } from "./imports";
export class SharedMemoryRuntimeExecutor implements RuntimeExecutor {
private memory: MemoryManager;
private results: Map<string, { pos: number; len: number }> = new Map();
private instances: Map<string, WebAssembly.Instance> = new Map();
constructor(private registry: NodeRegistry) {
this.memory = new MemoryManager();
}
async execute(graph: Graph, settings: Record<string, unknown>) {
this.memory.reset();
this.results.clear();
const [outputNode, nodes] = await this.addMetaData(graph);
const sortedNodes = nodes.sort((a, b) => b.depth - a.depth);
for (const node of sortedNodes) {
await this.executeNode(node, settings);
}
const result = this.results.get(outputNode.id);
const view = this.memory.getInt32View();
return view.subarray(result.pos / 4, result.pos / 4 + result.len / 4);
}
private async executeNode(
node: RuntimeNode,
settings: Record<string, unknown>,
) {
const def = this.definitionMap.get(node.type)!;
const inputs = def.inputs || {};
const inputNames = Object.keys(inputs);
const outputSize = this.estimateOutputSize(def);
const outputPos = this.memory.alloc(outputSize);
const args: number[] = [outputPos];
for (const inputName of inputNames) {
const inputDef = inputs[inputName];
const inputNode = node.state.inputNodes[inputName];
if (inputNode) {
const parentResult = this.results.get(inputNode.id)!;
args.push(parentResult.pos);
continue;
}
const valuePos = this.memory.alloc(16);
this.writeValue(
valuePos,
inputDef,
node.props?.[inputName] ??
settings[inputDef.setting ?? ""] ??
inputDef.value,
);
args.push(valuePos);
}
let instance = this.instances.get(node.type);
if (!instance) {
instance = await this.instantiateNode(node.type);
this.instances.set(node.type, instance);
}
const writtenLen = instance.exports.execute(...args);
this.results.set(node.id, { pos: outputPos, len: writtenLen });
}
private writeValue(pos: number, inputDef: NodeInput, value: unknown) {
const view = this.memory.getFloat32View();
const intView = this.memory.getInt32View();
switch (inputDef.type) {
case "float":
view[pos / 4] = value as number;
break;
case "integer":
case "select":
case "seed":
intView[pos / 4] = value as number;
break;
case "boolean":
intView[pos / 4] = value ? 1 : 0;
break;
case "vec3":
const arr = value as number[];
view[pos / 4] = arr[0];
view[pos / 4 + 1] = arr[1];
view[pos / 4 + 2] = arr[2];
break;
}
}
private estimateOutputSize(def: NodeDefinition): number {
const sizes: Record<string, number> = {
float: 16,
integer: 16,
boolean: 16,
vec3: 16,
geometry: 8192,
path: 4096,
};
return sizes[def.outputs?.[0] || "float"] || 64;
}
private async instantiateNode(
nodeType: string,
): Promise<WebAssembly.Instance> {
const wasmBytes = await this.fetchWasm(nodeType);
const module = await WebAssembly.compile(wasmBytes);
const importObject = createImportObject(nodeType);
return WebAssembly.instantiate(module, importObject);
}
}
```
## Phase 7: Execution Flow Visualization
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Execution Timeline │
└─────────────────────────────────────────────────────────────────────────────┘
Step 1: Setup
SHARED_MEMORY = new WebAssembly.Memory({ initial: 1024 })
memory.offset = 0
Step 2: Execute Node A (math with 3 inputs)
outputPos = memory.alloc(16) = 0
args = [0, ptr_to_op_type, ptr_to_a, ptr_to_b]
Node A reads:
*ptr_to_op_type → op
*ptr_to_a → a
*ptr_to_b → b
Node A returns: vec![result.to_bits()]
Macro writes result directly to SHARED_MEMORY[0..4]
Returns: 4
results['A'] = { pos: 0, len: 4 }
memory.offset = 4
Step 3: Execute Node B (stem with 5 inputs, input[0] from A)
outputPos = memory.alloc(4096) = 4
args = [4, results['A'].pos, ptr_to_amount, ptr_to_length, ...]
Node B reads:
*results['A'].pos → value from Node A
*ptr_to_amount → amount
...
Node B returns: stem_data Vec<i32> (1000 elements = 4000 bytes)
Macro writes stem_data directly to SHARED_MEMORY[4..4004]
Returns: 4000
results['B'] = { pos: 4, len: 4000 }
memory.offset = 4004
Step 4: Execute Node C (output, 1 input from B)
outputPos = memory.alloc(16) = 4004
args = [4004, results['B'].pos, results['B'].len]
Node C reads:
*results['B'].pos → stem geometry
Node C returns: vec![1] (identity)
Macro writes to SHARED_MEMORY[4004..4008]
results['C'] = { pos: 4004, len: 4 }
Final: Return SHARED_MEMORY[4004..4008] as geometry result
```
## Phase 6: Memory Growth Strategy
```typescript
class MemoryManager {
alloc(bytes: number): number {
const required = this.offset + bytes;
const currentBytes = SHARED_MEMORY.buffer.byteLength;
if (required > currentBytes) {
const pagesNeeded = Math.ceil((required - currentBytes) / 65536);
const success = SHARED_MEMORY.grow(pagesNeeded);
if (!success) {
throw new Error(`Out of memory: need ${bytes} bytes`);
}
this.int32View = new Int32Array(SHARED_MEMORY.buffer);
this.float32View = new Float32Array(SHARED_MEMORY.buffer);
}
const pos = this.offset;
this.offset += bytes;
return pos;
}
}
```
## Phase 8: Migration Checklist
### Build Configuration
- [ ] Add `--import-memory` to Rust flags in `Cargo.toml`
- [ ] Ensure no nodes export memory
### Runtime
- [ ] Create `SHARED_MEMORY` instance
- [ ] Implement `MemoryManager` with alloc/read/write
- [ ] Create import object factory
- [ ] Implement `SharedMemoryRuntimeExecutor`
### Macro
- [ ] Parse definition JSON
- [ ] Validate function signature (N params, Vec<i32> return)
- [ ] Generate wrapper that writes return value to `output_pos`
- [ ] Add panic hook
### Utilities
- [ ] `read_i32(ptr: *const i32) -> i32`
- [ ] `read_f32(ptr: *const i32) -> f32`
- [ ] `read_bool(ptr: *const i32) -> bool`
- [ ] `read_vec3(ptr: *const i32) -> [f32; 3]`
- [ ] `read_i32_slice(ptr: *const i32, len: usize) -> &[i32]`
### Nodes
- [ ] `float`, `integer`, `boolean` nodes
- [ ] `vec3` node
- [ ] `math` node
- [ ] `random` node
- [ ] `box` node
- [ ] `stem` node
- [ ] `branch` node
- [ ] `instance` node
- [ ] `output` node
## Phase 9: Before vs After
### Before (per-node memory)
```rust
#[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> {
let args = split_args(input);
let a = evaluate_float(args[0]);
let b = evaluate_float(args[1]);
vec![(a + b).to_bits()]
}
```
### After (shared memory)
```rust
#[nodarium_execute]
pub fn execute(a: *const i32, b: *const i32) -> Vec<i32> {
use nodarium_utils::read_f32;
let a_val = unsafe { read_f32(a) };
let b_val = unsafe { read_f32(b) };
vec![(a_val + b_val).to_bits()]
}
```
**Key differences:**
- Parameters are input pointers, not a slice
- Use `read_f32` helper instead of `evaluate_float`
- Macro writes result directly to shared memory
- All nodes share the same memory import
## Phase 10: Benefits
| Aspect | Before | After |
| ----------------- | -------------- | -------------------- |
| Memory | N × ~1MB heaps | 1 × 64-256MB shared |
| Cross-node access | Copy via JS | Direct read |
| API | `&[i32]` slice | `*const i32` pointer |
| Validation | Runtime | Compile-time |
+227
View File
@@ -0,0 +1,227 @@
# Nodarium - AI Coding Agent Summary
## Project Overview
Nodarium is a WebAssembly-based visual programming language used to build <https://nodes.max-richter.dev>, a procedural 3D plant modeling tool. The system allows users to create visual node graphs where each node is a compiled WebAssembly module.
## Technology Stack
**Frontend (SvelteKit):**
- Framework: SvelteKit with Svelte 5
- 3D Rendering: Three.js via Threlte
- Styling: Tailwind CSS 4
- Build Tool: Vite
- State Management: Custom store-client package
- WASM Integration: vite-plugin-wasm, comlink
**Backend/Core (Rust/WASM):**
- Language: Rust
- Output: WebAssembly (wasm32-unknown-unknown target)
- Build Tool: cargo
- Procedural Macros: custom macros package
**Package Management:**
- Node packages: pnpm workspace (v10.28.1)
- Rust packages: Cargo workspace
## Directory Structure
```
nodarium/
├── app/ # SvelteKit web application
│ ├── src/
│ │ ├── lib/ # App-specific components and utilities
│ │ ├── routes/ # SvelteKit routes (pages)
│ │ ├── app.css # Global styles
│ │ └── app.html # HTML template
│ ├── static/
│ │ └── nodes/ # Compiled WASM node files served statically
│ ├── package.json # App dependencies
│ ├── svelte.config.js # SvelteKit configuration
│ ├── vite.config.ts # Vite configuration
│ └── tsconfig.json # TypeScript configuration
├── packages/ # Shared workspace packages
│ ├── ui/ # Svelte UI component library (published as @nodarium/ui)
│ │ ├── src/ # UI components
│ │ ├── static/ # Static assets for UI
│ │ ├── dist/ # Built output
│ │ └── package.json
│ ├── registry/ # Node registry with IndexedDB persistence (@nodarium/registry)
│ │ └── src/
│ ├── types/ # Shared TypeScript types (@nodarium/types)
│ │ └── src/
│ ├── utils/ # Shared utilities (@nodarium/utils)
│ │ └── src/
│ └── macros/ # Rust procedural macros for node development
├── nodes/ # WebAssembly node packages (Rust)
│ └── max/plantarium/ # Plantarium nodes namespace
│ ├── box/ # Box geometry node
│ ├── branch/ # Branch generation node
│ ├── float/ # Float value node
│ ├── gravity/ # Gravity simulation node
│ ├── instance/ # Geometry instancing node
│ ├── math/ # Math operations node
│ ├── noise/ # Noise generation node
│ ├── output/ # Output node for results
│ ├── random/ # Random value node
│ ├── rotate/ # Rotation transformation node
│ ├── stem/ # Stem geometry node
│ ├── triangle/ # Triangle geometry node
│ ├── vec3/ # Vector3 manipulation node
│ └── .template/ # Node template for creating new nodes
├── docs/ # Documentation
│ ├── ARCHITECTURE.md # System architecture overview
│ ├── DEVELOPING_NODES.md # Guide for creating new nodes
│ ├── NODE_DEFINITION.md # Node definition schema
│ └── PLANTARIUM.md # Plantarium-specific documentation
├── Cargo.toml # Rust workspace configuration
├── package.json # Root npm scripts
├── pnpm-workspace.yaml # pnpm workspace configuration
├── pnpm-lock.yaml # Locked dependency versions
└── README.md # Project readme
```
## Node System Architecture
### What is a Node?
Nodes are WebAssembly modules that:
- Have a unique ID (e.g., `max/plantarium/stem`)
- Define inputs with types and default values
- Define outputs they produce
- Execute logic when called with arguments
### Node Definition Schema
Nodes are defined via `definition.json` embedded in each WASM module:
```json
{
"id": "namespace/category/node-name",
"outputs": ["geometry"],
"inputs": {
"height": { "type": "float", "value": 1.0 },
"radius": { "type": "float", "value": 0.1 }
}
}
```
For now the outputs are limited to a single output.
### Node Execution
Nodes receive serialized arguments and return serialized outputs. The `nodarium_utils` Rust crate provides helpers for:
- Parsing input arguments
- Creating geometry data
- Concatenating output vectors
### Node Registration
Nodes are:
1. Compiled to WASM files in `target/wasm32-unknown-unknown/release/`
2. Copied to `app/static/nodes/` for serving
3. Registered in the browser via IndexedDB using the registry package
## Key Dependencies
**Frontend:**
- `@sveltejs/kit` - Application framework
- `@threlte/core` & `@threlte/extras` - Three.js Svelte integration
- `three` - 3D graphics library
- `tailwindcss` - CSS framework
- `comlink` - WebWorker RPC
- `idb` - IndexedDB wrapper
- `wabt` - WebAssembly binary toolkit
**Rust/WASM:**
- Language: Rust (compiled with plain cargo)
- Output: WebAssembly (wasm32-unknown-unknown target)
- Generic WASM wrapper for language-agnostic node development
- `glam` - Math library (Vec2, Vec3, Mat4, etc.)
- `nodarium_macros` - Custom procedural macros
- `nodarium_utils` - Shared node utilities
## Build Commands
From root directory:
```bash
# Install dependencies
pnpm i
# Build all WASM nodes (compiles Rust, copies to app/static)
pnpm build:nodes
# Build the app (builds UI library + SvelteKit app)
pnpm build:app
# Full build (nodes + app)
pnpm build
# Development
pnpm dev # Run all dev commands in parallel
pnpm dev:nodes # Watch nodes/, auto-rebuild on changes
pnpm dev:app_ui # Watch app and UI package
pnpm dev_ui # Watch UI package only
```
## Workspace Packages
The project uses pnpm workspaces with the following packages:
| Package | Location | Purpose |
| ------------------ | ------------------ | ------------------------------ |
| @nodarium/app | app/ | Main SvelteKit application |
| @nodarium/ui | packages/ui/ | Reusable UI component library |
| @nodarium/registry | packages/registry/ | Node registry with persistence |
| @nodarium/types | packages/types/ | Shared TypeScript types |
| @nodarium/utils | packages/utils/ | Shared utilities |
| nodarium macros | packages/macros/ | Rust procedural macros |
## Configuration Files
- `.dprint.jsonc` - Dprint formatter configuration
- `svelte.config.js` - SvelteKit configuration (app and ui)
- `vite.config.ts` - Vite bundler configuration
- `tsconfig.json` - TypeScript configuration (app and packages)
- `Cargo.toml` - Rust workspace with member packages
- `flake.nix` - Nix development environment
## Development Workflow
### Adding a New Node
1. Copy the `.template` directory in `nodes/max/plantarium/` to create a new node directory
2. Define node in `src/definition.json`
3. Implement logic in `src/lib.rs`
4. Build with `cargo build --release --target wasm32-unknown-unknown`
5. Test by dragging onto the node graph
### Modifying UI Components
1. Changes to `packages/ui/` automatically rebuild with watch mode
2. App imports from `@nodarium/ui`
3. Run `pnpm dev:app_ui` for hot reload
## Important Notes for AI Agents
1. **WASM Compilation**: Nodes require `wasm32-unknown-unknown` target (`rustup target add wasm32-unknown-unknown`)
2. **Cross-Compilation**: WASM build happens on host, not in containers/VMs
3. **Static Serving**: Compiled WASM files must exist in `app/static/nodes/` before dev server runs
4. **Workspace Dependencies**: Use `workspace:*` protocol for internal packages
5. **Threlte Version**: Uses Threlte 8.x, not 7.x (important for 3D component APIs)
6. **Svelte 5**: Project uses Svelte 5 with runes (`$state`, `$derived`, `$effect`)
7. **Tailwind 4**: Uses Tailwind CSS v4 with `@tailwindcss/vite` plugin
8. **IndexedDB**: Registry uses IDB for persistent node storage in browser
+294
View File
@@ -0,0 +1,294 @@
# Node Compilation and Runtime Execution
## Overview
Nodarium nodes are WebAssembly modules written in Rust. Each node is a compiled WASM binary that exposes a standardized C ABI interface. The system uses procedural macros to generate the necessary boilerplate for node definitions, memory management, and execution.
## Node Compilation
### 1. Node Definition (JSON)
Each node has a `src/input.json` file that defines:
```json
{
"id": "max/plantarium/stem",
"meta": { "description": "Creates a stem" },
"outputs": ["path"],
"inputs": {
"origin": { "type": "vec3", "value": [0, 0, 0], "external": true },
"amount": { "type": "integer", "value": 1, "min": 1, "max": 64 },
"length": { "type": "float", "value": 5 },
"thickness": { "type": "float", "value": 0.2 }
}
}
```
### 2. Procedural Macros
The `nodarium_macros` crate provides two procedural macros:
#### `#[nodarium_execute]`
Transforms a Rust function into a WASM-compatible entry point:
```rust
#[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> {
// Node logic here
}
```
The macro generates:
- **C ABI wrapper**: Converts the WASM interface to a standard C FFI
- **`execute` function**: Takes `(ptr: *const i32, len: usize)` and returns `*mut i32`
- **Memory allocation**: `__alloc(len: usize) -> *mut i32` for buffer allocation
- **Memory deallocation**: `__free(ptr: *mut i32, len: usize)` for cleanup
- **Static output buffer**: `OUTPUT_BUFFER` for returning results
- **Panic hook**: Routes panics through `host_log_panic` for debugging
- **Internal logic wrapper**: Wraps the original function
#### `nodarium_definition_file!("path")`
Embeds the node definition JSON into the WASM binary:
```rust
nodarium_definition_file!("src/input.json");
```
Generates:
- **`DEFINITION_DATA`**: Static byte array in `nodarium_definition` section
- **`get_definition_ptr()`**: Returns pointer to definition data
- **`get_definition_len()`**: Returns length of definition data
### 3. Build Process
Nodes are compiled with:
```bash
cargo build --release --target wasm32-unknown-unknown
```
The resulting `.wasm` files are copied to `app/static/nodes/` for serving.
## Node Execution Runtime
### Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ WebWorker Thread │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ WorkerRuntimeExecutor ││
│ │ ┌───────────────────────────────────────────────────┐ ││
│ │ │ MemoryRuntimeExecutor ││
│ │ │ ┌─────────────────────────────────────────────┐ ││
│ │ │ │ Node Registry (WASM + Definitions) ││
│ │ │ └─────────────────────────────────────────────┘ ││
│ │ │ ┌─────────────────────────────────────────────┐ ││
│ │ │ │ Execution Engine (Bottom-Up Evaluation) ││
│ │ │ └─────────────────────────────────────────────┘ ││
│ │ └───────────────────────────────────────────────────┘ ││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
```
### 1. MemoryRuntimeExecutor
The core execution engine in `runtime-executor.ts`:
#### Metadata Collection (`addMetaData`)
1. Load node definitions from registry
2. Build parent/child relationships from graph edges
3. Calculate execution depth via reverse BFS from output node
#### Node Sorting
Nodes are sorted by depth (highest depth first) for bottom-up execution:
```
Depth 3: n3 n6
Depth 2: n2 n4 n5
Depth 1: n1
Depth 0: Output
Execution order: n3, n6, n2, n4, n5, n1, Output
```
#### Input Collection
For each node, inputs are gathered from:
1. **Connected nodes**: Results from parent nodes in the graph
2. **Node props**: Values stored directly on the node instance
3. **Settings**: Global settings mapped via `setting` property
4. **Defaults**: Values from node definition
#### Input Encoding
Values are encoded as `Int32Array`:
- **Floats**: IEEE 754 bits cast to i32
- **Vectors**: `[0, count, v1, v2, v3, 1, 1]` (nested bracket format)
- **Booleans**: `0` or `1`
- **Integers**: Direct i32 value
#### Caching
Results are cached using:
```typescript
inputHash = `node-${node.id}-${fastHashArrayBuffer(encoded_inputs)}`
```
The cache uses LRU eviction (default size: 50 entries).
### 2. Execution Flow
```typescript
async execute(graph: Graph, settings) {
// 1. Load definitions and build node relationships
const [outputNode, nodes] = await this.addMetaData(graph);
// 2. Sort nodes by depth (bottom-up)
const sortedNodes = nodes.sort((a, b) => b.depth - a.depth);
// 3. Execute each node
for (const node of sortedNodes) {
const inputs = this.collectInputs(node, settings);
const encoded = concatEncodedArrays(inputs);
const result = nodeType.execute(encoded);
this.results[node.id] = result;
}
// 4. Return output node result
return this.results[outputNode.id];
}
```
### 3. Worker Isolation
`WorkerRuntimeExecutor` runs execution in a WebWorker via Comlink:
```typescript
class WorkerRuntimeExecutor implements RuntimeExecutor {
private worker = new ComlinkWorker(...);
async execute(graph, settings) {
return this.worker.executeGraph(graph, settings);
}
}
```
The worker backend (`worker-runtime-executor-backend.ts`):
- Creates a single `MemoryRuntimeExecutor` instance
- Manages caching state
- Collects performance metrics
### 4. Remote Execution (Optional)
`RemoteRuntimeExecutor` can execute graphs on a remote server:
```typescript
class RemoteRuntimeExecutor implements RuntimeExecutor {
async execute(graph, settings) {
const res = await fetch(this.url, {
method: "POST",
body: JSON.stringify({ graph, settings })
});
return new Int32Array(await res.arrayBuffer());
}
}
```
## Data Encoding Format
### Bracket Notation
Inputs and outputs use a nested bracket encoding:
```
[0, count, item1, item2, ..., 1, 1]
^ ^ items ^ ^
| | | |
| | | +-- closing bracket
| +-- number of items + 1 |
+-- opening bracket (0) +-- closing bracket (1)
```
### Example Encodings
**Float (5.0)**:
```typescript
encodeFloat(5.0) // → 1084227584 (IEEE 754 bits as i32)
```
**Vec3 ([1, 2, 3])**:
```typescript
[0, 4, encodeFloat(1), encodeFloat(2), encodeFloat(3), 1, 1]
```
**Nested Math Expression**:
```
[0, 3, 0, 2, 0, 3, 0, 0, 0, 3, 7549747, 127, 1, 1, ...]
```
### Decoding Utilities
From `packages/utils/src/tree.rs`:
- `split_args()`: Parses nested bracket arrays into segments
- `evaluate_float()`: Recursively evaluates and decodes float expressions
- `evaluate_int()`: Evaluates integer/math node expressions
- `evaluate_vec3()`: Decodes vec3 arrays
## Geometry Data Format
### Path Data
Paths represent procedural plant structures:
```
[0, count, [0, header_size, node_type, depth, x, y, z, w, ...], 1, 1]
```
Each point has 4 values: x, y, z position + thickness (w).
### Geometry Data
Meshes use a similar format with vertices and face indices.
## Performance Tracking
The runtime collects detailed performance metrics:
- `collect-metadata`: Time to build node graph
- `collected-inputs`: Time to gather inputs
- `encoded-inputs`: Time to encode inputs
- `hash-inputs`: Time to compute cache hash
- `cache-hit`: 1 if cache hit, 0 if miss
- `node/{node_type}`: Time per node execution
## Caching Strategy
### MemoryRuntimeCache
LRU cache implementation:
```typescript
class MemoryRuntimeCache {
private map = new Map<string, unknown>();
size: number = 50;
get(key) { /* move to front */ }
set(key, value) { /* evict oldest if at capacity */ }
}
```
### IndexDBCache
For persistence across sessions, the registry uses IndexedDB caching.
## Summary
The Nodarium node system works as follows:
1. **Compilation**: Rust functions are decorated with macros that generate C ABI WASM exports
2. **Registration**: Node definitions are embedded in WASM and loaded at runtime
3. **Graph Analysis**: Runtime builds node relationships and execution order
4. **Bottom-Up Execution**: Nodes execute from leaves to output
5. **Caching**: Results are cached per-node-inputs hash for performance
6. **Isolation**: Execution runs in a WebWorker to prevent main thread blocking
+1
View File
@@ -28,5 +28,6 @@ RUN rm /etc/nginx/conf.d/default.conf
COPY app/docker/app.conf /etc/nginx/conf.d/app.conf COPY app/docker/app.conf /etc/nginx/conf.d/app.conf
COPY --from=builder /app/app/build /app COPY --from=builder /app/app/build /app
COPY --from=builder /app/packages/ui/build /app/ui
EXPOSE 80 EXPOSE 80
+9 -159
View File
@@ -1,204 +1,54 @@
import type { Graph, Graph as GraphType, NodeId } from '@nodarium/types'; import type { Graph, Graph as GraphType, NodeId } from '@nodarium/types';
import { createLogger, createPerformanceStore, splitNestedArray } from '@nodarium/utils'; import { createLogger, createPerformanceStore } from '@nodarium/utils';
import { mkdir, writeFile } from 'node:fs/promises'; import { mkdir, writeFile } from 'node:fs/promises';
import { freemem, loadavg, totalmem } from 'node:os';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { MemoryRuntimeExecutor } from '../src/lib/runtime/runtime-executor.ts'; import { MemoryRuntimeExecutor } from '../src/lib/runtime/runtime-executor.ts';
import { BenchmarkRegistry } from './benchmarkRegistry.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 defaultPlantTemplate from './templates/default.json' assert { type: 'json' };
import lottaFacesTemplate from './templates/lotta-faces.json' assert { type: 'json' }; import lottaFacesTemplate from './templates/lotta-faces.json' assert { type: 'json' };
import plantTemplate from './templates/plant.json' assert { type: 'json' }; import plantTemplate from './templates/plant.json' assert { type: 'json' };
const registry = new BenchmarkRegistry(); const registry = new BenchmarkRegistry();
const r = new MemoryRuntimeExecutor(registry); const r = new MemoryRuntimeExecutor(registry);
const perfStore = createPerformanceStore();
const log = createLogger('bench'); const log = createLogger('bench');
const templates: Record<string, Graph> = { const templates: Record<string, Graph> = {
plant: plantTemplate as unknown as GraphType, 'plant': plantTemplate as unknown as GraphType,
'lotta-faces': lottaFacesTemplate as unknown as GraphType, 'lotta-faces': lottaFacesTemplate as unknown as GraphType,
default: defaultPlantTemplate 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) { async function run(g: GraphType, amount: number) {
await registry.load(g.nodes.map(n => n.type) as NodeId[]); await registry.load(plantTemplate.nodes.map(n => n.type) as NodeId[]);
log.log('loaded ' + g.nodes.length + ' nodes'); log.log('loaded ' + g.nodes.length + ' nodes');
log.log('warming up'); log.log('warming up');
// Warm up the runtime? maybe this does something?
for (let index = 0; index < 10; index++) { for (let index = 0; index < 10; index++) {
await r.execute(g, { randomSeed: true }); 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'); log.log('executing');
const perfStore = createPerformanceStore();
r.perf = perfStore; r.perf = perfStore;
let res: Int32Array | undefined;
const cgroupBefore = await readCgroupCpuStat();
for (let i = 0; i < amount; i++) { for (let i = 0; i < amount; i++) {
r.perf?.startRun(); r.perf?.startRun();
await r.execute(g, { randomSeed: true });
res = await r.execute(g, { randomSeed: true });
r.perf?.stopRun(); 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'); log.log('finished');
return r.perf.get();
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() { async function main() {
const outPath = resolve('benchmark/out/'); const outPath = resolve('benchmark/out/');
await mkdir(outPath, { recursive: true }); await mkdir(outPath, { recursive: true });
for (const key in templates) { for (const key in templates) {
log.log('executing ' + key); log.log('executing ' + key);
const perfData = await run(templates[key], 100); const perfData = await run(templates[key], 100);
await writeFile(resolve(outPath, key + '.json'), JSON.stringify(perfData));
await writeFile(
resolve(outPath, key + '.json'),
JSON.stringify(perfData, null, 2)
);
await new Promise(res => setTimeout(res, 200)); await new Promise(res => setTimeout(res, 200));
} }
} }
-129
View File
@@ -1,129 +0,0 @@
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
}
};
}
+5 -2
View File
@@ -8,6 +8,9 @@ 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');
@@ -20,9 +23,9 @@ test('test', async ({ page }) => {
id: '10', id: '10',
type: 'max/plantarium/stem', type: 'max/plantarium/stem',
props: { props: {
amount: 4, amount: 50,
length: 4, length: 4,
thickness: 0.2 thickness: 1
} }
}, },
{ {
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+30 -31
View File
@@ -1,13 +1,13 @@
{ {
"name": "@nodarium/app", "name": "@nodarium/app",
"private": true, "private": true,
"version": "0.0.6", "version": "0.0.5",
"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 --browser=false", "test:unit": "vitest",
"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",
@@ -18,49 +18,48 @@
"bench": "tsx ./benchmark/index.ts" "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.59.0", "@sveltejs/kit": "^2.50.2",
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.1.18",
"@threlte/core": "8.5.11", "@threlte/core": "8.3.1",
"@threlte/extras": "9.15.1", "@threlte/extras": "9.7.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.2.4", "tailwindcss": "^4.1.18",
"three": "^0.184.0" "three": "^0.182.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.0.5", "@eslint/compat": "^2.0.2",
"@eslint/js": "^10.0.1", "@eslint/js": "^9.39.2",
"@iconify-json/tabler": "^1.2.33", "@iconify-json/tabler": "^1.2.26",
"@iconify/tailwind4": "^1.2.3", "@iconify/tailwind4": "^1.2.1",
"@nodarium/types": "workspace:^", "@nodarium/types": "workspace:^",
"@playwright/test": "^1.59.1", "@playwright/test": "^1.58.1",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tsconfig/svelte": "^5.0.8", "@tsconfig/svelte": "^5.0.7",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/three": "^0.184.0", "@types/three": "^0.182.0",
"@vitest/browser-playwright": "^4.1.5", "@vitest/browser-playwright": "^4.0.18",
"dprint": "^0.54.0", "dprint": "^0.51.1",
"eslint": "^10.3.0", "eslint": "^9.39.2",
"eslint-plugin-svelte": "^3.17.1", "eslint-plugin-svelte": "^3.14.0",
"globals": "^17.6.0", "globals": "^17.3.0",
"svelte": "^5.55.5", "svelte": "^5.49.2",
"svelte-check": "^4.4.7", "svelte-check": "^4.3.6",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^6.0.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.59.1", "typescript-eslint": "^8.54.0",
"vite": "^8.0.10", "vite": "^7.3.1",
"vite-plugin-comlink": "^5.3.0", "vite-plugin-comlink": "^5.3.0",
"vite-plugin-glsl": "^1.6.0", "vite-plugin-glsl": "^1.5.5",
"vite-plugin-wasm": "^3.6.0", "vite-plugin-wasm": "^3.5.0",
"vitest": "^4.1.5", "vitest": "^4.0.18",
"vitest-browser-svelte": "^2.1.1" "vitest-browser-svelte": "^2.0.2"
} }
} }
-2
View File
@@ -1,7 +1,5 @@
@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");
-1
View File
@@ -3,7 +3,6 @@
<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>
@@ -1,12 +1,10 @@
<script lang="ts"> <script lang="ts">
import { appSettings } from '$lib/settings/app-settings.svelte'; import { appSettings } from '$lib/settings/app-settings.svelte';
import { T, useThrelte } from '@threlte/core'; import { T } from '@threlte/core';
import { colors } from '../graph/colors.svelte'; import { colors } from '../graph/colors.svelte';
import BackgroundFrag from './Background.frag'; import BackgroundFrag from './Background.frag';
import BackgroundVert from './Background.vert'; import BackgroundVert from './Background.vert';
const { invalidate } = useThrelte();
type Props = { type Props = {
minZoom?: number; minZoom?: number;
maxZoom?: number; maxZoom?: number;
@@ -35,16 +33,9 @@
let bw = $derived(width / cameraPosition[2]); let bw = $derived(width / cameraPosition[2]);
let bh = $derived(height / cameraPosition[2]); let bh = $derived(height / cameraPosition[2]);
$effect(() => {
if (appSettings.value.theme) {
setTimeout(() => invalidate(), 10);
}
});
</script> </script>
<T.Group <T.Group
visible={!appSettings.value.theme.includes('contrast')}
position.x={cameraPosition[0]} position.x={cameraPosition[0]}
position.z={cameraPosition[1]} position.z={cameraPosition[1]}
position.y={-1.0} position.y={-1.0}
@@ -183,10 +183,8 @@
activeNodeId = node.id; activeNodeId = node.id;
}} }}
> >
{node.meta?.title ?? node.id.split('/').at(-1)} {node.id.split('/').at(-1)}
</div> </div>
{:else}
<div class="no-results">No results for "{value}"</div>
{/each} {/each}
</div> </div>
</div> </div>
@@ -243,11 +241,4 @@
background: var(--color-layer-2); background: var(--color-layer-2);
opacity: 1; opacity: 1;
} }
.no-results {
padding: 1em 0.9em;
font-size: 0.85em;
opacity: 0.45;
font-style: italic;
}
</style> </style>
@@ -1,68 +0,0 @@
<script lang="ts">
import { Button } from '@nodarium/ui';
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 variant="ghost" size="sm" onclick={() => exitToGroup()}>Root</Button>
{#each intermediateGroups as entry (entry.id)}
<span class="i-[tabler--arrow-right]"></span>
<Button variant="ghost" size="sm" onclick={() => exitToGroup(entry.id)}>
{getGroupName(entry.id)}
</Button>
{/each}
<span class="i-[tabler--arrow-right]"></span>
<Button variant="ghost" size="sm" class="opacity-100!">
{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 { assert, describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { GraphManager } from './graph-manager.svelte'; import { GraphManager } from './graph-manager.svelte';
import { import {
createMockNodeRegistry, createMockNodeRegistry,
@@ -9,399 +9,257 @@ import {
mockVec3OutputNode mockVec3OutputNode
} from './test-utils'; } from './test-utils';
describe('groupNodes', () => { describe('GraphManager', () => {
it('should not do anything if no nodes are selected', () => { describe('getPossibleSockets', () => {
const registry = createMockNodeRegistry([ describe('when dragging an output socket', () => {
mockFloatOutputNode, it('should return compatible input sockets based on type', () => {
mockFloatInputNode, const registry = createMockNodeRegistry([
mockGeometryOutputNode, mockFloatOutputNode,
mockPathInputNode mockFloatInputNode,
]); mockGeometryOutputNode,
mockPathInputNode
]);
const manager = new GraphManager(registry); const manager = new GraphManager(registry);
const floatInputNode = manager.createNode({ const floatInputNode = manager.createNode({
type: 'test/node/input', type: 'test/node/input',
position: [100, 100], position: [100, 100],
props: {} props: {}
}); });
assert.isDefined(floatInputNode); const floatOutputNode = manager.createNode({
type: 'test/node/output',
position: [0, 0],
props: {}
});
const floatOutputNode = manager.createNode({ expect(floatInputNode).toBeDefined();
type: 'test/node/output', expect(floatOutputNode).toBeDefined();
position: [0, 0],
props: {}
});
assert.isDefined(floatOutputNode);
const edge = manager.createEdge(floatInputNode, 0, floatOutputNode, 'input'); const possibleSockets = manager.getPossibleSockets({
assert.isDefined(edge); node: floatOutputNode!,
manager.save(); index: 0,
position: [0, 0]
});
manager.groupNodes([]); expect(possibleSockets.length).toBe(1);
const socketNodeIds = possibleSockets.map(([node]) => node.id);
const graph = manager.serialize(); expect(socketNodeIds).toContain(floatInputNode!.id);
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('when dragging an output socket', () => {
it('should return compatible input sockets based on type', () => {
const registry = createMockNodeRegistry([
mockFloatOutputNode,
mockFloatInputNode,
mockGeometryOutputNode,
mockPathInputNode
]);
const manager = new GraphManager(registry);
const floatInputNode = manager.createNode({
type: 'test/node/input',
position: [100, 100],
props: {}
}); });
const floatOutputNode = manager.createNode({ it('should exclude self node from possible sockets', () => {
type: 'test/node/output', const registry = createMockNodeRegistry([
position: [0, 0], mockFloatOutputNode,
props: {} mockFloatInputNode
]);
const manager = new GraphManager(registry);
const floatInputNode = manager.createNode({
type: 'test/node/input',
position: [100, 100],
props: {}
});
expect(floatInputNode).toBeDefined();
const possibleSockets = manager.getPossibleSockets({
node: floatInputNode!,
index: 'value',
position: [0, 0]
});
const socketNodeIds = possibleSockets.map(([node]) => node.id);
expect(socketNodeIds).not.toContain(floatInputNode!.id);
}); });
expect(floatInputNode).toBeDefined(); it('should exclude parent nodes from possible sockets when dragging output', () => {
expect(floatOutputNode).toBeDefined(); const registry = createMockNodeRegistry([
mockFloatOutputNode,
mockFloatInputNode
]);
const possibleSockets = manager.getPossibleSockets({ const manager = new GraphManager(registry);
node: floatOutputNode!,
index: 0, const parentNode = manager.createNode({
position: [0, 0] type: 'test/node/output',
position: [0, 0],
props: {}
});
const childNode = manager.createNode({
type: 'test/node/input',
position: [100, 100],
props: {}
});
expect(parentNode).toBeDefined();
expect(childNode).toBeDefined();
if (parentNode && childNode) {
manager.createEdge(parentNode, 0, childNode, 'value');
}
const possibleSockets = manager.getPossibleSockets({
node: parentNode!,
index: 0,
position: [0, 0]
});
const socketNodeIds = possibleSockets.map(([node]) => node.id);
expect(socketNodeIds).not.toContain(childNode!.id);
}); });
expect(possibleSockets.length).toBe(1); it('should return sockets compatible with accepts property', () => {
const socketNodeIds = possibleSockets.map(([node]) => node.id); const registry = createMockNodeRegistry([
expect(socketNodeIds).toContain(floatInputNode!.id); mockGeometryOutputNode,
}); mockPathInputNode
]);
it('should exclude self node from possible sockets', () => { const manager = new GraphManager(registry);
const registry = createMockNodeRegistry([
mockFloatOutputNode,
mockFloatInputNode
]);
const manager = new GraphManager(registry); const geometryOutputNode = manager.createNode({
type: 'test/node/geometry',
position: [0, 0],
props: {}
});
const floatInputNode = manager.createNode({ const pathInputNode = manager.createNode({
type: 'test/node/input', type: 'test/node/path',
position: [100, 100], position: [100, 100],
props: {} props: {}
});
expect(geometryOutputNode).toBeDefined();
expect(pathInputNode).toBeDefined();
const possibleSockets = manager.getPossibleSockets({
node: geometryOutputNode!,
index: 0,
position: [0, 0]
});
const socketNodeIds = possibleSockets.map(([node]) => node.id);
expect(socketNodeIds).toContain(pathInputNode!.id);
}); });
expect(floatInputNode).toBeDefined(); it('should return empty array when no compatible sockets exist', () => {
const registry = createMockNodeRegistry([
mockVec3OutputNode,
mockFloatInputNode
]);
const possibleSockets = manager.getPossibleSockets({ const manager = new GraphManager(registry);
node: floatInputNode!,
index: 'value', const vec3OutputNode = manager.createNode({
position: [0, 0] type: 'test/node/vec3',
position: [0, 0],
props: {}
});
const floatInputNode = manager.createNode({
type: 'test/node/input',
position: [100, 100],
props: {}
});
expect(vec3OutputNode).toBeDefined();
expect(floatInputNode).toBeDefined();
const possibleSockets = manager.getPossibleSockets({
node: vec3OutputNode!,
index: 0,
position: [0, 0]
});
const socketNodeIds = possibleSockets.map(([node]) => node.id);
expect(socketNodeIds).not.toContain(floatInputNode!.id);
expect(possibleSockets.length).toBe(0);
}); });
const socketNodeIds = possibleSockets.map(([node]) => node.id); it('should return socket info with correct socket key for inputs', () => {
expect(socketNodeIds).not.toContain(floatInputNode!.id); const registry = createMockNodeRegistry([
}); mockFloatOutputNode,
mockFloatInputNode
]);
it('should exclude parent nodes from possible sockets when dragging output', () => { const manager = new GraphManager(registry);
const registry = createMockNodeRegistry([
mockFloatOutputNode,
mockFloatInputNode
]);
const manager = new GraphManager(registry); const floatOutputNode = manager.createNode({
type: 'test/node/output',
position: [0, 0],
props: {}
});
const parentNode = manager.createNode({ const floatInputNode = manager.createNode({
type: 'test/node/output', type: 'test/node/input',
position: [0, 0], position: [100, 100],
props: {} props: {}
});
expect(floatOutputNode).toBeDefined();
expect(floatInputNode).toBeDefined();
const possibleSockets = manager.getPossibleSockets({
node: floatOutputNode!,
index: 0,
position: [0, 0]
});
const matchingSocket = possibleSockets.find(([node]) => node.id === floatInputNode!.id);
expect(matchingSocket).toBeDefined();
expect(matchingSocket![1]).toBe('value');
}); });
const childNode = manager.createNode({ it('should return multiple compatible sockets', () => {
type: 'test/node/input', const registry = createMockNodeRegistry([
position: [100, 100], mockFloatOutputNode,
props: {} mockFloatInputNode,
mockGeometryOutputNode,
mockPathInputNode
]);
const manager = new GraphManager(registry);
const floatOutputNode = manager.createNode({
type: 'test/node/output',
position: [0, 0],
props: {}
});
const geometryOutputNode = manager.createNode({
type: 'test/node/geometry',
position: [200, 0],
props: {}
});
const floatInputNode = manager.createNode({
type: 'test/node/input',
position: [100, 100],
props: {}
});
const pathInputNode = manager.createNode({
type: 'test/node/path',
position: [300, 100],
props: {}
});
expect(floatOutputNode).toBeDefined();
expect(geometryOutputNode).toBeDefined();
expect(floatInputNode).toBeDefined();
expect(pathInputNode).toBeDefined();
const possibleSocketsForFloat = manager.getPossibleSockets({
node: floatOutputNode!,
index: 0,
position: [0, 0]
});
expect(possibleSocketsForFloat.length).toBe(1);
expect(possibleSocketsForFloat.map(([n]) => n.id)).toContain(floatInputNode!.id);
}); });
expect(parentNode).toBeDefined();
expect(childNode).toBeDefined();
if (parentNode && childNode) {
manager.createEdge(parentNode, 0, childNode, 'value');
}
const possibleSockets = manager.getPossibleSockets({
node: parentNode!,
index: 0,
position: [0, 0]
});
const socketNodeIds = possibleSockets.map(([node]) => node.id);
expect(socketNodeIds).not.toContain(childNode!.id);
});
it('should return sockets compatible with accepts property', () => {
const registry = createMockNodeRegistry([
mockGeometryOutputNode,
mockPathInputNode
]);
const manager = new GraphManager(registry);
const geometryOutputNode = manager.createNode({
type: 'test/node/geometry',
position: [0, 0],
props: {}
});
const pathInputNode = manager.createNode({
type: 'test/node/path',
position: [100, 100],
props: {}
});
expect(geometryOutputNode).toBeDefined();
expect(pathInputNode).toBeDefined();
const possibleSockets = manager.getPossibleSockets({
node: geometryOutputNode!,
index: 0,
position: [0, 0]
});
const socketNodeIds = possibleSockets.map(([node]) => node.id);
expect(socketNodeIds).toContain(pathInputNode!.id);
});
it('should return empty array when no compatible sockets exist', () => {
const registry = createMockNodeRegistry([
mockVec3OutputNode,
mockFloatInputNode
]);
const manager = new GraphManager(registry);
const vec3OutputNode = manager.createNode({
type: 'test/node/vec3',
position: [0, 0],
props: {}
});
const floatInputNode = manager.createNode({
type: 'test/node/input',
position: [100, 100],
props: {}
});
expect(vec3OutputNode).toBeDefined();
expect(floatInputNode).toBeDefined();
const possibleSockets = manager.getPossibleSockets({
node: vec3OutputNode!,
index: 0,
position: [0, 0]
});
const socketNodeIds = possibleSockets.map(([node]) => node.id);
expect(socketNodeIds).not.toContain(floatInputNode!.id);
expect(possibleSockets.length).toBe(0);
});
it('should return socket info with correct socket key for inputs', () => {
const registry = createMockNodeRegistry([
mockFloatOutputNode,
mockFloatInputNode
]);
const manager = new GraphManager(registry);
const floatOutputNode = manager.createNode({
type: 'test/node/output',
position: [0, 0],
props: {}
});
const floatInputNode = manager.createNode({
type: 'test/node/input',
position: [100, 100],
props: {}
});
expect(floatOutputNode).toBeDefined();
expect(floatInputNode).toBeDefined();
const possibleSockets = manager.getPossibleSockets({
node: floatOutputNode!,
index: 0,
position: [0, 0]
});
const matchingSocket = possibleSockets.find(([node]) => node.id === floatInputNode!.id);
expect(matchingSocket).toBeDefined();
expect(matchingSocket![1]).toBe('value');
});
it('should return multiple compatible sockets', () => {
const registry = createMockNodeRegistry([
mockFloatOutputNode,
mockFloatInputNode,
mockGeometryOutputNode,
mockPathInputNode
]);
const manager = new GraphManager(registry);
const floatOutputNode = manager.createNode({
type: 'test/node/output',
position: [0, 0],
props: {}
});
const geometryOutputNode = manager.createNode({
type: 'test/node/geometry',
position: [200, 0],
props: {}
});
const floatInputNode = manager.createNode({
type: 'test/node/input',
position: [100, 100],
props: {}
});
const pathInputNode = manager.createNode({
type: 'test/node/path',
position: [300, 100],
props: {}
});
expect(floatOutputNode).toBeDefined();
expect(geometryOutputNode).toBeDefined();
expect(floatInputNode).toBeDefined();
expect(pathInputNode).toBeDefined();
const possibleSocketsForFloat = manager.getPossibleSockets({
node: floatOutputNode!,
index: 0,
position: [0, 0]
});
expect(possibleSocketsForFloat.length).toBe(1);
expect(possibleSocketsForFloat.map(([n]) => n.id)).toContain(floatInputNode!.id);
}); });
}); });
}); });
File diff suppressed because it is too large Load Diff
@@ -1,262 +0,0 @@
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);
});
});
+64 -163
View File
@@ -1,16 +1,10 @@
import { animate, debounce, lerp } from '$lib/helpers'; import type { NodeInstance, Socket } from '@nodarium/types';
import type { NodeInstance, SerializedEdge, SerializedNode, 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 { import { getNodeHeight, getSocketPosition } from './helpers/nodeHelpers';
getNodeHeight,
getParameterHeight,
serializeEdge,
serializeNode
} from './helpers/nodeHelpers';
const graphStateKey = Symbol('graph-state'); const graphStateKey = Symbol('graph-state');
export function getGraphState() { export function getGraphState() {
@@ -62,20 +56,12 @@ export class GraphState {
colors = new ColorGenerator(predefinedColors); colors = new ColorGenerator(predefinedColors);
constructor(private graph: GraphManager) { constructor(private graph: GraphManager) {
const saveCameraPosition = debounce(() => {
localStorage.setItem(
'cameraPosition',
`[${this.cameraPosition[0]},${this.cameraPosition[1]},${this.cameraPosition[2]}]`
);
}, 500);
$effect.root(() => { $effect.root(() => {
$effect(() => { $effect(() => {
// Read values to subscribe to reactivity, then flush lazily. localStorage.setItem(
void this.cameraPosition[0]; 'cameraPosition',
void this.cameraPosition[1]; `[${this.cameraPosition[0]},${this.cameraPosition[1]},${this.cameraPosition[2]}]`
void this.cameraPosition[2]; );
saveCameraPosition();
}); });
}); });
const storedPosition = localStorage.getItem('cameraPosition'); const storedPosition = localStorage.getItem('cameraPosition');
@@ -108,8 +94,8 @@ export class GraphState {
cameraPosition: [number, number, number] = $state([140, 100, 3.5]); cameraPosition: [number, number, number] = $state([140, 100, 3.5]);
clipboard: null | { clipboard: null | {
nodes: SerializedNode[]; nodes: NodeInstance[];
edges: SerializedEdge[]; edges: [number, number, number, string][];
} = null; } = null;
cameraBounds = $derived([ cameraBounds = $derived([
@@ -138,9 +124,6 @@ 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(
@@ -165,25 +148,8 @@ export class GraphState {
this.edges.delete(edgeId); this.edges.delete(edgeId);
} }
private _dirtyPositions = new Set<NodeInstance>(); getEdgeData() {
private _positionFlushPending = false; return this.edges;
private _flushPositions() {
for (const node of this._dirtyPositions) {
if (node.state['x'] !== undefined && node.state['y'] !== undefined) {
if (node.state.ref) {
node.state.ref.style.setProperty('--nx', `${node.state.x * 10}px`);
node.state.ref.style.setProperty('--ny', `${node.state.y * 10}px`);
}
} else {
if (node.state.ref) {
node.state.ref.style.setProperty('--nx', `${node.position[0] * 10}px`);
node.state.ref.style.setProperty('--ny', `${node.position[1] * 10}px`);
}
}
}
this._dirtyPositions.clear();
this._positionFlushPending = false;
} }
updateNodePosition(node: NodeInstance) { updateNodePosition(node: NodeInstance) {
@@ -195,10 +161,16 @@ export class GraphState {
delete node.state.y; delete node.state.y;
} }
this._dirtyPositions.add(node); if (node.state['x'] !== undefined && node.state['y'] !== undefined) {
if (!this._positionFlushPending) { if (node.state.ref) {
this._positionFlushPending = true; node.state.ref.style.setProperty('--nx', `${node.state.x * 10}px`);
requestAnimationFrame(() => this._flushPositions()); node.state.ref.style.setProperty('--ny', `${node.state.y * 10}px`);
}
} else {
if (node.state.ref) {
node.state.ref.style.setProperty('--nx', `${node.position[0] * 10}px`);
node.state.ref.style.setProperty('--ny', `${node.position[1] * 10}px`);
}
} }
} }
@@ -214,14 +186,39 @@ 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;
} }
const ids = new SvelteSet([this.activeNodeId, ...(this.selectedNodes?.values() || [])]); let nodes = [
let nodes = [...ids] this.activeNodeId,
...(this.selectedNodes?.values() || [])
]
.map((id) => this.graph.getNode(id)) .map((id) => this.graph.getNode(id))
.filter((b): b is NodeInstance => !!b); .filter(b => !!b);
const edges = this.graph.getEdgesBetweenNodes(nodes); const edges = this.graph.getEdgesBetweenNodes(nodes);
nodes = nodes.map((node) => ({ nodes = nodes.map((node) => ({
@@ -229,67 +226,26 @@ export class GraphState {
position: [ position: [
this.mousePosition[0] - node.position[0], this.mousePosition[0] - node.position[0],
this.mousePosition[1] - node.position[1] this.mousePosition[1] - node.position[1]
] ],
tmp: undefined
})); }));
this.clipboard = { this.clipboard = {
nodes: nodes.map(n => serializeNode(n)), nodes: nodes,
edges: edges.map(e => serializeEdge(e)) edges: edges
}; };
} }
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;
// Create fresh node objects — never mutate clipboard so repeat pastes work correctly. const nodes = this.clipboard.nodes
// State is also spread (with cleared parents/children) so createGraph's mutations .map((node) => {
// don't corrupt the clipboard's stored state references. node.position[0] = this.mousePosition[0] - node.position[0];
const nodes = this.clipboard.nodes.map((node) => ({ node.position[1] = this.mousePosition[1] - node.position[1];
...node, return node;
position: [ })
this.mousePosition[0] - node.position[0], .filter(Boolean) as NodeInstance[];
this.mousePosition[1] - node.position[1]
] as [number, number]
}));
const newNodes = this.graph.createGraph(nodes, this.clipboard.edges); const newNodes = this.graph.createGraph(nodes, this.clipboard.edges);
this.selectedNodes.clear(); this.selectedNodes.clear();
@@ -303,14 +259,14 @@ export class GraphState {
let { node, index, position } = socket; let { node, index, position } = socket;
// remove existing edge // if the socket is an input socket -> remove existing edges
if (typeof index === 'string') { if (typeof index === 'string') {
const edges = this.graph.getEdgesToNode(node); const edges = this.graph.getEdgesToNode(node);
for (const edge of edges) { for (const edge of edges) {
if (edge[3] === index) { if (edge[3] === index) {
node = edge[0]; node = edge[0];
index = edge[1]; index = edge[1];
position = this.getSocketPosition(node, index); position = getSocketPosition(node, index);
this.graph.removeEdge(edge); this.graph.removeEdge(edge);
break; break;
} }
@@ -330,7 +286,7 @@ export class GraphState {
return { return {
node, node,
index, index,
position: this.getSocketPosition(node, index) position: getSocketPosition(node, index)
}; };
}); });
} }
@@ -367,8 +323,7 @@ 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 nodeType = this.graph.getNodeType(node); const height = getNodeHeight(node.state.type!);
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;
@@ -380,8 +335,7 @@ export class GraphState {
} }
isNodeInView(node: NodeInstance) { isNodeInView(node: NodeInstance) {
if (!node) return false; const height = getNodeHeight(node.state.type!);
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]
@@ -392,57 +346,4 @@ 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
];
}
}
} }
+14 -28
View File
@@ -7,23 +7,22 @@
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';
import { MouseEventManager } from './mouse.events'; import { MouseEventManager } from './mouse.events';
import ZoomIndicator from './ZoomIndicator.svelte';
const { const {
keymap, keymap,
safePadding addMenuPadding
}: { }: {
keymap: ReturnType<typeof createKeyMap>; keymap: ReturnType<typeof createKeyMap>;
safePadding?: { left?: number; right?: number; bottom?: number; top?: number }; addMenuPadding?: { left?: number; right?: number; bottom?: number; top?: number };
} = $props(); } = $props();
const graph = getGraphManager(); const graph = getGraphManager();
@@ -40,8 +39,8 @@
return [0, 0, 0, 0]; return [0, 0, 0, 0];
} }
const pos1 = graphState.getSocketPosition(fromNode, edge[1]); const pos1 = getSocketPosition(fromNode, edge[1]);
const pos2 = graphState.getSocketPosition(toNode, edge[3]); const pos2 = getSocketPosition(toNode, edge[3]);
return [pos1[0], pos1[1], pos2[0], pos2[1]]; return [pos1[0], pos1[1], pos2[0], pos2[1]];
} }
@@ -98,17 +97,10 @@
} }
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 nodeType?.inputs?.[index].type || 'unknown'; return node.state.type?.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>
@@ -122,7 +114,6 @@
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"
@@ -130,7 +121,6 @@
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)}
@@ -146,8 +136,6 @@
/> />
<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}
@@ -184,10 +172,10 @@
{#if graphState.addMenuPosition} {#if graphState.addMenuPosition}
<AddMenu <AddMenu
onnode={handleNodeCreation} onnode={handleNodeCreation}
paddingTop={safePadding?.top} paddingTop={addMenuPadding?.top}
paddingRight={safePadding?.right} paddingRight={addMenuPadding?.right}
paddingBottom={safePadding?.bottom} paddingBottom={addMenuPadding?.bottom}
paddingLeft={safePadding?.left} paddingLeft={addMenuPadding?.left}
/> />
{/if} {/if}
@@ -228,10 +216,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.nodeArray as node, index (node)} {#each graph.nodes.values() as node (node.id)}
<NodeEl <NodeEl
bind:node={graph.nodeArray[index]} {node}
inView={node ? graphState.isNodeInView(node) : false} inView={graphState.isNodeInView(node)}
/> />
{/each} {/each}
</div> </div>
@@ -248,8 +236,6 @@
<HelpView registry={graph.registry} /> <HelpView registry={graph.registry} />
{/if} {/if}
<ZoomIndicator {safePadding} />
<style> <style>
.graph-wrapper { .graph-wrapper {
position: relative; position: relative;
@@ -1,7 +1,6 @@
<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';
@@ -19,7 +18,7 @@
showHelp?: boolean; showHelp?: boolean;
settingTypes?: Record<string, unknown>; settingTypes?: Record<string, unknown>;
safePadding?: { left?: number; right?: number; bottom?: number; top?: number }; addMenuPadding?: { left?: number; right?: number; bottom?: number; top?: number };
onsave?: (save: Graph) => void; onsave?: (save: Graph) => void;
onresult?: (result: unknown) => void; onresult?: (result: unknown) => void;
@@ -28,13 +27,13 @@
let { let {
graph, graph,
registry, registry,
safePadding, addMenuPadding,
// eslint-disable-next-line no-useless-assignment
settings = $bindable(), settings = $bindable(),
activeNode = $bindable(), activeNode = $bindable(),
backgroundType = $bindable('grid'), backgroundType = $bindable('grid'),
snapToGrid = $bindable(true), snapToGrid = $bindable(true),
showHelp = $bindable(false), showHelp = $bindable(false),
settings = $bindable(),
settingTypes = $bindable(), settingTypes = $bindable(),
onsave, onsave,
onresult onresult
@@ -46,32 +45,29 @@
export const manager = new GraphManager(registry); export const manager = new GraphManager(registry);
setGraphManager(manager); setGraphManager(manager);
export const state = new GraphState(manager); const graphState = new GraphState(manager);
$effect(() => { $effect(() => {
if (safePadding) { graphState.backgroundType = backgroundType;
state.safePadding = safePadding; graphState.snapToGrid = snapToGrid;
} graphState.showHelp = showHelp;
state.backgroundType = backgroundType;
state.snapToGrid = snapToGrid;
state.showHelp = showHelp;
}); });
setGraphState(state); setGraphState(graphState);
setupKeymaps(keymap, manager, state); setupKeymaps(keymap, manager, graphState);
$effect(() => { $effect(() => {
if (state.activeNodeId !== -1) { if (graphState.activeNodeId !== -1) {
activeNode = manager.getNode(state.activeNodeId); activeNode = manager.getNode(graphState.activeNodeId);
} else if (activeNode) { } else if (activeNode) {
activeNode = undefined; activeNode = undefined;
} }
}); });
$effect(() => { $effect(() => {
if (!state.addMenuPosition) { if (!graphState.addMenuPosition) {
state.edgeEndPosition = null; graphState.edgeEndPosition = null;
state.activeSocket = null; graphState.activeSocket = null;
} }
}); });
@@ -84,11 +80,11 @@
manager.on('save', (save) => onsave?.(save)); manager.on('save', (save) => onsave?.(save));
onMount(() => { $effect(() => {
if (graph) { if (graph) {
manager.load(graph); manager.load(graph);
} }
}); });
</script> </script>
<GraphEl {keymap} {safePadding} /> <GraphEl {keymap} {addMenuPadding} />
@@ -1,52 +0,0 @@
<script lang="ts">
import { getGraphState } from '../graph-state.svelte';
const { safePadding }: {
safePadding?: { left?: number; right?: number; bottom?: number; top?: number };
} = $props();
const graphState = getGraphState();
</script>
<div class="zoom-indicator" style:right="calc({safePadding?.right ?? 0}px + 10px)">
<button
class="fit-btn"
title="Fit to view (.)"
onclick={() => graphState.centerNode()}
aria-label="Fit nodes to view"
>
</button>
<span>{Math.round(graphState.cameraPosition[2] * 10)}%</span>
</div>
<style>
.zoom-indicator {
position: absolute;
bottom: 10px;
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-family);
font-size: 0.75em;
color: var(--color-text);
opacity: 0.35;
z-index: 10;
transition: opacity 0.15s, right 0.2s;
pointer-events: auto;
}
.zoom-indicator:hover {
opacity: 0.9;
}
.fit-btn {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 1.1em;
line-height: 1;
padding: 0;
}
</style>
+1 -9
View File
@@ -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,14 +10,6 @@ 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)!);
@@ -1,5 +1,4 @@
import { GraphSchema, type NodeId } from '@nodarium/types'; import { GraphSchema, type NodeId } from '@nodarium/types';
import { toast } from '@nodarium/ui';
import type { GraphManager } from '../graph-manager.svelte'; import type { GraphManager } from '../graph-manager.svelte';
import type { GraphState } from '../graph-state.svelte'; import type { GraphState } from '../graph-state.svelte';
@@ -42,9 +41,6 @@ export class FileDropEventManager {
props, props,
position: pos position: pos
}); });
}).catch((e) => {
toast(`Failed to load node: ${nodeId}`, 'error');
console.error(e);
}); });
} else if (event.dataTransfer.files.length) { } else if (event.dataTransfer.files.length) {
const file = event.dataTransfer.files[0]; const file = event.dataTransfer.files[0];
@@ -69,13 +65,8 @@ export class FileDropEventManager {
reader.onload = (e) => { reader.onload = (e) => {
const buffer = e.target?.result as ArrayBuffer; const buffer = e.target?.result as ArrayBuffer;
if (buffer) { if (buffer) {
try { const state = GraphSchema.parse(JSON.parse(buffer.toString()));
const state = GraphSchema.parse(JSON.parse(buffer.toString())); this.graph.load(state);
this.graph.load(state);
} catch (e) {
toast('Failed to load graph: invalid file', 'error');
console.error(e);
}
} }
}; };
reader.readAsText(file); reader.readAsText(file);
@@ -9,7 +9,6 @@ import { EdgeInteractionManager } from './edge.events';
export class MouseEventManager { export class MouseEventManager {
edgeInteractionManager: EdgeInteractionManager; edgeInteractionManager: EdgeInteractionManager;
private pendingSelectionFrame = false;
constructor( constructor(
private graph: GraphManager, private graph: GraphManager,
@@ -191,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.graph.tryConnectToDebugNode(clickedNodeId); this.state.tryConnectToDebugNode(clickedNodeId);
return; return;
} }
if (this.state.activeNodeId === -1) { if (this.state.activeNodeId === -1) {
@@ -283,31 +282,24 @@ export class MouseEventManager {
if (this.state.boxSelection) { if (this.state.boxSelection) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (!this.pendingSelectionFrame) { const mouseD = this.state.projectScreenToWorld(
this.pendingSelectionFrame = true; this.state.mouseDown[0],
requestAnimationFrame(() => { this.state.mouseDown[1]
this.pendingSelectionFrame = false; );
if (!this.state.mouseDown) return; const x1 = Math.min(mouseD[0], this.state.mousePosition[0]);
const mouseD = this.state.projectScreenToWorld( const x2 = Math.max(mouseD[0], this.state.mousePosition[0]);
this.state.mouseDown[0], const y1 = Math.min(mouseD[1], this.state.mousePosition[1]);
this.state.mouseDown[1] const y2 = Math.max(mouseD[1], this.state.mousePosition[1]);
); for (const node of this.graph.nodes.values()) {
const x1 = Math.min(mouseD[0], this.state.mousePosition[0]); if (!node?.state) continue;
const x2 = Math.max(mouseD[0], this.state.mousePosition[0]); const x = node.position[0];
const y1 = Math.min(mouseD[1], this.state.mousePosition[1]); const y = node.position[1];
const y2 = Math.max(mouseD[1], this.state.mousePosition[1]); const height = getNodeHeight(node.state.type!);
for (const node of this.graph.nodes.values()) { if (x > x1 - 20 && x < x2 && y > y1 - height && y < y2) {
if (!node?.state) continue; this.state.selectedNodes?.add(node.id);
const x = node.position[0]; } else {
const y = node.position[1]; this.state.selectedNodes?.delete(node.id);
const height = getNodeHeight(node.state.type!); }
if (x > x1 - 20 && x < x2 && y > y1 - height && y < y2) {
this.state.selectedNodes?.add(node.id);
} else {
this.state.selectedNodes?.delete(node.id);
}
}
});
} }
return; return;
} }
@@ -1,16 +1,6 @@
import type { import type { NodeDefinition, NodeInstance } from '@nodarium/types';
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;
@@ -33,31 +23,42 @@ export function getParameterHeight(node: NodeDefinition, inputKey: string) {
return 50; return 50;
} }
export function serializeNode(node: SerializedNode | NodeInstance): SerializedNode { export function getSocketPosition(
return { node: NodeInstance,
id: node.id, index: string | number
position: [...node.position], ): [number, number] {
type: node.type, if (typeof index === 'number') {
props: node.props ? JSON.parse(JSON.stringify(node.props)) : undefined return [
}; (node?.state?.x ?? node.position[0]) + 20,
} (node?.state?.y ?? node.position[1]) + 2.5 + 10 * index
];
export function serializeEdge(edge: SerializedEdge | Edge): SerializedEdge { } else {
if (typeof edge[0] === 'number' && typeof edge[2] === 'number') { let height = 5;
return [edge[0], edge[1], edge[2], edge[3]]; 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;
}
return [
node?.state?.x ?? node.position[0],
(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) {
@@ -68,34 +69,3 @@ 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;
}
+22 -30
View File
@@ -1,6 +1,6 @@
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 { toast } from '@nodarium/ui';
import FileSaver from 'file-saver'; import FileSaver from 'file-saver';
import type { GraphManager } from './graph-manager.svelte'; import type { GraphManager } from './graph-manager.svelte';
import type { GraphState } from './graph-state.svelte'; import type { GraphState } from './graph-state.svelte';
@@ -48,10 +48,6 @@ 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;
@@ -59,29 +55,6 @@ 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,
@@ -94,7 +67,27 @@ 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;
});
} }
}); });
@@ -147,7 +140,6 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
type: 'application/json;charset=utf-8' type: 'application/json;charset=utf-8'
}); });
FileSaver.saveAs(blob, 'nodarium-graph.json'); FileSaver.saveAs(blob, 'nodarium-graph.json');
toast('Graph downloaded', 'success', 1500);
} }
}); });
+7 -10
View File
@@ -3,14 +3,13 @@
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 { getGraphManager, getGraphState } from '../graph-state.svelte'; import { 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 = {
@@ -19,7 +18,7 @@
}; };
let { node = $bindable(), inView }: Props = $props(); let { node = $bindable(), inView }: Props = $props();
const nodeType = $derived(node ? graph.getNodeType(node) : undefined); const nodeType = $derived(node.state.type!);
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));
@@ -33,17 +32,15 @@
); );
const sectionHeights = $derived( const sectionHeights = $derived(
nodeType Object
? Object .keys(nodeType.inputs || {})
.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 = $derived(nodeType ? getNodeHeight(nodeType) : 20); const height = getNodeHeight(node.state.type!);
const zoom = $derived(graphState.cameraPosition[2]); const zoom = $derived(graphState.cameraPosition[2]);
@@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { NodeInstance } from '@nodarium/types'; import type { NodeInstance } from '@nodarium/types';
import { getGraphManager, getGraphState } from '../graph-state.svelte'; import { 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 = {
@@ -31,12 +30,8 @@
const zOffset = Math.random() - 0.5; const zOffset = Math.random() - 0.5;
const zLimit = 2 - zOffset; const zLimit = 2 - zOffset;
const nodeType = $derived(graph.getNodeType(node)); const parameters = Object.entries(node.state?.type?.inputs || {}).filter(
(p) => p[1].type !== 'seed' && !('setting' in p[1]) && p[1]?.hidden !== true
const parameters = $derived(
Object.entries(nodeType?.inputs || {}).filter(
(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 { getGraphManager, getGraphState } from '../graph-state.svelte'; import { 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,24 +16,13 @@
graphState.setDownSocket?.({ graphState.setDownSocket?.({
node, node,
index: 0, index: 0,
position: graphState.getSocketPosition?.(node, 0) position: getSocketPosition?.(node, 0)
}); });
} }
} }
const cornerTop = 10; const cornerTop = 10;
const nodeType = $derived(graph.getNodeType(node)); const rightBump = $derived(!!node?.state?.type?.outputs?.length);
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(
@@ -42,7 +31,6 @@
height: 34, height: 34,
y: 49, y: 49,
cornerTop, cornerTop,
cornerBottom,
rightBump, rightBump,
aspectRatio aspectRatio
}) })
@@ -53,7 +41,6 @@
height: 40, height: 40,
y: 49, y: 49,
cornerTop, cornerTop,
cornerBottom,
rightBump, rightBump,
aspectRatio aspectRatio
}) })
@@ -83,17 +70,15 @@
{#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}
{nodeType?.meta?.title || node.type?.split('/').pop()} {node.type.split('/').pop()}
</div>
<div
class="target"
role="button"
tabindex="0"
onmousedown={handleMouseDown}
>
</div> </div>
{#if rightBump}
<div
class="target"
role="button"
tabindex="0"
onmousedown={handleMouseDown}
>
</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 } from '../helpers/nodeHelpers'; import { getParameterHeight, getSocketPosition } 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();
let nodeType = $derived(graph.getNodeType(node)!); const nodeType = $derived(node.state.type!);
const inputType = $derived(nodeType.inputs?.[id]); const inputType = $derived(nodeType.inputs?.[id]);
@@ -29,27 +29,14 @@
function handleMouseDown(ev: MouseEvent) { function handleMouseDown(ev: MouseEvent) {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
graphState.setDownSocket({
if (node.type === '__internal/group/input') { node,
const outputIndex = Object.entries(nodeType?.inputs ?? {}).findIndex(([key]) => key === id); index: id,
graphState.setDownSocket({ position: getSocketPosition(node, id)
node, });
index: outputIndex,
position: graphState.getSocketPosition(node, outputIndex)
});
} else {
graphState.setDownSocket({
node,
index: id,
position: graphState.getSocketPosition(node, id)
});
}
} }
const leftBump = $derived( const leftBump = $derived(nodeType.inputs?.[id].internal !== true);
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;
@@ -59,7 +46,6 @@
height: 2000 / height, height: 2000 / height,
y: 50.5, y: 50.5,
cornerBottom, cornerBottom,
rightBump,
leftBump, leftBump,
aspectRatio aspectRatio
}) })
@@ -69,7 +55,6 @@
depth: 7, depth: 7,
height: 2200 / height, height: 2200 / height,
y: 50.5, y: 50.5,
rightBump,
cornerBottom, cornerBottom,
leftBump, leftBump,
aspectRatio aspectRatio
@@ -91,7 +76,6 @@
<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}
@@ -146,11 +130,6 @@
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;
+2 -6
View File
@@ -23,11 +23,7 @@ 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()
@@ -36,7 +32,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: ['float'], outputs: [],
meta: { title: 'Float Input' }, meta: { title: 'Float Input' },
execute: () => new Int32Array() execute: () => new Int32Array()
}; };
+1 -2
View File
@@ -4,8 +4,7 @@ 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;
-1
View File
@@ -6,4 +6,3 @@ 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 -3
View File
@@ -3,7 +3,7 @@
"settings": { "settings": {
"resolution.circle": 54, "resolution.circle": 54,
"resolution.curve": 20, "resolution.curve": 20,
"randomSeed": false "randomSeed": true
}, },
"meta": { "meta": {
"title": "New Project", "title": "New Project",
@@ -27,9 +27,9 @@
], ],
"type": "max/plantarium/stem", "type": "max/plantarium/stem",
"props": { "props": {
"amount": 4, "amount": 50,
"length": 4, "length": 4,
"thickness": 0.2 "thickness": 1
} }
}, },
{ {
+1 -2
View File
@@ -47,7 +47,6 @@ 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: []
}; };
} }
-24
View File
@@ -1,24 +0,0 @@
{
"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": []
}
+2 -6
View File
@@ -1,12 +1,8 @@
export const debugNode = { export const debugNode = {
id: '__internal/node/debug', id: 'max/plantarium/debug',
meta: {
title: 'Debug'
},
inputs: { inputs: {
input: { input: {
type: '*', type: '*'
label: ''
} }
}, },
execute(_data: Int32Array): Int32Array { execute(_data: Int32Array): Int32Array {
-14
View File
@@ -1,14 +0,0 @@
export const groupNode = {
id: '__internal/group/instance',
meta: { title: 'Group' },
inputs: {
groupId: {
label: '',
type: 'select',
values: []
}
},
execute(_data: Int32Array): Int32Array {
return _data;
}
} as const;
@@ -2,6 +2,7 @@ import {
type AsyncCache, type AsyncCache,
type NodeDefinition, type NodeDefinition,
NodeDefinitionSchema, NodeDefinitionSchema,
type NodeId,
type NodeRegistry type NodeRegistry
} from '@nodarium/types'; } from '@nodarium/types';
import { createLogger, createWasmWrapper } from '@nodarium/utils'; import { createLogger, createWasmWrapper } from '@nodarium/utils';
@@ -12,6 +13,7 @@ log.mute();
export class RemoteNodeRegistry implements NodeRegistry { export class RemoteNodeRegistry implements NodeRegistry {
status: 'loading' | 'ready' | 'error' = 'loading'; status: 'loading' | 'ready' | 'error' = 'loading';
private nodes: Map<string, NodeDefinition> = new Map(); private nodes: Map<string, NodeDefinition> = new Map();
private memory = new WebAssembly.Memory({ initial: 1024, maximum: 8192 });
constructor( constructor(
private url: string, private url: string,
@@ -88,7 +90,6 @@ 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`);
} }
@@ -110,8 +111,6 @@ 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 {
@@ -141,7 +140,6 @@ export class RemoteNodeRegistry implements NodeRegistry {
wrapper = createWasmWrapper(wasmBuffer); wrapper = createWasmWrapper(wasmBuffer);
} catch (error) { } catch (error) {
console.error(`Failed to create node wrapper for node: ${id}`, error); console.error(`Failed to create node wrapper for node: ${id}`, error);
throw error;
} }
const rawDefinition = wrapper.get_definition(); const rawDefinition = wrapper.get_definition();
@@ -174,6 +172,13 @@ export class RemoteNodeRegistry implements NodeRegistry {
} }
getAllNodes() { getAllNodes() {
return [...this.nodes.values()]; const allNodes = [...this.nodes.values()];
log.info('getting all nodes', allNodes);
return allNodes;
}
async overwriteNode(nodeId: NodeId, node: NodeDefinition) {
log.info('Overwritten node', { nodeId, node });
this.nodes.set(nodeId, node);
} }
} }
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { defaultPlant, lottaFaces, plant, simple } from '$lib/graph-templates'; import { defaultPlant, lottaFaces, plant, simple } from '$lib/graph-templates';
import type { Graph } from '$lib/types'; import type { Graph } from '$lib/types';
import { Button, ConfirmDialog, InputSelect, Spinner } from '@nodarium/ui'; import { InputSelect } from '@nodarium/ui';
import type { ProjectManager } from './project-manager.svelte'; import type { ProjectManager } from './project-manager.svelte';
const { projectManager } = $props<{ projectManager: ProjectManager }>(); const { projectManager } = $props<{ projectManager: ProjectManager }>();
@@ -31,27 +31,16 @@
newProjectName = ''; newProjectName = '';
showNewProject = false; showNewProject = false;
} }
let pendingDeleteId = $state<number | null>(null);
let confirmOpen = $state(false);
function requestDelete(id: number, e: MouseEvent) {
e.stopPropagation();
pendingDeleteId = id;
confirmOpen = true;
}
function confirmDelete() {
if (pendingDeleteId !== null) {
projectManager.handleDeleteProject(pendingDeleteId);
pendingDeleteId = null;
}
}
</script> </script>
<header class="flex justify-between px-4 h-[70px] border-b-1 border-outline items-center bg-layer-2"> <header class="flex justify-between px-4 h-[70px] border-b-1 border-outline items-center bg-layer-2">
<h3>Project</h3> <h3>Project</h3>
<Button onclick={() => (showNewProject = !showNewProject)}>New</Button> <button
class="px-3 py-1 bg-layer-1 rounded"
onclick={() => (showNewProject = !showNewProject)}
>
New
</button>
</header> </header>
{#if showNewProject} {#if showNewProject}
@@ -64,11 +53,20 @@
onkeydown={(e) => e.key === 'Enter' && handleCreate()} onkeydown={(e) => e.key === 'Enter' && handleCreate()}
/> />
<InputSelect options={templates.map(t => t.name)} bind:value={selectedTemplateIndex} /> <InputSelect options={templates.map(t => t.name)} bind:value={selectedTemplateIndex} />
<Button variant="primary" class="self-end" onclick={() => handleCreate()}>Create</Button> <button
class="cursor-pointer self-end px-3 py-1 bg-selected rounded"
onclick={() => handleCreate()}
>
Create
</button>
</div> </div>
{/if} {/if}
<div class="text-white min-h-screen"> <div class="text-white min-h-screen">
{#if projectManager.loading}
<p>Loading...</p>
{/if}
<ul> <ul>
{#each projectManager.projects as project (project.id)} {#each projectManager.projects as project (project.id)}
<li> <li>
@@ -91,35 +89,16 @@
<div class="flex justify-between items-center grow"> <div class="flex justify-between items-center grow">
<span>{project.meta?.title || 'Untitled'}</span> <span>{project.meta?.title || 'Untitled'}</span>
<button <button
class="opacity-20 hover:opacity-70 transition-opacity cursor-pointer p-1 rounded text-red-400" class="text-layer-1! bg-red-500 w-7 text-xl rounded-sm cursor-pointer opacity-20 hover:opacity-80"
onclick={(e) => requestDelete(project.id!, e)} onclick={() => {
aria-label="Delete project" projectManager.handleDeleteProject(project.id!);
}}
> >
<span class="i-[tabler--trash] w-4 h-4 block"></span> ×
</button> </button>
</div> </div>
</div> </div>
</li> </li>
{:else}
{#if projectManager.loading}
<div class="flex items-center gap-2 p-4">
<Spinner size={12} />
<p>Loading</p>
</div>
{:else}
<li class="px-4 py-8 text-center opacity-40 text-sm">
No projects yet.<br />Press <b>New</b> to create one.
</li>
{/if}
{/each} {/each}
</ul> </ul>
</div> </div>
<ConfirmDialog
bind:open={confirmOpen}
title="Delete project?"
message="This cannot be undone. The project and all its data will be permanently removed."
confirmLabel="Delete"
cancelLabel="Cancel"
onconfirm={confirmDelete}
/>
@@ -10,16 +10,14 @@ export class ProjectManager {
'node.activeProjectId', 'node.activeProjectId',
undefined undefined
); );
public readonly loading = $derived( public readonly loading = $derived(this.graph?.id !== this.activeProjectId.value);
this.projects.length && this.graph?.id !== this.activeProjectId.value
);
constructor() { constructor() {
this.init(); this.init();
} }
async saveGraph(g: Graph) { async saveGraph(g: Graph) {
await db.saveGraph(g); db.saveGraph(g);
} }
private async init() { private async init() {
-4
View File
@@ -24,10 +24,6 @@
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);
+1 -1
View File
@@ -32,7 +32,7 @@ function writePath(scene: Group, data: Int32Array): Vector3[] {
// Instanced spheres at points // Instanced spheres at points
if (positions.length > 0) { if (positions.length > 0) {
const sphereGeometry = new SphereGeometry(0.02, 8, 8); // keep low-poly const sphereGeometry = new SphereGeometry(0.05, 8, 8); // keep low-poly
const sphereMaterial = new MeshBasicMaterial({ const sphereMaterial = new MeshBasicMaterial({
color: 0xff0000, color: 0xff0000,
depthTest: false depthTest: false
+1 -1
View File
@@ -80,6 +80,7 @@ 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
@@ -207,7 +208,6 @@ export function createInstancedGeometryPool(
existingInstance existingInstance
&& instanceCount > existingInstance.geometry.userData.count && instanceCount > existingInstance.geometry.userData.count
) { ) {
existingInstance.geometry.dispose();
scene.remove(existingInstance); scene.remove(existingInstance);
instances.splice(instances.indexOf(existingInstance), 1); instances.splice(instances.indexOf(existingInstance), 1);
existingInstance = new InstancedMesh(geometry, material, instanceCount); existingInstance = new InstancedMesh(geometry, material, instanceCount);
+39
View File
@@ -0,0 +1,39 @@
export function logInt32ArrayChanges(
before: Int32Array,
after: Int32Array,
clamp = 10
): void {
if (before.length !== after.length) {
throw new Error('Arrays must have the same length');
}
let rangeStart: number | null = null;
let collected: number[] = [];
const flush = (endIndex: number) => {
if (rangeStart === null) return;
const preview = collected.slice(0, clamp);
const suffix = collected.length > clamp ? '...' : '';
console.log(
`Change ${rangeStart}-${endIndex}: [${preview.join(', ')}${suffix}]`
);
rangeStart = null;
collected = [];
};
for (let i = 0; i < before.length; i++) {
if (before[i] !== after[i]) {
if (rangeStart === null) {
rangeStart = i;
}
collected.push(after[i]);
} else {
flush(i - 1);
}
}
flush(before.length - 1);
}
@@ -0,0 +1,18 @@
import type { Graph, RuntimeExecutor } from '@nodarium/types';
export class RemoteRuntimeExecutor implements RuntimeExecutor {
constructor(private url: string) {}
async execute(graph: Graph, settings: Record<string, unknown>): Promise<Int32Array> {
const res = await fetch(this.url, {
method: 'POST',
body: JSON.stringify({ graph, settings })
});
if (!res.ok) {
throw new Error(`Failed to execute graph`);
}
return new Int32Array(await res.arrayBuffer());
}
}
@@ -1,164 +0,0 @@
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);
});
});
+271 -228
View File
@@ -1,3 +1,5 @@
import type { SettingsToStore } from '$lib/settings/app-settings.svelte';
import { RemoteNodeRegistry } from '@nodarium/registry';
import type { import type {
Graph, Graph,
NodeDefinition, NodeDefinition,
@@ -7,139 +9,38 @@ import type {
SyncCache SyncCache
} from '@nodarium/types'; } from '@nodarium/types';
import { import {
concatEncodedArrays,
createLogger, createLogger,
createWasmWrapper,
encodeFloat, encodeFloat,
fastHashArrayBuffer,
type PerformanceStore type PerformanceStore
} from '@nodarium/utils'; } from '@nodarium/utils';
import { DevSettingsType } from '../../routes/dev/settings.svelte';
import { logInt32ArrayChanges } from './helpers';
import type { RuntimeNode } from './types'; import type { RuntimeNode } from './types';
const log = createLogger('runtime-executor'); const log = createLogger('runtime-executor');
log.mute(); // log.mute(); // Keep logging enabled for debug info
export function expandGroups(graph: Graph): Graph { const remoteRegistry = new RemoteNodeRegistry('');
if (!graph.groups || graph.groups.length === 0) return graph;
function groupContainsSelf(groupId: number, visited = new Set<number>()): boolean { type WasmExecute = (outputPos: number, args: number[]) => number;
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) { function getValue(input: NodeInput, value?: unknown): number | number[] | Int32Array {
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) {
if (value === undefined && 'value' in input) { if (value === undefined && 'value' in input) {
value = input.value; value = input.value;
} }
if (input.type === 'float') { switch (input.type) {
return encodeFloat(value as number); case 'float':
} return encodeFloat(value as number);
if (input.type === 'select' && typeof value !== 'number') { case 'select':
const index = input.options?.indexOf(value as string); return (value as number) ?? 0;
if (index === undefined || index < 0) {
throw new Error(`Unknown value ${value} for select input ${input.label}`); case 'vec3': {
const arr = Array.isArray(value) ? value : [];
return [0, arr.length + 1, ...arr.map(v => encodeFloat(v)), 1, 1];
} }
return index;
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
@@ -155,25 +56,26 @@ function getValue(input: NodeInput, value?: unknown) {
return [0, value.length + 1, ...value, 1, 1] as number[]; return [0, value.length + 1, ...value, 1, 1] as number[];
} }
if (typeof value === 'boolean') { if (typeof value === 'boolean') return value ? 1 : 0;
return value ? 1 : 0; if (typeof value === 'number') return value;
} if (value instanceof Int32Array) return value;
if (typeof value === 'number') { throw new Error(`Unsupported input type: ${input.type}`);
return value;
}
if (value instanceof Int32Array) {
return value;
}
console.log({ input, value });
throw new Error(`Unknown input type ${input.type}`);
} }
export class MemoryRuntimeExecutor implements RuntimeExecutor { function compareInt32(a: Int32Array, b: Int32Array): boolean {
private definitionMap: Map<string, NodeDefinition> = new Map(); if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
export type Pointer = {
start: number;
end: number;
_title?: string;
};
private seed = Math.floor(Math.random() * 100000000); private seed = Math.floor(Math.random() * 100000000);
private debugData: Record<number, { type: string; data: Int32Array }> = {}; private debugData: Record<number, { type: string; data: Int32Array }> = {};
@@ -181,36 +83,55 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
perf?: PerformanceStore; perf?: PerformanceStore;
constructor( constructor(
private registry: NodeRegistry, private readonly registry: NodeRegistry,
public cache?: SyncCache<Int32Array> public cache?: SyncCache<Int32Array>
) {} ) {
this.cache = undefined;
this.refreshView();
log.info('MemoryRuntimeExecutor initialized');
}
private refreshView(): void {
this.memoryView = new Int32Array(this.memory.buffer);
log.info(`Memory view refreshed, length: ${this.memoryView.length}`);
}
public getMemory(): Int32Array {
return new Int32Array(this.memory.buffer);
}
private map = new Map<string, { definition: NodeDefinition; execute: WasmExecute }>();
private async getNodeDefinitions(graph: Graph) { private async getNodeDefinitions(graph: Graph) {
if (this.registry.status !== 'ready') { if (this.registry.status !== 'ready') {
throw new Error('Node registry is not ready'); throw new Error('Node registry is not ready');
} }
// Only load non-virtual types (virtual nodes are resolved locally) await this.registry.load(graph.nodes.map(n => n.type));
const nonVirtualTypes = graph.nodes log.info(`Loaded ${graph.nodes.length} node types from registry`);
.map(node => node.type)
.filter(t => !t.startsWith('__internal/'));
await this.registry.load(nonVirtualTypes);
const typeMap = new Map<string, NodeDefinition>(); for (const { type } of graph.nodes) {
for (const node of graph.nodes) { if (this.map.has(type)) continue;
if (!typeMap.has(node.type)) {
const type = this.registry.getNode(node.type); const def = this.registry.getNode(type);
if (type) { if (!def) continue;
typeMap.set(node.type, type);
} log.info(`Fetching WASM for node type: ${type}`);
} const buffer = await remoteRegistry.fetchArrayBuffer(`nodes/${type}.wasm`);
const wrapper = createWasmWrapper(buffer, this.memory);
this.map.set(type, {
definition: def,
execute: wrapper.execute
});
log.info(`Node type ${type} loaded and wrapped`);
} }
return typeMap;
return this.map;
} }
private async addMetaData(graph: Graph) { private async addMetaData(graph: Graph) {
// First, lets check if all nodes have a definition this.nodes = await this.getNodeDefinitions(graph);
this.definitionMap = await this.getNodeDefinitions(graph); log.info(`Metadata added for ${this.nodes.size} nodes`);
const graphNodes = graph.nodes.map(node => { const graphNodes = graph.nodes.map(node => {
const n = node as RuntimeNode; const n = node as RuntimeNode;
@@ -223,25 +144,21 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
return n; return n;
}); });
const outputNode = graphNodes.find((node) => node.type.endsWith('/output')); const outputNode = graphNodes.find(n => n.type.endsWith('/output') || n.type.endsWith('/debug'))
if (!outputNode) { ?? graphNodes[0];
throw new Error('No output node found');
}
const nodeMap = new Map( const nodeMap = new Map(graphNodes.map(n => [n.id, n]));
graphNodes.map((node) => [node.id, node])
);
// loop through all edges and assign the parent and child nodes to each node // loop through all edges and assign the parent and child nodes to each node
for (const edge of graph.edges) { for (const edge of graph.edges) {
const [parentId, /*_parentOutput*/, childId, childInput] = edge; const [parentId, /*_parentOutput*/, childId, childInput] = edge;
const parent = nodeMap.get(parentId); const parent = nodeMap.get(parentId);
const child = nodeMap.get(childId); const child = nodeMap.get(childId);
if (parent && child) { if (!parent || !child) continue;
parent.state.children.push(child);
child.state.parents.push(parent); parent.state.children.push(child);
child.state.inputNodes[childInput] = parent; child.state.parents.push(parent);
} child.state.inputNodes[childInput] = parent;
} }
const nodes = new Map<number, RuntimeNode>(); const nodes = new Map<number, RuntimeNode>();
@@ -249,10 +166,8 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
// loop through all the nodes and assign each nodes its depth // loop through all the nodes and assign each nodes its depth
const stack = [outputNode, ...graphNodes.filter(n => n.type.endsWith('/debug'))]; const stack = [outputNode, ...graphNodes.filter(n => n.type.endsWith('/debug'))];
while (stack.length) { while (stack.length) {
const node = stack.pop(); const node = stack.pop()!;
if (!node) continue;
for (const parent of node.state.parents) { for (const parent of node.state.parents) {
parent.state = parent.state || {};
parent.state.depth = node.state.depth + 1; parent.state.depth = node.state.depth + 1;
stack.push(parent); stack.push(parent);
} }
@@ -276,20 +191,21 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
return [outputNode, _nodes] as const; return [outputNode, _nodes] as const;
} }
async execute(graph: Graph, settings: Record<string, unknown>) { private writeToMemory(value: number | number[] | Int32Array, title?: string): Pointer {
this.perf?.addPoint('runtime'); const start = this.offset;
if (typeof value === 'number') {
this.memoryView[this.offset++] = value;
} else {
this.memoryView.set(value, this.offset);
this.offset += value.length;
}
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();
this.perf?.addPoint('collect-metadata', b - a);
/* /*
* Here we sort the nodes into buckets, which we then execute one by one * Here we sort the nodes into buckets, which we then execute one by one
@@ -307,58 +223,75 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
(a, b) => (b.state?.depth || 0) - (a.state?.depth || 0) (a, b) => (b.state?.depth || 0) - (a.state?.depth || 0)
); );
// here we store the intermediate results of the nodes console.log({ settings });
const results: Record<string, Int32Array> = {};
if (settings['randomSeed']) { this.printMemory();
this.seed = Math.floor(Math.random() * 100000000); const seedPtr = this.writeToMemory(this.seed, 'seed');
}
const settingPtrs = new Map<string, Pointer>(
Object.entries(settings).map((
[key, value]
) => [key as string, this.writeToMemory(value as number, `setting.${key}`)])
);
for (const node of sortedNodes) { for (const node of sortedNodes) {
const node_type = this.definitionMap.get(node.type)!; const node_type = this.nodes.get(node.type)!;
console.log('---------------');
console.log('STARTING NODE EXECUTION', node_type.definition.id + '/' + node.id);
this.printMemory();
// console.log(node_type.definition.inputs);
const inputs = Object.entries(node_type.definition.inputs || {}).map(
([key, input]) => {
// We should probably initially write this to memory
if (input.type === 'seed') {
return seedPtr;
}
const title = `${node.id}.${key}`;
// We should probably initially write this to memory
// If the input is linked to a setting, we use that value
// TODO: handle nodes which reference undefined settings
if (input.setting) {
return settingPtrs.get(input.setting)!;
}
// check if the input is connected to another node
const inputNode = node.state.inputNodes[key];
if (inputNode) {
if (this.results[inputNode.id] === undefined) {
throw new Error(
`Node ${node.type}/${node.id} is missing input from node ${inputNode.type}/${inputNode.id}`
);
}
return this.results[inputNode.id];
}
// If the value is stored in the node itself, we use that value
if (node.props?.[key] !== undefined) {
const value = getValue(input, node.props[key]);
console.log(`Writing prop for ${node.id} -> ${key} to memory`, node.props[key], value);
return this.writeToMemory(value, title);
}
return this.writeToMemory(getValue(input), title);
}
);
this.printMemory();
if (!node_type || !node.state || !node_type.execute) { if (!node_type || !node.state || !node_type.execute) {
log.warn(`Node ${node.id} has no definition`); log.warn(`Node ${node.id} has no definition`);
continue; continue;
} }
a = performance.now(); this.inputPtrs[node.id] = inputs;
const args = inputs.map(s => [s.start, s.end]).flat();
// Collect the inputs for the node console.log('ARGS', inputs);
const inputs = Object.entries(node_type.inputs || {}).map(
([key, input]) => {
if (input.type === 'seed') {
return this.seed;
}
// If the input is linked to a setting, we use that value
if (input.setting) {
return getValue(input, settings[input.setting]);
}
// check if the input is connected to another node
const inputNode = node.state.inputNodes[key];
if (inputNode) {
if (results[inputNode.id] === undefined) {
throw new Error(
`Node ${node.type} is missing input from node ${inputNode.type}#${inputNode.id}`
);
}
return results[inputNode.id];
}
// If the value is stored in the node itself, we use that value
if (node.props?.[key] !== undefined) {
return getValue(input, node.props[key]);
}
return getValue(input);
}
);
b = performance.now();
this.perf?.addPoint('collected-inputs', b - a);
this.printMemory();
try { try {
a = performance.now(); a = performance.now();
const encoded_inputs = concatEncodedArrays(inputs); const encoded_inputs = concatEncodedArrays(inputs);
@@ -399,28 +332,138 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
b = performance.now(); b = performance.now();
if (this.cache && node.id !== outputNode.id) { if (this.cache && node.id !== outputNode.id) {
this.cache.set(inputHash, results[node.id]); this.cache.set(inputHash, this.results[node.id]);
} }
this.perf?.addPoint('node/' + node_type.id, b - a); this.perf?.addPoint('node/' + node_type.id, b - a);
log.log('Result:', results[node.id]); log.log('Result:', results[node.id]);
log.groupEnd(); log.groupEnd();
} catch (e) { } catch (e) {
log.groupEnd(); console.error(`Failed to execute node ${node.type}/${node.id}`, e);
throw e; this.isRunning = false;
} }
} }
// return the result of the parent of the output node this.isRunning = true;
const res = results[outputNode.id]; log.info('Execution started');
if (this.cache) { try {
this.cache.size = sortedNodes.length * 2; this.offset = 0;
this.results = {};
this.inputPtrs = {};
this.allPtrs = [];
this.seed += 2;
this.refreshView();
const [outputNode, nodes] = await this.addMetaData(graph);
const sortedNodes = [...nodes].sort(
(a, b) => (b.state.depth ?? 0) - (a.state.depth ?? 0)
);
const seedPtr = this.writeToMemory(this.seed, 'seed');
const settingPtrs = new Map<string, Pointer>();
for (const [key, value] of Object.entries(settings)) {
const ptr = this.writeToMemory(value as number, `setting.${key}`);
settingPtrs.set(key, ptr);
}
let lastNodePtr: Pointer | undefined = undefined;
for (const node of sortedNodes) {
const nodeType = this.nodes.get(node.type);
if (!nodeType) continue;
log.info(`Executing node: ${node.id} (type: ${node.type})`);
const inputs = Object.entries(nodeType.definition.inputs || {}).map(
([key, input]) => {
if (input.type === 'seed') return seedPtr;
if (input.setting) {
const ptr = settingPtrs.get(input.setting);
if (!ptr) throw new Error(`Missing setting: ${input.setting}`);
return ptr;
}
const src = node.state.inputNodes[key];
if (src) {
const res = this.results[src.id];
if (!res) {
throw new Error(`Missing input from ${src.type}/${src.id}`);
}
return res;
}
if (node.props?.[key] !== undefined) {
return this.writeToMemory(
getValue(input, node.props[key]),
`${node.id}.${key}`
);
}
return this.writeToMemory(getValue(input), `${node.id}.${key}`);
}
);
this.inputPtrs[node.id] = inputs;
const args = inputs.flatMap(p => [p.start * 4, p.end * 4]);
log.info(`Executing node ${node.type}/${node.id}`);
const memoryBefore = this.memoryView.slice(0, this.offset);
const bytesWritten = nodeType.execute(this.offset * 4, args);
this.refreshView();
const memoryAfter = this.memoryView.slice(0, this.offset);
logInt32ArrayChanges(memoryBefore, memoryAfter);
this.refreshView();
const outLen = bytesWritten >> 2;
const outputStart = this.offset;
if (
args.length === 2
&& inputs[0].end - inputs[0].start === outLen
&& compareInt32(
this.memoryView.slice(inputs[0].start, inputs[0].end),
this.memoryView.slice(outputStart, outputStart + outLen)
)
) {
this.results[node.id] = inputs[0];
this.allPtrs.push(this.results[node.id]);
log.info(`Node ${node.id} result reused input memory`);
} else {
this.results[node.id] = {
start: outputStart,
end: outputStart + outLen,
_title: `${node.id} ->`
};
this.allPtrs.push(this.results[node.id]);
this.offset += outLen;
lastNodePtr = this.results[node.id];
log.info(
`Node ${node.id} wrote result to memory: start=${outputStart}, end=${outputStart + outLen
}`
);
}
}
const res = this.results[outputNode.id] ?? lastNodePtr;
if (!res) throw new Error('Output node produced no result');
log.info(`Execution finished, output pointer: start=${res.start}, end=${res.end}`);
this.refreshView();
return this.memoryView.slice(res.start, res.end);
} catch (e) {
log.info('Execution error:', e);
console.error(e);
} finally {
this.isRunning = false;
console.log('Final Memory', [...this.memoryView.slice(0, 20)]);
this.perf?.endPoint('runtime');
log.info('Executor state reset');
} }
this.perf?.endPoint('runtime');
return res as unknown as Int32Array;
} }
getDebugData() { getDebugData() {
@@ -2,7 +2,6 @@ import { debugNode } from '$lib/node-registry/debugNode';
import { IndexDBCache, RemoteNodeRegistry } from '$lib/node-registry/index'; import { IndexDBCache, RemoteNodeRegistry } from '$lib/node-registry/index';
import type { Graph } from '@nodarium/types'; import type { Graph } from '@nodarium/types';
import { createPerformanceStore } from '@nodarium/utils'; import { createPerformanceStore } from '@nodarium/utils';
import * as Comlink from 'comlink';
import { MemoryRuntimeExecutor } from './runtime-executor'; import { MemoryRuntimeExecutor } from './runtime-executor';
import { MemoryRuntimeCache } from './runtime-executor-cache'; import { MemoryRuntimeCache } from './runtime-executor-cache';
@@ -39,9 +38,6 @@ export async function executeGraph(
performanceStore.startRun(); performanceStore.startRun();
const res = await executor.execute(graph, settings); const res = await executor.execute(graph, settings);
performanceStore.stopRun(); performanceStore.stopRun();
if (res?.buffer) {
return Comlink.transfer(res, [res.buffer]);
}
return res; return res;
} }
@@ -12,8 +12,8 @@ export class WorkerRuntimeExecutor implements RuntimeExecutor {
getPerformanceData() { getPerformanceData() {
return this.worker.getPerformanceData(); return this.worker.getPerformanceData();
} }
async getDebugData() { getDebugData() {
return await this.worker.getDebugData(); return this.worker.getDebugData();
} }
set useRuntimeCache(useCache: boolean) { set useRuntimeCache(useCache: boolean) {
this.worker.setUseRuntimeCache(useCache); this.worker.setUseRuntimeCache(useCache);
+13 -14
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { localState } from '$lib/helpers/localState.svelte'; import { localState } from '$lib/helpers/localState.svelte';
import type { NodeInput } from '@nodarium/types'; import type { NodeInput } from '@nodarium/types';
import Input, { Button as UiButton } from '@nodarium/ui'; import Input from '@nodarium/ui';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import NestedSettings from './NestedSettings.svelte'; import NestedSettings from './NestedSettings.svelte';
@@ -28,14 +28,13 @@
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, onButtonClick, depth = 0 }: Props = $props(); let { id, key = '', value = $bindable(), type, 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;
@@ -108,6 +107,11 @@
} }
}); });
function handleClick() {
const callback = value[key] as unknown as () => void;
callback();
}
onMount(() => { onMount(() => {
open = openSections.value[id]; open = openSections.value[id];
@@ -126,9 +130,9 @@
{@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'}
<UiButton onclick={() => onButtonClick?.(id)}> <button onclick={handleClick}>
{inputType.label || key} {inputType.label || key}
</UiButton> </button>
{:else} {:else}
{#if inputType.label !== ''} {#if inputType.label !== ''}
<label for={id}>{inputType.label || key}</label> <label for={id}>{inputType.label || key}</label>
@@ -139,7 +143,6 @@
{: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
@@ -157,7 +160,6 @@
<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}
@@ -204,13 +206,6 @@
.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 {
@@ -224,6 +219,10 @@
gap: 10px; gap: 10px;
} }
button {
cursor: pointer;
}
hr { hr {
margin: 0; margin: 0;
left: 0; left: 0;
+3 -6
View File
@@ -28,10 +28,6 @@ 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: {
@@ -113,8 +109,9 @@ export const AppSettingTypes = {
} }
} as const; } as const;
type SettingsToStore<T> = T extends { value: infer V } ? V extends readonly string[] ? V[number] type SettingsToStore<T> = T extends { type: 'button' } ? () => void
: V : T extends { value: infer V } ? V extends readonly string[] ? V[number]
: 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]>;
} }
-1
View File
@@ -2,7 +2,6 @@
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(() => {
@@ -0,0 +1,101 @@
<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,103 +1,26 @@
<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 NestedSettings from '$lib/settings/NestedSettings.svelte'; import type { NodeInstance } from '@nodarium/types';
import type { NodeId, NodeInput, NodeInstance } from '@nodarium/types'; import ActiveNodeSelected from './ActiveNodeSelected.svelte';
type InternalNodeInput = NodeInput & {
__node_type?: NodeId;
__node_input: string;
};
type Props = { type Props = {
manager: GraphManager; manager: GraphManager;
node: NodeInstance | undefined; node: NodeInstance | undefined;
}; };
const { manager, node = $bindable() }: Props = $props(); let { 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}
@@ -8,7 +8,7 @@
import { humanizeDuration } from '$lib/helpers'; import { humanizeDuration } from '$lib/helpers';
import { localState } from '$lib/helpers/localState.svelte'; import { localState } from '$lib/helpers/localState.svelte';
import Monitor from '$lib/performance/Monitor.svelte'; import Monitor from '$lib/performance/Monitor.svelte';
import { Button, InputNumber } from '@nodarium/ui'; import { InputNumber } from '@nodarium/ui';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
function calculateStandardDeviation(array: number[]) { function calculateStandardDeviation(array: number[]) {
@@ -112,7 +112,7 @@
onclick={() => copyContent(result?.stdev + '')} onclick={() => copyContent(result?.stdev + '')}
>(click to copy)</i> >(click to copy)</i>
<div> <div>
<Button onclick={() => (isRunning = false)}>reset</Button> <button onclick={() => (isRunning = false)}>reset</button>
</div> </div>
{:else if isRunning} {:else if isRunning}
<p>WarmUp ({$warmUp}/{warmUpAmount})</p> <p>WarmUp ({$warmUp}/{warmUpAmount})</p>
@@ -126,7 +126,7 @@
{:else} {:else}
<label for="bench-samples">Samples</label> <label for="bench-samples">Samples</label>
<InputNumber id="bench-sample" bind:value={amount.value} max={1000} step={1} /> <InputNumber id="bench-sample" bind:value={amount.value} max={1000} step={1} />
<Button variant="primary" onclick={benchmark} disabled={isRunning}>start</Button> <button onclick={benchmark} disabled={isRunning}>start</button>
{/if} {/if}
</div> </div>
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { Button, toast } from '@nodarium/ui';
import FileSaver from 'file-saver'; import FileSaver from 'file-saver';
import type { Group } from 'three'; import type { Group } from 'three';
import type { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js'; import type { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
@@ -29,12 +28,11 @@
exporter.parse( exporter.parse(
scene, scene,
(gltf) => { (gltf) => {
// download .gltf file
download(gltf as ArrayBuffer, 'plant', 'text/plain', 'gltf'); download(gltf as ArrayBuffer, 'plant', 'text/plain', 'gltf');
toast('Exported as GLTF', 'success');
}, },
(err) => { (err) => {
const msg = err instanceof Error ? err.message : String(err); console.log(err);
toast(`GLTF export failed: ${msg}`, 'error');
} }
); );
} }
@@ -47,18 +45,13 @@
objExporter = new m.OBJExporter(); objExporter = new m.OBJExporter();
return objExporter; return objExporter;
})); }));
try { const result = exporter.parse(scene);
const result = exporter.parse(scene); // download .obj file
download(result, 'plant', 'text/plain', 'obj'); download(result, 'plant', 'text/plain', 'obj');
toast('Exported as OBJ', 'success');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast(`OBJ export failed: ${msg}`, 'error');
}
} }
</script> </script>
<div class="p-4 flex gap-2"> <div class="p-4">
<Button onclick={exportObj}>export obj</Button> <button onclick={exportObj}>export obj</button>
<Button onclick={exportGltf}>export gltf</Button> <button onclick={exportGltf}>export gltf</button>
</div> </div>
+13 -16
View File
@@ -1,23 +1,20 @@
<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();
const data = $derived( function convert(g: Graph): string {
graph return JSON.stringify(
? { {
...graph, ...g,
nodes: graph.nodes.map((n: object) => ({ ...n, state: undefined })) nodes: g.nodes.map((n: object) => ({ ...n, tmp: undefined, state: undefined }))
} },
: null null,
); 2
);
}
</script> </script>
<div class="overflow-auto p-2"> <pre>
{#if data} {graph ? convert(graph) : "No graph loaded"}
<JsonViewer value={data} path="graph" /> </pre>
{:else}
<span class="font-mono text-xs text-neutral-500">No graph loaded</span>
{/if}
</div>
@@ -1,156 +0,0 @@
<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>
@@ -1,122 +0,0 @@
<script lang="ts">
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
import type { GroupDefinition } from '@nodarium/types';
import { Button } from '@nodarium/ui';
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 size="sm" variant="destructive" 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;
}
.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>
-240
View File
@@ -1,240 +0,0 @@
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 }
]
}
}
};
+52 -213
View File
@@ -4,10 +4,9 @@
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'; import { debugNode } from '$lib/node-registry/debugNode.js';
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 PerformanceViewer from '$lib/performance/PerformanceViewer.svelte'; import PerformanceViewer from '$lib/performance/PerformanceViewer.svelte';
import { ProjectManager } from '$lib/project-manager/project-manager.svelte'; import { ProjectManager } from '$lib/project-manager/project-manager.svelte';
import ProjectManagerEl from '$lib/project-manager/ProjectManager.svelte'; import ProjectManagerEl from '$lib/project-manager/ProjectManager.svelte';
@@ -22,26 +21,20 @@
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 { Spinner, Toast, toast } from '@nodarium/ui';
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>>();
let pendingSave = false;
const { data } = $props(); const { data } = $props();
const registryCache = new IndexDBCache('node-registry'); const registryCache = new IndexDBCache('node-registry');
const nodeRegistry = new RemoteNodeRegistry('', registryCache, [debugNode, groupNode]); const nodeRegistry = new RemoteNodeRegistry('', registryCache, [debugNode]);
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);
@@ -53,8 +46,8 @@
); );
$effect(() => { $effect(() => {
workerRuntime.useRegistryCache = appSettings.value.debug.cache.useRegistryCache; workerRuntime.useRegistryCache = appSettings.value.debug.cache.useRuntimeCache;
workerRuntime.useRuntimeCache = appSettings.value.debug.cache.useRuntimeCache; workerRuntime.useRuntimeCache = appSettings.value.debug.cache.useRegistryCache;
if (appSettings.value.debug.cache.useRegistryCache) { if (appSettings.value.debug.cache.useRegistryCache) {
nodeRegistry.cache = registryCache; nodeRegistry.cache = registryCache;
@@ -69,19 +62,8 @@
} }
}); });
$effect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (pendingSave) {
e.preventDefault();
}
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
});
let activeNode = $state<NodeInstance | undefined>(undefined); let activeNode = $state<NodeInstance | undefined>(undefined);
let scene = $state<Group>(null!); let scene = $state<Group>(null!);
let isExecuting = $state(false);
let sidebarOpen = $state(false); let sidebarOpen = $state(false);
let graphInterface = $state<ReturnType<typeof GraphInterface>>(null!); let graphInterface = $state<ReturnType<typeof GraphInterface>>(null!);
@@ -108,22 +90,11 @@
let graphSettingTypes = $state({ let graphSettingTypes = $state({
randomSeed: { type: 'boolean', value: false } randomSeed: { type: 'boolean', value: false }
}); });
$effect(() => {
if (graphSettings && graphSettingTypes && manager?.loaded) {
manager?.setSettings($state.snapshot(graphSettings));
}
});
let timeout: ReturnType<typeof setTimeout>;
async function update( async function update(
g: Graph, g: Graph,
s: Record<string, unknown> = $state.snapshot(graphSettings) s: Record<string, unknown> = $state.snapshot(graphSettings)
) { ) {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
isExecuting = true;
}, 100);
performanceStore.startRun(); performanceStore.startRun();
try { try {
let a = performance.now(); let a = performance.now();
@@ -146,173 +117,76 @@
} }
viewerComponent?.update(graphResult); viewerComponent?.update(graphResult);
} catch (error) { } catch (error) {
const msg = error instanceof Error ? error.message : String(error); console.log('errors', error);
toast(`Execution failed: ${msg}`, 'error');
} finally { } finally {
clearTimeout(timeout);
isExecuting = false;
performanceStore.stopRun(); performanceStore.stopRun();
} }
} }
const handleUpdate = debounceAsyncFunction(update); const handleUpdate = debounceAsyncFunction(update);
function handleSettingsButton(id: string) { onMount(() => {
switch (id) { appSettings.value.debug.stressTest = {
case 'general.clippy': ...appSettings.value.debug.stressTest,
planty?.start(); loadGrid: () => {
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; },
case 'general.debug.stressTest.loadTree': loadTree: () => {
manager.load(templates.tree(appSettings.value.debug.stressTest.amount)); manager.load(templates.tree(appSettings.value.debug.stressTest.amount));
break; },
case 'general.debug.stressTest.lottaFaces': lottaFaces: () => {
manager.load(templates.lottaFaces as unknown as Graph); manager.load(templates.lottaFaces as unknown as Graph);
break; },
case 'general.debug.stressTest.lottaNodes': lottaNodes: () => {
manager.load(templates.lottaNodes as unknown as Graph); manager.load(templates.lottaNodes as unknown as Graph);
break; },
case 'general.debug.stressTest.lottaNodesAndFaces': 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>
<Grid.Cell> <Grid.Cell>
<div class="viewer-cell"> <Viewer
<Viewer bind:scene
bind:scene bind:this={viewerComponent}
bind:this={viewerComponent} perf={performanceStore}
perf={performanceStore} debugData={debugData}
debugData={debugData} centerCamera={appSettings.value.centerCamera}
centerCamera={appSettings.value.centerCamera} />
/>
{#if isExecuting}
<div class="viewer-spinner" aria-label="Executing graph">
<Spinner size={28} />
</div>
{/if}
</div>
</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 bind:showHelp={appSettings.value.nodeInterface.showHelp}
bind:showHelp={appSettings.value.nodeInterface.showHelp} bind:settings={graphSettings}
bind:settings={graphSettings} bind:settingTypes={graphSettingTypes}
bind:settingTypes={graphSettingTypes} onsave={(g) => pm.saveGraph(g)}
onsave={async (g) => { onresult={(result) => handleUpdate(result as Graph)}
pendingSave = true; />
await pm.saveGraph(g);
pendingSave = false;
}}
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}
/> />
@@ -332,7 +206,13 @@
<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>
<Panel
id="node-store"
title="Node Store"
icon="i-[tabler--database] bg-green-400"
>
<NodeStore registry={nodeRegistry} />
</Panel>
<Panel <Panel
id="performance" id="performance"
title="Performance" title="Performance"
@@ -352,9 +232,7 @@
hidden={!appSettings.value.debug.advancedMode} hidden={!appSettings.value.debug.advancedMode}
icon="i-[tabler--code]" icon="i-[tabler--code]"
> >
{#if manager?.status === 'idle'} <GraphSource graph={pm.graph ?? manager?.serialize()} />
<GraphSource graph={manager.serialize()} />
{/if}
</Panel> </Panel>
<Panel <Panel
id="benchmark" id="benchmark"
@@ -369,16 +247,12 @@
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"
@@ -392,30 +266,9 @@
</Grid.Row> </Grid.Row>
</div> </div>
<Toast />
<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 {
@@ -426,20 +279,6 @@
grid-template-rows: 0px 1fr; grid-template-rows: 0px 1fr;
} }
.viewer-cell {
position: relative;
height: 100%;
}
.viewer-spinner {
position: absolute;
bottom: 12px;
right: 12px;
color: var(--color-text, #cecece);
opacity: 0.6;
pointer-events: none;
}
.wrapper :global(canvas) { .wrapper :global(canvas) {
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
opacity: 1; opacity: 1;
+1 -1
View File
@@ -3,6 +3,6 @@
const { children } = $props<{ children?: Snippet }>(); const { children } = $props<{ children?: Snippet }>();
</script> </script>
<main class="w-screen overflow-x-hidden"> <main class="w-screen h-screen overflow-x-hidden">
{@render children()} {@render children()}
</main> </main>
+93 -12
View File
@@ -44,8 +44,9 @@
} }
} }
$effect(() => { let graphSettings = $state<Record<string, any>>({});
fetchNodeData(activeNode.value); let graphSettingTypes = $state({
randomSeed: { type: "boolean", value: false },
}); });
$effect(() => { $effect(() => {
@@ -61,19 +62,85 @@
}); });
</script> </script>
<div class="node-wrapper absolute bottom-8 left-8"> <svelte:window
{#if nodeInstance} bind:innerHeight={windowHeight}
<NodeHTML inView position="relative" z={5} bind:node={nodeInstance} /> onkeydown={(ev) => ev.key === "r" && handleResult()}
{/if} />
</div>
<Grid.Row> <Grid.Row>
<Grid.Cell> <Grid.Cell>
<pre> {#if visibleRows?.length}
<code> <table
{JSON.stringify(nodeInstance?.props)} class="min-w-full select-none overflow-auto text-left text-sm flex-1"
</code> onscroll={(e) => {
</pre> const scrollTop = e.currentTarget.scrollTop;
start.value = Math.floor(scrollTop / rowHeight);
}}
>
<thead class="">
<tr>
<th class="px-4 py-2 border-b border-[var(--outline)]">i</th>
<th
class="px-4 py-2 border-b border-[var(--outline)] w-[50px]"
style:width="50px">Ptrs</th
>
<th class="px-4 py-2 border-b border-[var(--outline)]">Value</th>
<th class="px-4 py-2 border-b border-[var(--outline)]">Float</th>
</tr>
</thead>
<tbody
onscroll={(e) => {
const scrollTop = e.currentTarget.scrollTop;
start.value = Math.floor(scrollTop / rowHeight);
}}
>
{#each visibleRows as r, i}
{@const index = i + start.value}
{@const ptr = ptrs[i]}
<tr class="h-[40px] odd:bg-[var(--layer-1)]">
<td class="px-4 border-b border-[var(--outline)] w-8">{index}</td>
<td
class="border-b border-[var(--outline)] overflow-hidden text-ellipsis pl-2
{ptr?._title?.includes('->')
? 'bg-red-500'
: 'bg-blue-500'}"
style="width: 100px; min-width: 100px; max-width: 100px;"
>
{ptr?._title}
</td>
<td
class="px-4 border-b border-[var(--outline)] cursor-pointer text-blue-600 hover:text-blue-800"
onclick={() =>
(rowIsFloat.value[index] = !rowIsFloat.value[index])}
>
{decodeValue(r, rowIsFloat.value[index])}
</td>
<td class="px-4 border-b border-[var(--outline)] italic w-5">
<input
type="checkbox"
checked={rowIsFloat.value[index]}
onclick={() =>
(rowIsFloat.value[index] = !rowIsFloat.value[index])}
/>
</td>
</tr>
{/each}
</tbody>
</table>
<button
onclick={() => copyVisibleMemory(visibleRows, ptrs, start.value)}
class="flex items-center cursor-pointer absolute bottom-4 left-4 z-100 bg-gray-200 px-2 py-1 rounded hover:bg-gray-300"
>
Copy Visible Memory
</button>
<input
class="absolute bottom-4 right-4 bg-white"
bind:value={start.value}
min="0"
type="number"
step="1"
/>
{/if}
</Grid.Cell> </Grid.Cell>
<Grid.Cell> <Grid.Cell>
@@ -82,6 +149,20 @@
</Grid.Row> </Grid.Row>
<Sidebar> <Sidebar>
<Panel id="general" title="General" icon="i-[tabler--settings]">
<h3 class="p-4 pb-0">Debug Settings</h3>
<NestedSettings
id="Debug"
bind:value={devSettings.value}
type={DevSettingsType}
/>
<hr />
<NestedSettings
id="general"
bind:value={appSettings.value}
type={AppSettingTypes}
/>
</Panel>
<Panel <Panel
id="node-store" id="node-store"
classes="text-green-400" classes="text-green-400"
+74
View File
@@ -0,0 +1,74 @@
{
"settings": {
"resolution.circle": 26,
"resolution.curve": 39
},
"nodes": [
{
"id": 9,
"position": [
225,
65
],
"type": "max/plantarium/output",
"props": {
"out": 0
}
},
{
"id": 10,
"position": [
200,
60
],
"type": "max/plantarium/math",
"props": {
"op_type": 3,
"a": 2,
"b": 0.38
}
},
{
"id": 11,
"position": [
175,
60
],
"type": "max/plantarium/float",
"props": {
"value": 0.66
}
},
{
"id": 12,
"position": [
175,
80
],
"type": "max/plantarium/float",
"props": {
"value": 1
}
}
],
"edges": [
[
11,
0,
10,
"a"
],
[
12,
0,
10,
"b"
],
[
10,
0,
9,
"out"
]
]
}
+48
View File
@@ -0,0 +1,48 @@
import type { Pointer } from '$lib/runtime';
export function copyVisibleMemory(rows: Int32Array, currentPtrs: Pointer[], start: number) {
if (!rows?.length) return;
// Build an array of rows for the table
const tableRows = [...rows].map((value, i) => {
const index = start + i;
const ptr = currentPtrs[i];
return {
index,
ptr: ptr?._title ?? '',
value: value
};
});
// Compute column widths
const indexWidth = Math.max(
5,
...tableRows.map((r) => r.index.toString().length)
);
const ptrWidth = Math.max(
10,
...tableRows.map((r) => r.ptr.length)
);
const valueWidth = Math.max(
10,
...tableRows.map((r) => r.value.toString().length)
);
// Build header
let output =
`| ${'Index'.padEnd(indexWidth)} | ${'Ptr'.padEnd(ptrWidth)} | ${'Value'.padEnd(valueWidth)
} |\n`
+ `|-${'-'.repeat(indexWidth)}-|-${'-'.repeat(ptrWidth)}-|-${'-'.repeat(valueWidth)}-|\n`;
// Add rows
for (const row of tableRows) {
output += `| ${row.index.toString().padEnd(indexWidth)} | ${row.ptr.padEnd(ptrWidth)} | ${row.value.toString().padEnd(valueWidth)
} |\n`;
}
// Copy to clipboard
navigator.clipboard
.writeText(output)
.then(() => console.log('Memory + metadata copied as table'))
.catch((err) => console.error('Failed to copy memory:', err));
}
+15
View File
@@ -0,0 +1,15 @@
import { localState } from '$lib/helpers/localState.svelte';
import { settingsToStore } from '$lib/settings/app-settings.svelte';
export const DevSettingsType = {
debugNode: {
type: 'boolean',
label: 'Debug Nodes',
value: true
}
} as const;
export let devSettings = localState(
'dev-settings',
settingsToStore(DevSettingsType)
);
-6
View File
@@ -1,7 +1,6 @@
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';
@@ -20,11 +19,6 @@ export default defineConfig({
comlink() comlink()
] ]
}, },
resolve: {
alias: {
'@nodarium/planty': path.resolve(__dirname, '../packages/planty/src/lib/index.ts')
}
},
ssr: { ssr: {
noExternal: ['three'] noExternal: ['three']
}, },
+4 -5
View File
@@ -4,20 +4,19 @@ This guide will help you developing your first Nodarium Node written in Rust. As
## Prerequesites ## Prerequesites
You need to have [Rust](https://www.rust-lang.org/tools/install) and [wasm-pack](https://rustwasm.github.io/docs/wasm-pack/) installed. Rust is the language we are going to develop our node in and wasm-pack helps us compile our rust code into a webassembly file. You need to have [Rust](https://www.rust-lang.org/tools/install) installed. Rust is the language we are going to develop our node in and cargo compiles our rust code into webassembly.
```bash ```bash
# install rust # install rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# install wasm-pack
cargo install wasm-pack
``` ```
## Clone Template ## Clone Template
```bash ```bash
wasm-pack new my-new-node --template https://github.com/jim-fx/nodarium_template # copy the template directory
cd my-new-node cp -r nodes/max/plantarium/.template nodes/max/plantarium/my-new-node
cd nodes/max/plantarium/my-new-node
``` ```
## Setup Definition ## Setup Definition
-216
View File
@@ -1,216 +0,0 @@
# 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
```
-645
View File
@@ -1,645 +0,0 @@
# Comprehensive UX Practices for Web Applications
## Introduction
This document consolidates many of the most important practical UX principles for modern web applications.
---
# 1. Core UX Principles
## 1.1 Visibility of System Status
Users should always understand:
- What the system is doing
- Whether an action succeeded
- Whether work is still in progress
- Whether an error occurred
### Good Practices
- Show loading indicators immediately
- Show success confirmations after important actions
- Show inline validation messages
- Display progress for long-running tasks
- Use skeleton loading states instead of blank screens
- Prevent silent failures
- Avoid ambiguous UI states
### Bad Practices
- Buttons with no feedback after clicking
- Infinite spinners without explanation
- Hidden background operations
- Saving without visible confirmation
---
## 1.2 Predictability and Consistency
Users build mental models quickly.
Breaking established expectations increases cognitive load and causes mistakes.
### Good Practices
- Use consistent layouts
- Keep interaction patterns stable
- Reuse common UI conventions
- Keep naming and terminology consistent
- Use standard keyboard shortcuts
- Make similar components behave similarly
### Bad Practices
- Different button styles for identical actions
- Inconsistent navigation behavior
- Custom controls that ignore platform conventions
- Unexpected modal behavior
---
## 1.3 Recognition Over Recall
Interfaces should minimize memory requirements.
Users should recognize options instead of remembering information.
### Good Practices
- Show recent searches
- Use autocomplete
- Display contextual hints
- Preserve previously entered values
- Use visible labels
- Keep important actions visible
### Bad Practices
- Placeholder-only labels
- Hidden functionality
- Requiring users to remember previous state
- Removing useful context during workflows
---
## 1.4 Error Prevention
Preventing mistakes is better than handling mistakes.
### Good Practices
- Disable impossible actions
- Validate input early
- Warn before destructive operations
- Use constrained input formats
- Use safe defaults
- Prefer undo over confirmation dialogs
### Bad Practices
- Destructive actions near common actions
- Easy accidental deletion
- Poor validation timing
- Irreversible operations without recovery
---
# 2. Input and Form UX
Forms are one of the most important and failure-prone areas in web applications.
---
## 2.1 Input Focus Behavior
### Good Practices
- Autofocus the primary field when appropriate
- Preserve focus during rerenders
- Preserve cursor position
- Support keyboard-first workflows
- Use logical tab ordering
### Auto-Selecting Input Text
Auto-selecting text on focus is context-dependent.
### Good Use Cases
- Quantity fields
- Rename dialogs
- Editable defaults
- Quick replacement workflows
- Temporary values users often replace entirely
### Bad Use Cases
- Long textareas
- Complex text editing
- Fields users commonly partially edit
- Rich text editing
### Principle
Only auto-select when full replacement is more likely than partial editing.
---
## 2.2 Labels and Placeholders
### Good Practices
- Always use visible labels
- Use placeholders only as supplementary examples
- Keep labels visible after typing
- Associate labels correctly for accessibility
### Bad Practices
- Placeholder-only forms
- Ambiguous labels
- Labels that disappear during editing
---
## 2.3 Validation
### Recommended Validation Timing
| Validation Type | Timing |
| ------------------- | --------- |
| Format validation | Immediate |
| Semantic validation | On blur |
| Server validation | On submit |
### Good Practices
- Show errors near the relevant field
- Explain how to fix issues
- Preserve entered values after errors
- Validate incrementally
- Use clear language
### Bad Practices
- Generic “Invalid input” messages
- Clearing form data after errors
- Delayed validation surprises
- Validation that interrupts typing
---
## 2.4 Input Types
### Good Practices
Use appropriate HTML input types:
- `email`
- `tel`
- `number`
- `date`
- `password`
- `search`
### Benefits
- Better mobile keyboards
- Native validation
- Improved accessibility
- Better autofill support
---
## 2.5 Form Submission
### Good Practices
- Enter submits forms when expected
- Escape cancels dialogs
- Show loading states during submission
- Prevent duplicate submissions
- Preserve draft state
- Allow keyboard submission
### Bad Practices
- Disabled submit buttons without explanation
- Hidden validation failures
- Silent submission failures
---
## 2.6 Dropdowns and Selection UX
### Good Practices
- Use radio buttons for small option sets
- Use searchable selects for large datasets
- Prefer autocomplete for many options
- Show selected state clearly
### Bad Practices
- Massive unsearchable dropdowns
- Nested dropdown hierarchies
- Multi-select controls without search
---
# 3. Navigation UX
---
## 3.1 Orientation
Users should always know:
- Where they are
- How they got there
- What they can do next
- How to go back
### Good Practices
- Highlight active navigation
- Use breadcrumbs when helpful
- Use meaningful page titles
- Preserve navigation consistency
---
## 3.2 Navigation Structure
### Good Practices
- Keep hierarchy shallow
- Group related actions
- Use descriptive names
- Keep primary actions stable
### Bad Practices
- Deep nesting
- Ambiguous navigation labels
- Constantly moving actions
---
## 3.3 URL Design
### Good Practices
- Use readable URLs
- Make URLs shareable
- Preserve app state in URLs when useful
- Support browser history correctly
### Bad Practices
- Opaque generated URLs
- Broken back button behavior
- Losing state during navigation
---
# 4. Interaction Design
---
## 4.1 Click Targets
### Good Practices
- Large clickable areas
- Adequate spacing between actions
- Clear hover/focus states
- Touch-friendly sizing
### Bad Practices
- Tiny clickable regions
- Overlapping interactive elements
- Hidden hit areas
---
## 4.2 Feedback
Every interaction should produce feedback.
### Good Practices
- Hover states
- Active states
- Loading indicators
- Success states
- Error states
- Optimistic updates when appropriate
### Bad Practices
- Dead-feeling interfaces
- Invisible processing
- Delayed reactions
---
## 4.3 Destructive Actions
### Good Practices
- Require confirmation for dangerous actions
- Prefer undo systems
- Visually distinguish destructive buttons
- Separate destructive actions spatially
### Bad Practices
- Immediate irreversible deletion
- Dangerous actions near common actions
- Ambiguous destructive wording
---
## 4.4 Modal UX
### Good Practices
- Trap keyboard focus
- Support Escape to close
- Restore focus after closing
- Prevent background interaction
- Keep modal purpose focused
### Bad Practices
- Nested modals
- Full workflows inside modals
- Losing unsaved work accidentally
---
# 5. Performance UX
Performance is a UX feature.
Users interpret slowness as unreliability.
---
## 5.1 Perceived Performance
### Good Practices
- Show immediate visual response
- Use optimistic UI updates
- Preload likely next content
- Stream content progressively
- Use skeleton loaders
### Bad Practices
- Blank screens during loading
- Long blocking operations
- Frozen interfaces
---
## 5.2 Layout Stability
### Good Practices
- Prevent layout shift
- Reserve image dimensions
- Avoid moving buttons during loading
- Keep skeletons aligned with final layout
### Bad Practices
- Jumping content
- Shifting controls
- Reflow-heavy rendering
---
## 5.3 Responsiveness
### Good Practices
- Keep UI interactive during async operations
- Avoid blocking the main thread
- Debounce expensive operations
- Virtualize large lists
### Bad Practices
- UI freezes
- Excessive rerenders
- Laggy typing experiences
---
# 6. Accessibility
Accessibility improves usability for everyone.
---
## 6.1 Keyboard Accessibility
### Good Practices
- Full keyboard navigation
- Visible focus indicators
- Logical tab order
- Keyboard shortcuts for power users
### Bad Practices
- Mouse-only workflows
- Hidden focus state
- Keyboard traps
---
## 6.2 Semantic HTML
### Good Practices
- Use proper semantic elements
- Use buttons for actions
- Use links for navigation
- Use headings correctly
### Bad Practices
- Clickable divs without accessibility support
- Fake buttons
- Missing semantic structure
---
## 6.3 Visual Accessibility
### Good Practices
- Sufficient color contrast
- Support reduced motion
- Avoid color-only communication
- Use scalable typography
### Bad Practices
- Tiny text
- Low contrast interfaces
- Flashing animations
---
## 6.4 Screen Reader Support
### Good Practices
- Proper labels
- Meaningful alt text
- ARIA only when necessary
- Correct live regions for updates
### Bad Practices
- Unlabeled controls
- Excessive ARIA misuse
- Non-announced state changes
---
# 7. Enterprise Application UX
Enterprise UX differs significantly from marketing-oriented consumer interfaces.
Power users often prioritize efficiency over visual minimalism.
---
## 7.1 Dense Information Design
### Good Practices
- Efficient data density
- Resizable tables
- Sticky headers
- Multi-column layouts
- High information throughput
### Bad Practices
- Excessive whitespace
- Oversimplified dashboards
- Hidden operational controls
---
## 7.2 Table UX
### Good Practices
- Sorting
- Filtering
- Column resizing
- Pagination or virtualization
- Keyboard navigation
- Export functionality
- Persistent user preferences
### Bad Practices
- Non-sortable enterprise tables
- Horizontal scrolling nightmares
- Missing filtering
---
## 7.3 Power User Workflows
### Good Practices
- Keyboard shortcuts
- Bulk actions
- Batch editing
- Command palettes
- State persistence
- Fast navigation
### Bad Practices
- Forced wizard workflows
- Excessive confirmations
- Repetitive manual work
---
# 8. Mobile UX
---
## 8.1 Touch Design
### Good Practices
- Large touch targets
- Thumb-friendly layouts
- Avoid hover dependencies
- Mobile-friendly spacing
### Bad Practices
- Tiny controls
- Hover-only interactions
- Precision-dependent gestures
---
## 8.2 Mobile Forms
### Good Practices
- Mobile keyboard optimization
- Minimal typing
- Autofill support
- Step-by-step flows when necessary
### Bad Practices
- Long complex forms
- Tiny input fields
- Excessive required typing
---
# 9. Cognitive Psychology and UX
---
## 9.1 Hicks Law
More choices increase decision time.
### Applications
- Reduce unnecessary options
- Group related actions
- Prioritize primary actions
---
## 9.2 Fittss Law
Closer and larger targets are easier to use.
### Applications
- Large primary buttons
- Edge/corner placement for important actions
+5 -9
View File
@@ -1,20 +1,18 @@
use nodarium_macros::nodarium_definition_file; use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute; use nodarium_macros::nodarium_execute;
use nodarium_utils::{ use nodarium_utils::{
encode_float, evaluate_float, geometry::calculate_normals,log, encode_float, evaluate_float, geometry::calculate_normals, wrap_arg,
split_args, wrap_arg, read_i32_slice
}; };
nodarium_definition_file!("src/input.json"); nodarium_definition_file!("src/input.json");
#[nodarium_execute] #[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> { pub fn execute(size: (i32, i32)) -> Vec<i32> {
let args = split_args(input); let args = read_i32_slice(size);
log!("WASM(cube): input: {:?} -> {:?}", input, args); let size = evaluate_float(&args);
let size = evaluate_float(args[0]);
let p = encode_float(size); let p = encode_float(size);
let n = encode_float(-size); let n = encode_float(-size);
@@ -77,8 +75,6 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
let res = wrap_arg(&cube_geometry); let res = wrap_arg(&cube_geometry);
log!("WASM(box): output: {:?}", res);
res res
} }
@@ -83,14 +83,6 @@
"min": 0, "min": 0,
"max": 360, "max": 360,
"step": 0.01, "step": 0.01,
"value": 137.5
},
"angle": {
"type": "float",
"description": "Upward tilt of branches. 0 = horizontal, positive = upward, negative = drooping.",
"min": -90,
"max": 90,
"step": 1,
"value": 0 "value": 0
} }
} }
+26 -16
View File
@@ -1,5 +1,6 @@
use nodarium_macros::nodarium_definition_file; use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute; use nodarium_macros::nodarium_execute;
use nodarium_utils::read_i32_slice;
use nodarium_utils::{ use nodarium_utils::{
concat_arg_vecs, evaluate_float, evaluate_int, concat_arg_vecs, evaluate_float, evaluate_int,
geometry::{ geometry::{
@@ -13,15 +14,25 @@ use std::f32::consts::PI;
nodarium_definition_file!("src/input.json"); nodarium_definition_file!("src/input.json");
#[nodarium_execute] #[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> { pub fn execute(
let args = split_args(input); path: (i32, i32),
length: (i32, i32),
let paths = split_args(args[0]); thickness: (i32, i32),
offset_single: (i32, i32),
lowest_branch: (i32, i32),
highest_branch: (i32, i32),
depth: (i32, i32),
amount: (i32, i32),
resolution_curve: (i32, i32),
rotation: (i32, i32),
) -> Vec<i32> {
let arg = read_i32_slice(path);
let paths = split_args(arg.as_slice());
let mut output: Vec<Vec<i32>> = Vec::new(); let mut output: Vec<Vec<i32>> = Vec::new();
let resolution = evaluate_int(args[8]).max(4) as usize; let resolution = evaluate_int(read_i32_slice(resolution_curve).as_slice()).max(4) as usize;
let depth = evaluate_int(args[6]); let depth = evaluate_int(read_i32_slice(depth).as_slice());
let mut max_depth = 0; let mut max_depth = 0;
for path_data in paths.iter() { for path_data in paths.iter() {
@@ -40,18 +51,18 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
let path = wrap_path(path_data); let path = wrap_path(path_data);
let branch_amount = evaluate_int(args[7]).max(1); let branch_amount = evaluate_int(read_i32_slice(amount).as_slice()).max(1);
let lowest_branch = evaluate_float(args[4]); let lowest_branch = evaluate_float(read_i32_slice(lowest_branch).as_slice());
let highest_branch = evaluate_float(args[5]); let highest_branch = evaluate_float(read_i32_slice(highest_branch).as_slice());
for i in 0..branch_amount { for i in 0..branch_amount {
let a = i as f32 / (branch_amount - 1).max(1) as f32; let a = i as f32 / (branch_amount - 1).max(1) as f32;
let length = evaluate_float(args[1]); let length = evaluate_float(read_i32_slice(length).as_slice());
let thickness = evaluate_float(args[2]); let thickness = evaluate_float(read_i32_slice(thickness).as_slice());
let offset_single = if i % 2 == 0 { let offset_single = if i % 2 == 0 {
evaluate_float(args[3]) evaluate_float(read_i32_slice(offset_single).as_slice())
} else { } else {
0.0 0.0
}; };
@@ -65,7 +76,8 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
root_alpha + (offset_single - 0.5) * 6.0 / resolution as f32, root_alpha + (offset_single - 0.5) * 6.0 / resolution as f32,
); );
let rotation_angle = (evaluate_float(args[9]) * PI / 180.0) * i as f32; let rotation_angle =
(evaluate_float(read_i32_slice(rotation).as_slice()) * PI / 180.0) * i as f32;
// check if diration contains NaN // check if diration contains NaN
if orthogonal[0].is_nan() || orthogonal[1].is_nan() || orthogonal[2].is_nan() { if orthogonal[0].is_nan() || orthogonal[1].is_nan() || orthogonal[2].is_nan() {
@@ -78,9 +90,7 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
continue; continue;
} }
let up_angle = evaluate_float(args[10]) * PI / 180.0; let branch_direction = rotate_vector_by_angle(orthogonal, direction, rotation_angle);
let tilted = (orthogonal * up_angle.cos() + direction * up_angle.sin()).normalize();
let branch_direction = rotate_vector_by_angle(tilted, direction, rotation_angle);
log!( log!(
"BRANCH depth: {}, branch_origin: {:?}, direction_at: {:?}, branch_direction: {:?}", "BRANCH depth: {}, branch_origin: {:?}, direction_at: {:?}, branch_direction: {:?}",
+6
View File
@@ -0,0 +1,6 @@
/target
**/*.rs.bk
Cargo.lock
bin/
pkg/
wasm-pack.log
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "debug"
version = "0.1.0"
authors = ["Max Richter <jim-x@web.de>"]
edition = "2018"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nodarium_macros = { version = "0.1.0", path = "../../../../packages/macros" }
nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" }
+22
View File
@@ -0,0 +1,22 @@
{
"id": "max/plantarium/debug",
"outputs": [],
"inputs": {
"input": {
"type": "float",
"accepts": [
"*"
],
"external": true
},
"type": {
"type": "select",
"options": [
"float",
"vec3",
"geometry"
],
"internal": true
}
}
}
+25
View File
@@ -0,0 +1,25 @@
use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute;
use nodarium_utils::encode_float;
use nodarium_utils::evaluate_float;
use nodarium_utils::evaluate_vec3;
use nodarium_utils::read_i32;
use nodarium_utils::read_i32_slice;
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(input: (i32, i32), input_type: (i32, i32)) -> Vec<i32> {
let inp = read_i32_slice(input);
let t = read_i32(input_type.0);
if t == 0 {
let f = evaluate_float(inp.as_slice());
return vec![encode_float(f)];
}
if t == 1 {
let f = evaluate_vec3(inp.as_slice());
return vec![encode_float(f[0]), encode_float(f[1]), encode_float(f[2])];
}
return inp;
}
+4 -1
View File
@@ -2,11 +2,14 @@
name = "float" name = "float"
version = "0.1.0" version = "0.1.0"
authors = ["Max Richter <jim-x@web.de>"] authors = ["Max Richter <jim-x@web.de>"]
edition = "2018" edition = "2021"
[lib] [lib]
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[profile.dev]
panic = "unwind"
[dependencies] [dependencies]
nodarium_macros = { version = "0.1.0", path = "../../../../packages/macros" } nodarium_macros = { version = "0.1.0", path = "../../../../packages/macros" }
nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" } nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" }
+3 -2
View File
@@ -1,9 +1,10 @@
use nodarium_macros::nodarium_definition_file; use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute; use nodarium_macros::nodarium_execute;
use nodarium_utils::read_i32;
nodarium_definition_file!("src/input.json"); nodarium_definition_file!("src/input.json");
#[nodarium_execute] #[nodarium_execute]
pub fn execute(args: &[i32]) -> Vec<i32> { pub fn execute(a: (i32, i32)) -> Vec<i32> {
args.into() vec![read_i32(a.0)]
} }
+7 -16
View File
@@ -13,28 +13,19 @@
"max": 1, "max": 1,
"value": 1 "value": 1
}, },
"curviness": {
"type": "float",
"hidden": true,
"min": 0,
"max": 1,
"value": 0.5
},
"depth": { "depth": {
"type": "integer", "type": "integer",
"min": 1, "min": 1,
"max": 10, "max": 10,
"hidden": true, "hidden": true,
"value": 1 "value": 1
},
"elasticity": {
"type": "float",
"description": "How rigid the stem is. 0 = rope (uniform droop), 1 = stiff rod (only the tip bends).",
"min": 0,
"max": 1,
"step": 0.05,
"value": 0.3
},
"mode": {
"type": "select",
"internal": true,
"label": "Mode",
"options": ["closed-form", "chain"],
"hidden": true,
"description": "closed-form lerps each segment toward gravity; chain is a forward-kinematic cantilever where each segment rotates by an angle that grows along the stem."
} }
} }
} }
+40 -113
View File
@@ -1,6 +1,7 @@
use glam::Vec3; use glam::Vec3;
use nodarium_macros::nodarium_definition_file; use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute; use nodarium_macros::nodarium_execute;
use nodarium_utils::read_i32_slice;
use nodarium_utils::{ use nodarium_utils::{
concat_args, evaluate_float, evaluate_int, concat_args, evaluate_float, evaluate_int,
geometry::{wrap_path, wrap_path_mut}, geometry::{wrap_path, wrap_path_mut},
@@ -14,17 +15,17 @@ fn lerp_vec3(a: Vec3, b: Vec3, t: f32) -> Vec3 {
} }
#[nodarium_execute] #[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> { pub fn execute(
plant: (i32, i32),
strength: (i32, i32),
curviness: (i32, i32),
depth: (i32, i32),
) -> Vec<i32> {
reset_call_count(); reset_call_count();
let args = split_args(input); let arg = read_i32_slice(plant);
let plants = split_args(arg.as_slice());
let plants = split_args(args[0]); let depth = evaluate_int(read_i32_slice(depth).as_slice());
let depth = evaluate_int(args[2]);
let elasticity = evaluate_float(args[3]).clamp(0.0, 1.0);
let mode = evaluate_int(args[4]); // 0 = closed-form, 1 = verlet
// 0 → sqrt (rope), 1 → ~4.5 (only the tip droops)
let bend_exponent = 0.5 + elasticity * 4.0;
let mut max_depth = 0; let mut max_depth = 0;
for path_data in plants.iter() { for path_data in plants.iter() {
@@ -46,124 +47,50 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
let mut output_data = path_data.clone(); let mut output_data = path_data.clone();
let output = wrap_path_mut(&mut output_data); let output = wrap_path_mut(&mut output_data);
if mode == 1 { let mut offset_vec = Vec3::ZERO;
// Forward-kinematic cantilever chain. Each segment rotates around
// an axis perpendicular to (rest_dir, gravity) by an angle that
// grows with alpha along the stem. Positions are built from the
// anchored base outward, so segment lengths are preserved by
// construction (no iteration, no rescaling, no oscillation).
let raw_strength = evaluate_float(args[1]); for i in 0..path.length - 1 {
let gravity_dir = Vec3::new(0.0, -1.0, 0.0); let alpha = i as f32 / (path.length - 1) as f32;
let start_index = i * 4;
// Tip bend angle in radians. PI/2 = horizontal tip at strength=1. let start_point = Vec3::from_slice(&path.points[start_index..start_index + 3]);
let max_angle = raw_strength * std::f32::consts::FRAC_PI_2; let end_point = Vec3::from_slice(&path.points[start_index + 4..start_index + 7]);
let original: Vec<Vec3> = (0..path.length) let direction = end_point - start_point;
.map(|i| {
let s = i * 4;
Vec3::from_slice(&path.points[s..s + 3])
})
.collect();
let seg_lens: Vec<f32> = (0..path.length - 1) let length = direction.length();
.map(|i| (original[i + 1] - original[i]).length())
.collect();
let rest_dirs: Vec<Vec3> = (0..path.length - 1)
.map(|i| {
let d = original[i + 1] - original[i];
let l = d.length();
if l > 0.0001 { d / l } else { Vec3::Y }
})
.collect();
let mut cur = vec![Vec3::ZERO; path.length]; let str = evaluate_float(read_i32_slice(strength).as_slice());
cur[0] = original[0]; let curviness = evaluate_float(read_i32_slice(curviness).as_slice());
let strength = str / curviness.max(0.0001) * str;
for i in 1..path.length { log!(
let seg_idx = i - 1; "length: {}, curviness: {}, strength: {}",
let alpha = if path.length > 2 { length,
seg_idx as f32 / (path.length - 2) as f32 curviness,
} else { strength
1.0 );
};
let bend_angle = max_angle * alpha.powf(bend_exponent);
let rest_dir = rest_dirs[seg_idx]; let down_point = Vec3::new(0.0, -length * strength, 0.0);
let mut bend_axis = rest_dir.cross(gravity_dir);
let axis_len = bend_axis.length();
bend_axis = if axis_len > 0.0001 {
bend_axis / axis_len
} else {
// rest_dir parallel to gravity — pick an arbitrary
// perpendicular axis to break symmetry.
Vec3::X
};
// Rodrigues' rotation formula let mut mid_point = lerp_vec3(direction, down_point, curviness * alpha.sqrt());
let (sin_a, cos_a) = bend_angle.sin_cos();
let bent_dir = rest_dir * cos_a
+ bend_axis.cross(rest_dir) * sin_a
+ bend_axis * bend_axis.dot(rest_dir) * (1.0 - cos_a);
cur[i] = cur[i - 1] + bent_dir * seg_lens[seg_idx]; if mid_point[0] == 0.0 && mid_point[2] == 0.0 {
mid_point[0] += 0.0001;
mid_point[2] += 0.0001;
} }
for i in 0..path.length { // Correct midpoint length
let s = i * 4; mid_point *= length / mid_point.length();
output.points[s] = cur[i].x;
output.points[s + 1] = cur[i].y;
output.points[s + 2] = cur[i].z;
}
} else {
// Closed-form: per-segment lerp toward a downward vector
let mut offset_vec = Vec3::ZERO;
for i in 0..path.length - 1 { let final_end_point = start_point + mid_point;
let alpha = i as f32 / (path.length - 1) as f32; let offset_end_point = end_point + offset_vec;
let start_index = i * 4;
let start_point = Vec3::from_slice(&path.points[start_index..start_index + 3]); output.points[start_index + 4] = offset_end_point[0];
let end_point = output.points[start_index + 5] = offset_end_point[1];
Vec3::from_slice(&path.points[start_index + 4..start_index + 7]); output.points[start_index + 6] = offset_end_point[2];
let direction = end_point - start_point; offset_vec += final_end_point - end_point;
let length = direction.length();
let curviness = elasticity.max(0.0001);
let strength_arg = evaluate_float(args[1]) * 10.0;
let strength = strength_arg / curviness * strength_arg;
log!(
"length: {}, curviness: {}, strength: {}",
length,
curviness,
strength
);
let down_point = Vec3::new(0.0, -length * strength, 0.0);
let mut mid_point =
lerp_vec3(direction, down_point, curviness * alpha.powf(bend_exponent));
if mid_point[0] == 0.0 && mid_point[2] == 0.0 {
mid_point[0] += 0.0001;
mid_point[2] += 0.0001;
}
// Correct midpoint length
mid_point *= length / mid_point.length();
let final_end_point = start_point + mid_point;
let offset_end_point = end_point + offset_vec;
output.points[start_index + 4] = offset_end_point[0];
output.points[start_index + 5] = offset_end_point[1];
output.points[start_index + 6] = offset_end_point[2];
offset_vec += final_end_point - end_point;
}
} }
output_data output_data
}) })
+1 -1
View File
@@ -13,7 +13,7 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
let args = split_args(input); let args = split_args(input);
let mut inputs = split_args(args[0]); let mut inputs = split_args(args[0]);
let mut geo_data = args[1].to_vec(); let mut geo_data = read_i32_slice(geometry);
let geo = wrap_geometry_data(&mut geo_data); let geo = wrap_geometry_data(&mut geo_data);
let mut transforms: Vec<Mat4> = Vec::new(); let mut transforms: Vec<Mat4> = Vec::new();
-1
View File
@@ -8,6 +8,5 @@ edition = "2018"
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
glam = "0.30.10"
nodarium_macros = { version = "0.1.0", path = "../../../../packages/macros" } nodarium_macros = { version = "0.1.0", path = "../../../../packages/macros" }
nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" } nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" }
-27
View File
@@ -19,33 +19,6 @@
"max": 64, "max": 64,
"value": 1, "value": 1,
"hidden": true "hidden": true
},
"yCurve": {
"type": "float",
"description": "Curl the leaf upward along its length (radians). 0 = flat, ~1.57 = 90° tip curl.",
"min": -3.14,
"max": 3.14,
"step": 0.05,
"value": 0,
"hidden": true
},
"yTwist": {
"type": "float",
"description": "Twist around the leaf's spine. Combined with yCurve, produces a 3D spiral.",
"min": -6.28,
"max": 6.28,
"step": 0.05,
"value": 0,
"hidden": true
},
"xCurve": {
"type": "float",
"description": "Curl each cross-section into an arc, mirrored around the midrib. 0 = flat, ~1.57 = U-shape.",
"min": -3.14,
"max": 3.14,
"step": 0.05,
"value": 0,
"hidden": true
} }
} }
} }
+9 -83
View File
@@ -1,7 +1,6 @@
use std::convert::TryInto; use std::convert::TryInto;
use std::f32::consts::PI; use std::f32::consts::PI;
use glam::Vec3;
use nodarium_macros::nodarium_definition_file; use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute; use nodarium_macros::nodarium_execute;
use nodarium_utils::encode_float; use nodarium_utils::encode_float;
@@ -43,9 +42,6 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
let input_path = split_args(args[0])[0]; let input_path = split_args(args[0])[0];
let size = evaluate_float(args[1]); let size = evaluate_float(args[1]);
let width_resolution = evaluate_int(args[2]).max(3) as usize; let width_resolution = evaluate_int(args[2]).max(3) as usize;
let y_curve = evaluate_float(args[3]);
let y_twist = evaluate_float(args[4]);
let x_curve = evaluate_float(args[5]);
let path_length = (input_path.len() - 4) / 2; let path_length = (input_path.len() - 4) / 2;
let slice_count = path_length; let slice_count = path_length;
@@ -97,97 +93,27 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
// Writing Positions // Writing Positions
let width = 50.0; let width = 50.0;
let leaf_length: f32 = 100.0;
let mut positions = vec![[0.0f32; 3]; position_amount]; let mut positions = vec![[0.0f32; 3]; position_amount];
// Pre-compute a local frame (center, normal=local-Y, binormal=local-X) for
// each slice by walking the FK chain. At each step we bend around the
// current binormal (curls the leaf) and twist around the current tangent
// (rotates the bend plane → spiral).
let segs = (slice_count - 1).max(1) as f32;
let bend_per_step = y_curve / segs;
let twist_per_step = y_twist / segs;
let mut centers: Vec<Vec3> = Vec::with_capacity(slice_count);
let mut frame_n: Vec<Vec3> = Vec::with_capacity(slice_count);
let mut frame_b: Vec<Vec3> = Vec::with_capacity(slice_count);
let mut tangent = Vec3::new(0.0, 0.0, 1.0);
let mut normal = Vec3::new(0.0, 1.0, 0.0);
let mut binormal = Vec3::new(1.0, 0.0, 0.0);
let pz_first = decode_float(input_path[2 + 1]);
let mut center = Vec3::new(0.0, 0.0, pz_first - leaf_length);
for i in 0..slice_count { for i in 0..slice_count {
centers.push(center); let ax = i as f32 / (slice_count -1) as f32;
frame_n.push(normal);
frame_b.push(binormal);
if i + 1 < slice_count {
let pz_curr = decode_float(input_path[2 + i * 2 + 1]);
let pz_next = decode_float(input_path[2 + (i + 1) * 2 + 1]);
let seg_len = pz_next - pz_curr;
center = center + tangent * seg_len;
// Bend around binormal — tilts tangent toward normal
let (sin_b, cos_b) = bend_per_step.sin_cos();
let new_t = tangent * cos_b + normal * sin_b;
let new_n = -tangent * sin_b + normal * cos_b;
tangent = new_t;
normal = new_n;
// Twist around tangent — rotates normal/binormal so the next bend
// happens in a rotated plane
let (sin_tw, cos_tw) = twist_per_step.sin_cos();
let new_n2 = normal * cos_tw + binormal * sin_tw;
let new_b = -normal * sin_tw + binormal * cos_tw;
normal = new_n2;
binormal = new_b;
}
}
for i in 0..slice_count {
let ax = i as f32 / segs;
let px = decode_float(input_path[2 + i * 2 + 0]); let px = decode_float(input_path[2 + i * 2 + 0]);
let hw = width - px; // half-width at this slice let pz = decode_float(input_path[2 + i * 2 + 1]);
let c = centers[i];
let n = frame_n[i];
let b = frame_b[i];
for j in 0..width_resolution { for j in 0..width_resolution {
let alpha = j as f32 / (width_resolution - 1) as f32; let alpha = j as f32 / (width_resolution - 1) as f32;
// Signed cross-section parameter, -1 (left edge) → +1 (right edge) let x = 2.0 * (-px * (alpha - 0.5) + alpha * width);
let t = 2.0 * alpha - 1.0; let py = calculate_y(alpha-0.5)*5.0*(ax*PI).sin();
let py_local = calculate_y(alpha - 0.5) * 5.0 * (ax * PI).sin(); let pz_val = pz - 100.0;
// X-curl: each cross-section traces a circular arc with curvature
// x_curve / hw. Because theta = x_curve * t is signed around the
// midrib, sin/cos give a mirrored arc (left and right edges curl
// the same direction).
let theta = x_curve * t;
let (sin_t, cos_t) = theta.sin_cos();
let (b_arc, n_arc) = if x_curve.abs() < 0.0001 {
(t * hw, 0.0)
} else {
let r = hw / x_curve;
(r * sin_t, r * (1.0 - cos_t))
};
// Cross-section bulge follows the rotated local frame
let b_total = b_arc - py_local * sin_t;
let n_total = n_arc + py_local * cos_t;
let world = c + b * b_total + n * n_total;
let pos_idx = i * width_resolution + j; let pos_idx = i * width_resolution + j;
positions[pos_idx] = [world.x, world.y, world.z]; positions[pos_idx] = [x - width, py, pz_val];
let flat_idx = offset + pos_idx * 3; let flat_idx = offset + pos_idx * 3;
out[flat_idx + 0] = encode_float(world.x * size); out[flat_idx + 0] = encode_float((x - width) * size);
out[flat_idx + 1] = encode_float(world.y * size); out[flat_idx + 1] = encode_float(py * size);
out[flat_idx + 2] = encode_float(world.z * size); out[flat_idx + 2] = encode_float(pz_val * size);
} }
} }
+11 -9
View File
@@ -1,13 +1,15 @@
use nodarium_macros::nodarium_definition_file; use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute; use nodarium_macros::nodarium_execute;
use nodarium_utils::{ use nodarium_utils::log;
concat_args, split_args use nodarium_utils::{concat_arg_vecs, read_i32_slice};
};
#[nodarium_execute]
pub fn execute(args: &[i32]) -> Vec<i32> {
let args = split_args(args);
concat_args(vec![&[0], args[0], args[1], args[2]])
}
nodarium_definition_file!("src/input.json"); nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(op_type: (i32, i32), a: (i32, i32), b: (i32, i32)) -> Vec<i32> {
log!("math.op {:?}", op_type);
let op = read_i32_slice(op_type);
let a_val = read_i32_slice(a);
let b_val = read_i32_slice(b);
concat_arg_vecs(vec![vec![0], op, a_val, b_val])
}
+1 -2
View File
@@ -2,13 +2,12 @@
name = "noise" name = "noise"
version = "0.1.0" version = "0.1.0"
authors = ["Max Richter <jim-x@web.de>"] authors = ["Max Richter <jim-x@web.de>"]
edition = "2018" edition = "2021"
[lib] [lib]
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
glam = "0.30.10"
nodarium_macros = { version = "0.1.0", path = "../../../../packages/macros" } nodarium_macros = { version = "0.1.0", path = "../../../../packages/macros" }
nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" } nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" }
noise = "0.9.0" noise = "0.9.0"
+3 -11
View File
@@ -10,14 +10,12 @@
"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, "min": 0.1,
"max": 1, "max": 10
"value": 0.5
}, },
"fixBottom": { "fixBottom": {
"type": "float", "type": "float",
@@ -52,12 +50,6 @@
"max": 5, "max": 5,
"value": 1, "value": 1,
"hidden": true "hidden": true
},
"preserveLength": {
"type": "boolean",
"label": "Preserve length",
"value": true,
"hidden": true
} }
} }
} }
+32 -83
View File
@@ -1,8 +1,8 @@
use glam::Vec3;
use nodarium_macros::nodarium_definition_file; use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute; use nodarium_macros::nodarium_execute;
use nodarium_utils::read_i32_slice;
use nodarium_utils::{ use nodarium_utils::{
concat_args, evaluate_float, evaluate_int, evaluate_vec3, geometry::wrap_path_mut, concat_args, evaluate_float, evaluate_int, evaluate_vec3, geometry::wrap_path_mut, read_i32,
reset_call_count, split_args, reset_call_count, split_args,
}; };
use noise::{HybridMulti, MultiFractal, NoiseFn, OpenSimplex}; use noise::{HybridMulti, MultiFractal, NoiseFn, OpenSimplex};
@@ -14,24 +14,31 @@ fn lerp(a: f32, b: f32, t: f32) -> f32 {
} }
#[nodarium_execute] #[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> { pub fn execute(
plant: (i32, i32),
scale: (i32, i32),
strength: (i32, i32),
fix_bottom: (i32, i32),
seed: (i32, i32),
directional_strength: (i32, i32),
depth: (i32, i32),
octaves: (i32, i32),
) -> Vec<i32> {
reset_call_count(); reset_call_count();
let args = split_args(input); let arg = read_i32_slice(plant);
let plants = split_args(arg.as_slice());
let scale = (evaluate_float(read_i32_slice(scale).as_slice()) * 0.1) as f64;
let strength = evaluate_float(read_i32_slice(strength).as_slice());
let fix_bottom = evaluate_float(read_i32_slice(fix_bottom).as_slice());
let plants = split_args(args[0]); let seed = read_i32(seed.0);
let scale = (evaluate_float(args[1]) * 0.1) as f64;
let strength = evaluate_float(args[2]);
let fix_bottom = evaluate_float(args[3]);
let seed = args[4][0]; let directional_strength = evaluate_vec3(read_i32_slice(directional_strength).as_slice());
let directional_strength = evaluate_vec3(args[5]); let depth = evaluate_int(read_i32_slice(depth).as_slice());
let depth = evaluate_int(args[6]); let octaves = evaluate_int(read_i32_slice(octaves).as_slice());
let octaves = evaluate_int(args[7]);
let preserve_length = evaluate_int(args[8]) != 0;
let noise_x: HybridMulti<OpenSimplex> = let noise_x: HybridMulti<OpenSimplex> =
HybridMulti::new(seed as u32 + 1).set_octaves(octaves as usize); HybridMulti::new(seed as u32 + 1).set_octaves(octaves as usize);
@@ -67,82 +74,24 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
let length = path.get_length() as f64; let length = path.get_length() as f64;
if preserve_length { for i in 0..path.length {
// Snapshot original positions so we can derive each segment's original let a = i as f64 / (path.length - 1) as f64;
// direction even after we've modified earlier points.
let orig: Vec<f32> = path.points[..path.length * 4].to_vec();
// Anchor the base (fix_bottom=1 → scale=0, no displacement at root) let px = j as f64 + a * length * scale;
let scale0 = lerp(1.0, 0.0, fix_bottom); let py = a * scale as f64;
path.points[0] += noise_x.get([j as f64, 0.0]) as f32
path.points[i * 4] += noise_x.get([px, py]) as f32
* directional_strength[0] * directional_strength[0]
* strength * strength
* scale0; * lerp(1.0, a as f32, fix_bottom);
path.points[1] += noise_y.get([j as f64, 0.0]) as f32 path.points[i * 4 + 1] += noise_y.get([px, py]) as f32
* directional_strength[1] * directional_strength[1]
* strength * strength
* scale0; * lerp(1.0, a as f32, fix_bottom);
path.points[2] += noise_z.get([j as f64, 0.0]) as f32 path.points[i * 4 + 2] += noise_z.get([px, py]) as f32
* directional_strength[2] * directional_strength[2]
* strength * strength
* scale0; * lerp(1.0, a as f32, fix_bottom);
let mut prev = Vec3::new(path.points[0], path.points[1], path.points[2]);
for i in 1..path.length {
let a = i as f64 / (path.length - 1) as f64;
let px = j as f64 + a * length * scale;
let py = a * scale as f64;
let sf = lerp(1.0, a as f32, fix_bottom);
let orig_dir = Vec3::new(
orig[i * 4] - orig[(i - 1) * 4],
orig[i * 4 + 1] - orig[(i - 1) * 4 + 1],
orig[i * 4 + 2] - orig[(i - 1) * 4 + 2],
);
let orig_len = orig_dir.length();
let perturb = Vec3::new(
noise_x.get([px, py]) as f32 * directional_strength[0] * strength * sf,
noise_y.get([px, py]) as f32 * directional_strength[1] * strength * sf,
noise_z.get([px, py]) as f32 * directional_strength[2] * strength * sf,
);
// Perturb the original direction and rescale to original length.
// Biasing toward orig_dir prevents the segment from folding back.
let mut new_dir = orig_dir + perturb;
let nd_len = new_dir.length();
if nd_len > 0.0001 && orig_len > 0.0001 {
new_dir *= orig_len / nd_len;
} else {
new_dir = orig_dir;
}
let cur = prev + new_dir;
path.points[i * 4] = cur.x;
path.points[i * 4 + 1] = cur.y;
path.points[i * 4 + 2] = cur.z;
prev = cur;
}
} else {
for i in 0..path.length {
let a = i as f64 / (path.length - 1) as f64;
let px = j as f64 + a * length * scale;
let py = a * scale as f64;
let sf = lerp(1.0, a as f32, fix_bottom);
path.points[i * 4] += noise_x.get([px, py]) as f32
* directional_strength[0]
* strength
* sf;
path.points[i * 4 + 1] += noise_y.get([px, py]) as f32
* directional_strength[1]
* strength
* sf;
path.points[i * 4 + 2] += noise_z.get([px, py]) as f32
* directional_strength[2]
* strength
* sf;
}
} }
path_data path_data
}) })
@@ -5,7 +5,7 @@
"input": { "input": {
"type": "path", "type": "path",
"accepts": [ "accepts": [
"geometry" "*"
], ],
"external": true "external": true
}, },
+5 -38
View File
@@ -1,44 +1,11 @@
use nodarium_macros::nodarium_definition_file; use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute; use nodarium_macros::nodarium_execute;
use nodarium_utils::{ use nodarium_utils::read_i32_slice;
concat_args, evaluate_int,
geometry::{extrude_path, wrap_path},
log, split_args,
};
nodarium_definition_file!("src/inputs.json"); nodarium_definition_file!("src/input.json");
#[nodarium_execute] #[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> { pub fn execute(input: (i32, i32), _res: (i32, i32)) -> Vec<i32> {
log!("WASM(output): input: {:?}", input); let inp = read_i32_slice(input);
return inp;
let args = split_args(input);
log!("WASM(output) args: {:?}", args);
assert_eq!(args.len(), 2, "Expected 2 arguments, got {}", args.len());
let inputs = split_args(args[0]);
let resolution = evaluate_int(args[1]) as usize;
log!("inputs: {}, resolution: {}", inputs.len(), resolution);
let mut output: Vec<Vec<i32>> = Vec::new();
for arg in inputs {
let arg_type = arg[2];
log!("arg_type: {}, \n {:?}", arg_type, arg,);
if arg_type == 0 {
// if this is path we need to extrude it
output.push(arg.to_vec());
let path_data = wrap_path(arg);
let geometry = extrude_path(path_data, resolution);
output.push(geometry);
continue;
}
output.push(arg.to_vec());
}
concat_args(output.iter().map(|v| v.as_slice()).collect())
} }

Some files were not shown because too many files have changed in this diff Show More