49 Commits

Author SHA1 Message Date
fdd9785fc7 wip 2026-02-17 13:45:21 +01:00
3be978ffb0 feat: some shit 2026-02-17 13:45:21 +01:00
Max Richter
39b8095497 feat: add debug node 2026-02-17 13:45:21 +01:00
Max Richter
251f999739 feat: some shit 2026-02-17 13:45:21 +01:00
Max Richter
e3947534f6 chore: make sure to specify wasm flags only on wasm build 2026-02-17 13:45:21 +01:00
Max Richter
664ed4e029 chore: cargo fix 2026-02-17 13:45:20 +01:00
Max Richter
1063d33536 feat: make all nodes work with new runtime 2026-02-17 13:45:20 +01:00
Max Richter
ab08fc7486 feat: first working version of new allocator 2026-02-17 13:45:20 +01:00
Max Richter
2a14ed7202 feat: add "*"/any type input for dev page 2026-02-17 13:45:19 +01:00
8d403ba803 Merge pull request 'feat/shape-node' (#36) from feat/shape-node into main
All checks were successful
🚀 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
All checks were successful
🚀 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
All checks were successful
🚀 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
All checks were successful
🚀 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
8865b9b032 feat(node): initial leaf / shape nodes
All checks were successful
🚀 Lint & Test & Deploy / release (pull_request) Successful in 4m1s
2026-02-09 18:32:52 +01:00
235ee5d979 fix(app): wrong linter errors in changelog
All checks were successful
🚀 Lint & Test & Deploy / release (pull_request) Successful in 3m37s
2026-02-09 16:54:45 +01:00
23a48572f3 feat(app): dots background for node interface 2026-02-09 16:53:57 +01:00
e89a46e146 feat(app): add error page
All checks were successful
🚀 Lint & Test & Deploy / release (pull_request) Successful in 4m20s
2026-02-09 16:18:27 +01:00
cefda41fcf feat(theme): optimize node readability 2026-02-09 16:18:19 +01:00
21d0f0da5a feat: add high-contrast-light theme 2026-02-09 16:04:17 +01:00
46202451ba ci: simplify ci quality checks
All checks were successful
🚀 Lint & Test & Deploy / release (pull_request) Successful in 3m43s
2026-02-09 15:51:55 +01:00
0f4239d179 ci: simplify ci quality checks
Some checks failed
🚀 Lint & Test & Deploy / release (pull_request) Failing after 38s
2026-02-09 15:50:05 +01:00
d9c9bb5234 fix(theme): allow raw html in head style 2026-02-09 15:49:50 +01:00
18802fdc10 fix(ui): add missing types
Some checks failed
🚀 Lint & Test & Deploy / release (pull_request) Failing after 2m45s
2026-02-09 15:37:37 +01:00
b1cbd23542 feat(app): use same color for node outline and header
Some checks failed
🚀 Lint & Test & Deploy / release (pull_request) Failing after 2m3s
2026-02-09 15:30:40 +01:00
33f10da396 feat(ui): make details stand out
Some checks failed
🚀 Lint & Test & Deploy / release (pull_request) Failing after 2m6s
2026-02-09 15:26:48 +01:00
af5b3b23ba fix: make sure that CHANGELOG.md is in correct place 2026-02-09 15:26:40 +01:00
64d75b9686 feat(ui): add InputColor and custom theme 2026-02-09 15:26:18 +01:00
release-bot
2e6466ceca chore: update dprint linters
All checks were successful
🚀 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
All checks were successful
🚀 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
All checks were successful
🚀 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
All checks were successful
🚀 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
All checks were successful
🚀 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
All checks were successful
🚀 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
All checks were successful
🚀 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
979e9fd922 feat: improve changelog readbility
Some checks failed
🚀 Lint & Test & Deploy / release (push) Failing after 2m41s
2026-02-07 17:40:49 +01:00
544500e7fe chore: remove pgp from changelog
All checks were successful
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
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
99 changed files with 4090 additions and 704 deletions

9
.cargo/config.toml Normal file
View File

@@ -0,0 +1,9 @@
[target.wasm32-unknown-unknown]
rustflags = [
"-C",
"link-arg=--import-memory",
"-C",
"link-arg=--initial-memory=67108864", # 64 MiB
"-C",
"link-arg=--max-memory=536870912", # 512 MiB
]

View File

@@ -42,15 +42,18 @@
"**/*-lock.yaml",
"**/yaml.lock",
"**/.DS_Store",
"**/.pnpm-store",
"**/.cargo",
"**/target",
],
"plugins": [
"https://plugins.dprint.dev/typescript-0.95.13.wasm",
"https://plugins.dprint.dev/typescript-0.95.15.wasm",
"https://plugins.dprint.dev/json-0.21.1.wasm",
"https://plugins.dprint.dev/markdown-0.20.0.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.5.1.wasm",
"https://plugins.dprint.dev/g-plane/pretty_yaml-v0.6.0.wasm",
"https://plugins.dprint.dev/exec-0.6.0.json@a054130d458f124f9b5c91484833828950723a5af3f8ff2bd1523bd47b83b364",
],
}

View File

@@ -21,6 +21,8 @@ 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:-}",
@@ -31,7 +33,7 @@ cat >app/static/git.json <<EOF
"event_name": "${GITHUB_EVENT_NAME:-}",
"workflow": "${GITHUB_WORKFLOW:-}",
"job": "${GITHUB_JOB:-}",
"commit_message": "$(git log -1 --pretty=%B)",
"commit_message": "${commit_message}",
"commit_timestamp": "$(git log -1 --pretty=%cI)",
"branch": "${BRANCH}",
"commits_since_last_release": "${COMMITS_SINCE_LAST_RELEASE}"

View File

@@ -1,18 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
pnpm build
pnpm lint &
LINT_PID=$!
pnpm format:check &
FORMAT_PID=$!
pnpm check &
TYPE_PID=$!
xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" pnpm test &
TEST_PID=$!
wait $LINT_PID
wait $FORMAT_PID
wait $TYPE_PID
wait $TEST_PID

View File

