310 Commits

Author SHA1 Message Date
max c42bc93174 feat: make it work
📊 Benchmark the Runtime / release (pull_request) Successful in 1m5s
🚀 Lint & Test & Deploy / release (pull_request) Failing after 59s
2026-04-24 21:45:56 +02:00
max 6457c9db0b fix(ci): correct subsitute stuff
📊 Benchmark the Runtime / release (pull_request) Successful in 1m29s
🚀 Lint & Test & Deploy / release (pull_request) Failing after 1m1s
2026-04-24 21:39:42 +02:00
max 3dba3c2b39 feat: count total-vertices and faces in benchmark
📊 Benchmark the Runtime / release (pull_request) Failing after 1m21s
🚀 Lint & Test & Deploy / release (pull_request) Failing after 56s
2026-04-24 21:35:33 +02:00
max 09a9f8ce2c feat: initial app code 2026-04-24 17:34:10 +02:00
max 0b48740a85 feat: correctly create benchmark folder 2026-04-24 17:32:37 +02:00
max 985b5179af chore: remove debug logs from ci 2026-04-24 14:55:29 +02:00
max dab03753a2 chore: debug ci ssh
🚀 Lint & Test & Deploy / release (pull_request) Failing after 1m19s
📊 Benchmark the Runtime / release (pull_request) Successful in 1m5s
2026-04-24 14:53:16 +02:00
max 26c7e915ef chore: debug ci ssh
📊 Benchmark the Runtime / release (pull_request) Failing after 1m3s
🚀 Lint & Test & Deploy / release (pull_request) Failing after 56s
2026-04-24 14:51:22 +02:00
max a3a1f6af35 chore: debug ci ssh
📊 Benchmark the Runtime / release (pull_request) Failing after 1m16s
🚀 Lint & Test & Deploy / release (pull_request) Failing after 1m5s
2026-04-24 14:42:17 +02:00
max 4615489128 chore: debug ci ssh
📊 Benchmark the Runtime / release (pull_request) Failing after 1m10s
🚀 Lint & Test & Deploy / release (pull_request) Failing after 1m12s
2026-04-24 14:40:38 +02:00
max b23ad01c74 chore: debug ci ssh
📊 Benchmark the Runtime / release (pull_request) Failing after 1m16s
🚀 Lint & Test & Deploy / release (pull_request) Failing after 1m11s
2026-04-24 14:36:35 +02:00
max 237d04b4f1 chore: use ssh private key in ci
📊 Benchmark the Runtime / release (pull_request) Failing after 1m1s
🚀 Lint & Test & Deploy / release (pull_request) Failing after 1m7s
2026-04-24 14:35:10 +02:00
max 5b8eabc32d chore: use ssh private key in ci
📊 Benchmark the Runtime / release (pull_request) Failing after 1m0s
🚀 Lint & Test & Deploy / release (pull_request) Failing after 57s
2026-04-24 14:30:27 +02:00
max 7011c3653d chore: use ssh private key in ci
📊 Benchmark the Runtime / release (pull_request) Failing after 1m5s
🚀 Lint & Test & Deploy / release (pull_request) Failing after 55s
2026-04-24 14:25:55 +02:00
max 059022e8a8 chore: upgrade ci container image
📊 Benchmark the Runtime / release (pull_request) Failing after 2m26s
🚀 Lint & Test & Deploy / release (pull_request) Failing after 1m15s
2026-04-24 14:12:27 +02:00
max e9dce2e79c feat(ci): push benchmarks to different repo
🚀 Lint & Test & Deploy / release (pull_request) Failing after 54s
📊 Benchmark the Runtime / release (pull_request) Failing after 1m11s
2026-04-24 13:52:23 +02:00
max fd1da58cd9 feat(ci): push benchmarks to different repo
📊 Benchmark the Runtime / release (pull_request) Failing after 47s
🚀 Lint & Test & Deploy / release (pull_request) Failing after 55s
2026-04-24 13:48:55 +02:00
max b1418f6778 feat: initial group nodes /w some bugs
🚀 Lint & Test & Deploy / release (pull_request) Failing after 1m12s
📊 Benchmark the Runtime / release (pull_request) Successful in 50s
2026-04-24 13:38:32 +02:00
max 12572742eb fix(planty): remove debug span
📊 Benchmark the Runtime / release (push) Successful in 1m4s
🚀 Lint & Test & Deploy / release (push) Successful in 3m52s
2026-04-21 01:01:37 +02:00
max 7aa9979e35 chore: update e2e tests
📊 Benchmark the Runtime / release (push) Successful in 1m0s
🚀 Lint & Test & Deploy / release (push) Successful in 3m47s
2026-04-21 00:51:09 +02:00
max fc35a68826 fix: dont package ui library
📊 Benchmark the Runtime / release (push) Successful in 48s
🚀 Lint & Test & Deploy / release (push) Failing after 3m7s
2026-04-21 00:40:49 +02:00
max aba6f03bcc fix: dont package ui library
📊 Benchmark the Runtime / release (push) Successful in 57s
🚀 Lint & Test & Deploy / release (push) Failing after 1m53s
2026-04-21 00:33:56 +02:00
max 2d6fd00fd1 fix: dont package ui library
📊 Benchmark the Runtime / release (push) Successful in 50s
🚀 Lint & Test & Deploy / release (push) Failing after 2m8s
2026-04-21 00:09:49 +02:00
max d231946e50 fix: remove unused imports
📊 Benchmark the Runtime / release (push) Successful in 47s
🚀 Lint & Test & Deploy / release (push) Failing after 1m45s
2026-04-20 23:57:07 +02:00
max e2f4a24f75 fix(planty): make sure config is completely static
📊 Benchmark the Runtime / release (push) Successful in 54s
🚀 Lint & Test & Deploy / release (push) Failing after 57s
2026-04-20 21:34:24 +02:00
max 58d39cd101 feat: improve planty ux 2026-04-20 21:23:55 +02:00
max 7ebb1297ac feat(app): make zoom in nicer 2026-04-20 19:45:34 +02:00
max 23f65a1c63 fix: remove unused header div 2026-04-20 19:45:23 +02:00
max acdc582e95 feat: use ui and planty without build 2026-04-20 19:45:10 +02:00
max 7a3e9eb893 chore: update test screenshot
📊 Benchmark the Runtime / release (push) Successful in 1m20s
🚀 Lint & Test & Deploy / release (push) Successful in 4m48s
2026-04-20 02:06:13 +02:00
max be82312ea0 chore: update test screenshot
📊 Benchmark the Runtime / release (push) Successful in 1m33s
🚀 Lint & Test & Deploy / release (push) Has been cancelled
2026-04-20 02:04:07 +02:00
max 84f67e9c33 fix: update planty types
📊 Benchmark the Runtime / release (push) Successful in 1m8s
🚀 Lint & Test & Deploy / release (push) Failing after 4m18s
2026-04-20 01:57:56 +02:00
max 491e345c2f feat: build planty in post install
📊 Benchmark the Runtime / release (push) Successful in 1m24s
🚀 Lint & Test & Deploy / release (push) Failing after 1m41s
2026-04-20 01:53:40 +02:00
max ba501b211d fix: correct tsconfig for planty
📊 Benchmark the Runtime / release (push) Successful in 1m12s
🚀 Lint & Test & Deploy / release (push) Failing after 1m32s
2026-04-20 01:43:05 +02:00
max 7d76b9e1f7 fix: mark planty as type:module
📊 Benchmark the Runtime / release (push) Successful in 1m21s
🚀 Lint & Test & Deploy / release (push) Failing after 1m27s
2026-04-20 01:38:29 +02:00
max 5d4e2e9280 fix: make formatter happy
📊 Benchmark the Runtime / release (push) Successful in 1m3s
🚀 Lint & Test & Deploy / release (push) Failing after 1m32s
2026-04-20 01:32:30 +02:00
max 4de15b19c8 feat: wire up planty with nodarium/app
📊 Benchmark the Runtime / release (push) Successful in 3m55s
🚀 Lint & Test & Deploy / release (push) Failing after 56s
2026-04-20 01:08:52 +02:00
max 168e6fcc19 feat: update some node default settings 2026-04-20 01:08:41 +02:00
max c0eb75d53c feat: new planty package 2026-04-20 01:08:29 +02:00
max 2ec9bfc3c9 feat(ci): compress benchmark data
📊 Benchmark the Runtime / release (push) Successful in 1m20s
🚀 Lint & Test & Deploy / release (push) Successful in 4m7s
2026-02-13 15:21:57 +01:00
max c97520617a fix(ci): use older upload-artifact action
📊 Benchmark the Runtime / release (push) Successful in 1m16s
🚀 Lint & Test & Deploy / release (push) Successful in 4m23s
2026-02-13 15:01:59 +01:00
max 6475790176 fix(ci): build nodes before benchmarking
📊 Benchmark the Runtime / release (push) Failing after 1m16s
🚀 Lint & Test & Deploy / release (push) Has been cancelled
2026-02-13 14:57:07 +01:00
max 580ec73465 ci: run benchmark in ci
📊 Benchmark the Runtime / release (push) Failing after 42s
🚀 Lint & Test & Deploy / release (push) Successful in 4m20s
2026-02-13 14:46:21 +01:00
nodarium-bot fd98d457a3 chore(release): v0.0.5 2026-02-13 01:47:35 +00:00
max f16ba2601f fix(ci): still trying to get gpg to work
🚀 Lint & Test & Deploy / release (push) Successful in 3m54s
2026-02-13 02:43:02 +01:00
max cc6b832f15 fix(ci): trying to get gpg to work
🚀 Lint & Test & Deploy / release (push) Failing after 3m22s
2026-02-13 02:25:11 +01:00
max dd5fd5bf17 fix(ci): better add updates to package.json
🚀 Lint & Test & Deploy / release (push) Failing after 4m0s
2026-02-13 02:10:34 +01:00
max 38d0fffcf4 chore: update ci image
🚀 Lint & Test & Deploy / release (push) Failing after 3m48s
2026-02-13 01:58:16 +01:00
max bce06da456 ci: add gpg-agent to ci image
Build & Push CI Image / build-and-push (push) Successful in 8m43s
🚀 Lint & Test & Deploy / release (push) Has been cancelled
2026-02-13 01:47:32 +01:00
max af585d56ec feat: use new ci image with gpg
🚀 Lint & Test & Deploy / release (push) Failing after 3m45s
2026-02-13 01:24:19 +01:00
max 0aa73a27c1 feat: install gpg in ci image
Build & Push CI Image / build-and-push (push) Successful in 10m7s
🚀 Lint & Test & Deploy / release (push) Has been cancelled
2026-02-13 01:13:01 +01:00
max c1ae70282c feat: add color to sockets
🚀 Lint & Test & Deploy / release (push) Failing after 3m5s
Closes #34
2026-02-13 00:57:28 +01:00
max 4c7b03dfb8 feat: add gradient mesh line 2026-02-13 00:51:21 +01:00
max 144e8cc797 fix: correctly highlight possible outputs 2026-02-12 23:38:44 +01:00
max 12ff9c1518 Merge pull request 'feat/debug-node' (#41) from feat/debug-node into main
🚀 Lint & Test & Deploy / release (push) Successful in 3m58s
Reviewed-on: #41
2026-02-12 23:20:58 +01:00
max 8d3ffe84ab Merge branch 'main' into feat/debug-node
🚀 Lint & Test & Deploy / release (pull_request) Successful in 3m54s
2026-02-12 23:05:09 +01:00
max 95ec93eead feat: better handle ctrl+shift clicks and selections
🚀 Lint & Test & Deploy / release (pull_request) Successful in 3m45s
2026-02-12 22:46:50 +01:00
max d39185efaf feat: add "pnpm qa" command to check before commit
🚀 Lint & Test & Deploy / release (pull_request) Successful in 3m52s
2026-02-12 22:33:37 +01:00
max 81580ccd8c fix: cleanup some type errors 2026-02-12 22:33:25 +01:00
max bf6f632d27 feat: add shortcut to quick connect to debug
🚀 Lint & Test & Deploy / release (pull_request) Failing after 1m22s
2026-02-12 22:27:11 +01:00
release-bot e098be6013 fix: also execute all nodes before debug node
🚀 Lint & Test & Deploy / release (pull_request) Successful in 3m56s
2026-02-12 21:57:33 +01:00
release-bot ec13850e1c fix: make debug node work with runtime 2026-02-12 21:42:44 +01:00
release-bot 15e08a8163 feat: implement debug node
🚀 Lint & Test & Deploy / release (pull_request) Successful in 3m53s
Closes #39
2026-02-12 21:33:47 +01:00
release-bot 48cee58ad3 chore: update test snapshots
🚀 Lint & Test & Deploy / release (pull_request) Successful in 4m8s
2026-02-12 18:26:13 +01:00
release-bot 3235cae904 chore: fix lint and typecheck errors
🚀 Lint & Test & Deploy / release (pull_request) Failing after 3m15s
2026-02-12 18:19:27 +01:00
release-bot 3f440728fc feat: implement variable height for node shader
🚀 Lint & Test & Deploy / release (pull_request) Failing after 1m3s
2026-02-12 18:11:14 +01:00
release-bot da09f8ba1e refactor: move debug node into runtime 2026-02-12 16:18:29 +01:00
release-bot ddc3b4ce35 feat: allow variable height node parameters 2026-02-12 16:18:12 +01:00
release-bot 2690fc8712 chore: gitignore pnpm-store
🚀 Lint & Test & Deploy / release (push) Successful in 4m6s
2026-02-12 15:42:38 +01:00
release-bot 072ab9063b feat: add initial debug node 2026-02-12 14:00:18 +01:00
release-bot e23cad254d feat: add "*" datatype for inputs for debug node 2026-02-12 14:00:06 +01:00
release-bot 5b5c63c1a9 fix(ui): make arrows on inputnumber visible on lighttheme 2026-02-12 13:31:34 +01:00
release-bot c9021f2383 refactor: merge all dev settings into one setting 2026-02-12 13:10:14 +01:00
max 9eecdd4fb8 Merge pull request 'feat: merge localState recursively with initial' (#38) from feat/debug-node into main
🚀 Lint & Test & Deploy / release (push) Successful in 3m50s
Reviewed-on: #38
2026-02-12 12:51:28 +01:00
release-bot 7e71a41e52 feat: merge localState recursively with initial
🚀 Lint & Test & Deploy / release (pull_request) Successful in 4m0s
Closes #17
2026-02-12 12:50:58 +01:00
release-bot 07cd9e84eb feat: clamp AddMenu to viewport
🚀 Lint & Test & Deploy / release (push) Successful in 4m10s
2026-02-10 21:51:50 +01:00
release-bot a31a49ad50 ci: lint and typecheck before build
🚀 Lint & Test & Deploy / release (push) Successful in 3m42s
2026-02-10 15:54:50 +01:00
release-bot 850d641a25 chore: pnpm format
🚀 Lint & Test & Deploy / release (push) Has been cancelled
2026-02-10 15:54:01 +01:00
release-bot ee5ca81757 ci: sign release commits with pgp key
🚀 Lint & Test & Deploy / release (push) Failing after 2m10s
2026-02-10 15:47:42 +01:00
release-bot 22a11832b8 fix(ci): correctly format changelog 2026-02-10 15:24:23 +01:00
release-bot b5ce5723fa chore: format favicon svg
🚀 Lint & Test & Deploy / release (push) Successful in 3m53s
2026-02-10 15:14:35 +01:00
release-bot 102130cc77 feat: add favicon
🚀 Lint & Test & Deploy / release (push) Failing after 2m5s
2026-02-10 15:11:58 +01:00
release-bot 1668a2e6d5 chore: format changelog.md
🚀 Lint & Test & Deploy / release (push) Has been cancelled
2026-02-10 15:09:03 +01:00
release-bot b0af83004e chore(release): v0.0.4 2026-02-10 14:03:49 +00:00
release-bot 51de3ced13 fix(ci): update changelog before building
🚀 Lint & Test & Deploy / release (push) Successful in 3m47s
2026-02-10 14:59:17 +01:00
max 8d403ba803 Merge pull request 'feat/shape-node' (#36) from feat/shape-node into main
🚀 Lint & Test & Deploy / release (push) Successful in 4m0s
Reviewed-on: #36
2026-02-09 22:32:14 +01:00
release-bot 6bb301153a Merge remote-tracking branch 'origin/main' into feat/shape-node
🚀 Lint & Test & Deploy / release (pull_request) Successful in 3m47s
2026-02-09 22:27:43 +01:00
release-bot 02eee5f9bf fix: disable macro logs in wasm
🚀 Lint & Test & Deploy / release (pull_request) Successful in 3m47s
2026-02-09 22:21:28 +01:00
release-bot 4f48a519a9 feat(nodes): add rotation to instance node
🚀 Lint & Test & Deploy / release (pull_request) Successful in 4m9s
2026-02-09 22:16:20 +01:00
release-bot 97199ac20f feat(nodes): implement leaf node 2026-02-09 22:16:02 +01:00
release-bot f36f0cb230 feat(ui): show circles only when hovering InputShape 2026-02-09 22:15:39 +01:00
release-bot ed3d48e07f fix(runtime): correctly encode 2d shape for wasm nodes 2026-02-09 22:15:11 +01:00
release-bot c610d6c991 fix(app): show backside in three instances 2026-02-09 22:14:45 +01:00
max 8865b9b032 feat(node): initial leaf / shape nodes
🚀 Lint & Test & Deploy / release (pull_request) Successful in 4m1s
2026-02-09 18:32:52 +01:00
max 235ee5d979 fix(app): wrong linter errors in changelog
🚀 Lint & Test & Deploy / release (pull_request) Successful in 3m37s
2026-02-09 16:54:45 +01:00
max 23a48572f3 feat(app): dots background for node interface 2026-02-09 16:53:57 +01:00
max e89a46e146 feat(app): add error page
🚀 Lint & Test & Deploy / release (pull_request) Successful in 4m20s
2026-02-09 16:18:27 +01:00
max cefda41fcf feat(theme): optimize node readability 2026-02-09 16:18:19 +01:00
max 21d0f0da5a feat: add high-contrast-light theme 2026-02-09 16:04:17 +01:00
max 46202451ba ci: simplify ci quality checks
🚀 Lint & Test & Deploy / release (pull_request) Successful in 3m43s
2026-02-09 15:51:55 +01:00
max 0f4239d179 ci: simplify ci quality checks
🚀 Lint & Test & Deploy / release (pull_request) Failing after 38s
2026-02-09 15:50:05 +01:00
max d9c9bb5234 fix(theme): allow raw html in head style 2026-02-09 15:49:50 +01:00
max 18802fdc10 fix(ui): add missing types
🚀 Lint & Test & Deploy / release (pull_request) Failing after 2m45s
2026-02-09 15:37:37 +01:00
max b1cbd23542 feat(app): use same color for node outline and header
🚀 Lint & Test & Deploy / release (pull_request) Failing after 2m3s
2026-02-09 15:30:40 +01:00
max 33f10da396 feat(ui): make details stand out
🚀 Lint & Test & Deploy / release (pull_request) Failing after 2m6s
2026-02-09 15:26:48 +01:00
max af5b3b23ba fix: make sure that CHANGELOG.md is in correct place 2026-02-09 15:26:40 +01:00
max 64d75b9686 feat(ui): add InputColor and custom theme 2026-02-09 15:26:18 +01:00
release-bot 2e6466ceca chore: update dprint linters
🚀 Lint & Test & Deploy / release (pull_request) Successful in 4m17s
2026-02-09 01:58:05 +01:00
release-bot 20d8e2abed feat(theme): improve light theme a bit 2026-02-09 01:57:32 +01:00
release-bot 715e1d095b feat(theme): merge edge and connection color
🚀 Lint & Test & Deploy / release (pull_request) Successful in 3m35s
2026-02-09 01:35:41 +01:00
release-bot 07e2826f16 feat(ui): improve colors of input shape 2026-02-09 00:52:35 +01:00
release-bot e0ad97b003 feat(ui): highlight circle on hover on InputShape
🚀 Lint & Test & Deploy / release (pull_request) Successful in 3m53s
2026-02-09 00:21:58 +01:00
release-bot 93df4a19ff fix(ci): handle newline in commit messages for git.json
🚀 Lint & Test & Deploy / release (pull_request) Successful in 3m41s
2026-02-09 00:09:28 +01:00
release-bot d661a4e4a9 feat(ui): improve InputShape ux
🚀 Lint & Test & Deploy / release (pull_request) Successful in 4m17s
Allow interactions in mirrored side aswell. Use rightclick to delete
circles.
2026-02-08 23:59:39 +01:00
release-bot c7f808ce2d wip 2026-02-08 22:56:41 +01:00
release-bot 72d6cd6ea2 feat(ui): add initial InputShape element 2026-02-08 21:59:43 +01:00
release-bot 615f2d3c48 feat(ui): allow custom snippets in ui section header 2026-02-08 21:59:00 +01:00
release-bot 2fadb6802d refactor: make changelog code simpler 2026-02-08 21:58:01 +01:00
release-bot 9271d3a7e4 fix(app): handle error while parsing commit
🚀 Lint & Test & Deploy / release (push) Successful in 3m53s
2026-02-08 21:01:34 +01:00
release-bot 13c83efdb9 fix(app): handle error while parsing changelog 2026-02-08 21:00:30 +01:00
release-bot e44b73bebf feat: optimize changelog display
🚀 Lint & Test & Deploy / release (push) Successful in 4m5s
- Hide releases under a Detail
- Hide all commits under a Detail
2026-02-08 19:04:56 +01:00
max 979e9fd922 feat: improve changelog readbility
🚀 Lint & Test & Deploy / release (push) Failing after 2m41s
2026-02-07 17:40:49 +01:00
max 544500e7fe chore: remove pgp from changelog
Build & Push CI Image / build-and-push (push) Successful in 8m48s
🚀 Lint & Test & Deploy / release (push) Successful in 4m13s
2026-02-07 16:58:06 +01:00
max aaebbc4bc0 fix: some stuff with ci 2026-02-07 16:57:50 +01:00
release-bot 894ab70b79 chore(release): v0.0.3 2026-02-07 15:56:02 +00:00
max f8a2a95bc1 chore: clean CHANGELOG.md
Build & Push CI Image / build-and-push (push) Has been cancelled
🚀 Lint & Test & Deploy / release (push) Successful in 4m14s
2026-02-07 16:32:19 +01:00
max c9dd143916 fix(ci): correctly add release notes from tag to changelog 2026-02-07 16:29:59 +01:00
max 898dd49aee fix(ci): correctly copy changelog to build output
🚀 Lint & Test & Deploy / release (push) Successful in 4m7s
2026-02-07 16:20:28 +01:00
max 9fb69d760f feat: show commits since last release in changelog
🚀 Lint & Test & Deploy / release (push) Successful in 4m5s
2026-02-07 16:15:48 +01:00
max bafbcca2b8 fix: wrong socket was highlighted when dragging node
The old code had a bug that highlighted a socket from a node to which a
edge already exists which could not be connected to
2026-02-07 16:15:48 +01:00
max 8ad9e5535c feat: highlight possible sockets when dragging edge
Closes #14
2026-02-07 16:15:44 +01:00
release-bot 43a3c54838 chore(release): v0.0.3 2026-02-07 15:14:21 +00:00
max 11eaeb719b feat(app): display some git metadata in changelog
🚀 Lint & Test & Deploy / release (push) Successful in 3m35s
2026-02-06 16:30:21 +01:00
max 74c2978cd1 chore: cleanup git.json a bit 2026-02-06 16:16:07 +01:00
max 4fdc247904 ci: update build.sh to correct git.json
🚀 Lint & Test & Deploy / release (push) Successful in 4m21s
2026-02-06 15:57:19 +01:00
max c3f8b4b5aa ci: debug available env vars
🚀 Lint & Test & Deploy / release (push) Successful in 3m31s
2026-02-06 15:53:08 +01:00
max 67591c0572 chore: pnpm format
🚀 Lint & Test & Deploy / release (push) Successful in 4m3s
2026-02-06 15:46:54 +01:00
max de1f9d6ab6 feat(ui): change inputnumber to snap to values when alt is pressed
🚀 Lint & Test & Deploy / release (push) Has been cancelled
2026-02-06 15:44:24 +01:00
max 6acce72fb8 fix(ui): correctly initialize InputNumber
When the value is outside min/max the value should not be clamped.
2026-02-06 15:25:18 +01:00
max cf8943b205 chore: pnpm update 2026-02-06 15:18:32 +01:00
max 9e03d36482 chore: use newest ci image
🚀 Lint & Test & Deploy / release (push) Successful in 3m55s
2026-02-06 15:06:39 +01:00
max fd7268d620 ci: make dockerfile work
Build & Push CI Image / build-and-push (push) Successful in 8m42s
🚀 Lint & Test & Deploy / release (push) Failing after 2m31s
2026-02-06 14:43:31 +01:00
max 6358c22a85 ci: use tagged own image for ci
🚀 Lint & Test & Deploy / release (push) Failing after 3m17s
2026-02-06 13:22:33 +01:00
max 655b6a18b2 ci: make dockerfile work
Build & Push CI Image / build-and-push (push) Successful in 9m11s
🚀 Lint & Test & Deploy / release (push) Has been cancelled
2026-02-06 13:10:41 +01:00
max 37b2bdc8bd ci: update ci Dockerfile to work
Build & Push CI Image / build-and-push (push) Failing after 1m24s
🚀 Lint & Test & Deploy / release (push) Failing after 2m23s
2026-02-06 12:52:42 +01:00
max 94e01d4ea8 ci: correctly build and push ci image
Build & Push CI Image / build-and-push (push) Failing after 1m0s
🚀 Lint & Test & Deploy / release (push) Has been cancelled
2026-02-06 12:50:34 +01:00
max 35f5177884 feat: try to optimize the Dockerfile
Build & Push CI Image / build-and-push (push) Failing after 9s
🚀 Lint & Test & Deploy / release (push) Failing after 2m31s
2026-02-06 12:33:36 +01:00
max ac2c61f221 ci: use actual git url in ci
🚀 Lint & Test & Deploy / release (push) Failing after 2m16s
2026-02-06 12:22:51 +01:00
max ef3d46279f fix(ci): build before testing 2026-02-06 11:59:43 +01:00
max 703da324fa ci: automatically build ci image and store locally
Build & Push CI Image / build-and-push (push) Failing after 3m6s
🚀 Lint & Test & Deploy / release (push) Failing after 1m59s
2026-02-06 11:57:44 +01:00
max 1dae472253 ci: add a git.json metadata file during build
🚀 Lint & Test & Deploy / release (push) Failing after 3m14s
2026-02-06 11:48:12 +01:00
max 09fdfb88cd chore: update test screenshots
🚀 Lint & Test & Deploy / release (push) Successful in 4m35s
2026-02-06 00:53:58 +01:00
max 04b63cc7e2 feat: add changelog to sidebar
🚀 Lint & Test & Deploy / release (push) Failing after 4m36s
2026-02-06 00:45:33 +01:00
max cb6a35606d feat(ci): also cache cargo stuff
🚀 Lint & Test & Deploy / release (push) Successful in 4m15s
2026-02-04 21:13:10 +01:00
max 9c9f3ba3b7 fix(ci): use GITHUB_ instead of GITEA_ for env vars
🚀 Lint & Test & Deploy / release (push) Has been cancelled
2026-02-04 20:39:22 +01:00
max 08dda2b2cb chore: pnpm format
🚀 Lint & Test & Deploy / release (push) Failing after 4m46s
2026-02-04 20:30:59 +01:00
max 059129a738 fix(ci): deploy prs and main
🚀 Lint & Test & Deploy / release (push) Failing after 1m45s
2026-02-04 20:28:41 +01:00
max 437c9f4a25 feat(ci): add list of all commits to changelog entry 2026-02-04 20:14:38 +01:00
max 48bf447ce1 docs: straighten up changelog a bit 2026-02-04 20:08:29 +01:00
max 548fa4f0a1 fix(app): correctly initialize vec3 inputs in nestedsettings
Closes #32
2026-02-04 20:08:04 +01:00
release-bot 642cca30ad chore(release): v0.0.2 2026-02-04 18:37:09 +00:00
release-bot 419249aca3 chore(release): v0.0.2
🚀 Release / release (push) Failing after 4m24s
2026-02-03 23:40:36 +00:00
max c69cb94ac7 fix(ci): actually deploy on tags
🚀 Release / release (push) Successful in 4m12s
2026-02-04 00:34:39 +01:00
release-bot 4b652d885f chore(release): v0.0.2 2026-02-03 22:05:51 +00:00
max 381f784775 fix(app): correctly handle false value in settings
🚀 Release / release (push) Successful in 4m30s
This caused a bug where random seed could not be false.
2026-02-03 22:46:43 +01:00
max 91866b4e9a feat/e2e-testing (#31)
🚀 Release / release (push) Successful in 4m7s
Reviewed-on: #31
Co-authored-by: Max Richter <max@max-richter.dev>
Co-committed-by: Max Richter <max@max-richter.dev>
2026-02-03 22:29:43 +01:00
max 01f1568221 fix(ci): auto format changelog.md after release
🚀 Release / release (push) Successful in 3m41s
2026-02-03 15:47:38 +01:00
max 3e8d2768b3 chore: format
🚀 Release / release (push) Failing after 1m42s
2026-02-03 15:43:47 +01:00
max 16a832779a chore(ci): make release script work with sh 2026-02-03 15:43:47 +01:00
max d582915842 chore(ci): add jq and git to ci docker image 2026-02-03 15:43:47 +01:00
release-bot caaecd7a02 chore(release): v0.0.1 2026-02-03 14:43:26 +00:00
max 93ca436412 fix(ci): make scripts executable
🚀 Release / release (push) Failing after 3m36s
2026-02-03 15:18:37 +01:00
max ecdb986a96 chore(ci): debug some information
🚀 Release / release (push) Failing after 3m28s
2026-02-03 15:12:47 +01:00
max 304abf2866 chore(ci): debug some information
🚀 Release / release (push) Has been cancelled
2026-02-03 15:09:57 +01:00
max a547d86946 chore(ci): debug some information
🚀 Release / release (push) Has been cancelled
2026-02-03 15:09:22 +01:00
max 667d140883 docs: add information on how to release to readme
🚀 Release / release (push) Successful in 3m37s
2026-02-03 14:59:12 +01:00
max 0ac65fd7a7 feat(ci): add release workflow
🚀 Release / release (push) Successful in 3m34s
2026-02-03 14:52:24 +01:00
Max Richter 5437e062e1 feat(ci): add release workflow 2026-02-03 14:49:14 +01:00
Max Richter 1015d17afb fix(ci): put pnpm store in home instead of workspace
🏗️ Build and Deploy / build_and_deploy (push) Successful in 3m45s
2026-02-03 13:15:31 +01:00
Max Richter fd8e5e92d2 chore: run formatting 2026-02-03 13:14:06 +01:00
max a2c2503a8e Merge pull request 'feat/ui-float' (#30) from feat/ui-float into main
🏗️ Build and Deploy / build_and_deploy (push) Failing after 1m40s
Reviewed-on: #30
2026-02-03 13:11:06 +01:00
Max Richter e18f75e1b8 refactor(ci): make it simpler 2026-02-03 13:10:08 +01:00
Max Richter 6a178dc3a7 fix(ci): correctly copy ui output to app 2026-02-03 13:07:05 +01:00
Max Richter 76cdfee018 feat(app): merge active node and graph settings panel 2026-02-03 13:04:50 +01:00
Max Richter b19da950a6 refactor: use tailwind custom colors for themes
Use tailwind v4 @theme block so we can use bg-layer-0 instead of
bg-[--layer-0] for theme colors.
2026-02-03 12:18:44 +01:00
Max Richter 89e4cf8364 chore: use vite for auto building @nodarium/ui instead of chokidar
We already use vite for building and during dev. Can use a custom vite
plugin to automatically package ui after every change, so no need for
chokidar.
2026-02-03 12:18:44 +01:00
Max Richter a28f15c256 chore(ci): simplify ci step
🏗️ Build and Deploy / setup (push) Successful in 1m5s
🏗️ Build and Deploy / lint (push) Successful in 1m22s
🏗️ Build and Deploy / build_and_deploy (push) Failing after 1m51s
2026-02-02 17:48:11 +01:00
Max Richter 57e3a707c5 chore(ci): simplify ci step
🏗️ Build and Deploy / setup (push) Successful in 1m12s
🏗️ Build and Deploy / build_and_deploy (push) Has been cancelled
🏗️ Build and Deploy / lint (push) Has been cancelled
2026-02-02 17:46:04 +01:00
Max Richter dced7db3ad chore(ci): simplify ci step
🏗️ Build and Deploy / build_and_deploy (push) Has been cancelled
2026-02-02 17:45:04 +01:00
Max Richter c2dc538c05 fix(ci): make deploy step work
🏗️ Build and Deploy / setup (push) Successful in 1m11s
🏗️ Build and Deploy / lint (push) Successful in 36s
🏗️ Build and Deploy / format (push) Successful in 40s
🏗️ Build and Deploy / typecheck (push) Successful in 37s
🏗️ Build and Deploy / build_and_deploy (push) Successful in 1m51s
2026-02-02 17:39:17 +01:00
Max Richter 9484b3599e chore: run formatting on all files
🏗️ Build and Deploy / setup (push) Successful in 1m10s
🏗️ Build and Deploy / lint (push) Successful in 34s
🏗️ Build and Deploy / format (push) Successful in 38s
🏗️ Build and Deploy / typecheck (push) Successful in 33s
🏗️ Build and Deploy / build_and_deploy (push) Failing after 1m53s
2026-02-02 17:33:01 +01:00
Max Richter 3c168aa9b6 chore: add check script to ui package 2026-02-02 17:31:20 +01:00
Max Richter 812099c55d chore: run formatting
🏗️ Build and Deploy / setup (push) Successful in 1m5s
🏗️ Build and Deploy / lint (push) Successful in 34s
🏗️ Build and Deploy / format (push) Successful in 40s
🏗️ Build and Deploy / typecheck (push) Failing after 12s
🏗️ Build and Deploy / build_and_deploy (push) Has been skipped
2026-02-02 17:07:43 +01:00
Max Richter 025921aeab feat(ci): run on every branch
🏗️ Build and Deploy / setup (push) Successful in 1m6s
🏗️ Build and Deploy / lint (push) Successful in 51s
🏗️ Build and Deploy / format (push) Failing after 40s
🏗️ Build and Deploy / typecheck (push) Failing after 14s
🏗️ Build and Deploy / build_and_deploy (push) Has been skipped
2026-02-02 16:56:04 +01:00
Max Richter abaf5245d3 feat(ci): run on every branch
🏗️ Build and Deploy / lint (push) Has been cancelled
🏗️ Build and Deploy / format (push) Has been cancelled
🏗️ Build and Deploy / typecheck (push) Has been cancelled
🏗️ Build and Deploy / build_and_deploy (push) Has been cancelled
🏗️ Build and Deploy / setup (push) Has been cancelled
2026-02-02 16:51:20 +01:00
Max Richter a53cee2d5c feat(ci): run on every branch
🏗️ Build and Deploy / setup (push) Failing after 1m12s
🏗️ Build and Deploy / format (push) Has been skipped
🏗️ Build and Deploy / typecheck (push) Has been skipped
🏗️ Build and Deploy / lint (push) Has been skipped
🏗️ Build and Deploy / build_and_deploy (push) Has been skipped
2026-02-02 16:46:00 +01:00
Max Richter 7d91e53704 feat(ci): run on every branch
🏗️ Build and Deploy / setup (push) Failing after 1m16s
🏗️ Build and Deploy / lint (push) Has been skipped
🏗️ Build and Deploy / format (push) Has been skipped
🏗️ Build and Deploy / typecheck (push) Has been skipped
🏗️ Build and Deploy / build_and_deploy (push) Has been skipped
2026-02-02 16:41:14 +01:00
Max Richter 18db3cb9f2 Merge branch 'chore/linting'
🏗️ Build and Deploy / setup (push) Successful in 1m47s
🏗️ Build and Deploy / lint (push) Failing after 2s
🏗️ Build and Deploy / format (push) Failing after 2s
🏗️ Build and Deploy / typecheck (push) Failing after 1s
🏗️ Build and Deploy / build_and_deploy (push) Has been skipped
2026-02-02 16:32:36 +01:00
Max Richter b6d3269478 feat(ci): run on every branch 2026-02-02 16:32:01 +01:00
max 7d18c10007 Merge pull request 'chore: setup linting' (#29) from chore/linting into main
Reviewed-on: #29
2026-02-02 16:28:42 +01:00
Max Richter 6f33cdf066 feat(ci): run on every branch 2026-02-02 16:28:08 +01:00
Max Richter 30e897468a chore: setup linting 2026-02-02 16:22:14 +01:00
Max Richter 137425b31b chore: rename .github to .gitea
Deploy to GitHub Pages / build_site (push) Has been cancelled
2026-02-02 11:49:41 +01:00
Felix Hungenberg 5570d975f5 feat: unmigrate number into universal float, inherit step if unset
Deploy to GitHub Pages / build_site (push) Successful in 2m1s
2026-01-22 23:57:56 +01:00
Felix Hungenberg 8c1ba2ee65 feat: move add context menu within view if outside
Deploy to GitHub Pages / build_site (push) Successful in 1m59s
2026-01-22 23:26:56 +01:00
Felix Hungenberg 3e019e4e21 feat: don't move graph on right click drag 2026-01-22 23:26:26 +01:00
Felix Hungenberg a58b19e935 Merge branch 'main' of github.com:jim-fx/nodarium
Deploy to GitHub Pages / build_site (push) Successful in 1m57s
2026-01-22 14:06:44 +01:00
Max Richter 6f5c5bb46e feat: change initial camera position so that nodes are visible
Deploy to GitHub Pages / build_site (push) Successful in 2m3s
2026-01-22 12:07:37 +01:00
max 7f2214f15c fix(utils): make sure we do not build a .wasm file for utils
Deploy to GitHub Pages / build_site (push) Successful in 1m54s
2026-01-21 17:24:54 +01:00
max 43ef563ae7 feat: show all nodes in add menu
Deploy to GitHub Pages / build_site (push) Successful in 1m57s
2026-01-21 17:08:47 +01:00
Felix Hungenberg 714d01da94 chore: move pnpm links to workspace (auto link) 2026-01-21 16:39:54 +01:00
Felix Hungenberg 92308fc43a chore: run ui debug server from root
Deploy to GitHub Pages / build_site (push) Successful in 1m57s
2026-01-21 16:36:20 +01:00
Felix Hungenberg 5adf67ed52 Merge branch 'main' of git.max-richter.dev:max/nodarium
Deploy to GitHub Pages / build_site (push) Has been cancelled
2026-01-21 16:35:34 +01:00
Felix Hungenberg f54cde734e fix: integer width 2026-01-21 16:35:13 +01:00
max 70d8095869 Merge pull request 'feat: project manager' (#21) from feat/project-manager into main
Deploy to GitHub Pages / build_site (push) Has been cancelled
Reviewed-on: #21
2026-01-21 16:35:03 +01:00
Felix Hungenberg 2a90d5de3f chore: dev scripts & linting 2026-01-21 16:31:26 +01:00
max d7e9e8b8de chore: remove some old console.logs 2026-01-21 16:01:11 +01:00
max bdbaab25a4 feat: initial working version of project manager 2026-01-21 15:02:34 +01:00
Felix Hungenberg c7bfb0f05b fix(ui): integrate number input to exports, ui page, benchmark
Deploy to GitHub Pages / build_site (push) Successful in 1m46s
2026-01-21 11:55:10 +01:00
Felix Hungenberg d3a46af4c2 chore: update pnpm corepack up
Deploy to GitHub Pages / build_site (push) Successful in 1m47s
2026-01-21 11:24:53 +01:00
max 4c76c62a3e feat: add header element 2026-01-21 11:09:51 +01:00
max 36cf9211d2 fix: run pnpm i :)
Deploy to GitHub Pages / build_site (push) Successful in 1m46s
2026-01-20 19:28:35 +01:00
max 97a2ffb683 feat: use workspace instead of link for app/package.json
Deploy to GitHub Pages / build_site (push) Failing after 6s
2026-01-20 19:18:17 +01:00
max fffa8c7cdf feat: cleanup Dockerfiles and use prepared image for deployments
Deploy to GitHub Pages / build_site (push) Successful in 1m46s
2026-01-20 19:12:56 +01:00
max de799c2d55 Merge pull request 'feat/remove-wasm-bindgen' (#19) from feat/remove-wasm-bindgen into main
Deploy to GitHub Pages / build_site (push) Successful in 1m45s
Reviewed-on: #19
2026-01-20 18:57:42 +01:00
max 24bef0460c Merge branch 'main' into feat/remove-wasm-bindgen 2026-01-20 18:57:34 +01:00
max 93b64fc7dd feat: add app/Dockerfile
Deploy to GitHub Pages / build_site (push) Successful in 2m4s
2026-01-20 18:50:54 +01:00
max 64ac28f60c chore: cleanup node buildscripts 2026-01-20 18:26:48 +01:00
max bd0c2eaacd Merge remote-tracking branch 'origin/main' into feat/remove-wasm-bindgen 2026-01-20 18:04:56 +01:00
max 8693c63d16 feat: resize canvases to fit window height
Deploy to GitHub Pages / build_site (push) Successful in 2m3s
Closes #16

The canvases fit their parents size, so adding a wrapper with 100vh
solved it. https://threlte.xyz/docs/reference/core/canvas#size
2026-01-20 17:55:58 +01:00
max fbd82bbdfa Merge pull request 'feat/drop-node-on-connection' (#18) from feat/drop-node-on-connection into main
Deploy to GitHub Pages / build_site (push) Successful in 2m2s
Reviewed-on: #18
2026-01-20 17:47:04 +01:00
max 63997ec262 Merge branch 'main' into feat/drop-node-on-connection 2026-01-20 17:46:55 +01:00
max a3d10d6094 feat: drop node on edge
Closes #13
2026-01-20 17:46:09 +01:00
Felix Hungenberg 6b6038e546 feat: use new number input
Deploy to GitHub Pages / build_site (push) Successful in 2m2s
fix missing id in html
2026-01-20 16:49:56 +01:00
Felix Hungenberg 3e3d41ae98 feat: open keyboard shortcuts with ?
Deploy to GitHub Pages / build_site (push) Successful in 2m4s
2026-01-20 16:23:21 +01:00
Felix Hungenberg a8c76a846e fmt: ignore pnpm-lock.YAML
Deploy to GitHub Pages / build_site (push) Successful in 9m27s
2026-01-20 15:19:39 +01:00
max f98b90dcd3 Merge remote-tracking branch 'origin/main' into feat/drop-node-on-connection 2026-01-20 15:17:17 +01:00
max 1ea7d6629f chore: remove ai comments from dockerfile 2026-01-20 14:44:20 +01:00
max 617dfb0c9d feat: add Dockerfile for app to deploy preview 2026-01-20 14:08:45 +01:00
Felix Hungenberg c46bf9e64f ci: fix lockfile
Deploy to GitHub Pages / build_site (push) Successful in 2m1s
2026-01-19 16:57:54 +01:00
Felix Hungenberg 0cfd1e5c96 feat(ui): add id prop for inputs
Deploy to GitHub Pages / build_site (push) Failing after 5s
2026-01-19 16:43:41 +01:00
Felix Hungenberg ecfd4d5f2f feat(ui): cleanup integer input
remove warnings, migrate deprecated (dispatch to prop), include min max switch from float input, include esc/enter from float input, include precision, include partial styling from float input
2026-01-19 16:43:07 +01:00
Felix Hungenberg 03102fdc75 ci: improve dev setup
Deploy to GitHub Pages / build_site (push) Failing after 7s
2026-01-19 16:33:24 +01:00
Felix Hungenberg 0bd00b0192 feat(app): add description and input debug info to node params 2026-01-19 16:32:19 +01:00
Felix Hungenberg edbc71ed8f add dprint config 2026-01-19 16:31:27 +01:00
Felix Hungenberg 4485cef82b chore(app): remove old (pnpm v6 - now 9) pnpm lock 2026-01-19 16:30:45 +01:00
niklaskoll-dynabase cdef71265e Add svelte language server
Deploy to GitHub Pages / build_site (push) Successful in 2m45s
2026-01-19 16:26:15 +01:00
max 8e5412c25c Merge remote-tracking branch 'origin/main' into feat/remove-wasm-bindgen 2026-01-19 16:26:06 +01:00
max 2904c13c41 feat: init 2026-01-19 16:25:29 +01:00
max 450262b4ae fix(app): remove unused func
Deploy to GitHub Pages / build_site (push) Successful in 2m7s
2026-01-19 14:24:47 +01:00
max 11de746c01 feat(app): allow disabling of runtime/registry caches
Deploy to GitHub Pages / build_site (push) Successful in 1m58s
2026-01-19 14:22:14 +01:00
max 83cb2bd950 feat: move analytics script to env
Deploy to GitHub Pages / build_site (push) Successful in 2m2s
2026-01-19 14:04:00 +01:00
niklaskoll-dynabase e84c715f4c chore: Add flake and direnv stuff 2026-01-19 12:51:33 +01:00
niklaskoll-dynabase f5cea555cd chore: Add flake and direnv stuff
Deploy to GitHub Pages / build_site (push) Successful in 2m7s
2026-01-19 12:50:12 +01:00
Max Richter ecbcc814ed chore: remove store code 2026-01-19 01:29:28 +01:00
Max Richter be97387252 feat: trying to remove wasm-bindgen 2026-01-19 01:29:12 +01:00
Max Richter 987ece2a4b fix: update performance bars to work with tailwind
Deploy to GitHub Pages / build_site (push) Successful in 2m15s
2026-01-18 18:55:18 +01:00
Max Richter 8d2e3f006b fix: make graph source work 2026-01-18 18:54:53 +01:00
Max Richter 80d3e117b4 feat: update sidebar to svelte-5
Deploy to GitHub Pages / build_site (push) Successful in 2m1s
2026-01-18 18:39:02 +01:00
Max Richter 8a540522dd chore: replace unocss with tailwind
Deploy to GitHub Pages / build_site (push) Successful in 2m4s
2026-01-18 17:11:47 +01:00
Max Richter a11214072f chore: some updates
Deploy to GitHub Pages / build_site (push) Successful in 2m6s
2026-01-18 16:27:42 +01:00
max d068828b68 refactor: rename state.svelte.ts to graph-state.svelte.ts
Deploy to GitHub Pages / build_site (push) Successful in 1m59s
2025-12-09 20:00:52 +01:00
max 3565a18364 feat: cache everything in node store not only wasm 2025-12-05 14:19:29 +01:00
max 73be4fdd73 feat: better handle node position updates 2025-12-05 14:19:11 +01:00
max 702c3ee6cf feat: better handle camera positioning 2025-12-05 14:18:56 +01:00
max 98672eb702 fix: error that changes in active node panel did not get saved 2025-12-05 12:28:30 +01:00
max 3eafdc50b1 feat: keep benchmark result if panel is hidden 2025-12-05 11:49:10 +01:00
Max Richter 548e445eb7 fix: correctly show hide geometries in geometrypool
Deploy to GitHub Pages / build_site (push) Successful in 2m4s
2025-12-03 22:59:06 +01:00
max db77a4fd94 Merge pull request 'refactor: split ui/runtime/serialized node types' (#10) from refactor/split-node-runtime-types into main
Deploy to GitHub Pages / build_site (push) Successful in 2m3s
Reviewed-on: #10
2025-12-03 19:19:17 +01:00
Max Richter 7ae1fae3b9 refactor: split ui/runtime/serialized node types
Closes #6
2025-12-03 19:18:56 +01:00
max 1126cf8f9f feat: dont use custom edge geometry
Deploy to GitHub Pages / build_site (push) Successful in 1m55s
2025-12-03 10:33:24 +01:00
Max Richter ef479d0557 chore: update
Deploy to GitHub Pages / build_site (push) Successful in 3m50s
2025-12-02 17:31:58 +01:00
Max Richter a1c926c3cf fix: better handle randomGeneration 2025-12-02 17:27:34 +01:00
max ca8b1e15ac chore: cleanup edge and node code
Deploy to GitHub Pages / build_site (push) Successful in 2m8s
2025-12-02 16:59:43 +01:00
max 4878d02705 refactor: remove unneeded random var in node 2025-12-02 16:59:29 +01:00
max 2b4c81f557 fix: make sure new nodes are reactive
Closes #7
2025-12-02 16:59:11 +01:00
max d178f812fb refactor: move event handlers to own classes 2025-12-02 16:58:31 +01:00
max 669a2c7991 docs: remove placeholder content in readme 2025-12-02 15:20:26 +01:00
max becd7a1eb3 fix: make sure we do not pass svelte state into comlink
cant clone proxies
2025-12-02 15:20:13 +01:00
max d140f42468 feat: better a18n for node parameters
Dunno of a18n would even be possible for the node graph
2025-12-02 15:19:48 +01:00
max be835e5cff fix: better stroke width and color for edges 2025-12-02 15:00:41 +01:00
Max Richter 6229becfd8 fix: display add menu at correct position
Deploy to GitHub Pages / build_site (push) Successful in 1m58s
2025-12-01 22:39:43 +01:00
Max Richter af944cefaa chore: disable cache from runtime executor 2025-12-01 22:39:06 +01:00
Max Richter a1ea56093c fix: correctly handle node wrapper resizing
Deploy to GitHub Pages / build_site (push) Successful in 1m57s
2025-12-01 19:48:40 +01:00
Max Richter 1850e21810 fix: make clipboard work 2025-12-01 19:30:44 +01:00
Max Richter 7e51cc5ea1 chore: some updates
Deploy to GitHub Pages / build_site (push) Successful in 1m58s
2025-12-01 18:29:47 +01:00
Max Richter 1ea544e765 chore: rename @nodes -> @nodarium for everything
Deploy to GitHub Pages / build_site (push) Successful in 3m33s
2025-12-01 17:03:14 +01:00
max e5658b8a7e feat: initial auto connect nodes
Deploy to GitHub Pages / build_site (push) Successful in 2m35s
2025-11-26 17:27:32 +01:00
max d3a9b3f056 fix: make node wasm loading work
Deploy to GitHub Pages / build_site (push) Successful in 2m32s
2025-11-26 12:10:25 +01:00
max 0894141d3e fix: correctly load nodes from cache/fetch
Deploy to GitHub Pages / build_site (push) Successful in 4m9s
2025-11-26 11:09:19 +01:00
max 925167d9f2 feat: setup antialising on grids 2025-11-26 11:08:57 +01:00
Max Richter 9c4554a1f0 chore: log some more stuff during registry
Deploy to GitHub Pages / build_site (push) Successful in 2m31s
2025-11-24 22:42:08 +01:00
Max Richter 67a104ff84 chore: add some more logs
Deploy to GitHub Pages / build_site (push) Successful in 2m34s
2025-11-24 22:25:01 +01:00
Max Richter 1212c28152 feat: enable some debug logs
Deploy to GitHub Pages / build_site (push) Successful in 2m45s
2025-11-24 22:09:54 +01:00
Max Richter cfcb447784 feat: update some more components to svelte 5
Deploy to GitHub Pages / build_site (push) Successful in 2m48s
2025-11-24 21:11:16 +01:00
Max Richter d64877666b fix: 120 type errors
Deploy to GitHub Pages / build_site (push) Successful in 2m47s
2025-11-24 00:10:38 +01:00
Max Richter 0fa1b64d49 feat: update ci to cache pnpm and rust
Deploy to GitHub Pages / build_site (push) Successful in 2m40s
2025-11-23 19:40:50 +01:00
Max Richter 6ca1ff2a34 chore: move some more components to svelte 5
Deploy to GitHub Pages / build_site (push) Has been cancelled
2025-11-23 19:35:33 +01:00
Max Richter 716df245ab fix: trying to correctly setup sftp
Deploy to GitHub Pages / build_site (push) Successful in 2m40s
2025-11-23 16:09:57 +01:00
Max Richter 2e76202c63 fix: trying to correctly setup sftp 2025-11-23 16:07:26 +01:00
Max Richter 7818148b12 chore: pnpm up -r latest
Deploy to GitHub Pages / build_site (push) Failing after 4m6s
2025-11-23 16:01:59 +01:00
Max Richter 566b287550 chore: upgrade ci docker image
Deploy to GitHub Pages / build_site (push) Failing after 2m30s
2025-11-23 15:39:16 +01:00
Max Richter 62d3f58d86 chore: update pnpm to latest 2025-11-23 15:19:27 +01:00
Max Richter c868818ba2 feat: use local node registry again
Deploy to GitHub Pages / build_site (push) Failing after 2m56s
2025-11-23 15:15:50 +01:00
Max Richter 64ea7ac349 chore: make some things a bit more typesafe 2025-11-23 15:15:38 +01:00
Max Richter 2dcd797762 chore: pnpm upgrade 2025-11-23 15:15:15 +01:00
max 05b192e7ab commit to trigger deploy 2025-01-15 19:30:12 +01:00
max edcaab4bd4 fix: use correct url 2025-01-15 18:17:04 +01:00
max a99040f42e feat: some shit
Deploy to GitHub Pages / build_site (push) Failing after 47s
2024-12-20 16:35:23 +01:00
max fca59e87e5 feat: some shit 2024-12-20 16:35:16 +01:00
389 changed files with 22924 additions and 16254 deletions
+59
View File
@@ -0,0 +1,59 @@
{
"$schema": "https://dprint.dev/schemas/v0.json",
"indentWidth": 2,
"lineWidth": 100,
"typescript": {
// https://dprint.dev/plugins/typescript/config/
"quoteStyle": "preferSingle",
"trailingCommas": "never",
},
"json": {
// https://dprint.dev/plugins/json/config/
},
"markdown": {},
"toml": {},
"dockerfile": {},
"markup": {
// https://dprint.dev/plugins/markup_fmt/config/
"scriptIndent": true,
"styleIndent": true,
},
"yaml": {},
"exec": {
"cwd": "${configDir}",
"commands": [
{
"command": "rustfmt",
"exts": [
"rs",
],
"cacheKeyFiles": [
"rustfmt.toml",
"rust-toolchain.toml",
],
},
],
},
"excludes": [
"**/node_modules",
"**/build",
"**/.svelte-kit",
"**/package",
"**/*-lock.yaml",
"**/yaml.lock",
"**/.DS_Store",
"**/.pnpm-store",
"**/.cargo",
"**/target",
],
"plugins": [
"https://plugins.dprint.dev/typescript-0.95.15.wasm",
"https://plugins.dprint.dev/json-0.21.1.wasm",
"https://plugins.dprint.dev/markdown-0.21.1.wasm",
"https://plugins.dprint.dev/toml-0.7.0.wasm",
"https://plugins.dprint.dev/dockerfile-0.3.3.wasm",
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm",
"https://plugins.dprint.dev/g-plane/pretty_yaml-v0.6.0.wasm",
"https://plugins.dprint.dev/exec-0.6.0.json@a054130d458f124f9b5c91484833828950723a5af3f8ff2bd1523bd47b83b364",
],
}
+1
View File
@@ -0,0 +1 @@
use flake
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
mkdir -p app/static
cp CHANGELOG.md app/static/CHANGELOG.md
# Derive branch/tag info
REF_TYPE="${GITHUB_REF_TYPE:-branch}"
REF_NAME="${GITHUB_REF_NAME:-$(basename "$GITHUB_REF")}"
BRANCH="${GITHUB_HEAD_REF:-}"
if [[ -z "$BRANCH" && "$REF_TYPE" == "branch" ]]; then
BRANCH="$REF_NAME"
fi
# Determine last tag and commits since
LAST_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)"
if [[ -n "$LAST_TAG" ]]; then
COMMITS_SINCE_LAST_RELEASE="$(git rev-list --count "${LAST_TAG}..HEAD")"
else
COMMITS_SINCE_LAST_RELEASE="0"
fi
commit_message=$(git log -1 --pretty=%B | tr -d '\n' | sed 's/"/\\"/g')
cat >app/static/git.json <<EOF
{
"ref": "${GITHUB_REF:-}",
"ref_name": "${REF_NAME}",
"ref_type": "${REF_TYPE}",
"sha": "${GITHUB_SHA:-}",
"run_number": "${GITHUB_RUN_NUMBER:-}",
"event_name": "${GITHUB_EVENT_NAME:-}",
"workflow": "${GITHUB_WORKFLOW:-}",
"job": "${GITHUB_JOB:-}",
"commit_message": "${commit_message}",
"commit_timestamp": "$(git log -1 --pretty=%cI)",
"branch": "${BRANCH}",
"commits_since_last_release": "${COMMITS_SINCE_LAST_RELEASE}"
}
EOF
pnpm build
cp -R packages/ui/build app/build/ui
+102
View File
@@ -0,0 +1,102 @@
#!/bin/sh
set -eu
TAG="$GITHUB_REF_NAME"
VERSION=$(echo "$TAG" | sed 's/^v//')
DATE=$(date +%Y-%m-%d)
echo "🚀 Creating release for $TAG"
# -------------------------------------------------------------------
# 1. Extract release notes from annotated tag
# -------------------------------------------------------------------
# Ensure the local git knows this is an annotated tag with metadata
git fetch origin "refs/tags/$TAG:refs/tags/$TAG" --force
# %(contents) gets the whole message.
# If you want ONLY what you typed after the first line, use %(contents:body)
NOTES=$(git tag -l "$TAG" --format='%(contents)' | sed '/-----BEGIN PGP SIGNATURE-----/,/-----END PGP SIGNATURE-----/d')
if [ -z "$(echo "$NOTES" | tr -d '[:space:]')" ]; then
echo "❌ Tag message is empty or tag is not annotated"
exit 1
fi
git checkout main
# -------------------------------------------------------------------
# 2. Update all package.json versions
# -------------------------------------------------------------------
echo "🔧 Updating package.json versions to $VERSION"
find . -name package.json ! -path "*/node_modules/*" | while read -r file; do
tmp_file="$file.tmp"
jq --arg v "$VERSION" '.version = $v' "$file" >"$tmp_file"
mv "$tmp_file" "$file"
done
# -------------------------------------------------------------------
# 3. Generate commit list since last release
# -------------------------------------------------------------------
LAST_TAG=$(git tag --sort=-creatordate | grep -v "^$TAG$" | head -n 1 || echo "")
if [ -n "$LAST_TAG" ]; then
# Filter out previous 'chore(release)' commits so the list stays clean
COMMITS=$(git log "$LAST_TAG"..HEAD --pretty=format:'* [%h](https://git.max-richter.dev/max/nodarium/commit/%H) %s' | grep -v "chore(release)")
else
COMMITS=$(git log HEAD --pretty=format:'* [%h](https://git.max-richter.dev/max/nodarium/commit/%H) %s' | grep -v "chore(release)")
fi
# -------------------------------------------------------------------
# 4. Update CHANGELOG.md (prepend)
# -------------------------------------------------------------------
tmp_changelog="CHANGELOG.tmp"
{
echo "# $TAG ($DATE)"
echo ""
echo "$NOTES"
echo ""
if [ -n "$COMMITS" ]; then
echo "---"
echo ""
echo "$COMMITS"
echo ""
fi
echo ""
if [ -f CHANGELOG.md ]; then
cat CHANGELOG.md
fi
} >"$tmp_changelog"
mv "$tmp_changelog" CHANGELOG.md
pnpm exec dprint fmt CHANGELOG.md
# -------------------------------------------------------------------
# 5. Setup GPG signing
# -------------------------------------------------------------------
echo "$BOT_PGP_PRIVATE_KEY" | base64 -d | gpg --batch --import
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
export GPG_TTY=$(tty)
echo "allow-loopback-pinentry" >>~/.gnupg/gpg-agent.conf
gpg-connect-agent reloadagent /bye
git config user.name "nodarium-bot"
git config user.email "nodarium-bot@max-richter.dev"
git config --global user.signingkey "$GPG_KEY_ID"
git config --global commit.gpgsign true
# -------------------------------------------------------------------
# 6. Create release commit
# -------------------------------------------------------------------
git add CHANGELOG.md $(git ls-files '**/package.json')
if git diff --cached --quiet; then
echo "No changes to commit for release $TAG"
else
git commit -m "chore(release): $TAG"
git push origin main
fi
echo "✅ Release process for $TAG complete"
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
echo "Configuring rclone"
KEY_FILE="$(mktemp)"
echo "${SSH_PRIVATE_KEY}" >"${KEY_FILE}"
chmod 600 "${KEY_FILE}"
mkdir -p ~/.config/rclone
cat >~/.config/rclone/rclone.conf <<EOF
[sftp-remote]
type = sftp
host = ${SSH_HOST}
user = ${SSH_USER}
port = ${SSH_PORT}
key_file = ${KEY_FILE}
EOF
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
TARGET_DIR="${REMOTE_DIR}"
elif [[ "${GITHUB_EVENT_NAME:-}" == "pull_request" ]]; then
SAFE_PR_NAME="${GITHUB_HEAD_REF//\//-}"
TARGET_DIR="${REMOTE_DIR}_${SAFE_PR_NAME}"
elif [[ "${GITHUB_REF_NAME:-}" == "main" ]]; then
TARGET_DIR="${REMOTE_DIR}_main"
else
SAFE_REF="${GITHUB_REF_NAME//\//-}"
TARGET_DIR="${REMOTE_DIR}_${SAFE_REF}"
fi
echo "Deploying to ${TARGET_DIR}"
rclone sync \
--update \
--verbose \
--progress \
--exclude _astro/** \
--stats 2s \
--stats-one-line \
--transfers 4 \
./app/build/ \
"sftp-remote:${TARGET_DIR}"
+86
View File
@@ -0,0 +1,86 @@
name: 📊 Benchmark the Runtime
on:
push:
branches: ["*"]
pull_request:
branches: ["*"]
env:
PNPM_CACHE_FOLDER: .pnpm-store
CARGO_HOME: .cargo
CARGO_TARGET_DIR: target
jobs:
release:
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 pnpm Cache
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: 🛠️Build Nodes
run: pnpm build:nodes
- name: 🏃 Execute Runtime
run: pnpm run --filter @nodarium/app bench
- name: 🔑 Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.GIT_SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
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"
# 2. Clone the benchmarks repo into a temp folder
git config --global core.sshCommand "ssh -p 2222 -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes"
git clone git@git.max-richter.dev:max/nodarium-benchmarks.git target_bench_repo
# 3. Create a directory structure based on the branch
# This allows the UI to "switch between branches"
SAFE_PR_NAME=$(printf "%s" "$GITHUB_HEAD_REF" | tr '/' '-')
DEST_DIR="target_bench_repo/data/$SAFE_PR_NAME/$(date +%s)"
mkdir -p "$DEST_DIR"
# 4. Copy the new results
# Assuming your bench tool outputs a file named 'results.json'
cp app/benchmark/out/*.json "$DEST_DIR/"
# 5. Commit and Push
cd target_bench_repo
git add .
git commit -m "Update benchmarks for $SAFE_PR_NAME: ${{ github.sha }}"
git push origin main
+41
View File
@@ -0,0 +1,41 @@
name: Build & Push CI Image
on:
push:
branches:
- main
paths:
- "Dockerfile.ci"
- ".gitea/workflows/build-ci-image.yaml"
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v2
with:
config-inline: |
[registry."git.max-richter.dev"]
https = true
insecure = false
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.max-richter.dev
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and Push
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.ci
push: true
tags: |
git.max-richter.dev/${{ gitea.repository }}-ci:latest
git.max-richter.dev/${{ gitea.repository }}-ci:${{ gitea.sha }}
+83
View File
@@ -0,0 +1,83 @@
name: 🚀 Lint & Test & Deploy
on:
push:
branches: ["*"]
tags: ["*"]
pull_request:
branches: ["*"]
env:
PNPM_CACHE_FOLDER: .pnpm-store
CARGO_HOME: .cargo
CARGO_TARGET_DIR: target
jobs:
release:
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 pnpm Cache
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
run: |
pnpm lint
pnpm format:check
pnpm check
pnpm build
xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" pnpm test
- name: 🚀 Create Release Commit
if: gitea.ref_type == 'tag'
run: ./.gitea/scripts/create-release.sh
env:
BOT_PGP_PRIVATE_KEY: ${{ secrets.BOT_PGP_PRIVATE_KEY }}
- name: 🛠️ Build
run: ./.gitea/scripts/build.sh
- name: 🏷️ Create Gitea Release
if: gitea.ref_type == 'tag'
uses: akkuman/gitea-release-action@v1
with:
tag_name: ${{ gitea.ref_name }}
release_name: Release ${{ gitea.ref_name }}
body_path: CHANGELOG.md
draft: false
prerelease: false
- name: 🚀 Deploy Changed Files via rclone
run: ./.gitea/scripts/deploy-files.sh
env:
REMOTE_DIR: ${{ vars.REMOTE_DIR }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_HOST: ${{ vars.SSH_HOST }}
SSH_PORT: ${{ vars.SSH_PORT }}
SSH_USER: ${{ vars.SSH_USER }}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 36 KiB

-38
View File
@@ -1,38 +0,0 @@
name: Deploy to GitHub Pages
on:
push:
branches: 'main'
jobs:
build_site:
runs-on: ubuntu-latest
container: jimfx/nodes:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: pnpm install
- name: build
run: pnpm run build:deploy
- name: 🔑 Configure rclone
run: |
echo "$SSH_PRIVATE_KEY" > /tmp/id_rsa
chmod 600 /tmp/id_rsa
mkdir -p ~/.config/rclone
echo "[sftp-remote]\ntype = sftp\nhost = ${SSH_HOST}\nuser = ${SSH_USER}\nport = ${SSH_PORT}\nkey_file = /tmp/id_rsa" > ~/.config/rclone/rclone.conf
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_HOST: ${{ vars.SSH_HOST }}
SSH_PORT: ${{ vars.SSH_PORT }}
SSH_USER: ${{ vars.SSH_USER }}
- name: 🚀 Deploy Changed Files via rclone
run: |
echo "Uploading the rest"
rclone sync --update -v --progress --exclude _astro/** --stats 2s --stats-one-line ./app/build/ sftp-remote:${REMOTE_DIR} --transfers 4
env:
REMOTE_DIR: ${{ vars.REMOTE_DIR }}
+3
View File
@@ -4,3 +4,6 @@ node_modules/
# Added by cargo # Added by cargo
/target /target
.direnv/
.pnpm-store/
+198
View File
@@ -0,0 +1,198 @@
# v0.0.5 (2026-02-13)
## Features
- Implement debug node with full runtime integration, wildcard (`*`) inputs, variable-height nodes and parameters, and a quick-connect shortcut.
- Add color-coded node sockets and edges to visually indicate data types.
- Recursively merge `localState` with the initial state to safely handle outdated settings stored in `localStorage` when the settings schema changes.
- Clamp the Add Menu to the viewport.
- Add application favicon.
- Consolidate all developer settings into a single **Advanced Mode** setting.
## Fixes
- Fix InputNumber arrow visibility in the light theme.
- Correct changelog formatting issues.
## Chores
- Add `pnpm qa` pre-commit command.
- Run linting and type checks before build in CI.
- Sign release commits with a PGP key.
- General formatting, lint/type cleanup, test snapshot updates, and `.gitignore` maintenance.
---
- [f16ba26](https://git.max-richter.dev/max/nodarium/commit/f16ba2601ff0e8f0f4454e24689499112a2a257a) fix(ci): still trying to get gpg to work
- [cc6b832](https://git.max-richter.dev/max/nodarium/commit/cc6b832f1576356e5453ee4289b02f854152ff9a) fix(ci): trying to get gpg to work
- [dd5fd5b](https://git.max-richter.dev/max/nodarium/commit/dd5fd5bf1715d371566bd40419b72ca05e63401e) fix(ci): better add updates to package.json
- [38d0fff](https://git.max-richter.dev/max/nodarium/commit/38d0fffcf4ca0a346857c3658ccefdfcdf16e217) chore: update ci image
- [bce06da](https://git.max-richter.dev/max/nodarium/commit/bce06da456e3c008851ac006033cfff256015a47) ci: add gpg-agent to ci image
- [af585d5](https://git.max-richter.dev/max/nodarium/commit/af585d56ec825662961c8796226ed9d8cb900795) feat: use new ci image with gpg
- [0aa73a2](https://git.max-richter.dev/max/nodarium/commit/0aa73a27c1f23bea177ecc66034f8e0384c29a8e) feat: install gpg in ci image
- [c1ae702](https://git.max-richter.dev/max/nodarium/commit/c1ae70282cb5d58527180614a80823d80ca478c5) feat: add color to sockets
- [4c7b03d](https://git.max-richter.dev/max/nodarium/commit/4c7b03dfb82174317d8ba01f4725af804201154d) feat: add gradient mesh line
- [144e8cc](https://git.max-richter.dev/max/nodarium/commit/144e8cc797cfcc5a7a1fd9a0a2098dc99afb6170) fix: correctly highlight possible outputs
- [12ff9c1](https://git.max-richter.dev/max/nodarium/commit/12ff9c151873d253ed2e54dcf56aa9c9c4716c7c) Merge pull request 'feat/debug-node' (#41) from feat/debug-node into main
- [8d3ffe8](https://git.max-richter.dev/max/nodarium/commit/8d3ffe84ab9ca9e6d6d28333752e34da878fd3ea) Merge branch 'main' into feat/debug-node
- [95ec93e](https://git.max-richter.dev/max/nodarium/commit/95ec93eeada9bf062e01e1e77b67b8f0343a51bf) feat: better handle ctrl+shift clicks and selections
- [d39185e](https://git.max-richter.dev/max/nodarium/commit/d39185efafc360f49ab9437c0bad1f64665df167) feat: add "pnpm qa" command to check before commit
- [81580cc](https://git.max-richter.dev/max/nodarium/commit/81580ccd8c1db30ce83433c4c4df84bd660d3460) fix: cleanup some type errors
- [bf6f632](https://git.max-richter.dev/max/nodarium/commit/bf6f632d2772c3da812d5864c401f17e1aa8666a) feat: add shortcut to quick connect to debug
- [e098be6](https://git.max-richter.dev/max/nodarium/commit/e098be60135f57cf863904a58489e032ed27e8b4) fix: also execute all nodes before debug node
- [ec13850](https://git.max-richter.dev/max/nodarium/commit/ec13850e1c0ca5846da614d25887ff492cf8be04) fix: make debug node work with runtime
- [15e08a8](https://git.max-richter.dev/max/nodarium/commit/15e08a816339bdf9de9ecb6a57a7defff42dbe8c) feat: implement debug node
- [48cee58](https://git.max-richter.dev/max/nodarium/commit/48cee58ad337c1c6c59a0eb55bf9b5ecd16b99d0) chore: update test snapshots
- [3235cae](https://git.max-richter.dev/max/nodarium/commit/3235cae9049e193c242b6091cee9f01e67ee850e) chore: fix lint and typecheck errors
- [3f44072](https://git.max-richter.dev/max/nodarium/commit/3f440728fc8a94d59022bb545f418be049a1f1ba) feat: implement variable height for node shader
- [da09f8b](https://git.max-richter.dev/max/nodarium/commit/da09f8ba1eda5ed347433d37064a3b4ab49e627e) refactor: move debug node into runtime
- [ddc3b4c](https://git.max-richter.dev/max/nodarium/commit/ddc3b4ce357ef1c1e8066c0a52151713d0b6ed95) feat: allow variable height node parameters
- [2690fc8](https://git.max-richter.dev/max/nodarium/commit/2690fc871291e73d3d028df9668e8fffb1e77476) chore: gitignore pnpm-store
- [072ab90](https://git.max-richter.dev/max/nodarium/commit/072ab9063ba56df0673020eb639548f3a8601e04) feat: add initial debug node
- [e23cad2](https://git.max-richter.dev/max/nodarium/commit/e23cad254d610e00f196b7fdb4532f36fd735a4b) feat: add "*" datatype for inputs for debug node
- [5b5c63c](https://git.max-richter.dev/max/nodarium/commit/5b5c63c1a9c4ef757382bd4452149dc9777693ff) fix(ui): make arrows on inputnumber visible on lighttheme
- [c9021f2](https://git.max-richter.dev/max/nodarium/commit/c9021f2383828f2e2b5594d125165bbc8f70b8e7) refactor: merge all dev settings into one setting
- [9eecdd4](https://git.max-richter.dev/max/nodarium/commit/9eecdd4fb85dc60b8196101050334e26732c9a34) Merge pull request 'feat: merge localState recursively with initial' (#38) from feat/debug-node into main
- [7e71a41](https://git.max-richter.dev/max/nodarium/commit/7e71a41e5229126d404f56598c624709961dbf3b) feat: merge localState recursively with initial
- [07cd9e8](https://git.max-richter.dev/max/nodarium/commit/07cd9e84eb51bc02b7fed39c36cf83caba175ad7) feat: clamp AddMenu to viewport
- [a31a49a](https://git.max-richter.dev/max/nodarium/commit/a31a49ad503d69f92f2491dd685729060ea49896) ci: lint and typecheck before build
- [850d641](https://git.max-richter.dev/max/nodarium/commit/850d641a25cd0c781478c58c117feaf085bdbc62) chore: pnpm format
- [ee5ca81](https://git.max-richter.dev/max/nodarium/commit/ee5ca817573b83cacfa3709e0ae88c6263bc39c1) ci: sign release commits with pgp key
- [22a1183](https://git.max-richter.dev/max/nodarium/commit/22a11832b861ae8b44e2d374b55d12937ecab247) fix(ci): correctly format changelog
- [b5ce572](https://git.max-richter.dev/max/nodarium/commit/b5ce5723fa4a35443df39a9096d0997f808f0b4f) chore: format favicon svg
- [102130c](https://git.max-richter.dev/max/nodarium/commit/102130cc7777ceebcdb3de8466c90cef5b380111) feat: add favicon
- [1668a2e](https://git.max-richter.dev/max/nodarium/commit/1668a2e6d59db071ab3da45204c2b7bfcd2150a2) chore: format changelog.md
# v0.0.4 (2026-02-10)
## Features
- Added shape and leaf nodes, including rotation support.
- Added high-contrast light theme and improved overall node readability.
- Enhanced UI with dots background, clearer details, and consistent node coloring.
- Improved changelog display and parsing robustness.
## Fixes
- Fixed UI issues (backside rendering, missing types, linter errors).
- Improved CI handling of commit messages and changelog placement.
## Chores
- Simplified CI quality checks.
- Updated dprint linters.
- Refactored changelog code.
---
- [51de3ce](https://git.max-richter.dev/max/nodarium/commit/51de3ced133af07b9432e1137068ef43ddfecbc9) fix(ci): update changelog before building
- [8d403ba](https://git.max-richter.dev/max/nodarium/commit/8d403ba8039a05b687f050993a6afca7fb743e12) Merge pull request 'feat/shape-node' (#36) from feat/shape-node into main
- [6bb3011](https://git.max-richter.dev/max/nodarium/commit/6bb301153ac13c31511b6b28ae95c6e0d4c03e9e) Merge remote-tracking branch 'origin/main' into feat/shape-node
- [02eee5f](https://git.max-richter.dev/max/nodarium/commit/02eee5f9bf4b1bc813d5d28673c4d5d77b392a92) fix: disable macro logs in wasm
- [4f48a51](https://git.max-richter.dev/max/nodarium/commit/4f48a519a950123390530f1b6040e2430a767745) feat(nodes): add rotation to instance node
- [97199ac](https://git.max-richter.dev/max/nodarium/commit/97199ac20fb079d6c157962d1a998d63670d8797) feat(nodes): implement leaf node
- [f36f0cb](https://git.max-richter.dev/max/nodarium/commit/f36f0cb2305692c7be60889bcde7f91179e18b81) feat(ui): show circles only when hovering InputShape
- [ed3d48e](https://git.max-richter.dev/max/nodarium/commit/ed3d48e07fa6db84bbb24db6dbe044cbc36f049f) fix(runtime): correctly encode 2d shape for wasm nodes
- [c610d6c](https://git.max-richter.dev/max/nodarium/commit/c610d6c99152d8233235064b81503c2b0dc4ada8) fix(app): show backside in three instances
- [8865b9b](https://git.max-richter.dev/max/nodarium/commit/8865b9b032bdf5a1385b4e9db0b1923f0e224fdd) feat(node): initial leaf / shape nodes
- [235ee5d](https://git.max-richter.dev/max/nodarium/commit/235ee5d979fbd70b3e0fb6f09a352218c3ff1e6d) fix(app): wrong linter errors in changelog
- [23a4857](https://git.max-richter.dev/max/nodarium/commit/23a48572f3913d91d839873cc155a16139c286a6) feat(app): dots background for node interface
- [e89a46e](https://git.max-richter.dev/max/nodarium/commit/e89a46e146e9e95de57ffdf55b05d16d6fe975f4) feat(app): add error page
- [cefda41](https://git.max-richter.dev/max/nodarium/commit/cefda41fcf3d5d011c9f7598a4f3f37136602dbd) feat(theme): optimize node readability
- [21d0f0d](https://git.max-richter.dev/max/nodarium/commit/21d0f0da5a26492fa68ad4897a9b1d9e88857030) feat: add high-contrast-light theme
- [4620245](https://git.max-richter.dev/max/nodarium/commit/46202451ba3eea73bd1bc6ef5129b3e26ee9315c) ci: simplify ci quality checks
- [0f4239d](https://git.max-richter.dev/max/nodarium/commit/0f4239d179ddedd3d012ca98b5bc3312afbc8f10) ci: simplify ci quality checks
- [d9c9bb5](https://git.max-richter.dev/max/nodarium/commit/d9c9bb5234bc8776daf26be99ba77a2145c70649) fix(theme): allow raw html in head style
- [18802fd](https://git.max-richter.dev/max/nodarium/commit/18802fdc10294a58425f052a4fde4bcf4be58caf) fix(ui): add missing types
- [b1cbd23](https://git.max-richter.dev/max/nodarium/commit/b1cbd235420c99a11154ef6a899cc7e14faf1c37) feat(app): use same color for node outline and header
- [33f10da](https://git.max-richter.dev/max/nodarium/commit/33f10da396fdc13edcb8faaee212280102b24f3a) feat(ui): make details stand out
- [af5b3b2](https://git.max-richter.dev/max/nodarium/commit/af5b3b23ba18d73d6abec60949fb0c9edfc25ff8) fix: make sure that CHANGELOG.md is in correct place
- [64d75b9](https://git.max-richter.dev/max/nodarium/commit/64d75b9686c494075223a0a318297fe59ec99e81) feat(ui): add InputColor and custom theme
- [2e6466c](https://git.max-richter.dev/max/nodarium/commit/2e6466ceca1d2131581d1862e93c756affdf6cd6) chore: update dprint linters
- [20d8e2a](https://git.max-richter.dev/max/nodarium/commit/20d8e2abedf0de30299d947575afef9c8ffd61d9) feat(theme): improve light theme a bit
- [715e1d0](https://git.max-richter.dev/max/nodarium/commit/715e1d095b8a77feb0cf66bbb444baf0f163adcb) feat(theme): merge edge and connection color
- [07e2826](https://git.max-richter.dev/max/nodarium/commit/07e2826f16dafa6a07377c9fb591168fa5c2abcf) feat(ui): improve colors of input shape
- [e0ad97b](https://git.max-richter.dev/max/nodarium/commit/e0ad97b003fd8cb4d950c03e5488a5accf6a37d0) feat(ui): highlight circle on hover on InputShape
- [93df4a1](https://git.max-richter.dev/max/nodarium/commit/93df4a19ff816e2bdfa093594721f0829f84c9e6) fix(ci): handle newline in commit messages for git.json
- [d661a4e](https://git.max-richter.dev/max/nodarium/commit/d661a4e4a9dfa6c9c73b5e24a3edcf56e1bbf48c) feat(ui): improve InputShape ux
- [c7f808c](https://git.max-richter.dev/max/nodarium/commit/c7f808ce2d52925425b49f92edf49d9557f8901d) wip
- [72d6cd6](https://git.max-richter.dev/max/nodarium/commit/72d6cd6ea2886626823e6e86856f19338c7af3c1) feat(ui): add initial InputShape element
- [615f2d3](https://git.max-richter.dev/max/nodarium/commit/615f2d3c4866a9e85f3eca398f3f02100c4df355) feat(ui): allow custom snippets in ui section header
- [2fadb68](https://git.max-richter.dev/max/nodarium/commit/2fadb6802de640d692fdab7d654311df0d7b4836) refactor: make changelog code simpler
- [9271d3a](https://git.max-richter.dev/max/nodarium/commit/9271d3a7e4cb0cc751b635c2adb518de7b4100c7) fix(app): handle error while parsing commit
- [13c83ef](https://git.max-richter.dev/max/nodarium/commit/13c83efdb962a6578ade59f10cc574fef0e17534) fix(app): handle error while parsing changelog
- [e44b73b](https://git.max-richter.dev/max/nodarium/commit/e44b73bebfb1cc8e872cd2fa7d8b6ff3565df374) feat: optimize changelog display
- [979e9fd](https://git.max-richter.dev/max/nodarium/commit/979e9fd92289eba9f77221c563337c00028e4cf5) feat: improve changelog readbility
- [544500e](https://git.max-richter.dev/max/nodarium/commit/544500e7fe9ee14412cef76f3c7a32ba6f291656) chore: remove pgp from changelog
- [aaebbc4](https://git.max-richter.dev/max/nodarium/commit/aaebbc4bc082ee93c2317ce45071c9bc61b0b77e) fix: some stuff with ci
# v0.0.3 (2026-02-07)
## Features
- Edge dragging now highlights valid connection sockets, improving graph editing clarity.
- InputNumber supports snapping to predefined values while holding Alt.
- Changelog is accessible directly from the sidebar and now includes git metadata and a list of commits.
## Fixes
- Fixed incorrect socket highlighting when an edge already existed.
- Corrected initialization of `InputNumber` values outside min/max bounds.
- Fixed initialization of nested vec3 inputs.
- Multiple CI fixes to ensure reliable builds, correct environment variables, and proper image handling.
## Maintenance / CI
- Significant CI and Dockerfile cleanup and optimization.
- Improved git metadata generation during builds.
- Dependency updates, formatting, and test snapshot updates.
---
- [f8a2a95](https://git.max-richter.dev/max/nodarium/commit/f8a2a95bc18fa3c8c1db67dc0c2b66db1ff0d866) chore: clean CHANGELOG.md
- [c9dd143](https://git.max-richter.dev/max/nodarium/commit/c9dd143916d758991f3ba30723a32c18b6f98bb5) fix(ci): correctly add release notes from tag to changelog
- [898dd49](https://git.max-richter.dev/max/nodarium/commit/898dd49aee930350af8645382ef5042765a1fac7) fix(ci): correctly copy changelog to build output
- [9fb69d7](https://git.max-richter.dev/max/nodarium/commit/9fb69d760fdf92ecc2448e468242970ec48443b0) feat: show commits since last release in changelog
- [bafbcca](https://git.max-richter.dev/max/nodarium/commit/bafbcca2b8a7cd9f76e961349f11ec84d1e4da63) fix: wrong socket was highlighted when dragging node
- [8ad9e55](https://git.max-richter.dev/max/nodarium/commit/8ad9e5535cd752ef111504226b4dac57b5adcf3d) feat: highlight possible sockets when dragging edge
- [11eaeb7](https://git.max-richter.dev/max/nodarium/commit/11eaeb719be7f34af8db8b7908008a15308c0cac) feat(app): display some git metadata in changelog
- [74c2978](https://git.max-richter.dev/max/nodarium/commit/74c2978cd16d2dd95ce1ae8019dfb9098e52b4b6) chore: cleanup git.json a bit
- [4fdc247](https://git.max-richter.dev/max/nodarium/commit/4fdc24790490d3f13ee94a557159617f4077a2f9) ci: update build.sh to correct git.json
- [c3f8b4b](https://git.max-richter.dev/max/nodarium/commit/c3f8b4b5aad7a525fb11ab14c9236374cb60442d) ci: debug available env vars
- [67591c0](https://git.max-richter.dev/max/nodarium/commit/67591c0572b873d8c7cd00db8efb7dac2d6d4de2) chore: pnpm format
- [de1f9d6](https://git.max-richter.dev/max/nodarium/commit/de1f9d6ab669b8e699d98b8855e125e21030b5b3) feat(ui): change inputnumber to snap to values when alt is pressed
- [6acce72](https://git.max-richter.dev/max/nodarium/commit/6acce72fb8c416cc7f6eec99c2ae94d6529e960c) fix(ui): correctly initialize InputNumber
- [cf8943b](https://git.max-richter.dev/max/nodarium/commit/cf8943b2059aa286e41865caf75058d35498daf7) chore: pnpm update
- [9e03d36](https://git.max-richter.dev/max/nodarium/commit/9e03d36482bb4f972c384b66b2dcf258f0cd18be) chore: use newest ci image
- [fd7268d](https://git.max-richter.dev/max/nodarium/commit/fd7268d6208aede435e1685817ae6b271c68bd83) ci: make dockerfile work
- [6358c22](https://git.max-richter.dev/max/nodarium/commit/6358c22a853ec340be5223fabb8289092e4f4afe) ci: use tagged own image for ci
- [655b6a1](https://git.max-richter.dev/max/nodarium/commit/655b6a18b282f0cddcc750892e575ee6c311036b) ci: make dockerfile work
- [37b2bdc](https://git.max-richter.dev/max/nodarium/commit/37b2bdc8bdbd8ded6b22b89214b49de46f788351) ci: update ci Dockerfile to work
- [94e01d4](https://git.max-richter.dev/max/nodarium/commit/94e01d4ea865f15ce06b52827a1ae6906de5be5e) ci: correctly build and push ci image
- [35f5177](https://git.max-richter.dev/max/nodarium/commit/35f5177884b62bbf119af1bbf4df61dd0291effb) feat: try to optimize the Dockerfile
- [ac2c61f](https://git.max-richter.dev/max/nodarium/commit/ac2c61f2211ba96bbdbb542179905ca776537cec) ci: use actual git url in ci
- [ef3d462](https://git.max-richter.dev/max/nodarium/commit/ef3d46279f4ff9c04d80bb2d9a9e7cfec63b224e) fix(ci): build before testing
- [703da32](https://git.max-richter.dev/max/nodarium/commit/703da324fabbef0e2c017f0f7a925209fa26bd03) ci: automatically build ci image and store locally
- [1dae472](https://git.max-richter.dev/max/nodarium/commit/1dae472253ccb5e3766f2270adc053b922f46738) ci: add a git.json metadata file during build
- [09fdfb8](https://git.max-richter.dev/max/nodarium/commit/09fdfb88cd203ace0e36663ebdb2c8c7ba53f190) chore: update test screenshots
- [04b63cc](https://git.max-richter.dev/max/nodarium/commit/04b63cc7e2fc4fcfa0973cf40592d11457179db3) feat: add changelog to sidebar
- [cb6a356](https://git.max-richter.dev/max/nodarium/commit/cb6a35606dfda50b0c81b04902d7a6c8e59458d2) feat(ci): also cache cargo stuff
- [9c9f3ba](https://git.max-richter.dev/max/nodarium/commit/9c9f3ba3b7c94215a86b0a338a5cecdd87b96b28) fix(ci): use GITHUB_instead of GITEA_ for env vars
- [08dda2b](https://git.max-richter.dev/max/nodarium/commit/08dda2b2cb4d276846abe30bc260127626bb508a) chore: pnpm format
- [059129a](https://git.max-richter.dev/max/nodarium/commit/059129a738d02b8b313bb301a515697c7c4315ac) fix(ci): deploy prs and main
- [437c9f4](https://git.max-richter.dev/max/nodarium/commit/437c9f4a252125e1724686edace0f5f006f58439) feat(ci): add list of all commits to changelog entry
- [48bf447](https://git.max-richter.dev/max/nodarium/commit/48bf447ce12949d7c29a230806d160840b7847e1) docs: straighten up changelog a bit
- [548fa4f](https://git.max-richter.dev/max/nodarium/commit/548fa4f0a1a14adc40a74da1182fa6da81eab3df) fix(app): correctly initialize vec3 inputs in nestedsettings
# v0.0.2 (2026-02-04)
## Fixes
---
- []() fix(ci): actually deploy on tags
- []() fix(app): correctly handle false value in settings
# v0.0.1 (2026-02-03)
chore: format
Generated
+78 -297
View File
@@ -1,176 +1,88 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 4
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.2.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]] [[package]]
name = "box" name = "box"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"console_error_panic_hook",
"nodarium_macros", "nodarium_macros",
"nodarium_utils", "nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
] ]
[[package]] [[package]]
name = "branch" name = "branch"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"console_error_panic_hook",
"glam",
"nodarium_macros", "nodarium_macros",
"nodarium_utils", "nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "bumpalo"
version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
] ]
[[package]] [[package]]
name = "float" name = "float"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"console_error_panic_hook",
"nodarium_macros", "nodarium_macros",
"nodarium_utils", "nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
] ]
[[package]] [[package]]
name = "glam" name = "glam"
version = "0.27.0" version = "0.30.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
[[package]] [[package]]
name = "gravity" name = "gravity"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"console_error_panic_hook",
"glam", "glam",
"nodarium_macros", "nodarium_macros",
"nodarium_utils", "nodarium_utils",
"noise", ]
"serde",
"serde-wasm-bindgen", [[package]]
"wasm-bindgen", name = "instance"
"wasm-bindgen-test", version = "0.1.0"
"web-sys", dependencies = [
"glam",
"nodarium_macros",
"nodarium_utils",
] ]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.11" version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]] [[package]]
name = "js-sys" name = "leaf"
version = "0.3.69" version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d"
dependencies = [ dependencies = [
"wasm-bindgen", "nodarium_macros",
"nodarium_utils",
] ]
[[package]]
name = "log"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
[[package]] [[package]]
name = "math" name = "math"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"console_error_panic_hook",
"nodarium_macros", "nodarium_macros",
"nodarium_utils", "nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
] ]
[[package]] [[package]]
name = "max-plantarium-triangle" name = "memchr"
version = "0.1.0" version = "2.7.6"
dependencies = [ source = "registry+https://github.com/rust-lang/crates.io-index"
"console_error_panic_hook", checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "max-plantarium-vec3"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "nodarium_instance"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"glam",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]] [[package]]
name = "nodarium_macros" name = "nodarium_macros"
@@ -180,7 +92,7 @@ dependencies = [
"quote", "quote",
"serde", "serde",
"serde_json", "serde_json",
"syn 1.0.109", "syn",
] ]
[[package]] [[package]]
@@ -195,29 +107,19 @@ dependencies = [
name = "nodarium_utils" name = "nodarium_utils"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"console_error_panic_hook",
"glam", "glam",
"noise", "noise 0.9.0",
"serde", "serde",
"serde_json", "serde_json",
"wasm-bindgen",
"web-sys",
] ]
[[package]] [[package]]
name = "nodes-noise" name = "noise"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"console_error_panic_hook",
"glam",
"nodarium_macros", "nodarium_macros",
"nodarium_utils", "nodarium_utils",
"noise", "noise 0.9.0",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
] ]
[[package]] [[package]]
@@ -233,48 +135,35 @@ dependencies = [
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.18" version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "once_cell"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]] [[package]]
name = "output" name = "output"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"console_error_panic_hook",
"glam",
"nodarium_macros", "nodarium_macros",
"nodarium_utils", "nodarium_utils",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
] ]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.81" version = "1.0.105"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba" checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.36" version = "1.0.43"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@@ -307,103 +196,84 @@ dependencies = [
name = "random" name = "random"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"console_error_panic_hook",
"nodarium_macros", "nodarium_macros",
"nodarium_utils", "nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
] ]
[[package]] [[package]]
name = "rotate" name = "rotate"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"console_error_panic_hook",
"glam", "glam",
"nodarium_macros", "nodarium_macros",
"nodarium_utils", "nodarium_utils",
"serde", "serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
] ]
[[package]]
name = "ryu"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1"
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.198" version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9846a40c979031340571da2545a4e5b7c4163bdae79b301d5f86d03979451fcc" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "serde-wasm-bindgen"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3b4c031cd0d9014307d82b8abf653c0290fbdaeb4c02d00c63cf52f728628bf"
dependencies = [
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.198" version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e88edab869b01783ba905e7d0153f9fc1a6505a96e4ad3018011eedb838566d9" checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.60", "syn",
] ]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.116" version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [ dependencies = [
"itoa", "itoa",
"ryu", "memchr",
"serde", "serde",
"serde_core",
"zmij",
]
[[package]]
name = "shape"
version = "0.1.0"
dependencies = [
"nodarium_macros",
"nodarium_utils",
] ]
[[package]] [[package]]
name = "stem" name = "stem"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"console_error_panic_hook",
"nodarium_macros", "nodarium_macros",
"nodarium_utils", "nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
] ]
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.109" version = "2.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -411,119 +281,30 @@ dependencies = [
] ]
[[package]] [[package]]
name = "syn" name = "triangle"
version = "2.0.60" version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3"
dependencies = [ dependencies = [
"proc-macro2", "nodarium_macros",
"quote", "nodarium_utils",
"unicode-ident",
] ]
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.12" version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]] [[package]]
name = "wasm-bindgen" name = "vec3"
version = "0.2.92" version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
dependencies = [ dependencies = [
"cfg-if", "nodarium_macros",
"wasm-bindgen-macro", "nodarium_utils",
"serde",
] ]
[[package]] [[package]]
name = "wasm-bindgen-backend" name = "zmij"
version = "0.2.92" version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2"
dependencies = [
"bumpalo",
"log",
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.60",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0"
dependencies = [
"cfg-if",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
[[package]]
name = "wasm-bindgen-test"
version = "0.3.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9bf62a58e0780af3e852044583deee40983e5886da43a271dd772379987667b"
dependencies = [
"console_error_panic_hook",
"js-sys",
"scoped-tls",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test-macro",
]
[[package]]
name = "wasm-bindgen-test-macro"
version = "0.3.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f89739351a2e03cb94beb799d47fb2cac01759b40ec441f7de39b00cbf7ef0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
]
[[package]]
name = "web-sys"
version = "0.3.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef"
dependencies = [
"js-sys",
"wasm-bindgen",
]
-18
View File
@@ -1,18 +0,0 @@
FROM node:22
# IMAGE CUSTOMISATIONS
# Install rust
# https://github.com/rust-lang/rustup/issues/1085
RUN RUSTUP_URL="https://sh.rustup.rs" \
&& curl --silent --show-error --location --fail --retry 3 --proto '=https' --tlsv1.2 --output /tmp/rustup-linux-install.sh $RUSTUP_URL \
&& bash /tmp/rustup-linux-install.sh -y \
&& curl https://rclone.org/install.sh --output /tmp/rclone-install.sh \
&& bash /tmp/rclone-install.sh
ENV PATH=/root/.cargo/bin:$PATH
RUN rustup target add wasm32-unknown-unknown \
&& cargo install wasm-pack \
&& npm i -g pnpm
+32
View File
@@ -0,0 +1,32 @@
FROM node:25-bookworm-slim
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN apt-get update && apt-get install -y \
ca-certificates=20230311+deb12u1 \
gpg=2.2.40-1.1+deb12u2 \
gpg-agent=2.2.40-1.1+deb12u2 \
curl=7.88.1-10+deb12u14 \
git=1:2.39.5-0+deb12u3 \
jq=1.6-2.1+deb12u1 \
g++=4:12.2.0-3 \
rclone=1.60.1+dfsg-2+b5 \
xvfb=2:21.1.7-3+deb12u11 \
xauth=1:1.1.2-1 \
--no-install-recommends \
# Install Rust
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
--default-toolchain stable \
--profile minimal \
&& rustup target add wasm32-unknown-unknown \
# Setup Playwright
&& npm i -g pnpm \
&& pnpm dlx playwright install --with-deps firefox \
# Final Cleanup
&& rm -rf /usr/local/rustup/downloads /usr/local/rustup/tmp \
&& rm -rf /usr/local/cargo/registry/index /usr/local/cargo/registry/cache \
&& rm -rf /usr/local/rustup/toolchains/*/share/doc \
&& apt-get purge -y --auto-remove \
&& rm -rf /var/lib/apt/lists/*
+30 -5
View File
@@ -2,17 +2,17 @@ Nodarium
<div align="center"> <div align="center">
<a href="https://nodes.max-richter.dev/"><h2 align="center">Nodarium</h2></a> <a href="https://nodes.max-richter.dev/"><h2 align="center">Nodarium</h2></a>
<p align="center"> <p align="center">
Nodarium is a WebAssembly based visual programming language. Nodarium is a WebAssembly based visual programming language.
</p> </p>
<img src=".github/graphics/nodes.svg" width="80%"/> <img src=".gitea/graphics/nodes.svg" width="80%"/>
</div> </div>
Currently this visual programming language is used to develop https://nodes.max-richter.dev, a procedural modelling tool for 3d-plants. Currently this visual programming language is used to develop <https://nodes.max-richter.dev>, a procedural modelling tool for 3d-plants.
# Table of contents # Table of contents
@@ -22,7 +22,7 @@ Currently this visual programming language is used to develop https://nodes.max-
# Developing # Developing
### Install prerequisites: ### Install prerequisites
- [Node.js](https://nodejs.org/en/download) - [Node.js](https://nodejs.org/en/download)
- [pnpm](https://pnpm.io/installation) - [pnpm](https://pnpm.io/installation)
@@ -50,4 +50,29 @@ pnpm dev
### [Now you can create your first node 🤓](./docs/DEVELOPING_NODES.md) ### [Now you can create your first node 🤓](./docs/DEVELOPING_NODES.md)
# Releasing
## Creating a Release
1. **Create an annotated tag** with your release notes:
```bash
git tag -a v1.0.0 -m "Release notes for this version"
git push origin v1.0.0
```
2. **The CI workflow will:**
- Run lint, format check, and type check
- Build the project
- Update all `package.json` versions to match the tag
- Generate/update `CHANGELOG.md`
- Create a release commit on `main`
- Publish a Gitea release
## Version Requirements
- Tag must match pattern `v*` (e.g., `v1.0.0`, `v2.3.1`)
- Tag message must not be empty (annotated tag required)
- Tag must be pushed from `main` branch
# Roadmap # Roadmap
+1
View File
@@ -0,0 +1 @@
PUBLIC_ANALYTIC_SCRIPT=""
+2
View File
@@ -27,3 +27,5 @@ dist-ssr
*.sln *.sln
*.sw? *.sw?
build/ build/
test-results/
+32
View File
@@ -0,0 +1,32 @@
FROM jimfx/nodes:latest AS builder
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json Cargo.lock Cargo.toml ./
COPY packages/ ./packages/
COPY nodes/ ./nodes/
COPY app/package.json ./app/
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
--mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
pnpm install --frozen-lockfile
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
--mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
pnpm build:nodes && \
pnpm --filter @nodarium/app... build
FROM nginx:alpine AS runner
RUN rm /etc/nginx/conf.d/default.conf
COPY app/docker/app.conf /etc/nginx/conf.d/app.conf
COPY --from=builder /app/app/build /app
EXPOSE 80
+1 -7
View File
@@ -1,7 +1 @@
# Tauri + Svelte + Typescript # Nodarium App
This template should help get you started developing with Tauri, Svelte and TypeScript in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
+1
View File
@@ -0,0 +1 @@
out/
+47
View File
@@ -0,0 +1,47 @@
import { NodeDefinition, NodeId, NodeRegistry } from '@nodarium/types';
import { createWasmWrapper } from '@nodarium/utils';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
export class BenchmarkRegistry implements NodeRegistry {
status: 'loading' | 'ready' | 'error' = 'loading';
private nodes = new Map<string, NodeDefinition>();
async load(nodeIds: NodeId[]): Promise<NodeDefinition[]> {
const nodes = await Promise.all(nodeIds.map(async id => {
const p = resolve('static/nodes/' + id + '.wasm');
const file = await readFile(p);
const node = createWasmWrapper(file as unknown as ArrayBuffer);
const d = node.get_definition();
return {
...d,
execute: node.execute
};
}));
for (const n of nodes) {
this.nodes.set(n.id, n);
}
this.status = 'ready';
return nodes;
}
async register(id: string, wasmBuffer: ArrayBuffer): Promise<NodeDefinition> {
const wasm = createWasmWrapper(wasmBuffer);
const d = wasm.get_definition();
const node = {
...d,
execute: wasm.execute
};
this.nodes.set(id, node);
return node;
}
getNode(id: NodeId | string): NodeDefinition | undefined {
return this.nodes.get(id);
}
getAllNodes(): NodeDefinition[] {
return [];
}
}
+80
View File
@@ -0,0 +1,80 @@
import type { Graph, Graph as GraphType, NodeId } from '@nodarium/types';
import { createLogger, createPerformanceStore, splitNestedArray } from '@nodarium/utils';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { MemoryRuntimeExecutor } from '../src/lib/runtime/runtime-executor.ts';
import { BenchmarkRegistry } from './benchmarkRegistry.ts';
import defaultPlantTemplate from './templates/default.json' assert { type: 'json' };
import lottaFacesTemplate from './templates/lotta-faces.json' assert { type: 'json' };
import plantTemplate from './templates/plant.json' assert { type: 'json' };
const registry = new BenchmarkRegistry();
const r = new MemoryRuntimeExecutor(registry);
const perfStore = createPerformanceStore();
const log = createLogger('bench');
const templates: Record<string, Graph> = {
'plant': plantTemplate as unknown as GraphType,
'lotta-faces': lottaFacesTemplate as unknown as GraphType,
'default': defaultPlantTemplate as unknown as GraphType
};
function 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];
const faceCount = part[2];
if (type === 2) {
const instanceCount = part[3];
totalVertices += vertexCount * instanceCount;
totalFaces += faceCount * instanceCount;
} else {
totalVertices += vertexCount;
totalFaces += faceCount;
}
}
return { totalVertices, totalFaces };
}
async function run(g: GraphType, amount: number) {
await registry.load(plantTemplate.nodes.map(n => n.type) as NodeId[]);
log.log('loaded ' + g.nodes.length + ' nodes');
log.log('warming up');
// Warm up the runtime? maybe this does something?
for (let index = 0; index < 10; index++) {
await r.execute(g, { randomSeed: true });
}
log.log('executing');
r.perf = perfStore;
let res;
for (let i = 0; i < amount; i++) {
r.perf?.startRun();
res = await r.execute(g, { randomSeed: true });
r.perf?.stopRun();
const { totalVertices, totalFaces } = countGeometry(res!);
r.perf?.addToLastRun('total-vertices', totalVertices);
r.perf?.addToLastRun('total-faces', totalFaces);
}
log.log('finished');
return r.perf.get();
}
async function main() {
const outPath = resolve('benchmark/out/');
await mkdir(outPath, { recursive: true });
for (const key in templates) {
log.log('executing ' + key);
const perfData = await run(templates[key], 100);
await writeFile(resolve(outPath, key + '.json'), JSON.stringify(perfData));
await new Promise(res => setTimeout(res, 200));
}
}
main();
+95
View File
@@ -0,0 +1,95 @@
{
"settings": { "resolution.circle": 26, "resolution.curve": 39 },
"nodes": [
{ "id": 9, "position": [220, 80], "type": "max/plantarium/output", "props": {} },
{
"id": 10,
"position": [95, 80],
"type": "max/plantarium/stem",
"props": { "amount": 5, "length": 11, "thickness": 0.1 }
},
{
"id": 14,
"position": [195, 80],
"type": "max/plantarium/gravity",
"props": {
"strength": 0.38,
"scale": 39,
"fixBottom": 0,
"directionalStrength": [1, 1, 1],
"depth": 1,
"curviness": 1
}
},
{
"id": 15,
"position": [120, 80],
"type": "max/plantarium/noise",
"props": {
"strength": 4.9,
"scale": 2.2,
"fixBottom": 1,
"directionalStrength": [1, 1, 1],
"depth": 1,
"octaves": 1
}
},
{
"id": 16,
"position": [70, 80],
"type": "max/plantarium/vec3",
"props": { "0": 0, "1": 0, "2": 0 }
},
{
"id": 17,
"position": [45, 80],
"type": "max/plantarium/random",
"props": { "min": -2, "max": 2 }
},
{
"id": 18,
"position": [170, 80],
"type": "max/plantarium/branch",
"props": {
"length": 1.6,
"thickness": 0.69,
"amount": 36,
"offsetSingle": 0.5,
"lowestBranch": 0.46,
"highestBranch": 1,
"depth": 1,
"rotation": 180
}
},
{
"id": 19,
"position": [145, 80],
"type": "max/plantarium/gravity",
"props": {
"strength": 0.38,
"scale": 39,
"fixBottom": 0,
"directionalStrength": [1, 1, 1],
"depth": 1,
"curviness": 1
}
},
{
"id": 20,
"position": [70, 120],
"type": "max/plantarium/random",
"props": { "min": 0.073, "max": 0.15 }
}
],
"edges": [
[14, 0, 9, "input"],
[10, 0, 15, "plant"],
[16, 0, 10, "origin"],
[17, 0, 16, "0"],
[17, 0, 16, "2"],
[18, 0, 14, "plant"],
[15, 0, 19, "plant"],
[19, 0, 18, "plant"],
[20, 0, 10, "thickness"]
]
}
+44
View File
@@ -0,0 +1,44 @@
{
"settings": { "resolution.circle": 64, "resolution.curve": 64, "randomSeed": false },
"nodes": [
{ "id": 9, "position": [260, 0], "type": "max/plantarium/output", "props": {} },
{
"id": 18,
"position": [185, 0],
"type": "max/plantarium/stem",
"props": { "amount": 64, "length": 12, "thickness": 0.15 }
},
{
"id": 19,
"position": [210, 0],
"type": "max/plantarium/noise",
"props": { "scale": 1.3, "strength": 5.4 }
},
{
"id": 20,
"position": [235, 0],
"type": "max/plantarium/branch",
"props": { "length": 0.8, "thickness": 0.8, "amount": 3 }
},
{
"id": 21,
"position": [160, 0],
"type": "max/plantarium/vec3",
"props": { "0": 0.39, "1": 0, "2": 0.41 }
},
{
"id": 22,
"position": [130, 0],
"type": "max/plantarium/random",
"props": { "min": -2, "max": 2 }
}
],
"edges": [
[18, 0, 19, "plant"],
[19, 0, 20, "plant"],
[20, 0, 9, "input"],
[21, 0, 18, "origin"],
[22, 0, 21, "0"],
[22, 0, 21, "2"]
]
}
+71
View File
@@ -0,0 +1,71 @@
{
"settings": { "resolution.circle": 26, "resolution.curve": 39 },
"nodes": [
{ "id": 9, "position": [180, 80], "type": "max/plantarium/output", "props": {} },
{
"id": 10,
"position": [55, 80],
"type": "max/plantarium/stem",
"props": { "amount": 1, "length": 11, "thickness": 0.71 }
},
{
"id": 11,
"position": [80, 80],
"type": "max/plantarium/noise",
"props": {
"strength": 35,
"scale": 4.6,
"fixBottom": 1,
"directionalStrength": [1, 0.74, 0.083],
"depth": 1
}
},
{
"id": 12,
"position": [105, 80],
"type": "max/plantarium/branch",
"props": {
"length": 3,
"thickness": 0.6,
"amount": 10,
"rotation": 180,
"offsetSingle": 0.34,
"lowestBranch": 0.53,
"highestBranch": 1,
"depth": 1
}
},
{
"id": 13,
"position": [130, 80],
"type": "max/plantarium/noise",
"props": {
"strength": 8,
"scale": 7.7,
"fixBottom": 1,
"directionalStrength": [1, 0, 1],
"depth": 1
}
},
{
"id": 14,
"position": [155, 80],
"type": "max/plantarium/gravity",
"props": {
"strength": 0.11,
"scale": 39,
"fixBottom": 0,
"directionalStrength": [1, 1, 1],
"depth": 1,
"curviness": 1
}
}
],
"edges": [
[10, 0, 11, "plant"],
[11, 0, 12, "plant"],
[12, 0, 13, "plant"],
[13, 0, 14, "plant"],
[14, 0, 9, "input"]
]
}
+10
View File
@@ -0,0 +1,10 @@
server {
listen 80;
server_name _;
root /app;
index index.html;
location / {
try_files \$uri \$uri/ /index.html;
}
}
+62
View File
@@ -0,0 +1,62 @@
import { expect, test } from '@playwright/test';
test('test', async ({ page }) => {
// Listen for console messages
page.on('console', msg => {
console.log(`[Browser Console] ${msg.type()}: ${msg.text()}`);
});
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: 'New', exact: true }).click();
await page.getByRole('combobox').selectOption('2');
await page.getByRole('textbox', { name: 'Project name' }).click();
await page.getByRole('textbox', { name: 'Project name' }).fill('Test Project');
await page.getByRole('button', { name: 'Create' }).click();
const expectedNodes = [
{
id: '10',
type: 'max/plantarium/stem',
props: {
amount: 4,
length: 4,
thickness: 0.2
}
},
{
id: '11',
type: 'max/plantarium/noise',
props: {
scale: 0.5,
strength: 5
}
},
{
id: '9',
type: 'max/plantarium/output'
}
];
for (const node of expectedNodes) {
const wrapper = page.locator(
`div.wrapper[data-node-id="${node.id}"][data-node-type="${node.type}"]`
);
await expect(wrapper).toBeVisible();
if ('props' in node) {
const props = node.props as unknown as Record<string, number>;
for (const propId in node.props) {
const expectedValue = props[propId];
const inputElement = page.locator(
`div.wrapper[data-node-type="${node.type}"][data-node-input="${propId}"] input[type="number"]`
);
const value = parseFloat(await inputElement.inputValue());
expect(value).toBe(expectedValue);
}
}
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+37
View File
@@ -0,0 +1,37 @@
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import path from 'node:path';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
tsconfigRootDir: import.meta.dirname,
svelteConfig
}
}
}
);
+50 -30
View File
@@ -1,46 +1,66 @@
{ {
"name": "@nodes/app", "name": "@nodarium/app",
"private": true, "private": true,
"version": "0.0.0", "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",
"build": "svelte-kit sync && vite build", "build": "svelte-kit sync && vite build",
"test": "vitest", "test:unit": "vitest",
"preview": "vite preview" "test": "npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test",
"preview": "vite preview",
"format": "dprint fmt -c '../.dprint.jsonc' .",
"format:check": "dprint check -c '../.dprint.jsonc' .",
"lint": "eslint .",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"bench": "tsx ./benchmark/index.ts"
}, },
"dependencies": { "dependencies": {
"@nodes/registry": "link:../packages/registry", "@nodarium/ui": "workspace:*",
"@nodes/ui": "link:../packages/ui", "@nodarium/utils": "workspace:*",
"@nodes/utils": "link:../packages/utils", "@nodarium/planty": "workspace:*",
"@sveltejs/kit": "^2.12.2", "@sveltejs/kit": "^2.50.2",
"@threlte/core": "8.0.0-next.23", "@tailwindcss/vite": "^4.1.18",
"@threlte/extras": "9.0.0-next.33", "@threlte/core": "8.3.1",
"@types/three": "^0.171.0", "@threlte/extras": "9.7.1",
"@unocss/reset": "^0.65.2",
"comlink": "^4.4.2", "comlink": "^4.4.2",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"idb": "^8.0.1", "idb": "^8.0.3",
"jsondiffpatch": "^0.6.0", "jsondiffpatch": "^0.7.3",
"three": "^0.171.0" "micromark": "^4.0.2",
"tailwindcss": "^4.1.18",
"three": "^0.182.0"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/tabler": "^1.2.13", "@eslint/compat": "^2.0.2",
"@nodes/types": "link:../packages/types", "@eslint/js": "^9.39.2",
"@sveltejs/adapter-static": "^3.0.6", "@iconify-json/tabler": "^1.2.26",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@iconify/tailwind4": "^1.2.1",
"@tsconfig/svelte": "^5.0.4", "@nodarium/types": "workspace:^",
"@playwright/test": "^1.58.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tsconfig/svelte": "^5.0.7",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@unocss/preset-icons": "^0.65.2", "@types/three": "^0.182.0",
"svelte": "^5.14.4", "@vitest/browser-playwright": "^4.0.18",
"svelte-check": "^4.1.1", "dprint": "^0.51.1",
"eslint": "^9.39.2",
"eslint-plugin-svelte": "^3.14.0",
"globals": "^17.3.0",
"svelte": "^5.49.2",
"svelte-check": "^4.3.6",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.7.2", "tsx": "^4.21.0",
"unocss": "^0.65.2", "typescript": "^5.9.3",
"vite": "^6.0.4", "typescript-eslint": "^8.54.0",
"vite-plugin-comlink": "^5.1.0", "vite": "^7.3.1",
"vite-plugin-glsl": "^1.3.1", "vite-plugin-comlink": "^5.3.0",
"vite-plugin-wasm": "^3.3.0", "vite-plugin-glsl": "^1.5.5",
"vitest": "^2.1.8" "vite-plugin-wasm": "^3.5.0",
"vitest": "^4.0.18",
"vitest-browser-svelte": "^2.0.2"
} }
} }
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: { command: 'pnpm build && pnpm preview', port: 4173 },
testDir: 'e2e',
use: {
browserName: 'firefox',
launchOptions: {
firefoxUserPrefs: {
// Force WebGL even without a GPU
'webgl.force-enabled': true,
'webgl.disabled': false,
// Use software rendering (Mesa) instead of hardware
'layers.acceleration.disabled': true,
'gfx.webrender.software': true,
'webgl.enable-webgl2': true
}
}
}
});
-1576
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
@import "tailwindcss";
@source "../../packages/ui/**/*.svelte";
@source "../../packages/planty/src/lib/**/*.svelte";
@plugin "@iconify/tailwind4" {
prefix: "i";
icon-sets: from-folder("custom", "./src/lib/icons");
}
body * {
color: var(--color-text);
}
+11 -13
View File
@@ -1,29 +1,27 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/svelte.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" />
<script defer src="https://umami.max-richter.dev/script.js" data-website-id="585c442b-0524-4874-8955-f9853b44b17e"></script>
%sveltekit.head% %sveltekit.head%
<title>Nodes</title> <title>Nodes</title>
<script> <script>
var store = localStorage.getItem("node-settings"); var store = localStorage.getItem('node-settings');
if (store) { if (store) {
try { try {
var value = JSON.parse(store); var value = JSON.parse(store);
var themes = ["dark", "light", "catppuccin"]; var themes = ['dark', 'light', 'catppuccin'];
if (themes[value.theme]) { if (themes[value.theme]) {
document.documentElement.classList.add("theme-" + themes[value.theme]); document.documentElement.classList.add('theme-' + themes[value.theme]);
} }
} catch (e) { } } catch (e) {}
} }
</script> </script>
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>
</html> </html>
+2
View File
@@ -0,0 +1,2 @@
import { PUBLIC_ANALYTIC_SCRIPT } from '$env/static/public';
export const ANALYTIC_SCRIPT = PUBLIC_ANALYTIC_SCRIPT;
-168
View File
@@ -1,168 +0,0 @@
<script lang="ts">
import type { GraphManager } from "./graph-manager.js";
import { HTML } from "@threlte/extras";
import { onMount } from "svelte";
export let position: [x: number, y: number] | null;
export let graph: GraphManager;
let input: HTMLInputElement;
let value: string = "";
let activeNodeId: string = "";
const allNodes = graph.getNodeDefinitions();
function filterNodes() {
return allNodes.filter((node) => node.id.includes(value));
}
$: nodes = value === "" ? allNodes : filterNodes();
$: if (nodes) {
if (activeNodeId === "") {
activeNodeId = nodes[0].id;
} else if (nodes.length) {
const node = nodes.find((node) => node.id === activeNodeId);
if (!node) {
activeNodeId = nodes[0].id;
}
}
}
function handleKeyDown(event: KeyboardEvent) {
event.stopImmediatePropagation();
if (event.key === "Escape") {
position = null;
return;
}
if (event.key === "ArrowDown") {
const index = nodes.findIndex((node) => node.id === activeNodeId);
activeNodeId = nodes[(index + 1) % nodes.length].id;
return;
}
if (event.key === "ArrowUp") {
const index = nodes.findIndex((node) => node.id === activeNodeId);
activeNodeId = nodes[(index - 1 + nodes.length) % nodes.length].id;
return;
}
if (event.key === "Enter") {
if (activeNodeId && position) {
graph.createNode({ type: activeNodeId, position });
position = null;
}
return;
}
}
onMount(() => {
input.disabled = false;
setTimeout(() => input.focus(), 50);
});
</script>
<HTML position.x={position?.[0]} position.z={position?.[1]} transform={false}>
<div class="add-menu-wrapper">
<div class="header">
<input
id="add-menu"
type="text"
aria-label="Search for a node type"
role="searchbox"
placeholder="Search..."
disabled={false}
on:keydown={handleKeyDown}
bind:value
bind:this={input}
/>
</div>
<div class="content">
{#each nodes as node}
<div
class="result"
role="treeitem"
tabindex="0"
aria-selected={node.id === activeNodeId}
on:keydown={(event) => {
if (event.key === "Enter") {
if (position) {
graph.createNode({ type: node.id, position, props: {} });
position = null;
}
}
}}
on:mousedown={() => {
if (position) {
graph.createNode({ type: node.id, position, props: {} });
position = null;
}
}}
on:focus={() => {
activeNodeId = node.id;
}}
class:selected={node.id === activeNodeId}
on:mouseover={() => {
activeNodeId = node.id;
}}
>
{node.id.split("/").at(-1)}
</div>
{/each}
</div>
</div>
</HTML>
<style>
.header {
padding: 5px;
}
input {
background: var(--layer-0);
font-family: var(--font-family);
border: none;
border-radius: 5px;
color: var(--text-color);
padding: 0.6em;
width: calc(100% - 2px);
box-sizing: border-box;
font-size: 0.8em;
margin-left: 1px;
margin-top: 1px;
}
input:focus {
outline: solid 2px rgba(255, 255, 255, 0.2);
}
.add-menu-wrapper {
position: absolute;
background: var(--layer-1);
border-radius: 7px;
overflow: hidden;
border: solid 2px var(--layer-2);
width: 150px;
}
.content {
min-height: none;
width: 100%;
color: var(--text-color);
}
.result {
padding: 1em 0.9em;
border-bottom: solid 1px var(--layer-2);
opacity: 0.7;
font-size: 0.9em;
cursor: pointer;
}
.result[aria-selected="true"] {
background: var(--layer-2);
opacity: 1;
}
</style>
@@ -1,4 +1,6 @@
precision highp float; precision highp float;
// For WebGL1 make sure this extension is enabled in your material:
// #extension GL_OES_standard_derivatives : enable
varying vec2 vUv; varying vec2 vUv;
@@ -9,34 +11,47 @@ uniform vec3 camPos;
uniform vec2 zoomLimits; uniform vec2 zoomLimits;
uniform vec3 backgroundColor; uniform vec3 backgroundColor;
uniform vec3 lineColor; uniform vec3 lineColor;
uniform int gridType; // 0 = grid lines, 1 = dots
// Anti-aliased step: threshold in the same units as `value`
float aaStep(float threshold, float value, float deriv) {
float w = deriv * 0.5; // ~one pixel
return smoothstep(threshold - w, threshold + w, value);
}
float grid(float x, float y, float divisions, float thickness) { float grid(float x, float y, float divisions, float thickness) {
x = fract(x * divisions); // Continuous grid coordinates
x = min(x, 1.0 - x); float gx = x * divisions;
float gy = y * divisions;
float xdelta = fwidth(x); // Distance to nearest grid line (0 at the line)
x = smoothstep(x - xdelta, x + xdelta, thickness); float fx = fract(gx);
fx = min(fx, 1.0 - fx);
float fy = fract(gy);
fy = min(fy, 1.0 - fy);
y = fract(y * divisions); // Derivatives in screen space use the continuous coords here
y = min(y, 1.0 - y); float dx = fwidth(gx);
float dy = fwidth(gy);
float ydelta = fwidth(y); // Keep the original semantics: thickness is the threshold in the [0, 0.5] distance domain
y = smoothstep(y - ydelta, y + ydelta, thickness); float lineX = 1.0 - aaStep(thickness, fx, dx);
float lineY = 1.0 - aaStep(thickness, fy, dy);
return clamp(x + y, 0.0, 1.0); return clamp(lineX + lineY, 0.0, 1.0);
} }
float circle_grid(float x, float y, float divisions, float circleRadius) { float circle_grid(float x, float y, float divisions, float circleRadius) {
float gridX = mod(x + divisions * 0.5, divisions) - divisions * 0.5;
float gridY = mod(y + divisions * 0.5, divisions) - divisions * 0.5;
float gridX = mod(x + divisions/2.0, divisions) - divisions / 2.0; vec2 g = vec2(gridX, gridY);
float gridY = mod(y + divisions/2.0, divisions) - divisions / 2.0; float d = length(g);
// Calculate the distance from the center of the grid // Screen-space derivative for AA on the circle edge
float gridDistance = length(vec2(gridX, gridY)); float w = fwidth(d);
// Use smoothstep to create a smooth transition at the edges of the circle
float circle = 1.0 - smoothstep(circleRadius - 0.5, circleRadius + 0.5, gridDistance);
float circle = 1.0 - smoothstep(circleRadius - w, circleRadius + w, d);
return circle; return circle;
} }
@@ -56,44 +71,59 @@ void main(void) {
float minZ = zoomLimits.x; float minZ = zoomLimits.x;
float maxZ = zoomLimits.y; float maxZ = zoomLimits.y;
float divisions = 0.1/cz; float divisions = 0.1 / cz;
float thickness = 0.05/cz; float thickness = 0.05 / cz;
float delta = 0.1 / 2.0;
float nz = (cz - minZ) / (maxZ - minZ); float nz = (cz - minZ) / (maxZ - minZ);
float ux = (vUv.x-0.5) * width + cx*cz; float ux = (vUv.x - 0.5) * width + cx * cz;
float uy = (vUv.y-0.5) * height - cy*cz; float uy = (vUv.y - 0.5) * height - cy * cz;
if(gridType == 0) {
//extra small grid // extra small grid
float m1 = grid(ux, uy, divisions*4.0, thickness*4.0) * 0.9; float m1 = grid(ux, uy, divisions * 4.0, thickness * 4.0) * 0.9;
float m2 = grid(ux, uy, divisions*16.0, thickness*16.0) * 0.5; float m2 = grid(ux, uy, divisions * 16.0, thickness * 16.0) * 0.5;
float xsmall = max(m1, m2); float xsmall = max(m1, m2);
float s3 = circle_grid(ux, uy, cz/1.6, 1.0) * 0.5; float s3 = circle_grid(ux, uy, cz / 1.6, 1.0) * 0.5;
xsmall = max(xsmall, s3); xsmall = max(xsmall, s3);
// small grid // small grid
float c1 = grid(ux, uy, divisions, thickness) * 0.6; float c1 = grid(ux, uy, divisions, thickness) * 0.6;
float c2 = grid(ux, uy, divisions*2.0, thickness) * 0.5; float c2 = grid(ux, uy, divisions * 2.0, thickness * 2.0) * 0.5;
float small = max(c1, c2); float small = max(c1, c2);
float s1 = circle_grid(ux, uy, cz*10.0, 2.0) * 0.5; float s1 = circle_grid(ux, uy, cz * 10.0, 2.0) * 0.5;
small = max(small, s1); small = max(small, s1);
// large grid // large grid
float c3 = grid(ux, uy, divisions/8.0, thickness/8.0) * 0.5; float c3 = grid(ux, uy, divisions / 8.0, thickness / 8.0) * 0.5;
float c4 = grid(ux, uy, divisions/2.0, thickness/4.0) * 0.4; float c4 = grid(ux, uy, divisions / 2.0, thickness / 4.0) * 0.4;
float large = max(c3, c4); float large = max(c3, c4);
float s2 = circle_grid(ux, uy, cz*20.0, 1.0) * 0.4; float s2 = circle_grid(ux, uy, cz * 20.0, 1.0) * 0.4;
large = max(large, s2); large = max(large, s2);
float c = mix(large, small, min(nz*2.0+0.05, 1.0)); float c = mix(large, small, min(nz * 2.0 + 0.05, 1.0));
c = mix(c, xsmall, max(min((nz-0.3)/0.7, 1.0), 0.0)); c = mix(c, xsmall, clamp((nz - 0.3) / 0.7, 0.0, 1.0));
vec3 color = mix(backgroundColor, lineColor, c); vec3 color = mix(backgroundColor, lineColor, c);
gl_FragColor = vec4(color, 1.0); gl_FragColor = vec4(color, 1.0);
} else {
float large = circle_grid(ux, uy, cz * 20.0, 1.0) * 0.4;
float medium = circle_grid(ux, uy, cz * 10.0, 1.0) * 0.6;
float small = circle_grid(ux, uy, cz * 2.5, 1.0) * 0.8;
float c = mix(large, medium, min(nz * 2.0 + 0.05, 1.0));
c = mix(c, small, clamp((nz - 0.3) / 0.7, 0.0, 1.0));
vec3 color = mix(backgroundColor, lineColor, c);
gl_FragColor = vec4(color, 1.0);
}
} }
@@ -1,21 +0,0 @@
<script lang="ts">
import type { Hst } from "@histoire/plugin-svelte";
export let Hst: Hst;
import Background from "./Background.svelte";
import { Canvas } from "@threlte/core";
import Camera from "../Camera.svelte";
let width = globalThis.innerWidth || 100;
let height = globalThis.innerHeight || 100;
let cameraPosition: [number, number, number] = [0, 1, 0];
</script>
<svelte:window bind:innerWidth={width} bind:innerHeight={height} />
<Hst.Story>
<Canvas shadows={false}>
<Camera bind:position={cameraPosition} />
<Background {cameraPosition} {width} {height} />
</Canvas>
</Hst.Story>
@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import { T } from "@threlte/core"; import { appSettings } from '$lib/settings/app-settings.svelte';
import BackgroundVert from "./Background.vert"; import { T } from '@threlte/core';
import BackgroundFrag from "./Background.frag"; import { colors } from '../graph/colors.svelte';
import { colors } from "../graph/colors.svelte"; import BackgroundFrag from './Background.frag';
import { Color } from "three"; import BackgroundVert from './Background.vert';
import { appSettings } from "$lib/settings/app-settings.svelte";
type Props = { type Props = {
minZoom: number; minZoom?: number;
maxZoom: number; maxZoom?: number;
cameraPosition: [number, number, number]; cameraPosition?: [number, number, number];
width: number; width?: number;
height: number; height?: number;
type?: 'grid' | 'dots' | 'none';
}; };
let { let {
@@ -20,8 +20,17 @@
cameraPosition = [0, 1, 0], cameraPosition = [0, 1, 0],
width = globalThis?.innerWidth || 100, width = globalThis?.innerWidth || 100,
height = globalThis?.innerHeight || 100, height = globalThis?.innerHeight || 100,
type = 'grid'
}: Props = $props(); }: Props = $props();
const typeMap = new Map([
['grid', 0],
['dots', 1],
['none', 2]
]);
const gridType = $derived(typeMap.get(type) || 0);
let bw = $derived(width / cameraPosition[2]); let bw = $derived(width / cameraPosition[2]);
let bh = $derived(height / cameraPosition[2]); let bh = $derived(height / cameraPosition[2]);
</script> </script>
@@ -39,27 +48,31 @@
fragmentShader={BackgroundFrag} fragmentShader={BackgroundFrag}
uniforms={{ uniforms={{
camPos: { camPos: {
value: [0, 1, 0], value: [0, 1, 0]
}, },
backgroundColor: { backgroundColor: {
value: colors["layer-0"].clone(), value: colors['layer-0']
}, },
lineColor: { lineColor: {
value: colors["outline"].clone(), value: colors['outline']
}, },
zoomLimits: { zoomLimits: {
value: [2, 50], value: [2, 50]
}, },
dimensions: { dimensions: {
value: [100, 100], value: [100, 100]
}, },
gridType: {
value: 0
}
}} }}
uniforms.camPos.value={cameraPosition} uniforms.camPos.value={cameraPosition}
uniforms.backgroundColor.value={appSettings.theme && uniforms.backgroundColor.value={appSettings.value.theme
colors["layer-0"].clone()} && colors['layer-0']}
uniforms.lineColor.value={appSettings.theme && colors["outline"].clone()} uniforms.lineColor.value={appSettings.value.theme && colors['outline']}
uniforms.zoomLimits.value={[minZoom, maxZoom]} uniforms.zoomLimits.value={[minZoom, maxZoom]}
uniforms.dimensions.value={[width, height]} uniforms.dimensions.value={[width, height]}
uniforms.gridType.value={gridType}
/> />
</T.Mesh> </T.Mesh>
</T.Group> </T.Group>
@@ -0,0 +1,244 @@
<script lang="ts">
import type { NodeId, NodeInstance } from '@nodarium/types';
import { HTML } from '@threlte/extras';
import { onMount } from 'svelte';
import { getGraphManager, getGraphState } from '../graph-state.svelte';
type Props = {
paddingLeft?: number;
paddingRight?: number;
paddingTop?: number;
paddingBottom?: number;
onnode: (n: NodeInstance) => void;
};
const padding = 10;
const {
paddingLeft = padding,
paddingRight = padding,
paddingTop = padding,
paddingBottom = padding,
onnode
}: Props = $props();
const graph = getGraphManager();
const graphState = getGraphState();
let input: HTMLInputElement;
let value = $state<string>();
let activeNodeId = $state<NodeId>();
const MENU_WIDTH = 150;
const MENU_HEIGHT = 350;
const allNodes = graphState.activeSocket
? graph.getPossibleNodes(graphState.activeSocket)
: graph.getNodeDefinitions();
function filterNodes() {
return allNodes.filter((node) => node.id.includes(value ?? ''));
}
const nodes = $derived(value === '' ? allNodes : filterNodes());
$effect(() => {
if (nodes) {
if (activeNodeId === undefined) {
activeNodeId = nodes?.[0]?.id;
} else if (nodes.length) {
const node = nodes.find((node) => node.id === activeNodeId);
if (!node) {
activeNodeId = nodes[0].id;
}
}
}
});
function handleNodeCreation(nodeType: NodeInstance['type']) {
if (!graphState.addMenuPosition) return;
onnode?.({
id: -1,
type: nodeType,
position: [...graphState.addMenuPosition],
props: {},
state: {}
});
}
function handleKeyDown(event: KeyboardEvent) {
event.stopImmediatePropagation();
if (event.key === 'Escape') {
graphState.addMenuPosition = null;
return;
}
if (event.key === 'ArrowDown') {
const index = nodes.findIndex((node) => node.id === activeNodeId);
activeNodeId = nodes[(index + 1) % nodes.length].id;
return;
}
if (event.key === 'ArrowUp') {
const index = nodes.findIndex((node) => node.id === activeNodeId);
activeNodeId = nodes[(index - 1 + nodes.length) % nodes.length].id;
return;
}
if (event.key === 'Enter') {
if (activeNodeId && graphState.addMenuPosition) {
handleNodeCreation(activeNodeId);
}
return;
}
}
function clampAddMenuPosition() {
if (!graphState.addMenuPosition) return;
const camX = graphState.cameraPosition[0];
const camY = graphState.cameraPosition[1];
const zoom = graphState.cameraPosition[2];
const halfViewportWidth = (graphState.width / 2) / zoom;
const halfViewportHeight = (graphState.height / 2) / zoom;
const halfMenuWidth = (MENU_WIDTH / 2) / zoom;
const halfMenuHeight = (MENU_HEIGHT / 2) / zoom;
const minX = camX - halfViewportWidth - halfMenuWidth + paddingLeft / zoom;
const maxX = camX + halfViewportWidth - halfMenuWidth - paddingRight / zoom;
const minY = camY - halfViewportHeight - halfMenuHeight + paddingTop / zoom;
const maxY = camY + halfViewportHeight - halfMenuHeight - paddingBottom / zoom;
const clampedX = Math.max(
minX + halfMenuWidth,
Math.min(graphState.addMenuPosition[0], maxX - halfMenuWidth)
);
const clampedY = Math.max(
minY + halfMenuHeight,
Math.min(graphState.addMenuPosition[1], maxY - halfMenuHeight)
);
if (clampedX !== graphState.addMenuPosition[0] || clampedY !== graphState.addMenuPosition[1]) {
graphState.addMenuPosition = [clampedX, clampedY];
}
}
$effect(() => {
const pos = graphState.addMenuPosition;
const zoom = graphState.cameraPosition[2];
const width = graphState.width;
const height = graphState.height;
if (pos && zoom && width && height) {
clampAddMenuPosition();
}
});
onMount(() => {
input.disabled = false;
setTimeout(() => input.focus(), 50);
});
</script>
<HTML
position.x={graphState.addMenuPosition?.[0]}
position.z={graphState.addMenuPosition?.[1]}
transform={false}
>
<div class="add-menu-wrapper">
<div class="header">
<input
id="add-menu"
type="text"
aria-label="Search for a node type"
role="searchbox"
placeholder="Search..."
disabled={false}
onkeydown={handleKeyDown}
bind:value
bind:this={input}
/>
</div>
<div class="content">
{#each nodes as node (node.id)}
<div
class="result"
role="treeitem"
tabindex="0"
aria-selected={node.id === activeNodeId}
onkeydown={(event) => {
if (event.key === 'Enter') {
handleNodeCreation(node.id);
}
}}
onmousedown={() => handleNodeCreation(node.id)}
onfocus={() => {
activeNodeId = node.id;
}}
class:selected={node.id === activeNodeId}
onmouseover={() => {
activeNodeId = node.id;
}}
>
{node.meta?.title ?? node.id.split('/').at(-1)}
</div>
{/each}
</div>
</div>
</HTML>
<style>
.header {
padding: 5px;
}
input {
background: var(--color-layer-0);
font-family: var(--font-family);
border: none;
border-radius: 5px;
color: var(--text-color);
padding: 0.6em;
width: calc(100% - 2px);
box-sizing: border-box;
font-size: 0.8em;
margin-left: 1px;
margin-top: 1px;
}
input:focus {
outline: solid 2px rgba(255, 255, 255, 0.2);
}
.add-menu-wrapper {
position: absolute;
background: var(--color-layer-1);
border-radius: 7px;
overflow: hidden;
border: solid 2px var(--color-layer-2);
width: 150px;
}
.content {
min-height: none;
width: 100%;
color: var(--text-color);
max-height: 300px;
overflow-y: auto;
}
.result {
padding: 1em 0.9em;
border-bottom: solid 1px var(--color-layer-2);
opacity: 0.7;
font-size: 0.9em;
cursor: pointer;
}
.result[aria-selected="true"] {
background: var(--color-layer-2);
opacity: 1;
}
</style>
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { HTML } from "@threlte/extras"; import { HTML } from '@threlte/extras';
type Props = { type Props = {
p1: { x: number; y: number }; p1: { x: number; y: number };
@@ -10,7 +10,7 @@
const { const {
p1 = { x: 0, y: 0 }, p1 = { x: 0, y: 0 },
p2 = { x: 0, y: 0 }, p2 = { x: 0, y: 0 },
cameraPosition = [0, 1, 0], cameraPosition = [0, 1, 0]
}: Props = $props(); }: Props = $props();
const width = $derived(Math.abs(p1.x - p2.x) * cameraPosition[2]); const width = $derived(Math.abs(p1.x - p2.x) * cameraPosition[2]);
@@ -24,14 +24,15 @@
<div <div
class="box-selection" class="box-selection"
style={`width: ${width}px; height: ${height}px;`} style={`width: ${width}px; height: ${height}px;`}
></div> >
</div>
</HTML> </HTML>
<style> <style>
.box-selection { .box-selection {
width: 40px; width: 40px;
height: 20px; height: 20px;
border: solid 2px var(--outline); border: solid 2px var(--color-outline);
border-style: dashed; border-style: dashed;
border-radius: 2px; border-radius: 2px;
} }
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { T } from "@threlte/core"; import { T } from '@threlte/core';
import { type OrthographicCamera } from "three"; import { type OrthographicCamera } from 'three';
type Props = { type Props = {
camera: OrthographicCamera; camera: OrthographicCamera;
position: [number, number, number]; position: [number, number, number];
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { NodeDefinition, NodeRegistry } from "@nodes/types"; import type { NodeDefinition, NodeRegistry } from '@nodarium/types';
import { onDestroy, onMount } from "svelte"; import { onMount } from 'svelte';
let mx = $state(0); let mx = $state(0);
let my = $state(0); let my = $state(0);
@@ -20,15 +20,15 @@
my = ev.clientY; my = ev.clientY;
if (!target) return; if (!target) return;
const closest = target?.closest?.("[data-node-type]"); const closest = target?.closest?.('[data-node-type]');
if (!closest) { if (!closest) {
node = undefined; node = undefined;
return; return;
} }
let nodeType = closest.getAttribute("data-node-type"); let nodeType = closest.getAttribute('data-node-type');
let nodeInput = closest.getAttribute("data-node-input"); let nodeInput = closest.getAttribute('data-node-input');
if (!nodeType) { if (!nodeType) {
node = undefined; node = undefined;
@@ -40,9 +40,9 @@
onMount(() => { onMount(() => {
const style = wrapper.parentElement?.style; const style = wrapper.parentElement?.style;
style?.setProperty("cursor", "help"); style?.setProperty('cursor', 'help');
return () => { return () => {
style?.removeProperty("cursor"); style?.removeProperty('cursor');
}; };
}); });
</script> </script>
@@ -53,12 +53,12 @@
class="help-wrapper p-4" class="help-wrapper p-4"
class:visible={node} class:visible={node}
bind:clientWidth={width} bind:clientWidth={width}
style="--my:{my}px; --mx:{Math.min(mx, window.innerWidth - width - 20)}px;" style="--my: {my}px; --mx: {Math.min(mx, window.innerWidth - width - 20)}px"
bind:this={wrapper} bind:this={wrapper}
> >
<p class="m-0 text-light opacity-40 flex items-center gap-3 mb-4"> <p class="m-0 text-light opacity-40 flex items-center gap-3 mb-4">
<span class="i-tabler-help block w-4 h-4"></span> <span class="i-tabler-help block w-4 h-4"></span>
{node?.id.split("/").at(-1) || "Help"} {node?.id.split('/').at(-1) || 'Help'}
{#if input} {#if input}
<span>> {input}</span> <span>> {input}</span>
{/if} {/if}
@@ -77,7 +77,7 @@
{#if !input} {#if !input}
<div> <div>
<span class="i-tabler-arrow-right opacity-30">-></span> <span class="i-tabler-arrow-right opacity-30">-></span>
{node?.outputs?.map((o) => o).join(", ") ?? "nothing"} {node?.outputs?.map((o) => o).join(', ') ?? 'nothing'}
</div> </div>
{/if} {/if}
{/if} {/if}
@@ -88,12 +88,12 @@
position: fixed; position: fixed;
pointer-events: none; pointer-events: none;
transform: translate(var(--mx), var(--my)); transform: translate(var(--mx), var(--my));
background: var(--layer-1); background: var(--color-layer-1);
border-radius: 5px; border-radius: 5px;
top: 10px; top: 10px;
left: 10px; left: 10px;
max-width: 250px; max-width: 250px;
border: 1px solid var(--outline); border: 1px solid var(--color-outline);
z-index: 10000; z-index: 10000;
display: none; display: none;
} }
+54 -8
View File
@@ -1,19 +1,65 @@
<script lang="ts"> <script lang="ts">
import { MeshLineGeometry, MeshLineMaterial } from '@threlte/extras'; import type { Box } from '@nodarium/types';
import { points, lines } from './store.js';
import { T } from '@threlte/core'; import { T } from '@threlte/core';
import { MeshLineGeometry, MeshLineMaterial } from '@threlte/extras';
import { Color, Vector3 } from 'three';
import { lines, points, rects } from './store.js';
type Line = {
points: Vector3[];
color?: Color;
};
function getEachKey(value: Vector3 | Box | Line): string {
if ('x' in value) {
return [value.x, value.y, value.z].join('-');
}
if ('minX' in value) {
return [value.maxX, value.minX, value.maxY, value.minY].join('-');
}
if ('points' in value) {
return getEachKey(value.points[Math.floor(value.points.length / 2)]);
}
return '';
}
</script> </script>
{#each $points as point} {#each $points as point (getEachKey(point))}
<T.Mesh position.x={point.x} position.y={point.y} position.z={point.z} rotation.x={-Math.PI / 2}> <T.Mesh
position.x={point.x}
position.y={point.y}
position.z={point.z}
rotation.x={-Math.PI / 2}
>
<T.CircleGeometry args={[0.2, 32]} /> <T.CircleGeometry args={[0.2, 32]} />
<T.MeshBasicMaterial color="red" /> <T.MeshBasicMaterial color="red" />
</T.Mesh> </T.Mesh>
{/each} {/each}
{#each $lines as line} {#each $rects as rect, i (getEachKey(rect))}
<T.Mesh> <T.Mesh
<MeshLineGeometry points={line} /> position.x={(rect.minX + rect.maxX) / 2}
<MeshLineMaterial color="red" linewidth={1} attenuate={false} /> position.y={0}
position.z={(rect.minY + rect.maxY) / 2}
rotation.x={-Math.PI / 2}
>
<T.PlaneGeometry args={[rect.maxX - rect.minX, rect.maxY - rect.minY]} />
<T.MeshBasicMaterial
color={new Color().setHSL((i * 1.77) % 1, 1, 0.5)}
opacity={0.9}
/>
</T.Mesh>
{/each}
{#each $lines as line (getEachKey(line))}
<T.Mesh position.y={1}>
<MeshLineGeometry points={line.points} />
<MeshLineMaterial
color={line.color || 'red'}
linewidth={1}
attenuate={false}
/>
</T.Mesh> </T.Mesh>
{/each} {/each}
+19 -7
View File
@@ -1,5 +1,8 @@
import { Vector3 } from "three/src/math/Vector3.js"; import type { Box } from '@nodarium/types';
import { lines, points } from "./store"; import type { Color } from 'three';
import { Vector3 } from 'three/src/math/Vector3.js';
import Component from './Debug.svelte';
import { lines, points, rects } from './store';
export function debugPosition(x: number, y: number) { export function debugPosition(x: number, y: number) {
points.update((p) => { points.update((p) => {
@@ -8,18 +11,27 @@ export function debugPosition(x: number, y: number) {
}); });
} }
export function debugRect(rect: Box) {
rects.update((r) => {
r.push(rect);
return r;
});
}
export function clear() { export function clear() {
points.set([]); points.set([]);
lines.set([]); lines.set([]);
rects.set([]);
} }
export function debugLine(line: Vector3[]) { export function debugLine(points: Vector3[], color?: Color) {
lines.update((l) => { lines.update((l) => {
l.push(line); l.push({ points, color });
return l; return l;
}); });
} }
import Component from "./Debug.svelte"; export default Component;
export function clearLines() {
export default Component lines.set([]);
}
+6 -4
View File
@@ -1,6 +1,8 @@
import { writable } from "svelte/store"; import type { Box } from '@nodarium/types';
import { Vector3 } from "three/src/math/Vector3.js"; import { writable } from 'svelte/store';
import type { Color } from 'three';
import { Vector3 } from 'three/src/math/Vector3.js';
export const points = writable<Vector3[]>([]); export const points = writable<Vector3[]>([]);
export const rects = writable<Box[]>([]);
export const lines = writable<Vector3[][]>([]); export const lines = writable<{ points: Vector3[]; color?: Color }[]>([]);
+83 -53
View File
@@ -1,119 +1,149 @@
<script module lang="ts"> <script module lang="ts">
import { colors } from "../graph/colors.svelte"; import { colors } from '../graph/colors.svelte';
const circleMaterial = new MeshBasicMaterial({ const circleMaterial = new MeshBasicMaterial({
color: colors.edge.clone(), color: colors.outline.clone(),
toneMapped: false, toneMapped: false
});
$effect.root(() => {
$effect(() => {
appSettings.theme;
circleMaterial.color = colors.edge.clone().convertSRGBToLinear();
});
}); });
const lineCache = new Map<number, BufferGeometry>(); $effect.root(() => {
$effect(() => {
if (appSettings.value.theme === undefined) {
return;
}
circleMaterial.color = colors.outline.clone().convertSRGBToLinear();
});
});
const curve = new CubicBezierCurve( const curve = new CubicBezierCurve(
new Vector2(0, 0), new Vector2(0, 0),
new Vector2(0, 0), new Vector2(0, 0),
new Vector2(0, 0), new Vector2(0, 0),
new Vector2(0, 0), new Vector2(0, 0)
); );
</script> </script>
<script lang="ts"> <script lang="ts">
import { T } from "@threlte/core"; import { appSettings } from '$lib/settings/app-settings.svelte';
import { MeshLineMaterial } from "@threlte/extras"; import { T } from '@threlte/core';
import { BufferGeometry, MeshBasicMaterial, Vector3 } from "three"; import { MeshLineGeometry, MeshLineMaterial } from '@threlte/extras';
import { CubicBezierCurve } from "three/src/extras/curves/CubicBezierCurve.js"; import { onDestroy } from 'svelte';
import { Vector2 } from "three/src/math/Vector2.js"; import { MeshBasicMaterial, Vector3 } from 'three';
import { createEdgeGeometry } from "./createEdgeGeometry.js"; import { CubicBezierCurve } from 'three/src/extras/curves/CubicBezierCurve.js';
import { appSettings } from "$lib/settings/app-settings.svelte"; import { Vector2 } from 'three/src/math/Vector2.js';
import { getGraphState } from '../graph-state.svelte';
import MeshGradientLineMaterial from './MeshGradientLine/MeshGradientLineMaterial.svelte';
const graphState = getGraphState();
type Props = { type Props = {
from: { x: number; y: number }; x1: number;
to: { x: number; y: number }; y1: number;
x2: number;
y2: number;
z: number; z: number;
id?: string;
inputType?: string;
outputType?: string;
}; };
const { from, to, z }: Props = $props(); const { x1, y1, x2, y2, z, inputType = 'unknown', outputType = 'unknown', id }: Props = $props();
let geometry: BufferGeometry | null = $state(null); const thickness = $derived(Math.max(0.001, 0.00082 * Math.exp(0.055 * z)));
const lineColor = $derived( const inputColor = $derived(graphState.colors.getColor(inputType));
appSettings.theme && colors.edge.clone().convertSRGBToLinear(), const outputColor = $derived(graphState.colors.getColor(outputType));
);
let lastId: number | null = null; let points = $state<Vector3[]>([]);
const primeA = 31; let lastId: string | null = null;
const primeB = 37; const curveId = $derived(`${x1}-${y1}-${x2}-${y2}`);
function update() { function update() {
const new_x = to.x - from.x; const new_x = x2 - x1;
const new_y = to.y - from.y; const new_y = y2 - y1;
const curveId = new_x * primeA + new_y * primeB;
if (lastId === curveId) { if (lastId === curveId) {
return; return;
} }
lastId = curveId;
const mid = new Vector2(new_x / 2, new_y / 2);
if (lineCache.has(curveId)) {
geometry = lineCache.get(curveId)!;
return;
}
const length = Math.floor( const length = Math.floor(
Math.sqrt(Math.pow(new_x, 2) + Math.pow(new_y, 2)) / 4, Math.sqrt(Math.pow(new_x, 2) + Math.pow(new_y, 2)) / 4
); );
const samples = Math.max(length * 16, 10); const samples = Math.max(length * 16, 10);
curve.v0.set(0, 0); curve.v0.set(0, 0);
curve.v1.set(mid.x, 0); curve.v1.set(new_x / 2, 0);
curve.v2.set(mid.x, new_y); curve.v2.set(new_x / 2, new_y);
curve.v3.set(new_x, new_y); curve.v3.set(new_x, new_y);
const points = curve points = curve
.getPoints(samples) .getPoints(samples)
.map((p) => new Vector3(p.x, 0, p.y)) .map((p) => new Vector3(p.x, 0, p.y))
.flat(); .flat();
geometry = createEdgeGeometry(points); if (id) {
lineCache.set(curveId, geometry); graphState.setEdgeGeometry(
id,
x1,
y1,
$state.snapshot(points) as unknown as Vector3[]
);
}
} }
$effect(() => { $effect(() => {
if (from || to) { if (x1 || x2 || y1 || y2) {
update(); update();
} }
}); });
onDestroy(() => {
if (id) graphState.removeEdgeGeometry(id);
});
</script> </script>
<T.Mesh <T.Mesh
position.x={from.x} position.x={x1}
position.z={from.y} position.z={y1}
position.y={0.8} position.y={0.8}
rotation.x={-Math.PI / 2} rotation.x={-Math.PI / 2}
material={circleMaterial}
> >
<T.CircleGeometry args={[0.5, 16]} /> <T.CircleGeometry args={[0.5, 16]} />
<T.MeshBasicMaterial color={inputColor} toneMapped={false} />
</T.Mesh> </T.Mesh>
<T.Mesh <T.Mesh
position.x={to.x} position.x={x2}
position.z={to.y} position.z={y2}
position.y={0.8} position.y={0.8}
rotation.x={-Math.PI / 2} rotation.x={-Math.PI / 2}
material={circleMaterial} material={circleMaterial}
> >
<T.CircleGeometry args={[0.5, 16]} /> <T.CircleGeometry args={[0.5, 16]} />
<T.MeshBasicMaterial color={outputColor} toneMapped={false} />
</T.Mesh> </T.Mesh>
{#if geometry} {#if graphState.hoveredEdgeId === id}
<T.Mesh position.x={from.x} position.z={from.y} position.y={0.1} {geometry}> <T.Mesh position.x={x1} position.z={y1} position.y={0.1}>
<MeshLineMaterial width={Math.max(z * 0.0001, 0.00001)} color={lineColor} /> <MeshLineGeometry {points} />
<MeshLineMaterial
width={thickness * 5}
color={inputColor}
tonemapped={false}
opacity={0.5}
transparent
/>
</T.Mesh> </T.Mesh>
{/if} {/if}
<T.Mesh position.x={x1} position.z={y1} position.y={0.1}>
<MeshLineGeometry {points} />
<MeshGradientLineMaterial
width={thickness}
colorStart={inputColor}
colorEnd={outputColor}
tonemapped={false}
/>
</T.Mesh>
@@ -1,12 +0,0 @@
<script lang="ts">
import Edge from "./Edge.svelte";
type Props = {
from: { x: number; y: number };
to: { x: number; y: number };
z: number;
};
const { from, to, z }: Props = $props();
</script>
<Edge {from} {to} {z} />
@@ -0,0 +1,112 @@
<script lang="ts">
import { T, useThrelte } from '@threlte/core';
import { Color, ShaderMaterial, Vector2 } from 'three';
import fragmentShader from './fragment.frag';
import type { MeshLineMaterialProps } from './types';
import vertexShader from './vertex.vert';
let {
opacity = 1,
colorStart = '#ffffff',
colorEnd = '#ffffff',
dashOffset = 0,
dashArray = 0,
dashRatio = 0,
attenuate = true,
width = 1,
scaleDown = 0,
alphaMap,
ref = $bindable(),
children,
...props
}: MeshLineMaterialProps = $props();
let { invalidate, size } = useThrelte();
// svelte-ignore state_referenced_locally
const uniforms = {
lineWidth: { value: width },
colorStart: { value: new Color(colorStart) },
colorEnd: { value: new Color(colorEnd) },
opacity: { value: opacity },
resolution: { value: new Vector2(1, 1) },
sizeAttenuation: { value: attenuate ? 1 : 0 },
dashArray: { value: dashArray },
useDash: { value: dashArray > 0 ? 1 : 0 },
dashOffset: { value: dashOffset },
dashRatio: { value: dashRatio },
scaleDown: { value: scaleDown / 10 },
alphaTest: { value: 0 },
alphaMap: { value: alphaMap },
useAlphaMap: { value: alphaMap ? 1 : 0 }
};
const material = new ShaderMaterial({ uniforms });
$effect.pre(() => {
uniforms.lineWidth.value = width;
invalidate();
});
$effect.pre(() => {
uniforms.opacity.value = opacity;
invalidate();
});
$effect.pre(() => {
uniforms.resolution.value.set($size.width, $size.height);
invalidate();
});
$effect.pre(() => {
uniforms.sizeAttenuation.value = attenuate ? 1 : 0;
invalidate();
});
$effect.pre(() => {
uniforms.dashArray.value = dashArray;
uniforms.useDash.value = dashArray > 0 ? 1 : 0;
invalidate();
});
$effect.pre(() => {
uniforms.dashOffset.value = dashOffset;
invalidate();
});
$effect.pre(() => {
uniforms.dashRatio.value = dashRatio;
invalidate();
});
$effect.pre(() => {
uniforms.scaleDown.value = scaleDown / 10;
invalidate();
});
$effect.pre(() => {
uniforms.alphaMap.value = alphaMap;
uniforms.useAlphaMap.value = alphaMap ? 1 : 0;
invalidate();
});
$effect.pre(() => {
uniforms.colorStart.value.set(colorStart);
invalidate();
});
$effect.pre(() => {
uniforms.colorEnd.value.set(colorEnd);
invalidate();
});
</script>
<T
is={material}
bind:ref
{fragmentShader}
{vertexShader}
{...props}
>
{@render children?.({ ref: material })}
</T>
@@ -0,0 +1,30 @@
uniform vec3 colorStart;
uniform vec3 colorEnd;
uniform float useDash;
uniform float dashArray;
uniform float dashOffset;
uniform float dashRatio;
uniform sampler2D alphaMap;
uniform float useAlphaMap;
varying vec2 vUV;
varying vec4 vColor;
varying float vCounters;
vec4 CustomLinearTosRGB( in vec4 value ) {
return vec4( mix( pow( value.rgb, vec3( 0.41666 ) ) * 1.055 - vec3( 0.055 ), value.rgb * 12.92, vec3( lessThanEqual( value.rgb, vec3( 0.0031308 ) ) ) ), value.a );
}
void main() {
vec4 c = mix(vec4(colorStart,1.0),vec4(colorEnd, 1.0), vCounters);
if( useAlphaMap == 1. ) c.a *= texture2D( alphaMap, vUV ).r;
if( useDash == 1. ){
c.a *= ceil(mod(vCounters + dashOffset, dashArray) - (dashArray * dashRatio));
}
gl_FragColor = CustomLinearTosRGB(c);
}
@@ -0,0 +1,68 @@
import type { Props } from '@threlte/core';
import type { BufferGeometry, Vector3 } from 'three';
import type { ColorRepresentation, ShaderMaterial, Texture } from 'three';
export type MeshLineGeometryProps = Props<BufferGeometry> & {
/**
* @default []
*/
points: Vector3[];
/**
* @default 'none'
*/
shape?: 'none' | 'taper' | 'custom';
/**
* @default () => 1
*/
shapeFunction?: (p: number) => number;
};
export type MeshLineMaterialProps =
& Omit<
Props<ShaderMaterial>,
'uniforms' | 'fragmentShader' | 'vertexShader'
>
& {
/**
* @default 1
*/
opacity?: number;
/**
* @default '#ffffff'
*/
color?: ColorRepresentation;
/**
* @default 0
*/
dashOffset?: number;
/**
* @default 0
*/
dashArray?: number;
/**
* @default 0
*/
dashRatio?: number;
/**
* @default true
*/
attenuate?: boolean;
/**
* @default 1
*/
width?: number;
/**
* @default 0
*/
scaleDown?: number;
alphaMap?: Texture | undefined;
};
@@ -0,0 +1,83 @@
attribute vec3 previous;
attribute vec3 next;
attribute float side;
attribute float width;
attribute float counters;
uniform vec2 resolution;
uniform float lineWidth;
uniform vec3 color;
uniform float opacity;
uniform float sizeAttenuation;
uniform float scaleDown;
varying vec2 vUV;
varying vec4 vColor;
varying float vCounters;
vec2 intoScreen(vec4 i) {
return resolution * (0.5 * i.xy / i.w + 0.5);
}
void main() {
float aspect = resolution.y / resolution.x;
mat4 m = projectionMatrix * modelViewMatrix;
vec4 currentClip = m * vec4( position, 1.0 );
vec4 prevClip = m * vec4( previous, 1.0 );
vec4 nextClip = m * vec4( next, 1.0 );
vec4 currentNormed = currentClip / currentClip.w;
vec4 prevNormed = prevClip / prevClip.w;
vec4 nextNormed = nextClip / nextClip.w;
vec2 currentScreen = intoScreen(currentNormed);
vec2 prevScreen = intoScreen(prevNormed);
vec2 nextScreen = intoScreen(nextNormed);
float actualWidth = lineWidth * width;
vec2 dir;
if(nextScreen == currentScreen) {
dir = normalize( currentScreen - prevScreen );
} else if(prevScreen == currentScreen) {
dir = normalize( nextScreen - currentScreen );
} else {
vec2 inDir = currentScreen - prevScreen;
vec2 outDir = nextScreen - currentScreen;
vec2 fullDir = nextScreen - prevScreen;
if(length(fullDir) > 0.0) {
dir = normalize(fullDir);
} else if(length(inDir) > 0.0){
dir = normalize(inDir);
} else {
dir = normalize(outDir);
}
}
vec2 normal = vec2(-dir.y, dir.x);
if(sizeAttenuation != 0.0) {
normal /= currentClip.w;
normal *= min(resolution.x, resolution.y);
}
if (scaleDown > 0.0) {
float dist = length(nextNormed - prevNormed);
normal *= smoothstep(0.0, scaleDown, dist);
}
vec2 offsetInScreen = actualWidth * normal * side * 0.5;
vec2 withOffsetScreen = currentScreen + offsetInScreen;
vec3 withOffsetNormed = vec3((2.0 * withOffsetScreen/resolution - 1.0), currentNormed.z);
vCounters = counters;
vColor = vec4( color, opacity );
vUV = uv;
gl_Position = currentClip.w * vec4(withOffsetNormed, 1.0);
}
@@ -1,116 +0,0 @@
import { BufferAttribute, BufferGeometry, Vector3 } from 'three';
import { setXY, setXYZ, setXYZW, setXYZXYZ } from './utils.js';
export function createEdgeGeometry(points: Vector3[]) {
const length = points[0].distanceTo(points[points.length - 1]);
const startRadius = 8;
const constantWidth = 2;
const taperFraction = 0.8 / length;
function ease(t: number) {
return t * t * (3 - 2 * t);
}
let shapeFunction = (alpha: number) => {
if (alpha < taperFraction) {
const easedAlpha = ease(alpha / taperFraction);
return startRadius + (constantWidth - startRadius) * easedAlpha;
} else if (alpha > 1 - taperFraction) {
const easedAlpha = ease((alpha - (1 - taperFraction)) / taperFraction);
return constantWidth + (startRadius - constantWidth) * easedAlpha;
} else {
return constantWidth;
}
};
// When the component first runs we create the buffer geometry and allocate the buffer attributes
let pointCount = points.length
let counters: number[] = []
let counterIndex = 0
let side: number[] = []
let widthArray: number[] = []
let doubleIndex = 0
let uvArray: number[] = []
let uvIndex = 0
let indices: number[] = []
let indicesIndex = 0
for (let j = 0; j < pointCount; j++) {
const c = j / points.length
counters[counterIndex + 0] = c
counters[counterIndex + 1] = c
counterIndex += 2
setXY(side, doubleIndex, 1, -1)
let width = shapeFunction((j / (pointCount - 1)))
setXY(widthArray, doubleIndex, width, width)
doubleIndex += 2
setXYZW(uvArray, uvIndex, j / (pointCount - 1), 0, j / (pointCount - 1), 1)
uvIndex += 4
if (j < pointCount - 1) {
const n = j * 2
setXYZ(indices, indicesIndex, n + 0, n + 1, n + 2)
setXYZ(indices, indicesIndex + 3, n + 2, n + 1, n + 3)
indicesIndex += 6
}
}
const geometry = new BufferGeometry()
// create these buffer attributes at the correct length but leave them empty for now
geometry.setAttribute('position', new BufferAttribute(new Float32Array(pointCount * 6), 3))
geometry.setAttribute('previous', new BufferAttribute(new Float32Array(pointCount * 6), 3))
geometry.setAttribute('next', new BufferAttribute(new Float32Array(pointCount * 6), 3))
// create and populate these buffer attributes
geometry.setAttribute('counters', new BufferAttribute(new Float32Array(counters), 1))
geometry.setAttribute('side', new BufferAttribute(new Float32Array(side), 1))
geometry.setAttribute('width', new BufferAttribute(new Float32Array(widthArray), 1))
geometry.setAttribute('uv', new BufferAttribute(new Float32Array(uvArray), 2))
geometry.setIndex(new BufferAttribute(new Uint16Array(indices), 1))
let positions: number[] = []
let previous: number[] = []
let next: number[] = []
let positionIndex = 0
let previousIndex = 0
let nextIndex = 0
setXYZXYZ(previous, previousIndex, points[0].x, points[0].y, points[0].z)
previousIndex += 6
for (let j = 0; j < pointCount; j++) {
const p = points[j]
setXYZXYZ(positions, positionIndex, p.x, p.y, p.z)
positionIndex += 6
if (j < pointCount - 1) {
setXYZXYZ(previous, previousIndex, p.x, p.y, p.z)
previousIndex += 6
}
if (j > 0 && j + 1 <= pointCount) {
setXYZXYZ(next, nextIndex, p.x, p.y, p.z)
nextIndex += 6
}
}
setXYZXYZ(
next,
nextIndex,
points[pointCount - 1].x,
points[pointCount - 1].y,
points[pointCount - 1].z
)
const positionAttribute = (geometry.getAttribute('position') as BufferAttribute).set(positions)
const previousAttribute = (geometry.getAttribute('previous') as BufferAttribute).set(previous)
const nextAttribute = (geometry.getAttribute('next') as BufferAttribute).set(next)
positionAttribute.needsUpdate = true
previousAttribute.needsUpdate = true
nextAttribute.needsUpdate = true
geometry.computeBoundingSphere()
return geometry;
}
+19 -19
View File
@@ -1,23 +1,23 @@
export const setXYZXYZ = (array: number[], location: number, x: number, y: number, z: number) => { export const setXYZXYZ = (array: number[], location: number, x: number, y: number, z: number) => {
array[location + 0] = x array[location + 0] = x;
array[location + 1] = y array[location + 1] = y;
array[location + 2] = z array[location + 2] = z;
array[location + 3] = x array[location + 3] = x;
array[location + 4] = y array[location + 4] = y;
array[location + 5] = z array[location + 5] = z;
} };
export const setXY = (array: number[], location: number, x: number, y: number) => { export const setXY = (array: number[], location: number, x: number, y: number) => {
array[location + 0] = x array[location + 0] = x;
array[location + 1] = y array[location + 1] = y;
} };
export const setXYZ = (array: number[], location: number, x: number, y: number, z: number) => { export const setXYZ = (array: number[], location: number, x: number, y: number, z: number) => {
array[location + 0] = x array[location + 0] = x;
array[location + 1] = y array[location + 1] = y;
array[location + 2] = z array[location + 2] = z;
} };
export const setXYZW = ( export const setXYZW = (
array: number[], array: number[],
@@ -27,8 +27,8 @@ export const setXYZW = (
z: number, z: number,
w: number w: number
) => { ) => {
array[location + 0] = x array[location + 0] = x;
array[location + 1] = y array[location + 1] = y;
array[location + 2] = z array[location + 2] = z;
array[location + 3] = w array[location + 3] = w;
} };
@@ -0,0 +1,265 @@
import { describe, expect, it } from 'vitest';
import { GraphManager } from './graph-manager.svelte';
import {
createMockNodeRegistry,
mockFloatInputNode,
mockFloatOutputNode,
mockGeometryOutputNode,
mockPathInputNode,
mockVec3OutputNode
} from './test-utils';
describe('GraphManager', () => {
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({
type: 'test/node/output',
position: [0, 0],
props: {}
});
expect(floatInputNode).toBeDefined();
expect(floatOutputNode).toBeDefined();
const possibleSockets = manager.getPossibleSockets({
node: floatOutputNode!,
index: 0,
position: [0, 0]
});
expect(possibleSockets.length).toBe(1);
const socketNodeIds = possibleSockets.map(([node]) => node.id);
expect(socketNodeIds).toContain(floatInputNode!.id);
});
it('should exclude self node from possible sockets', () => {
const registry = createMockNodeRegistry([
mockFloatOutputNode,
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);
});
it('should exclude parent nodes from possible sockets when dragging output', () => {
const registry = createMockNodeRegistry([
mockFloatOutputNode,
mockFloatInputNode
]);
const manager = new GraphManager(registry);
const parentNode = manager.createNode({
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);
});
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,611 +0,0 @@
import type { Edge, Graph, Node, NodeInput, NodeRegistry, Socket, } from "@nodes/types";
import { fastHashString } from "@nodes/utils";
import { writable, type Writable } from "svelte/store";
import EventEmitter from "./helpers/EventEmitter.js";
import { createLogger } from "./helpers/index.js";
import throttle from "./helpers/throttle.js";
import { HistoryManager } from "./history-manager.js";
const logger = createLogger("graph-manager");
logger.mute();
const clone = "structuredClone" in self ? self.structuredClone : (args: any) => JSON.parse(JSON.stringify(args));
function areSocketsCompatible(output: string | undefined, inputs: string | string[] | undefined) {
if (Array.isArray(inputs) && output) {
return inputs.includes(output);
}
return inputs === output;
}
export class GraphManager extends EventEmitter<{ "save": Graph, "result": any, "settings": { types: Record<string, NodeInput>, values: Record<string, unknown> } }> {
status: Writable<"loading" | "idle" | "error"> = writable("loading");
loaded = false;
graph: Graph = { id: 0, nodes: [], edges: [] };
id = writable(0);
private _nodes: Map<number, Node> = new Map();
nodes: Writable<Map<number, Node>> = writable(new Map());
private _edges: Edge[] = [];
edges: Writable<Edge[]> = writable([]);
settingTypes: Record<string, NodeInput> = {};
settings: Record<string, unknown> = {};
currentUndoGroup: number | null = null;
inputSockets: Writable<Set<string>> = writable(new Set());
history: HistoryManager = new HistoryManager();
execute = throttle(() => {
if (this.loaded === false) return;
this.emit("result", this.serialize());
}, 10);
constructor(public registry: NodeRegistry) {
super();
this.nodes.subscribe((nodes) => {
this._nodes = nodes;
});
this.edges.subscribe((edges) => {
this._edges = edges;
const s = new Set<string>();
for (const edge of edges) {
s.add(`${edge[2].id}-${edge[3]}`);
}
this.inputSockets.set(s);
});
}
serialize(): Graph {
logger.group("serializing graph")
const nodes = Array.from(this._nodes.values()).map(node => ({
id: node.id,
position: [...node.position],
type: node.type,
props: node.props,
})) as Node[];
const edges = this._edges.map(edge => [edge[0].id, edge[1], edge[2].id, edge[3]]) as Graph["edges"];
const serialized = { id: this.graph.id, settings: this.settings, nodes, edges };
logger.groupEnd();
return clone(serialized);
}
private lastSettingsHash = 0;
setSettings(settings: Record<string, unknown>) {
let hash = fastHashString(JSON.stringify(settings));
if (hash === this.lastSettingsHash) return;
this.lastSettingsHash = hash;
this.settings = settings;
this.save();
this.execute();
}
getNodeDefinitions() {
return this.registry.getAllNodes();
}
getLinkedNodes(node: Node) {
const nodes = new Set<Node>();
const stack = [node];
while (stack.length) {
const n = stack.pop();
if (!n) continue;
nodes.add(n);
const children = this.getChildrenOfNode(n);
const parents = this.getParentsOfNode(n);
const newNodes = [...children, ...parents].filter(n => !nodes.has(n));
stack.push(...newNodes);
}
return [...nodes.values()];
}
getEdgesBetweenNodes(nodes: Node[]): [number, number, number, string][] {
const edges = [];
for (const node of nodes) {
const children = node.tmp?.children || [];
for (const child of children) {
if (nodes.includes(child)) {
const edge = this._edges.find(e => e[0].id === node.id && e[2].id === child.id);
if (edge) {
edges.push([edge[0].id, edge[1], edge[2].id, edge[3]] as [number, number, number, string]);
}
}
}
}
return edges;
}
private _init(graph: Graph) {
const nodes = new Map(graph.nodes.map(node => {
const nodeType = this.registry.getNode(node.type);
if (nodeType) {
node.tmp = {
random: (Math.random() - 0.5) * 2,
type: nodeType
};
}
return [node.id, node]
}));
const edges = graph.edges.map((edge) => {
const from = nodes.get(edge[0]);
const to = nodes.get(edge[2]);
if (!from || !to) {
throw new Error("Edge references non-existing node");
};
from.tmp = from.tmp || {};
from.tmp.children = from.tmp.children || [];
from.tmp.children.push(to);
to.tmp = to.tmp || {};
to.tmp.parents = to.tmp.parents || [];
to.tmp.parents.push(from);
return [from, edge[1], to, edge[3]] as Edge;
})
this.edges.set(edges);
this.nodes.set(nodes);
this.execute();
}
async load(graph: Graph) {
const a = performance.now();
this.loaded = false;
this.graph = graph;
this.status.set("loading");
this.id.set(graph.id);
const nodeIds = Array.from(new Set([...graph.nodes.map(n => n.type)]));
await this.registry.load(nodeIds);
for (const node of this.graph.nodes) {
const nodeType = this.registry.getNode(node.type);
if (!nodeType) {
logger.error(`Node type not found: ${node.type}`);
this.status.set("error");
return;
}
node.tmp = node.tmp || {};
node.tmp.random = (Math.random() - 0.5) * 2;
node.tmp.type = nodeType;
}
// load settings
const settingTypes: Record<string, NodeInput> = {};
const settingValues = graph.settings || {};
const types = this.getNodeDefinitions();
for (const type of types) {
if (type.inputs) {
for (const key in type.inputs) {
let settingId = type.inputs[key].setting;
if (settingId) {
settingTypes[settingId] = { __node_type: type.id, __node_input: key, ...type.inputs[key] };
if (settingValues[settingId] === undefined && "value" in type.inputs[key]) {
settingValues[settingId] = type.inputs[key].value;
}
}
}
}
}
this.settings = settingValues;
this.emit("settings", { types: settingTypes, values: settingValues });
this.history.reset();
this._init(this.graph);
this.save();
this.status.set("idle");
this.loaded = true;
logger.log(`Graph loaded in ${performance.now() - a}ms`);
setTimeout(() => this.execute(), 100);
}
getAllNodes() {
return Array.from(this._nodes.values());
}
getNode(id: number) {
return this._nodes.get(id);
}
getNodeType(id: string) {
return this.registry.getNode(id);
}
async loadNode(id: string) {
await this.registry.load([id]);
const nodeType = this.registry.getNode(id);
if (!nodeType) return;
const settingTypes = this.settingTypes;
const settingValues = this.settings;
if (nodeType.inputs) {
for (const key in nodeType.inputs) {
let settingId = nodeType.inputs[key].setting;
if (settingId) {
settingTypes[settingId] = nodeType.inputs[key];
if (settingValues[settingId] === undefined && "value" in nodeType.inputs[key]) {
settingValues[settingId] = nodeType.inputs[key].value;
}
}
}
}
this.settings = settingValues;
this.settingTypes = settingTypes;
this.emit("settings", { types: settingTypes, values: settingValues });
}
getChildrenOfNode(node: Node) {
const children = [];
const stack = node.tmp?.children?.slice(0);
while (stack?.length) {
const child = stack.pop();
if (!child) continue;
children.push(child);
stack.push(...child.tmp?.children || []);
}
return children;
}
getNodesBetween(from: Node, to: Node): Node[] | undefined {
// < - - - - from
const toParents = this.getParentsOfNode(to);
// < - - - - from - - - - to
const fromParents = this.getParentsOfNode(from);
if (toParents.includes(from)) {
const fromChildren = this.getChildrenOfNode(from);
return toParents.filter(n => fromChildren.includes(n));
} else if (fromParents.includes(to)) {
const toChildren = this.getChildrenOfNode(to);
return fromParents.filter(n => toChildren.includes(n));
} else {
// these two nodes are not connected
return;
}
}
removeNode(node: Node, { restoreEdges = false } = {}) {
const edgesToNode = this._edges.filter((edge) => edge[2].id === node.id);
const edgesFromNode = this._edges.filter((edge) => edge[0].id === node.id);
for (const edge of [...edgesToNode, ...edgesFromNode]) {
this.removeEdge(edge, { applyDeletion: false });
}
if (restoreEdges) {
const outputSockets = edgesToNode.map(e => [e[0], e[1]] as const);
const inputSockets = edgesFromNode.map(e => [e[2], e[3]] as const);
for (const [to, toSocket] of inputSockets) {
for (const [from, fromSocket] of outputSockets) {
const outputType = from.tmp?.type?.outputs?.[fromSocket];
const inputType = to?.tmp?.type?.inputs?.[toSocket]?.type;
if (outputType === inputType) {
this.createEdge(from, fromSocket, to, toSocket, { applyUpdate: false });
continue;
}
}
}
}
this.edges.set(this._edges);
this.nodes.update((nodes) => {
nodes.delete(node.id);
return nodes;
});
this.execute()
this.save();
}
createNodeId() {
const max = Math.max(...this._nodes.keys());
return max + 1;
}
createGraph(nodes: Node[], edges: [number, number, number, string][]) {
// map old ids to new ids
const idMap = new Map<number, number>();
const startId = this.createNodeId();
nodes = nodes.map((node, i) => {
const id = startId + i;
idMap.set(node.id, id);
const type = this.registry.getNode(node.type);
if (!type) {
throw new Error(`Node type not found: ${node.type}`);
}
return { ...node, id, tmp: { type } };
});
const _edges = edges.map(edge => {
const from = nodes.find(n => n.id === idMap.get(edge[0]));
const to = nodes.find(n => n.id === idMap.get(edge[2]));
if (!from || !to) {
throw new Error("Edge references non-existing node");
}
to.tmp = to.tmp || {};
to.tmp.parents = to.tmp.parents || [];
to.tmp.parents.push(from);
from.tmp = from.tmp || {};
from.tmp.children = from.tmp.children || [];
from.tmp.children.push(to);
return [from, edge[1], to, edge[3]] as Edge;
});
for (const node of nodes) {
this._nodes.set(node.id, node);
}
this._edges.push(..._edges);
this.nodes.set(this._nodes);
this.edges.set(this._edges);
this.save();
return nodes;
}
createNode({ type, position, props = {} }: { type: Node["type"], position: Node["position"], props: Node["props"] }) {
const nodeType = this.registry.getNode(type);
if (!nodeType) {
logger.error(`Node type not found: ${type}`);
return;
}
const node: Node = { id: this.createNodeId(), type, position, tmp: { type: nodeType }, props };
this.nodes.update((nodes) => {
nodes.set(node.id, node);
return nodes;
});
this.save();
}
createEdge(from: Node, fromSocket: number, to: Node, toSocket: string, { applyUpdate = true } = {}) {
const existingEdges = this.getEdgesToNode(to);
// check if this exact edge already exists
const existingEdge = existingEdges.find(e => e[0].id === from.id && e[1] === fromSocket && e[3] === toSocket);
if (existingEdge) {
logger.error("Edge already exists", existingEdge);
return;
};
// check if socket types match
const fromSocketType = from.tmp?.type?.outputs?.[fromSocket];
const toSocketType = [to.tmp?.type?.inputs?.[toSocket]?.type];
if (to.tmp?.type?.inputs?.[toSocket]?.accepts) {
toSocketType.push(...(to?.tmp?.type?.inputs?.[toSocket]?.accepts || []));
}
if (!areSocketsCompatible(fromSocketType, toSocketType)) {
logger.error(`Socket types do not match: ${fromSocketType} !== ${toSocketType}`);
return;
}
const edgeToBeReplaced = this._edges.find(e => e[2].id === to.id && e[3] === toSocket);
if (edgeToBeReplaced) {
this.removeEdge(edgeToBeReplaced, { applyDeletion: false });
}
if (applyUpdate) {
this._edges.push([from, fromSocket, to, toSocket]);
} else {
this._edges.push([from, fromSocket, to, toSocket]);
}
from.tmp = from.tmp || {};
from.tmp.children = from.tmp.children || [];
from.tmp.children.push(to);
to.tmp = to.tmp || {};
to.tmp.parents = to.tmp.parents || [];
to.tmp.parents.push(from);
if (applyUpdate) {
this.edges.set(this._edges);
this.save();
}
this.execute();
}
undo() {
const nextState = this.history.undo();
if (nextState) {
this._init(nextState);
this.emit("save", this.serialize());
}
}
redo() {
const nextState = this.history.redo();
if (nextState) {
this._init(nextState);
this.emit("save", this.serialize());
}
}
startUndoGroup() {
this.currentUndoGroup = 1;
}
saveUndoGroup() {
this.currentUndoGroup = null;
this.save();
}
save() {
if (this.currentUndoGroup) return;
const state = this.serialize();
this.history.save(state);
this.emit("save", state);
logger.log("saving graphs", state);
}
getParentsOfNode(node: Node) {
const parents = [];
const stack = node.tmp?.parents?.slice(0);
while (stack?.length) {
if (parents.length > 1000000) {
logger.warn("Infinite loop detected")
break;
}
const parent = stack.pop();
if (!parent) continue;
parents.push(parent);
stack.push(...parent.tmp?.parents || []);
}
return parents.reverse();
}
getPossibleSockets({ node, index }: Socket): [Node, string | number][] {
const nodeType = node?.tmp?.type;
if (!nodeType) return [];
const sockets: [Node, string | number][] = []
// if index is a string, we are an input looking for outputs
if (typeof index === "string") {
// filter out self and child nodes
const children = new Set(this.getChildrenOfNode(node).map(n => n.id));
const nodes = this.getAllNodes().filter(n => n.id !== node.id && !children.has(n.id));
const ownType = nodeType?.inputs?.[index].type;
for (const node of nodes) {
const nodeType = node?.tmp?.type;
const inputs = nodeType?.outputs;
if (!inputs) continue;
for (let index = 0; index < inputs.length; index++) {
if (inputs[index] === ownType) {
sockets.push([node, index]);
}
}
}
} else if (typeof index === "number") {
// if index is a number, we are an output looking for inputs
// filter out self and parent nodes
const parents = new Set(this.getParentsOfNode(node).map(n => n.id));
const nodes = this.getAllNodes().filter(n => n.id !== node.id && !parents.has(n.id));
// get edges from this socket
const edges = new Map(this.getEdgesFromNode(node).filter(e => e[1] === index).map(e => [e[2].id, e[3]]));
const ownType = nodeType.outputs?.[index];
for (const node of nodes) {
const inputs = node?.tmp?.type?.inputs;
if (!inputs) continue;
for (const key in inputs) {
const otherType = [inputs[key].type];
otherType.push(...(inputs[key].accepts || []));
if (areSocketsCompatible(ownType, otherType) && edges.get(node.id) !== key) {
sockets.push([node, key]);
}
}
}
}
return sockets;
}
removeEdge(edge: Edge, { applyDeletion = true }: { applyDeletion?: boolean } = {}) {
const id0 = edge[0].id;
const sid0 = edge[1];
const id2 = edge[2].id;
const sid2 = edge[3];
const _edge = this._edges.find((e) => e[0].id === id0 && e[1] === sid0 && e[2].id === id2 && e[3] === sid2);
if (!_edge) return;
edge[0].tmp = edge[0].tmp || {};
if (edge[0].tmp.children) {
edge[0].tmp.children = edge[0].tmp.children.filter(n => n.id !== id2);
}
edge[2].tmp = edge[2].tmp || {};
if (edge[2].tmp.parents) {
edge[2].tmp.parents = edge[2].tmp.parents.filter(n => n.id !== id0);
}
if (applyDeletion) {
this.edges.update((edges) => {
return edges.filter(e => e !== _edge);
});
this.execute();
this.save();
} else {
this._edges = this._edges.filter(e => e !== _edge);
}
}
getEdgesToNode(node: Node) {
return this._edges
.filter((edge) => edge[2].id === node.id)
.map((edge) => {
const from = this.getNode(edge[0].id);
const to = this.getNode(edge[2].id);
if (!from || !to) return;
return [from, edge[1], to, edge[3]] as const;
})
.filter(Boolean) as unknown as [Node, number, Node, string][];
}
getEdgesFromNode(node: Node) {
return this._edges
.filter((edge) => edge[0].id === node.id)
.map((edge) => {
const from = this.getNode(edge[0].id);
const to = this.getNode(edge[2].id);
if (!from || !to) return;
return [from, edge[1], to, edge[3]] as const;
})
.filter(Boolean) as unknown as [Node, number, Node, string][];
}
}
@@ -0,0 +1,387 @@
import { animate, lerp } from '$lib/helpers';
import type { NodeInstance, Socket } from '@nodarium/types';
import { getContext, setContext } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type { OrthographicCamera, Vector3 } from 'three';
import type { GraphManager } from './graph-manager.svelte';
import { ColorGenerator } from './graph/colors';
import { getNodeHeight, getSocketPosition } from './helpers/nodeHelpers';
const graphStateKey = Symbol('graph-state');
export function getGraphState() {
return getContext<GraphState>(graphStateKey);
}
export function setGraphState(graphState: GraphState) {
return setContext(graphStateKey, graphState);
}
const graphManagerKey = Symbol('graph-manager');
export function getGraphManager() {
return getContext<GraphManager>(graphManagerKey);
}
export function setGraphManager(manager: GraphManager) {
return setContext(graphManagerKey, manager);
}
type EdgeData = {
x1: number;
y1: number;
points: Vector3[];
};
const predefinedColors = {
path: {
hue: 80,
lightness: 20,
saturation: 80
},
float: {
hue: 70,
lightness: 10,
saturation: 0
},
geometry: {
hue: 0,
lightness: 50,
saturation: 70
},
'*': {
hue: 200,
lightness: 20,
saturation: 100
}
} as const;
export class GraphState {
colors = new ColorGenerator(predefinedColors);
constructor(private graph: GraphManager) {
$effect.root(() => {
$effect(() => {
localStorage.setItem(
'cameraPosition',
`[${this.cameraPosition[0]},${this.cameraPosition[1]},${this.cameraPosition[2]}]`
);
});
});
const storedPosition = localStorage.getItem('cameraPosition');
if (storedPosition) {
try {
const d = JSON.parse(storedPosition);
this.cameraPosition[0] = d[0];
this.cameraPosition[1] = d[1];
this.cameraPosition[2] = d[2];
} catch (e) {
console.log('Failed to parsed stored camera position', e);
}
}
}
width = $state(100);
height = $state(100);
hoveredEdgeId = $state<string | null>(null);
edges = new SvelteMap<string, EdgeData>();
wrapper = $state<HTMLDivElement>(null!);
rect: DOMRect = $derived(
(this.wrapper && this.width && this.height)
? this.wrapper.getBoundingClientRect()
: new DOMRect(0, 0, 0, 0)
);
camera = $state<OrthographicCamera>(null!);
cameraPosition: [number, number, number] = $state([140, 100, 3.5]);
clipboard: null | {
nodes: NodeInstance[];
edges: [number, number, number, string][];
} = null;
// Saved camera position per group so re-entering restores where you left off
groupCameras = new Map<string, [number, number, number]>();
cameraBounds = $derived([
this.cameraPosition[0] - this.width / this.cameraPosition[2] / 2,
this.cameraPosition[0] + this.width / this.cameraPosition[2] / 2,
this.cameraPosition[1] - this.height / this.cameraPosition[2] / 2,
this.cameraPosition[1] + this.height / this.cameraPosition[2] / 2
]);
boxSelection = $state(false);
edgeEndPosition = $state<[number, number] | null>();
addMenuPosition = $state<[number, number] | null>(null);
snapToGrid = $state(false);
backgroundType = $state<'grid' | 'dots' | 'none'>('grid');
showHelp = $state(false);
cameraDown = [0, 0];
mouseDownNodeId = -1;
isPanning = $state(false);
isDragging = $state(false);
hoveredNodeId = $state(-1);
mousePosition = $state([0, 0]);
mouseDown = $state<[number, number] | null>(null);
activeNodeId = $state(-1);
selectedNodes = new SvelteSet<number>();
activeSocket = $state<Socket | null>(null);
safePadding = $state<{ left?: number; right?: number; bottom?: number; top?: number } | null>(
null
);
hoveredSocket = $state<Socket | null>(null);
possibleSockets = $state<Socket[]>([]);
possibleSocketIds = $derived(
new SvelteSet(this.possibleSockets.map((s) => `${s.node.id}-${s.index}`))
);
getEdges() {
return $state.snapshot(this.edges);
}
clearSelection() {
this.selectedNodes.clear();
}
isBodyFocused = () => document?.activeElement?.nodeName !== 'INPUT';
setEdgeGeometry(edgeId: string, x1: number, y1: number, points: Vector3[]) {
this.edges.set(edgeId, { x1, y1, points });
}
removeEdgeGeometry(edgeId: string) {
this.edges.delete(edgeId);
}
getEdgeData() {
return this.edges;
}
updateNodePosition(node: NodeInstance) {
if (
node.state.x === node.position[0]
&& node.state.y === node.position[1]
) {
delete node.state.x;
delete node.state.y;
}
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`);
}
}
}
getSnapLevel() {
const z = this.cameraPosition[2];
if (z > 66) {
return 8;
} else if (z > 55) {
return 4;
} else if (z > 11) {
return 2;
}
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() {
if (this.activeNodeId === -1 && !this.selectedNodes?.size) {
return;
}
let nodes = [
this.activeNodeId,
...(this.selectedNodes?.values() || [])
]
.map((id) => this.graph.getNode(id))
.filter(b => !!b);
const edges = this.graph.getEdgesBetweenNodes(nodes);
nodes = nodes.map((node) => ({
...node,
position: [
this.mousePosition[0] - node.position[0],
this.mousePosition[1] - node.position[1]
],
tmp: undefined
}));
this.clipboard = {
nodes: nodes,
edges: edges
};
}
centerNode(node?: NodeInstance) {
const average = [0, 0, 4];
if (node) {
average[0] = node.position[0] + (this.safePadding?.right || 0) / 10;
average[1] = node.position[1];
average[2] = 10;
} else {
for (const node of this.graph.nodes.values()) {
average[0] += node.position[0];
average[1] += node.position[1];
}
average[0] = (average[0] / this.graph.nodes.size)
+ (this.safePadding?.right || 0) / (average[2] * 2);
average[1] /= this.graph.nodes.size;
}
const camX = this.cameraPosition[0];
const camY = this.cameraPosition[1];
const camZ = this.cameraPosition[2];
const ease = (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t);
const easeZoom = (t: number) => t * t * (3 - 2 * t);
animate(500, (a: number) => {
this.cameraPosition[0] = lerp(camX, average[0], ease(a));
this.cameraPosition[1] = lerp(camY, average[1], ease(a));
this.cameraPosition[2] = lerp(camZ, average[2], easeZoom(a));
if (this.mouseDown) return false;
});
}
pasteNodes() {
if (!this.clipboard) return;
const nodes = this.clipboard.nodes
.map((node) => {
node.position[0] = this.mousePosition[0] - node.position[0];
node.position[1] = this.mousePosition[1] - node.position[1];
return node;
})
.filter(Boolean) as NodeInstance[];
const newNodes = this.graph.createGraph(nodes, this.clipboard.edges);
this.selectedNodes.clear();
for (const node of newNodes) {
this.selectedNodes.add(node.id);
}
}
setDownSocket(socket: Socket) {
this.activeSocket = socket;
let { node, index, position } = socket;
// remove existing edge
if (typeof index === 'string') {
const edges = this.graph.getEdgesToNode(node);
for (const edge of edges) {
if (edge[3] === index) {
node = edge[0];
index = edge[1];
position = getSocketPosition(node, index);
this.graph.removeEdge(edge);
break;
}
}
}
this.mouseDown = position;
this.activeSocket = {
node,
index,
position
};
this.possibleSockets = this.graph
.getPossibleSockets(this.activeSocket)
.map(([node, index]) => {
return {
node,
index,
position: getSocketPosition(node, index)
};
});
}
projectScreenToWorld(x: number, y: number): [number, number] {
return [
this.cameraPosition[0]
+ (x - this.width / 2) / this.cameraPosition[2],
this.cameraPosition[1]
+ (y - this.height / 2) / this.cameraPosition[2]
];
}
getNodeIdFromEvent(event: MouseEvent) {
let clickedNodeId = -1;
const mx = event.clientX - this.rect.x;
const my = event.clientY - this.rect.y;
if (event.button === 0) {
// check if the clicked element is a node
if (event.target instanceof HTMLElement) {
const nodeElement = event.target.closest('.node');
const nodeId = nodeElement?.getAttribute?.('data-node-id');
if (nodeId) {
clickedNodeId = parseInt(nodeId, 10);
}
}
// if we do not have an active node,
// we are going to check if we clicked on a node by coordinates
if (clickedNodeId === -1) {
const [downX, downY] = this.projectScreenToWorld(mx, my);
for (const node of this.graph.nodes.values()) {
const x = node.position[0];
const y = node.position[1];
const height = getNodeHeight(node.state.type!);
if (downX > x && downX < x + 20 && downY > y && downY < y + height) {
clickedNodeId = node.id;
break;
}
}
}
}
return clickedNodeId;
}
isNodeInView(node: NodeInstance) {
const height = getNodeHeight(node.state.type!);
const width = 20;
return node.position[0] > this.cameraBounds[0] - width
&& node.position[0] < this.cameraBounds[1]
&& node.position[1] > this.cameraBounds[2] - height
&& node.position[1] < this.cameraBounds[3];
}
openNodePalette() {
this.addMenuPosition = [this.mousePosition[0], this.mousePosition[1]];
}
}
File diff suppressed because it is too large Load Diff
@@ -1,94 +0,0 @@
<script lang="ts">
import type { Edge as EdgeType, Node as NodeType } from "@nodes/types";
import { HTML } from "@threlte/extras";
import Edge from "../edges/Edge.svelte";
import Node from "../node/Node.svelte";
import { getContext, onMount } from "svelte";
import type { Writable } from "svelte/store";
import { getGraphState } from "./state.svelte";
import { useThrelte } from "@threlte/core";
import { appSettings } from "$lib/settings/app-settings.svelte";
type Props = {
nodes: Writable<Map<number, NodeType>>;
edges: Writable<EdgeType[]>;
cameraPosition: [number, number, number];
};
const { nodes, edges, cameraPosition = [0, 0, 4] }: Props = $props();
const { invalidate } = useThrelte();
$effect(() => {
appSettings.theme;
invalidate();
});
const graphState = getGraphState();
const isNodeInView = getContext<(n: NodeType) => boolean>("isNodeInView");
const getSocketPosition =
getContext<(node: NodeType, index: string | number) => [number, number]>(
"getSocketPosition",
);
function getEdgePosition(edge: EdgeType) {
const pos1 = getSocketPosition(edge[0], edge[1]);
const pos2 = getSocketPosition(edge[2], edge[3]);
return [pos1[0], pos1[1], pos2[0], pos2[1]];
}
onMount(() => {
for (const node of $nodes.values()) {
if (node?.tmp?.ref) {
node.tmp.ref.style.setProperty("--nx", `${node.position[0] * 10}px`);
node.tmp.ref.style.setProperty("--ny", `${node.position[1] * 10}px`);
}
}
});
</script>
{#each $edges as edge (`${edge[0].id}-${edge[1]}-${edge[2].id}-${edge[3]}`)}
{@const pos = getEdgePosition(edge)}
{@const [x1, y1, x2, y2] = pos}
<Edge
z={cameraPosition[2]}
from={{
x: x1,
y: y1,
}}
to={{
x: x2,
y: y2,
}}
/>
{/each}
<HTML transform={false}>
<div
role="tree"
id="graph"
tabindex="0"
class="wrapper"
style:transform={`scale(${cameraPosition[2] * 0.1})`}
class:hovering-sockets={graphState.activeSocket}
>
{#each $nodes.values() as node (node.id)}
<Node
{node}
inView={cameraPosition && isNodeInView(node)}
z={cameraPosition[2]}
/>
{/each}
</div>
</HTML>
<style>
.wrapper {
position: absolute;
z-index: 100;
width: 0px;
height: 0px;
}
</style>
+138 -33
View File
@@ -1,78 +1,183 @@
<script lang="ts"> <script lang="ts">
import type { Graph, Node, NodeRegistry } from "@nodes/types"; import { createKeyMap } from '$lib/helpers/createKeyMap';
import GraphEl from "./Graph.svelte"; import type { Graph, NodeInstance, NodeRegistry } from '@nodarium/types';
import { GraphManager } from "../graph-manager.js"; import { GraphManager } from '../graph-manager.svelte';
import { setContext } from "svelte"; import { GraphState, setGraphManager, setGraphState } from '../graph-state.svelte';
import { debounce } from "$lib/helpers"; import { setupKeymaps } from '../keymaps';
import { createKeyMap } from "$lib/helpers/createKeyMap"; import GraphEl from './Graph.svelte';
import { GraphState } from "./state.svelte";
const graphState = new GraphState();
setContext("graphState", graphState);
type Props = { type Props = {
graph: Graph; graph?: Graph;
registry: NodeRegistry; registry: NodeRegistry;
settings?: Record<string, any>; settings?: Record<string, unknown>;
activeNode?: Node; activeNode?: NodeInstance;
showGrid?: boolean; backgroundType?: 'grid' | 'dots' | 'none';
snapToGrid?: boolean; snapToGrid?: boolean;
showHelp?: boolean; showHelp?: boolean;
settingTypes?: Record<string, any>; settingTypes?: Record<string, unknown>;
safePadding?: { left?: number; right?: number; bottom?: number; top?: number };
onsave?: (save: Graph) => void; onsave?: (save: Graph) => void;
onresult?: (result: any) => void; onresult?: (result: unknown) => void;
}; };
let { let {
graph, graph,
registry, registry,
safePadding,
settings = $bindable(), settings = $bindable(),
activeNode = $bindable(), activeNode = $bindable(),
showGrid, backgroundType = $bindable('grid'),
snapToGrid, snapToGrid = $bindable(true),
showHelp = $bindable(false), showHelp = $bindable(false),
settingTypes = $bindable(), settingTypes = $bindable(),
onsave, onsave,
onresult, onresult
}: Props = $props(); }: Props = $props();
export const keymap = createKeyMap([]); export const keymap = createKeyMap([]);
setContext("keymap", keymap);
// svelte-ignore state_referenced_locally
export const manager = new GraphManager(registry); export const manager = new GraphManager(registry);
setContext("graphManager", manager); setGraphManager(manager);
export const state = new GraphState(manager);
$effect(() => {
if (safePadding) {
state.safePadding = safePadding;
}
state.backgroundType = backgroundType;
state.snapToGrid = snapToGrid;
state.showHelp = showHelp;
});
setGraphState(state);
setupKeymaps(keymap, manager, state);
$effect(() => { $effect(() => {
if (graphState.activeNodeId !== -1) { if (state.activeNodeId !== -1) {
activeNode = manager.getNode(graphState.activeNodeId); activeNode = manager.getNode(state.activeNodeId);
} else if (activeNode) { } else if (activeNode) {
activeNode = undefined; activeNode = undefined;
} }
}); });
const updateSettings = debounce((s) => {
manager.setSettings(s);
}, 200);
$effect(() => { $effect(() => {
if (settingTypes && settings) { if (!state.addMenuPosition) {
updateSettings($state.snapshot(settings)); state.edgeEndPosition = null;
state.activeSocket = null;
} }
}); });
manager.on("settings", (_settings) => { manager.on('settings', (_settings) => {
settingTypes = { ...settingTypes, ..._settings.types }; settingTypes = { ...settingTypes, ..._settings.types };
settings = _settings.values; settings = _settings.values;
}); });
manager.on("result", (result) => onresult?.(result)); manager.on('result', (result) => onresult?.(result));
manager.on("save", (save) => onsave?.(save)); manager.on('save', (save) => onsave?.(save));
$effect(() => {
if (graph) {
manager.load(graph); manager.load(graph);
}
});
function navigateToBreadcrumb(index: number) {
const crumbs = manager.breadcrumbs;
const depth = crumbs.length - 1 - index;
let result: { camera: [number, number, number]; nodeId: number } | false = false;
for (let i = 0; i < depth; i++) {
const groupId = manager.currentGroupContext;
if (groupId) {
state.groupCameras.set(groupId, [...state.cameraPosition] as [number, number, number]);
}
result = manager.exitGroup();
}
if (result !== false) {
state.activeNodeId = result.nodeId;
state.clearSelection();
state.cameraPosition[0] = result.camera[0];
state.cameraPosition[1] = result.camera[1];
state.cameraPosition[2] = result.camera[2];
} else {
state.activeNodeId = -1;
state.clearSelection();
state.centerNode();
}
}
</script> </script>
<GraphEl bind:showGrid bind:snapToGrid bind:showHelp /> {#if manager.isInsideGroup}
<div class="breadcrumb-bar">
{#each manager.breadcrumbs as crumb, i}
{#if i > 0}
<span class="sep"></span>
{/if}
<button
class="crumb"
class:active={i === manager.breadcrumbs.length - 1}
onclick={() => navigateToBreadcrumb(i)}
>
{crumb.name}
</button>
{/each}
</div>
{/if}
<GraphEl {keymap} {safePadding} />
<style>
.breadcrumb-bar {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
display: flex;
align-items: center;
gap: 4px;
background: rgba(10, 15, 28, 0.85);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
padding: 4px 10px;
font-size: 12px;
pointer-events: all;
backdrop-filter: blur(8px);
}
.sep {
opacity: 0.4;
font-size: 14px;
}
.crumb {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
font-size: 12px;
transition: color 0.15s, background 0.15s;
}
.crumb:hover {
color: white;
background: rgba(255, 255, 255, 0.08);
}
.crumb.active {
color: white;
cursor: default;
}
.crumb.active:hover {
background: none;
}
</style>
@@ -1,32 +1,34 @@
import { appSettings } from "$lib/settings/app-settings.svelte"; import { appSettings } from '$lib/settings/app-settings.svelte';
import { Color, LinearSRGBColorSpace } from "three"; import { Color, LinearSRGBColorSpace } from 'three';
const variables = [ const variables = [
"layer-0", 'layer-0',
"layer-1", 'layer-1',
"layer-2", 'layer-2',
"layer-3", 'layer-3',
"outline", 'outline',
"active", 'active',
"selected", 'selected',
"edge", 'connection'
] as const; ] as const;
function getColor(variable: typeof variables[number]) { function getColor(variable: (typeof variables)[number]) {
const style = getComputedStyle(document.body.parentElement!); const style = getComputedStyle(document.body.parentElement!);
let color = style.getPropertyValue(`--${variable}`); const color = style.getPropertyValue(`--color-${variable}`);
return new Color().setStyle(color, LinearSRGBColorSpace); return new Color().setStyle(color, LinearSRGBColorSpace);
} }
export const colors = Object.fromEntries(variables.map(v => [v, getColor(v)])) as Record<typeof variables[number], Color>; export const colors = Object.fromEntries(
variables.map((v) => [v, getColor(v)])
) as Record<(typeof variables)[number], Color>;
$effect.root(() => { $effect.root(() => {
$effect(() => { $effect(() => {
if (!appSettings.theme || !("getComputedStyle" in globalThis)) return; if (!appSettings.value.theme || !('getComputedStyle' in globalThis)) return;
const style = getComputedStyle(document.body.parentElement!); const style = getComputedStyle(document.body.parentElement!);
for (const v of variables) { for (const v of variables) {
const hex = style.getPropertyValue(`--${v}`); const hex = style.getPropertyValue(`--color-${v}`);
colors[v].setStyle(hex, LinearSRGBColorSpace); colors[v].setStyle(hex, LinearSRGBColorSpace);
} }
}); });
}) });
@@ -0,0 +1,44 @@
type Color = { hue: number; saturation: number; lightness: number };
export class ColorGenerator {
private colors: Map<string, Color> = new Map();
private lightnessLevels = [10, 60];
constructor(predefined: Record<string, Color>) {
for (const [id, colorStr] of Object.entries(predefined)) {
this.colors.set(id, colorStr);
}
}
public getColor(id: string): string {
if (this.colors.has(id)) {
return this.colorToHsl(this.colors.get(id)!);
}
const newColor = this.generateNewColor();
this.colors.set(id, newColor);
return this.colorToHsl(newColor);
}
private generateNewColor(): Color {
const existingHues = Array.from(this.colors.values()).map(c => c.hue).sort();
let hue = existingHues[0];
let attempts = 0;
while (
existingHues.some(h => Math.abs(h - hue) < 30 || Math.abs(h - hue) > 330)
&& attempts < 360
) {
hue = (hue + 30) % 360;
attempts++;
}
const lightness = 60;
return { hue, lightness, saturation: 100 };
}
private colorToHsl(c: Color): string {
return `hsl(${c.hue}, ${c.saturation}%, ${c.lightness}%)`;
}
}
@@ -0,0 +1,3 @@
export const minZoom = 1;
export const maxZoom = 40;
export const zoomSpeed = 2;
@@ -1,6 +0,0 @@
import type { GraphManager } from "../graph-manager.js";
import { getContext } from "svelte";
export function getGraphManager(): GraphManager {
return getContext("graphManager");
}
@@ -0,0 +1,109 @@
import { GraphSchema, type NodeId } from '@nodarium/types';
import type { GraphManager } from '../graph-manager.svelte';
import type { GraphState } from '../graph-state.svelte';
export class FileDropEventManager {
constructor(
private graph: GraphManager,
private state: GraphState
) {}
handleFileDrop(event: DragEvent) {
event.preventDefault();
this.state.isDragging = false;
if (!event.dataTransfer) return;
const nodeId = event.dataTransfer.getData('data/node-id') as NodeId;
let mx = event.clientX - this.state.rect.x;
let my = event.clientY - this.state.rect.y;
if (nodeId) {
const nodeOffsetX = event.dataTransfer.getData('data/node-offset-x');
const nodeOffsetY = event.dataTransfer.getData('data/node-offset-y');
if (nodeOffsetX && nodeOffsetY) {
mx += parseInt(nodeOffsetX);
my += parseInt(nodeOffsetY);
}
let props = {};
const rawNodeProps = event.dataTransfer.getData('data/node-props');
if (rawNodeProps) {
try {
props = JSON.parse(rawNodeProps);
} catch (e) {
console.error('Failed to parse node dropped', e);
}
}
const pos = this.state.projectScreenToWorld(mx, my);
this.graph.registry.load([nodeId]).then(() => {
this.graph.createNode({
type: nodeId,
props,
position: pos
});
});
} else if (event.dataTransfer.files.length) {
const file = event.dataTransfer.files[0];
if (file.type === 'application/wasm') {
const reader = new FileReader();
reader.onload = async (e) => {
const buffer = e.target?.result;
if (buffer?.constructor === ArrayBuffer) {
const nodeType = await this.graph.registry.register(nodeId, buffer);
this.graph.createNode({
type: nodeType.id,
props: {},
position: this.state.projectScreenToWorld(mx, my)
});
}
};
reader.readAsArrayBuffer(file);
} else if (file.type === 'application/json') {
const reader = new FileReader();
reader.onload = (e) => {
const buffer = e.target?.result as ArrayBuffer;
if (buffer) {
const state = GraphSchema.parse(JSON.parse(buffer.toString()));
this.graph.load(state);
}
};
reader.readAsText(file);
}
}
}
handleMouseLeave() {
this.state.isDragging = false;
this.state.isPanning = false;
}
handleDragEnter(e: DragEvent) {
e.preventDefault();
this.state.isDragging = true;
this.state.isPanning = false;
}
handleDragOver(e: DragEvent) {
e.preventDefault();
this.state.isDragging = true;
this.state.isPanning = false;
}
handleDragEnd(e: DragEvent) {
e.preventDefault();
this.state.isDragging = true;
this.state.isPanning = false;
}
getEventListenerProps() {
return {
ondragenter: (ev: DragEvent) => this.handleDragEnter(ev),
ondragover: (ev: DragEvent) => this.handleDragOver(ev),
ondragexit: (ev: DragEvent) => this.handleDragEnd(ev),
ondrop: (ev: DragEvent) => this.handleFileDrop(ev),
onmouseleave: () => this.handleMouseLeave()
};
}
}
@@ -0,0 +1,117 @@
import type { Box } from '@nodarium/types';
import type { GraphManager } from '../graph-manager.svelte';
import type { GraphState } from '../graph-state.svelte';
import { distanceFromPointToSegment } from '../helpers';
export class EdgeInteractionManager {
constructor(
private graph: GraphManager,
private state: GraphState
) {}
private MIN_DISTANCE = 3;
private _boundingBoxes = new Map<string, Box>();
handleMouseDown() {
this._boundingBoxes.clear();
const possibleEdges = this.graph
.getPossibleDropOnEdges(this.state.activeNodeId)
.map(e => this.graph.getEdgeId(e));
const edges = this.state.getEdges();
for (const edge of edges) {
const edgeId = edge[0];
if (!possibleEdges.includes(edgeId)) {
edges.delete(edgeId);
}
}
for (const [edgeId, data] of edges) {
const points = data.points;
let minX = points[0].x + data.x1;
let maxX = points[0].x + data.x1;
let minY = points[0].z + data.y1;
let maxY = points[0].z + data.y1;
// Iterate through all points to find the true bounds
for (let i = 0; i < points.length; i++) {
const x = data.x1 + points[i].x;
const y = data.y1 + points[i].z;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
const boundingBox = {
minX: minX - this.MIN_DISTANCE,
maxX: maxX + this.MIN_DISTANCE,
minY: minY - this.MIN_DISTANCE,
maxY: maxY + this.MIN_DISTANCE
};
this._boundingBoxes.set(edgeId, boundingBox);
}
}
handleMouseMove() {
const [mouseX, mouseY] = this.state.mousePosition;
const hoveredEdgeIds: string[] = [];
const edges = this.state.getEdges();
// Check if mouse is inside any bounding box
for (const [edgeId, box] of this._boundingBoxes) {
const isInside = mouseX >= box.minX
&& mouseX <= box.maxX
&& mouseY >= box.minY
&& mouseY <= box.maxY;
if (isInside) {
hoveredEdgeIds.push(edgeId);
}
}
let hoveredEdgeId: string | null = null;
let hoveredEdgeDistance = Infinity;
const DENSITY = 10; // higher DENSITY = less points checked (yes density might not be the best name :-)
for (const edgeId of hoveredEdgeIds) {
const edge = edges.get(edgeId)!;
for (let i = 0; i < edge.points.length - DENSITY; i += DENSITY) {
const pointAx = edge.points[i].x + edge.x1;
const pointAy = edge.points[i].z + edge.y1;
const pointBx = edge.points[i + DENSITY].x + edge.x1;
const pointBy = edge.points[i + DENSITY].z + edge.y1;
const distance = distanceFromPointToSegment(
pointAx,
pointAy,
pointBx,
pointBy,
mouseX,
mouseY
);
if (distance < this.MIN_DISTANCE) {
if (distance < hoveredEdgeDistance) {
hoveredEdgeDistance = distance;
hoveredEdgeId = edgeId;
}
}
}
}
this.state.hoveredEdgeId = hoveredEdgeId;
}
handleMouseUp() {
if (this.state.hoveredEdgeId) {
const edge = this.graph.getEdgeById(this.state.hoveredEdgeId);
if (edge) {
this.graph.dropNodeOnEdge(this.state.activeNodeId, edge);
}
this.state.hoveredEdgeId = null;
}
}
}
@@ -0,0 +1,402 @@
import { animate, lerp } from '$lib/helpers';
import { type NodeInstance } from '@nodarium/types';
import type { GraphManager } from '../graph-manager.svelte';
import { type GraphState } from '../graph-state.svelte';
import { snapToGrid as snapPointToGrid } from '../helpers';
import { getNodeHeight } from '../helpers/nodeHelpers';
import { maxZoom, minZoom, zoomSpeed } from './constants';
import { EdgeInteractionManager } from './edge.events';
export class MouseEventManager {
edgeInteractionManager: EdgeInteractionManager;
constructor(
private graph: GraphManager,
private state: GraphState
) {
this.edgeInteractionManager = new EdgeInteractionManager(graph, state);
}
handleWindowMouseUp(event: MouseEvent) {
this.edgeInteractionManager.handleMouseUp();
this.state.isPanning = false;
if (!this.state.mouseDown) return;
const activeNode = this.graph.getNode(this.state.activeNodeId);
const clickedNodeId = this.state.getNodeIdFromEvent(event);
if (clickedNodeId !== -1) {
if (activeNode) {
if (!activeNode?.state?.isMoving && !event.ctrlKey && !event.shiftKey) {
this.state.activeNodeId = clickedNodeId;
this.state.clearSelection();
}
}
}
if (activeNode?.state?.isMoving) {
activeNode.state = activeNode.state || {};
activeNode.state.isMoving = false;
if (this.state.snapToGrid) {
const snapLevel = this.state.getSnapLevel();
activeNode.position[0] = snapPointToGrid(
activeNode?.state?.x ?? activeNode.position[0],
5 / snapLevel
);
activeNode.position[1] = snapPointToGrid(
activeNode?.state?.y ?? activeNode.position[1],
5 / snapLevel
);
} else {
activeNode.position[0] = activeNode?.state?.x ?? activeNode.position[0];
activeNode.position[1] = activeNode?.state?.y ?? activeNode.position[1];
}
const nodes = [
...[...(this.state.selectedNodes?.values() || [])].map((id) => this.graph.getNode(id))
] as NodeInstance[];
const vec = [
activeNode.position[0] - (activeNode?.state.x || 0),
activeNode.position[1] - (activeNode?.state.y || 0)
];
for (const node of nodes) {
if (!node) continue;
node.state = node.state || {};
const { x, y } = node.state;
if (x !== undefined && y !== undefined) {
node.position[0] = x + vec[0];
node.position[1] = y + vec[1];
}
}
nodes.push(activeNode);
animate(500, (a: number) => {
for (const node of nodes) {
if (
node?.state
&& node.state['x'] !== undefined
&& node.state['y'] !== undefined
) {
node.state.x = lerp(node.state.x, node.position[0], a);
node.state.y = lerp(node.state.y, node.position[1], a);
this.state.updateNodePosition(node);
if (node?.state?.isMoving) {
return false;
}
}
}
});
this.graph.save();
} else if (this.state.hoveredSocket && this.state.activeSocket) {
if (
typeof this.state.hoveredSocket.index === 'number'
&& typeof this.state.activeSocket.index === 'string'
) {
this.graph.createEdge(
this.state.hoveredSocket.node,
this.state.hoveredSocket.index || 0,
this.state.activeSocket.node,
this.state.activeSocket.index
);
} else if (
typeof this.state.activeSocket.index == 'number'
&& typeof this.state.hoveredSocket.index === 'string'
) {
this.graph.createEdge(
this.state.activeSocket.node,
this.state.activeSocket.index || 0,
this.state.hoveredSocket.node,
this.state.hoveredSocket.index
);
}
this.graph.save();
} else if (this.state.activeSocket && event.ctrlKey) {
// Handle automatic adding of nodes on ctrl+mouseUp
this.state.edgeEndPosition = [
this.state.mousePosition[0],
this.state.mousePosition[1]
];
if (typeof this.state.activeSocket.index === 'number') {
this.state.addMenuPosition = [
this.state.mousePosition[0],
this.state.mousePosition[1] - 25 / this.state.cameraPosition[2]
];
} else {
this.state.addMenuPosition = [
this.state.mousePosition[0] - 155 / this.state.cameraPosition[2],
this.state.mousePosition[1] - 25 / this.state.cameraPosition[2]
];
}
return;
}
// check if camera moved
if (
clickedNodeId === -1
&& !this.state.boxSelection
&& this.state.cameraDown[0] === this.state.cameraPosition[0]
&& this.state.cameraDown[1] === this.state.cameraPosition[1]
&& this.state.isBodyFocused()
) {
this.state.activeNodeId = -1;
this.state.clearSelection();
}
this.state.mouseDown = null;
this.state.boxSelection = false;
this.state.activeSocket = null;
this.state.possibleSockets = [];
this.state.hoveredSocket = null;
this.state.addMenuPosition = null;
}
handleContextMenu(event: MouseEvent) {
if (!this.state.addMenuPosition) {
event.preventDefault();
this.state.openNodePalette();
}
}
handleMouseDown(event: MouseEvent) {
// Right click
if (event.button === 2) {
return;
}
if (this.state.mouseDown) return;
this.state.edgeEndPosition = null;
const target = event.target as HTMLElement;
if (
target.nodeName !== 'CANVAS'
&& !target.classList.contains('node')
&& !target.classList.contains('content')
) {
return;
}
const mx = event.clientX - this.state.rect.x;
const my = event.clientY - this.state.rect.y;
this.state.mouseDown = [mx, my];
this.state.cameraDown[0] = this.state.cameraPosition[0];
this.state.cameraDown[1] = this.state.cameraPosition[1];
const clickedNodeId = this.state.getNodeIdFromEvent(event);
this.state.mouseDownNodeId = clickedNodeId;
// if we clicked on a node
if (clickedNodeId !== -1) {
if (event.ctrlKey && event.shiftKey) {
this.state.tryConnectToDebugNode(clickedNodeId);
return;
}
if (this.state.activeNodeId === -1) {
this.state.activeNodeId = clickedNodeId;
// if the selected node is the same as the clicked node
} else if (this.state.activeNodeId === clickedNodeId) {
// $activeNodeId = -1;
// if the clicked node is different from the selected node and secondary
} else if (event.ctrlKey) {
this.state.selectedNodes.add(this.state.activeNodeId);
this.state.selectedNodes.delete(clickedNodeId);
this.state.activeNodeId = clickedNodeId;
// select the node
} else if (event.shiftKey) {
const activeNode = this.graph.getNode(this.state.activeNodeId);
const newNode = this.graph.getNode(clickedNodeId);
if (activeNode && newNode) {
const edge = this.graph.getNodesBetween(activeNode, newNode);
if (edge) {
this.state.selectedNodes.clear();
for (const node of edge) {
this.state.selectedNodes.add(node.id);
}
this.state.selectedNodes.add(clickedNodeId);
}
}
} else if (!this.state.selectedNodes.has(clickedNodeId)) {
this.state.activeNodeId = clickedNodeId;
this.state.clearSelection();
}
this.edgeInteractionManager.handleMouseDown();
} else if (event.ctrlKey) {
this.state.boxSelection = true;
}
const node = this.graph.getNode(this.state.activeNodeId);
if (!node) return;
node.state = node.state || {};
node.state.downX = node.position[0];
node.state.downY = node.position[1];
if (this.state.selectedNodes) {
for (const nodeId of this.state.selectedNodes) {
const n = this.graph.getNode(nodeId);
if (!n) continue;
n.state = n.state || {};
n.state.downX = n.position[0];
n.state.downY = n.position[1];
}
}
this.state.edgeEndPosition = null;
}
handleWindowMouseMove(event: MouseEvent) {
const mx = event.clientX - this.state.rect.x;
const my = event.clientY - this.state.rect.y;
this.state.mousePosition = this.state.projectScreenToWorld(mx, my);
this.state.hoveredNodeId = this.state.getNodeIdFromEvent(event);
if (!this.state.mouseDown) return;
// we are creating a new edge here
if (this.state.activeSocket || this.state.possibleSockets?.length) {
let smallestDist = 1000;
let _socket;
for (const socket of this.state.possibleSockets) {
const dist = Math.sqrt(
(socket.position[0] - this.state.mousePosition[0]) ** 2
+ (socket.position[1] - this.state.mousePosition[1]) ** 2
);
if (dist < smallestDist) {
smallestDist = dist;
_socket = socket;
}
}
if (_socket && smallestDist < 1.5) {
this.state.mousePosition = _socket.position;
this.state.hoveredSocket = _socket;
} else {
this.state.hoveredSocket = null;
}
return;
}
// handle box selection
if (this.state.boxSelection) {
event.preventDefault();
event.stopPropagation();
const mouseD = this.state.projectScreenToWorld(
this.state.mouseDown[0],
this.state.mouseDown[1]
);
const x1 = Math.min(mouseD[0], this.state.mousePosition[0]);
const x2 = Math.max(mouseD[0], this.state.mousePosition[0]);
const y1 = Math.min(mouseD[1], this.state.mousePosition[1]);
const y2 = Math.max(mouseD[1], this.state.mousePosition[1]);
for (const node of this.graph.nodes.values()) {
if (!node?.state) continue;
const x = node.position[0];
const y = node.position[1];
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;
}
// here we are handling dragging of nodes
if (this.state.activeNodeId !== -1 && this.state.mouseDownNodeId !== -1) {
this.edgeInteractionManager.handleMouseMove();
const node = this.graph.getNode(this.state.activeNodeId);
if (!node || event.buttons !== 1) return;
node.state = node.state || {};
const oldX = node.state.downX || 0;
const oldY = node.state.downY || 0;
let newX = oldX + (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2];
let newY = oldY + (my - this.state.mouseDown[1]) / this.state.cameraPosition[2];
if (event.ctrlKey) {
const snapLevel = this.state.getSnapLevel();
if (this.state.snapToGrid) {
newX = snapPointToGrid(newX, 5 / snapLevel);
newY = snapPointToGrid(newY, 5 / snapLevel);
}
}
if (!node.state.isMoving) {
const dist = Math.sqrt((oldX - newX) ** 2 + (oldY - newY) ** 2);
if (dist > 0.2) {
node.state.isMoving = true;
}
}
const vecX = oldX - newX;
const vecY = oldY - newY;
if (this.state.selectedNodes?.size) {
for (const nodeId of this.state.selectedNodes) {
const n = this.graph.getNode(nodeId);
if (!n?.state) continue;
n.state.x = (n?.state?.downX || 0) - vecX;
n.state.y = (n?.state?.downY || 0) - vecY;
this.state.updateNodePosition(n);
}
}
node.state.x = newX;
node.state.y = newY;
this.state.updateNodePosition(node);
return;
}
// here we are handling panning of camera
this.state.isPanning = true;
const newX = this.state.cameraDown[0]
- (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2];
const newY = this.state.cameraDown[1]
- (my - this.state.mouseDown[1]) / this.state.cameraPosition[2];
this.state.cameraPosition[0] = newX;
this.state.cameraPosition[1] = newY;
}
handleMouseScroll(event: WheelEvent) {
const bodyIsFocused = document.activeElement === document.body
|| document.activeElement === this.state.wrapper
|| document?.activeElement?.id === 'graph';
if (!bodyIsFocused) return;
// Define zoom speed and clamp it between -1 and 1
const isNegative = event.deltaY < 0;
const normalizedDelta = Math.abs(event.deltaY * 0.01);
const delta = Math.pow(0.95, zoomSpeed * normalizedDelta);
// Calculate new zoom level and clamp it between minZoom and maxZoom
const newZoom = Math.max(
minZoom,
Math.min(
maxZoom,
isNegative
? this.state.cameraPosition[2] / delta
: this.state.cameraPosition[2] * delta
)
);
// Calculate the ratio of the new zoom to the original zoom
const zoomRatio = newZoom / this.state.cameraPosition[2];
// Update camera position and zoom level
this.state.cameraPosition[0] = this.state.mousePosition[0]
- (this.state.mousePosition[0] - this.state.cameraPosition[0])
/ zoomRatio;
this.state.cameraPosition[1] = this.state.mousePosition[1]
- (this.state.mousePosition[1] - this.state.cameraPosition[1])
/ zoomRatio;
this.state.cameraPosition[2] = newZoom;
}
}
@@ -1,22 +0,0 @@
import type { Socket } from "@nodes/types";
import { getContext } from "svelte";
import { SvelteSet } from 'svelte/reactivity';
export function getGraphState() {
return getContext<GraphState>("graphState");
}
export class GraphState {
activeNodeId = $state(-1);
selectedNodes = new SvelteSet<number>();
activeSocket = $state<Socket | null>(null);
hoveredSocket = $state<Socket | null>(null);
possibleSockets = $state<Socket[]>([]);
possibleSocketIds = $derived(new Set(
this.possibleSockets.map((s) => `${s.node.id}-${s.index}`),
));
clearSelection() {
this.selectedNodes.clear();
}
}
@@ -1,15 +1,15 @@
import throttle from './throttle.js'; import throttle from '$lib/helpers/throttle';
type EventMap = Record<string, unknown>; type EventMap = Record<string, unknown>;
type EventKey<T extends EventMap> = string & keyof T; type EventKey<T extends EventMap> = string & keyof T;
type EventReceiver<T> = (params: T, stuff?: Record<string, unknown>) => unknown; type EventReceiver<T> = (params: T, stuff?: Record<string, unknown>) => unknown;
export default class EventEmitter<
export default class EventEmitter<T extends EventMap = { [key: string]: unknown }> { T extends EventMap = { [key: string]: unknown }
> {
index = 0; index = 0;
public eventMap: T = {} as T; public eventMap: T = {} as T;
constructor() { constructor() {}
}
private cbs: { [key: string]: ((data?: unknown) => unknown)[] } = {}; private cbs: { [key: string]: ((data?: unknown) => unknown)[] } = {};
private cbsOnce: { [key: string]: ((data?: unknown) => unknown)[] } = {}; private cbsOnce: { [key: string]: ((data?: unknown) => unknown)[] } = {};
@@ -29,16 +29,20 @@ export default class EventEmitter<T extends EventMap = { [key: string]: unknown
} }
} }
public on<K extends EventKey<T>>(event: K, cb: EventReceiver<T[K]>, throttleTimer = 0) { public on<K extends EventKey<T>>(
event: K,
cb: EventReceiver<T[K]>,
throttleTimer = 0
) {
if (throttleTimer > 0) cb = throttle(cb, throttleTimer); if (throttleTimer > 0) cb = throttle(cb, throttleTimer);
const cbs = Object.assign(this.cbs, { const cbs = Object.assign(this.cbs, {
[event]: [...(this.cbs[event] || []), cb], [event]: [...(this.cbs[event] || []), cb]
}); });
this.cbs = cbs; this.cbs = cbs;
// console.log('New EventEmitter ', this.constructor.name); // console.log('New EventEmitter ', this.constructor.name);
return () => { return () => {
cbs[event]?.splice(cbs[event].indexOf(cb), 1); this.cbs[event]?.splice(cbs[event].indexOf(cb), 1);
}; };
} }
@@ -48,10 +52,17 @@ export default class EventEmitter<T extends EventMap = { [key: string]: unknown
* @param {function} cb Listener, gets called everytime the event is emitted * @param {function} cb Listener, gets called everytime the event is emitted
* @returns {function} Returns a function which removes the listener when called * @returns {function} Returns a function which removes the listener when called
*/ */
public once<K extends EventKey<T>>(event: K, cb: EventReceiver<T[K]>): () => void { public once<K extends EventKey<T>>(
this.cbsOnce[event] = [...(this.cbsOnce[event] || []), cb]; event: K,
cb: EventReceiver<T[K]>
): () => void {
const cbsOnce = Object.assign(this.cbsOnce, {
[event]: [...(this.cbsOnce[event] || []), cb]
});
this.cbsOnce = cbsOnce;
return () => { return () => {
this.cbsOnce[event].splice(this.cbsOnce[event].indexOf(cb), 1); cbsOnce[event]?.splice(cbsOnce[event].indexOf(cb), 1);
}; };
} }
+47 -43
View File
@@ -6,7 +6,10 @@ export function lerp(a: number, b: number, t: number) {
return a + (b - a) * t; return a + (b - a) * t;
} }
export function animate(duration: number, callback: (progress: number) => void | false) { export function animate(
duration: number,
callback: (progress: number) => void | false
) {
const start = performance.now(); const start = performance.now();
const loop = (time: number) => { const loop = (time: number) => {
const progress = (time - start) / duration; const progress = (time - start) / duration;
@@ -18,7 +21,7 @@ export function animate(duration: number, callback: (progress: number) => void |
} else { } else {
callback(1); callback(1);
} }
} };
requestAnimationFrame(loop); requestAnimationFrame(loop);
} }
@@ -30,10 +33,14 @@ export function createNodePath({
cornerBottom = 0, cornerBottom = 0,
leftBump = false, leftBump = false,
rightBump = false, rightBump = false,
aspectRatio = 1, aspectRatio = 1
} = {}) { } = {}) {
const leftBumpTopY = y + height / 2;
const leftBumpBottomY = y - height / 2;
return `M0,${cornerTop} return `M0,${cornerTop}
${cornerTop ${
cornerTop
? ` V${cornerTop} ? ` V${cornerTop}
Q0,0 ${cornerTop * aspectRatio},0 Q0,0 ${cornerTop * aspectRatio},0
H${100 - cornerTop * aspectRatio} H${100 - cornerTop * aspectRatio}
@@ -44,11 +51,13 @@ export function createNodePath({
` `
} }
V${y - height / 2} V${y - height / 2}
${rightBump ${
rightBump
? ` C${100 - depth},${y - height / 2} ${100 - depth},${y + height / 2} 100,${y + height / 2}` ? ` C${100 - depth},${y - height / 2} ${100 - depth},${y + height / 2} 100,${y + height / 2}`
: ` H100` : ` H100`
} }
${cornerBottom ${
cornerBottom
? ` V${100 - cornerBottom} ? ` V${100 - cornerBottom}
Q100,100 ${100 - cornerBottom * aspectRatio},100 Q100,100 ${100 - cornerBottom * aspectRatio},100
H${cornerBottom * aspectRatio} H${cornerBottom * aspectRatio}
@@ -56,48 +65,19 @@ export function createNodePath({
` `
: `${leftBump ? `V100 H0` : `V100`}` : `${leftBump ? `V100 H0` : `V100`}`
} }
${leftBump ${
? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${y - height / 2}` leftBump
? ` V${leftBumpTopY} C${depth},${leftBumpTopY} ${depth},${leftBumpBottomY} 0,${leftBumpBottomY}`
: ` H0` : ` H0`
} }
Z`.replace(/\s+/g, " "); Z`.replace(/\s+/g, ' ');
} }
export const debounce = (fn: Function, ms = 300) => { export const clone: <T>(v: T) => T = 'structedClone' in globalThis
let timeoutId: ReturnType<typeof setTimeout>; ? globalThis.structuredClone
return function (this: any, ...args: any[]) { : (obj) => JSON.parse(JSON.stringify(obj));
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), ms);
};
};
export const clone: <T>(v: T) => T = "structedClone" in globalThis ? globalThis.structuredClone : (obj) => JSON.parse(JSON.stringify(obj)); export function withSubComponents<A, B extends Record<string, unknown>>(
export const createLogger = (() => {
let maxLength = 5;
return (scope: string) => {
maxLength = Math.max(maxLength, scope.length);
let muted = false;
return {
log: (...args: any[]) => !muted && console.log(`[%c${scope.padEnd(maxLength, " ")}]:`, "color: #888", ...args),
group: (...args: any[]) => !muted && console.groupCollapsed(`[%c${scope.padEnd(maxLength, " ")}]:`, "color: #888", ...args),
groupEnd: () => !muted && console.groupEnd(),
info: (...args: any[]) => !muted && console.info(`[%c${scope.padEnd(maxLength, " ")}]:`, "color: #888", ...args),
warn: (...args: any[]) => !muted && console.warn(`[%c${scope.padEnd(maxLength, " ")}]:`, "color: #888", ...args),
error: (...args: any[]) => console.error(`[%c${scope.padEnd(maxLength, " ")}]:`, "color: #f88", ...args),
mute() {
muted = true;
},
unmute() {
muted = false;
}
}
}
})();
export function withSubComponents<A, B extends Record<string, any>>(
component: A, component: A,
subcomponents: B subcomponents: B
): A & B { ): A & B {
@@ -107,3 +87,27 @@ export function withSubComponents<A, B extends Record<string, any>>(
}); });
return component as A & B; return component as A & B;
} }
export function distanceFromPointToSegment(
x1: number,
y1: number,
x2: number,
y2: number,
x0: number,
y0: number
): number {
const dx = x2 - x1;
const dy = y2 - y1;
if (dx === 0 && dy === 0) {
return Math.hypot(x0 - x1, y0 - y1);
}
const t = ((x0 - x1) * dx + (y0 - y1) * dy) / (dx * dx + dy * dy);
const clampedT = Math.max(0, Math.min(1, t));
const px = x1 + clampedT * dx;
const py = y1 + clampedT * dy;
return Math.hypot(x0 - px, y0 - py);
}
@@ -1,15 +1,14 @@
import { writable, type Writable } from "svelte/store"; import { type Writable, writable } from 'svelte/store';
function isStore(v: unknown): v is Writable<unknown> { function isStore(v: unknown): v is Writable<unknown> {
return v !== null && typeof v === "object" && "subscribe" in v && "set" in v; return v !== null && typeof v === 'object' && 'subscribe' in v && 'set' in v;
} }
const storeIds: Map<string, ReturnType<typeof createLocalStore>> = new Map(); const storeIds: Map<string, ReturnType<typeof createLocalStore>> = new Map();
const HAS_LOCALSTORAGE = "localStorage" in globalThis; const HAS_LOCALSTORAGE = 'localStorage' in globalThis;
function createLocalStore<T>(key: string, initialValue: T | Writable<T>) { function createLocalStore<T>(key: string, initialValue: T | Writable<T>) {
let store: Writable<T>; let store: Writable<T>;
if (HAS_LOCALSTORAGE) { if (HAS_LOCALSTORAGE) {
@@ -36,18 +35,15 @@ function createLocalStore<T>(key: string, initialValue: T | Writable<T>) {
subscribe: store.subscribe, subscribe: store.subscribe,
set: store.set, set: store.set,
update: store.update update: store.update
} };
} }
export default function localStore<T>(key: string, initialValue: T | Writable<T>): Writable<T> { export default function localStore<T>(key: string, initialValue: T | Writable<T>): Writable<T> {
if (storeIds.has(key)) return storeIds.get(key) as Writable<T>; if (storeIds.has(key)) return storeIds.get(key) as Writable<T>;
const store = createLocalStore(key, initialValue) const store = createLocalStore(key, initialValue);
storeIds.set(key, store); storeIds.set(key, store);
return store return store;
} }
@@ -0,0 +1,92 @@
import type { NodeDefinition, NodeInstance } from '@nodarium/types';
export function getParameterHeight(node: NodeDefinition, inputKey: string) {
const input = node.inputs?.[inputKey];
if (!input) {
if (inputKey.startsWith('__virtual')) {
return 50;
}
return 0;
}
if (inputKey === 'seed') return 0;
if (!node.inputs) return 0;
if ('setting' in input) return 0;
if (input.hidden) return 0;
if (input.type === 'shape' && input.external !== true) {
return 200;
}
if (
input?.label !== '' && !input.external && input.type !== 'path'
&& input.type !== 'geometry'
) {
return 100;
}
return 50;
}
export function getSocketPosition(
node: NodeInstance,
index: string | number
): [number, number] {
if (typeof index === 'number') {
if (node.type === '__virtual/group/input') {
const nodeType = node.state.type;
const keys = Object.keys(nodeType?.inputs || {});
let height = 5;
for (let i = 0; i < keys.length; i++) {
const h = getParameterHeight(nodeType!, keys[i]) / 10;
if (i === index) { height += h / 2; break; }
height += h;
}
return [
(node?.state?.x ?? node.position[0]) + 20,
(node?.state?.y ?? node.position[1]) + height
];
}
return [
(node?.state?.x ?? node.position[0]) + 20,
(node?.state?.y ?? node.position[1]) + 2.5 + 10 * index
];
} else {
let height = 5;
const nodeType = node.state.type!;
const inputs = nodeType.inputs || {};
for (const inputKey in inputs) {
const h = getParameterHeight(nodeType, inputKey) / 10;
if (inputKey === index) {
height += h / 2;
break;
}
height += h;
}
return [
node?.state?.x ?? node.position[0],
(node?.state?.y ?? node.position[1]) + height
];
}
}
const nodeHeightCache: Record<string, number> = {};
export function getNodeHeight(node: NodeDefinition) {
// Don't cache virtual nodes — their inputs can change dynamically
const isVirtual = (node.id as string).startsWith('__virtual/');
if (!isVirtual && node.id in nodeHeightCache) {
return nodeHeightCache[node.id];
}
if (!node?.inputs) {
return 5;
}
let height = 5;
for (const key in node.inputs) {
const h = getParameterHeight(node, key) / 10;
height += h;
}
if (!isVirtual) {
nodeHeightCache[node.id] = height;
}
return height;
}
@@ -1,20 +0,0 @@
export default <R, A extends any[]>(
fn: (...args: A) => R,
delay: number
): ((...args: A) => R) => {
let wait = false;
return (...args: A) => {
if (wait) return undefined;
const val = fn(...args);
wait = true;
setTimeout(() => {
wait = false;
}, delay);
return val;
}
};
+18 -19
View File
@@ -1,24 +1,24 @@
import { create, type Delta } from "jsondiffpatch"; import type { Graph } from '@nodarium/types';
import type { Graph } from "@nodes/types"; import { createLogger } from '@nodarium/utils';
import { createLogger, clone } from "./helpers/index.js"; import { create, type Delta } from 'jsondiffpatch';
import { clone } from './helpers/index.js';
const diff = create({ const diff = create({
objectHash: function (obj, index) { objectHash: function(obj, index) {
if (obj === null) return obj; if (obj === null) return obj;
if ("id" in obj) return obj.id; if ('id' in obj) return obj.id as string;
if ('_id' in obj) return obj._id as string;
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
return obj.join("-") return obj.join('-');
} }
return obj?.id || obj._id || '$$index:' + index; return '$$index:' + index;
} }
}) });
const log = createLogger("history") const log = createLogger('history');
log.mute(); log.mute();
export class HistoryManager { export class HistoryManager {
index: number = -1; index: number = -1;
history: Delta[] = []; history: Delta[] = [];
private initialState: Graph | undefined; private initialState: Graph | undefined;
@@ -26,27 +26,26 @@ export class HistoryManager {
private opts = { private opts = {
debounce: 400, debounce: 400,
maxHistory: 100, maxHistory: 100
} };
constructor({ maxHistory = 100, debounce = 100 } = {}) { constructor({ maxHistory = 100, debounce = 100 } = {}) {
this.history = []; this.history = [];
this.index = -1; this.index = -1;
this.opts.debounce = debounce; this.opts.debounce = debounce;
this.opts.maxHistory = maxHistory; this.opts.maxHistory = maxHistory;
globalThis["_history"] = this;
} }
save(state: Graph) { save(state: Graph) {
if (!this.state) { if (!this.state) {
this.state = clone(state); this.state = clone(state);
this.initialState = this.state; this.initialState = this.state;
log.log("initial state saved") log.log('initial state saved');
} else { } else {
const newState = state; const newState = state;
const delta = diff.diff(this.state, newState); const delta = diff.diff(this.state, newState);
if (delta) { if (delta) {
log.log("saving state") log.log('saving state');
// Add the delta to history // Add the delta to history
if (this.index < this.history.length - 1) { if (this.index < this.history.length - 1) {
// Clear the history after the current index if new changes are made // Clear the history after the current index if new changes are made
@@ -62,7 +61,7 @@ export class HistoryManager {
} }
this.state = newState; this.state = newState;
} else { } else {
log.log("no changes") log.log('no changes');
} }
} }
} }
@@ -76,7 +75,7 @@ export class HistoryManager {
undo() { undo() {
if (this.index === -1 && this.initialState) { if (this.index === -1 && this.initialState) {
log.log("reached start, loading initial state") log.log('reached start, loading initial state');
return clone(this.initialState); return clone(this.initialState);
} else { } else {
const delta = this.history[this.index]; const delta = this.history[this.index];
@@ -96,7 +95,7 @@ export class HistoryManager {
this.state = nextState; this.state = nextState;
return clone(nextState); return clone(nextState);
} else { } else {
log.log("reached end") log.log('reached end');
} }
} }
} }
+256
View File
@@ -0,0 +1,256 @@
import type { createKeyMap } from '$lib/helpers/createKeyMap';
import { panelState } from '$lib/sidebar/PanelState.svelte';
import FileSaver from 'file-saver';
import type { GraphManager } from './graph-manager.svelte';
import type { GraphState } from './graph-state.svelte';
type Keymap = ReturnType<typeof createKeyMap>;
export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: GraphState) {
keymap.addShortcut({
key: 'l',
description: 'Select linked nodes',
callback: () => {
const activeNode = graph.getNode(graphState.activeNodeId);
if (activeNode) {
const nodes = graph.getLinkedNodes(activeNode);
graphState.selectedNodes.clear();
for (const node of nodes) {
graphState.selectedNodes.add(node.id);
}
}
}
});
keymap.addShortcut({
key: '?',
description: 'Toggle Help',
callback: () => {
panelState.setActivePanel('shortcuts');
}
});
keymap.addShortcut({
key: 'c',
ctrl: true,
description: 'Copy active nodes',
callback: () => graphState.copyNodes()
});
keymap.addShortcut({
key: 'v',
ctrl: true,
description: 'Paste nodes',
callback: () => graphState.pasteNodes()
});
keymap.addShortcut({
key: 'Escape',
description: 'Deselect nodes / Exit group',
callback: () => {
if (graph.isInsideGroup) {
const groupId = graph.currentGroupContext;
if (groupId) {
graphState.groupCameras.set(
groupId,
[...graphState.cameraPosition] as [number, number, number]
);
}
const result = graph.exitGroup();
if (result !== false) {
graphState.activeNodeId = result.nodeId;
graphState.clearSelection();
graphState.cameraPosition[0] = result.camera[0];
graphState.cameraPosition[1] = result.camera[1];
graphState.cameraPosition[2] = result.camera[2];
return;
}
}
graphState.activeNodeId = -1;
graphState.clearSelection();
graphState.edgeEndPosition = null;
(document.activeElement as HTMLElement)?.blur();
}
});
keymap.addShortcut({
key: 'A',
shift: true,
description: 'Add new Node',
callback: () => graphState.openNodePalette()
});
keymap.addShortcut({
key: '.',
description: 'Center camera',
callback: () => {
if (!graphState.isBodyFocused()) return;
graphState.centerNode(graph.getNode(graphState.activeNodeId));
}
});
keymap.addShortcut({
key: 'a',
ctrl: true,
preventDefault: true,
description: 'Select all nodes',
callback: () => {
if (!graphState.isBodyFocused()) return;
for (const node of graph.nodes.keys()) {
graphState.selectedNodes.add(node);
}
}
});
keymap.addShortcut({
key: 'z',
ctrl: true,
description: 'Undo',
callback: () => {
if (!graphState.isBodyFocused()) return;
graph.undo();
for (const node of graph.nodes.values()) {
graphState.updateNodePosition(node);
}
}
});
keymap.addShortcut({
key: 'y',
ctrl: true,
description: 'Redo',
callback: () => {
graph.redo();
for (const node of graph.nodes.values()) {
graphState.updateNodePosition(node);
}
}
});
keymap.addShortcut({
key: 's',
ctrl: true,
description: 'Save',
preventDefault: true,
callback: () => {
const state = graph.serialize();
const blob = new Blob([JSON.stringify(state)], {
type: 'application/json;charset=utf-8'
});
FileSaver.saveAs(blob, 'nodarium-graph.json');
}
});
keymap.addShortcut({
key: ['Delete', 'Backspace', 'x'],
description: 'Delete selected nodes',
callback: (event) => {
if (!graphState.isBodyFocused()) return;
graph.startUndoGroup();
if (graphState.activeNodeId !== -1) {
const node = graph.getNode(graphState.activeNodeId);
if (node) {
graph.removeNode(node, { restoreEdges: event.ctrlKey });
graphState.activeNodeId = -1;
}
}
if (graphState.selectedNodes) {
for (const nodeId of graphState.selectedNodes) {
const node = graph.getNode(nodeId);
if (node) {
graph.removeNode(node, { restoreEdges: event.ctrlKey });
}
}
graphState.clearSelection();
}
graph.saveUndoGroup();
}
});
keymap.addShortcut({
key: 'f',
description: 'Smart Connect Nodes',
callback: () => {
const nodes = [...graphState.selectedNodes.values()]
.map((g) => graph.getNode(g))
.filter((n) => !!n);
const edge = graph.smartConnect(nodes[0], nodes[1]);
if (!edge) graph.smartConnect(nodes[1], nodes[0]);
}
});
keymap.addShortcut({
key: 'g',
ctrl: true,
preventDefault: true,
description: 'Group selected nodes',
callback: () => {
if (!graphState.isBodyFocused()) return;
const nodeIds = Array.from(
new Set([
...(graphState.selectedNodes.size > 0 ? graphState.selectedNodes.values() : []),
...(graphState.activeNodeId !== -1 ? [graphState.activeNodeId] : [])
])
);
if (nodeIds.length === 0) return;
const groupNode = graph.createGroup(nodeIds);
if (groupNode) {
graphState.selectedNodes.clear();
graphState.activeNodeId = groupNode.id;
}
}
});
keymap.addShortcut({
key: 'g',
alt: true,
shift: true,
preventDefault: true,
description: 'Ungroup selected node',
callback: () => {
if (!graphState.isBodyFocused()) return;
const nodeId = graphState.activeNodeId !== -1
? graphState.activeNodeId
: graphState.selectedNodes.size === 1
? [...graphState.selectedNodes.values()][0]
: -1;
if (nodeId === -1) return;
graph.ungroup(nodeId);
graphState.activeNodeId = -1;
graphState.clearSelection();
}
});
keymap.addShortcut({
key: 'Tab',
preventDefault: true,
description: 'Enter focused group node',
callback: () => {
if (!graphState.isBodyFocused()) return;
const entered = graph.enterGroup(
graphState.activeNodeId,
[...graphState.cameraPosition] as [number, number, number]
);
if (entered) {
graphState.activeNodeId = -1;
graphState.clearSelection();
// Restore group-specific camera if we've been here before, else snap to center
const groupId = graph.currentGroupContext;
const saved = groupId ? graphState.groupCameras.get(groupId) : undefined;
if (saved) {
graphState.cameraPosition[0] = saved[0];
graphState.cameraPosition[1] = saved[1];
graphState.cameraPosition[2] = saved[2];
} else {
const nodes = [...graph.nodes.values()];
if (nodes.length) {
const avgX = nodes.reduce((s, n) => s + n.position[0], 0) / nodes.length;
const avgY = nodes.reduce((s, n) => s + n.position[1], 0) / nodes.length;
graphState.cameraPosition[0] = avgX;
graphState.cameraPosition[1] = avgY;
graphState.cameraPosition[2] = 10;
}
}
}
}
});
}
+55 -23
View File
@@ -1,56 +1,88 @@
varying vec2 vUv; varying vec2 vUv;
uniform float uWidth; uniform float uWidth;
uniform float uHeight; uniform float uHeight;
uniform float uZoom;
uniform vec3 uColorDark; uniform vec3 uColorDark;
uniform vec3 uColorBright; uniform vec3 uColorBright;
uniform vec3 uStrokeColor; uniform vec3 uStrokeColor;
uniform float uStrokeWidth;
const float uHeaderHeight = 5.0;
uniform float uSectionHeights[16];
uniform int uNumSections;
float msign(in float x) { return (x < 0.0) ? -1.0 : 1.0; } float msign(in float x) { return (x < 0.0) ? -1.0 : 1.0; }
float sdCircle(vec2 p, float r) { return length(p) - r; }
vec4 roundedBoxSDF( in vec2 p, in vec2 b, in float r, in float s) { vec4 roundedBoxSDF( in vec2 p, in vec2 b, in float r, in float s) {
vec2 q = abs(p) - b + r; vec2 q = abs(p) - b + r;
float l = b.x + b.y + 1.570796 * r; float l = b.x + b.y + 1.570796 * r;
float k1 = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; float k1 = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
float k2 = ((q.x > 0.0) ? atan(q.y, q.x) : 1.570796); float k2 = ((q.x > 0.0) ? atan(q.y, q.x) : 1.570796);
float k3 = 3.0 + 2.0 * msign(min(p.x, -p.y)) - msign(p.x); float k3 = 3.0 + 2.0 * msign(min(p.x, -p.y)) - msign(p.x);
float k4 = msign(p.x * p.y); float k4 = msign(p.x * p.y);
float k5 = r * k2 + max(-q.x, 0.0); float k5 = r * k2 + max(-q.x, 0.0);
float ra = s * round(k1 / s); float ra = s * round(k1 / s);
float l2 = l + 1.570796 * ra; float l2 = l + 1.570796 * ra;
return vec4(k1 - ra, k3 * l2 + k4 * (b.y + ((q.y > 0.0) ? k5 + k2 * ra : q.y)), 4.0 * l2, k1); return vec4(k1 - ra, k3 * l2 + k4 * (b.y + ((q.y > 0.0) ? k5 + k2 * ra : q.y)), 4.0 * l2, k1);
} }
void main(){ void main(){
float strokeWidth = mix(2.0, 0.5, uZoom);
float y = (1.0-vUv.y) * uHeight; float borderRadius = 0.5;
float dentRadius = 0.8;
float y = (1.0 - vUv.y) * uHeight;
float x = vUv.x * uWidth; float x = vUv.x * uWidth;
vec2 size = vec2(uWidth, uHeight); vec2 size = vec2(uWidth, uHeight);
vec2 uv = (vUv - 0.5) * 2.0; vec2 uvCenter = (vUv - 0.5) * 2.0;
float u_border_radius = 0.4; vec4 boxData = roundedBoxSDF(uvCenter * size, size, borderRadius * 2.0, 0.0);
vec4 distance = roundedBoxSDF(uv * size, size, u_border_radius*2.0, 0.0); float sceneSDF = boxData.w;
if (distance.w > 0.0 ) { vec2 headerDentPos = vec2(uWidth, uHeaderHeight * 0.5);
// outside float headerDentDist = sdCircle(vec2(x, y) - headerDentPos, dentRadius);
gl_FragColor = vec4(0.0,0.0,0.0, 0.0); sceneSDF = max(sceneSDF, -headerDentDist*2.0);
}else{
if (distance.w > -uStrokeWidth || mod(y+5.0, 10.0) < uStrokeWidth/2.0) { float currentYBoundary = uHeaderHeight;
// draw the outer stroke float previousYBoundary = uHeaderHeight;
gl_FragColor = vec4(uStrokeColor, 1.0);
}else if (y<5.0){ for (int i = 0; i < 16; i++) {
// draw the header if (i >= uNumSections) break;
gl_FragColor = vec4(uColorBright, 1.0);
}else{ float sectionHeight = uSectionHeights[i];
gl_FragColor = vec4(uColorDark, 1.0); currentYBoundary += sectionHeight;
float centerY = previousYBoundary + (sectionHeight * 0.5);
vec2 circlePos = vec2(0.0, centerY);
float circleDist = sdCircle(vec2(x, y) - circlePos, dentRadius);
sceneSDF = max(sceneSDF, -circleDist*2.0);
previousYBoundary = currentYBoundary;
} }
if (sceneSDF > 0.05) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
return;
}
vec3 finalColor = (y < uHeaderHeight) ? uColorBright : uColorDark;
bool isDivider = false;
float dividerY = uHeaderHeight;
if (abs(y - dividerY) < strokeWidth * 0.25) isDivider = true;
for (int i = 0; i < 16; i++) {
if (i >= uNumSections - 1) break;
dividerY += uSectionHeights[i];
if (abs(y - dividerY) < strokeWidth * 0.25) isDivider = true;
}
if (sceneSDF > -strokeWidth || isDivider) {
gl_FragColor = vec4(uStrokeColor, 1.0);
} else {
gl_FragColor = vec4(finalColor, 1.0);
} }
} }
+51 -43
View File
@@ -1,65 +1,68 @@
<script lang="ts"> <script lang="ts">
import type { Node } from "@nodes/types"; import { appSettings } from '$lib/settings/app-settings.svelte';
import { getContext, onMount } from "svelte"; import type { NodeInstance } from '@nodarium/types';
import { getGraphState } from "../graph/state.svelte"; import { T } from '@threlte/core';
import { T } from "@threlte/core"; import { type Mesh } from 'three';
import { type Mesh } from "three"; import { getGraphState } from '../graph-state.svelte';
import NodeFrag from "./Node.frag"; import { colors } from '../graph/colors.svelte';
import NodeVert from "./Node.vert"; import { getNodeHeight, getParameterHeight } from '../helpers/nodeHelpers';
import NodeHtml from "./NodeHTML.svelte"; import NodeFrag from './Node.frag';
import { colors } from "../graph/colors.svelte"; import NodeVert from './Node.vert';
import { appSettings } from "$lib/settings/app-settings.svelte"; import NodeHtml from './NodeHTML.svelte';
const graphState = getGraphState(); const graphState = getGraphState();
type Props = { type Props = {
node: Node; node: NodeInstance;
inView: boolean; inView: boolean;
z: number;
}; };
const { node, inView, z }: Props = $props(); let { node = $bindable(), inView }: Props = $props();
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));
let strokeColor = $state(colors.selected); const strokeColor = $derived(
$effect(() => { appSettings.value.theme
appSettings.theme; && (isSelected
strokeColor = isSelected
? colors.selected ? colors.selected
: isActive : isActive
? colors.active ? colors.active
: colors.outline; : colors.outline)
}); );
const updateNodePosition = const sectionHeights = $derived(
getContext<(n: Node) => void>("updateNodePosition"); Object
.keys(nodeType.inputs || {})
const getNodeHeight = getContext<(n: string) => number>("getNodeHeight"); .map(key => getParameterHeight(nodeType, key) / 10)
.filter(b => !!b)
);
let meshRef: Mesh | undefined = $state(); let meshRef: Mesh | undefined = $state();
const height = getNodeHeight?.(node.type); const height = $derived(getNodeHeight(node.state.type!));
const zoom = $derived(graphState.cameraPosition[2]);
$effect(() => { $effect(() => {
node.tmp = node.tmp || {}; if (meshRef && !node.state?.mesh) {
node.tmp.mesh = meshRef; node.state.mesh = meshRef;
updateNodePosition?.(node); graphState.updateNodePosition(node);
}); }
onMount(() => {
node.tmp = node.tmp || {};
node.tmp.mesh = meshRef;
updateNodePosition?.(node);
}); });
const zoomValue = $derived(
(Math.log(graphState.cameraPosition[2]) - Math.log(1)) / (Math.log(40) - Math.log(1))
);
// const zoomValue = (graphState.cameraPosition[2] - 1) / 39;
</script> </script>
<T.Mesh <T.Mesh
position.x={node.position[0] + 10} position.x={(node.state.x ?? node.position[0]) + 10}
position.z={node.position[1] + height / 2} position.z={(node.state.y ?? node.position[1]) + height / 2}
position.y={0.8} position.y={0.8}
rotation.x={-Math.PI / 2} rotation.x={-Math.PI / 2}
bind:ref={meshRef} bind:ref={meshRef}
visible={inView && z < 7} visible={inView && zoom < 7}
> >
<T.PlaneGeometry args={[20, height]} radius={1} /> <T.PlaneGeometry args={[20, height]} radius={1} />
<T.ShaderMaterial <T.ShaderMaterial
@@ -67,16 +70,21 @@
fragmentShader={NodeFrag} fragmentShader={NodeFrag}
transparent transparent
uniforms={{ uniforms={{
uColorBright: { value: colors["layer-2"] }, uColorBright: { value: colors['layer-2'] },
uColorDark: { value: colors["layer-1"] }, uColorDark: { value: colors['layer-1'] },
uStrokeColor: { value: colors.outline.clone() }, uStrokeColor: { value: colors.outline.clone() },
uStrokeWidth: { value: 1.0 }, uSectionHeights: { value: [5, 10] },
uNumSections: { value: 2 },
uWidth: { value: 20 }, uWidth: { value: 20 },
uHeight: { value: height }, uHeight: { value: 200 },
uZoom: { value: 1.0 }
}} }}
uniforms.uStrokeColor.value={strokeColor.clone()} uniforms.uZoom.value={zoomValue}
uniforms.uStrokeWidth.value={(7 - z) / 3} uniforms.uHeight.value={height}
uniforms.uSectionHeights.value={sectionHeights}
uniforms.uNumSections.value={sectionHeights.length}
uniforms.uStrokeColor.value={strokeColor}
/> />
</T.Mesh> </T.Mesh>
<NodeHtml {node} {inView} {isActive} {isSelected} {z} /> <NodeHtml bind:node {inView} {isActive} {isSelected} z={zoom} />
+110 -26
View File
@@ -1,14 +1,17 @@
<script lang="ts"> <script lang="ts">
import type { Node } from "@nodes/types"; import type { NodeDefinition, NodeInstance } from '@nodarium/types';
import NodeHeader from "./NodeHeader.svelte"; import { getGraphManager, getGraphState } from '../graph-state.svelte';
import NodeParameter from "./NodeParameter.svelte"; import NodeHeader from './NodeHeader.svelte';
import { getContext, onMount } from "svelte"; import NodeParameter from './NodeParameter.svelte';
let ref: HTMLDivElement; let ref: HTMLDivElement;
const graphState = getGraphState();
const manager = getGraphManager();
type Props = { type Props = {
node: Node; node: NodeInstance;
position?: "absolute" | "fixed" | "relative"; position?: 'absolute' | 'fixed' | 'relative';
isActive?: boolean; isActive?: boolean;
isSelected?: boolean; isSelected?: boolean;
inView?: boolean; inView?: boolean;
@@ -17,30 +20,76 @@
let { let {
node = $bindable(), node = $bindable(),
position = "absolute", position = 'absolute',
isActive = false, isActive = false,
isSelected = false, isSelected = false,
inView = true, inView = true,
z = 2, z = 2
}: Props = $props(); }: Props = $props();
const zOffset = (node.tmp?.random || 0) * 0.5; // If we dont have a random offset, all nodes becom visible at the same zoom level -> stuttering
const zOffset = Math.random() - 0.5;
const zLimit = 2 - zOffset; const zLimit = 2 - zOffset;
const type = node?.tmp?.type; function buildParameters(node: NodeInstance, inputs: NodeDefinition['inputs']) {
let parameters = Object.entries(inputs || {}).filter(
const parameters = Object.entries(type?.inputs || {}).filter( (p) => p[1].type !== 'seed' && !('setting' in p[1]) && p[1]?.hidden !== true
(p) =>
p[1].type !== "seed" && !("setting" in p[1]) && p[1]?.hidden !== true,
); );
const updateNodePosition = if (node.type === '__virtual/group/instance') {
getContext<(n: Node) => void>("updateNodePosition"); const groupOptions = [...(manager?.groups?.entries() ?? [])].map(([id, g]) => ({
label: g.name,
value: id
}));
// Remove the static placeholder from the definition (height-only) and replace
// with a fully dynamic version that carries current names + value.
parameters = parameters.filter(([key]) => key !== '__virtual/groupId');
parameters = [['__virtual/groupId', {
type: 'select',
value: node.props?.groupId as string,
options: groupOptions
}], ...parameters];
}
onMount(() => { return parameters;
node.tmp = node.tmp || {}; }
node.tmp.ref = ref;
updateNodePosition?.(node); $effect(() => {
const props = node.props as Record<string, unknown> | undefined;
const virtualGroupId = props?.['__virtual/groupId'] as string | undefined;
if (!virtualGroupId) return;
const activeGroupId = props?.groupId as string | undefined;
if (virtualGroupId === activeGroupId) return;
const newGroupDef = manager?.groupNodeDefinitions.get(`__virtual/group/${virtualGroupId}`);
if (!newGroupDef) return;
const { children, parents, ref } = node.state;
node.props = { ...props, groupId: virtualGroupId, '__virtual/groupId': virtualGroupId };
node.state = { type: newGroupDef, children, parents, ref };
manager?.execute();
manager?.save();
});
const parameters = $derived(buildParameters(node, node?.state?.type?.inputs || {}));
const currentGroupId = $derived((node.props?.groupId as string) ?? '');
function onGroupSelect(event: Event) {
const select = event.target as HTMLSelectElement;
const newGroupId = select.value;
if (!manager || newGroupId === currentGroupId) return;
const newGroupDef = manager.groupNodeDefinitions.get(`__virtual/group/${newGroupId}`);
if (!newGroupDef) return;
node.props = { ...(node.props ?? {}), groupId: newGroupId };
node.state = { type: newGroupDef };
manager.execute();
manager.save();
}
$effect(() => {
if ('state' in node && !node.state.ref) {
node.state.ref = ref;
graphState?.updateNodePosition(node);
}
}); });
</script> </script>
@@ -48,7 +97,7 @@
class="node {position}" class="node {position}"
class:active={isActive} class:active={isActive}
style:--cz={z + zOffset} style:--cz={z + zOffset}
style:display={inView && z > zLimit ? "block" : "none"} style:display={inView && z > zLimit ? 'block' : 'none'}
class:selected={isSelected} class:selected={isSelected}
class:out-of-view={!inView} class:out-of-view={!inView}
data-node-id={node.id} data-node-id={node.id}
@@ -57,38 +106,73 @@
> >
<NodeHeader {node} /> <NodeHeader {node} />
{#each parameters as [key, value], i} {#if false && node.type === '__virtual/group/instance'}
<div class="group-param">
<select
value={currentGroupId}
onchange={onGroupSelect}
onmousedown={(e) => e.stopPropagation()}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
{#each manager?.groups?.entries() ?? [] as [gid, gdef]}
<option value={gid}>{gdef.name}</option>
{/each}
</select>
</div>
{/if}
{#each parameters as [key, value], i (key)}
<NodeParameter <NodeParameter
bind:node bind:node
id={key} id={key}
input={value} input={value}
isLast={i == parameters.length - 1} isLast={i == parameters.length - 1}
outputIndex={node.type === '__virtual/group/input' ? i : undefined}
/> />
{/each} {/each}
</div> </div>
<style> <style>
.group-param {
padding: 5px 8px;
border-bottom: solid 1px var(--color-layer-2);
background: var(--color-layer-1);
}
.group-param select {
width: 100%;
background: var(--color-layer-2);
color: var(--color-text);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
padding: 4px 6px;
font-size: 0.8em;
cursor: pointer;
box-sizing: border-box;
}
.node { .node {
box-sizing: border-box; box-sizing: border-box;
user-select: none !important; user-select: none !important;
cursor: pointer; cursor: pointer;
width: 200px; width: 200px;
color: var(--text-color); color: var(--color-text);
transform: translate3d(var(--nx), var(--ny), 0); transform: translate3d(var(--nx), var(--ny), 0);
z-index: 1; z-index: 1;
opacity: calc((var(--cz) - 2.5) / 3.5); opacity: calc((var(--cz) - 2.5) / 3.5);
font-weight: 300; font-weight: 300;
--stroke: var(--outline); --stroke: var(--color-outline);
--stroke-width: 2px; --stroke-width: 2px;
} }
.node.active { .node.active {
--stroke: var(--active); --stroke: var(--color-active);
--stroke-width: 2px; --stroke-width: 2px;
} }
.node.selected { .node.selected {
--stroke: var(--selected); --stroke: var(--color-selected);
--stroke-width: 2px; --stroke-width: 2px;
} }
</style> </style>
@@ -1,66 +1,86 @@
<script lang="ts"> <script lang="ts">
import { createNodePath } from "../helpers/index.js"; import { appSettings } from '$lib/settings/app-settings.svelte';
import type { Node, Socket } from "@nodes/types"; import type { NodeInstance, Socket } from '@nodarium/types';
import { getContext } from "svelte"; import { getGraphState } from '../graph-state.svelte';
import { createNodePath } from '../helpers/index.js';
import { getSocketPosition } from '../helpers/nodeHelpers';
export let node: Node; const graphState = getGraphState();
const setDownSocket = getContext<(socket: Socket) => void>("setDownSocket"); const { node }: { node: NodeInstance } = $props();
const getSocketPosition =
getContext<(node: Node, index: number) => [number, number]>(
"getSocketPosition",
);
function handleMouseDown(event: MouseEvent) { function handleMouseDown(event: MouseEvent) {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
setDownSocket?.({ if ('state' in node) {
graphState.setDownSocket?.({
node, node,
index: 0, index: 0,
position: getSocketPosition?.(node, 0), position: getSocketPosition?.(node, 0)
}); });
} }
}
const cornerTop = 10; const cornerTop = 10;
const rightBump = !!node?.tmp?.type?.outputs?.length; const rightBump = $derived(!!node?.state?.type?.outputs?.length && node.type !== '__virtual/group/input');
const aspectRatio = 0.25; const aspectRatio = 0.25;
const path = createNodePath({ const path = $derived(
createNodePath({
depth: 5.5, depth: 5.5,
height: 34, height: 34,
y: 49, y: 49,
cornerTop, cornerTop,
rightBump, rightBump,
aspectRatio, aspectRatio
}); })
const pathDisabled = createNodePath({ );
depth: 0, const pathHover = $derived(
height: 15, createNodePath({
y: 50, depth: 7,
cornerTop, height: 40,
rightBump,
aspectRatio,
});
const pathHover = createNodePath({
depth: 8.5,
height: 50,
y: 49, y: 49,
cornerTop, cornerTop,
rightBump, rightBump,
aspectRatio, aspectRatio
}); })
);
const socketId = $derived(`${node.id}-${0}`);
function getSocketType(s: Socket | null) {
if (!s) return 'unknown';
if (typeof s.index === 'string') {
return s.node.state.type?.inputs?.[s.index].type || 'unknown';
}
return s.node.state.type?.outputs?.[s.index] || 'unknown';
}
const socketType = $derived(getSocketType(graphState.activeSocket));
const hoverColor = $derived(graphState.colors.getColor(socketType));
</script> </script>
<div class="wrapper" data-node-id={node.id} data-node-type={node.type}> <div
class="wrapper"
data-node-id={node.id}
data-node-type={node.type}
style:--socket-color={hoverColor}
class:possible-socket={graphState?.possibleSocketIds.has(socketId)}
>
<div class="content"> <div class="content">
{node.type.split("/").pop()} {#if appSettings.value.debug.advancedMode}
<span class="bg-white text-black! mr-2 px-1 rounded-sm opacity-30">{node.id}</span>
{/if}
{node.state?.type?.meta?.title ?? node.type.split('/').pop()}
</div> </div>
{#if node.type !== '__virtual/group/input'}
<div <div
class="click-target" class="target"
role="button" role="button"
tabindex="0" tabindex="0"
on:mousedown={handleMouseDown} onmousedown={handleMouseDown}
></div> >
</div>
{/if}
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100" viewBox="0 0 100 100"
@@ -72,8 +92,7 @@
--hover-path: path("${pathHover}"); --hover-path: path("${pathHover}");
`} `}
> >
<path vector-effect="non-scaling-stroke" stroke="white" stroke-width="0.1" <path vector-effect="non-scaling-stroke" stroke="white" stroke-width="0.1"></path>
></path>
</svg> </svg>
</div> </div>
@@ -84,7 +103,20 @@
height: 50px; height: 50px;
} }
.click-target { .possible-socket .target::before {
content: "";
position: absolute;
width: 30px;
height: 30px;
border-radius: 100%;
box-shadow: 0px 0px 10px var(--socket-color);
background-color: var(--socket-color);
outline: solid thin var(--socket-color);
opacity: 0.7;
z-index: -10;
}
.target {
position: absolute; position: absolute;
right: 0px; right: 0px;
top: 50%; top: 50%;
@@ -93,11 +125,9 @@
width: 30px; width: 30px;
z-index: 100; z-index: 100;
border-radius: 50%; border-radius: 50%;
/* background: red; */
/* opacity: 0.2; */
} }
.click-target:hover + svg path { .target:hover + svg path {
d: var(--hover-path); d: var(--hover-path);
} }
@@ -117,10 +147,13 @@
transition: transition:
d 0.3s ease, d 0.3s ease,
fill 0.3s ease; fill 0.3s ease;
fill: var(--layer-2); fill: var(--color-layer-2);
stroke: var(--stroke); stroke: var(--stroke);
stroke-width: var(--stroke-width); stroke-width: var(--stroke-width);
d: var(--path); d: var(--path);
stroke-linejoin: round;
shape-rendering: geometricPrecision;
} }
.content { .content {
@@ -1,40 +1,54 @@
<script lang="ts"> <script lang="ts">
import type { Node, NodeInput } from "@nodes/types"; import type { NodeInput, NodeInstance } from '@nodarium/types';
import { getGraphManager } from "../graph/context.js"; import { Input } from '@nodarium/ui';
import { Input } from "@nodes/ui"; import type { GraphManager } from '../graph-manager.svelte';
type Props = { type Props = {
node: Node; node: NodeInstance;
input: NodeInput; input: NodeInput;
id: string; id: string;
elementId?: string; elementId?: string;
graph?: GraphManager;
}; };
const { const {
node, node = $bindable(),
input, input,
id, id,
elementId = `input-${Math.random().toString(36).substring(7)}`, elementId = `input-${Math.random().toString(36).substring(7)}`,
graph
}: Props = $props(); }: Props = $props();
const graph = getGraphManager();
function getDefaultValue() { function getDefaultValue() {
if (node?.props?.[id] !== undefined) return node?.props?.[id] as number; if (node?.props?.[id] !== undefined) return node?.props?.[id] as number;
if ("value" in input && input?.value !== undefined) if ('value' in input && input?.value !== undefined) {
return input?.value as number; return input?.value as number;
if (input.type === "boolean") return 0; }
if (input.type === "float") return 0.5; if (input.type === 'boolean') return 0;
if (input.type === "integer") return 0; if (input.type === 'float') return 0.5;
if (input.type === "select") return 0; if (input.type === 'integer') return 0;
if (input.type === 'select') return 0;
return 0; return 0;
} }
let value = $state(getDefaultValue()); let value = $state(structuredClone($state.snapshot(getDefaultValue())));
function diffArray(a: number[], b?: number[] | number) {
if (!Array.isArray(b)) return true;
if (Array.isArray(a) !== Array.isArray(b)) return true;
if (a.length !== b.length) return true;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return true;
}
return false;
}
$effect(() => { $effect(() => {
if (value !== undefined && node?.props?.[id] !== value) { const a = $state.snapshot(value);
node.props = { ...node.props, [id]: value }; const b = $state.snapshot(node?.props?.[id]) as number | number[] | undefined;
const isDiff = Array.isArray(a) ? diffArray(a, b) : a !== b;
if (value !== undefined && isDiff) {
node.props = { ...node.props, [id]: a };
if (graph) { if (graph) {
graph.save(); graph.save();
graph.execute(); graph.execute();
@@ -1,126 +1,143 @@
<script lang="ts"> <script lang="ts">
import type { import type { NodeInput, NodeInstance, Socket } from '@nodarium/types';
NodeInput as NodeInputType, import { getGraphManager, getGraphState } from '../graph-state.svelte';
Socket, import { createNodePath } from '../helpers';
Node as NodeType, import { getParameterHeight, getSocketPosition } from '../helpers/nodeHelpers';
} from "@nodes/types"; import NodeInputEl from './NodeInput.svelte';
import { getContext } from "svelte";
import { createNodePath } from "../helpers/index.js";
import { getGraphManager } from "../graph/context.js";
import NodeInput from "./NodeInput.svelte";
import { getGraphState } from "../graph/state.svelte.js";
type Props = { type Props = {
node: NodeType; node: NodeInstance;
input: NodeInputType; input: NodeInput;
id: string; id: string;
isLast?: boolean; isLast?: boolean;
outputIndex?: number;
}; };
const { node = $bindable(), input, id, isLast }: Props = $props();
const inputType = node?.tmp?.type?.inputs?.[id]!;
const socketId = `${node.id}-${id}`;
const graph = getGraphManager(); const graph = getGraphManager();
const graphState = getGraphState(); const graphState = getGraphState();
const graphId = graph?.id; const graphId = graph?.id;
const inputSockets = graph?.inputSockets;
const elementId = `input-${Math.random().toString(36).substring(7)}`; const elementId = `input-${Math.random().toString(36).substring(7)}`;
const setDownSocket = getContext<(socket: Socket) => void>("setDownSocket"); let { node = $bindable(), input, id, isLast, outputIndex = undefined }: Props = $props();
const getSocketPosition =
getContext<(node: NodeType, index: string) => [number, number]>( const nodeType = $derived(node.state.type!);
"getSocketPosition",
); const inputType = $derived(nodeType.inputs?.[id]);
const socketId = $derived(`${node.id}-${id}`);
const outputSocketId = $derived(outputIndex !== undefined ? `${node.id}-${outputIndex}` : '');
const height = $derived(getParameterHeight(nodeType, id));
function handleMouseDown(ev: MouseEvent) { function handleMouseDown(ev: MouseEvent) {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
setDownSocket?.({ graphState.setDownSocket({
node, node,
index: id, index: id,
position: getSocketPosition?.(node, id), position: getSocketPosition(node, id)
}); });
} }
const leftBump = node.tmp?.type?.inputs?.[id].internal !== true; function handleOutputMouseDown(ev: MouseEvent) {
const cornerBottom = isLast ? 5 : 0; ev.preventDefault();
ev.stopPropagation();
if (outputIndex === undefined) return;
graphState.setDownSocket({
node,
index: outputIndex,
position: getSocketPosition(node, outputIndex)
});
}
const leftBump = $derived(!id.startsWith('__virtual') && nodeType.inputs?.[id].internal !== true && outputIndex === undefined);
const rightBump = $derived(outputIndex !== undefined);
const cornerBottom = $derived(isLast ? 5 : 0);
const aspectRatio = 0.5; const aspectRatio = 0.5;
const path = createNodePath({ const path = $derived(
depth: 7, createNodePath({
height: 20,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio,
});
const pathDisabled = createNodePath({
depth: 6, depth: 6,
height: 18, height: 2000 / height,
y: 50.5, y: 50.5,
cornerBottom, cornerBottom,
leftBump, leftBump,
aspectRatio, rightBump,
}); aspectRatio
const pathHover = createNodePath({ })
depth: 8, );
height: 25, const pathHover = $derived(
createNodePath({
depth: 7,
height: 2200 / height,
y: 50.5, y: 50.5,
cornerBottom, cornerBottom,
leftBump, leftBump,
aspectRatio, rightBump,
}); aspectRatio
})
);
function getSocketType(s: Socket | null) {
if (!s) return 'unknown';
if (typeof s.index === 'string') {
return s.node.state.type?.inputs?.[s.index].type || 'unknown';
}
return s.node.state.type?.outputs?.[s.index] || 'unknown';
}
const socketType = $derived(getSocketType(graphState.activeSocket));
const hoverColor = $derived(graphState.colors.getColor(socketType));
</script> </script>
<div <div
class="wrapper" class="wrapper"
data-node-type={node.type} data-node-type={node.type}
data-node-input={id} data-node-input={id}
class:disabled={!graphState?.possibleSocketIds.has(socketId)} style:height="{height}px"
style:--socket-color={hoverColor}
class:possible-socket={outputIndex !== undefined
? graphState?.possibleSocketIds.has(outputSocketId)
: graphState?.possibleSocketIds.has(socketId)}
> >
{#key id && graphId} {#key id && graphId}
<div class="content" class:disabled={$inputSockets?.has(socketId)}> <div class="content" class:disabled={graph?.inputSockets?.has(socketId)}>
{#if inputType.label !== ""} {#if inputType?.label !== '' && !id.startsWith('__virtual')}
<label for={elementId}>{input.label || id}</label> <label for={elementId} title={input.description}>{input.label || id}</label>
{/if} {/if}
{#if inputType.external !== true} {#if inputType?.external !== true}
<NodeInput {elementId} {node} {input} {id} /> <NodeInputEl {graph} {elementId} bind:node {input} {id} />
{/if} {/if}
</div> </div>
{#if node?.tmp?.type?.inputs?.[id]?.internal !== true} {#if outputIndex === undefined && node?.state?.type?.inputs?.[id]?.internal !== true}
<div <div
data-node-socket data-node-socket
class="large target" class="target"
onmousedown={handleMouseDown} onmousedown={handleMouseDown}
role="button" role="button"
tabindex="0" tabindex="0"
></div> >
<div </div>
data-node-socket
class="small target"
onmousedown={handleMouseDown}
role="button"
tabindex="0"
></div>
{/if} {/if}
{/key} {/key}
{#if outputIndex !== undefined}
<div
data-node-socket
class="target target-right"
onmousedown={handleOutputMouseDown}
role="button"
tabindex="0"
>
</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"
width="100"
height="100"
preserveAspectRatio="none" preserveAspectRatio="none"
style={` style:--path={`path("${path}")`}
--path: path("${path}"); style:--hover-path={`path("${pathHover}")`}
--hover-path: path("${pathHover}");
--hover-path-disabled: path("${pathDisabled}");
`}
> >
<path vector-effect="non-scaling-stroke"></path> <path vector-effect="non-scaling-stroke"></path>
</svg> </svg>
@@ -130,42 +147,53 @@
.wrapper { .wrapper {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100px;
transform: translateY(-0.5px); transform: translateY(-0.5px);
} }
.target { .target {
width: 30px;
height: 30px;
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
top: 50%; top: 50%;
transform: translateY(-50%) translateX(-50%); transform: translateY(-50%) translateX(-50%);
/* background: red; */
/* opacity: 0.1; */
} }
.small.target { .target-right {
right: 0;
left: auto;
transform: translateY(-50%) translateX(50%);
}
.target-right:hover ~ svg path {
d: var(--hover-path);
}
.possible-socket .target::before {
content: "";
position: absolute;
width: 30px; width: 30px;
height: 30px; height: 30px;
border-radius: 100%;
box-shadow: 0px 0px 10px var(--socket-color);
background-color: var(--socket-color);
outline: solid thin var(--socket-color);
opacity: 0.5;
z-index: -10;
} }
.large.target { .target:hover ~ svg path{
width: 60px; d: var(--hover-path);
height: 60px;
cursor: unset;
pointer-events: none;
}
:global(.hovering-sockets) .large.target {
pointer-events: all;
} }
.content { .content {
position: relative; position: relative;
padding: 10px 20px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-inline: 20px;
height: 100%; height: 100%;
justify-content: space-around; justify-content: center;
gap: 10px;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -181,29 +209,21 @@
} }
svg path { svg path {
transition: transition: d 0.3s ease, fill 0.3s ease;
d 0.3s ease, fill: var(--color-layer-1);
fill 0.3s ease;
fill: var(--layer-1);
stroke: var(--stroke); stroke: var(--stroke);
stroke-width: var(--stroke-width); stroke-width: var(--stroke-width);
d: var(--path); d: var(--path);
}
:global { stroke-linejoin: round;
.hovering-sockets .large:hover ~ svg path { shape-rendering: geometricPrecision;
d: var(--hover-path);
}
} }
.content.disabled { .content.disabled {
opacity: 0.2; opacity: 0.2;
} }
.content.disabled > * {
pointer-events: none;
}
.disabled svg path { .possible-socket svg path {
d: var(--hover-path-disabled) !important; d: var(--hover-path);
} }
</style> </style>
+86
View File
@@ -0,0 +1,86 @@
import type { NodeDefinition, NodeId, NodeRegistry } from '@nodarium/types';
export function createMockNodeRegistry(nodes: NodeDefinition[]): NodeRegistry {
const nodesMap = new Map(nodes.map(n => [n.id, n]));
return {
status: 'ready' as const,
load: async (nodeIds: NodeId[]) => {
const loaded: NodeDefinition[] = [];
for (const id of nodeIds) {
if (nodesMap.has(id)) {
loaded.push(nodesMap.get(id)!);
}
}
return loaded;
},
getNode: (id: string) => nodesMap.get(id as NodeId),
getAllNodes: () => Array.from(nodesMap.values()),
register: async () => {
throw new Error('Not implemented in mock');
}
};
}
export const mockFloatOutputNode: NodeDefinition = {
id: 'test/node/output',
inputs: {},
outputs: ['float'],
meta: { title: 'Float Output' },
execute: () => new Int32Array()
};
export const mockFloatInputNode: NodeDefinition = {
id: 'test/node/input',
inputs: { value: { type: 'float' } },
outputs: [],
meta: { title: 'Float Input' },
execute: () => new Int32Array()
};
export const mockGeometryOutputNode: NodeDefinition = {
id: 'test/node/geometry',
inputs: {},
outputs: ['geometry'],
meta: { title: 'Geometry Output' },
execute: () => new Int32Array()
};
export const mockPathInputNode: NodeDefinition = {
id: 'test/node/path',
inputs: { input: { type: 'path', accepts: ['geometry'] } },
outputs: [],
meta: { title: 'Path Input' },
execute: () => new Int32Array()
};
export const mockVec3OutputNode: NodeDefinition = {
id: 'test/node/vec3',
inputs: {},
outputs: ['vec3'],
meta: { title: 'Vec3 Output' },
execute: () => new Int32Array()
};
export const mockIntegerInputNode: NodeDefinition = {
id: 'test/node/integer',
inputs: { value: { type: 'integer' } },
outputs: [],
meta: { title: 'Integer Input' },
execute: () => new Int32Array()
};
export const mockBooleanOutputNode: NodeDefinition = {
id: 'test/node/boolean',
inputs: {},
outputs: ['boolean'],
meta: { title: 'Boolean Output' },
execute: () => new Int32Array()
};
export const mockBooleanInputNode: NodeDefinition = {
id: 'test/node/boolean-input',
inputs: { value: { type: 'boolean' } },
outputs: [],
meta: { title: 'Boolean Input' },
execute: () => new Int32Array()
};
-20
View File
@@ -1,20 +0,0 @@
import type { Node, NodeDefinition } from "@nodes/types";
export type GraphNode = Node & {
tmp?: {
depth?: number;
mesh?: any;
random?: number;
parents?: Node[];
children?: Node[];
inputNodes?: Record<string, Node>;
type?: NodeDefinition;
downX?: number;
downY?: number;
x?: number;
y?: number;
ref?: HTMLElement;
visible?: boolean;
isMoving?: boolean;
};
};
+110
View File
@@ -0,0 +1,110 @@
import { grid } from '$lib/graph-templates/grid';
import { tree } from '$lib/graph-templates/tree';
import { describe, expect, it } from 'vitest';
describe('graph-templates', () => {
describe('grid', () => {
it('should create a grid graph with nodes and edges', () => {
const result = grid(2, 3);
expect(result.nodes.length).toBeGreaterThan(0);
expect(result.edges.length).toBeGreaterThan(0);
});
it('should have output node at the end', () => {
const result = grid(1, 1);
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
expect(outputNode).toBeDefined();
});
it('should create nodes based on grid dimensions', () => {
const result = grid(2, 2);
const mathNodes = result.nodes.filter(n => n.type === 'max/plantarium/math');
expect(mathNodes.length).toBeGreaterThan(0);
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
expect(outputNode).toBeDefined();
});
it('should have output node at the end', () => {
const result = grid(1, 1);
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
expect(outputNode).toBeDefined();
});
it('should create nodes based on grid dimensions', () => {
const result = grid(2, 2);
const mathNodes = result.nodes.filter(n => n.type === 'max/plantarium/math');
expect(mathNodes.length).toBeGreaterThan(0);
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
expect(outputNode).toBeDefined();
});
it('should have valid node positions', () => {
const result = grid(3, 2);
result.nodes.forEach(node => {
expect(node.position).toHaveLength(2);
expect(typeof node.position[0]).toBe('number');
expect(typeof node.position[1]).toBe('number');
});
});
it('should generate valid graph structure', () => {
const result = grid(2, 2);
result.nodes.forEach(node => {
expect(typeof node.id).toBe('number');
expect(node.type).toBeTruthy();
});
result.edges.forEach(edge => {
expect(edge).toHaveLength(4);
});
});
});
describe('tree', () => {
it('should create a tree graph with specified depth', () => {
const result = tree(0);
expect(result.nodes.length).toBeGreaterThan(0);
expect(result.edges.length).toBeGreaterThan(0);
});
it('should have root output node', () => {
const result = tree(2);
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
expect(outputNode).toBeDefined();
expect(outputNode?.id).toBe(0);
});
it('should increase node count with depth', () => {
const tree0 = tree(0);
const tree1 = tree(1);
const tree2 = tree(2);
expect(tree0.nodes.length).toBeLessThan(tree1.nodes.length);
expect(tree1.nodes.length).toBeLessThan(tree2.nodes.length);
});
it('should create binary tree structure', () => {
const result = tree(2);
const mathNodes = result.nodes.filter(n => n.type === 'max/plantarium/math');
expect(mathNodes.length).toBeGreaterThan(0);
const edgeCount = result.edges.length;
expect(edgeCount).toBe(result.nodes.length - 1);
});
it('should have valid node positions', () => {
const result = tree(3);
result.nodes.forEach(node => {
expect(node.position).toHaveLength(2);
expect(typeof node.position[0]).toBe('number');
expect(typeof node.position[1]).toBe('number');
});
});
});
});
+95 -1
View File
@@ -1 +1,95 @@
{"settings":{"resolution.circle":26,"resolution.curve":39},"nodes":[{"id":9,"position":[220,80],"type":"max/plantarium/output","props":{}},{"id":10,"position":[95,80],"type":"max/plantarium/stem","props":{"amount":5,"length":11,"thickness":0.1}},{"id":14,"position":[195,80],"type":"max/plantarium/gravity","props":{"strength":0.38,"scale":39,"fixBottom":0,"directionalStrength":[1,1,1],"depth":1,"curviness":1}},{"id":15,"position":[120,80],"type":"max/plantarium/noise","props":{"strength":4.9,"scale":2.2,"fixBottom":1,"directionalStrength":[1,1,1],"depth":1,"octaves":1}},{"id":16,"position":[70,80],"type":"max/plantarium/vec3","props":{"0":0,"1":0,"2":0}},{"id":17,"position":[45,80],"type":"max/plantarium/random","props":{"min":-2,"max":2}},{"id":18,"position":[170,80],"type":"max/plantarium/branch","props":{"length":1.6,"thickness":0.69,"amount":36,"offsetSingle":0.5,"lowestBranch":0.46,"highestBranch":1,"depth":1,"rotation":180}},{"id":19,"position":[145,80],"type":"max/plantarium/gravity","props":{"strength":0.38,"scale":39,"fixBottom":0,"directionalStrength":[1,1,1],"depth":1,"curviness":1}},{"id":20,"position":[70,120],"type":"max/plantarium/random","props":{"min":0.073,"max":0.15}}],"edges":[[14,0,9,"input"],[10,0,15,"plant"],[16,0,10,"origin"],[17,0,16,"0"],[17,0,16,"2"],[18,0,14,"plant"],[15,0,19,"plant"],[19,0,18,"plant"],[20,0,10,"thickness"]]} {
"settings": { "resolution.circle": 26, "resolution.curve": 39 },
"nodes": [
{ "id": 9, "position": [220, 80], "type": "max/plantarium/output", "props": {} },
{
"id": 10,
"position": [95, 80],
"type": "max/plantarium/stem",
"props": { "amount": 5, "length": 11, "thickness": 0.1 }
},
{
"id": 14,
"position": [195, 80],
"type": "max/plantarium/gravity",
"props": {
"strength": 0.38,
"scale": 39,
"fixBottom": 0,
"directionalStrength": [1, 1, 1],
"depth": 1,
"curviness": 1
}
},
{
"id": 15,
"position": [120, 80],
"type": "max/plantarium/noise",
"props": {
"strength": 4.9,
"scale": 2.2,
"fixBottom": 1,
"directionalStrength": [1, 1, 1],
"depth": 1,
"octaves": 1
}
},
{
"id": 16,
"position": [70, 80],
"type": "max/plantarium/vec3",
"props": { "0": 0, "1": 0, "2": 0 }
},
{
"id": 17,
"position": [45, 80],
"type": "max/plantarium/random",
"props": { "min": -2, "max": 2 }
},
{
"id": 18,
"position": [170, 80],
"type": "max/plantarium/branch",
"props": {
"length": 1.6,
"thickness": 0.69,
"amount": 36,
"offsetSingle": 0.5,
"lowestBranch": 0.46,
"highestBranch": 1,
"depth": 1,
"rotation": 180
}
},
{
"id": 19,
"position": [145, 80],
"type": "max/plantarium/gravity",
"props": {
"strength": 0.38,
"scale": 39,
"fixBottom": 0,
"directionalStrength": [1, 1, 1],
"depth": 1,
"curviness": 1
}
},
{
"id": 20,
"position": [70, 120],
"type": "max/plantarium/random",
"props": { "min": 0.073, "max": 0.15 }
}
],
"edges": [
[14, 0, 9, "input"],
[10, 0, 15, "plant"],
[16, 0, 10, "origin"],
[17, 0, 16, "0"],
[17, 0, 16, "2"],
[18, 0, 14, "plant"],
[15, 0, 19, "plant"],
[19, 0, 18, "plant"],
[20, 0, 10, "thickness"]
]
}
+6 -14
View File
@@ -1,11 +1,10 @@
import type { Graph } from "@nodes/types"; import type { Graph } from '@nodarium/types';
export function grid(width: number, height: number) { 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: []
}; };
const amount = width * height; const amount = width * height;
@@ -16,27 +15,20 @@ export function grid(width: number, height: number) {
graph.nodes.push({ graph.nodes.push({
id: i, id: i,
tmp: {
visible: false,
},
position: [x * 30, y * 40], position: [x * 30, y * 40],
props: i == 0 ? { value: 0 } : { op_type: 0, a: 1, b: 0.05 }, props: i == 0 ? { value: 0 } : { op_type: 0, a: 1, b: 0.05 },
type: i == 0 ? "max/plantarium/float" : "max/plantarium/math", type: i == 0 ? 'max/plantarium/float' : 'max/plantarium/math'
}); });
graph.edges.push([i, 0, i + 1, i === amount - 1 ? "input" : "a",]); graph.edges.push([i, 0, i + 1, i === amount - 1 ? 'input' : 'a']);
} }
graph.nodes.push({ graph.nodes.push({
id: amount, id: amount,
tmp: {
visible: false,
},
position: [width * 30, (height - 1) * 40], position: [width * 30, (height - 1) * 40],
type: "max/plantarium/output", type: 'max/plantarium/output',
props: {}, props: {}
}); });
return graph; return graph;
} }
+9 -8
View File
@@ -1,8 +1,9 @@
export { grid } from "./grid"; export { default as defaultPlant } from './default.json';
export { tree } from "./tree"; export { grid } from './grid';
export { plant } from "./plant"; export { default as lottaFaces } from './lotta-faces.json';
export { default as lottaFaces } from "./lotta-faces.json"; export { default as lottaNodesAndFaces } from './lotta-nodes-and-faces.json';
export { default as lottaNodes } from "./lotta-nodes.json"; export { default as lottaNodes } from './lotta-nodes.json';
export { default as defaultPlant } from "./default.json" export { plant } from './plant';
export { default as lottaNodesAndFaces } from "./lotta-nodes-and-faces.json"; export { default as simple } from './simple.json';
export { tree } from './tree';
export { default as tutorial } from './tutorial.json';
+44 -1
View File
@@ -1 +1,44 @@
{"settings":{"resolution.circle":64,"resolution.curve":64,"randomSeed":false},"nodes":[{"id":9,"position":[260,0],"type":"max/plantarium/output","props":{}},{"id":18,"position":[185,0],"type":"max/plantarium/stem","props":{"amount":64,"length":12,"thickness":0.15}},{"id":19,"position":[210,0],"type":"max/plantarium/noise","props":{"scale":1.3,"strength":5.4}},{"id":20,"position":[235,0],"type":"max/plantarium/branch","props":{"length":0.8,"thickness":0.8,"amount":3}},{"id":21,"position":[160,0],"type":"max/plantarium/vec3","props":{"0":0.39,"1":0,"2":0.41}},{"id":22,"position":[130,0],"type":"max/plantarium/random","props":{"min":-2,"max":2}}],"edges":[[18,0,19,"plant"],[19,0,20,"plant"],[20,0,9,"input"],[21,0,18,"origin"],[22,0,21,"0"],[22,0,21,"2"]]} {
"settings": { "resolution.circle": 64, "resolution.curve": 64, "randomSeed": false },
"nodes": [
{ "id": 9, "position": [260, 0], "type": "max/plantarium/output", "props": {} },
{
"id": 18,
"position": [185, 0],
"type": "max/plantarium/stem",
"props": { "amount": 64, "length": 12, "thickness": 0.15 }
},
{
"id": 19,
"position": [210, 0],
"type": "max/plantarium/noise",
"props": { "scale": 1.3, "strength": 5.4 }
},
{
"id": 20,
"position": [235, 0],
"type": "max/plantarium/branch",
"props": { "length": 0.8, "thickness": 0.8, "amount": 3 }
},
{
"id": 21,
"position": [160, 0],
"type": "max/plantarium/vec3",
"props": { "0": 0.39, "1": 0, "2": 0.41 }
},
{
"id": 22,
"position": [130, 0],
"type": "max/plantarium/random",
"props": { "min": -2, "max": 2 }
}
],
"edges": [
[18, 0, 19, "plant"],
[19, 0, 20, "plant"],
[20, 0, 9, "input"],
[21, 0, 18, "origin"],
[22, 0, 21, "0"],
[22, 0, 21, "2"]
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+69 -10
View File
@@ -1,12 +1,71 @@
export const plant = { export const plant = {
"settings": { "resolution.circle": 26, "resolution.curve": 39 }, 'settings': { 'resolution.circle': 26, 'resolution.curve': 39 },
"nodes": [ 'nodes': [
{ "id": 9, "position": [180, 80], "type": "max/plantarium/output", "props": {} }, { 'id': 9, 'position': [180, 80], 'type': 'max/plantarium/output', 'props': {} },
{ "id": 10, "position": [55, 80], "type": "max/plantarium/stem", "props": { "amount": 1, "length": 11, "thickness": 0.71 } }, {
{ "id": 11, "position": [80, 80], "type": "max/plantarium/noise", "props": { "strength": 35, "scale": 4.6, "fixBottom": 1, "directionalStrength": [1, 0.74, 0.083], "depth": 1 } }, 'id': 10,
{ "id": 12, "position": [105, 80], "type": "max/plantarium/branch", "props": { "length": 3, "thickness": 0.6, "amount": 10, "rotation": 180, "offsetSingle": 0.34, "lowestBranch": 0.53, "highestBranch": 1, "depth": 1 } }, 'position': [55, 80],
{ "id": 13, "position": [130, 80], "type": "max/plantarium/noise", "props": { "strength": 8, "scale": 7.7, "fixBottom": 1, "directionalStrength": [1, 0, 1], "depth": 1 } }, 'type': 'max/plantarium/stem',
{ "id": 14, "position": [155, 80], "type": "max/plantarium/gravity", "props": { "strength": 0.11, "scale": 39, "fixBottom": 0, "directionalStrength": [1, 1, 1], "depth": 1, "curviness": 1 } } 'props': { 'amount': 1, 'length': 11, 'thickness': 0.71 }
},
{
'id': 11,
'position': [80, 80],
'type': 'max/plantarium/noise',
'props': {
'strength': 35,
'scale': 4.6,
'fixBottom': 1,
'directionalStrength': [1, 0.74, 0.083],
'depth': 1
}
},
{
'id': 12,
'position': [105, 80],
'type': 'max/plantarium/branch',
'props': {
'length': 3,
'thickness': 0.6,
'amount': 10,
'rotation': 180,
'offsetSingle': 0.34,
'lowestBranch': 0.53,
'highestBranch': 1,
'depth': 1
}
},
{
'id': 13,
'position': [130, 80],
'type': 'max/plantarium/noise',
'props': {
'strength': 8,
'scale': 7.7,
'fixBottom': 1,
'directionalStrength': [1, 0, 1],
'depth': 1
}
},
{
'id': 14,
'position': [155, 80],
'type': 'max/plantarium/gravity',
'props': {
'strength': 0.11,
'scale': 39,
'fixBottom': 0,
'directionalStrength': [1, 1, 1],
'depth': 1,
'curviness': 1
}
}
], ],
"edges": [[10, 0, 11, "plant"], [11, 0, 12, "plant"], [12, 0, 13, "plant"], [13, 0, 14, "plant"], [14, 0, 9, "input"]] 'edges': [
} [10, 0, 11, 'plant'],
[11, 0, 12, 'plant'],
[12, 0, 13, 'plant'],
[13, 0, 14, 'plant'],
[14, 0, 9, 'input']
]
};
+63
View File
@@ -0,0 +1,63 @@
{
"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": {}
},
{
"id": 10,
"position": [
165,
72.5
],
"type": "max/plantarium/stem",
"props": {
"amount": 4,
"length": 4,
"thickness": 0.2
}
},
{
"id": 11,
"position": [
190,
77.5
],
"type": "max/plantarium/noise",
"props": {
"plant": 0,
"scale": 0.5,
"strength": 5
}
}
],
"edges": [
[
10,
0,
11,
"plant"
],
[
11,
0,
9,
"input"
]
]
}
+12 -16
View File
@@ -1,28 +1,26 @@
import type { Graph, Node } from "@nodes/types"; import type { Graph, SerializedNode } from '@nodarium/types';
export function tree(depth: number): Graph { export function tree(depth: number): Graph {
const nodes: SerializedNode[] = [
const nodes: Node[] = [
{ {
id: 0, id: 0,
type: "max/plantarium/output", type: 'max/plantarium/output',
position: [0, 0] position: [0, 0]
}, },
{ {
id: 1, id: 1,
type: "max/plantarium/math", type: 'max/plantarium/math',
position: [-40, -10] position: [-40, -10]
} }
] ];
const edges: [number, number, number, string][] = [ const edges: [number, number, number, string][] = [
[1, 0, 0, "input"] [1, 0, 0, 'input']
]; ];
for (let d = 0; d < depth; d++) { for (let d = 0; d < depth; d++) {
const amount = Math.pow(2, d); const amount = Math.pow(2, d);
for (let i = 0; i < amount; i++) { for (let i = 0; i < amount; i++) {
const id0 = amount * 2 + i * 2; const id0 = amount * 2 + i * 2;
const id1 = amount * 2 + i * 2 + 1; const id1 = amount * 2 + i * 2 + 1;
@@ -33,24 +31,22 @@ export function tree(depth: number): Graph {
nodes.push({ nodes.push({
id: id0, id: id0,
type: "max/plantarium/math", type: 'max/plantarium/math',
position: [x, y], position: [x, y]
}); });
edges.push([id0, 0, parent, "a"]); edges.push([id0, 0, parent, 'a']);
nodes.push({ nodes.push({
id: id1, id: id1,
type: "max/plantarium/math", type: 'max/plantarium/math',
position: [x, y + 35], position: [x, y + 35]
}); });
edges.push([id1, 0, parent, "b"]); edges.push([id1, 0, parent, 'b']);
} }
} }
return { return {
id: Math.floor(Math.random() * 100000), id: Math.floor(Math.random() * 100000),
nodes, nodes,
edges edges
}; };
} }
+24
View File
@@ -0,0 +1,24 @@
{
"id": 0,
"settings": {
"resolution.circle": 54,
"resolution.curve": 20,
"randomSeed": false
},
"meta": {
"title": "New Project",
"lastModified": "2026-02-03T16:56:40.375Z"
},
"nodes": [
{
"id": 9,
"position": [
215,
85
],
"type": "max/plantarium/output",
"props": {}
}
],
"edges": []
}
+17 -16
View File
@@ -1,23 +1,24 @@
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext, type Snippet } from 'svelte';
import type { Writable } from "svelte/store";
let index = -1; let index = $state(-1);
let wrapper: HTMLDivElement; let wrapper: HTMLDivElement;
$: if (index === -1) { const { children } = $props<{ children?: Snippet }>();
index = getContext<() => number>("registerCell")();
$effect(() => {
if (index === -1) {
index = getContext<() => number>('registerCell')();
} }
});
const sizes = getContext<Writable<string[]>>("sizes"); const sizes = getContext<{ value: string[] }>('sizes');
let downSizes: string[] = [];
let downWidth = 0; let downWidth = 0;
let mouseDown = false; let mouseDown = false;
let startX = 0; let startX = 0;
function handleMouseDown(event: MouseEvent) { function handleMouseDown(event: MouseEvent) {
downSizes = [...$sizes];
mouseDown = true; mouseDown = true;
startX = event.clientX; startX = event.clientX;
downWidth = wrapper.getBoundingClientRect().width; downWidth = wrapper.getBoundingClientRect().width;
@@ -26,15 +27,14 @@
function handleMouseMove(event: MouseEvent) { function handleMouseMove(event: MouseEvent) {
if (mouseDown) { if (mouseDown) {
const width = downWidth + startX - event.clientX; const width = downWidth + startX - event.clientX;
$sizes[index] = `${width}px`; sizes.value[index] = `${width}px`;
$sizes = $sizes;
} }
} }
</script> </script>
<svelte:window <svelte:window
on:mouseup={() => (mouseDown = false)} onmouseup={() => (mouseDown = false)}
on:mousemove={handleMouseMove} onmousemove={handleMouseMove}
/> />
{#if index > 0} {#if index > 0}
@@ -42,12 +42,13 @@
class="seperator" class="seperator"
role="button" role="button"
tabindex="0" tabindex="0"
on:mousedown={handleMouseDown} onmousedown={handleMouseDown}
></div> >
</div>
{/if} {/if}
<div class="cell" bind:this={wrapper}> <div class="cell" bind:this={wrapper}>
<slot /> {@render children?.()}
</div> </div>
<style> <style>
@@ -61,7 +62,7 @@
cursor: ew-resize; cursor: ew-resize;
height: 100%; height: 100%;
width: 1px; width: 1px;
background: var(--outline); background: var(--color-outline);
} }
.seperator::before { .seperator::before {
content: ""; content: "";
+6 -4
View File
@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { setContext } from "svelte"; import { onMount, setContext, type Snippet } from 'svelte';
export let id = "grid-0"; const { children, id } = $props<{ children?: Snippet; id?: string }>();
setContext("grid-id", id); onMount(() => {
setContext('grid-id', id);
});
</script> </script>
<slot {id} /> {@render children({ id })}
+14 -10
View File
@@ -1,27 +1,31 @@
<script lang="ts"> <script lang="ts">
import { setContext, getContext } from "svelte"; import { localState } from '$lib/helpers/localState.svelte';
import localStore from "$lib/helpers/localStore"; import { getContext, setContext } from 'svelte';
const gridId = getContext<string>("grid-id") || "grid-0"; const gridId = getContext<string>('grid-id') || 'grid-0';
let sizes = localStore<string[]>(gridId, []); let sizes = localState<string[]>(gridId, []);
const { children } = $props();
let registerIndex = 0; let registerIndex = 0;
setContext("registerCell", function () { setContext('registerCell', function() {
let index = registerIndex; let index = registerIndex;
registerIndex++; registerIndex++;
if (registerIndex > $sizes.length) { if (registerIndex > sizes.value.length) {
$sizes = [...$sizes, "1fr"]; sizes.value = [...sizes.value, '1fr'];
} }
return index; return index;
}); });
setContext("sizes", sizes); setContext('sizes', sizes);
$: cols = $sizes.map((size, i) => `${i > 0 ? "1px " : ""}` + size).join(" "); const cols = $derived(
sizes.value.map((size, i) => `${i > 0 ? '1px ' : ''}` + size).join(' ')
);
</script> </script>
<div class="wrapper" style={`grid-template-columns: ${cols};`}> <div class="wrapper" style={`grid-template-columns: ${cols};`}>
<slot /> {@render children()}
</div> </div>
<style> <style>

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