@@ -16,7 +16,7 @@ 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)')
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"
@@ -52,16 +52,15 @@ fi
# -------------------------------------------------------------------
tmp_changelog="CHANGELOG.tmp"
{
echo "## $TAG ($DATE)"
echo "# $TAG ($DATE)"
echo ""
echo "$NOTES"
echo ""
if [ -n "$COMMITS" ]; then
echo "### All Commits in this version:"
echo "---"
echo "$COMMITS"
echo ""
fi
echo "---"
echo ""
if [ -f CHANGELOG.md ]; then
cat CHANGELOG.md
@@ -87,5 +86,6 @@ else
git push origin main
fi
rm app/static/CHANGELOG.md
cp CHANGELOG.md app/static/CHANGELOG.md
echo "✅ Release process for $TAG complete"

View File

@@ -2,6 +2,8 @@ name: Build & Push CI Image
on:
push:
branches:
- main
paths:
- "Dockerfile.ci"
- ".gitea/workflows/build-ci-image.yaml"
@@ -36,4 +38,4 @@ jobs:
push: true
tags: |
git.max-richter.dev/${{ gitea.repository }}-ci:latest
git.max-richter.dev/${{ gitea.repository }}-ci:${{ github.sha }}
git.max-richter.dev/${{ gitea.repository }}-ci:${{ gitea.sha }}

View File

@@ -8,8 +8,8 @@ on:
branches: ["*"]
env:
PNPM_CACHE_FOLDER: /.pnpm-store
CARGO_HOME: /.cargo
PNPM_CACHE_FOLDER: .pnpm-store
CARGO_HOME: .cargo
CARGO_TARGET_DIR: target
jobs:
@@ -47,7 +47,12 @@ jobs:
run: pnpm install --frozen-lockfile --store-dir ${{ env.PNPM_CACHE_FOLDER }}
- name: 🧹 Quality Control
run: ./.gitea/scripts/ci-checks.sh
run: |
pnpm build
pnpm lint
pnpm format:check
pnpm check
xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" pnpm test
- name: 🛠️ Build
run: ./.gitea/scripts/build.sh

View File

@@ -1,13 +1,70 @@
## v0.0.2 (2026-02-04)
# v0.0.3 (2026-02-07)
fix(ci): actually deploy on tags
fix(app): correctly handle false value in settings
-> This caused a bug where random seed could not be false.
## 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.
---
## v0.0.1 (2026-02-03)
- [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
---

24
Cargo.lock generated
View File

@@ -24,6 +24,14 @@ dependencies = [
"nodarium_utils",
]
[[package]]
name = "debug"
version = "0.1.0"
dependencies = [
"nodarium_macros",
"nodarium_utils",
]
[[package]]
name = "float"
version = "0.1.0"
@@ -62,6 +70,14 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "leaf"
version = "0.1.0"
dependencies = [
"nodarium_macros",
"nodarium_utils",
]
[[package]]
name = "math"
version = "0.1.0"
@@ -245,6 +261,14 @@ dependencies = [
"zmij",
]
[[package]]
name = "shape"
version = "0.1.0"
dependencies = [
"nodarium_macros",
"nodarium_utils",
]
[[package]]
name = "stem"
version = "0.1.0"

View File

@@ -27,7 +27,6 @@ Currently this visual programming language is used to develop <https://nodes.max
- [Node.js](https://nodejs.org/en/download)
- [pnpm](https://pnpm.io/installation)
- [rust](https://www.rust-lang.org/tools/install)
- wasm-pack
### Install dependencies

View File

@@ -0,0 +1,783 @@
# Shared Memory Refactor Plan
## Executive Summary
Migrate to a single shared `WebAssembly.Memory` instance imported by all nodes using `--import-memory`. The `#[nodarium_execute]` macro writes the function's return value directly to shared memory at the specified offset.
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────────┐
│ Shared WebAssembly.Memory │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ [Node A output] [Node B output] [Node C output] ... │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │
│ │ │ Vec<i32> │ │ Vec<i32> │ │ Vec<i32> │ │ │
│ │ │ 4 bytes │ │ 12 bytes │ │ 2KB │ │ │
│ │ └────────────┘ └────────────┘ └────────────┘ │ │
│ │ │ │
│ │ offset: 0 ────────────────────────────────────────────────► │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│ import { memory } from "env"
┌─────────────────────────┼─────────────────────────┐
│ │ │
┌────┴────┐ ┌────┴────┐ ┌────┴────┐
│ Node A │ │ Node B │ │ Node C │
│ WASM │ │ WASM │ │ WASM │
└─────────┘ └─────────┘ └─────────┘
```
## Phase 1: Compilation Configuration
### 1.1 Cargo Config
```toml
# nodes/max/plantarium/box/.cargo/config.toml
[build]
rustflags = ["-C", "link-arg=--import-memory"]
```
Or globally in `Cargo.toml`:
```toml
[profile.release]
rustflags = ["-C", "link-arg=--import-memory"]
```
### 1.2 Import Memory Semantics
With `--import-memory`:
- Nodes **import** memory from the host (not export their own)
- All nodes receive the same `WebAssembly.Memory` instance
- Memory is read/write accessible from all modules
- No `memory.grow` needed (host manages allocation)
## Phase 2: Macro Design
### 2.1 Clean Node API
```rust
// input.json has 3 inputs: op_type, a, b
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(op_type: *i32, a: *i32, b: *i32) -> Vec<i32> {
// Read inputs directly from shared memory
let op = unsafe { *op_type };
let a_val = f32::from_bits(unsafe { *a } as u32);
let b_val = f32::from_bits(unsafe { *b } as u32);
let result = match op {
0 => a_val + b_val,
1 => a_val - b_val,
2 => a_val * b_val,
3 => a_val / b_val,
_ => 0.0,
};
// Return Vec<i32>, macro handles writing to shared memory
vec![result.to_bits()]
}
```
### 2.2 Macro Implementation
```rust
// packages/macros/src/lib.rs
#[proc_macro_attribute]
pub fn nodarium_execute(_attr: TokenStream, item: TokenStream) -> TokenStream {
let input_fn = parse_macro_input!(item as syn::ItemFn);
let fn_name = &input_fn.sig.ident;
// Parse definition to get input count
let project_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
let def: NodeDefinition = serde_json::from_str(&fs::read_to_string(
Path::new(&project_dir).join("src/input.json")
).unwrap()).unwrap();
let input_count = def.inputs.as_ref().map(|i| i.len()).unwrap_or(0);
// Validate signature
validate_signature(&input_fn, input_count);
// Generate wrapper
generate_execute_wrapper(input_fn, fn_name, input_count)
}
fn validate_signature(fn_sig: &syn::Signature, expected_inputs: usize) {
let param_count = fn_sig.inputs.len();
if param_count != expected_inputs {
panic!(
"Execute function has {} parameters but definition has {} inputs\n\
Definition inputs: {:?}\n\
Expected signature:\n\
pub fn execute({}) -> Vec<i32>",
param_count,
expected_inputs,
def.inputs.as_ref().map(|i| i.keys().collect::<Vec<_>>()),
(0..expected_inputs)
.map(|i| format!("arg{}: *const i32", i))
.collect::<Vec<_>>()
.join(", ")
);
}
// Verify return type is Vec<i32>
match &fn_sig.output {
syn::ReturnType::Type(_, ty) => {
if !matches!(&**ty, syn::Type::Path(tp) if tp.path.is_ident("Vec")) {
panic!("Execute function must return Vec<i32>");
}
}
syn::ReturnType::Default => {
panic!("Execute function must return Vec<i32>");
}
}
}
fn generate_execute_wrapper(
input_fn: syn::ItemFn,
fn_name: &syn::Ident,
input_count: usize,
) -> TokenStream {
let arg_names: Vec<_> = (0..input_count)
.map(|i| syn::Ident::new(&format!("arg{}", i), proc_macro2::Span::call_site()))
.collect();
let expanded = quote! {
#input_fn
#[no_mangle]
pub extern "C" fn execute(
output_pos: i32,
#( #arg_names: i32 ),*
) -> i32 {
extern "C" {
fn __nodarium_log(ptr: *const u8, len: usize);
fn __nodarium_log_panic(ptr: *const u8, len: usize);
}
// Setup panic hook
static SET_HOOK: std::sync::Once = std::sync::Once::new();
SET_HOOK.call_once(|| {
std::panic::set_hook(Box::new(|info| {
let msg = info.to_string();
unsafe { __nodarium_log_panic(msg.as_ptr(), msg.len()); }
}));
});
// Call user function
let result = #fn_name(
#( #arg_names as *const i32 ),*
);
// Write result directly to shared memory at output_pos
let len_bytes = result.len() * 4;
unsafe {
let src = result.as_ptr() as *const u8;
let dst = output_pos as *mut u8;
dst.copy_from_nonoverlapping(src, len_bytes);
}
// Forget the Vec to prevent deallocation (data is in shared memory now)
core::mem::forget(result);
len_bytes as i32
}
};
TokenStream::from(expanded)
}
```
### 2.3 Generated Assembly
The macro generates:
```asm
; Input: output_pos in register r0, arg0 in r1, arg1 in r2, arg2 in r3
execute:
; Call user function
bl user_execute ; returns pointer to Vec<i32> in r0
; Calculate byte length
ldr r4, [r0, #8] ; Vec::len field
lsl r4, r4, #2 ; len * 4 (i32 = 4 bytes)
; Copy Vec data to shared memory at output_pos
ldr r5, [r0, #0] ; Vec::ptr field
ldr r6, [r0, #4] ; capacity (unused)
; memcpy(dst=output_pos, src=r5, len=r4)
; (implemented via copy_from_nonoverlapping)
; Return length
mov r0, r4
bx lr
```
## Phase 3: Input Reading Helpers
```rust
// packages/utils/src/accessor.rs
/// Read i32 from shared memory
#[inline]
pub unsafe fn read_i32(ptr: *const i32) -> i32 {
*ptr
}
/// Read f32 from shared memory (stored as i32 bits)
#[inline]
pub unsafe fn read_f32(ptr: *const i32) -> f32 {
f32::from_bits(*ptr as u32)
}
/// Read boolean from shared memory
#[inline]
pub unsafe fn read_bool(ptr: *const i32) -> bool {
*ptr != 0
}
/// Read vec3 (3 f32s) from shared memory
#[inline]
pub unsafe fn read_vec3(ptr: *const i32) -> [f32; 3] {
let p = ptr as *const f32;
[p.read(), p.add(1).read(), p.add(2).read()]
}
/// Read slice from shared memory
#[inline]
pub unsafe fn read_i32_slice(ptr: *const i32, len: usize) -> &[i32] {
std::slice::from_raw_parts(ptr, len)
}
/// Read f32 slice from shared memory
#[inline]
pub unsafe fn read_f32_slice(ptr: *const i32, len: usize) -> &[f32] {
std::slice::from_raw_parts(ptr as *const f32, len)
}
/// Read with default value
#[inline]
pub unsafe fn read_f32_default(ptr: *const i32, default: f32) -> f32 {
if ptr.is_null() { default } else { read_f32(ptr) }
}
#[inline]
pub unsafe fn read_i32_default(ptr: *const i32, default: i32) -> i32 {
if ptr.is_null() { default } else { read_i32(ptr) }
}
```
## Phase 4: Node Implementation Examples
### 4.1 Math Node
```rust
// nodes/max/plantarium/math/src/lib.rs
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(op_type: *const i32, a: *const i32, b: *const i32) -> Vec<i32> {
use nodarium_utils::{read_i32, read_f32};
let op = unsafe { read_i32(op_type) };
let a_val = unsafe { read_f32(a) };
let b_val = unsafe { read_f32(b) };
let result = match op {
0 => a_val + b_val, // add
1 => a_val - b_val, // subtract
2 => a_val * b_val, // multiply
3 => a_val / b_val, // divide
_ => 0.0,
};
vec![result.to_bits()]
}
```
### 4.2 Vec3 Node
```rust
// nodes/max/plantarium/vec3/src/lib.rs
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(x: *const i32, y: *const i32, z: *const i32) -> Vec<i32> {
use nodarium_utils::read_f32;
let x_val = unsafe { read_f32(x) };
let y_val = unsafe { read_f32(y) };
let z_val = unsafe { read_f32(z) };
vec![x_val.to_bits(), y_val.to_bits(), z_val.to_bits()]
}
```
### 4.3 Box Node
```rust
// nodes/max/plantarium/box/src/lib.rs
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(size: *const i32) -> Vec<i32> {
use nodarium_utils::{read_f32, encode_float, calculate_normals};
let size = unsafe { read_f32(size) };
let p = encode_float(size);
let n = encode_float(-size);
let mut cube_geometry = vec![
1, // 1: geometry
8, // 8 vertices
12, // 12 faces
// Face indices
0, 1, 2, 0, 2, 3,
0, 3, 4, 4, 5, 0,
6, 1, 0, 5, 6, 0,
7, 2, 1, 6, 7, 1,
2, 7, 3, 3, 7, 4,
7, 6, 4, 4, 6, 5,
// Bottom plate
p, n, n, p, n, p, n, n, p, n, n, n,
// Top plate
n, p, n, p, p, n, p, p, p, n, p, p,
// Normals
0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0,
];
calculate_normals(&mut cube_geometry);
cube_geometry
}
```
### 4.4 Stem Node
```rust
// nodes/max/plantarium/stem/src/lib.rs
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(
origin: *const i32,
amount: *const i32,
length: *const i32,
thickness: *const i32,
resolution: *const i32,
) -> Vec<i32> {
use nodarium_utils::{
read_vec3, read_i32, read_f32,
geometry::{create_multiple_paths, wrap_multiple_paths},
};
let origin = unsafe { read_vec3(origin) };
let amount = unsafe { read_i32(amount) } as usize;
let length = unsafe { read_f32(length) };
let thickness = unsafe { read_f32(thickness) };
let resolution = unsafe { read_i32(resolution) } as usize;
let mut stem_data = create_multiple_paths(amount, resolution, 1);
let mut stems = wrap_multiple_paths(&mut stem_data);
for stem in stems.iter_mut() {
let points = stem.get_points_mut();
for (i, point) in points.iter_mut().enumerate() {
let t = i as f32 / (resolution as f32 - 1.0);
point.x = origin[0];
point.y = origin[1] + t * length;
point.z = origin[2];
point.w = thickness * (1.0 - t);
}
}
stem_data
}
```
## Phase 5: Runtime Implementation
```typescript
// app/src/lib/runtime/memory-manager.ts
export const SHARED_MEMORY = new WebAssembly.Memory({
initial: 1024, // 64MB initial
maximum: 4096, // 256MB maximum
});
export class MemoryManager {
private offset: number = 0;
private readonly start: number = 0;
reset() {
this.offset = this.start;
}
alloc(bytes: number): number {
const pos = this.offset;
this.offset += bytes;
return pos;
}
readInt32(pos: number): number {
return new Int32Array(SHARED_MEMORY.buffer)[pos / 4];
}
readFloat32(pos: number): number {
return new Float32Array(SHARED_MEMORY.buffer)[pos / 4];
}
readBytes(pos: number, length: number): Uint8Array {
return new Uint8Array(SHARED_MEMORY.buffer, pos, length);
}
getInt32View(): Int32Array {
return new Int32Array(SHARED_MEMORY.buffer);
}
getFloat32View(): Float32Array {
return new Float32Array(SHARED_MEMORY.buffer);
}
getRemaining(): number {
return SHARED_MEMORY.buffer.byteLength - this.offset;
}
}
```
```typescript
// app/src/lib/runtime/imports.ts
import { SHARED_MEMORY } from "./memory-manager";
export function createImportObject(nodeId: string): WebAssembly.Imports {
return {
env: {
// Import shared memory
memory: SHARED_MEMORY,
// Logging
__nodarium_log: (ptr: number, len: number) => {
const msg = new TextDecoder().decode(
new Uint8Array(SHARED_MEMORY.buffer, ptr, len),
);
console.log(`[${nodeId}] ${msg}`);
},
__nodarium_log_panic: (ptr: number, len: number) => {
const msg = new TextDecoder().decode(
new Uint8Array(SHARED_MEMORY.buffer, ptr, len),
);
console.error(`[${nodeId}] PANIC: ${msg}`);
},
},
};
}
```
```typescript
// app/src/lib/runtime/executor.ts
import { SHARED_MEMORY } from "./memory-manager";
import { createImportObject } from "./imports";
export class SharedMemoryRuntimeExecutor implements RuntimeExecutor {
private memory: MemoryManager;
private results: Map<string, { pos: number; len: number }> = new Map();
private instances: Map<string, WebAssembly.Instance> = new Map();
constructor(private registry: NodeRegistry) {
this.memory = new MemoryManager();
}
async execute(graph: Graph, settings: Record<string, unknown>) {
this.memory.reset();
this.results.clear();
const [outputNode, nodes] = await this.addMetaData(graph);
const sortedNodes = nodes.sort((a, b) => b.depth - a.depth);
for (const node of sortedNodes) {
await this.executeNode(node, settings);
}
const result = this.results.get(outputNode.id);
const view = this.memory.getInt32View();
return view.subarray(result.pos / 4, result.pos / 4 + result.len / 4);
}
private async executeNode(
node: RuntimeNode,
settings: Record<string, unknown>,
) {
const def = this.definitionMap.get(node.type)!;
const inputs = def.inputs || {};
const inputNames = Object.keys(inputs);
const outputSize = this.estimateOutputSize(def);
const outputPos = this.memory.alloc(outputSize);
const args: number[] = [outputPos];
for (const inputName of inputNames) {
const inputDef = inputs[inputName];
const inputNode = node.state.inputNodes[inputName];
if (inputNode) {
const parentResult = this.results.get(inputNode.id)!;
args.push(parentResult.pos);
continue;
}
const valuePos = this.memory.alloc(16);
this.writeValue(
valuePos,
inputDef,
node.props?.[inputName] ??
settings[inputDef.setting ?? ""] ??
inputDef.value,
);
args.push(valuePos);
}
let instance = this.instances.get(node.type);
if (!instance) {
instance = await this.instantiateNode(node.type);
this.instances.set(node.type, instance);
}
const writtenLen = instance.exports.execute(...args);
this.results.set(node.id, { pos: outputPos, len: writtenLen });
}
private writeValue(pos: number, inputDef: NodeInput, value: unknown) {
const view = this.memory.getFloat32View();
const intView = this.memory.getInt32View();
switch (inputDef.type) {
case "float":
view[pos / 4] = value as number;
break;
case "integer":
case "select":
case "seed":
intView[pos / 4] = value as number;
break;
case "boolean":
intView[pos / 4] = value ? 1 : 0;
break;
case "vec3":
const arr = value as number[];
view[pos / 4] = arr[0];
view[pos / 4 + 1] = arr[1];
view[pos / 4 + 2] = arr[2];
break;
}
}
private estimateOutputSize(def: NodeDefinition): number {
const sizes: Record<string, number> = {
float: 16,
integer: 16,
boolean: 16,
vec3: 16,
geometry: 8192,
path: 4096,
};
return sizes[def.outputs?.[0] || "float"] || 64;
}
private async instantiateNode(
nodeType: string,
): Promise<WebAssembly.Instance> {
const wasmBytes = await this.fetchWasm(nodeType);
const module = await WebAssembly.compile(wasmBytes);
const importObject = createImportObject(nodeType);
return WebAssembly.instantiate(module, importObject);
}
}
```
## Phase 7: Execution Flow Visualization
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Execution Timeline │
└─────────────────────────────────────────────────────────────────────────────┘
Step 1: Setup
SHARED_MEMORY = new WebAssembly.Memory({ initial: 1024 })
memory.offset = 0
Step 2: Execute Node A (math with 3 inputs)
outputPos = memory.alloc(16) = 0
args = [0, ptr_to_op_type, ptr_to_a, ptr_to_b]
Node A reads:
*ptr_to_op_type → op
*ptr_to_a → a
*ptr_to_b → b
Node A returns: vec![result.to_bits()]
Macro writes result directly to SHARED_MEMORY[0..4]
Returns: 4
results['A'] = { pos: 0, len: 4 }
memory.offset = 4
Step 3: Execute Node B (stem with 5 inputs, input[0] from A)
outputPos = memory.alloc(4096) = 4
args = [4, results['A'].pos, ptr_to_amount, ptr_to_length, ...]
Node B reads:
*results['A'].pos → value from Node A
*ptr_to_amount → amount
...
Node B returns: stem_data Vec<i32> (1000 elements = 4000 bytes)
Macro writes stem_data directly to SHARED_MEMORY[4..4004]
Returns: 4000
results['B'] = { pos: 4, len: 4000 }
memory.offset = 4004
Step 4: Execute Node C (output, 1 input from B)
outputPos = memory.alloc(16) = 4004
args = [4004, results['B'].pos, results['B'].len]
Node C reads:
*results['B'].pos → stem geometry
Node C returns: vec![1] (identity)
Macro writes to SHARED_MEMORY[4004..4008]
results['C'] = { pos: 4004, len: 4 }
Final: Return SHARED_MEMORY[4004..4008] as geometry result
```
## Phase 6: Memory Growth Strategy
```typescript
class MemoryManager {
alloc(bytes: number): number {
const required = this.offset + bytes;
const currentBytes = SHARED_MEMORY.buffer.byteLength;
if (required > currentBytes) {
const pagesNeeded = Math.ceil((required - currentBytes) / 65536);
const success = SHARED_MEMORY.grow(pagesNeeded);
if (!success) {
throw new Error(`Out of memory: need ${bytes} bytes`);
}
this.int32View = new Int32Array(SHARED_MEMORY.buffer);
this.float32View = new Float32Array(SHARED_MEMORY.buffer);
}
const pos = this.offset;
this.offset += bytes;
return pos;
}
}
```
## Phase 8: Migration Checklist
### Build Configuration
- [ ] Add `--import-memory` to Rust flags in `Cargo.toml`
- [ ] Ensure no nodes export memory
### Runtime
- [ ] Create `SHARED_MEMORY` instance
- [ ] Implement `MemoryManager` with alloc/read/write
- [ ] Create import object factory
- [ ] Implement `SharedMemoryRuntimeExecutor`
### Macro
- [ ] Parse definition JSON
- [ ] Validate function signature (N params, Vec<i32> return)
- [ ] Generate wrapper that writes return value to `output_pos`
- [ ] Add panic hook
### Utilities
- [ ] `read_i32(ptr: *const i32) -> i32`
- [ ] `read_f32(ptr: *const i32) -> f32`
- [ ] `read_bool(ptr: *const i32) -> bool`
- [ ] `read_vec3(ptr: *const i32) -> [f32; 3]`
- [ ] `read_i32_slice(ptr: *const i32, len: usize) -> &[i32]`
### Nodes
- [ ] `float`, `integer`, `boolean` nodes
- [ ] `vec3` node
- [ ] `math` node
- [ ] `random` node
- [ ] `box` node
- [ ] `stem` node
- [ ] `branch` node
- [ ] `instance` node
- [ ] `output` node
## Phase 9: Before vs After
### Before (per-node memory)
```rust
#[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> {
let args = split_args(input);
let a = evaluate_float(args[0]);
let b = evaluate_float(args[1]);
vec![(a + b).to_bits()]
}
```
### After (shared memory)
```rust
#[nodarium_execute]
pub fn execute(a: *const i32, b: *const i32) -> Vec<i32> {
use nodarium_utils::read_f32;
let a_val = unsafe { read_f32(a) };
let b_val = unsafe { read_f32(b) };
vec![(a_val + b_val).to_bits()]
}
```
**Key differences:**
- Parameters are input pointers, not a slice
- Use `read_f32` helper instead of `evaluate_float`
- Macro writes result directly to shared memory
- All nodes share the same memory import
## Phase 10: Benefits
| Aspect | Before | After |
| ----------------- | -------------- | -------------------- |
| Memory | N × ~1MB heaps | 1 × 64-256MB shared |
| Cross-node access | Copy via JS | Direct read |
| API | `&[i32]` slice | `*const i32` pointer |
| Validation | Runtime | Compile-time |

227
SUMMARY.md Normal file
View File

@@ -0,0 +1,227 @@
# Nodarium - AI Coding Agent Summary
## Project Overview
Nodarium is a WebAssembly-based visual programming language used to build <https://nodes.max-richter.dev>, a procedural 3D plant modeling tool. The system allows users to create visual node graphs where each node is a compiled WebAssembly module.
## Technology Stack
**Frontend (SvelteKit):**
- Framework: SvelteKit with Svelte 5
- 3D Rendering: Three.js via Threlte
- Styling: Tailwind CSS 4
- Build Tool: Vite
- State Management: Custom store-client package
- WASM Integration: vite-plugin-wasm, comlink
**Backend/Core (Rust/WASM):**
- Language: Rust
- Output: WebAssembly (wasm32-unknown-unknown target)
- Build Tool: cargo
- Procedural Macros: custom macros package
**Package Management:**
- Node packages: pnpm workspace (v10.28.1)
- Rust packages: Cargo workspace
## Directory Structure
```
nodarium/
├── app/ # SvelteKit web application
│ ├── src/
│ │ ├── lib/ # App-specific components and utilities
│ │ ├── routes/ # SvelteKit routes (pages)
│ │ ├── app.css # Global styles
│ │ └── app.html # HTML template
│ ├── static/
│ │ └── nodes/ # Compiled WASM node files served statically
│ ├── package.json # App dependencies
│ ├── svelte.config.js # SvelteKit configuration
│ ├── vite.config.ts # Vite configuration
│ └── tsconfig.json # TypeScript configuration
├── packages/ # Shared workspace packages
│ ├── ui/ # Svelte UI component library (published as @nodarium/ui)
│ │ ├── src/ # UI components
│ │ ├── static/ # Static assets for UI
│ │ ├── dist/ # Built output
│ │ └── package.json
│ ├── registry/ # Node registry with IndexedDB persistence (@nodarium/registry)
│ │ └── src/
│ ├── types/ # Shared TypeScript types (@nodarium/types)
│ │ └── src/
│ ├── utils/ # Shared utilities (@nodarium/utils)
│ │ └── src/
│ └── macros/ # Rust procedural macros for node development
├── nodes/ # WebAssembly node packages (Rust)
│ └── max/plantarium/ # Plantarium nodes namespace
│ ├── box/ # Box geometry node
│ ├── branch/ # Branch generation node
│ ├── float/ # Float value node
│ ├── gravity/ # Gravity simulation node
│ ├── instance/ # Geometry instancing node
│ ├── math/ # Math operations node
│ ├── noise/ # Noise generation node
│ ├── output/ # Output node for results
│ ├── random/ # Random value node
│ ├── rotate/ # Rotation transformation node
│ ├── stem/ # Stem geometry node
│ ├── triangle/ # Triangle geometry node
│ ├── vec3/ # Vector3 manipulation node
│ └── .template/ # Node template for creating new nodes
├── docs/ # Documentation
│ ├── ARCHITECTURE.md # System architecture overview
│ ├── DEVELOPING_NODES.md # Guide for creating new nodes
│ ├── NODE_DEFINITION.md # Node definition schema
│ └── PLANTARIUM.md # Plantarium-specific documentation
├── Cargo.toml # Rust workspace configuration
├── package.json # Root npm scripts
├── pnpm-workspace.yaml # pnpm workspace configuration
├── pnpm-lock.yaml # Locked dependency versions
└── README.md # Project readme
```
## Node System Architecture
### What is a Node?
Nodes are WebAssembly modules that:
- Have a unique ID (e.g., `max/plantarium/stem`)
- Define inputs with types and default values
- Define outputs they produce
- Execute logic when called with arguments
### Node Definition Schema
Nodes are defined via `definition.json` embedded in each WASM module:
```json
{
"id": "namespace/category/node-name",
"outputs": ["geometry"],
"inputs": {
"height": { "type": "float", "value": 1.0 },
"radius": { "type": "float", "value": 0.1 }
}
}
```
For now the outputs are limited to a single output.
### Node Execution
Nodes receive serialized arguments and return serialized outputs. The `nodarium_utils` Rust crate provides helpers for:
- Parsing input arguments
- Creating geometry data
- Concatenating output vectors
### Node Registration
Nodes are:
1. Compiled to WASM files in `target/wasm32-unknown-unknown/release/`
2. Copied to `app/static/nodes/` for serving
3. Registered in the browser via IndexedDB using the registry package
## Key Dependencies
**Frontend:**
- `@sveltejs/kit` - Application framework
- `@threlte/core` & `@threlte/extras` - Three.js Svelte integration
- `three` - 3D graphics library
- `tailwindcss` - CSS framework
- `comlink` - WebWorker RPC
- `idb` - IndexedDB wrapper
- `wabt` - WebAssembly binary toolkit
**Rust/WASM:**
- Language: Rust (compiled with plain cargo)
- Output: WebAssembly (wasm32-unknown-unknown target)
- Generic WASM wrapper for language-agnostic node development
- `glam` - Math library (Vec2, Vec3, Mat4, etc.)
- `nodarium_macros` - Custom procedural macros
- `nodarium_utils` - Shared node utilities
## Build Commands
From root directory:
```bash
# Install dependencies
pnpm i
# Build all WASM nodes (compiles Rust, copies to app/static)
pnpm build:nodes
# Build the app (builds UI library + SvelteKit app)
pnpm build:app
# Full build (nodes + app)
pnpm build
# Development
pnpm dev # Run all dev commands in parallel
pnpm dev:nodes # Watch nodes/, auto-rebuild on changes
pnpm dev:app_ui # Watch app and UI package
pnpm dev_ui # Watch UI package only
```
## Workspace Packages
The project uses pnpm workspaces with the following packages:
| Package | Location | Purpose |
| ------------------ | ------------------ | ------------------------------ |
| @nodarium/app | app/ | Main SvelteKit application |
| @nodarium/ui | packages/ui/ | Reusable UI component library |
| @nodarium/registry | packages/registry/ | Node registry with persistence |
| @nodarium/types | packages/types/ | Shared TypeScript types |
| @nodarium/utils | packages/utils/ | Shared utilities |
| nodarium macros | packages/macros/ | Rust procedural macros |
## Configuration Files
- `.dprint.jsonc` - Dprint formatter configuration
- `svelte.config.js` - SvelteKit configuration (app and ui)
- `vite.config.ts` - Vite bundler configuration
- `tsconfig.json` - TypeScript configuration (app and packages)
- `Cargo.toml` - Rust workspace with member packages
- `flake.nix` - Nix development environment
## Development Workflow
### Adding a New Node
1. Copy the `.template` directory in `nodes/max/plantarium/` to create a new node directory
2. Define node in `src/definition.json`
3. Implement logic in `src/lib.rs`
4. Build with `cargo build --release --target wasm32-unknown-unknown`
5. Test by dragging onto the node graph
### Modifying UI Components
1. Changes to `packages/ui/` automatically rebuild with watch mode
2. App imports from `@nodarium/ui`
3. Run `pnpm dev:app_ui` for hot reload
## Important Notes for AI Agents
1. **WASM Compilation**: Nodes require `wasm32-unknown-unknown` target (`rustup target add wasm32-unknown-unknown`)
2. **Cross-Compilation**: WASM build happens on host, not in containers/VMs
3. **Static Serving**: Compiled WASM files must exist in `app/static/nodes/` before dev server runs
4. **Workspace Dependencies**: Use `workspace:*` protocol for internal packages
5. **Threlte Version**: Uses Threlte 8.x, not 7.x (important for 3D component APIs)
6. **Svelte 5**: Project uses Svelte 5 with runes (`$state`, `$derived`, `$effect`)
7. **Tailwind 4**: Uses Tailwind CSS v4 with `@tailwindcss/vite` plugin
8. **IndexedDB**: Registry uses IDB for persistent node storage in browser

294
SUMMARY_RUNTIME.md Normal file
View File

@@ -0,0 +1,294 @@
# Node Compilation and Runtime Execution
## Overview
Nodarium nodes are WebAssembly modules written in Rust. Each node is a compiled WASM binary that exposes a standardized C ABI interface. The system uses procedural macros to generate the necessary boilerplate for node definitions, memory management, and execution.
## Node Compilation
### 1. Node Definition (JSON)
Each node has a `src/input.json` file that defines:
```json
{
"id": "max/plantarium/stem",
"meta": { "description": "Creates a stem" },
"outputs": ["path"],
"inputs": {
"origin": { "type": "vec3", "value": [0, 0, 0], "external": true },
"amount": { "type": "integer", "value": 1, "min": 1, "max": 64 },
"length": { "type": "float", "value": 5 },
"thickness": { "type": "float", "value": 0.2 }
}
}
```
### 2. Procedural Macros
The `nodarium_macros` crate provides two procedural macros:
#### `#[nodarium_execute]`
Transforms a Rust function into a WASM-compatible entry point:
```rust
#[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> {
// Node logic here
}
```
The macro generates:
- **C ABI wrapper**: Converts the WASM interface to a standard C FFI
- **`execute` function**: Takes `(ptr: *const i32, len: usize)` and returns `*mut i32`
- **Memory allocation**: `__alloc(len: usize) -> *mut i32` for buffer allocation
- **Memory deallocation**: `__free(ptr: *mut i32, len: usize)` for cleanup
- **Static output buffer**: `OUTPUT_BUFFER` for returning results
- **Panic hook**: Routes panics through `host_log_panic` for debugging
- **Internal logic wrapper**: Wraps the original function
#### `nodarium_definition_file!("path")`
Embeds the node definition JSON into the WASM binary:
```rust
nodarium_definition_file!("src/input.json");
```
Generates:
- **`DEFINITION_DATA`**: Static byte array in `nodarium_definition` section
- **`get_definition_ptr()`**: Returns pointer to definition data
- **`get_definition_len()`**: Returns length of definition data
### 3. Build Process
Nodes are compiled with:
```bash
cargo build --release --target wasm32-unknown-unknown
```
The resulting `.wasm` files are copied to `app/static/nodes/` for serving.
## Node Execution Runtime
### Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ WebWorker Thread │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ WorkerRuntimeExecutor ││
│ │ ┌───────────────────────────────────────────────────┐ ││
│ │ │ MemoryRuntimeExecutor ││
│ │ │ ┌─────────────────────────────────────────────┐ ││
│ │ │ │ Node Registry (WASM + Definitions) ││
│ │ │ └─────────────────────────────────────────────┘ ││
│ │ │ ┌─────────────────────────────────────────────┐ ││
│ │ │ │ Execution Engine (Bottom-Up Evaluation) ││
│ │ │ └─────────────────────────────────────────────┘ ││
│ │ └───────────────────────────────────────────────────┘ ││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
```
### 1. MemoryRuntimeExecutor
The core execution engine in `runtime-executor.ts`:
#### Metadata Collection (`addMetaData`)
1. Load node definitions from registry
2. Build parent/child relationships from graph edges
3. Calculate execution depth via reverse BFS from output node
#### Node Sorting
Nodes are sorted by depth (highest depth first) for bottom-up execution:
```
Depth 3: n3 n6
Depth 2: n2 n4 n5
Depth 1: n1
Depth 0: Output
Execution order: n3, n6, n2, n4, n5, n1, Output
```
#### Input Collection
For each node, inputs are gathered from:
1. **Connected nodes**: Results from parent nodes in the graph
2. **Node props**: Values stored directly on the node instance
3. **Settings**: Global settings mapped via `setting` property
4. **Defaults**: Values from node definition
#### Input Encoding
Values are encoded as `Int32Array`:
- **Floats**: IEEE 754 bits cast to i32
- **Vectors**: `[0, count, v1, v2, v3, 1, 1]` (nested bracket format)
- **Booleans**: `0` or `1`
- **Integers**: Direct i32 value
#### Caching
Results are cached using:
```typescript
inputHash = `node-${node.id}-${fastHashArrayBuffer(encoded_inputs)}`
```
The cache uses LRU eviction (default size: 50 entries).
### 2. Execution Flow
```typescript
async execute(graph: Graph, settings) {
// 1. Load definitions and build node relationships
const [outputNode, nodes] = await this.addMetaData(graph);
// 2. Sort nodes by depth (bottom-up)
const sortedNodes = nodes.sort((a, b) => b.depth - a.depth);
// 3. Execute each node
for (const node of sortedNodes) {
const inputs = this.collectInputs(node, settings);
const encoded = concatEncodedArrays(inputs);
const result = nodeType.execute(encoded);
this.results[node.id] = result;
}
// 4. Return output node result
return this.results[outputNode.id];
}
```
### 3. Worker Isolation
`WorkerRuntimeExecutor` runs execution in a WebWorker via Comlink:
```typescript
class WorkerRuntimeExecutor implements RuntimeExecutor {
private worker = new ComlinkWorker(...);
async execute(graph, settings) {
return this.worker.executeGraph(graph, settings);
}
}
```
The worker backend (`worker-runtime-executor-backend.ts`):
- Creates a single `MemoryRuntimeExecutor` instance
- Manages caching state
- Collects performance metrics
### 4. Remote Execution (Optional)
`RemoteRuntimeExecutor` can execute graphs on a remote server:
```typescript
class RemoteRuntimeExecutor implements RuntimeExecutor {
async execute(graph, settings) {
const res = await fetch(this.url, {
method: "POST",
body: JSON.stringify({ graph, settings })
});
return new Int32Array(await res.arrayBuffer());
}
}
```
## Data Encoding Format
### Bracket Notation
Inputs and outputs use a nested bracket encoding:
```
[0, count, item1, item2, ..., 1, 1]
^ ^ items ^ ^
| | | |
| | | +-- closing bracket
| +-- number of items + 1 |
+-- opening bracket (0) +-- closing bracket (1)
```
### Example Encodings
**Float (5.0)**:
```typescript
encodeFloat(5.0) // → 1084227584 (IEEE 754 bits as i32)
```
**Vec3 ([1, 2, 3])**:
```typescript
[0, 4, encodeFloat(1), encodeFloat(2), encodeFloat(3), 1, 1]
```
**Nested Math Expression**:
```
[0, 3, 0, 2, 0, 3, 0, 0, 0, 3, 7549747, 127, 1, 1, ...]
```
### Decoding Utilities
From `packages/utils/src/tree.rs`:
- `split_args()`: Parses nested bracket arrays into segments
- `evaluate_float()`: Recursively evaluates and decodes float expressions
- `evaluate_int()`: Evaluates integer/math node expressions
- `evaluate_vec3()`: Decodes vec3 arrays
## Geometry Data Format
### Path Data
Paths represent procedural plant structures:
```
[0, count, [0, header_size, node_type, depth, x, y, z, w, ...], 1, 1]
```
Each point has 4 values: x, y, z position + thickness (w).
### Geometry Data
Meshes use a similar format with vertices and face indices.
## Performance Tracking
The runtime collects detailed performance metrics:
- `collect-metadata`: Time to build node graph
- `collected-inputs`: Time to gather inputs
- `encoded-inputs`: Time to encode inputs
- `hash-inputs`: Time to compute cache hash
- `cache-hit`: 1 if cache hit, 0 if miss
- `node/{node_type}`: Time per node execution
## Caching Strategy
### MemoryRuntimeCache
LRU cache implementation:
```typescript
class MemoryRuntimeCache {
private map = new Map<string, unknown>();
size: number = 50;
get(key) { /* move to front */ }
set(key, value) { /* evict oldest if at capacity */ }
}
```
### IndexDBCache
For persistence across sessions, the registry uses IndexedDB caching.
## Summary
The Nodarium node system works as follows:
1. **Compilation**: Rust functions are decorated with macros that generate C ABI WASM exports
2. **Registration**: Node definitions are embedded in WASM and loaded at runtime
3. **Graph Analysis**: Runtime builds node relationships and execution order
4. **Bottom-Up Execution**: Nodes execute from leaves to output
5. **Caching**: Results are cached per-node-inputs hash for performance
6. **Isolation**: Execution runs in a WebWorker to prevent main thread blocking

View File

@@ -28,5 +28,6 @@ RUN rm /etc/nginx/conf.d/default.conf
COPY app/docker/app.conf /etc/nginx/conf.d/app.conf
COPY --from=builder /app/app/build /app
COPY --from=builder /app/packages/ui/build /app/ui
EXPOSE 80

View File

@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite dev",
"predev": "rm static/CHANGELOG.md && ln -s ../../CHANGELOG.md static/CHANGELOG.md",
"build": "svelte-kit sync && vite build",
"test:unit": "vitest",
"test": "npm run test:unit -- --run && npm run test:e2e",
@@ -26,6 +27,7 @@
"file-saver": "^2.0.5",
"idb": "^8.0.3",
"jsondiffpatch": "^0.7.3",
"micromark": "^4.0.2",
"tailwindcss": "^4.1.18",
"three": "^0.182.0"
},

View File

@@ -11,6 +11,7 @@ uniform vec3 camPos;
uniform vec2 zoomLimits;
uniform vec3 backgroundColor;
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) {
@@ -78,6 +79,7 @@ void main(void) {
float ux = (vUv.x - 0.5) * width + cx * cz;
float uy = (vUv.y - 0.5) * height - cy * cz;
if(gridType == 0) {
// extra small grid
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;
@@ -107,6 +109,21 @@ void main(void) {
vec3 color = mix(backgroundColor, lineColor, c);
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);
}
}

View File

@@ -6,11 +6,12 @@
import BackgroundVert from './Background.vert';
type Props = {
minZoom: number;
maxZoom: number;
cameraPosition: [number, number, number];
width: number;
height: number;
minZoom?: number;
maxZoom?: number;
cameraPosition?: [number, number, number];
width?: number;
height?: number;
type?: 'grid' | 'dots' | 'none';
};
let {
@@ -18,9 +19,18 @@
maxZoom = 150,
cameraPosition = [0, 1, 0],
width = globalThis?.innerWidth || 100,
height = globalThis?.innerHeight || 100
height = globalThis?.innerHeight || 100,
type = 'grid'
}: 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 bh = $derived(height / cameraPosition[2]);
</script>
@@ -51,6 +61,9 @@
},
dimensions: {
value: [100, 100]
},
gridType: {
value: 0
}
}}
uniforms.camPos.value={cameraPosition}
@@ -59,6 +72,7 @@
uniforms.lineColor.value={appSettings.value.theme && colors['outline']}
uniforms.zoomLimits.value={[minZoom, maxZoom]}
uniforms.dimensions.value={[width, height]}
uniforms.gridType.value={gridType}
/>
</T.Mesh>
</T.Group>

View File

@@ -2,19 +2,19 @@
import { colors } from '../graph/colors.svelte';
const circleMaterial = new MeshBasicMaterial({
color: colors.edge.clone(),
color: colors.outline.clone(),
toneMapped: false
});
let lineColor = $state(colors.edge.clone().convertSRGBToLinear());
let lineColor = $state(colors.outline.clone().convertSRGBToLinear());
$effect.root(() => {
$effect(() => {
if (appSettings.value.theme === undefined) {
return;
}
circleMaterial.color = colors.edge.clone().convertSRGBToLinear();
lineColor = colors.edge.clone().convertSRGBToLinear();
circleMaterial.color = colors.outline.clone().convertSRGBToLinear();
lineColor = colors.outline.clone().convertSRGBToLinear();
});
});

View File

@@ -25,14 +25,14 @@ const clone = 'structuredClone' in self
? self.structuredClone
: (args: unknown) => JSON.parse(JSON.stringify(args));
function areSocketsCompatible(
export function areSocketsCompatible(
output: string | undefined,
inputs: string | (string | undefined)[] | undefined
) {
if (Array.isArray(inputs) && output) {
return inputs.includes(output);
return inputs.includes('*') || inputs.includes(output);
}
return inputs === output;
return inputs === output || inputs === '*';
}
function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) {
@@ -268,14 +268,7 @@ export class GraphManager extends EventEmitter<{
private _init(graph: Graph) {
const nodes = new SvelteMap(
graph.nodes.map((node) => {
const nodeType = this.registry.getNode(node.type);
const n = node as NodeInstance;
if (nodeType) {
n.state = {
type: nodeType
};
}
return [node.id, n];
return [node.id, node as NodeInstance];
})
);
@@ -300,6 +293,30 @@ export class GraphManager extends EventEmitter<{
this.execute();
}
private async loadAllCollections() {
// Fetch all nodes from all collections of the loaded nodes
const nodeIds = Array.from(new Set([...this.graph.nodes.map((n) => n.type)]));
const allCollections = new Set<`${string}/${string}`>();
for (const id of nodeIds) {
const [user, collection] = id.split('/');
allCollections.add(`${user}/${collection}`);
}
const allCollectionIds = await Promise
.all([...allCollections]
.map(async (collection) =>
remoteRegistry
.fetchCollection(collection)
.then((collection: { nodes: { id: NodeId }[] }) => {
return collection.nodes.map(n => n.id.replace(/\.wasm$/, '') as NodeId);
})
));
const missingNodeIds = [...new Set(allCollectionIds.flat())];
this.registry.load(missingNodeIds);
}
async load(graph: Graph) {
const a = performance.now();
@@ -384,7 +401,9 @@ export class GraphManager extends EventEmitter<{
this.loaded = true;
logger.log(`Graph loaded in ${performance.now() - a}ms`);
setTimeout(() => this.execute(), 100);
this.loadAllCollections(); // lazily load all nodes from all collections
}
getAllNodes() {
@@ -491,10 +510,10 @@ export class GraphManager extends EventEmitter<{
const inputs = Object.entries(to.state?.type?.inputs ?? {});
const outputs = from.state?.type?.outputs ?? [];
for (let i = 0; i < inputs.length; i++) {
const [inputName, input] = inputs[0];
const [inputName, input] = inputs[i];
for (let o = 0; o < outputs.length; o++) {
const output = outputs[0];
if (input.type === output) {
const output = outputs[o];
if (input.type === output || input.type === '*') {
return this.createEdge(from, o, to, inputName);
}
}
@@ -596,11 +615,14 @@ export class GraphManager extends EventEmitter<{
return;
}
const fromType = from.state.type || this.registry.getNode(from.type);
const toType = to.state.type || this.registry.getNode(to.type);
// check if socket types match
const fromSocketType = from.state?.type?.outputs?.[fromSocket];
const toSocketType = [to.state?.type?.inputs?.[toSocket]?.type];
if (to.state?.type?.inputs?.[toSocket]?.accepts) {
toSocketType.push(...(to?.state?.type?.inputs?.[toSocket]?.accepts || []));
const fromSocketType = fromType?.outputs?.[fromSocket];
const toSocketType = [toType?.inputs?.[toSocket]?.type];
if (toType?.inputs?.[toSocket]?.accepts) {
toSocketType.push(...(toType?.inputs?.[toSocket]?.accepts || []));
}
if (!areSocketsCompatible(fromSocketType, toSocketType)) {
@@ -723,8 +745,9 @@ export class GraphManager extends EventEmitter<{
}
getPossibleSockets({ node, index }: Socket): [NodeInstance, string | number][] {
const nodeType = node?.state?.type;
const nodeType = this.registry.getNode(node.type);
if (!nodeType) return [];
console.log({ index });
const sockets: [NodeInstance, string | number][] = [];
@@ -739,7 +762,7 @@ export class GraphManager extends EventEmitter<{
const ownType = nodeType?.inputs?.[index].type;
for (const node of nodes) {
const nodeType = node?.state?.type;
const nodeType = this.registry.getNode(node.type);
const inputs = nodeType?.outputs;
if (!inputs) continue;
for (let index = 0; index < inputs.length; index++) {
@@ -771,7 +794,7 @@ export class GraphManager extends EventEmitter<{
const ownType = nodeType.outputs?.[index];
for (const node of nodes) {
const inputs = node?.state?.type?.inputs;
const inputs = this.registry.getNode(node.type)?.inputs;
if (!inputs) continue;
for (const key in inputs) {
const otherType = [inputs[key].type];
@@ -787,6 +810,7 @@ export class GraphManager extends EventEmitter<{
}
}
console.log(`Found ${sockets.length} possible sockets`, sockets);
return sockets;
}

View File

@@ -83,7 +83,7 @@ export class GraphState {
addMenuPosition = $state<[number, number] | null>(null);
snapToGrid = $state(false);
showGrid = $state(true);
backgroundType = $state<'grid' | 'dots' | 'none'>('grid');
showHelp = $state(false);
cameraDown = [0, 0];
@@ -169,11 +169,14 @@ export class GraphState {
(node?.state?.y ?? node.position[1]) + 2.5 + 10 * index
];
} else {
const _index = Object.keys(node.state?.type?.inputs || {}).indexOf(index);
return [
const inputs = node.state.type?.inputs || this.graph.registry.getNode(node.type)?.inputs
|| {};
const _index = Object.keys(inputs).indexOf(index);
const pos = [
node?.state?.x ?? node.position[0],
(node?.state?.y ?? node.position[1]) + 10 + 10 * _index
];
] as [number, number];
return pos;
}
}
@@ -186,15 +189,25 @@ export class GraphState {
if (!node?.inputs) {
return 5;
}
const height = 5
+ 10
* Object.keys(node.inputs).filter(
(p) =>
p !== 'seed'
&& node?.inputs
&& !(node?.inputs?.[p] !== undefined && 'setting' in node.inputs[p])
&& node.inputs[p].hidden !== true
).length;
let height = 5;
for (const key of Object.keys(node.inputs)) {
if (key === 'seed') continue;
if (!node.inputs) continue;
if (node?.inputs?.[key] === undefined) continue;
if ('setting' in node.inputs[key]) continue;
if (node.inputs[key].hidden) continue;
if (
node.inputs[key].type === 'shape'
&& node.inputs[key].external !== true
&& node.inputs[key].internal !== false
) {
height += 20;
continue;
}
height += 10;
}
this.nodeHeightCache[nodeTypeId] = height;
return height;
}
@@ -249,7 +262,7 @@ export class GraphState {
let { node, index, position } = socket;
// remove existing edge
// if the socket is an input socket -> remove existing edges
if (typeof index === 'string') {
const edges = this.graph.getEdgesToNode(node);
for (const edge of edges) {

View File

@@ -132,8 +132,9 @@
position={graphState.cameraPosition}
/>
{#if graphState.showGrid !== false}
{#if graphState.backgroundType !== 'none'}
<Background
type={graphState.backgroundType}
cameraPosition={graphState.cameraPosition}
{maxZoom}
{minZoom}

View File

@@ -13,7 +13,7 @@
settings?: Record<string, unknown>;
activeNode?: NodeInstance;
showGrid?: boolean;
backgroundType?: 'grid' | 'dots' | 'none';
snapToGrid?: boolean;
showHelp?: boolean;
settingTypes?: Record<string, unknown>;
@@ -25,11 +25,11 @@
let {
graph,
registry,
settings = $bindable(),
activeNode = $bindable(),
showGrid = $bindable(true),
backgroundType = $bindable('grid'),
snapToGrid = $bindable(true),
showHelp = $bindable(false),
settings = $bindable(),
settingTypes = $bindable(),
onsave,
onresult
@@ -43,7 +43,7 @@
const graphState = new GraphState(manager);
$effect(() => {
graphState.showGrid = showGrid;
graphState.backgroundType = backgroundType;
graphState.snapToGrid = snapToGrid;
graphState.showHelp = showHelp;
});

View File

@@ -9,7 +9,7 @@ const variables = [
'outline',
'active',
'selected',
'edge'
'connection'
] as const;
function getColor(variable: (typeof variables)[number]) {

View File

@@ -166,16 +166,15 @@ export class MouseEventManager {
if (this.state.mouseDown) return;
this.state.edgeEndPosition = null;
const target = event.target as HTMLElement;
if (event.target instanceof HTMLElement) {
if (
event.target.nodeName !== 'CANVAS'
&& !event.target.classList.contains('node')
&& !event.target.classList.contains('content')
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;

View File

@@ -57,7 +57,7 @@
uniforms={{
uColorBright: { value: colors['layer-2'] },
uColorDark: { value: colors['layer-1'] },
uStrokeColor: { value: colors.outline.clone() },
uStrokeColor: { value: colors['layer-2'].clone() },
uStrokeWidth: { value: 1.0 },
uWidth: { value: 20 },
uHeight: { value: height }

View File

@@ -87,8 +87,6 @@
width: 30px;
z-index: 100;
border-radius: 50%;
/* background: red; */
/* opacity: 0.2; */
}
.click-target:hover + svg path {
@@ -108,7 +106,9 @@
svg path {
stroke-width: 0.2px;
transition: d 0.3s ease, fill 0.3s ease;
transition:
d 0.3s ease,
fill 0.3s ease;
fill: var(--color-layer-2);
stroke: var(--stroke);
stroke-width: var(--stroke-width);

View File

@@ -31,11 +31,24 @@
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(() => {
if (value !== undefined && node?.props?.[id] !== value) {
node.props = { ...node.props, [id]: value };
const a = $state.snapshot(value);
const b = $state.snapshot(node?.props?.[id]);
const isDiff = Array.isArray(a) ? diffArray(a, b) : a !== b;
if (value !== undefined && isDiff) {
node.props = { ...node.props, [id]: a };
if (graph) {
graph.save();
graph.execute();

View File

@@ -18,6 +18,8 @@
const inputType = $derived(node?.state?.type?.inputs?.[id]);
const socketId = $derived(`${node.id}-${id}`);
const isShape = $derived(input.type === 'shape' && input.external !== true);
const height = $derived(isShape ? 200 : 100);
const graphState = getGraphState();
const graphId = graph?.id;
@@ -64,6 +66,7 @@
class="wrapper"
data-node-type={node.type}
data-node-input={id}
style:height="{height}px"
class:possible-socket={graphState?.possibleSocketIds.has(socketId)}
>
{#key id && graphId}
@@ -95,8 +98,6 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
width="100"
height="100"
preserveAspectRatio="none"
style={`
--path: path("${path}");
@@ -111,7 +112,6 @@
.wrapper {
position: relative;
width: 100%;
height: 100px;
transform: translateY(-0.5px);
}

View File

@@ -2,6 +2,7 @@ import {
type AsyncCache,
type NodeDefinition,
NodeDefinitionSchema,
type NodeId,
type NodeRegistry
} from '@nodarium/types';
import { createLogger, createWasmWrapper } from '@nodarium/utils';
@@ -12,6 +13,7 @@ log.mute();
export class RemoteNodeRegistry implements NodeRegistry {
status: 'loading' | 'ready' | 'error' = 'loading';
private nodes: Map<string, NodeDefinition> = new Map();
private memory = new WebAssembly.Memory({ initial: 1024, maximum: 8192 });
constructor(
private url: string,
@@ -163,6 +165,13 @@ export class RemoteNodeRegistry implements NodeRegistry {
}
getAllNodes() {
return [...this.nodes.values()];
const allNodes = [...this.nodes.values()];
log.info('getting all nodes', allNodes);
return allNodes;
}
async overwriteNode(nodeId: NodeId, node: NodeDefinition) {
log.info('Overwritten node', { nodeId, node });
this.nodes.set(nodeId, node);
}
}

View File

@@ -86,7 +86,7 @@
position: absolute;
}
svg {
height: 124px;
height: 126px;
margin: 24px 0px;
border-top: solid thin var(--color-outline);
border-bottom: solid thin var(--color-outline);

View File

@@ -4,7 +4,7 @@
import { decodeFloat, splitNestedArray } from '@nodarium/utils';
import type { PerformanceStore } from '@nodarium/utils';
import { Canvas } from '@threlte/core';
import { Vector3 } from 'three';
import { DoubleSide, Vector3 } from 'three';
import { type Group, MeshMatcapMaterial, TextureLoader } from 'three';
import { createGeometryPool, createInstancedGeometryPool } from './geometryPool';
import Scene from './Scene.svelte';
@@ -14,7 +14,8 @@
matcap.colorSpace = 'srgb';
const material = new MeshMatcapMaterial({
color: 0xffffff,
matcap
matcap,
side: DoubleSide
});
let sceneComponent = $state<ReturnType<typeof Scene>>();

View File

@@ -0,0 +1,39 @@
export function logInt32ArrayChanges(
before: Int32Array,
after: Int32Array,
clamp = 10
): void {
if (before.length !== after.length) {
throw new Error('Arrays must have the same length');
}
let rangeStart: number | null = null;
let collected: number[] = [];
const flush = (endIndex: number) => {
if (rangeStart === null) return;
const preview = collected.slice(0, clamp);
const suffix = collected.length > clamp ? '...' : '';
console.log(
`Change ${rangeStart}-${endIndex}: [${preview.join(', ')}${suffix}]`
);
rangeStart = null;
collected = [];
};
for (let i = 0; i < before.length; i++) {
if (before[i] !== after[i]) {
if (rangeStart === null) {
rangeStart = i;
}
collected.push(after[i]);
} else {
flush(i - 1);
}
}
flush(before.length - 1);
}

View File

@@ -1,3 +1,5 @@
import type { SettingsToStore } from '$lib/settings/app-settings.svelte';
import { RemoteNodeRegistry } from '@nodarium/registry';
import type {
Graph,
NodeDefinition,
@@ -7,28 +9,42 @@ import type {
SyncCache
} from '@nodarium/types';
import {
concatEncodedArrays,
createLogger,
createWasmWrapper,
encodeFloat,
fastHashArrayBuffer,
type PerformanceStore
} from '@nodarium/utils';
import { DevSettingsType } from '../../routes/dev/settings.svelte';
import { logInt32ArrayChanges } from './helpers';
import type { RuntimeNode } from './types';
const log = createLogger('runtime-executor');
log.mute();
// log.mute(); // Keep logging enabled for debug info
function getValue(input: NodeInput, value?: unknown) {
const remoteRegistry = new RemoteNodeRegistry('');
type WasmExecute = (outputPos: number, args: number[]) => number;
function getValue(input: NodeInput, value?: unknown): number | number[] | Int32Array {
if (value === undefined && 'value' in input) {
value = input.value;
}
if (input.type === 'float') {
switch (input.type) {
case 'float':
return encodeFloat(value as number);
case 'select':
return (value as number) ?? 0;
case 'vec3': {
const arr = Array.isArray(value) ? value : [];
return [0, arr.length + 1, ...arr.map(v => encodeFloat(v)), 1, 1];
}
}
if (Array.isArray(value)) {
if (input.type === 'vec3') {
if (input.type === 'vec3' || input.type === 'shape') {
return [
0,
value.length + 1,
@@ -40,57 +56,97 @@ function getValue(input: NodeInput, value?: unknown) {
return [0, value.length + 1, ...value, 1, 1] as number[];
}
if (typeof value === 'boolean') {
return value ? 1 : 0;
if (typeof value === 'boolean') return value ? 1 : 0;
if (typeof value === 'number') return value;
if (value instanceof Int32Array) return value;
throw new Error(`Unsupported input type: ${input.type}`);
}
if (typeof value === 'number') {
return value;
function compareInt32(a: Int32Array, b: Int32Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
if (value instanceof Int32Array) {
return value;
}
throw new Error(`Unknown input type ${input.type}`);
}
export type Pointer = {
start: number;
end: number;
_title?: string;
};
export class MemoryRuntimeExecutor implements RuntimeExecutor {
private definitionMap: Map<string, NodeDefinition> = new Map();
private nodes = new Map<string, { definition: NodeDefinition; execute: WasmExecute }>();
private seed = Math.floor(Math.random() * 100000000);
private offset = 0;
private isRunning = false;
private readonly memory = new WebAssembly.Memory({
initial: 4096,
maximum: 8192
});
private memoryView!: Int32Array;
results: Record<number, Pointer> = {};
inputPtrs: Record<number, Pointer[]> = {};
allPtrs: Pointer[] = [];
seed = 42424242;
perf?: PerformanceStore;
constructor(
private registry: NodeRegistry,
private readonly registry: NodeRegistry,
public cache?: SyncCache<Int32Array>
) {
this.cache = undefined;
this.refreshView();
log.info('MemoryRuntimeExecutor initialized');
}
private refreshView(): void {
this.memoryView = new Int32Array(this.memory.buffer);
log.info(`Memory view refreshed, length: ${this.memoryView.length}`);
}
public getMemory(): Int32Array {
return new Int32Array(this.memory.buffer);
}
private map = new Map<string, { definition: NodeDefinition; execute: WasmExecute }>();
private async getNodeDefinitions(graph: Graph) {
if (this.registry.status !== 'ready') {
throw new Error('Node registry is not ready');
}
await this.registry.load(graph.nodes.map((node) => node.type));
await this.registry.load(graph.nodes.map(n => n.type));
log.info(`Loaded ${graph.nodes.length} node types from registry`);
const typeMap = new Map<string, NodeDefinition>();
for (const node of graph.nodes) {
if (!typeMap.has(node.type)) {
const type = this.registry.getNode(node.type);
if (type) {
typeMap.set(node.type, type);
for (const { type } of graph.nodes) {
if (this.map.has(type)) continue;
const def = this.registry.getNode(type);
if (!def) continue;
log.info(`Fetching WASM for node type: ${type}`);
const buffer = await remoteRegistry.fetchArrayBuffer(`nodes/${type}.wasm`);
const wrapper = createWasmWrapper(buffer, this.memory);
this.map.set(type, {
definition: def,
execute: wrapper.execute
});
log.info(`Node type ${type} loaded and wrapped`);
}
}
}
return typeMap;
return this.map;
}
private async addMetaData(graph: Graph) {
// First, lets check if all nodes have a definition
this.definitionMap = await this.getNodeDefinitions(graph);
this.nodes = await this.getNodeDefinitions(graph);
log.info(`Metadata added for ${this.nodes.size} nodes`);
const graphNodes = graph.nodes.map(node => {
const n = node as RuntimeNode;
@@ -103,55 +159,72 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
return n;
});
const outputNode = graphNodes.find((node) => node.type.endsWith('/output'));
if (!outputNode) {
throw new Error('No output node found');
}
const outputNode = graphNodes.find(n => n.type.endsWith('/output') || n.type.endsWith('/debug'))
?? graphNodes[0];
const nodeMap = new Map(
graphNodes.map((node) => [node.id, node])
);
const nodeMap = new Map(graphNodes.map(n => [n.id, n]));
// loop through all edges and assign the parent and child nodes to each node
for (const edge of graph.edges) {
const [parentId, /*_parentOutput*/, childId, childInput] = edge;
const parent = nodeMap.get(parentId);
const child = nodeMap.get(childId);
if (parent && child) {
if (!parent || !child) continue;
parent.state.children.push(child);
child.state.parents.push(parent);
child.state.inputNodes[childInput] = parent;
}
}
const nodes = [];
// loop through all the nodes and assign each nodes its depth
const ordered: RuntimeNode[] = [];
const stack = [outputNode];
while (stack.length) {
const node = stack.pop();
if (!node) continue;
const node = stack.pop()!;
for (const parent of node.state.parents) {
parent.state = parent.state || {};
parent.state.depth = node.state.depth + 1;
stack.push(parent);
}
nodes.push(node);
ordered.push(node);
}
return [outputNode, nodes] as const;
log.info(`Output node: ${outputNode.id}, total nodes ordered: ${ordered.length}`);
return [outputNode, ordered] as const;
}
private writeToMemory(value: number | number[] | Int32Array, title?: string): Pointer {
const start = this.offset;
if (typeof value === 'number') {
this.memoryView[this.offset++] = value;
} else {
this.memoryView.set(value, this.offset);
this.offset += value.length;
}
const ptr = { start, end: this.offset, _title: title };
this.allPtrs.push(ptr);
log.info(`Memory written for ${title}: start=${ptr.start}, end=${ptr.end}`);
return ptr;
}
private printMemory() {
this.memoryView = new Int32Array(this.memory.buffer);
console.log('MEMORY', this.memoryView.slice(0, 10));
}
async execute(graph: Graph, settings: Record<string, unknown>) {
this.perf?.addPoint('runtime');
this.offset = 0;
this.inputPtrs = {};
this.seed = this.seed += 2;
this.results = {};
this.allPtrs = [];
let a = performance.now();
if (this.isRunning) return undefined as unknown as Int32Array;
this.isRunning = true;
// Then we add some metadata to the graph
const [outputNode, nodes] = await this.addMetaData(graph);
let b = performance.now();
this.perf?.addPoint('collect-metadata', b - a);
const [_outputNode, nodes] = await this.addMetaData(graph);
/*
* Here we sort the nodes into buckets, which we then execute one by one
@@ -169,58 +242,75 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
(a, b) => (b.state?.depth || 0) - (a.state?.depth || 0)
);
// here we store the intermediate results of the nodes
const results: Record<string, Int32Array> = {};
console.log({ settings });
if (settings['randomSeed']) {
this.seed = Math.floor(Math.random() * 100000000);
}
this.printMemory();
const seedPtr = this.writeToMemory(this.seed, 'seed');
const settingPtrs = new Map<string, Pointer>(
Object.entries(settings).map((
[key, value]
) => [key as string, this.writeToMemory(value as number, `setting.${key}`)])
);
for (const node of sortedNodes) {
const node_type = this.definitionMap.get(node.type)!;
const node_type = this.nodes.get(node.type)!;
console.log('---------------');
console.log('STARTING NODE EXECUTION', node_type.definition.id + '/' + node.id);
this.printMemory();
// console.log(node_type.definition.inputs);
const inputs = Object.entries(node_type.definition.inputs || {}).map(
([key, input]) => {
// We should probably initially write this to memory
if (input.type === 'seed') {
return seedPtr;
}
const title = `${node.id}.${key}`;
// We should probably initially write this to memory
// If the input is linked to a setting, we use that value
// TODO: handle nodes which reference undefined settings
if (input.setting) {
return settingPtrs.get(input.setting)!;
}
// check if the input is connected to another node
const inputNode = node.state.inputNodes[key];
if (inputNode) {
if (this.results[inputNode.id] === undefined) {
throw new Error(
`Node ${node.type}/${node.id} is missing input from node ${inputNode.type}/${inputNode.id}`
);
}
return this.results[inputNode.id];
}
// If the value is stored in the node itself, we use that value
if (node.props?.[key] !== undefined) {
const value = getValue(input, node.props[key]);
console.log(`Writing prop for ${node.id} -> ${key} to memory`, node.props[key], value);
return this.writeToMemory(value, title);
}
return this.writeToMemory(getValue(input), title);
}
);
this.printMemory();
if (!node_type || !node.state || !node_type.execute) {
log.warn(`Node ${node.id} has no definition`);
continue;
}
a = performance.now();
// Collect the inputs for the node
const inputs = Object.entries(node_type.inputs || {}).map(
([key, input]) => {
if (input.type === 'seed') {
return this.seed;
}
// If the input is linked to a setting, we use that value
if (input.setting) {
return getValue(input, settings[input.setting]);
}
// check if the input is connected to another node
const inputNode = node.state.inputNodes[key];
if (inputNode) {
if (results[inputNode.id] === undefined) {
throw new Error(
`Node ${node.type} is missing input from node ${inputNode.type}`
);
}
return results[inputNode.id];
}
// If the value is stored in the node itself, we use that value
if (node.props?.[key] !== undefined) {
return getValue(input, node.props[key]);
}
return getValue(input);
}
);
b = performance.now();
this.perf?.addPoint('collected-inputs', b - a);
this.inputPtrs[node.id] = inputs;
const args = inputs.map(s => [s.start, s.end]).flat();
console.log('ARGS', inputs);
this.printMemory();
try {
a = performance.now();
const encoded_inputs = concatEncodedArrays(inputs);
@@ -249,28 +339,138 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
b = performance.now();
if (this.cache && node.id !== outputNode.id) {
this.cache.set(inputHash, results[node.id]);
this.cache.set(inputHash, this.results[node.id]);
}
this.perf?.addPoint('node/' + node_type.id, b - a);
log.log('Result:', results[node.id]);
log.groupEnd();
} catch (e) {
log.groupEnd();
log.error(`Error executing node ${node_type.id || node.id}`, e);
console.error(`Failed to execute node ${node.type}/${node.id}`, e);
this.isRunning = false;
}
}
// return the result of the parent of the output node
const res = results[outputNode.id];
this.isRunning = true;
log.info('Execution started');
if (this.cache) {
this.cache.size = sortedNodes.length * 2;
try {
this.offset = 0;
this.results = {};
this.inputPtrs = {};
this.allPtrs = [];
this.seed += 2;
this.refreshView();
const [outputNode, nodes] = await this.addMetaData(graph);
const sortedNodes = [...nodes].sort(
(a, b) => (b.state.depth ?? 0) - (a.state.depth ?? 0)
);
const seedPtr = this.writeToMemory(this.seed, 'seed');
const settingPtrs = new Map<string, Pointer>();
for (const [key, value] of Object.entries(settings)) {
const ptr = this.writeToMemory(value as number, `setting.${key}`);
settingPtrs.set(key, ptr);
}
let lastNodePtr: Pointer | undefined = undefined;
for (const node of sortedNodes) {
const nodeType = this.nodes.get(node.type);
if (!nodeType) continue;
log.info(`Executing node: ${node.id} (type: ${node.type})`);
const inputs = Object.entries(nodeType.definition.inputs || {}).map(
([key, input]) => {
if (input.type === 'seed') return seedPtr;
if (input.setting) {
const ptr = settingPtrs.get(input.setting);
if (!ptr) throw new Error(`Missing setting: ${input.setting}`);
return ptr;
}
const src = node.state.inputNodes[key];
if (src) {
const res = this.results[src.id];
if (!res) {
throw new Error(`Missing input from ${src.type}/${src.id}`);
}
return res;
}
if (node.props?.[key] !== undefined) {
return this.writeToMemory(
getValue(input, node.props[key]),
`${node.id}.${key}`
);
}
return this.writeToMemory(getValue(input), `${node.id}.${key}`);
}
);
this.inputPtrs[node.id] = inputs;
const args = inputs.flatMap(p => [p.start * 4, p.end * 4]);
log.info(`Executing node ${node.type}/${node.id}`);
const memoryBefore = this.memoryView.slice(0, this.offset);
const bytesWritten = nodeType.execute(this.offset * 4, args);
this.refreshView();
const memoryAfter = this.memoryView.slice(0, this.offset);
logInt32ArrayChanges(memoryBefore, memoryAfter);
this.refreshView();
const outLen = bytesWritten >> 2;
const outputStart = this.offset;
if (
args.length === 2
&& inputs[0].end - inputs[0].start === outLen
&& compareInt32(
this.memoryView.slice(inputs[0].start, inputs[0].end),
this.memoryView.slice(outputStart, outputStart + outLen)
)
) {
this.results[node.id] = inputs[0];
this.allPtrs.push(this.results[node.id]);
log.info(`Node ${node.id} result reused input memory`);
} else {
this.results[node.id] = {
start: outputStart,
end: outputStart + outLen,
_title: `${node.id} ->`
};
this.allPtrs.push(this.results[node.id]);
this.offset += outLen;
lastNodePtr = this.results[node.id];
log.info(
`Node ${node.id} wrote result to memory: start=${outputStart}, end=${outputStart + outLen
}`
);
}
}
const res = this.results[outputNode.id] ?? lastNodePtr;
if (!res) throw new Error('Output node produced no result');
log.info(`Execution finished, output pointer: start=${res.start}, end=${res.end}`);
this.refreshView();
return this.memoryView.slice(res.start, res.end);
} catch (e) {
log.info('Execution error:', e);
console.error(e);
} finally {
this.isRunning = false;
console.log('Final Memory', [...this.memoryView.slice(0, 20)]);
this.perf?.endPoint('runtime');
return res as unknown as Int32Array;
log.info('Executor state reset');
}
}
getPerformanceData() {

View File

@@ -211,7 +211,7 @@
.first-level.input {
padding-left: 1em;
padding-right: 1em;
padding-bottom: 1px;
padding-bottom: 0.5px;
gap: 3px;
}

View File

@@ -6,6 +6,7 @@ const themes = [
'catppuccin',
'solarized',
'high-contrast',
'high-contrast-light',
'nord',
'dracula'
] as const;
@@ -29,10 +30,11 @@ export const AppSettingTypes = {
},
nodeInterface: {
title: 'Node Interface',
showNodeGrid: {
type: 'boolean',
label: 'Show Grid',
value: true
backgroundType: {
type: 'select',
label: 'Background',
options: ['grid', 'dots', 'none'],
value: 'grid'
},
snapToGrid: {
type: 'boolean',

View File

@@ -1,78 +1,105 @@
<script lang="ts">
type Change = { type: string; content: string };
import { Details } from '@nodarium/ui';
import { micromark } from 'micromark';
const typeMap: Record<string, string> = {
fix: 'bg-layer-2 bg-red-800',
feat: 'bg-layer-2 bg-green-800',
chore: 'bg-layer-2 bg-gray-800',
docs: 'bg-layer-2 bg-blue-800',
refactor: 'bg-layer-2 bg-purple-800',
default: 'bg-layer-2 text-text'
type Props = {
git?: Record<string, string>;
changelog?: string;
};
async function fetchChangelog() {
const res = await fetch('/CHANGELOG.md');
return await res.text();
const {
git,
changelog
}: Props = $props();
const typeMap = new Map([
['fix', 'border-l-red-800'],
['feat', 'border-l-green-800'],
['chore', 'border-l-gray-800'],
['docs', 'border-l-blue-800'],
['refactor', 'border-l-purple-800'],
['ci', 'border-l-red-400']
]);
function detectCommitType(commit: string) {
for (const key of typeMap.keys()) {
if (commit.startsWith(key)) {
return key;
}
}
return '';
}
async function fetchGitInfo() {
const res = await fetch('/git.json');
return await res.json();
function parseCommit(line?: string) {
if (!line) return;
const regex = /^\s*-\s*\[([a-f0-9]+)\]\((https?:\/\/[^\s)]+)\)\s+(.+)$/;
const match = line.match(regex);
if (!match) {
return;
}
const [, sha, link, description] = match;
return {
sha,
link,
description,
type: detectCommitType(description)
};
}
function parseChangelog(md: string) {
const lines = md.split('\n');
const parsed: (string | Change)[] = [];
return md.split(/^# v/gm)
.filter(l => !!l.length)
.map(release => {
const [firstLine, ...rest] = release.split('\n');
const title = firstLine.trim();
for (let line of lines) {
line = line.trim();
if (!line) continue;
const blocks = rest
.join('\n')
.split('---');
if (line === '---') {
parsed.push({ type: 'hr', content: '' });
continue;
}
const commits = blocks.length > 1
? blocks
.at(-1)
?.split('\n')
?.map(line => parseCommit(line))
?.filter(c => !!c)
: [];
// Headers
if (line.startsWith('## ')) {
parsed.push(line.replace('## ', ''));
continue;
}
const description = (
blocks.length > 1
? blocks
.slice(0, -1)
.join('\n')
: blocks[0]
).trim();
// Commit type
const match = line.match(/^(fix|feat|chore|docs|refactor)(\(|:)/i);
if (match) {
parsed.push({ type: match[1].toLowerCase(), content: line });
continue;
}
// Other lines
parsed.push({ type: 'default', content: line });
}
// Remove trailing horizontal rule
let lastLine = parsed.at(-1);
if (
lastLine !== undefined
&& typeof lastLine !== 'string'
&& lastLine.type === 'hr'
) {
parsed.pop();
}
return parsed;
return {
description: micromark(description),
title,
commits
};
});
}
</script>
<div class="p-4 font-mono text-text">
{#await Promise.all([fetchChangelog(), fetchGitInfo()])}
<p>Loading...</p>
{:then [md, git]}
<div class="mb-4 p-3 bg-layer-2 text-xs">
<div id="changelog" class="p-4 font-mono text-text overflow-y-auto max-h-full space-y-5">
{#if git}
<div class="mb-4 p-3 bg-layer-2 text-xs rounded">
<p><strong>Branch:</strong> {git.branch}</p>
<p>
<strong>Commit:</strong>
{git.sha.slice(0, 7)} {git.commit_message}
<a
href="https://git.max-richter.dev/max/nodarium/commit/{git.sha}"
class="link"
target="_blank"
>
{git.sha.slice(0, 7)}
</a>
{git.commit_message}
</p>
<p>
<strong>Commits since last release:</strong>
@@ -83,28 +110,76 @@
{new Date(git.commit_timestamp).toLocaleString()}
</p>
</div>
{/if}
{#each parseChangelog(md) as item (item)}
{#if typeof item === 'string'}
<h2 class="text-xl font-semibold mt-4 mb-4 text-layer-1">{item}</h2>
{:else if item.type === 'hr'}{:else}
<p class="py-1 mb-1 leading-8 border-b border-b-outline last:border-b-0">
{#if item.type !== 'default'}
<span
class="
p-1 rounded-sm opacity-80 font-semibold {typeMap[
item.type
]}
"
{#if changelog}
{#each parseChangelog(changelog) as release (release)}
<Details title={release.title}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div id="description" class="pb-5">{@html release.description}</div>
{#if release?.commits?.length}
<Details
title="All Commits"
class="commits"
>
{item.content.split(':')[0]}
</span>
{item.content.split(':').slice(1).join(':').trim()}
{:else}
{item.content}
{/if}
{#each release.commits as commit (commit)}
<p class="py-1 leading-7 text-xs border-b-1 border-l-1 border-b-outline last:border-b-0 -ml-2 pl-2 {typeMap.get(commit.type)}">
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={commit.link} class="link" target="_blank">{commit.sha}</a>
{commit.description}
</p>
{/if}
{/each}
{/await}
</Details>
{/if}
</Details>
{/each}
{/if}
</div>
<style lang="postcss">
@reference "tailwindcss";
#changelog :global(.commits) {
margin-left: -16px;
margin-right: -16px;
border-radius: 0px 0px 2px 2px !important;
}
#changelog :global(details > div){
padding-bottom: 0px;
}
#changelog :global(.commits > div) {
padding-bottom: 0px;
padding-top: 0px;
}
#description :global(h2) {
@apply font-bold mt-4 mb-1;
}
#description :global(h2:first-child) {
margin-top: 0px !important;
}
#description :global(ul) {
padding-left: 1em;
}
#description :global(li),
#description :global(p) {
@apply text-xs!;
list-style-type: disc;
}
#changelog :global(details > details[open] > summary){
margin-bottom: 20px !important;
}
.link {
color: #60a5fa;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { page } from '$app/state';
</script>
<main class="w-screen h-screen flex flex-col items-center justify-center">
<div class="outline-1 outline-outline bg-layer-2">
<h1 class="p-8 text-3xl">@nodarium/error</h1>
<hr>
<pre class="p-8">{JSON.stringify(page.error, null, 2)}</pre>
<hr>
<div class="flex p-4">
<button
class="bg-layer-2 outline-1 outline-outline p-3 px-6 rounded-sm cursor-pointer"
on:click={() => window.location.reload()}
>
reload
</button>
</div>
</div>
</main>

View File

@@ -1 +1,28 @@
export const prerender = true;
export async function load({ fetch }) {
async function fetchChangelog() {
try {
const res = await fetch('/CHANGELOG.md');
return await res.text();
} catch (error) {
console.log('Failed to fetch CHANGELOG.md', error);
return;
}
}
async function fetchGitInfo() {
try {
const res = await fetch('/git.json');
return await res.json();
} catch (error) {
console.log('Failed to fetch git.json', error);
return;
}
}
return {
git: await fetchGitInfo(),
changelog: await fetchChangelog()
};
}

View File

@@ -29,6 +29,8 @@
let performanceStore = createPerformanceStore();
const { data } = $props();
const registryCache = new IndexDBCache('node-registry');
const nodeRegistry = new RemoteNodeRegistry('', registryCache);
const workerRuntime = new WorkerRuntimeExecutor();
@@ -84,11 +86,6 @@
let graphSettingTypes = $state({
randomSeed: { type: 'boolean', value: false }
});
$effect(() => {
if (graphSettings && graphSettingTypes) {
manager?.setSettings($state.snapshot(graphSettings));
}
});
async function update(
g: Graph,
@@ -169,7 +166,7 @@
graph={pm.graph}
bind:this={graphInterface}
registry={nodeRegistry}
showGrid={appSettings.value.nodeInterface.showNodeGrid}
backgroundType={appSettings.value.nodeInterface.backgroundType}
snapToGrid={appSettings.value.nodeInterface.snapToGrid}
bind:activeNode
bind:showHelp={appSettings.value.nodeInterface.showHelp}
@@ -255,7 +252,7 @@
title="Changelog"
icon="i-[tabler--file-text-spark] bg-green-400"
>
<Changelog />
<Changelog git={data.git} changelog={data.changelog} />
</Panel>
</Sidebar>
</Grid.Cell>

View File

@@ -3,6 +3,6 @@
const { children } = $props<{ children?: Snippet }>();
</script>
<main class="w-screen overflow-x-hidden">
<main class="w-screen h-screen overflow-x-hidden">
{@render children()}
</main>

View File

@@ -44,8 +44,9 @@
}
}
$effect(() => {
fetchNodeData(activeNode.value);
let graphSettings = $state<Record<string, any>>({});
let graphSettingTypes = $state({
randomSeed: { type: "boolean", value: false },
});
$effect(() => {
@@ -61,19 +62,85 @@
});
</script>
<div class="node-wrapper absolute bottom-8 left-8">
{#if nodeInstance}
<NodeHTML inView position="relative" z={5} bind:node={nodeInstance} />
{/if}
</div>
<svelte:window
bind:innerHeight={windowHeight}
onkeydown={(ev) => ev.key === "r" && handleResult()}
/>
<Grid.Row>
<Grid.Cell>
<pre>
<code>
{JSON.stringify(nodeInstance?.props)}
</code>
</pre>
{#if visibleRows?.length}
<table
class="min-w-full select-none overflow-auto text-left text-sm flex-1"
onscroll={(e) => {
const scrollTop = e.currentTarget.scrollTop;
start.value = Math.floor(scrollTop / rowHeight);
}}
>
<thead class="">
<tr>
<th class="px-4 py-2 border-b border-[var(--outline)]">i</th>
<th
class="px-4 py-2 border-b border-[var(--outline)] w-[50px]"
style:width="50px">Ptrs</th
>
<th class="px-4 py-2 border-b border-[var(--outline)]">Value</th>
<th class="px-4 py-2 border-b border-[var(--outline)]">Float</th>
</tr>
</thead>
<tbody
onscroll={(e) => {
const scrollTop = e.currentTarget.scrollTop;
start.value = Math.floor(scrollTop / rowHeight);
}}
>
{#each visibleRows as r, i}
{@const index = i + start.value}
{@const ptr = ptrs[i]}
<tr class="h-[40px] odd:bg-[var(--layer-1)]">
<td class="px-4 border-b border-[var(--outline)] w-8">{index}</td>
<td
class="border-b border-[var(--outline)] overflow-hidden text-ellipsis pl-2
{ptr?._title?.includes('->')
? 'bg-red-500'
: 'bg-blue-500'}"
style="width: 100px; min-width: 100px; max-width: 100px;"
>
{ptr?._title}
</td>
<td
class="px-4 border-b border-[var(--outline)] cursor-pointer text-blue-600 hover:text-blue-800"
onclick={() =>
(rowIsFloat.value[index] = !rowIsFloat.value[index])}
>
{decodeValue(r, rowIsFloat.value[index])}
</td>
<td class="px-4 border-b border-[var(--outline)] italic w-5">
<input
type="checkbox"
checked={rowIsFloat.value[index]}
onclick={() =>
(rowIsFloat.value[index] = !rowIsFloat.value[index])}
/>
</td>
</tr>
{/each}
</tbody>
</table>
<button
onclick={() => copyVisibleMemory(visibleRows, ptrs, start.value)}
class="flex items-center cursor-pointer absolute bottom-4 left-4 z-100 bg-gray-200 px-2 py-1 rounded hover:bg-gray-300"
>
Copy Visible Memory
</button>
<input
class="absolute bottom-4 right-4 bg-white"
bind:value={start.value}
min="0"
type="number"
step="1"
/>
{/if}
</Grid.Cell>
<Grid.Cell>
@@ -82,6 +149,20 @@
</Grid.Row>
<Sidebar>
<Panel id="general" title="General" icon="i-[tabler--settings]">
<h3 class="p-4 pb-0">Debug Settings</h3>
<NestedSettings
id="Debug"
bind:value={devSettings.value}
type={DevSettingsType}
/>
<hr />
<NestedSettings
id="general"
bind:value={appSettings.value}
type={AppSettingTypes}
/>
</Panel>
<Panel
id="node-store"
classes="text-green-400"

View File

@@ -0,0 +1,74 @@
{
"settings": {
"resolution.circle": 26,
"resolution.curve": 39
},
"nodes": [
{
"id": 9,
"position": [
225,
65
],
"type": "max/plantarium/output",
"props": {
"out": 0
}
},
{
"id": 10,
"position": [
200,
60
],
"type": "max/plantarium/math",
"props": {
"op_type": 3,
"a": 2,
"b": 0.38
}
},
{
"id": 11,
"position": [
175,
60
],
"type": "max/plantarium/float",
"props": {
"value": 0.66
}
},
{
"id": 12,
"position": [
175,
80
],
"type": "max/plantarium/float",
"props": {
"value": 1
}
}
],
"edges": [
[
11,
0,
10,
"a"
],
[
12,
0,
10,
"b"
],
[
10,
0,
9,
"out"
]
]
}

View File

@@ -0,0 +1,48 @@
import type { Pointer } from '$lib/runtime';
export function copyVisibleMemory(rows: Int32Array, currentPtrs: Pointer[], start: number) {
if (!rows?.length) return;
// Build an array of rows for the table
const tableRows = [...rows].map((value, i) => {
const index = start + i;
const ptr = currentPtrs[i];
return {
index,
ptr: ptr?._title ?? '',
value: value
};
});
// Compute column widths
const indexWidth = Math.max(
5,
...tableRows.map((r) => r.index.toString().length)
);
const ptrWidth = Math.max(
10,
...tableRows.map((r) => r.ptr.length)
);
const valueWidth = Math.max(
10,
...tableRows.map((r) => r.value.toString().length)
);
// Build header
let output =
`| ${'Index'.padEnd(indexWidth)} | ${'Ptr'.padEnd(ptrWidth)} | ${'Value'.padEnd(valueWidth)
} |\n`
+ `|-${'-'.repeat(indexWidth)}-|-${'-'.repeat(ptrWidth)}-|-${'-'.repeat(valueWidth)}-|\n`;
// Add rows
for (const row of tableRows) {
output += `| ${row.index.toString().padEnd(indexWidth)} | ${row.ptr.padEnd(ptrWidth)} | ${row.value.toString().padEnd(valueWidth)
} |\n`;
}
// Copy to clipboard
navigator.clipboard
.writeText(output)
.then(() => console.log('Memory + metadata copied as table'))
.catch((err) => console.error('Failed to copy memory:', err));
}

View File

@@ -0,0 +1,15 @@
import { localState } from '$lib/helpers/localState.svelte';
import { settingsToStore } from '$lib/settings/app-settings.svelte';
export const DevSettingsType = {
debugNode: {
type: 'boolean',
label: 'Debug Nodes',
value: true
}
} as const;
export let devSettings = localState(
'dev-settings',
settingsToStore(DevSettingsType)
);

View File

@@ -1,2 +1,3 @@
nodes/
CHANGELOG.md
git.json

View File

@@ -4,20 +4,19 @@ This guide will help you developing your first Nodarium Node written in Rust. As
## Prerequesites
You need to have [Rust](https://www.rust-lang.org/tools/install) and [wasm-pack](https://rustwasm.github.io/docs/wasm-pack/) installed. Rust is the language we are going to develop our node in and wasm-pack helps us compile our rust code into a webassembly file.
You need to have [Rust](https://www.rust-lang.org/tools/install) installed. Rust is the language we are going to develop our node in and cargo compiles our rust code into webassembly.
```bash
# install rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# install wasm-pack
cargo install wasm-pack
```
## Clone Template
```bash
wasm-pack new my-new-node --template https://github.com/jim-fx/nodarium_template
cd my-new-node
# copy the template directory
cp -r nodes/max/plantarium/.template nodes/max/plantarium/my-new-node
cd nodes/max/plantarium/my-new-node
```
## Setup Definition

View File

@@ -1,20 +1,18 @@
use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute;
use nodarium_utils::{
encode_float, evaluate_float, geometry::calculate_normals,log,
split_args, wrap_arg,
encode_float, evaluate_float, geometry::calculate_normals, wrap_arg,
read_i32_slice
};
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> {
pub fn execute(size: (i32, i32)) -> Vec<i32> {
let args = split_args(input);
let args = read_i32_slice(size);
log!("WASM(cube): input: {:?} -> {:?}", input, args);
let size = evaluate_float(args[0]);
let size = evaluate_float(&args);
let p = encode_float(size);
let n = encode_float(-size);
@@ -77,8 +75,6 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
let res = wrap_arg(&cube_geometry);
log!("WASM(box): output: {:?}", res);
res
}

View File

@@ -1,5 +1,6 @@
use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute;
use nodarium_utils::read_i32_slice;
use nodarium_utils::{
concat_arg_vecs, evaluate_float, evaluate_int,
geometry::{
@@ -13,15 +14,25 @@ use std::f32::consts::PI;
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> {
let args = split_args(input);
let paths = split_args(args[0]);
pub fn execute(
path: (i32, i32),
length: (i32, i32),
thickness: (i32, i32),
offset_single: (i32, i32),
lowest_branch: (i32, i32),
highest_branch: (i32, i32),
depth: (i32, i32),
amount: (i32, i32),
resolution_curve: (i32, i32),
rotation: (i32, i32),
) -> Vec<i32> {
let arg = read_i32_slice(path);
let paths = split_args(arg.as_slice());
let mut output: Vec<Vec<i32>> = Vec::new();
let resolution = evaluate_int(args[8]).max(4) as usize;
let depth = evaluate_int(args[6]);
let resolution = evaluate_int(read_i32_slice(resolution_curve).as_slice()).max(4) as usize;
let depth = evaluate_int(read_i32_slice(depth).as_slice());
let mut max_depth = 0;
for path_data in paths.iter() {
@@ -40,18 +51,18 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
let path = wrap_path(path_data);
let branch_amount = evaluate_int(args[7]).max(1);
let branch_amount = evaluate_int(read_i32_slice(amount).as_slice()).max(1);
let lowest_branch = evaluate_float(args[4]);
let highest_branch = evaluate_float(args[5]);
let lowest_branch = evaluate_float(read_i32_slice(lowest_branch).as_slice());
let highest_branch = evaluate_float(read_i32_slice(highest_branch).as_slice());
for i in 0..branch_amount {
let a = i as f32 / (branch_amount - 1).max(1) as f32;
let length = evaluate_float(args[1]);
let thickness = evaluate_float(args[2]);
let length = evaluate_float(read_i32_slice(length).as_slice());
let thickness = evaluate_float(read_i32_slice(thickness).as_slice());
let offset_single = if i % 2 == 0 {
evaluate_float(args[3])
evaluate_float(read_i32_slice(offset_single).as_slice())
} else {
0.0
};
@@ -65,7 +76,8 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
root_alpha + (offset_single - 0.5) * 6.0 / resolution as f32,
);
let rotation_angle = (evaluate_float(args[9]) * PI / 180.0) * i as f32;
let rotation_angle =
(evaluate_float(read_i32_slice(rotation).as_slice()) * PI / 180.0) * i as f32;
// check if diration contains NaN
if orthogonal[0].is_nan() || orthogonal[1].is_nan() || orthogonal[2].is_nan() {

6
nodes/max/plantarium/debug/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/target
**/*.rs.bk
Cargo.lock
bin/
pkg/
wasm-pack.log

View File

@@ -0,0 +1,12 @@
[package]
name = "debug"
version = "0.1.0"
authors = ["Max Richter <jim-x@web.de>"]
edition = "2018"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nodarium_macros = { version = "0.1.0", path = "../../../../packages/macros" }
nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" }

View File

@@ -0,0 +1,22 @@
{
"id": "max/plantarium/debug",
"outputs": [],
"inputs": {
"input": {
"type": "float",
"accepts": [
"*"
],
"external": true
},
"type": {
"type": "select",
"options": [
"float",
"vec3",
"geometry"
],
"internal": true
}
}
}

View File

@@ -0,0 +1,25 @@
use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute;
use nodarium_utils::encode_float;
use nodarium_utils::evaluate_float;
use nodarium_utils::evaluate_vec3;
use nodarium_utils::read_i32;
use nodarium_utils::read_i32_slice;
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(input: (i32, i32), input_type: (i32, i32)) -> Vec<i32> {
let inp = read_i32_slice(input);
let t = read_i32(input_type.0);
if t == 0 {
let f = evaluate_float(inp.as_slice());
return vec![encode_float(f)];
}
if t == 1 {
let f = evaluate_vec3(inp.as_slice());
return vec![encode_float(f[0]), encode_float(f[1]), encode_float(f[2])];
}
return inp;
}

View File

@@ -2,11 +2,14 @@
name = "float"
version = "0.1.0"
authors = ["Max Richter <jim-x@web.de>"]
edition = "2018"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[profile.dev]
panic = "unwind"
[dependencies]
nodarium_macros = { version = "0.1.0", path = "../../../../packages/macros" }
nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" }

View File

@@ -1,9 +1,10 @@
use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute;
use nodarium_utils::read_i32;
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(args: &[i32]) -> Vec<i32> {
args.into()
pub fn execute(a: (i32, i32)) -> Vec<i32> {
vec![read_i32(a.0)]
}

View File

@@ -1,6 +1,7 @@
use glam::Vec3;
use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute;
use nodarium_utils::read_i32_slice;
use nodarium_utils::{
concat_args, evaluate_float, evaluate_int,
geometry::{wrap_path, wrap_path_mut},
@@ -14,13 +15,17 @@ fn lerp_vec3(a: Vec3, b: Vec3, t: f32) -> Vec3 {
}
#[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> {
pub fn execute(
plant: (i32, i32),
strength: (i32, i32),
curviness: (i32, i32),
depth: (i32, i32),
) -> Vec<i32> {
reset_call_count();
let args = split_args(input);
let plants = split_args(args[0]);
let depth = evaluate_int(args[3]);
let arg = read_i32_slice(plant);
let plants = split_args(arg.as_slice());
let depth = evaluate_int(read_i32_slice(depth).as_slice());
let mut max_depth = 0;
for path_data in plants.iter() {
@@ -55,9 +60,9 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
let length = direction.length();
let curviness = evaluate_float(args[2]);
let strength =
evaluate_float(args[1]) / curviness.max(0.0001) * evaluate_float(args[1]);
let str = evaluate_float(read_i32_slice(strength).as_slice());
let curviness = evaluate_float(read_i32_slice(curviness).as_slice());
let strength = str / curviness.max(0.0001) * str;
log!(
"length: {}, curviness: {}, strength: {}",

View File

@@ -28,6 +28,13 @@
"value": 1,
"hidden": true
},
"rotation": {
"type": "float",
"min": 0,
"max": 1,
"value": 0.5,
"hidden": true
},
"depth": {
"type": "integer",
"min": 1,

View File

@@ -1,12 +1,9 @@
use glam::{Mat4, Quat, Vec3};
use nodarium_macros::nodarium_execute;
use nodarium_macros::nodarium_definition_file;
use nodarium_macros::{nodarium_execute, nodarium_definition_file};
use nodarium_utils::{
concat_args, evaluate_float, evaluate_int,
geometry::{
create_instance_data, wrap_geometry_data, wrap_instance_data, wrap_path,
},
log, split_args,
geometry::{create_instance_data, wrap_geometry_data, wrap_instance_data, wrap_path},
split_args,
};
nodarium_definition_file!("src/input.json");
@@ -15,13 +12,13 @@ nodarium_definition_file!("src/input.json");
pub fn execute(input: &[i32]) -> Vec<i32> {
let args = split_args(input);
let mut inputs = split_args(args[0]);
log!("WASM(instance): inputs: {:?}", inputs);
let mut geo_data = args[1].to_vec();
let mut geo_data = read_i32_slice(geometry);
let geo = wrap_geometry_data(&mut geo_data);
let mut transforms: Vec<Mat4> = Vec::new();
// Find max depth
let mut max_depth = 0;
for path_data in inputs.iter() {
if path_data[2] != 0 {
@@ -30,7 +27,8 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
max_depth = max_depth.max(path_data[3]);
}
let depth = evaluate_int(args[5]);
let rotation = evaluate_float(args[5]);
let depth = evaluate_int(args[6]);
for path_data in inputs.iter() {
if path_data[3] < (max_depth - depth + 1) {
@@ -38,24 +36,34 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
}
let amount = evaluate_int(args[2]);
let lowest_instance = evaluate_float(args[3]);
let highest_instance = evaluate_float(args[4]);
let path = wrap_path(path_data);
for i in 0..amount {
let alpha =
lowest_instance + (i as f32 / amount as f32) * (highest_instance - lowest_instance);
let alpha = lowest_instance
+ (i as f32 / (amount - 1) as f32) * (highest_instance - lowest_instance);
let point = path.get_point_at(alpha);
let direction = path.get_direction_at(alpha);
let tangent = path.get_direction_at(alpha);
let size = point[3] + 0.01;
let axis_rotation = Quat::from_axis_angle(
Vec3::from_slice(&tangent).normalize(),
i as f32 * rotation,
);
let path_rotation = Quat::from_rotation_arc(Vec3::Y, Vec3::from_slice(&tangent).normalize());
let rotation = path_rotation * axis_rotation;
let transform = Mat4::from_scale_rotation_translation(
Vec3::new(point[3], point[3], point[3]),
Quat::from_xyzw(direction[0], direction[1], direction[2], 1.0).normalize(),
Vec3::new(size, size, size),
rotation,
Vec3::from_slice(&point),
);
transforms.push(transform);
}
}
@@ -67,11 +75,11 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
);
let mut instances = wrap_instance_data(&mut instance_data);
instances.set_geometry(geo);
(0..transforms.len()).for_each(|i| {
instances.set_transformation_matrix(i, &transforms[i].to_cols_array());
});
log!("WASM(instance): geo: {:?}", instance_data);
for (i, transform) in transforms.iter().enumerate() {
instances.set_transformation_matrix(i, &transform.to_cols_array());
}
inputs.push(&instance_data);
concat_args(inputs)

6
nodes/max/plantarium/leaf/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/target
**/*.rs.bk
Cargo.lock
bin/
pkg/
wasm-pack.log

View File

@@ -0,0 +1,12 @@
[package]
name = "leaf"
version = "0.1.0"
authors = ["Max Richter <jim-x@web.de>"]
edition = "2018"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nodarium_macros = { version = "0.1.0", path = "../../../../packages/macros" }
nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" }

View File

@@ -0,0 +1,24 @@
{
"id": "max/plantarium/leaf",
"outputs": [
"geometry"
],
"inputs": {
"shape": {
"type": "shape",
"external": true
},
"size": {
"type": "float",
"value": 1
},
"xResolution": {
"type": "integer",
"description": "The amount of stems to produce",
"min": 1,
"max": 64,
"value": 1,
"hidden": true
}
}
}

View File

@@ -0,0 +1,166 @@
use std::convert::TryInto;
use std::f32::consts::PI;
use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute;
use nodarium_utils::encode_float;
use nodarium_utils::evaluate_float;
use nodarium_utils::evaluate_int;
use nodarium_utils::log;
use nodarium_utils::wrap_arg;
use nodarium_utils::{split_args, decode_float};
nodarium_definition_file!("src/input.json");
fn calculate_y(x: f32) -> f32 {
let term1 = (x * PI * 2.0).sin().abs();
let term2 = (x * 2.0 * PI + (PI / 2.0)).sin() / 2.0;
term1 + term2
}
// Helper vector math functions
fn vec_sub(a: &[f32; 3], b: &[f32; 3]) -> [f32; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn vec_cross(a: &[f32; 3], b: &[f32; 3]) -> [f32; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn vec_normalize(v: &[f32; 3]) -> [f32; 3] {
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if len == 0.0 { [0.0, 0.0, 0.0] } else { [v[0]/len, v[1]/len, v[2]/len] }
}
#[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> {
let args = split_args(input);
let input_path = split_args(args[0])[0];
let size = evaluate_float(args[1]);
let width_resolution = evaluate_int(args[2]).max(3) as usize;
let path_length = (input_path.len() - 4) / 2;
let slice_count = path_length;
let face_amount = (slice_count - 1) * (width_resolution - 1) * 2;
let position_amount = slice_count * width_resolution;
let out_length =
3 // metadata
+ face_amount * 3 // indices
+ position_amount * 3 // positions
+ position_amount * 3; // normals
let mut out = vec![0 as i32; out_length];
log!("face_amount={:?} position_amount={:?}", face_amount, position_amount);
out[0] = 1;
out[1] = position_amount.try_into().unwrap();
out[2] = face_amount.try_into().unwrap();
let mut offset = 3;
// Writing Indices
let mut idx = 0;
for i in 0..(slice_count - 1) {
let base0 = (i * width_resolution) as i32;
let base1 = ((i + 1) * width_resolution) as i32;
for j in 0..(width_resolution - 1) {
let a = base0 + j as i32;
let b = base0 + j as i32 + 1;
let c = base1 + j as i32;
let d = base1 + j as i32 + 1;
// triangle 1
out[offset + idx + 0] = a;
out[offset + idx + 1] = b;
out[offset + idx + 2] = c;
// triangle 2
out[offset + idx + 3] = b;
out[offset + idx + 4] = d;
out[offset + idx + 5] = c;
idx += 6;
}
}
offset += face_amount * 3;
// Writing Positions
let width = 50.0;
let mut positions = vec![[0.0f32; 3]; position_amount];
for i in 0..slice_count {
let ax = i as f32 / (slice_count -1) as f32;
let px = decode_float(input_path[2 + i * 2 + 0]);
let pz = decode_float(input_path[2 + i * 2 + 1]);
for j in 0..width_resolution {
let alpha = j as f32 / (width_resolution - 1) as f32;
let x = 2.0 * (-px * (alpha - 0.5) + alpha * width);
let py = calculate_y(alpha-0.5)*5.0*(ax*PI).sin();
let pz_val = pz - 100.0;
let pos_idx = i * width_resolution + j;
positions[pos_idx] = [x - width, py, pz_val];
let flat_idx = offset + pos_idx * 3;
out[flat_idx + 0] = encode_float((x - width) * size);
out[flat_idx + 1] = encode_float(py * size);
out[flat_idx + 2] = encode_float(pz_val * size);
}
}
// Writing Normals
offset += position_amount * 3;
let mut normals = vec![[0.0f32; 3]; position_amount];
for i in 0..(slice_count - 1) {
for j in 0..(width_resolution - 1) {
let a = i * width_resolution + j;
let b = i * width_resolution + j + 1;
let c = (i + 1) * width_resolution + j;
let d = (i + 1) * width_resolution + j + 1;
// triangle 1: a,b,c
let u = vec_sub(&positions[b], &positions[a]);
let v = vec_sub(&positions[c], &positions[a]);
let n1 = vec_cross(&u, &v);
// triangle 2: b,d,c
let u2 = vec_sub(&positions[d], &positions[b]);
let v2 = vec_sub(&positions[c], &positions[b]);
let n2 = vec_cross(&u2, &v2);
for &idx in &[a, b, c] {
normals[idx][0] += n1[0];
normals[idx][1] += n1[1];
normals[idx][2] += n1[2];
}
for &idx in &[b, d, c] {
normals[idx][0] += n2[0];
normals[idx][1] += n2[1];
normals[idx][2] += n2[2];
}
}
}
// normalize and write to output
for i in 0..position_amount {
let n = vec_normalize(&normals[i]);
let flat_idx = offset + i * 3;
out[flat_idx + 0] = encode_float(n[0]);
out[flat_idx + 1] = encode_float(n[1]);
out[flat_idx + 2] = encode_float(n[2]);
}
wrap_arg(&out)
}

View File

@@ -1,13 +1,15 @@
use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute;
use nodarium_utils::{
concat_args, split_args
};
#[nodarium_execute]
pub fn execute(args: &[i32]) -> Vec<i32> {
let args = split_args(args);
concat_args(vec![&[0], args[0], args[1], args[2]])
}
use nodarium_utils::log;
use nodarium_utils::{concat_arg_vecs, read_i32_slice};
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(op_type: (i32, i32), a: (i32, i32), b: (i32, i32)) -> Vec<i32> {
log!("math.op {:?}", op_type);
let op = read_i32_slice(op_type);
let a_val = read_i32_slice(a);
let b_val = read_i32_slice(b);
concat_arg_vecs(vec![vec![0], op, a_val, b_val])
}

View File

@@ -2,7 +2,7 @@
name = "noise"
version = "0.1.0"
authors = ["Max Richter <jim-x@web.de>"]
edition = "2018"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]

View File

@@ -1,7 +1,8 @@
use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute;
use nodarium_utils::read_i32_slice;
use nodarium_utils::{
concat_args, evaluate_float, evaluate_int, evaluate_vec3, geometry::wrap_path_mut,
concat_args, evaluate_float, evaluate_int, evaluate_vec3, geometry::wrap_path_mut, read_i32,
reset_call_count, split_args,
};
use noise::{HybridMulti, MultiFractal, NoiseFn, OpenSimplex};
@@ -13,23 +14,31 @@ fn lerp(a: f32, b: f32, t: f32) -> f32 {
}
#[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> {
pub fn execute(
plant: (i32, i32),
scale: (i32, i32),
strength: (i32, i32),
fix_bottom: (i32, i32),
seed: (i32, i32),
directional_strength: (i32, i32),
depth: (i32, i32),
octaves: (i32, i32),
) -> Vec<i32> {
reset_call_count();
let args = split_args(input);
let arg = read_i32_slice(plant);
let plants = split_args(arg.as_slice());
let scale = (evaluate_float(read_i32_slice(scale).as_slice()) * 0.1) as f64;
let strength = evaluate_float(read_i32_slice(strength).as_slice());
let fix_bottom = evaluate_float(read_i32_slice(fix_bottom).as_slice());
let plants = split_args(args[0]);
let scale = (evaluate_float(args[1]) * 0.1) as f64;
let strength = evaluate_float(args[2]);
let fix_bottom = evaluate_float(args[3]);
let seed = read_i32(seed.0);
let seed = args[4][0];
let directional_strength = evaluate_vec3(read_i32_slice(directional_strength).as_slice());
let directional_strength = evaluate_vec3(args[5]);
let depth = evaluate_int(read_i32_slice(depth).as_slice());
let depth = evaluate_int(args[6]);
let octaves = evaluate_int(args[7]);
let octaves = evaluate_int(read_i32_slice(octaves).as_slice());
let noise_x: HybridMulti<OpenSimplex> =
HybridMulti::new(seed as u32 + 1).set_octaves(octaves as usize);

View File

@@ -5,7 +5,7 @@
"input": {
"type": "path",
"accepts": [
"geometry"
"*"
],
"external": true
},

View File

@@ -1,44 +1,11 @@
use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute;
use nodarium_utils::{
concat_args, evaluate_int,
geometry::{extrude_path, wrap_path},
log, split_args,
};
use nodarium_utils::read_i32_slice;
nodarium_definition_file!("src/inputs.json");
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> {
log!("WASM(output): input: {:?}", input);
let args = split_args(input);
log!("WASM(output) args: {:?}", args);
assert_eq!(args.len(), 2, "Expected 2 arguments, got {}", args.len());
let inputs = split_args(args[0]);
let resolution = evaluate_int(args[1]) as usize;
log!("inputs: {}, resolution: {}", inputs.len(), resolution);
let mut output: Vec<Vec<i32>> = Vec::new();
for arg in inputs {
let arg_type = arg[2];
log!("arg_type: {}, \n {:?}", arg_type, arg,);
if arg_type == 0 {
// if this is path we need to extrude it
output.push(arg.to_vec());
let path_data = wrap_path(arg);
let geometry = extrude_path(path_data, resolution);
output.push(geometry);
continue;
}
output.push(arg.to_vec());
}
concat_args(output.iter().map(|v| v.as_slice()).collect())
pub fn execute(input: (i32, i32), _res: (i32, i32)) -> Vec<i32> {
let inp = read_i32_slice(input);
return inp;
}

View File

@@ -1,11 +1,17 @@
use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute;
use nodarium_utils::{concat_args, split_args};
use nodarium_utils::concat_arg_vecs;
use nodarium_utils::read_i32_slice;
nodarium_definition_file!("src/definition.json");
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(args: &[i32]) -> Vec<i32> {
let args = split_args(args);
concat_args(vec![&[1], args[0], args[1], args[2]])
pub fn execute(min: (i32, i32), max: (i32, i32), seed: (i32, i32)) -> Vec<i32> {
nodarium_utils::log!("random execute start");
concat_arg_vecs(vec![
vec![1],
read_i32_slice(min),
read_i32_slice(max),
read_i32_slice(seed),
])
}

View File

@@ -1,23 +1,26 @@
use glam::{Mat4, Vec3};
use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute;
use nodarium_utils::read_i32_slice;
use nodarium_utils::{
concat_args, evaluate_float, evaluate_int, geometry::wrap_path_mut, log,
split_args,
concat_args, evaluate_float, evaluate_int, geometry::wrap_path_mut, log, split_args,
};
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> {
pub fn execute(
plant: (i32, i32),
axis: (i32, i32),
angle: (i32, i32),
spread: (i32, i32),
) -> Vec<i32> {
log!("DEBUG args: {:?}", plant);
log!("DEBUG args: {:?}", input);
let args = split_args(input);
let plants = split_args(args[0]);
let axis = evaluate_int(args[1]); // 0 =x, 1 = y, 2 = z
let spread = evaluate_int(args[3]);
let arg = read_i32_slice(plant);
let plants = split_args(arg.as_slice());
let axis = evaluate_int(read_i32_slice(axis).as_slice()); // 0 =x, 1 = y, 2 = z
let spread = evaluate_int(read_i32_slice(spread).as_slice());
let output: Vec<Vec<i32>> = plants
.iter()
@@ -32,7 +35,7 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
let path = wrap_path_mut(&mut path_data);
let angle = evaluate_float(args[2]);
let angle = evaluate_float(read_i32_slice(angle).as_slice());
let origin = [path.points[0], path.points[1], path.points[2]];

6
nodes/max/plantarium/shape/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/target
**/*.rs.bk
Cargo.lock
bin/
pkg/
wasm-pack.log

View File

@@ -0,0 +1,12 @@
[package]
name = "shape"
version = "0.1.0"
authors = ["Max Richter <jim-x@web.de>"]
edition = "2018"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nodarium_macros = { version = "0.1.0", path = "../../../../packages/macros" }
nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" }

View File

@@ -0,0 +1,27 @@
{
"id": "max/plantarium/shape",
"outputs": [
"shape"
],
"inputs": {
"shape": {
"type": "shape",
"internal": true,
"value": [
47.8,
100,
47.8,
82.8,
30.9,
69.1,
23.2,
40.7,
27.1,
14.5,
42.5,
0
],
"label": ""
}
}
}

View File

@@ -0,0 +1,10 @@
use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute;
use nodarium_utils::{concat_args, split_args};
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> {
concat_args(split_args(input))
}

View File

@@ -3,30 +3,29 @@ use nodarium_macros::nodarium_execute;
use nodarium_utils::{
evaluate_float, evaluate_int, evaluate_vec3,
geometry::{create_multiple_paths, wrap_multiple_paths},
log, reset_call_count, split_args,
log, reset_call_count,
read_i32_slice, read_i32,
};
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> {
pub fn execute(origin: (i32, i32), _amount: (i32,i32), length: (i32, i32), thickness: (i32, i32), resolution_curve: (i32, i32)) -> Vec<i32> {
reset_call_count();
let args = split_args(input);
let amount = evaluate_int(read_i32_slice(_amount).as_slice()) as usize;
let path_resolution = read_i32(resolution_curve.0) as usize;
let amount = evaluate_int(args[1]) as usize;
let path_resolution = evaluate_int(args[4]) as usize;
log!("stem args: {:?}", args);
log!("stem args: amount={:?}", amount);
let mut stem_data = create_multiple_paths(amount, path_resolution, 1);
let mut stems = wrap_multiple_paths(&mut stem_data);
for stem in stems.iter_mut() {
let origin = evaluate_vec3(args[0]);
let length = evaluate_float(args[2]);
let thickness = evaluate_float(args[3]);
let origin = evaluate_vec3(read_i32_slice(origin).as_slice());
let length = evaluate_float(read_i32_slice(length).as_slice());
let thickness = evaluate_float(read_i32_slice(thickness).as_slice());
let amount_points = stem.points.len() / 4;
for i in 0..amount_points {

View File

@@ -1,21 +1,17 @@
use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute;
use nodarium_utils::{
decode_float, encode_float, evaluate_int, split_args, wrap_arg, log
};
use nodarium_utils::read_i32_slice;
use nodarium_utils::{decode_float, encode_float, evaluate_int, log, wrap_arg};
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> {
let args = split_args(input);
let size = evaluate_int(args[0]);
pub fn execute(size: (i32, i32)) -> Vec<i32> {
let size = evaluate_int(read_i32_slice(size).as_slice());
let decoded = decode_float(size);
let negative_size = encode_float(-decoded);
log!("WASM(triangle): input: {:?} -> {}", args[0],decoded);
log!("WASM(triangle): input: {:?} -> {}", size, decoded);
// [[1,3, x, y, z, x, y,z,x,y,z]];
wrap_arg(&[
@@ -23,7 +19,9 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
3, // 3 vertices
1, // 1 face
// this are the indeces for the face
0, 2, 1,
0,
2,
1,
//
negative_size, // x -> point 1
0, // y
@@ -37,9 +35,14 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
0, // y
size, // z
// this is the normal for the single face 1065353216 == 1.0f encoded is i32
0, 1065353216, 0,
0, 1065353216, 0,
0, 1065353216, 0,
0,
1065353216,
0,
0,
1065353216,
0,
0,
1065353216,
0,
])
}

View File

@@ -1,13 +1,17 @@
use nodarium_macros::nodarium_definition_file;
use nodarium_macros::nodarium_execute;
use nodarium_utils::{concat_args, log, split_args};
use nodarium_utils::concat_args;
use nodarium_utils::log;
use nodarium_utils::read_i32_slice;
nodarium_definition_file!("src/input.json");
#[nodarium_execute]
pub fn execute(input: &[i32]) -> Vec<i32> {
let args = split_args(input);
log!("vec3 input: {:?}", input);
log!("vec3 args: {:?}", args);
concat_args(args)
pub fn execute(x: (i32, i32), y: (i32, i32), z: (i32, i32)) -> Vec<i32> {
log!("vec3 x: {:?}", x);
concat_args(vec![
read_i32_slice(x).as_slice(),
read_i32_slice(y).as_slice(),
read_i32_slice(z).as_slice(),
])
}

View File

@@ -5,8 +5,8 @@
"lint": "pnpm run -r --parallel lint",
"format": "pnpm dprint fmt",
"format:check": "pnpm dprint check",
"test": "pnpm run -r test",
"check": "pnpm run -r check",
"test": "pnpm run -r --parallel test",
"check": "pnpm run -r --parallel check",
"build": "pnpm build:nodes && pnpm build:app",
"build:app": "BASE_PATH=/ui pnpm -r --filter 'ui' build && pnpm -r --filter 'app' build",
"build:nodes": "cargo build --workspace --target wasm32-unknown-unknown --release && rm -rf ./app/static/nodes/max/plantarium/ && mkdir -p ./app/static/nodes/max/plantarium/ && cp -R ./target/wasm32-unknown-unknown/release/*.wasm ./app/static/nodes/max/plantarium/",

View File

@@ -6,96 +6,202 @@ use std::env;
use std::fs;
use std::path::Path;
use syn::parse_macro_input;
use syn::spanned::Spanned;
fn add_line_numbers(input: String) -> String {
return input
input
.split('\n')
.enumerate()
.map(|(i, line)| format!("{:2}: {}", i + 1, line))
.collect::<Vec<String>>()
.join("\n");
.join("\n")
}
fn read_node_definition(file_path: &Path) -> NodeDefinition {
let project_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
let full_path = Path::new(&project_dir).join(file_path);
let json_content = fs::read_to_string(&full_path).unwrap_or_else(|err| {
panic!(
"Failed to read JSON file at '{}/{}': {}",
project_dir,
file_path.to_string_lossy(),
err
)
});
serde_json::from_str(&json_content).unwrap_or_else(|err| {
panic!(
"JSON file contains invalid JSON: \n{} \n{}",
err,
add_line_numbers(json_content.clone())
)
})
}
#[proc_macro_attribute]
pub fn nodarium_execute(_attr: TokenStream, item: TokenStream) -> TokenStream {
let input_fn = parse_macro_input!(item as syn::ItemFn);
let _fn_name = &input_fn.sig.ident;
let _fn_vis = &input_fn.vis;
let fn_name = &input_fn.sig.ident;
let fn_vis = &input_fn.vis;
let fn_body = &input_fn.block;
let inner_fn_name = syn::Ident::new(&format!("__nodarium_inner_{}", fn_name), fn_name.span());
let first_arg_ident = if let Some(syn::FnArg::Typed(pat_type)) = input_fn.sig.inputs.first() {
let def: NodeDefinition = read_node_definition(Path::new("src/input.json"));
let input_count = def.inputs.as_ref().map(|i| i.len()).unwrap_or(0);
validate_signature(&input_fn.sig, input_count, &def);
let input_param_names: Vec<_> = input_fn
.sig
.inputs
.iter()
.filter_map(|arg| {
if let syn::FnArg::Typed(pat_type) = arg {
if let syn::Pat::Ident(pat_ident) = &*pat_type.pat {
&pat_ident.ident
Some(pat_ident.ident.clone())
} else {
panic!("Expected a simple identifier for the first argument");
None
}
} else {
panic!("The execute function must have at least one argument (the input slice)");
None
}
})
.collect();
let param_count = input_fn.sig.inputs.len();
let total_c_params = param_count * 2;
let arg_names: Vec<_> = (0..total_c_params)
.map(|i| syn::Ident::new(&format!("arg{i}"), input_fn.sig.span()))
.collect();
let mut tuple_args = Vec::new();
for i in 0..param_count {
let start_name = &arg_names[i * 2];
let end_name = &arg_names[i * 2 + 1];
let tuple_arg = quote! {
(#start_name, #end_name)
};
tuple_args.push(tuple_arg);
}
// We create a wrapper that handles the C ABI and pointer math
let expanded = quote! {
extern "C" {
fn host_log_panic(ptr: *const u8, len: usize);
fn host_log(ptr: *const u8, len: usize);
fn __nodarium_log(ptr: *const u8, len: usize);
}
fn setup_panic_hook() {
static SET_HOOK: std::sync::Once = std::sync::Once::new();
SET_HOOK.call_once(|| {
std::panic::set_hook(Box::new(|info| {
let msg = info.to_string();
unsafe { host_log_panic(msg.as_ptr(), msg.len()); }
#[cfg(target_arch = "wasm32")]
fn init_panic_hook() {
std::panic::set_hook(Box::new(|_info| {
unsafe {
__nodarium_log(b"PANIC\0".as_ptr(), 5);
}
}));
});
}
#[no_mangle]
pub extern "C" fn __alloc(len: usize) -> *mut i32 {
let mut buf = Vec::with_capacity(len);
let ptr = buf.as_mut_ptr();
std::mem::forget(buf);
ptr
pub extern "C" fn init_allocator() {
nodarium_utils::allocator::ALLOCATOR.init();
}
#[no_mangle]
pub extern "C" fn __free(ptr: *mut i32, len: usize) {
unsafe {
let _ = Vec::from_raw_parts(ptr, 0, len);
}
}
static mut OUTPUT_BUFFER: Vec<i32> = Vec::new();
#[no_mangle]
pub extern "C" fn execute(ptr: *const i32, len: usize) -> *mut i32 {
setup_panic_hook();
// 1. Convert raw pointer to slice
let input = unsafe { core::slice::from_raw_parts(ptr, len) };
// 2. Call the logic (which we define below)
let result_data: Vec<i32> = internal_logic(input);
// 3. Use the static buffer for the result
let result_len = result_data.len();
unsafe {
OUTPUT_BUFFER.clear();
OUTPUT_BUFFER.reserve(result_len + 1);
OUTPUT_BUFFER.push(result_len as i32);
OUTPUT_BUFFER.extend(result_data);
OUTPUT_BUFFER.as_mut_ptr()
}
}
fn internal_logic(#first_arg_ident: &[i32]) -> Vec<i32> {
#fn_vis fn #inner_fn_name(#( #input_param_names: (i32, i32) ),*) -> Vec<i32> {
#fn_body
}
#[no_mangle]
#fn_vis extern "C" fn execute(output_pos: i32, #( #arg_names: i32 ),*) -> i32 {
nodarium_utils::allocator::ALLOCATOR.init();
#[cfg(target_arch = "wasm32")]
init_panic_hook();
nodarium_utils::log!("before_fn");
let result = #inner_fn_name(
#( #tuple_args ),*
);
nodarium_utils::log!("after_fn: result_len={}", result.len());
let len_bytes = result.len() * 4;
unsafe {
let src = result.as_ptr() as *const u8;
let dst = output_pos as *mut u8;
nodarium_utils::log!("writing output_pos={:?} src={:?} len_bytes={:?}", output_pos, src, len_bytes);
dst.copy_from_nonoverlapping(src, len_bytes);
}
len_bytes as i32
}
};
TokenStream::from(expanded)
}
fn validate_signature(fn_sig: &syn::Signature, expected_inputs: usize, def: &NodeDefinition) {
let param_count = fn_sig.inputs.len();
let expected_params = expected_inputs;
if param_count != expected_params {
panic!(
"Execute function has {} parameters but definition has {} inputs\n\
Definition inputs: {:?}\n\
Expected signature:\n\
pub fn execute({}) -> Vec<i32>",
param_count,
expected_inputs,
def.inputs
.as_ref()
.map(|i| i.keys().collect::<Vec<_>>())
.unwrap_or_default(),
(0..expected_inputs)
.map(|i| format!("arg{i}: (i32, i32)"))
.collect::<Vec<_>>()
.join(", ")
);
}
for (i, arg) in fn_sig.inputs.iter().enumerate() {
match arg {
syn::FnArg::Typed(pat_type) => {
let type_str = quote! { #pat_type.ty }.to_string();
let clean_type = type_str
.trim()
.trim_start_matches("_")
.trim_end_matches(".ty")
.trim()
.to_string();
if !clean_type.contains("(") && !clean_type.contains(",") {
panic!(
"Parameter {i} has type '{clean_type}' but should be a tuple (i32, i32) representing (start, end) positions in memory",
);
}
}
syn::FnArg::Receiver(_) => {
panic!("Execute function cannot have 'self' parameter");
}
}
}
match &fn_sig.output {
syn::ReturnType::Type(_, ty) => {
let is_vec = match &**ty {
syn::Type::Path(tp) => tp
.path
.segments
.first()
.map(|seg| seg.ident == "Vec")
.unwrap_or(false),
_ => false,
};
if !is_vec {
panic!("Execute function must return Vec<i32>");
}
}
syn::ReturnType::Default => {
panic!("Execute function must return Vec<i32>");
}
}
}
#[proc_macro]
pub fn nodarium_definition_file(input: TokenStream) -> TokenStream {
let path_lit = syn::parse_macro_input!(input as syn::LitStr);
@@ -105,30 +211,23 @@ pub fn nodarium_definition_file(input: TokenStream) -> TokenStream {
let full_path = Path::new(&project_dir).join(&file_path);
let json_content = fs::read_to_string(&full_path).unwrap_or_else(|err| {
panic!("Failed to read JSON file at '{}/{}': {}", project_dir, file_path, err)
panic!("Failed to read JSON file at '{project_dir}/{file_path}': {err}",)
});
let _: NodeDefinition = serde_json::from_str(&json_content).unwrap_or_else(|err| {
panic!("JSON file contains invalid JSON: \n{} \n{}", err, add_line_numbers(json_content.clone()))
panic!(
"JSON file contains invalid JSON: \n{} \n{}",
err,
add_line_numbers(json_content.clone())
)
});
// We use the span from the input path literal
let bytes = syn::LitByteStr::new(json_content.as_bytes(), path_lit.span());
let len = json_content.len();
let expanded = quote! {
#[link_section = "nodarium_definition"]
static DEFINITION_DATA: [u8; #len] = *#bytes;
#[no_mangle]
pub extern "C" fn get_definition_ptr() -> *const u8 {
DEFINITION_DATA.as_ptr()
}
#[no_mangle]
pub extern "C" fn get_definition_len() -> usize {
DEFINITION_DATA.len()
}
};
TokenStream::from(expanded)

View File

@@ -9,6 +9,7 @@ const DefaultOptionsSchema = z.object({
accepts: z
.array(
z.union([
z.literal('*'),
z.literal('float'),
z.literal('integer'),
z.literal('boolean'),
@@ -26,22 +27,32 @@ const DefaultOptionsSchema = z.object({
export const NodeInputFloatSchema = z.object({
...DefaultOptionsSchema.shape,
type: z.literal('float'),
element: z.literal('slider').optional(),
value: z.number().optional(),
min: z.number().optional(),
max: z.number().optional(),
step: z.number().optional()
});
export const NodeInputColorSchema = z.object({
...DefaultOptionsSchema.shape,
type: z.literal('color'),
value: z.array(z.number()).optional()
});
export const NodeInputIntegerSchema = z.object({
...DefaultOptionsSchema.shape,
type: z.literal('integer'),
element: z.literal('slider').optional(),
value: z.number().optional(),
min: z.number().optional(),
max: z.number().optional()
});
export const NodeInputShapeSchema = z.object({
...DefaultOptionsSchema.shape,
type: z.literal('shape'),
value: z.array(z.number()).optional()
});
export const NodeInputBooleanSchema = z.object({
...DefaultOptionsSchema.shape,
type: z.literal('boolean'),
@@ -83,7 +94,9 @@ export const NodeInputSchema = z.union([
NodeInputSeedSchema,
NodeInputBooleanSchema,
NodeInputFloatSchema,
NodeInputColorSchema,
NodeInputIntegerSchema,
NodeInputShapeSchema,
NodeInputSelectSchema,
NodeInputSeedSchema,
NodeInputVec3Schema,

View File

@@ -103,6 +103,15 @@ pub struct NodeInputVec3 {
pub value: Option<Vec<f64>>,
}
#[derive(Serialize, Deserialize)]
pub struct NodeInputShape {
#[serde(flatten)]
pub default_options: DefaultOptions,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<Vec<f64>>,
}
#[derive(Serialize, Deserialize)]
pub struct NodeInputGeometry {
#[serde(flatten)]
@@ -125,6 +134,7 @@ pub enum NodeInput {
select(NodeInputSelect),
seed(NodeInputSeed),
vec3(NodeInputVec3),
shape(NodeInputShape),
geometry(NodeInputGeometry),
path(NodeInputPath),
}

View File

@@ -21,7 +21,7 @@ export type NodeRuntimeState = {
parents?: NodeInstance[];
children?: NodeInstance[];
inputNodes?: Record<string, NodeInstance>;
type?: NodeDefinition;
type?: NodeDefinition; // we should probably remove this and rely on registry.getNode(nodeType)
downX?: number;
downY?: number;
x?: number;
@@ -65,7 +65,7 @@ export const NodeSchema = z.object({
export type SerializedNode = z.infer<typeof NodeSchema>;
export type NodeDefinition = z.infer<typeof NodeDefinitionSchema> & {
execute(input: Int32Array): Int32Array;
execute(outputPos: number, args: number[]): number;
};
export type Socket = {

View File

@@ -1,31 +1,51 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { type Snippet } from 'svelte';
interface Props {
title?: string;
transparent?: boolean;
children?: Snippet;
open?: boolean;
class?: string;
}
let { title = 'Details', transparent = false, children, open = $bindable(false) }: Props =
$props();
let {
title = 'Details',
transparent = false,
children,
open = $bindable(false),
class: _class
}: Props = $props();
</script>
<details class:transparent bind:open class="text-text outline-1 outline-outline bg-layer-1">
<details
class:transparent
bind:open
class="text-text outline-1 outline-outline bg-layer-2 {_class}"
>
<summary>{title}</summary>
<div class="content">
<div>
{@render children?.()}
</div>
</details>
<style>
details {
border-radius: 2px;
}
summary {
padding: 1em;
padding-left: 20px;
border-radius: 2px;
font-weight: 300;
font-size: 0.9em;
}
details[open] > summary {
border-bottom: solid thin var(--color-outline);
}
details > div {
padding: 1em;
}
details.transparent {
background-color: transparent;
padding: 0;

View File

@@ -1,7 +1,14 @@
<script lang="ts">
import type { NodeInput } from '@nodarium/types';
import { InputCheckbox, InputNumber, InputSelect, InputVec3 } from './index';
import {
InputCheckbox,
InputColor,
InputNumber,
InputSelect,
InputShape,
InputVec3
} from './index';
interface Props {
input: NodeInput;
@@ -19,8 +26,17 @@
max={input?.max}
step={input?.step}
/>
{:else if input.type === 'shape'}
<InputShape bind:value={value as number[]} />
{:else if input.type === 'color'}
<InputColor bind:value={value as [number, number, number]} />
{:else if input.type === 'integer'}
<InputNumber bind:value={value as number} min={input?.min} max={input?.max} step={1} />
<InputNumber
bind:value={value as number}
min={input?.min}
max={input?.max}
step={1}
/>
{:else if input.type === 'boolean'}
<InputCheckbox bind:value={value as boolean} {id} />
{:else if input.type === 'select'}

View File

@@ -11,7 +11,6 @@
@source inline("{hover:,}{bg-,outline-,text-,}selected");
@source inline("{hover:,}{bg-,outline-,text-,}outline{!,}");
@source inline("{hover:,}{bg-,outline-,text-,}connection");
@source inline("{hover:,}{bg-,outline-,text-,}edge");
@source inline("{hover:,}{bg-,outline-,text-,}text");
/* fira-code-300 - latin */
@@ -72,12 +71,12 @@
--color-outline: var(--neutral-400);
--color-connection: #333333;
--color-edge: var(--connection, var(--color-outline));
--color-text: var(--neutral-200);
}
html {
--neutral-050: #f0f0f0;
--neutral-100: #e7e7e7;
--neutral-200: #cecece;
--neutral-300: #7c7c7c;
@@ -89,14 +88,13 @@ html {
--color-layer-0: var(--neutral-900);
--color-layer-1: var(--neutral-500);
--color-layer-2: var(--neutral-400);
--color-layer-3: var(--neutral-200);
--color-layer-3: var(--neutral-300);
--color-active: #ffffff;
--color-selected: #c65a19;
--color-outline: var(--neutral-400);
--color-outline: #3e3e3e;
--color-connection: #333333;
--color-edge: var(--connection, var(--color-outline));
--color-text-color: var(--neutral-200);
}
@@ -110,10 +108,10 @@ body {
html.theme-light {
--color-text: var(--neutral-800);
--color-outline: var(--neutral-300);
--color-layer-0: var(--neutral-100);
--color-layer-0: var(--neutral-050);
--color-layer-1: var(--neutral-100);
--color-layer-2: var(--neutral-200);
--color-layer-3: var(--neutral-500);
--color-layer-3: var(--neutral-300);
--color-active: #000000;
--color-selected: #c65a19;
--color-connection: #888;
@@ -142,15 +140,29 @@ html.theme-catppuccin {
}
html.theme-high-contrast {
--color-text: #ffffff;
--color-text: white;
--color-outline: white;
--color-layer-0: #000000;
--color-layer-0: black;
--color-layer-1: black;
--color-layer-2: #222222;
--color-layer-3: #ffffff;
--color-layer-2: black;
--color-layer-3: white;
--color-active: #00ff00;
--color-selected: #ff0000;
--color-connection: #fff;
}
html.theme-high-contrast-light {
--color-text: black;
--color-outline: black;
--color-layer-0: white;
--color-layer-1: white;
--color-layer-2: white;
--color-layer-3: black;
--color-active: #00ffff;
--color-selected: #ff0000;
--color-connection: black;
}
html.theme-nord {
--color-text: #d8dee9;
--color-outline: #4c566a;

View File

@@ -1,7 +1,9 @@
export { default as Input } from './Input.svelte';
export { default as InputCheckbox } from './inputs/InputCheckbox.svelte';
export { default as InputColor } from './inputs/InputColor.svelte';
export { default as InputNumber } from './inputs/InputNumber.svelte';
export { default as InputSelect } from './inputs/InputSelect.svelte';
export { default as InputShape } from './inputs/InputShape.svelte';
export { default as InputVec3 } from './inputs/InputVec3.svelte';
export { default as Details } from './Details.svelte';

View File

@@ -18,7 +18,7 @@
</script>
<label
class="relative inline-flex h-5.5 w-5.5 cursor-pointer items-center justify-center bg-layer-2 rounded-[5px]"
class="relative inline-flex h-5.5 w-5.5 cursor-pointer items-center justify-center bg-layer-2 outline-1 outline-outline rounded-[5px]"
>
<input
type="checkbox"
@@ -27,7 +27,7 @@
{id}
/>
<span
class="absolute opacity-0 peer-checked:opacity-100 transition-opacity duration-100 flex w-full h-full items-center justify-center"
class="absolute opacity-0 peer-checked:opacity-100 transition-opacity duration-50 flex w-full h-full items-center justify-center"
>
<svg
viewBox="0 0 19 14"

View File

@@ -0,0 +1,68 @@
<script lang="ts">
interface Props {
value?: [number, number, number];
id?: string;
}
let {
value = $bindable([255, 255, 255] as [number, number, number]),
id
}: Props = $props();
let hexValue = $derived(
`#${value.map((c) => c.toString(16).padStart(2, '0')).join('')}`
);
function handleHexInput(e: Event) {
const target = e.target as HTMLInputElement;
let val = target.value.replace(/[^0-9a-fA-F]/g, '');
if (val.length > 6) val = val.slice(0, 6);
if (val.length === 3) {
val = val
.split('')
.map((c) => c + c)
.join('');
}
if (val.length === 6) {
value = [
parseInt(val.slice(0, 2), 16),
parseInt(val.slice(2, 4), 16),
parseInt(val.slice(4, 6), 16)
] as [number, number, number];
}
}
</script>
<div class="flex overflow-hidden rounded-sm border border-outline bg-layer-2 w-min">
<label
class="-ml-px w-8 shrink-0 overflow-hidden"
style={`background-color: ${hexValue}`}
>
<input
type="color"
bind:value={hexValue}
{id}
oninput={handleHexInput}
class="h-full w-8 cursor-pointer appearance-none p-0"
/>
</label>
<div class="flex items-center gap-1 px-2 py-1">
<span class="pointer-events-none text-text opacity-30">#</span>
<input
type="text"
value={hexValue.slice(1)}
{id}
oninput={handleHexInput}
maxlength={6}
class="w-15 bg-transparent text-text outline-none"
/>
</div>
</div>
<style>
input[type="color"] {
margin-top: -1px;
margin-right: -1px;
height: calc(100% + 2px);
}
</style>

View File

@@ -18,7 +18,7 @@
select {
font-family: var(--font-family);
outline: solid 1px var(--color-outline);
padding: 0.8em 1em;
padding: 0.5em 0.8em;
border-radius: 5px;
border: none;
}

View File

@@ -0,0 +1,287 @@
<script lang="ts">
type Props = {
value: number[];
mirror?: boolean;
};
let { value: points = $bindable(), mirror = true }: Props = $props();
let mouseDown = $state<number[]>();
let draggingIndex = $state<number>();
let downCirclePosition = $state<number[]>();
let svgElement = $state<SVGElement>(null!);
let svgRect = $state<DOMRect>(null!);
let isMirroredEvent = $state(false);
const pathD = $derived(calculatePath(points, mirror));
const groupedPoints = $derived(group(points));
function group<T>(arr: T[], size = 2): T[][] {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
const dist = (a: [number, number], b: [number, number]) => Math.hypot(a[0] - b[0], a[1] - b[1]);
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
const round = (v: number) => Math.floor(v * 10) / 10;
const getPt = (i: number) => [points[i * 2], points[i * 2 + 1]] as [number, number];
$effect(() => {
if (!points.length) {
points = [
47.8,
100,
47.8,
82.8,
30.9,
69.1,
23.2,
40.7,
27.1,
14.5,
42.5,
0
];
}
});
$effect(() => {
if (mirror) {
const _points: [number, number, number][] = [];
for (let i = 0; i < points.length / 2; i++) {
const pt = [...getPt(i), i] as [number, number, number];
if (pt[0] > 50) {
pt[0] = 100 - pt[0];
}
_points.push(pt);
}
const sortedPoints = _points.sort((a, b) => {
if (a[1] !== b[1]) return b[1] - a[1];
return a[0] - b[0];
});
const newIndices = new Map(sortedPoints.map((p, i) => [p[2], i]));
const sorted = sortedPoints.map((p) => [p[0], p[1]]).flat();
let sortChanged = false;
for (let i = 0; i < sorted.length; i++) {
if (sorted[i] !== points[i]) {
sortChanged = true;
break;
}
}
if (sortChanged) {
points = sorted;
draggingIndex = newIndices.get(draggingIndex || 0) || 0;
}
}
});
function insertBetween(newPt: [number, number]): number {
const count = points.length / 2;
if (count < 2) {
points = [...points, ...newPt];
return count;
}
let minDist = Infinity;
let insertIdx = 0;
for (let i = 0; i < count - 1; i++) {
const a = getPt(i);
const b = getPt(i + 1);
const d = dist(newPt, a) + dist(newPt, b) - dist(a, b);
if (d < minDist) {
minDist = d;
insertIdx = i + 1;
}
}
points.splice(insertIdx * 2, 0, newPt[0], newPt[1]);
return insertIdx;
}
function calculatePath(pts: number[], mirror = false): string {
if (pts.length === 0) return '';
const arr = [...pts];
let d = `M ${arr[0]} ${arr[1]}`;
for (let i = 2; i < arr.length; i += 2) {
d += ` L ${arr[i]} ${arr[i + 1]}`;
}
if (mirror) {
for (let i = arr.length - 2; i >= 0; i -= 2) {
const x = 100 - arr[i];
d += ` L ${x} ${arr[i + 1]}`;
}
}
d += ' Z';
return d;
}
function handleMouseMove(ev: MouseEvent) {
if (
mouseDown === undefined
|| draggingIndex === undefined
|| !downCirclePosition
) {
return;
}
let vx = (mouseDown[0] - ev.clientX) * (100 / svgRect.width);
let vy = (mouseDown[1] - ev.clientY) * (100 / svgRect.height);
if (ev.shiftKey) {
vx /= 10;
vy /= 10;
}
let x = downCirclePosition[0] + (isMirroredEvent ? 1 : -1) * vx;
let y = downCirclePosition[1] - vy;
x = clamp(x, 0, mirror ? 50 : 100);
y = clamp(y, 0, 100);
points[draggingIndex * 2] = round(x);
points[draggingIndex * 2 + 1] = round(y);
}
function handleMouseDown(ev: MouseEvent) {
ev.preventDefault();
isMirroredEvent = false;
svgRect = svgElement.getBoundingClientRect();
mouseDown = [ev.clientX, ev.clientY];
const indexText = (ev.target as SVGCircleElement).dataset.index;
const x = ((ev.clientX - svgRect.left) / svgRect.width) * 100;
const y = ((ev.clientY - svgRect.top) / svgRect.height) * 100;
isMirroredEvent = mirror && x > 50;
if (indexText !== undefined) {
draggingIndex = parseInt(indexText);
downCirclePosition = getPt(draggingIndex);
} else {
draggingIndex = undefined;
const pt = [round(clamp(x, 0, 100)), round(clamp(y, 0, 100))] as [
number,
number
];
if (isMirroredEvent) {
pt[0] = 100 - pt[0];
}
draggingIndex = insertBetween(pt);
downCirclePosition = pt;
}
}
function handleMouseUp() {
mouseDown = undefined;
draggingIndex = undefined;
}
function handleContextMenu(ev: MouseEvent) {
const indexText = (ev.target as HTMLElement).dataset?.index;
if (indexText !== undefined) {
ev.preventDefault();
ev.stopImmediatePropagation();
const index = parseInt(indexText);
draggingIndex = undefined;
points.splice(index * 2, 2);
}
}
</script>
<svelte:window
onmousemove={handleMouseMove}
onmouseup={handleMouseUp}
oncontextmenu={handleContextMenu}
/>
<div class="wrapper" class:mirrored={mirror}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<svg
width="100"
height="100"
viewBox="0 0 100 100"
bind:this={svgElement}
aria-label="Interactive 2D Shape Editor"
onmousedown={handleMouseDown}
>
<path d={pathD} style:fill="var(--color-layer-3)" style:opacity={0.3} />
<path d={pathD} fill="none" stroke="var(--color-layer-3)" />
{#if mirror}
{#each groupedPoints as p, i (i)}
{@const x = 100 - p[0]}
{@const y = p[1]}
<circle
class:active={isMirroredEvent && draggingIndex === i}
data-index={i}
cx={x}
cy={y}
r={3}
>
</circle>
{/each}
{/if}
{#each groupedPoints as p, i (i)}
<circle
class:active={!isMirroredEvent && draggingIndex === i}
data-index={i}
cx={p[0]}
cy={p[1]}
r={3}
>
</circle>
{/each}
</svg>
</div>
<style>
.wrapper {
width: 100%;
aspect-ratio: 1;
background-color: var(--color-layer-2);
padding: 7px;
border-radius: 5px;
outline: solid thin var(--color-outline);
}
svg {
height: 100%;
width: 100%;
overflow: visible;
}
circle {
cursor: pointer;
stroke: transparent;
transition: fill 0.2s ease;
stroke-width: 1px;
stroke: var(--color-layer-3);
fill: var(--color-layer-2);
opacity: 0;
transition: opacity 0.2s ease;
}
svg:hover circle {
opacity: 1;
}
circle.active,
circle:hover {
fill: var(--color-layer-3);
}
</style>

View File

@@ -1,7 +1,18 @@
<script lang="ts">
import '$lib/app.css';
import { Details, InputCheckbox, InputNumber, InputSelect, InputVec3, ShortCut } from '$lib';
import {
Details,
InputCheckbox,
InputColor,
InputNumber,
InputSelect,
InputShape,
InputVec3,
ShortCut
} from '$lib';
import Section from './Section.svelte';
import Theme from './Theme.svelte';
import ThemeSelector from './ThemeSelector.svelte';
let intValue = $state(0);
let floatValue = $state(0.2);
@@ -10,61 +21,23 @@
const options = ['strawberry', 'raspberry', 'chickpeas'];
let selectValue = $state(0);
const d = $derived(options[selectValue]);
let checked = $state(false);
let colorValue = $state<[number, number, number]>([59, 130, 246]);
let mirrorShape = $state(true);
let detailsOpen = $state(false);
const themes = [
'dark',
'light',
'solarized',
'catppuccin',
'high-contrast',
'nord',
'dracula'
];
let themeIndex = $state(0);
$effect(() => {
const classList = document.documentElement.classList;
for (const c of classList) {
if (c.startsWith('theme-')) document.documentElement.classList.remove(c);
}
document.documentElement.classList.add(`theme-${themes[themeIndex]}`);
});
const colors = [
'layer-0',
'layer-1',
'layer-2',
'layer-3',
'active',
'selected',
'outline',
'connection',
'edge',
'text'
];
let points = $state([]);
let theme = $state('dark');
</script>
<main class="flex flex-col gap-8 py-8">
<div class="flex gap-4">
<h1 class="text-4xl">@nodarium/ui</h1>
<InputSelect bind:value={themeIndex} options={themes}></InputSelect>
<ThemeSelector bind:theme />
</div>
<Section title="Colors">
<table>
<tbody>
{#each colors as color (color)}
<tr>
<td>
<div class="w-6 h-6 mr-2 my-1 rounded-sm outline-1 bg-{color}"></div>
</td>
<td>{color}</td>
</tr>
{/each}
</tbody>
</table>
<Section title="InputNumber">
<Theme />
</Section>
<Section title="InputNumber">
@@ -90,6 +63,23 @@
<InputCheckbox bind:value={checked} />
</Section>
<Section title="Color" value={colorValue}>
<InputColor bind:value={colorValue} />
</Section>
<Section title="Shape">
{#snippet header()}
<label class="flex gap-2">
<InputCheckbox bind:value={mirrorShape} />
<p>mirror</p>
</label>
<p>{JSON.stringify(points)}</p>
{/snippet}
<div style:width="300px">
<InputShape bind:value={points} mirror={mirrorShape} />
</div>
</Section>
<Section title="Details" value={detailsOpen}>
<Details title="More Information" bind:open={detailsOpen}>
<p>Here is some more information that was previously hidden.</p>

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import { type Snippet } from 'svelte';
let { title, value, children, class: _class } = $props<{
let { title, value, header, children, class: _class } = $props<{
title?: string;
value?: unknown;
header?: Snippet;
children?: Snippet;
class?: string;
}>();
@@ -11,7 +12,13 @@
<section class="border-outline border-1/2 bg-layer-1 rounded border mb-4 p-4 flex flex-col gap-4 {_class}">
<h3 class="flex gap-2 font-bold">
{title}
<p class="font-normal! opacity-50!">{value}</p>
<div class="flex gap-4 w-full font-normal opacity-50 max-w-[75%] whitespace-pre overflow-hidden text-clip">
{#if header}
{@render header()}
{:else}
{value}
{/if}
</div>
</h3>
<div>
{@render children()}

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import { InputColor } from '$lib';
const colors = [
'layer-0',
'layer-1',
'layer-2',
'layer-3',
'active',
'selected',
'outline',
'connection',
'text'
];
type CustomColors = {
text: [number, number, number];
outline: [number, number, number];
'layer-0': [number, number, number];
'layer-1': [number, number, number];
'layer-2': [number, number, number];
'layer-3': [number, number, number];
active: [number, number, number];
selected: [number, number, number];
connection: [number, number, number];
};
type CustomColorKey = keyof CustomColors;
let customColors = $state<CustomColors>({
text: [205, 214, 244],
outline: [62, 62, 79],
'layer-0': [6, 6, 27],
'layer-1': [23, 23, 46],
'layer-2': [49, 50, 68],
'layer-3': [168, 170, 200],
active: [0, 0, 0],
selected: [38, 139, 210],
connection: [131, 148, 150]
});
const themeCss = $derived.by(() => {
return `<style>html.theme-custom{
${
Object.keys(customColors)
.map((v) => {
return `--color-${v}: rgb(${customColors[v as CustomColorKey].join(',')});`;
})
.join('\n')
}
</style>`;
});
</script>
<svelte:head>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html themeCss}
</svelte:head>
<table>
<thead>
<tr>
<th>Color</th>
<th>Name</th>
<th>Custom</th>
</tr>
</thead>
<tbody>
{#each colors as color (color)}
<tr>
<td>
<div class="w-6 h-6 mr-2 my-1 rounded-sm outline-1 bg-{color}"></div>
</td>
<td>{color}</td>
<td>
<InputColor bind:value={customColors[color as CustomColorKey]} />
</td>
</tr>
{/each}
</tbody>
</table>
<style>
table {
border-spacing: 5px;
border-collapse: separate;
text-align: left;
margin-left: 5px;
}

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { InputSelect } from '$lib';
const themes = [
'dark',
'light',
'solarized',
'catppuccin',
'high-contrast',
'high-contrast-light',
'nord',
'dracula',
'custom'
];
let { theme = $bindable() } = $props();
let themeIndex = $state(0);
$effect(() => {
theme = themes[themeIndex];
const classList = document.documentElement.classList;
for (const c of classList) {
if (c.startsWith('theme-')) document.documentElement.classList.remove(c);
}
document.documentElement.classList.add(`theme-${themes[themeIndex]}`);
});
</script>
<InputSelect bind:value={themeIndex} options={themes}></InputSelect>

View File

@@ -9,6 +9,10 @@ description = "A collection of utilities for Nodarium"
[lib]
crate-type = ["rlib"]
[features]
default = ["std"]
std = []
[dependencies]
glam = "0.30.10"
noise = "0.9.0"

View File

@@ -0,0 +1,68 @@
use core::alloc::{GlobalAlloc, Layout};
use core::sync::atomic::{AtomicUsize, Ordering};
extern "C" {
fn __wasm_memory_size() -> usize;
fn __nodarium_manual_end() -> usize;
}
#[allow(dead_code)]
const WASM_PAGE_SIZE: usize = 64 * 1024;
pub struct UpwardBumpAllocator {
heap_base: AtomicUsize,
}
impl Default for UpwardBumpAllocator {
fn default() -> Self {
Self::new()
}
}
impl UpwardBumpAllocator {
pub const fn new() -> Self {
Self {
heap_base: AtomicUsize::new(0),
}
}
#[allow(dead_code)]
pub fn init(&self) {
// Start heap at 10000 to leave space for data sections
self.heap_base.store(10000, Ordering::Relaxed);
}
}
#[global_allocator]
pub static ALLOCATOR: UpwardBumpAllocator = UpwardBumpAllocator::new();
unsafe impl GlobalAlloc for UpwardBumpAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let align = layout.align();
let size = layout.size();
let mut current = self.heap_base.load(Ordering::Relaxed);
loop {
let aligned = (current + align - 1) & !(align - 1);
let new_current = aligned + size;
let manual_end = unsafe { __nodarium_manual_end() };
if new_current > manual_end {
return core::ptr::null_mut();
}
match self.heap_base.compare_exchange(
current,
new_current,
Ordering::SeqCst,
Ordering::Relaxed,
) {
Ok(_) => return aligned as *mut u8,
Err(next) => current = next,
}
}
}
unsafe fn dealloc(&self, _: *mut u8, _: Layout) {}
}

View File

@@ -1,3 +1,5 @@
use crate::log;
pub fn encode_float(f: f32) -> i32 {
// Convert f32 to u32 using to_bits, then safely cast to i32
let bits = f.to_bits();
@@ -10,6 +12,52 @@ pub fn decode_float(bits: i32) -> f32 {
f32::from_bits(bits)
}
#[inline]
pub fn read_i32(ptr: i32) -> i32 {
log!("read_i32 ptr: {:?}", ptr);
unsafe {
let _ptr = ptr as *const i32;
*_ptr
}
}
#[inline]
pub fn read_f32(ptr: i32) -> f32 {
unsafe {
let _ptr = ptr as *const i32;
f32::from_bits(*_ptr as u32)
}
}
#[inline]
pub fn read_i32_slice(range: (i32, i32)) -> Vec<i32> {
log!("read_i32_slice ptr: {:?}", range);
let (start, end) = range;
assert!(end >= start);
let byte_len = (end - start) as usize;
assert!(byte_len % 4 == 0);
unsafe {
let ptr = start as *const i32;
let len = byte_len / 4;
std::slice::from_raw_parts(ptr, len).to_vec()
}
}
#[inline]
pub fn read_f32_slice(range: (i32, i32)) -> Vec<f32> {
let (start, end) = range;
assert!(end >= start);
let byte_len = (end - start) as usize;
assert!(byte_len % 4 == 0);
unsafe {
let ptr = start as *const f32;
let len = byte_len / 4;
std::slice::from_raw_parts(ptr, len).to_vec()
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -1,3 +1,4 @@
pub mod allocator;
mod encoding;
mod nodes;
mod tree;
@@ -8,30 +9,30 @@ pub mod geometry;
extern "C" {
#[cfg(target_arch = "wasm32")]
pub fn host_log(ptr: *const u8, len: usize);
pub fn __nodarium_log(ptr: *const u8, len: usize);
}
#[cfg(debug_assertions)]
// #[cfg(debug_assertions)]
#[macro_export]
macro_rules! log {
($($t:tt)*) => {{
let msg = std::format!($($t)*);
#[cfg(target_arch = "wasm32")]
unsafe {
$crate::host_log(msg.as_ptr(), msg.len());
$crate::__nodarium_log(msg.as_ptr(), msg.len());
}
#[cfg(not(target_arch = "wasm32"))]
println!("{}", msg);
}}
}
#[cfg(not(debug_assertions))]
#[macro_export]
macro_rules! log {
($($arg:tt)*) => {{
// This will expand to nothing in release builds
}};
}
// #[cfg(not(debug_assertions))]
// #[macro_export]
// macro_rules! log {
// ($($arg:tt)*) => {{
// // This will expand to nothing in release builds
// }};
// }
#[allow(dead_code)]
#[rustfmt::skip]

View File

@@ -1,21 +1,22 @@
interface NodariumExports extends WebAssembly.Exports {
memory: WebAssembly.Memory;
execute: (ptr: number, len: number) => number;
__free: (ptr: number, len: number) => void;
__alloc: (len: number) => number;
execute: (outputPos: number, ...args: number[]) => number;
init_allocator: () => void;
}
export function createWasmWrapper(buffer: ArrayBuffer) {
export function createWasmWrapper(buffer: ArrayBuffer, memory: WebAssembly.Memory) {
let exports: NodariumExports;
let end = 0;
const importObject = {
env: {
host_log_panic: (ptr: number, len: number) => {
memory: memory,
__nodarium_log_panic: (ptr: number, len: number) => {
if (!exports) return;
const view = new Uint8Array(exports.memory.buffer, ptr, len);
console.error('RUST PANIC:', new TextDecoder().decode(view));
},
host_log: (ptr: number, len: number) => {
__nodarium_log: (ptr: number, len: number) => {
if (!exports) return;
const view = new Uint8Array(exports.memory.buffer, ptr, len);
console.log('RUST:', new TextDecoder().decode(view));
@@ -26,20 +27,11 @@ export function createWasmWrapper(buffer: ArrayBuffer) {
const module = new WebAssembly.Module(buffer);
const instance = new WebAssembly.Instance(module, importObject);
exports = instance.exports as NodariumExports;
exports.init_allocator();
function execute(args: Int32Array) {
const inPtr = exports.__alloc(args.length);
new Int32Array(exports.memory.buffer).set(args, inPtr / 4);
const outPtr = exports.execute(inPtr, args.length);
const i32Result = new Int32Array(exports.memory.buffer);
const outLen = i32Result[outPtr / 4];
const out = i32Result.slice(outPtr / 4 + 1, outPtr / 4 + 1 + outLen);
exports.__free(inPtr, args.length);
return out;
function execute(outputPos: number, args: number[]): number {
end = outputPos;
return exports.execute(outputPos, ...args);
}
function get_definition() {

220
pnpm-lock.yaml generated
View File

@@ -53,6 +53,9 @@ importers:
jsondiffpatch:
specifier: ^0.7.3
version: 0.7.3
micromark:
specifier: ^4.0.2
version: 4.0.2
tailwindcss:
specifier: ^4.1.18
version: 4.1.18
@@ -1235,6 +1238,9 @@ packages:
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@@ -1250,6 +1256,9 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@22.8.6':
resolution: {integrity: sha512-tosuJYKrIqjQIlVCM4PEGxOmyg3FCPa/fViuJChnGeEIhjA46oy8FMVoF9su1/v8PNs2a8Q0iFNyOx0uOF91nw==}
@@ -1481,6 +1490,9 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
chokidar-cli@https://codeload.github.com/open-cli-tools/chokidar-cli/tar.gz/8dd8a1e8631d377de600f628d819a0cda46c102f:
resolution: {tarball: https://codeload.github.com/open-cli-tools/chokidar-cli/tar.gz/8dd8a1e8631d377de600f628d819a0cda46c102f}
version: 4.0.0
@@ -1592,6 +1604,9 @@ packages:
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
dedent-js@1.0.1:
resolution: {integrity: sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==}
@@ -1617,6 +1632,9 @@ packages:
devalue@5.6.2:
resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==}
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
diet-sprite@0.0.1:
resolution: {integrity: sha512-zSHI2WDAn1wJqJYxcmjWfJv3Iw8oL9reQIbEyx2x2/EZ4/qmUTIo8/5qOCurnAcq61EwtJJaZ0XTy2NRYqpB5A==}
@@ -2155,6 +2173,66 @@ packages:
meshoptimizer@0.22.0:
resolution: {integrity: sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==}
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
micromark-factory-destination@2.0.1:
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
micromark-factory-label@2.0.1:
resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
micromark-factory-space@2.0.1:
resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==}
micromark-factory-title@2.0.1:
resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
micromark-factory-whitespace@2.0.1:
resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
micromark-util-character@2.1.1:
resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
micromark-util-chunked@2.0.1:
resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
micromark-util-classify-character@2.0.1:
resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
micromark-util-combine-extensions@2.0.1:
resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
micromark-util-decode-numeric-character-reference@2.0.2:
resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
micromark-util-encode@2.0.1:
resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
micromark-util-html-tag-name@2.0.1:
resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
micromark-util-normalize-identifier@2.0.1:
resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
micromark-util-resolve-all@2.0.1:
resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
micromark-util-sanitize-uri@2.0.1:
resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
micromark-util-subtokenize@2.1.0:
resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==}
micromark-util-symbol@2.0.1:
resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
micromark-util-types@2.0.2:
resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
micromark@4.0.2:
resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
@@ -3593,6 +3671,10 @@ snapshots:
'@types/cookie@0.6.0': {}
'@types/debug@4.1.12':
dependencies:
'@types/ms': 2.1.0
'@types/deep-eql@4.0.2': {}
'@types/eslint@9.6.1':
@@ -3606,6 +3688,8 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/ms@2.1.0': {}
'@types/node@22.8.6':
dependencies:
undici-types: 6.19.8
@@ -3920,6 +4004,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
character-entities@2.0.2: {}
chokidar-cli@https://codeload.github.com/open-cli-tools/chokidar-cli/tar.gz/8dd8a1e8631d377de600f628d819a0cda46c102f:
dependencies:
chokidar: 3.6.0
@@ -4038,6 +4124,10 @@ snapshots:
decimal.js@10.6.0:
optional: true
decode-named-character-reference@1.3.0:
dependencies:
character-entities: 2.0.2
dedent-js@1.0.1: {}
deep-is@0.1.4: {}
@@ -4053,6 +4143,10 @@ snapshots:
devalue@5.6.2: {}
devlop@1.1.0:
dependencies:
dequal: 2.0.3
diet-sprite@0.0.1: {}
diff@4.0.4:
@@ -4661,6 +4755,132 @@ snapshots:
meshoptimizer@0.22.0: {}
micromark-core-commonmark@2.0.3:
dependencies:
decode-named-character-reference: 1.3.0
devlop: 1.1.0
micromark-factory-destination: 2.0.1
micromark-factory-label: 2.0.1
micromark-factory-space: 2.0.1
micromark-factory-title: 2.0.1
micromark-factory-whitespace: 2.0.1
micromark-util-character: 2.1.1
micromark-util-chunked: 2.0.1
micromark-util-classify-character: 2.0.1
micromark-util-html-tag-name: 2.0.1
micromark-util-normalize-identifier: 2.0.1
micromark-util-resolve-all: 2.0.1
micromark-util-subtokenize: 2.1.0
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-factory-destination@2.0.1:
dependencies:
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-factory-label@2.0.1:
dependencies:
devlop: 1.1.0
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-factory-space@2.0.1:
dependencies:
micromark-util-character: 2.1.1
micromark-util-types: 2.0.2
micromark-factory-title@2.0.1:
dependencies:
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-factory-whitespace@2.0.1:
dependencies:
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-util-character@2.1.1:
dependencies:
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-util-chunked@2.0.1:
dependencies:
micromark-util-symbol: 2.0.1
micromark-util-classify-character@2.0.1:
dependencies:
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-util-combine-extensions@2.0.1:
dependencies:
micromark-util-chunked: 2.0.1
micromark-util-types: 2.0.2
micromark-util-decode-numeric-character-reference@2.0.2:
dependencies:
micromark-util-symbol: 2.0.1
micromark-util-encode@2.0.1: {}
micromark-util-html-tag-name@2.0.1: {}
micromark-util-normalize-identifier@2.0.1:
dependencies:
micromark-util-symbol: 2.0.1
micromark-util-resolve-all@2.0.1:
dependencies:
micromark-util-types: 2.0.2
micromark-util-sanitize-uri@2.0.1:
dependencies:
micromark-util-character: 2.1.1
micromark-util-encode: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-subtokenize@2.1.0:
dependencies:
devlop: 1.1.0
micromark-util-chunked: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-util-symbol@2.0.1: {}
micromark-util-types@2.0.2: {}
micromark@4.0.2:
dependencies:
'@types/debug': 4.1.12
debug: 4.4.3
decode-named-character-reference: 1.3.0
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-chunked: 2.0.1
micromark-util-combine-extensions: 2.0.1
micromark-util-decode-numeric-character-reference: 2.0.2
micromark-util-encode: 2.0.1
micromark-util-normalize-identifier: 2.0.1
micromark-util-resolve-all: 2.0.1
micromark-util-sanitize-uri: 2.0.1
micromark-util-subtokenize: 2.1.0
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
transitivePeerDependencies:
- supports-color
mime-db@1.52.0:
optional: true