35 Commits

Author SHA1 Message Date
fdc4802d68 chore: format
All checks were successful
🚀 Release / release (push) Successful in 3m46s
2026-02-03 15:39:21 +01:00
5a30fce4ec chore(ci): make release script work with sh
Some checks failed
🚀 Release / release (push) Failing after 2m51s
2026-02-03 15:33:25 +01:00
0d0eb65ddd chore(ci): add jq and git to ci docker image 2026-02-03 15:33:12 +01:00
93ca436412 fix(ci): make scripts executable
Some checks failed
🚀 Release / release (push) Failing after 3m36s
2026-02-03 15:18:37 +01:00
ecdb986a96 chore(ci): debug some information
Some checks failed
🚀 Release / release (push) Failing after 3m28s
2026-02-03 15:12:47 +01:00
304abf2866 chore(ci): debug some information
Some checks failed
🚀 Release / release (push) Has been cancelled
2026-02-03 15:09:57 +01:00
a547d86946 chore(ci): debug some information
Some checks failed
🚀 Release / release (push) Has been cancelled
2026-02-03 15:09:22 +01:00
667d140883 docs: add information on how to release to readme
All checks were successful
🚀 Release / release (push) Successful in 3m37s
2026-02-03 14:59:12 +01:00
0ac65fd7a7 feat(ci): add release workflow
All checks were successful
🚀 Release / release (push) Successful in 3m34s
2026-02-03 14:52:24 +01:00
Max Richter
5437e062e1 feat(ci): add release workflow 2026-02-03 14:49:14 +01:00
Max Richter
1015d17afb fix(ci): put pnpm store in home instead of workspace
All checks were successful
🏗️ Build and Deploy / build_and_deploy (push) Successful in 3m45s
2026-02-03 13:15:31 +01:00
Max Richter
fd8e5e92d2 chore: run formatting 2026-02-03 13:14:06 +01:00
a2c2503a8e Merge pull request 'feat/ui-float' (#30) from feat/ui-float into main
Some checks failed
🏗️ Build and Deploy / build_and_deploy (push) Failing after 1m40s
Reviewed-on: #30
2026-02-03 13:11:06 +01:00
Max Richter
e18f75e1b8 refactor(ci): make it simpler 2026-02-03 13:10:08 +01:00
Max Richter
6a178dc3a7 fix(ci): correctly copy ui output to app 2026-02-03 13:07:05 +01:00
Max Richter
76cdfee018 feat(app): merge active node and graph settings panel 2026-02-03 13:04:50 +01:00
Max Richter
b19da950a6 refactor: use tailwind custom colors for themes
Use tailwind v4 @theme block so we can use bg-layer-0 instead of
bg-[--layer-0] for theme colors.
2026-02-03 12:18:44 +01:00
Max Richter
89e4cf8364 chore: use vite for auto building @nodarium/ui instead of chokidar
We already use vite for building and during dev. Can use a custom vite
plugin to automatically package ui after every change, so no need for
chokidar.
2026-02-03 12:18:44 +01:00
Max Richter
a28f15c256 chore(ci): simplify ci step
Some checks failed
🏗️ Build and Deploy / setup (push) Successful in 1m5s
🏗️ Build and Deploy / lint (push) Successful in 1m22s
🏗️ Build and Deploy / build_and_deploy (push) Failing after 1m51s
2026-02-02 17:48:11 +01:00
Max Richter
57e3a707c5 chore(ci): simplify ci step
Some checks failed
🏗️ Build and Deploy / setup (push) Successful in 1m12s
🏗️ Build and Deploy / build_and_deploy (push) Has been cancelled
🏗️ Build and Deploy / lint (push) Has been cancelled
2026-02-02 17:46:04 +01:00
Max Richter
dced7db3ad chore(ci): simplify ci step
Some checks failed
🏗️ Build and Deploy / build_and_deploy (push) Has been cancelled
2026-02-02 17:45:04 +01:00
Max Richter
c2dc538c05 fix(ci): make deploy step work
All checks were successful
🏗️ Build and Deploy / setup (push) Successful in 1m11s
🏗️ Build and Deploy / lint (push) Successful in 36s
🏗️ Build and Deploy / format (push) Successful in 40s
🏗️ Build and Deploy / typecheck (push) Successful in 37s
🏗️ Build and Deploy / build_and_deploy (push) Successful in 1m51s
2026-02-02 17:39:17 +01:00
Max Richter
9484b3599e chore: run formatting on all files
Some checks failed
🏗️ Build and Deploy / setup (push) Successful in 1m10s
🏗️ Build and Deploy / lint (push) Successful in 34s
🏗️ Build and Deploy / format (push) Successful in 38s
🏗️ Build and Deploy / typecheck (push) Successful in 33s
🏗️ Build and Deploy / build_and_deploy (push) Failing after 1m53s
2026-02-02 17:33:01 +01:00
Max Richter
3c168aa9b6 chore: add check script to ui package 2026-02-02 17:31:20 +01:00
Max Richter
812099c55d chore: run formatting
Some checks failed
🏗️ Build and Deploy / setup (push) Successful in 1m5s
🏗️ Build and Deploy / lint (push) Successful in 34s
🏗️ Build and Deploy / format (push) Successful in 40s
🏗️ Build and Deploy / typecheck (push) Failing after 12s
🏗️ Build and Deploy / build_and_deploy (push) Has been skipped
2026-02-02 17:07:43 +01:00
Max Richter
025921aeab feat(ci): run on every branch
Some checks failed
🏗️ Build and Deploy / setup (push) Successful in 1m6s
🏗️ Build and Deploy / lint (push) Successful in 51s
🏗️ Build and Deploy / format (push) Failing after 40s
🏗️ Build and Deploy / typecheck (push) Failing after 14s
🏗️ Build and Deploy / build_and_deploy (push) Has been skipped
2026-02-02 16:56:04 +01:00
Max Richter
abaf5245d3 feat(ci): run on every branch
Some checks failed
🏗️ Build and Deploy / lint (push) Has been cancelled
🏗️ Build and Deploy / format (push) Has been cancelled
🏗️ Build and Deploy / typecheck (push) Has been cancelled
🏗️ Build and Deploy / build_and_deploy (push) Has been cancelled
🏗️ Build and Deploy / setup (push) Has been cancelled
2026-02-02 16:51:20 +01:00
Max Richter
a53cee2d5c feat(ci): run on every branch
Some checks failed
🏗️ Build and Deploy / setup (push) Failing after 1m12s
🏗️ Build and Deploy / format (push) Has been skipped
🏗️ Build and Deploy / typecheck (push) Has been skipped
🏗️ Build and Deploy / lint (push) Has been skipped
🏗️ Build and Deploy / build_and_deploy (push) Has been skipped
2026-02-02 16:46:00 +01:00
Max Richter
7d91e53704 feat(ci): run on every branch
Some checks failed
🏗️ Build and Deploy / setup (push) Failing after 1m16s
🏗️ Build and Deploy / lint (push) Has been skipped
🏗️ Build and Deploy / format (push) Has been skipped
🏗️ Build and Deploy / typecheck (push) Has been skipped
🏗️ Build and Deploy / build_and_deploy (push) Has been skipped
2026-02-02 16:41:14 +01:00
Max Richter
18db3cb9f2 Merge branch 'chore/linting'
Some checks failed
🏗️ Build and Deploy / setup (push) Successful in 1m47s
🏗️ Build and Deploy / lint (push) Failing after 2s
🏗️ Build and Deploy / format (push) Failing after 2s
🏗️ Build and Deploy / typecheck (push) Failing after 1s
🏗️ Build and Deploy / build_and_deploy (push) Has been skipped
2026-02-02 16:32:36 +01:00
Max Richter
b6d3269478 feat(ci): run on every branch 2026-02-02 16:32:01 +01:00
7d18c10007 Merge pull request 'chore: setup linting' (#29) from chore/linting into main
Reviewed-on: #29
2026-02-02 16:28:42 +01:00
Max Richter
6f33cdf066 feat(ci): run on every branch 2026-02-02 16:28:08 +01:00
Max Richter
30e897468a chore: setup linting 2026-02-02 16:22:14 +01:00
Max Richter
137425b31b chore: rename .github to .gitea
Some checks failed
Deploy to GitHub Pages / build_site (push) Has been cancelled
2026-02-02 11:49:41 +01:00
229 changed files with 7290 additions and 7799 deletions

View File

@@ -1,9 +0,0 @@
[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

@@ -10,38 +10,33 @@
"json": {
// https://dprint.dev/plugins/json/config/
},
"markdown": {
},
"toml": {
},
"dockerfile": {
},
"ruff": {
},
"jupyter": {
},
"malva": {
},
"markdown": {},
"toml": {},
"dockerfile": {},
"ruff": {},
"jupyter": {},
"malva": {},
"markup": {
// https://dprint.dev/plugins/markup_fmt/config/
"scriptIndent": true,
"styleIndent": true,
},
"yaml": {
},
"graphql": {
},
"yaml": {},
"graphql": {},
"exec": {
"cwd": "${configDir}",
"commands": [{
"command": "rustfmt",
"exts": ["rs"],
"cacheKeyFiles": [
"rustfmt.toml",
"rust-toolchain.toml",
],
}],
"commands": [
{
"command": "rustfmt",
"exts": [
"rs",
],
"cacheKeyFiles": [
"rustfmt.toml",
"rust-toolchain.toml",
],
},
],
},
"excludes": [
"**/node_modules",

472
.gitea/graphics/nodes.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -0,0 +1,77 @@
#!/bin/sh
set -eu
TAG="$GITHUB_REF_NAME"
VERSION=$(echo "$TAG" | sed 's/^v//')
DATE=$(date +%Y-%m-%d)
echo "🚀 Creating release for $TAG (safe mode)"
# -------------------------------------------------------------------
# 1. Extract release notes from annotated tag
# -------------------------------------------------------------------
NOTES=$(git tag -l "$TAG" --format='%(contents)')
if [ -z "$NOTES" ]; then
echo "❌ Tag message is empty"
exit 1
fi
git checkout main
# -------------------------------------------------------------------
# 2. Update all package.json versions
# -------------------------------------------------------------------
echo "🔧 Updating package.json versions to $VERSION"
find . -name package.json ! -path "*/node_modules/*" | while read file; do
tmp_file="$file.tmp"
jq --arg v "$VERSION" '.version = $v' "$file" >"$tmp_file"
mv "$tmp_file" "$file"
done
# -------------------------------------------------------------------
# 3. Update CHANGELOG.md (prepend)
# -------------------------------------------------------------------
tmp_changelog="CHANGELOG.tmp"
{
echo "## $TAG ($DATE)"
echo ""
echo "$NOTES"
echo ""
echo "---"
echo ""
if [ -f CHANGELOG.md ]; then
cat CHANGELOG.md
fi
} >"$tmp_changelog"
mv "$tmp_changelog" CHANGELOG.md
# -------------------------------------------------------------------
# 4. Create release commit
# -------------------------------------------------------------------
git config user.name "release-bot"
git config user.email "release-bot@ci"
git add CHANGELOG.md $(find . -name package.json ! -path "*/node_modules/*")
# Skip commit if nothing changed
if git diff --cached --quiet; then
echo "No changes to commit for release $TAG"
exit 0
fi
git commit -m "chore(release): $TAG"
# -------------------------------------------------------------------
# 5. Push changes
# -------------------------------------------------------------------
git push origin main
echo "✅ Release commit for $TAG created successfully (tag untouched)"

View File

@@ -0,0 +1,58 @@
name: 🚀 Release
on:
push:
branches: ["*"]
tags: ["*"]
env:
PNPM_CACHE_FOLDER: ~/.pnpm-store
jobs:
release:
runs-on: ubuntu-latest
container: jimfx/nodes:latest
steps:
- name: 📑 Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: 💾 Setup pnpm Cache
uses: actions/cache@v4
with:
path: ${{ env.PNPM_CACHE_FOLDER }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: 📦 Install Dependencies
run: pnpm install --frozen-lockfile --store-dir ${{ env.PNPM_CACHE_FOLDER }}
- name: 🧹 Lint
run: pnpm lint
- name: 🎨 Format Check
run: pnpm format:check
- name: 🧬 Type Check
run: pnpm check
- name: 🛠️ Build
run: pnpm build:deploy
- name: 🚀 Create Release Commit
if: github.ref_type == 'tag'
run: ./.gitea/scripts/create-release.sh
- name: 🏷️ Create Gitea Release
if: github.ref_type == 'tag'
uses: akkuman/gitea-release-action@v1
with:
tag_name: ${{ github.ref_name }}
release_name: Release ${{ github.ref_name }}
body_path: CHANGELOG.md
draft: false
prerelease: false

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -1,38 +0,0 @@
name: Deploy to GitHub Pages
on:
push:
branches: "main"
jobs:
build_site:
runs-on: ubuntu-latest
container: jimfx/nodes:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: build
run: pnpm run build:deploy
- name: 🔑 Configure rclone
run: |
echo "$SSH_PRIVATE_KEY" > /tmp/id_rsa
chmod 600 /tmp/id_rsa
mkdir -p ~/.config/rclone
echo -e "[sftp-remote]\ntype = sftp\nhost = ${SSH_HOST}\nuser = ${SSH_USER}\nport = ${SSH_PORT}\nkey_file = /tmp/id_rsa" > ~/.config/rclone/rclone.conf
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_HOST: ${{ vars.SSH_HOST }}
SSH_PORT: ${{ vars.SSH_PORT }}
SSH_USER: ${{ vars.SSH_USER }}
- name: 🚀 Deploy Changed Files via rclone
run: |
echo "Uploading the rest"
rclone sync --update -v --progress --exclude _astro/** --stats 2s --stats-one-line ./app/build/ sftp-remote:${REMOTE_DIR} --transfers 4
env:
REMOTE_DIR: ${{ vars.REMOTE_DIR }}

8
Cargo.lock generated
View File

@@ -24,14 +24,6 @@ dependencies = [
"nodarium_utils",
]
[[package]]
name = "debug"
version = "0.1.0"
dependencies = [
"nodarium_macros",
"nodarium_utils",
]
[[package]]
name = "float"
version = "0.1.0"

View File

@@ -1,15 +1,19 @@
FROM node:24-alpine
RUN apk add --no-cache --update curl rclone g++
# Install all required packages in one layer
RUN apk add --no-cache curl git jq g++ make
# Set Rust paths
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
# Install Rust, wasm target, and pnpm
RUN curl --silent --show-error --location --fail --retry 3 \
--proto '=https' --tlsv1.2 \
--output /tmp/rustup-init.sh https://sh.rustup.rs \
&& sh /tmp/rustup-init.sh -y --no-modify-path --profile minimal \
&& rm /tmp/rustup-init.sh \
&& rustup target add wasm32-unknown-unknown \
&& npm i -g pnpm
--proto '=https' --tlsv1.2 \
--output /tmp/rustup-init.sh https://sh.rustup.rs \
&& sh /tmp/rustup-init.sh -y --no-modify-path --profile minimal \
&& rm /tmp/rustup-init.sh \
&& rustup target add wasm32-unknown-unknown \
&& rm -rf /usr/local/rustup/toolchains/*/share/doc \
&& npm i -g pnpm

View File

@@ -4,11 +4,11 @@ Nodarium
<a href="https://nodes.max-richter.dev/"><h2 align="center">Nodarium</h2></a>
<p align="center">
<p align="center">
Nodarium is a WebAssembly based visual programming language.
</p>
<img src=".github/graphics/nodes.svg" width="80%"/>
<img src=".gitea/graphics/nodes.svg" width="80%"/>
</div>
@@ -27,6 +27,7 @@ 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
@@ -49,4 +50,29 @@ pnpm dev
### [Now you can create your first node 🤓](./docs/DEVELOPING_NODES.md)
# Releasing
## Creating a Release
1. **Create an annotated tag** with your release notes:
```bash
git tag -a v1.0.0 -m "Release notes for this version"
git push origin v1.0.0
```
2. **The CI workflow will:**
- Run lint, format check, and type check
- Build the project
- Update all `package.json` versions to match the tag
- Generate/update `CHANGELOG.md`
- Create a release commit on `main`
- Publish a Gitea release
## Version Requirements
- Tag must match pattern `v*` (e.g., `v1.0.0`, `v2.3.1`)
- Tag message must not be empty (annotated tag required)
- Tag must be pushed from `main` branch
# Roadmap

View File

@@ -1,783 +0,0 @@
# 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 |

View File

@@ -1,227 +0,0 @@
# 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

View File

@@ -1,294 +0,0 @@
# 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

@@ -25,20 +25,8 @@ FROM nginx:alpine AS runner
RUN rm /etc/nginx/conf.d/default.conf
COPY <<EOF /etc/nginx/conf.d/app.conf
server {
listen 80;
server_name _;
root /app;
index index.html;
location / {
try_files \$uri \$uri/ /index.html;
}
}
EOF
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

10
app/docker/app.conf Normal file
View File

@@ -0,0 +1,10 @@
server {
listen 80;
server_name _;
root /app;
index index.html;
location / {
try_files \$uri \$uri/ /index.html;
}
}

37
app/eslint.config.mjs Normal file
View File

@@ -0,0 +1,37 @@
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import path from 'node:path';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
tsconfigRootDir: import.meta.dirname,
svelteConfig
}
}
}
);

View File

@@ -7,10 +7,13 @@
"dev": "vite dev",
"build": "svelte-kit sync && vite build",
"test": "vitest",
"preview": "vite preview"
"preview": "vite preview",
"format": "dprint fmt -c '../.dprint.jsonc' .",
"format:check": "dprint check -c '../.dprint.jsonc' .",
"lint": "eslint .",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
"@nodarium/registry": "workspace:*",
"@nodarium/ui": "workspace:*",
"@nodarium/utils": "workspace:*",
"@sveltejs/kit": "^2.50.0",
@@ -26,6 +29,8 @@
"wabt": "^1.0.39"
},
"devDependencies": {
"@eslint/compat": "^2.0.2",
"@eslint/js": "^9.39.2",
"@iconify-json/tabler": "^1.2.26",
"@iconify/tailwind4": "^1.2.1",
"@nodarium/types": "workspace:",
@@ -34,10 +39,15 @@
"@tsconfig/svelte": "^5.0.6",
"@types/file-saver": "^2.0.7",
"@types/three": "^0.182.0",
"dprint": "^0.51.1",
"eslint": "^9.39.2",
"eslint-plugin-svelte": "^3.14.0",
"globals": "^17.3.0",
"svelte": "^5.46.4",
"svelte-check": "^4.3.5",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"vite": "^7.3.1",
"vite-plugin-comlink": "^5.3.0",
"vite-plugin-glsl": "^1.5.5",

View File

@@ -2,5 +2,9 @@
@source "../../packages/ui/**/*.svelte";
@plugin "@iconify/tailwind4" {
prefix: "i";
icon-sets: from-folder(custom, "./src/lib/icons")
};
icon-sets: from-folder(custom, "./src/lib/icons");
}
body * {
color: var(--color-text);
}

14
app/src/app.d.ts vendored
View File

@@ -1,13 +1,13 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View File

@@ -1,28 +1,26 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/svelte.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<title>Nodes</title>
<script>
var store = localStorage.getItem('node-settings');
if (store) {
try {
var value = JSON.parse(store);
var themes = ['dark', 'light', 'catppuccin'];
if (themes[value.theme]) {
document.documentElement.classList.add('theme-' + themes[value.theme]);
}
} catch (e) {}
}
</script>
</head>
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/svelte.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<title>Nodes</title>
<script>
var store = localStorage.getItem("node-settings");
if (store) {
try {
var value = JSON.parse(store);
var themes = ["dark", "light", "catppuccin"];
if (themes[value.theme]) {
document.documentElement.classList.add("theme-" + themes[value.theme]);
}
} catch (e) { }
}
</script>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -1,2 +1,2 @@
import { PUBLIC_ANALYTIC_SCRIPT } from "$env/static/public";
import { PUBLIC_ANALYTIC_SCRIPT } from '$env/static/public';
export const ANALYTIC_SCRIPT = PUBLIC_ANALYTIC_SCRIPT;

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import { T } from "@threlte/core";
import BackgroundVert from "./Background.vert";
import BackgroundFrag from "./Background.frag";
import { colors } from "../graph/colors.svelte";
import { appSettings } from "$lib/settings/app-settings.svelte";
import { appSettings } from '$lib/settings/app-settings.svelte';
import { T } from '@threlte/core';
import { colors } from '../graph/colors.svelte';
import BackgroundFrag from './Background.frag';
import BackgroundVert from './Background.vert';
type Props = {
minZoom: number;
@@ -18,7 +18,7 @@
maxZoom = 150,
cameraPosition = [0, 1, 0],
width = globalThis?.innerWidth || 100,
height = globalThis?.innerHeight || 100,
height = globalThis?.innerHeight || 100
}: Props = $props();
let bw = $derived(width / cameraPosition[2]);
@@ -38,25 +38,25 @@
fragmentShader={BackgroundFrag}
uniforms={{
camPos: {
value: [0, 1, 0],
value: [0, 1, 0]
},
backgroundColor: {
value: colors["layer-0"],
value: colors['layer-0']
},
lineColor: {
value: colors["outline"],
value: colors['outline']
},
zoomLimits: {
value: [2, 50],
value: [2, 50]
},
dimensions: {
value: [100, 100],
},
value: [100, 100]
}
}}
uniforms.camPos.value={cameraPosition}
uniforms.backgroundColor.value={appSettings.value.theme &&
colors["layer-0"]}
uniforms.lineColor.value={appSettings.value.theme && colors["outline"]}
uniforms.backgroundColor.value={appSettings.value.theme
&& colors['layer-0']}
uniforms.lineColor.value={appSettings.value.theme && colors['outline']}
uniforms.zoomLimits.value={[minZoom, maxZoom]}
uniforms.dimensions.value={[width, height]}
/>

View File

@@ -116,7 +116,7 @@
</div>
<div class="content">
{#each nodes as node}
{#each nodes as node (node.id)}
<div
class="result"
role="treeitem"
@@ -149,7 +149,7 @@
}
input {
background: var(--layer-0);
background: var(--color-layer-0);
font-family: var(--font-family);
border: none;
border-radius: 5px;
@@ -168,10 +168,10 @@
.add-menu-wrapper {
position: absolute;
background: var(--layer-1);
background: var(--color-layer-1);
border-radius: 7px;
overflow: hidden;
border: solid 2px var(--layer-2);
border: solid 2px var(--color-layer-2);
width: 150px;
}
.content {
@@ -184,14 +184,14 @@
.result {
padding: 1em 0.9em;
border-bottom: solid 1px var(--layer-2);
border-bottom: solid 1px var(--color-layer-2);
opacity: 0.7;
font-size: 0.9em;
cursor: pointer;
}
.result[aria-selected="true"] {
background: var(--layer-2);
background: var(--color-layer-2);
opacity: 1;
}
</style>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { HTML } from "@threlte/extras";
import { HTML } from '@threlte/extras';
type Props = {
p1: { x: number; y: number };
@@ -10,7 +10,7 @@
const {
p1 = { x: 0, y: 0 },
p2 = { x: 0, y: 0 },
cameraPosition = [0, 1, 0],
cameraPosition = [0, 1, 0]
}: Props = $props();
const width = $derived(Math.abs(p1.x - p2.x) * cameraPosition[2]);
@@ -24,14 +24,15 @@
<div
class="box-selection"
style={`width: ${width}px; height: ${height}px;`}
></div>
>
</div>
</HTML>
<style>
.box-selection {
width: 40px;
height: 20px;
border: solid 2px var(--outline);
border: solid 2px var(--color-outline);
border-style: dashed;
border-radius: 2px;
}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { T } from "@threlte/core";
import { type OrthographicCamera } from "three";
import { T } from '@threlte/core';
import { type OrthographicCamera } from 'three';
type Props = {
camera: OrthographicCamera;
position: [number, number, number];

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { NodeDefinition, NodeRegistry } from "@nodarium/types";
import { onMount } from "svelte";
import type { NodeDefinition, NodeRegistry } from '@nodarium/types';
import { onMount } from 'svelte';
let mx = $state(0);
let my = $state(0);
@@ -20,15 +20,15 @@
my = ev.clientY;
if (!target) return;
const closest = target?.closest?.("[data-node-type]");
const closest = target?.closest?.('[data-node-type]');
if (!closest) {
node = undefined;
return;
}
let nodeType = closest.getAttribute("data-node-type");
let nodeInput = closest.getAttribute("data-node-input");
let nodeType = closest.getAttribute('data-node-type');
let nodeInput = closest.getAttribute('data-node-input');
if (!nodeType) {
node = undefined;
@@ -40,9 +40,9 @@
onMount(() => {
const style = wrapper.parentElement?.style;
style?.setProperty("cursor", "help");
style?.setProperty('cursor', 'help');
return () => {
style?.removeProperty("cursor");
style?.removeProperty('cursor');
};
});
</script>
@@ -53,12 +53,12 @@
class="help-wrapper p-4"
class:visible={node}
bind:clientWidth={width}
style="--my:{my}px; --mx:{Math.min(mx, window.innerWidth - width - 20)}px;"
style="--my: {my}px; --mx: {Math.min(mx, window.innerWidth - width - 20)}px"
bind:this={wrapper}
>
<p class="m-0 text-light opacity-40 flex items-center gap-3 mb-4">
<span class="i-tabler-help block w-4 h-4"></span>
{node?.id.split("/").at(-1) || "Help"}
{node?.id.split('/').at(-1) || 'Help'}
{#if input}
<span>> {input}</span>
{/if}
@@ -77,7 +77,7 @@
{#if !input}
<div>
<span class="i-tabler-arrow-right opacity-30">-></span>
{node?.outputs?.map((o) => o).join(", ") ?? "nothing"}
{node?.outputs?.map((o) => o).join(', ') ?? 'nothing'}
</div>
{/if}
{/if}
@@ -88,12 +88,12 @@
position: fixed;
pointer-events: none;
transform: translate(var(--mx), var(--my));
background: var(--layer-1);
background: var(--color-layer-1);
border-radius: 5px;
top: 10px;
left: 10px;
max-width: 250px;
border: 1px solid var(--outline);
border: 1px solid var(--color-outline);
z-index: 10000;
display: none;
}

View File

@@ -1,11 +1,32 @@
<script lang="ts">
import { MeshLineGeometry, MeshLineMaterial } from "@threlte/extras";
import { points, lines, rects } from "./store.js";
import { T } from "@threlte/core";
import { Color } from "three";
import type { Box } from '@nodarium/types';
import { T } from '@threlte/core';
import { MeshLineGeometry, MeshLineMaterial } from '@threlte/extras';
import { Color, Vector3 } from 'three';
import { lines, points, rects } from './store.js';
type Line = {
points: Vector3[];
color?: Color;
};
function getEachKey(value: Vector3 | Box | Line): string {
if ('x' in value) {
return [value.x, value.y, value.z].join('-');
}
if ('minX' in value) {
return [value.maxX, value.minX, value.maxY, value.minY].join('-');
}
if ('points' in value) {
return getEachKey(value.points[Math.floor(value.points.length / 2)]);
}
return '';
}
</script>
{#each $points as point}
{#each $points as point (getEachKey(point))}
<T.Mesh
position.x={point.x}
position.y={point.y}
@@ -17,7 +38,7 @@
</T.Mesh>
{/each}
{#each $rects as rect, i}
{#each $rects as rect, i (getEachKey(rect))}
<T.Mesh
position.x={(rect.minX + rect.maxX) / 2}
position.y={0}
@@ -32,11 +53,11 @@
</T.Mesh>
{/each}
{#each $lines as line}
{#each $lines as line (getEachKey(line))}
<T.Mesh position.y={1}>
<MeshLineGeometry points={line.points} />
<MeshLineMaterial
color={line.color || "red"}
color={line.color || 'red'}
linewidth={1}
attenuate={false}
/>

View File

@@ -1,16 +1,18 @@
<script module lang="ts">
import { colors } from "../graph/colors.svelte";
import { colors } from '../graph/colors.svelte';
const circleMaterial = new MeshBasicMaterial({
color: colors.edge.clone(),
toneMapped: false,
toneMapped: false
});
let lineColor = $state(colors.edge.clone().convertSRGBToLinear());
$effect.root(() => {
$effect(() => {
appSettings.value.theme;
if (appSettings.value.theme === undefined) {
return;
}
circleMaterial.color = colors.edge.clone().convertSRGBToLinear();
lineColor = colors.edge.clone().convertSRGBToLinear();
});
@@ -20,19 +22,19 @@
new Vector2(0, 0),
new Vector2(0, 0),
new Vector2(0, 0),
new Vector2(0, 0),
new Vector2(0, 0)
);
</script>
<script lang="ts">
import { T } from "@threlte/core";
import { MeshLineGeometry, MeshLineMaterial } from "@threlte/extras";
import { MeshBasicMaterial, Vector3 } from "three";
import { CubicBezierCurve } from "three/src/extras/curves/CubicBezierCurve.js";
import { Vector2 } from "three/src/math/Vector2.js";
import { appSettings } from "$lib/settings/app-settings.svelte";
import { getGraphState } from "../graph-state.svelte";
import { onDestroy } from "svelte";
import { appSettings } from '$lib/settings/app-settings.svelte';
import { T } from '@threlte/core';
import { MeshLineGeometry, MeshLineMaterial } from '@threlte/extras';
import { onDestroy } from 'svelte';
import { MeshBasicMaterial, Vector3 } from 'three';
import { CubicBezierCurve } from 'three/src/extras/curves/CubicBezierCurve.js';
import { Vector2 } from 'three/src/math/Vector2.js';
import { getGraphState } from '../graph-state.svelte';
const graphState = getGraphState();
@@ -63,7 +65,7 @@
lastId = curveId;
const length = Math.floor(
Math.sqrt(Math.pow(new_x, 2) + Math.pow(new_y, 2)) / 4,
Math.sqrt(Math.pow(new_x, 2) + Math.pow(new_y, 2)) / 4
);
const samples = Math.max(length * 16, 10);
@@ -83,7 +85,7 @@
id,
x1,
y1,
$state.snapshot(points) as unknown as Vector3[],
$state.snapshot(points) as unknown as Vector3[]
);
}
}

View File

@@ -1,23 +1,23 @@
export const setXYZXYZ = (array: number[], location: number, x: number, y: number, z: number) => {
array[location + 0] = x
array[location + 1] = y
array[location + 2] = z
array[location + 0] = x;
array[location + 1] = y;
array[location + 2] = z;
array[location + 3] = x
array[location + 4] = y
array[location + 5] = z
}
array[location + 3] = x;
array[location + 4] = y;
array[location + 5] = z;
};
export const setXY = (array: number[], location: number, x: number, y: number) => {
array[location + 0] = x
array[location + 1] = y
}
array[location + 0] = x;
array[location + 1] = y;
};
export const setXYZ = (array: number[], location: number, x: number, y: number, z: number) => {
array[location + 0] = x
array[location + 1] = y
array[location + 2] = z
}
array[location + 0] = x;
array[location + 1] = y;
array[location + 2] = z;
};
export const setXYZW = (
array: number[],
@@ -27,8 +27,8 @@ export const setXYZW = (
z: number,
w: number
) => {
array[location + 0] = x
array[location + 1] = y
array[location + 2] = z
array[location + 3] = w
}
array[location + 0] = x;
array[location + 1] = y;
array[location + 2] = z;
array[location + 3] = w;
};

View File

@@ -1,5 +1,5 @@
import throttle from '$lib/helpers/throttle';
import { RemoteNodeRegistry } from '@nodarium/registry';
import { RemoteNodeRegistry } from '$lib/node-registry/index';
import type {
Edge,
Graph,
@@ -12,7 +12,7 @@ import type {
} from '@nodarium/types';
import { fastHashString } from '@nodarium/utils';
import { createLogger } from '@nodarium/utils';
import { SvelteMap } from 'svelte/reactivity';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import EventEmitter from './helpers/EventEmitter';
import { HistoryManager } from './history-manager';
@@ -23,16 +23,16 @@ const remoteRegistry = new RemoteNodeRegistry('');
const clone = 'structuredClone' in self
? self.structuredClone
: (args: any) => JSON.parse(JSON.stringify(args));
: (args: unknown) => JSON.parse(JSON.stringify(args));
export function areSocketsCompatible(
function areSocketsCompatible(
output: string | undefined,
inputs: string | (string | undefined)[] | undefined
) {
if (Array.isArray(inputs) && output) {
return inputs.includes('*') || inputs.includes(output);
return inputs.includes(output);
}
return inputs === output || inputs === '*';
return inputs === output;
}
function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) {
@@ -57,7 +57,7 @@ function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) {
export class GraphManager extends EventEmitter<{
save: Graph;
result: any;
result: unknown;
settings: {
types: Record<string, NodeInput>;
values: Record<string, unknown>;
@@ -79,7 +79,7 @@ export class GraphManager extends EventEmitter<{
currentUndoGroup: number | null = null;
inputSockets = $derived.by(() => {
const s = new Set<string>();
const s = new SvelteSet<string>();
for (const edge of this.edges) {
s.add(`${edge[2].id}-${edge[3]}`);
}
@@ -122,7 +122,7 @@ export class GraphManager extends EventEmitter<{
private lastSettingsHash = 0;
setSettings(settings: Record<string, unknown>) {
let hash = fastHashString(JSON.stringify(settings));
const hash = fastHashString(JSON.stringify(settings));
if (hash === this.lastSettingsHash) return;
this.lastSettingsHash = hash;
@@ -136,7 +136,7 @@ export class GraphManager extends EventEmitter<{
}
getLinkedNodes(node: NodeInstance) {
const nodes = new Set<NodeInstance>();
const nodes = new SvelteSet<NodeInstance>();
const stack = [node];
while (stack.length) {
const n = stack.pop();
@@ -171,7 +171,7 @@ export class GraphManager extends EventEmitter<{
const targetInput = toNode.state?.type?.inputs?.[toSocketKey];
const targetAcceptedTypes = [targetInput?.type, ...(targetInput?.accepts || [])];
const bestInputEntry = draggedInputs.find(([_, input]) => {
const bestInputEntry = draggedInputs.find(([, input]) => {
const accepted = [input.type, ...(input.accepts || [])];
return areSocketsCompatible(edgeOutputSocketType, accepted);
});
@@ -209,7 +209,7 @@ export class GraphManager extends EventEmitter<{
const draggedOutputs = draggedNode.state.type.outputs ?? [];
// Optimization: Pre-calculate parents to avoid cycles
const parentIds = new Set(this.getParentsOfNode(draggedNode).map(n => n.id));
const parentIds = new SvelteSet(this.getParentsOfNode(draggedNode).map(n => n.id));
return this.edges.filter((edge) => {
const [fromNode, fromSocketIdx, toNode, toSocketKey] = edge;
@@ -266,9 +266,16 @@ export class GraphManager extends EventEmitter<{
}
private _init(graph: Graph) {
const nodes = new Map(
const nodes = new SvelteMap(
graph.nodes.map((node) => {
return [node.id, node as NodeInstance];
const nodeType = this.registry.getNode(node.type);
const n = node as NodeInstance;
if (nodeType) {
n.state = {
type: nodeType
};
}
return [node.id, n];
})
);
@@ -293,30 +300,6 @@ 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();
@@ -325,17 +308,26 @@ export class GraphManager extends EventEmitter<{
this.status = 'loading';
this.id = graph.id;
const nodeIds = Array.from(new Set([...graph.nodes.map((n) => n.type)]));
logger.info('loading graph', {
nodes: graph.nodes,
edges: graph.edges,
id: graph.id,
ids: nodeIds
});
logger.info('loading graph', { nodes: graph.nodes, edges: graph.edges, id: graph.id });
const nodeIds = Array.from(new SvelteSet([...graph.nodes.map((n) => n.type)]));
await this.registry.load(nodeIds);
// Fetch all nodes from all collections of the loaded nodes
const allCollections = new SvelteSet<`${string}/${string}`>();
for (const id of nodeIds) {
const [user, collection] = id.split('/');
allCollections.add(`${user}/${collection}`);
}
for (const collection of allCollections) {
remoteRegistry
.fetchCollection(collection)
.then((collection: { nodes: { id: NodeId }[] }) => {
const ids = collection.nodes.map((n) => n.id);
return this.registry.load(ids);
});
}
logger.info('loaded node types', this.registry.getAllNodes());
for (const node of this.graph.nodes) {
@@ -362,7 +354,7 @@ export class GraphManager extends EventEmitter<{
for (const type of types) {
if (type.inputs) {
for (const key in type.inputs) {
let settingId = type.inputs[key].setting;
const settingId = type.inputs[key].setting;
if (settingId) {
settingTypes[settingId] = {
__node_type: type.id,
@@ -392,9 +384,7 @@ 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() {
@@ -419,7 +409,7 @@ export class GraphManager extends EventEmitter<{
const settingValues = this.settings;
if (nodeType.inputs) {
for (const key in nodeType.inputs) {
let settingId = nodeType.inputs[key].setting;
const settingId = nodeType.inputs[key].setting;
if (settingId) {
settingTypes[settingId] = nodeType.inputs[key];
if (
@@ -501,10 +491,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[i];
const [inputName, input] = inputs[0];
for (let o = 0; o < outputs.length; o++) {
const output = outputs[o];
if (input.type === output || input.type === '*') {
const output = outputs[0];
if (input.type === output) {
return this.createEdge(from, o, to, inputName);
}
}
@@ -517,7 +507,7 @@ export class GraphManager extends EventEmitter<{
createGraph(nodes: NodeInstance[], edges: [number, number, number, string][]) {
// map old ids to new ids
const idMap = new Map<number, number>();
const idMap = new SvelteMap<number, number>();
let startId = this.createNodeId();
@@ -606,14 +596,11 @@ 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 = fromType?.outputs?.[fromSocket];
const toSocketType = [toType?.inputs?.[toSocket]?.type];
if (toType?.inputs?.[toSocket]?.accepts) {
toSocketType.push(...(toType?.inputs?.[toSocket]?.accepts || []));
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 || []));
}
if (!areSocketsCompatible(fromSocketType, toSocketType)) {
@@ -736,16 +723,15 @@ export class GraphManager extends EventEmitter<{
}
getPossibleSockets({ node, index }: Socket): [NodeInstance, string | number][] {
const nodeType = this.registry.getNode(node.type);
const nodeType = node?.state?.type;
if (!nodeType) return [];
console.log({ index });
const sockets: [NodeInstance, string | number][] = [];
// if index is a string, we are an input looking for outputs
if (typeof index === 'string') {
// filter out self and child nodes
const children = new Set(this.getChildren(node).map((n) => n.id));
const children = new SvelteSet(this.getChildren(node).map((n) => n.id));
const nodes = this.getAllNodes().filter(
(n) => n.id !== node.id && !children.has(n.id)
);
@@ -753,7 +739,7 @@ export class GraphManager extends EventEmitter<{
const ownType = nodeType?.inputs?.[index].type;
for (const node of nodes) {
const nodeType = this.registry.getNode(node.type);
const nodeType = node?.state?.type;
const inputs = nodeType?.outputs;
if (!inputs) continue;
for (let index = 0; index < inputs.length; index++) {
@@ -766,13 +752,13 @@ export class GraphManager extends EventEmitter<{
// if index is a number, we are an output looking for inputs
// filter out self and parent nodes
const parents = new Set(this.getParentsOfNode(node).map((n) => n.id));
const parents = new SvelteSet(this.getParentsOfNode(node).map((n) => n.id));
const nodes = this.getAllNodes().filter(
(n) => n.id !== node.id && !parents.has(n.id)
);
// get edges from this socket
const edges = new Map(
const edges = new SvelteMap(
this.getEdgesFromNode(node)
.filter((e) => e[1] === index)
.map((e) => [e[2].id, e[3]])
@@ -781,7 +767,7 @@ export class GraphManager extends EventEmitter<{
const ownType = nodeType.outputs?.[index];
for (const node of nodes) {
const inputs = this.registry.getNode(node.type)?.inputs;
const inputs = node?.state?.type?.inputs;
if (!inputs) continue;
for (const key in inputs) {
const otherType = [inputs[key].type];
@@ -797,7 +783,6 @@ export class GraphManager extends EventEmitter<{
}
}
console.log(`Found ${sockets.length} possible sockets`, sockets);
return sockets;
}

View File

@@ -1,6 +1,6 @@
import type { NodeInstance, Socket } from '@nodarium/types';
import { getContext, setContext } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type { OrthographicCamera, Vector3 } from 'three';
import type { GraphManager } from './graph-manager.svelte';
@@ -54,7 +54,7 @@ export class GraphState {
height = $state(100);
hoveredEdgeId = $state<string | null>(null);
edges = new Map<string, EdgeData>();
edges = new SvelteMap<string, EdgeData>();
wrapper = $state<HTMLDivElement>(null!);
rect: DOMRect = $derived(
@@ -100,7 +100,7 @@ export class GraphState {
hoveredSocket = $state<Socket | null>(null);
possibleSockets = $state<Socket[]>([]);
possibleSocketIds = $derived(
new Set(this.possibleSockets.map((s) => `${s.node.id}-${s.index}`))
new SvelteSet(this.possibleSockets.map((s) => `${s.node.id}-${s.index}`))
);
getEdges() {
@@ -155,7 +155,6 @@ export class GraphState {
return 4;
} else if (z > 11) {
return 2;
} else {
}
return 1;
}
@@ -170,14 +169,11 @@ export class GraphState {
(node?.state?.y ?? node.position[1]) + 2.5 + 10 * index
];
} else {
const inputs = node.state.type?.inputs || this.graph.registry.getNode(node.type)?.inputs
|| {};
const _index = Object.keys(inputs).indexOf(index);
const pos = [
const _index = Object.keys(node.state?.type?.inputs || {}).indexOf(index);
return [
node?.state?.x ?? node.position[0],
(node?.state?.y ?? node.position[1]) + 10 + 10 * _index
] as [number, number];
return pos;
];
}
}
@@ -196,7 +192,7 @@ export class GraphState {
(p) =>
p !== 'seed'
&& node?.inputs
&& !('setting' in node?.inputs?.[p])
&& !(node?.inputs?.[p] !== undefined && 'setting' in node.inputs[p])
&& node.inputs[p].hidden !== true
).length;
this.nodeHeightCache[nodeTypeId] = height;
@@ -253,7 +249,7 @@ export class GraphState {
let { node, index, position } = socket;
// if the socket is an input socket -> remove existing edges
// remove existing edge
if (typeof index === 'string') {
const edges = this.graph.getEdgesToNode(node);
for (const edge of edges) {
@@ -297,8 +293,8 @@ export class GraphState {
getNodeIdFromEvent(event: MouseEvent) {
let clickedNodeId = -1;
let mx = event.clientX - this.rect.x;
let my = event.clientY - this.rect.y;
const mx = event.clientX - this.rect.x;
const my = event.clientY - this.rect.y;
if (event.button === 0) {
// check if the clicked element is a node

View File

@@ -1,23 +1,23 @@
<script lang="ts">
import type { Edge, NodeInstance } from "@nodarium/types";
import { Canvas } from "@threlte/core";
import { HTML } from "@threlte/extras";
import { createKeyMap } from "../../helpers/createKeyMap";
import Background from "../background/Background.svelte";
import AddMenu from "../components/AddMenu.svelte";
import BoxSelection from "../components/BoxSelection.svelte";
import Camera from "../components/Camera.svelte";
import HelpView from "../components/HelpView.svelte";
import Debug from "../debug/Debug.svelte";
import EdgeEl from "../edges/Edge.svelte";
import { getGraphManager, getGraphState } from "../graph-state.svelte";
import NodeEl from "../node/Node.svelte";
import { maxZoom, minZoom } from "./constants";
import { FileDropEventManager } from "./drop.events";
import { MouseEventManager } from "./mouse.events";
import type { Edge, NodeInstance } from '@nodarium/types';
import { Canvas } from '@threlte/core';
import { HTML } from '@threlte/extras';
import { createKeyMap } from '../../helpers/createKeyMap';
import Background from '../background/Background.svelte';
import AddMenu from '../components/AddMenu.svelte';
import BoxSelection from '../components/BoxSelection.svelte';
import Camera from '../components/Camera.svelte';
import HelpView from '../components/HelpView.svelte';
import Debug from '../debug/Debug.svelte';
import EdgeEl from '../edges/Edge.svelte';
import { getGraphManager, getGraphState } from '../graph-state.svelte';
import NodeEl from '../node/Node.svelte';
import { maxZoom, minZoom } from './constants';
import { FileDropEventManager } from './drop.events';
import { MouseEventManager } from './mouse.events';
const {
keymap,
keymap
}: {
keymap: ReturnType<typeof createKeyMap>;
} = $props();
@@ -45,19 +45,18 @@
const newNode = graph.createNode({
type: node.type,
position: node.position,
props: node.props,
props: node.props
});
if (!newNode) return;
if (graphState.activeSocket) {
if (typeof graphState.activeSocket.index === "number") {
const socketType =
graphState.activeSocket.node.state?.type?.outputs?.[
graphState.activeSocket.index
];
if (typeof graphState.activeSocket.index === 'number') {
const socketType = graphState.activeSocket.node.state?.type?.outputs?.[
graphState.activeSocket.index
];
const input = Object.entries(newNode?.state?.type?.inputs || {}).find(
(inp) => inp[1].type === socketType || inp[1].type === "*",
(inp) => inp[1].type === socketType
);
if (input) {
@@ -65,18 +64,17 @@
graphState.activeSocket.node,
graphState.activeSocket.index,
newNode,
input[0],
input[0]
);
}
} else {
const socketType =
graphState.activeSocket.node.state?.type?.inputs?.[
graphState.activeSocket.index
];
const socketType = graphState.activeSocket.node.state?.type?.inputs?.[
graphState.activeSocket.index
];
const output = newNode.state?.type?.outputs?.find((out) => {
if (socketType?.type === out) return true;
if (socketType?.accepts?.includes(out as any)) return true;
if ((socketType?.accepts as string[])?.includes(out)) return true;
return false;
});
@@ -85,7 +83,7 @@
newNode,
output.indexOf(output),
graphState.activeSocket.node,
graphState.activeSocket.index,
graphState.activeSocket.index
);
}
}
@@ -148,20 +146,18 @@
<BoxSelection
cameraPosition={graphState.cameraPosition}
p1={{
x:
graphState.cameraPosition[0] +
(graphState.mouseDown[0] - graphState.width / 2) /
graphState.cameraPosition[2],
y:
graphState.cameraPosition[1] +
(graphState.mouseDown[1] - graphState.height / 2) /
graphState.cameraPosition[2],
x: graphState.cameraPosition[0]
+ (graphState.mouseDown[0] - graphState.width / 2)
/ graphState.cameraPosition[2],
y: graphState.cameraPosition[1]
+ (graphState.mouseDown[1] - graphState.height / 2)
/ graphState.cameraPosition[2]
}}
p2={{ x: graphState.mousePosition[0], y: graphState.mousePosition[1] }}
/>
{/if}
{#if graph.status === "idle"}
{#if graph.status === 'idle'}
{#if graphState.addMenuPosition}
<AddMenu onnode={handleNodeCreation} />
{/if}
@@ -176,7 +172,7 @@
/>
{/if}
{#each graph.edges as edge}
{#each graph.edges as edge (edge)}
{@const [x1, y1, x2, y2] = getEdgePosition(edge)}
<EdgeEl
id={graph.getEdgeId(edge)}
@@ -208,9 +204,9 @@
{/each}
</div>
</HTML>
{:else if graph.status === "loading"}
{:else if graph.status === 'loading'}
<span>Loading</span>
{:else if graph.status === "error"}
{:else if graph.status === 'error'}
<span>Error</span>
{/if}
</Canvas>
@@ -248,7 +244,7 @@
z-index: 1;
width: 100%;
height: 100%;
background: var(--layer-2);
background: var(--color-layer-2);
opacity: 0;
}
input:disabled {
@@ -268,8 +264,8 @@
border-radius: 5px;
width: calc(100% - 20px);
height: calc(100% - 25px);
border: dashed 4px var(--layer-2);
background: var(--layer-1);
border: dashed 4px var(--color-layer-2);
background: var(--color-layer-1);
opacity: 0.5;
}
</style>

View File

@@ -1,46 +1,43 @@
<script lang="ts">
import type { Graph, NodeInstance, NodeRegistry } from "@nodarium/types";
import GraphEl from "./Graph.svelte";
import { GraphManager } from "../graph-manager.svelte";
import { createKeyMap } from "$lib/helpers/createKeyMap";
import {
GraphState,
setGraphManager,
setGraphState,
} from "../graph-state.svelte";
import { setupKeymaps } from "../keymaps";
import { createKeyMap } from '$lib/helpers/createKeyMap';
import type { Graph, NodeInstance, NodeRegistry } from '@nodarium/types';
import { GraphManager } from '../graph-manager.svelte';
import { GraphState, setGraphManager, setGraphState } from '../graph-state.svelte';
import { setupKeymaps } from '../keymaps';
import GraphEl from './Graph.svelte';
type Props = {
graph?: Graph;
registry: NodeRegistry;
settings?: Record<string, any>;
settings?: Record<string, unknown>;
activeNode?: NodeInstance;
showGrid?: boolean;
snapToGrid?: boolean;
showHelp?: boolean;
settingTypes?: Record<string, any>;
settingTypes?: Record<string, unknown>;
onsave?: (save: Graph) => void;
onresult?: (result: any) => void;
onresult?: (result: unknown) => void;
};
let {
graph,
registry,
settings = $bindable(),
activeNode = $bindable(),
showGrid = $bindable(true),
snapToGrid = $bindable(true),
showHelp = $bindable(false),
settings = $bindable(),
settingTypes = $bindable(),
onsave,
onresult,
onresult
}: Props = $props();
export const keymap = createKeyMap([]);
// svelte-ignore state_referenced_locally
export const manager = new GraphManager(registry);
setGraphManager(manager);
@@ -70,14 +67,14 @@
}
});
manager.on("settings", (_settings) => {
manager.on('settings', (_settings) => {
settingTypes = { ...settingTypes, ..._settings.types };
settings = _settings.values;
});
manager.on("result", (result) => onresult?.(result));
manager.on('result', (result) => onresult?.(result));
manager.on("save", (save) => onsave?.(save));
manager.on('save', (save) => onsave?.(save));
$effect(() => {
if (graph) {

View File

@@ -1,33 +1,33 @@
import { appSettings } from "$lib/settings/app-settings.svelte";
import { Color, LinearSRGBColorSpace } from "three";
import { appSettings } from '$lib/settings/app-settings.svelte';
import { Color, LinearSRGBColorSpace } from 'three';
const variables = [
"layer-0",
"layer-1",
"layer-2",
"layer-3",
"outline",
"active",
"selected",
"edge",
'layer-0',
'layer-1',
'layer-2',
'layer-3',
'outline',
'active',
'selected',
'edge'
] as const;
function getColor(variable: (typeof variables)[number]) {
const style = getComputedStyle(document.body.parentElement!);
let color = style.getPropertyValue(`--${variable}`);
const color = style.getPropertyValue(`--color-${variable}`);
return new Color().setStyle(color, LinearSRGBColorSpace);
}
export const colors = Object.fromEntries(
variables.map((v) => [v, getColor(v)]),
variables.map((v) => [v, getColor(v)])
) as Record<(typeof variables)[number], Color>;
$effect.root(() => {
$effect(() => {
if (!appSettings.value.theme || !("getComputedStyle" in globalThis)) return;
if (!appSettings.value.theme || !('getComputedStyle' in globalThis)) return;
const style = getComputedStyle(document.body.parentElement!);
for (const v of variables) {
const hex = style.getPropertyValue(`--${v}`);
const hex = style.getPropertyValue(`--color-${v}`);
colors[v].setStyle(hex, LinearSRGBColorSpace);
}
});

View File

@@ -6,7 +6,7 @@ export class FileDropEventManager {
constructor(
private graph: GraphManager,
private state: GraphState
) { }
) {}
handleFileDrop(event: DragEvent) {
event.preventDefault();
@@ -17,19 +17,21 @@ export class FileDropEventManager {
let my = event.clientY - this.state.rect.y;
if (nodeId) {
let nodeOffsetX = event.dataTransfer.getData('data/node-offset-x');
let nodeOffsetY = event.dataTransfer.getData('data/node-offset-y');
const nodeOffsetX = event.dataTransfer.getData('data/node-offset-x');
const nodeOffsetY = event.dataTransfer.getData('data/node-offset-y');
if (nodeOffsetX && nodeOffsetY) {
mx += parseInt(nodeOffsetX);
my += parseInt(nodeOffsetY);
}
let props = {};
let rawNodeProps = event.dataTransfer.getData('data/node-props');
const rawNodeProps = event.dataTransfer.getData('data/node-props');
if (rawNodeProps) {
try {
props = JSON.parse(rawNodeProps);
} catch (e) { }
} catch (e) {
console.error('Failed to parse node dropped', e);
}
}
const pos = this.state.projectScreenToWorld(mx, my);
@@ -48,7 +50,7 @@ export class FileDropEventManager {
reader.onload = async (e) => {
const buffer = e.target?.result;
if (buffer?.constructor === ArrayBuffer) {
const nodeType = await this.graph.registry.register(buffer);
const nodeType = await this.graph.registry.register(nodeId, buffer);
this.graph.createNode({
type: nodeType.id,

View File

@@ -7,7 +7,7 @@ export class EdgeInteractionManager {
constructor(
private graph: GraphManager,
private state: GraphState
) { }
) {}
private MIN_DISTANCE = 3;
@@ -85,7 +85,14 @@ export class EdgeInteractionManager {
const pointAy = edge.points[i].z + edge.y1;
const pointBx = edge.points[i + DENSITY].x + edge.x1;
const pointBy = edge.points[i + DENSITY].z + edge.y1;
const distance = distanceFromPointToSegment(pointAx, pointAy, pointBx, pointBy, mouseX, mouseY);
const distance = distanceFromPointToSegment(
pointAx,
pointAy,
pointBx,
pointBy,
mouseX,
mouseY
);
if (distance < this.MIN_DISTANCE) {
if (distance < hoveredEdgeDistance) {
hoveredEdgeDistance = distance;

View File

@@ -177,8 +177,8 @@ export class MouseEventManager {
}
}
let mx = event.clientX - this.state.rect.x;
let my = event.clientY - this.state.rect.y;
const mx = event.clientX - this.state.rect.x;
const my = event.clientY - this.state.rect.y;
this.state.mouseDown = [mx, my];
this.state.cameraDown[0] = this.state.cameraPosition[0];
@@ -242,8 +242,8 @@ export class MouseEventManager {
}
handleWindowMouseMove(event: MouseEvent) {
let mx = event.clientX - this.state.rect.x;
let my = event.clientY - this.state.rect.y;
const mx = event.clientX - this.state.rect.x;
const my = event.clientY - this.state.rect.y;
this.state.mousePosition = this.state.projectScreenToWorld(mx, my);
this.state.hoveredNodeId = this.state.getNodeIdFromEvent(event);
@@ -352,9 +352,9 @@ export class MouseEventManager {
// here we are handling panning of camera
this.state.isPanning = true;
let newX = this.state.cameraDown[0]
const newX = this.state.cameraDown[0]
- (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2];
let newY = this.state.cameraDown[1]
const newY = this.state.cameraDown[1]
- (my - this.state.mouseDown[1]) / this.state.cameraPosition[2];
this.state.cameraPosition[0] = newX;
@@ -392,6 +392,7 @@ export class MouseEventManager {
/ zoomRatio;
this.state.cameraPosition[1] = this.state.mousePosition[1]
- (this.state.mousePosition[1] - this.state.cameraPosition[1])
/ zoomRatio, this.state.cameraPosition[2] = newZoom;
/ zoomRatio;
this.state.cameraPosition[2] = newZoom;
}
}

View File

@@ -1,11 +1,11 @@
import throttle from "$lib/helpers/throttle";
import throttle from '$lib/helpers/throttle';
type EventMap = Record<string, unknown>;
type EventKey<T extends EventMap> = string & keyof T;
type EventReceiver<T> = (params: T, stuff?: Record<string, unknown>) => unknown;
export default class EventEmitter<
T extends EventMap = { [key: string]: unknown },
T extends EventMap = { [key: string]: unknown }
> {
index = 0;
public eventMap: T = {} as T;
@@ -32,11 +32,11 @@ export default class EventEmitter<
public on<K extends EventKey<T>>(
event: K,
cb: EventReceiver<T[K]>,
throttleTimer = 0,
throttleTimer = 0
) {
if (throttleTimer > 0) cb = throttle(cb, throttleTimer);
const cbs = Object.assign(this.cbs, {
[event]: [...(this.cbs[event] || []), cb],
[event]: [...(this.cbs[event] || []), cb]
});
this.cbs = cbs;
@@ -54,10 +54,10 @@ export default class EventEmitter<
*/
public once<K extends EventKey<T>>(
event: K,
cb: EventReceiver<T[K]>,
cb: EventReceiver<T[K]>
): () => void {
const cbsOnce = Object.assign(this.cbsOnce, {
[event]: [...(this.cbsOnce[event] || []), cb],
[event]: [...(this.cbsOnce[event] || []), cb]
});
this.cbsOnce = cbsOnce;

View File

@@ -36,7 +36,8 @@ export function createNodePath({
aspectRatio = 1
} = {}) {
return `M0,${cornerTop}
${cornerTop
${
cornerTop
? ` V${cornerTop}
Q0,0 ${cornerTop * aspectRatio},0
H${100 - cornerTop * aspectRatio}
@@ -45,40 +46,37 @@ export function createNodePath({
: ` V0
H100
`
}
}
V${y - height / 2}
${rightBump
${
rightBump
? ` C${100 - depth},${y - height / 2} ${100 - depth},${y + height / 2} 100,${y + height / 2}`
: ` H100`
}
${cornerBottom
}
${
cornerBottom
? ` V${100 - cornerBottom}
Q100,100 ${100 - cornerBottom * aspectRatio},100
H${cornerBottom * aspectRatio}
Q0,100 0,${100 - cornerBottom}
`
: `${leftBump ? `V100 H0` : `V100`}`
}
${leftBump
? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${y - height / 2}`
}
${
leftBump
? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${
y - height / 2
}`
: ` H0`
}
}
Z`.replace(/\s+/g, ' ');
}
export const debounce = (fn: Function, ms = 300) => {
let timeoutId: ReturnType<typeof setTimeout>;
return function (this: any, ...args: any[]) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), ms);
};
};
export const clone: <T>(v: T) => T = 'structedClone' in globalThis
? globalThis.structuredClone
: (obj) => JSON.parse(JSON.stringify(obj));
export function withSubComponents<A, B extends Record<string, any>>(
export function withSubComponents<A, B extends Record<string, unknown>>(
component: A,
subcomponents: B
): A & B {

View File

@@ -1,15 +1,14 @@
import { writable, type Writable } from "svelte/store";
import { type Writable, writable } from 'svelte/store';
function isStore(v: unknown): v is Writable<unknown> {
return v !== null && typeof v === "object" && "subscribe" in v && "set" in v;
return v !== null && typeof v === 'object' && 'subscribe' in v && 'set' in v;
}
const storeIds: Map<string, ReturnType<typeof createLocalStore>> = new Map();
const HAS_LOCALSTORAGE = "localStorage" in globalThis;
const HAS_LOCALSTORAGE = 'localStorage' in globalThis;
function createLocalStore<T>(key: string, initialValue: T | Writable<T>) {
let store: Writable<T>;
if (HAS_LOCALSTORAGE) {
@@ -36,18 +35,15 @@ function createLocalStore<T>(key: string, initialValue: T | Writable<T>) {
subscribe: store.subscribe,
set: store.set,
update: store.update
}
};
}
export default function localStore<T>(key: string, initialValue: T | Writable<T>): Writable<T> {
if (storeIds.has(key)) return storeIds.get(key) as Writable<T>;
const store = createLocalStore(key, initialValue)
const store = createLocalStore(key, initialValue);
storeIds.set(key, store);
return store
return store;
}

View File

@@ -4,7 +4,7 @@ import { create, type Delta } from 'jsondiffpatch';
import { clone } from './helpers/index.js';
const diff = create({
objectHash: function (obj, index) {
objectHash: function(obj, index) {
if (obj === null) return obj;
if ('id' in obj) return obj.id as string;
if ('_id' in obj) return obj._id as string;

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import type { NodeInstance } from "@nodarium/types";
import { getGraphState } from "../graph-state.svelte";
import { T } from "@threlte/core";
import { type Mesh } from "three";
import NodeFrag from "./Node.frag";
import NodeVert from "./Node.vert";
import NodeHtml from "./NodeHTML.svelte";
import { colors } from "../graph/colors.svelte";
import { appSettings } from "$lib/settings/app-settings.svelte";
import { appSettings } from '$lib/settings/app-settings.svelte';
import type { NodeInstance } from '@nodarium/types';
import { T } from '@threlte/core';
import { type Mesh } from 'three';
import { getGraphState } from '../graph-state.svelte';
import { colors } from '../graph/colors.svelte';
import NodeFrag from './Node.frag';
import NodeVert from './Node.vert';
import NodeHtml from './NodeHTML.svelte';
const graphState = getGraphState();
@@ -21,12 +21,12 @@
const isActive = $derived(graphState.activeNodeId === node.id);
const isSelected = $derived(graphState.selectedNodes.has(node.id));
const strokeColor = $derived(
appSettings.value.theme &&
(isSelected
appSettings.value.theme
&& (isSelected
? colors.selected
: isActive
? colors.active
: colors.outline),
? colors.active
: colors.outline)
);
let meshRef: Mesh | undefined = $state();
@@ -55,12 +55,12 @@
fragmentShader={NodeFrag}
transparent
uniforms={{
uColorBright: { value: colors["layer-2"] },
uColorDark: { value: colors["layer-1"] },
uColorBright: { value: colors['layer-2'] },
uColorDark: { value: colors['layer-1'] },
uStrokeColor: { value: colors.outline.clone() },
uStrokeWidth: { value: 1.0 },
uWidth: { value: 20 },
uHeight: { value: height },
uHeight: { value: height }
}}
uniforms.uStrokeColor.value={strokeColor.clone()}
uniforms.uStrokeWidth.value={(7 - z) / 3}

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import type { NodeInstance } from "@nodarium/types";
import NodeHeader from "./NodeHeader.svelte";
import NodeParameter from "./NodeParameter.svelte";
import { getGraphState } from "../graph-state.svelte";
import type { NodeInstance } from '@nodarium/types';
import { getGraphState } from '../graph-state.svelte';
import NodeHeader from './NodeHeader.svelte';
import NodeParameter from './NodeParameter.svelte';
let ref: HTMLDivElement;
@@ -10,7 +10,7 @@
type Props = {
node: NodeInstance;
position?: "absolute" | "fixed" | "relative";
position?: 'absolute' | 'fixed' | 'relative';
isActive?: boolean;
isSelected?: boolean;
inView?: boolean;
@@ -19,11 +19,11 @@
let {
node = $bindable(),
position = "absolute",
position = 'absolute',
isActive = false,
isSelected = false,
inView = true,
z = 2,
z = 2
}: Props = $props();
// If we dont have a random offset, all nodes becom visible at the same zoom level -> stuttering
@@ -31,12 +31,11 @@
const zLimit = 2 - zOffset;
const parameters = Object.entries(node.state?.type?.inputs || {}).filter(
(p) =>
p[1].type !== "seed" && !("setting" in p[1]) && p[1]?.hidden !== true,
(p) => p[1].type !== 'seed' && !('setting' in p[1]) && p[1]?.hidden !== true
);
$effect(() => {
if ("state" in node && !node.state.ref) {
if ('state' in node && !node.state.ref) {
node.state.ref = ref;
graphState?.updateNodePosition(node);
}
@@ -47,7 +46,7 @@
class="node {position}"
class:active={isActive}
style:--cz={z + zOffset}
style:display={inView && z > zLimit ? "block" : "none"}
style:display={inView && z > zLimit ? 'block' : 'none'}
class:selected={isSelected}
class:out-of-view={!inView}
data-node-id={node.id}
@@ -56,7 +55,7 @@
>
<NodeHeader {node} />
{#each parameters as [key, value], i}
{#each parameters as [key, value], i (key)}
<NodeParameter
bind:node
id={key}
@@ -72,22 +71,22 @@
user-select: none !important;
cursor: pointer;
width: 200px;
color: var(--text-color);
color: var(--color-text);
transform: translate3d(var(--nx), var(--ny), 0);
z-index: 1;
opacity: calc((var(--cz) - 2.5) / 3.5);
font-weight: 300;
--stroke: var(--outline);
--stroke: var(--color-outline);
--stroke-width: 2px;
}
.node.active {
--stroke: var(--active);
--stroke: var(--color-active);
--stroke-width: 2px;
}
.node.selected {
--stroke: var(--selected);
--stroke: var(--color-selected);
--stroke-width: 2px;
}
</style>

View File

@@ -1,8 +1,7 @@
<script lang="ts">
import { getGraphState } from "../graph-state.svelte";
import { createNodePath } from "../helpers/index.js";
import type { NodeInstance } from "@nodarium/types";
import { appSettings } from "$lib/settings/app-settings.svelte";
import type { NodeInstance } from '@nodarium/types';
import { getGraphState } from '../graph-state.svelte';
import { createNodePath } from '../helpers/index.js';
const graphState = getGraphState();
@@ -11,52 +10,52 @@
function handleMouseDown(event: MouseEvent) {
event.stopPropagation();
event.preventDefault();
if ("state" in node) {
if ('state' in node) {
graphState.setDownSocket?.({
node,
index: 0,
position: graphState.getSocketPosition?.(node, 0),
position: graphState.getSocketPosition?.(node, 0)
});
}
}
const cornerTop = 10;
const rightBump = !!node?.state?.type?.outputs?.length;
const rightBump = $derived(!!node?.state?.type?.outputs?.length);
const aspectRatio = 0.25;
const path = createNodePath({
depth: 5.5,
height: 34,
y: 49,
cornerTop,
rightBump,
aspectRatio,
});
const pathHover = createNodePath({
depth: 8.5,
height: 50,
y: 49,
cornerTop,
rightBump,
aspectRatio,
});
const path = $derived(
createNodePath({
depth: 5.5,
height: 34,
y: 49,
cornerTop,
rightBump,
aspectRatio
})
);
const pathHover = $derived(
createNodePath({
depth: 8.5,
height: 50,
y: 49,
cornerTop,
rightBump,
aspectRatio
})
);
</script>
<div class="wrapper" data-node-id={node.id} data-node-type={node.type}>
<div class="content">
{#if appSettings.value.nodeInterface.showNodeIds}
<span class="bg-white text-black! mr-2 px-1 rounded-sm opacity-30"
>{node.id}</span
>
{/if}
{node.type.split("/").pop()}
{node.type.split('/').pop()}
</div>
<div
class="click-target"
role="button"
tabindex="0"
onmousedown={handleMouseDown}
></div>
>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
@@ -68,8 +67,7 @@
--hover-path: path("${pathHover}");
`}
>
<path vector-effect="non-scaling-stroke" stroke="white" stroke-width="0.1"
></path>
<path vector-effect="non-scaling-stroke" stroke="white" stroke-width="0.1"></path>
</svg>
</div>
@@ -110,10 +108,8 @@
svg path {
stroke-width: 0.2px;
transition:
d 0.3s ease,
fill 0.3s ease;
fill: var(--layer-2);
transition: d 0.3s ease, fill 0.3s ease;
fill: var(--color-layer-2);
stroke: var(--stroke);
stroke-width: var(--stroke-width);
d: var(--path);

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { NodeInstance, NodeInput } from "@nodarium/types";
import { Input } from "@nodarium/ui";
import type { GraphManager } from "../graph-manager.svelte";
import type { NodeInput, NodeInstance } from '@nodarium/types';
import { Input } from '@nodarium/ui';
import type { GraphManager } from '../graph-manager.svelte';
type Props = {
node: NodeInstance;
@@ -16,17 +16,18 @@
input,
id,
elementId = `input-${Math.random().toString(36).substring(7)}`,
graph,
graph
}: Props = $props();
function getDefaultValue() {
if (node?.props?.[id] !== undefined) return node?.props?.[id] as number;
if ("value" in input && input?.value !== undefined)
if ('value' in input && input?.value !== undefined) {
return input?.value as number;
if (input.type === "boolean") return 0;
if (input.type === "float") return 0.5;
if (input.type === "integer") return 0;
if (input.type === "select") return 0;
}
if (input.type === 'boolean') return 0;
if (input.type === 'float') return 0.5;
if (input.type === 'integer') return 0;
if (input.type === 'select') return 0;
return 0;
}

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import type { NodeInput, NodeInstance } from "@nodarium/types";
import { createNodePath } from "../helpers";
import NodeInputEl from "./NodeInput.svelte";
import { getGraphManager, getGraphState } from "../graph-state.svelte";
import type { NodeInput, NodeInstance } from '@nodarium/types';
import { getGraphManager, getGraphState } from '../graph-state.svelte';
import { createNodePath } from '../helpers';
import NodeInputEl from './NodeInput.svelte';
type Props = {
node: NodeInstance;
@@ -15,9 +15,9 @@
let { node = $bindable(), input, id, isLast }: Props = $props();
const inputType = node?.state?.type?.inputs?.[id]!;
const inputType = $derived(node?.state?.type?.inputs?.[id]);
const socketId = `${node.id}-${id}`;
const socketId = $derived(`${node.id}-${id}`);
const graphState = getGraphState();
const graphId = graph?.id;
@@ -30,38 +30,44 @@
graphState.setDownSocket({
node,
index: id,
position: graphState.getSocketPosition?.(node, id),
position: graphState.getSocketPosition?.(node, id)
});
}
const leftBump = node.state?.type?.inputs?.[id].internal !== true;
const cornerBottom = isLast ? 5 : 0;
const leftBump = $derived(node.state?.type?.inputs?.[id].internal !== true);
const cornerBottom = $derived(isLast ? 5 : 0);
const aspectRatio = 0.5;
const path = createNodePath({
depth: 7,
height: 20,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio,
});
const pathDisabled = createNodePath({
depth: 6,
height: 18,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio,
});
const pathHover = createNodePath({
depth: 8,
height: 25,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio,
});
const path = $derived(
createNodePath({
depth: 7,
height: 20,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio
})
);
const pathDisabled = $derived(
createNodePath({
depth: 6,
height: 18,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio
})
);
const pathHover = $derived(
createNodePath({
depth: 8,
height: 25,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio
})
);
</script>
<div
@@ -72,16 +78,14 @@
>
{#key id && graphId}
<div class="content" class:disabled={graph?.inputSockets?.has(socketId)}>
{#if inputType.label !== ""}
<label for={elementId} title={input.description}
>{input.label || id}</label
>
{#if inputType?.label !== ''}
<label for={elementId} title={input.description}>{input.label || id}</label>
{/if}
<span
class="absolute i-[tabler--help-circle] size-4 block top-2 right-2 opacity-30"
title={JSON.stringify(input, null, 2)}
></span>
{#if inputType.external !== true}
{#if inputType?.external !== true}
<NodeInputEl {graph} {elementId} bind:node {input} {id} />
{/if}
</div>
@@ -94,7 +98,8 @@
onmousedown={handleMouseDown}
role="button"
tabindex="0"
></div>
>
</div>
{/if}
{/key}
@@ -169,10 +174,8 @@
}
svg path {
transition:
d 0.3s ease,
fill 0.3s ease;
fill: var(--layer-1);
transition: d 0.3s ease, fill 0.3s ease;
fill: var(--color-layer-1);
stroke: var(--stroke);
stroke-width: var(--stroke-width);
d: var(--path);

View File

@@ -1 +1,95 @@
{"settings":{"resolution.circle":26,"resolution.curve":39},"nodes":[{"id":9,"position":[220,80],"type":"max/plantarium/output","props":{}},{"id":10,"position":[95,80],"type":"max/plantarium/stem","props":{"amount":5,"length":11,"thickness":0.1}},{"id":14,"position":[195,80],"type":"max/plantarium/gravity","props":{"strength":0.38,"scale":39,"fixBottom":0,"directionalStrength":[1,1,1],"depth":1,"curviness":1}},{"id":15,"position":[120,80],"type":"max/plantarium/noise","props":{"strength":4.9,"scale":2.2,"fixBottom":1,"directionalStrength":[1,1,1],"depth":1,"octaves":1}},{"id":16,"position":[70,80],"type":"max/plantarium/vec3","props":{"0":0,"1":0,"2":0}},{"id":17,"position":[45,80],"type":"max/plantarium/random","props":{"min":-2,"max":2}},{"id":18,"position":[170,80],"type":"max/plantarium/branch","props":{"length":1.6,"thickness":0.69,"amount":36,"offsetSingle":0.5,"lowestBranch":0.46,"highestBranch":1,"depth":1,"rotation":180}},{"id":19,"position":[145,80],"type":"max/plantarium/gravity","props":{"strength":0.38,"scale":39,"fixBottom":0,"directionalStrength":[1,1,1],"depth":1,"curviness":1}},{"id":20,"position":[70,120],"type":"max/plantarium/random","props":{"min":0.073,"max":0.15}}],"edges":[[14,0,9,"input"],[10,0,15,"plant"],[16,0,10,"origin"],[17,0,16,"0"],[17,0,16,"2"],[18,0,14,"plant"],[15,0,19,"plant"],[19,0,18,"plant"],[20,0,10,"thickness"]]}
{
"settings": { "resolution.circle": 26, "resolution.curve": 39 },
"nodes": [
{ "id": 9, "position": [220, 80], "type": "max/plantarium/output", "props": {} },
{
"id": 10,
"position": [95, 80],
"type": "max/plantarium/stem",
"props": { "amount": 5, "length": 11, "thickness": 0.1 }
},
{
"id": 14,
"position": [195, 80],
"type": "max/plantarium/gravity",
"props": {
"strength": 0.38,
"scale": 39,
"fixBottom": 0,
"directionalStrength": [1, 1, 1],
"depth": 1,
"curviness": 1
}
},
{
"id": 15,
"position": [120, 80],
"type": "max/plantarium/noise",
"props": {
"strength": 4.9,
"scale": 2.2,
"fixBottom": 1,
"directionalStrength": [1, 1, 1],
"depth": 1,
"octaves": 1
}
},
{
"id": 16,
"position": [70, 80],
"type": "max/plantarium/vec3",
"props": { "0": 0, "1": 0, "2": 0 }
},
{
"id": 17,
"position": [45, 80],
"type": "max/plantarium/random",
"props": { "min": -2, "max": 2 }
},
{
"id": 18,
"position": [170, 80],
"type": "max/plantarium/branch",
"props": {
"length": 1.6,
"thickness": 0.69,
"amount": 36,
"offsetSingle": 0.5,
"lowestBranch": 0.46,
"highestBranch": 1,
"depth": 1,
"rotation": 180
}
},
{
"id": 19,
"position": [145, 80],
"type": "max/plantarium/gravity",
"props": {
"strength": 0.38,
"scale": 39,
"fixBottom": 0,
"directionalStrength": [1, 1, 1],
"depth": 1,
"curviness": 1
}
},
{
"id": 20,
"position": [70, 120],
"type": "max/plantarium/random",
"props": { "min": 0.073, "max": 0.15 }
}
],
"edges": [
[14, 0, 9, "input"],
[10, 0, 15, "plant"],
[16, 0, 10, "origin"],
[17, 0, 16, "0"],
[17, 0, 16, "2"],
[18, 0, 14, "plant"],
[15, 0, 19, "plant"],
[19, 0, 18, "plant"],
[20, 0, 10, "thickness"]
]
}

View File

@@ -1,11 +1,10 @@
import type { Graph } from "@nodarium/types";
import type { Graph } from '@nodarium/types';
export function grid(width: number, height: number) {
const graph: Graph = {
id: Math.floor(Math.random() * 100000),
edges: [],
nodes: [],
nodes: []
};
const amount = width * height;
@@ -18,19 +17,18 @@ export function grid(width: number, height: number) {
id: i,
position: [x * 30, y * 40],
props: i == 0 ? { value: 0 } : { op_type: 0, a: 1, b: 0.05 },
type: i == 0 ? "max/plantarium/float" : "max/plantarium/math",
type: i == 0 ? 'max/plantarium/float' : 'max/plantarium/math'
});
graph.edges.push([i, 0, i + 1, i === amount - 1 ? "input" : "a",]);
graph.edges.push([i, 0, i + 1, i === amount - 1 ? 'input' : 'a']);
}
graph.nodes.push({
id: amount,
position: [width * 30, (height - 1) * 40],
type: "max/plantarium/output",
props: {},
type: 'max/plantarium/output',
props: {}
});
return graph;
}

View File

@@ -1,8 +1,7 @@
export { grid } from "./grid";
export { tree } from "./tree";
export { plant } from "./plant";
export { default as lottaFaces } from "./lotta-faces.json";
export { default as lottaNodes } from "./lotta-nodes.json";
export { default as defaultPlant } from "./default.json"
export { default as lottaNodesAndFaces } from "./lotta-nodes-and-faces.json";
export { default as defaultPlant } from './default.json';
export { grid } from './grid';
export { default as lottaFaces } from './lotta-faces.json';
export { default as lottaNodesAndFaces } from './lotta-nodes-and-faces.json';
export { default as lottaNodes } from './lotta-nodes.json';
export { plant } from './plant';
export { tree } from './tree';

View File

@@ -1 +1,44 @@
{"settings":{"resolution.circle":64,"resolution.curve":64,"randomSeed":false},"nodes":[{"id":9,"position":[260,0],"type":"max/plantarium/output","props":{}},{"id":18,"position":[185,0],"type":"max/plantarium/stem","props":{"amount":64,"length":12,"thickness":0.15}},{"id":19,"position":[210,0],"type":"max/plantarium/noise","props":{"scale":1.3,"strength":5.4}},{"id":20,"position":[235,0],"type":"max/plantarium/branch","props":{"length":0.8,"thickness":0.8,"amount":3}},{"id":21,"position":[160,0],"type":"max/plantarium/vec3","props":{"0":0.39,"1":0,"2":0.41}},{"id":22,"position":[130,0],"type":"max/plantarium/random","props":{"min":-2,"max":2}}],"edges":[[18,0,19,"plant"],[19,0,20,"plant"],[20,0,9,"input"],[21,0,18,"origin"],[22,0,21,"0"],[22,0,21,"2"]]}
{
"settings": { "resolution.circle": 64, "resolution.curve": 64, "randomSeed": false },
"nodes": [
{ "id": 9, "position": [260, 0], "type": "max/plantarium/output", "props": {} },
{
"id": 18,
"position": [185, 0],
"type": "max/plantarium/stem",
"props": { "amount": 64, "length": 12, "thickness": 0.15 }
},
{
"id": 19,
"position": [210, 0],
"type": "max/plantarium/noise",
"props": { "scale": 1.3, "strength": 5.4 }
},
{
"id": 20,
"position": [235, 0],
"type": "max/plantarium/branch",
"props": { "length": 0.8, "thickness": 0.8, "amount": 3 }
},
{
"id": 21,
"position": [160, 0],
"type": "max/plantarium/vec3",
"props": { "0": 0.39, "1": 0, "2": 0.41 }
},
{
"id": 22,
"position": [130, 0],
"type": "max/plantarium/random",
"props": { "min": -2, "max": 2 }
}
],
"edges": [
[18, 0, 19, "plant"],
[19, 0, 20, "plant"],
[20, 0, 9, "input"],
[21, 0, 18, "origin"],
[22, 0, 21, "0"],
[22, 0, 21, "2"]
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +1,71 @@
export const plant = {
"settings": { "resolution.circle": 26, "resolution.curve": 39 },
"nodes": [
{ "id": 9, "position": [180, 80], "type": "max/plantarium/output", "props": {} },
{ "id": 10, "position": [55, 80], "type": "max/plantarium/stem", "props": { "amount": 1, "length": 11, "thickness": 0.71 } },
{ "id": 11, "position": [80, 80], "type": "max/plantarium/noise", "props": { "strength": 35, "scale": 4.6, "fixBottom": 1, "directionalStrength": [1, 0.74, 0.083], "depth": 1 } },
{ "id": 12, "position": [105, 80], "type": "max/plantarium/branch", "props": { "length": 3, "thickness": 0.6, "amount": 10, "rotation": 180, "offsetSingle": 0.34, "lowestBranch": 0.53, "highestBranch": 1, "depth": 1 } },
{ "id": 13, "position": [130, 80], "type": "max/plantarium/noise", "props": { "strength": 8, "scale": 7.7, "fixBottom": 1, "directionalStrength": [1, 0, 1], "depth": 1 } },
{ "id": 14, "position": [155, 80], "type": "max/plantarium/gravity", "props": { "strength": 0.11, "scale": 39, "fixBottom": 0, "directionalStrength": [1, 1, 1], "depth": 1, "curviness": 1 } }
'settings': { 'resolution.circle': 26, 'resolution.curve': 39 },
'nodes': [
{ 'id': 9, 'position': [180, 80], 'type': 'max/plantarium/output', 'props': {} },
{
'id': 10,
'position': [55, 80],
'type': 'max/plantarium/stem',
'props': { 'amount': 1, 'length': 11, 'thickness': 0.71 }
},
{
'id': 11,
'position': [80, 80],
'type': 'max/plantarium/noise',
'props': {
'strength': 35,
'scale': 4.6,
'fixBottom': 1,
'directionalStrength': [1, 0.74, 0.083],
'depth': 1
}
},
{
'id': 12,
'position': [105, 80],
'type': 'max/plantarium/branch',
'props': {
'length': 3,
'thickness': 0.6,
'amount': 10,
'rotation': 180,
'offsetSingle': 0.34,
'lowestBranch': 0.53,
'highestBranch': 1,
'depth': 1
}
},
{
'id': 13,
'position': [130, 80],
'type': 'max/plantarium/noise',
'props': {
'strength': 8,
'scale': 7.7,
'fixBottom': 1,
'directionalStrength': [1, 0, 1],
'depth': 1
}
},
{
'id': 14,
'position': [155, 80],
'type': 'max/plantarium/gravity',
'props': {
'strength': 0.11,
'scale': 39,
'fixBottom': 0,
'directionalStrength': [1, 1, 1],
'depth': 1,
'curviness': 1
}
}
],
"edges": [[10, 0, 11, "plant"], [11, 0, 12, "plant"], [12, 0, 13, "plant"], [13, 0, 14, "plant"], [14, 0, 9, "input"]]
}
'edges': [
[10, 0, 11, 'plant'],
[11, 0, 12, 'plant'],
[12, 0, 13, 'plant'],
[13, 0, 14, 'plant'],
[14, 0, 9, 'input']
]
};

View File

@@ -1,28 +1,26 @@
import type { Graph, SerializedNode } from "@nodarium/types";
import type { Graph, SerializedNode } from '@nodarium/types';
export function tree(depth: number): Graph {
const nodes: SerializedNode[] = [
{
id: 0,
type: "max/plantarium/output",
type: 'max/plantarium/output',
position: [0, 0]
},
{
id: 1,
type: "max/plantarium/math",
type: 'max/plantarium/math',
position: [-40, -10]
}
]
];
const edges: [number, number, number, string][] = [
[1, 0, 0, "input"]
[1, 0, 0, 'input']
];
for (let d = 0; d < depth; d++) {
const amount = Math.pow(2, d);
for (let i = 0; i < amount; i++) {
const id0 = amount * 2 + i * 2;
const id1 = amount * 2 + i * 2 + 1;
@@ -33,24 +31,22 @@ export function tree(depth: number): Graph {
nodes.push({
id: id0,
type: "max/plantarium/math",
position: [x, y],
type: 'max/plantarium/math',
position: [x, y]
});
edges.push([id0, 0, parent, "a"]);
edges.push([id0, 0, parent, 'a']);
nodes.push({
id: id1,
type: "max/plantarium/math",
position: [x, y + 35],
type: 'max/plantarium/math',
position: [x, y + 35]
});
edges.push([id1, 0, parent, "b"]);
edges.push([id1, 0, parent, 'b']);
}
}
return {
id: Math.floor(Math.random() * 100000),
nodes,
edges
};
}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { getContext, type Snippet } from "svelte";
import { getContext, type Snippet } from 'svelte';
let index = $state(-1);
let wrapper: HTMLDivElement;
@@ -8,19 +8,17 @@
$effect(() => {
if (index === -1) {
index = getContext<() => number>("registerCell")();
index = getContext<() => number>('registerCell')();
}
});
const sizes = getContext<{ value: string[] }>("sizes");
const sizes = getContext<{ value: string[] }>('sizes');
let downSizes: string[] = [];
let downWidth = 0;
let mouseDown = false;
let startX = 0;
function handleMouseDown(event: MouseEvent) {
downSizes = [...sizes.value];
mouseDown = true;
startX = event.clientX;
downWidth = wrapper.getBoundingClientRect().width;
@@ -45,7 +43,8 @@
role="button"
tabindex="0"
onmousedown={handleMouseDown}
></div>
>
</div>
{/if}
<div class="cell" bind:this={wrapper}>
@@ -63,7 +62,7 @@
cursor: ew-resize;
height: 100%;
width: 1px;
background: var(--outline);
background: var(--color-outline);
}
.seperator::before {
content: "";

View File

@@ -1,9 +1,11 @@
<script lang="ts">
import { setContext, type Snippet } from "svelte";
import { onMount, setContext, type Snippet } from 'svelte';
const { children, id } = $props<{ children?: Snippet; id?: string }>();
setContext("grid-id", id);
onMount(() => {
setContext('grid-id', id);
});
</script>
{@render children({ id })}

View File

@@ -1,26 +1,26 @@
<script lang="ts">
import { setContext, getContext } from "svelte";
import { localState } from "$lib/helpers/localState.svelte";
import { localState } from '$lib/helpers/localState.svelte';
import { getContext, setContext } from 'svelte';
const gridId = getContext<string>("grid-id") || "grid-0";
const gridId = getContext<string>('grid-id') || 'grid-0';
let sizes = localState<string[]>(gridId, []);
const { children } = $props();
let registerIndex = 0;
setContext("registerCell", function () {
setContext('registerCell', function() {
let index = registerIndex;
registerIndex++;
if (registerIndex > sizes.value.length) {
sizes.value = [...sizes.value, "1fr"];
sizes.value = [...sizes.value, '1fr'];
}
return index;
});
setContext("sizes", sizes);
setContext('sizes', sizes);
const cols = $derived(
sizes.value.map((size, i) => `${i > 0 ? "1px " : ""}` + size).join(" "),
sizes.value.map((size, i) => `${i > 0 ? '1px ' : ''}` + size).join(' ')
);
</script>

View File

@@ -1,6 +1,6 @@
import { withSubComponents } from "$lib/helpers";
import Grid from "./Grid.svelte";
import Row from "./Row.svelte";
import Cell from "./Cell.svelte";
import { withSubComponents } from '$lib/helpers';
import Cell from './Cell.svelte';
import Grid from './Grid.svelte';
import Row from './Row.svelte';
export default withSubComponents(Grid, { Row, Cell });

View File

@@ -1,38 +1,39 @@
import { derived, get, writable } from "svelte/store";
import { derived, get, writable } from 'svelte/store';
type Shortcut = {
key: string | string[],
shift?: boolean,
ctrl?: boolean,
alt?: boolean,
preventDefault?: boolean,
description?: string,
callback: (event: KeyboardEvent) => void
export type ShortCut = {
key: string | string[];
shift?: boolean;
ctrl?: boolean;
alt?: boolean;
preventDefault?: boolean;
description?: string;
callback: (event: KeyboardEvent) => void;
};
function getShortcutId(shortcut: ShortCut) {
return `${shortcut.key}${shortcut.shift ? '+shift' : ''}${shortcut.ctrl ? '+ctrl' : ''}${
shortcut.alt ? '+alt' : ''
}`;
}
function getShortcutId(shortcut: Shortcut) {
return `${shortcut.key}${shortcut.shift ? "+shift" : ""}${shortcut.ctrl ? "+ctrl" : ""}${shortcut.alt ? "+alt" : ""}`;
}
export function createKeyMap(keys: Shortcut[]) {
export function createKeyMap(keys: ShortCut[]) {
const store = writable(new Map(keys.map(k => [getShortcutId(k), k])));
return {
handleKeyboardEvent: (event: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement;
if (activeElement?.tagName === "INPUT" || activeElement?.tagName === "TEXTAREA") return;
if (activeElement?.tagName === 'INPUT' || activeElement?.tagName === 'TEXTAREA') return;
const key = [...get(store).values()].find(k => {
if (Array.isArray(k.key) ? !k.key.includes(event.key) : k.key !== event.key) return false;
if ("shift" in k && k.shift !== event.shiftKey) return false;
if ("ctrl" in k && k.ctrl !== event.ctrlKey) return false;
if ("alt" in k && k.alt !== event.altKey) return false;
if ('shift' in k && k.shift !== event.shiftKey) return false;
if ('ctrl' in k && k.ctrl !== event.ctrlKey) return false;
if ('alt' in k && k.alt !== event.altKey) return false;
return true;
});
if (key && key.preventDefault) event.preventDefault();
key?.callback(event);
},
addShortcut: (shortcut: Shortcut) => {
addShortcut: (shortcut: ShortCut) => {
if (Array.isArray(shortcut.key)) {
for (const k of shortcut.key) {
store.update(shortcuts => {
@@ -52,6 +53,5 @@ export function createKeyMap(keys: Shortcut[]) {
}
},
keys: derived(store, $store => Array.from($store.values()))
}
};
}

View File

@@ -18,7 +18,7 @@ export function animate(duration: number, callback: (progress: number) => void |
} else {
callback(1);
}
}
};
requestAnimationFrame(loop);
}
@@ -30,10 +30,11 @@ export function createNodePath({
cornerBottom = 0,
leftBump = false,
rightBump = false,
aspectRatio = 1,
aspectRatio = 1
} = {}) {
return `M0,${cornerTop}
${cornerTop
${
cornerTop
? ` V${cornerTop}
Q0,0 ${cornerTop * aspectRatio},0
H${100 - cornerTop * aspectRatio}
@@ -42,40 +43,45 @@ export function createNodePath({
: ` V0
H100
`
}
}
V${y - height / 2}
${rightBump
${
rightBump
? ` C${100 - depth},${y - height / 2} ${100 - depth},${y + height / 2} 100,${y + height / 2}`
: ` H100`
}
${cornerBottom
}
${
cornerBottom
? ` V${100 - cornerBottom}
Q100,100 ${100 - cornerBottom * aspectRatio},100
H${cornerBottom * aspectRatio}
Q0,100 0,${100 - cornerBottom}
`
: `${leftBump ? `V100 H0` : `V100`}`
}
${leftBump
? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${y - height / 2}`
}
${
leftBump
? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${
y - height / 2
}`
: ` H0`
}
Z`.replace(/\s+/g, " ");
}
Z`.replace(/\s+/g, ' ');
}
export const debounce = (fn: Function, ms = 300) => {
export const debounce = (fn: () => void, ms = 300) => {
let timeoutId: ReturnType<typeof setTimeout>;
return function (this: any, ...args: any[]) {
return function(this: unknown, ...args: unknown[]) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), ms);
timeoutId = setTimeout(() => fn.apply(this, args as []), ms);
};
};
export const clone: <T>(v: T) => T = "structedClone" in globalThis ? globalThis.structuredClone : (obj) => JSON.parse(JSON.stringify(obj));
export const clone: <T>(v: T) => T = 'structedClone' in globalThis
? globalThis.structuredClone
: (obj) => JSON.parse(JSON.stringify(obj));
export function withSubComponents<A, B extends Record<string, any>>(
export function withSubComponents<A, B extends Record<string, unknown>>(
component: A,
subcomponents: B
): A & B {
@@ -87,7 +93,7 @@ export function withSubComponents<A, B extends Record<string, any>>(
}
export function humanizeNumber(number: number): string {
const suffixes = ["", "K", "M", "B", "T"];
const suffixes = ['', 'K', 'M', 'B', 'T'];
if (number < 1000) {
return number.toString();
}
@@ -104,11 +110,15 @@ export function humanizeDuration(durationInMilliseconds: number) {
const millisecondsPerHour = 3600000;
const millisecondsPerDay = 86400000;
let days = Math.floor(durationInMilliseconds / millisecondsPerDay);
let hours = Math.floor((durationInMilliseconds % millisecondsPerDay) / millisecondsPerHour);
let minutes = Math.floor((durationInMilliseconds % millisecondsPerHour) / millisecondsPerMinute);
let seconds = Math.floor((durationInMilliseconds % millisecondsPerMinute) / millisecondsPerSecond);
let millis = durationInMilliseconds % millisecondsPerSecond;
const days = Math.floor(durationInMilliseconds / millisecondsPerDay);
const hours = Math.floor((durationInMilliseconds % millisecondsPerDay) / millisecondsPerHour);
const minutes = Math.floor(
(durationInMilliseconds % millisecondsPerHour) / millisecondsPerMinute
);
const seconds = Math.floor(
(durationInMilliseconds % millisecondsPerMinute) / millisecondsPerSecond
);
const millis = durationInMilliseconds % millisecondsPerSecond;
let durationString = '';
@@ -131,32 +141,10 @@ export function humanizeDuration(durationInMilliseconds: number) {
return durationString.trim();
}
// export function debounceAsyncFunction<T extends any[], R>(
// func: (...args: T) => Promise<R>
// ): (...args: T) => Promise<R> {
// let timeoutId: ReturnType<typeof setTimeout> | null = null;
// let lastPromise: Promise<R> | null = null;
// let lastReject: ((reason?: any) => void) | null = null;
//
// return (...args: T): Promise<R> => {
// if (timeoutId) {
// clearTimeout(timeoutId);
// if (lastReject) {
// lastReject(new Error("Debounced: Previous call was canceled."));
// }
// }
//
// return new Promise<R>((resolve, reject) => {
// lastReject = reject;
// timeoutId = setTimeout(() => {
// timeoutId = null;
// lastReject = null;
// lastPromise = func(...args).then(resolve, reject);
// }, 300); // Default debounce time is 300ms; you can make this configurable.
// });
// };
// }
export function debounceAsyncFunction<T extends (...args: any[]) => Promise<any>>(asyncFn: T): T {
export function debounceAsyncFunction<T extends (...args: never[]) => Promise<unknown>>(
asyncFn: T
): T {
let isRunning = false;
let latestArgs: Parameters<T> | null = null;
let resolveNext: (() => void) | null = null;
@@ -177,7 +165,7 @@ export function debounceAsyncFunction<T extends (...args: any[]) => Promise<any>
try {
// Execute with the latest arguments
const result = await asyncFn(...latestArgs!);
return result;
return result as ReturnType<T>;
} finally {
// Allow the next execution
isRunning = false;
@@ -190,48 +178,18 @@ export function debounceAsyncFunction<T extends (...args: any[]) => Promise<any>
}) as T;
}
// export function debounceAsyncFunction<T extends any[], R>(func: (...args: T) => Promise<R>): (...args: T) => Promise<R> {
// let currentPromise: Promise<R> | null = null;
// let nextArgs: T | null = null;
// let resolveNext: ((result: R) => void) | null = null;
//
// const debouncedFunction = async (...args: T): Promise<R> => {
// if (currentPromise) {
// // Store the latest arguments and create a new promise to resolve them later
// nextArgs = args;
// return new Promise<R>((resolve) => {
// resolveNext = resolve;
// });
// } else {
// // Execute the function immediately
// try {
// currentPromise = func(...args);
// const result = await currentPromise;
// return result;
// } finally {
// currentPromise = null;
// // If there are stored arguments, call the function again with the latest arguments
// if (nextArgs) {
// const argsToUse = nextArgs;
// const resolver = resolveNext;
// nextArgs = null;
// resolveNext = null;
// resolver!(await debouncedFunction(...argsToUse));
// }
// }
// }
// };
//
// return debouncedFunction;
// }
export function withArgsChangeOnly<T extends any[], R>(func: (...args: T) => R): (...args: T) => R {
export function withArgsChangeOnly<T extends unknown[], R>(
func: (...args: T) => R
): (...args: T) => R {
let lastArgs: T | undefined = undefined;
let lastResult: R;
return (...args: T): R => {
// Check if arguments are the same as last call
if (lastArgs && args.length === lastArgs.length && args.every((val, index) => val === lastArgs?.[index])) {
if (
lastArgs && args.length === lastArgs.length
&& args.every((val, index) => val === lastArgs?.[index])
) {
return lastResult; // Return cached result if arguments haven't changed
}
@@ -241,4 +199,3 @@ export function withArgsChangeOnly<T extends any[], R>(func: (...args: T) => R):
return lastResult; // Return new result
};
}

View File

@@ -1,8 +1,8 @@
import { browser } from "$app/environment";
import { browser } from '$app/environment';
export class LocalStore<T> {
value = $state<T>() as T;
key = "";
key = '';
constructor(key: string, value: T) {
this.key = key;

View File

@@ -1,15 +1,14 @@
import { writable, type Writable } from "svelte/store";
import { type Writable, writable } from 'svelte/store';
function isStore(v: unknown): v is Writable<unknown> {
return v !== null && typeof v === "object" && "subscribe" in v && "set" in v;
return v !== null && typeof v === 'object' && 'subscribe' in v && 'set' in v;
}
const storeIds: Map<string, ReturnType<typeof createLocalStore>> = new Map();
const HAS_LOCALSTORAGE = "localStorage" in globalThis;
const HAS_LOCALSTORAGE = 'localStorage' in globalThis;
function createLocalStore<T>(key: string, initialValue: T | Writable<T>) {
let store: Writable<T>;
if (HAS_LOCALSTORAGE) {
@@ -36,18 +35,15 @@ function createLocalStore<T>(key: string, initialValue: T | Writable<T>) {
subscribe: store.subscribe,
set: store.set,
update: store.update
}
};
}
export default function localStore<T>(key: string, initialValue: T | Writable<T>): Writable<T> {
if (storeIds.has(key)) return storeIds.get(key) as Writable<T>;
const store = createLocalStore(key, initialValue)
const store = createLocalStore(key, initialValue);
storeIds.set(key, store);
return store
return store;
}

View File

@@ -1,6 +1,6 @@
export default <T extends unknown[]>(
callback: (...args: T) => void,
delay: number,
delay: number
) => {
let isWaiting = false;

View File

@@ -1,6 +1,10 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="17" y="8" width="5" height="7" rx="1" stroke="currentColor" stroke-width="2"/>
<rect x="2" y="3" width="5" height="7" rx="1" stroke="currentColor" stroke-width="2"/>
<rect x="2" y="14" width="5" height="7" rx="1" stroke="currentColor" stroke-width="2"/>
<path d="M16 10.5C9.33333 10.5 14.8889 6 8.22222 6H6M16 12.5C8.77778 12.5 14.8889 17 8.22222 17H6" stroke="currentColor" stroke-width="2"/>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="17" y="8" width="5" height="7" rx="1" stroke="currentColor" stroke-width="2" />
<rect x="2" y="3" width="5" height="7" rx="1" stroke="currentColor" stroke-width="2" />
<rect x="2" y="14" width="5" height="7" rx="1" stroke="currentColor" stroke-width="2" />
<path
d="M16 10.5C9.33333 10.5 14.8889 6 8.22222 6H6M16 12.5C8.77778 12.5 14.8889 17 8.22222 17H6"
stroke="currentColor"
stroke-width="2"
/>
</svg>

Before

Width:  |  Height:  |  Size: 496 B

After

Width:  |  Height:  |  Size: 509 B

View File

@@ -8,6 +8,7 @@ export async function getWasm(id: `${string}/${string}/${string}`) {
try {
await fs.access(filePath);
} catch (e) {
console.error(`Failed to read node: ${id}`, e);
return null;
}
@@ -20,12 +21,11 @@ export async function getNodeWasm(id: `${string}/${string}/${string}`) {
const wasmBytes = await getWasm(id);
if (!wasmBytes) return null;
const wrapper = createWasmWrapper(
wasmBytes.buffer,
new WebAssembly.Memory({ initial: 1024, maximum: 8192 })
);
return wrapper;
try {
return createWasmWrapper(wasmBytes.buffer);
} catch (error) {
console.error(`Failed to create node wrapper for node: ${id}`, error);
}
}
export async function getNode(id: `${string}/${string}/${string}`) {

View File

@@ -0,0 +1,2 @@
export * from './node-registry-cache';
export * from './node-registry-client';

View File

@@ -1,8 +1,7 @@
import type { AsyncCache } from '@nodarium/types';
import { openDB, type IDBPDatabase } from 'idb';
import { type IDBPDatabase, openDB } from 'idb';
export class IndexDBCache implements AsyncCache<unknown> {
size: number = 100;
db: Promise<IDBPDatabase<unknown>>;
@@ -12,7 +11,7 @@ export class IndexDBCache implements AsyncCache<unknown> {
this.db = openDB<unknown>('cache/' + id, 1, {
upgrade(db) {
db.createObjectStore('keyval');
},
}
});
}
@@ -33,16 +32,16 @@ export class IndexDBCache implements AsyncCache<unknown> {
if (res instanceof ArrayBuffer) {
return res;
}
return
return;
}
async getString(key: string) {
const res = await this.get(key);
if (!res) return;
if (typeof res === "string") {
if (typeof res === 'string') {
return res;
}
return
return;
}
async set(key: string, value: unknown) {
@@ -54,5 +53,4 @@ export class IndexDBCache implements AsyncCache<unknown> {
clear() {
this.db.then(db => db.clear('keyval'));
}
}

View File

@@ -2,7 +2,6 @@ import {
type AsyncCache,
type NodeDefinition,
NodeDefinitionSchema,
type NodeId,
type NodeRegistry
} from '@nodarium/types';
import { createLogger, createWasmWrapper } from '@nodarium/utils';
@@ -13,12 +12,11 @@ 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,
public cache?: AsyncCache<ArrayBuffer | string>
) { }
) {}
async fetchJson(url: string, skipCache = false) {
const finalUrl = `${this.url}/${url}`;
@@ -107,7 +105,7 @@ export class RemoteNodeRegistry implements NodeRegistry {
const wasmBuffer = await this.fetchNodeWasm(id);
try {
return await this.register(wasmBuffer);
return await this.register(id, wasmBuffer);
} catch (e) {
console.log('Failed to register: ', id);
console.error(e);
@@ -127,23 +125,33 @@ export class RemoteNodeRegistry implements NodeRegistry {
return nodes;
}
async register(wasmBuffer: ArrayBuffer) {
const wrapper = createWasmWrapper(
wasmBuffer,
this.memory
);
async register(id: string, wasmBuffer: ArrayBuffer) {
let wrapper: ReturnType<typeof createWasmWrapper> = null!;
try {
wrapper = createWasmWrapper(wasmBuffer);
} catch (error) {
console.error(`Failed to create node wrapper for node: ${id}`, error);
}
const definition = NodeDefinitionSchema.safeParse(wrapper.get_definition());
const rawDefinition = wrapper.get_definition();
const definition = NodeDefinitionSchema.safeParse(rawDefinition);
if (definition.error) {
throw definition.error;
throw new Error(
'Failed to parse node definition from node:+\n' + JSON.stringify(rawDefinition, null, 2)
+ '\n'
+ definition.error
);
}
if (this.cache) {
this.cache.set(definition.data.id, wasmBuffer);
}
let node = { ...definition.data, execute: wrapper.execute };
const node = {
...definition.data,
execute: wrapper.execute
};
this.nodes.set(definition.data.id, node);
@@ -155,13 +163,6 @@ export class RemoteNodeRegistry implements NodeRegistry {
}
getAllNodes() {
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);
return [...this.nodes.values()];
}
}

View File

@@ -1,16 +1,16 @@
<script lang="ts">
import { Select } from "@nodarium/ui";
import { InputSelect } from '@nodarium/ui';
let activeStore = $state(0);
let { activeId }: { activeId: string } = $props();
const [activeUser, activeCollection, activeNode] = $derived(
activeId.split(`/`),
activeId.split(`/`)
);
</script>
<div class="breadcrumbs">
{#if activeUser}
<Select id="root" options={["root"]} bind:value={activeStore}></Select>
<InputSelect id="root" options={['root']} bind:value={activeStore}></InputSelect>
{#if activeCollection}
<button
onclick={() => {
@@ -35,7 +35,7 @@
<span>{activeUser}</span>
{/if}
{:else}
<Select id="root" options={["root"]} bind:value={activeStore}></Select>
<InputSelect id="root" options={['root']} bind:value={activeStore}></InputSelect>
{/if}
</div>
@@ -47,7 +47,7 @@
gap: 0.8em;
height: 35px;
box-sizing: border-box;
border-bottom: solid thin var(--outline);
border-bottom: solid thin var(--color-outline);
}
.breadcrumbs > button {
position: relative;

View File

@@ -1,39 +1,44 @@
<script lang="ts">
import NodeHtml from "$lib/graph-interface/node/NodeHTML.svelte";
import type { NodeDefinition, NodeId, NodeInstance } from "@nodarium/types";
import NodeHtml from '$lib/graph-interface/node/NodeHTML.svelte';
import type { NodeDefinition, NodeId, NodeInstance } from '@nodarium/types';
import { onMount } from 'svelte';
const { node }: { node: NodeDefinition } = $props();
let dragging = $state(false);
let nodeData = $state<NodeInstance>({
id: 0,
type: node.id as unknown as NodeId,
position: [0, 0] as [number, number],
props: {},
state: {
type: node,
},
});
let nodeData = $state<NodeInstance>(null!);
function handleDragStart(e: DragEvent) {
dragging = true;
const box = (e?.target as HTMLElement)?.getBoundingClientRect();
if (e.dataTransfer === null) return;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("data/node-id", node.id.toString());
if (nodeData.props) {
e.dataTransfer.setData("data/node-props", JSON.stringify(nodeData.props));
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('data/node-id', node.id.toString());
if (nodeData?.props) {
e.dataTransfer.setData('data/node-props', JSON.stringify(nodeData.props));
}
e.dataTransfer.setData(
"data/node-offset-x",
Math.round(box.left - e.clientX).toString(),
'data/node-offset-x',
Math.round(box.left - e.clientX).toString()
);
e.dataTransfer.setData(
"data/node-offset-y",
Math.round(box.top - e.clientY).toString(),
'data/node-offset-y',
Math.round(box.top - e.clientY).toString()
);
}
onMount(() => {
nodeData = {
id: 0,
type: node.id as unknown as NodeId,
position: [0, 0] as [number, number],
props: {},
state: {
type: node
}
};
});
</script>
<div class="node-wrapper" class:dragging>
@@ -46,7 +51,9 @@
tabindex="0"
ondragstart={handleDragStart}
>
<NodeHtml bind:node={nodeData} inView={true} position={"relative"} z={5} />
{#if nodeData}
<NodeHtml bind:node={nodeData} inView={true} position="relative" z={5} />
{/if}
</div>
</div>
@@ -61,7 +68,7 @@
}
.dragging {
border: dashed 2px var(--outline);
border: dashed 2px var(--color-outline);
}
.node-wrapper > div {
opacity: 1;

View File

@@ -1,15 +1,15 @@
<script lang="ts">
import BreadCrumbs from "./BreadCrumbs.svelte";
import DraggableNode from "./DraggableNode.svelte";
import type { RemoteNodeRegistry } from "@nodarium/registry";
import type { RemoteNodeRegistry } from '$lib/node-registry/index';
import BreadCrumbs from './BreadCrumbs.svelte';
import DraggableNode from './DraggableNode.svelte';
const { registry }: { registry: RemoteNodeRegistry } = $props();
let activeId = $state("max/plantarium");
let activeId = $state('max/plantarium');
let showBreadCrumbs = false;
const [activeUser, activeCollection, activeNode] = $derived(
activeId.split(`/`),
activeId.split(`/`)
);
</script>
@@ -22,12 +22,14 @@
{#await registry.fetchUsers()}
<div>Loading Users...</div>
{:then users}
{#each users as user}
{#each users as user (user.id)}
<button
onclick={() => {
activeId = user.id;
}}>{user.id}</button
}}
>
{user.id}
</button>
{/each}
{:catch error}
<div>{error.message}</div>
@@ -36,7 +38,7 @@
{#await registry.fetchUser(activeUser)}
<div>Loading User...</div>
{:then user}
{#each user.collections as collection}
{#each user.collections as collection (collection)}
<button
onclick={() => {
activeId = collection.id;
@@ -52,7 +54,7 @@
{#await registry.fetchCollection(`${activeUser}/${activeCollection}`)}
<div>Loading Collection...</div>
{:then collection}
{#each collection.nodes as node}
{#each collection.nodes as node (node.id)}
{#await registry.fetchNodeDefinition(node.id)}
<div>Loading Node... {node.id}</div>
{:then node}

View File

@@ -8,15 +8,15 @@
const total = $derived(values.reduce((acc, v) => acc + v, 0));
let colors = ["red", "green", "blue"];
let colors = ['red', 'green', 'blue'];
</script>
<div class="wrapper">
<div class="bars">
{#each values as value, i}
{#each values as value, i (value)}
<div
class="bar bg-{colors[i]}-400"
style="width: {(value / total) * 100}%;"
style:width={(value / total) * 100 + '%'}
>
{Math.round(value)}ms
</div>
@@ -24,7 +24,7 @@
</div>
<div class="labels mt-2">
{#each values as _label, i}
{#each values as _label, i (_label)}
<div class="text-{colors[i]}-400">{labels[i]}</div>
{/each}
</div>

View File

@@ -9,17 +9,17 @@
let {
points,
type = "ms",
title = "Performance",
type = 'ms',
title = 'Performance',
max,
min,
min
}: Props = $props();
let internalMax = $derived(max ?? Math.max(...points));
let internalMin = $derived(min ?? Math.min(...points))!;
const maxText = $derived.by(() => {
if (type === "%") {
if (type === '%') {
return 100;
}
@@ -40,11 +40,10 @@
points
.map((point, i) => {
const x = (i / (points.length - 1)) * 100;
const y =
100 - ((point - internalMin) / (internalMax - internalMin)) * 100;
const y = 100 - ((point - internalMin) / (internalMax - internalMin)) * 100;
return `${x},${y}`;
})
.join(" "),
.join(' ')
);
</script>
@@ -75,7 +74,7 @@
.wrapper {
position: relative;
border-bottom: solid thin var(--outline);
border-bottom: solid thin var(--color-outline);
display: flex;
}
p {
@@ -89,13 +88,13 @@
svg {
height: 124px;
margin: 24px 0px;
border-top: solid thin var(--outline);
border-bottom: solid thin var(--outline);
border-top: solid thin var(--color-outline);
border-bottom: solid thin var(--color-outline);
width: 100%;
}
polyline {
fill: none;
stroke: var(--layer-3);
stroke: var(--color-layer-3);
opacity: 0.5;
stroke-width: 1;
}

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import Monitor from "./Monitor.svelte";
import { humanizeNumber } from "$lib/helpers";
import { Checkbox } from "@nodarium/ui";
import type { PerformanceData } from "@nodarium/utils";
import BarSplit from "./BarSplit.svelte";
import { humanizeNumber } from '$lib/helpers';
import { InputCheckbox } from '@nodarium/ui';
import type { PerformanceData } from '@nodarium/utils';
import BarSplit from './BarSplit.svelte';
import Monitor from './Monitor.svelte';
const { data }: { data: PerformanceData } = $props();
let activeType = $state("total");
let activeType = $state('total');
let showAverage = $state(true);
function round(v: number) {
@@ -21,21 +21,21 @@
}
function getTitle(t: string) {
if (t.includes("/")) {
return `Node ${t.split("/").slice(-1).join("/")}`;
if (t.includes('/')) {
return `Node ${t.split('/').slice(-1).join('/')}`;
}
return t
.split("-")
.split('-')
.map((v) => v[0].toUpperCase() + v.slice(1))
.join(" ");
.join(' ');
}
const viewerKeys = [
"total-vertices",
"total-faces",
"update-geometries",
"split-result",
'total-vertices',
'total-faces',
'update-geometries',
'split-result'
];
// --- Small helpers that query `data` directly ---
@@ -64,21 +64,19 @@
const lasts = $derived.by(() => data.at(-1) || {});
const totalPerformance = $derived.by(() => {
const onlyLast =
getLast("runtime") +
getLast("update-geometries") +
getLast("worker-transfer");
const average =
getAverage("runtime") +
getAverage("update-geometries") +
getAverage("worker-transfer");
const onlyLast = getLast('runtime')
+ getLast('update-geometries')
+ getLast('worker-transfer');
const average = getAverage('runtime')
+ getAverage('update-geometries')
+ getAverage('worker-transfer');
return { onlyLast, average };
});
const cacheRatio = $derived.by(() => {
return {
onlyLast: Math.floor(getLast("cache-hit") * 100),
average: Math.floor(getAverage("cache-hit") * 100),
onlyLast: Math.floor(getLast('cache-hit') * 100),
average: Math.floor(getAverage('cache-hit') * 100)
};
});
@@ -87,10 +85,10 @@
return Object.entries(source)
.filter(
([key]) =>
!key.startsWith("node/") &&
key !== "total" &&
!key.includes("cache") &&
!viewerKeys.includes(key),
!key.startsWith('node/')
&& key !== 'total'
&& !key.includes('cache')
&& !viewerKeys.includes(key)
)
.sort((a, b) => b[1] - a[1]);
});
@@ -98,7 +96,7 @@
const nodePerformanceData = $derived.by(() => {
const source = showAverage ? averages : lasts;
return Object.entries(source)
.filter(([key]) => key.startsWith("node/"))
.filter(([key]) => key.startsWith('node/'))
.sort((a, b) => b[1] - a[1]);
});
@@ -107,9 +105,9 @@
return Object.entries(source)
.filter(
([key]) =>
key !== "total-vertices" &&
key !== "total-faces" &&
viewerKeys.includes(key),
key !== 'total-vertices'
&& key !== 'total-faces'
&& viewerKeys.includes(key)
)
.sort((a, b) => b[1] - a[1]);
});
@@ -117,15 +115,15 @@
const splitValues = $derived.by(() => {
if (showAverage) {
return [
getAverage("worker-transfer"),
getAverage("runtime"),
getAverage("update-geometries"),
getAverage('worker-transfer'),
getAverage('runtime'),
getAverage('update-geometries')
];
}
return [
getLast("worker-transfer"),
getLast("runtime"),
getLast("update-geometries"),
getLast('worker-transfer'),
getLast('runtime'),
getLast('update-geometries')
];
});
@@ -133,24 +131,24 @@
if (showAverage) {
return data.map((run) => {
return (
(run["runtime"]?.reduce((acc, v) => acc + v, 0) || 0) +
(run["update-geometries"]?.reduce((acc, v) => acc + v, 0) || 0) +
(run["worker-transfer"]?.reduce((acc, v) => acc + v, 0) || 0)
(run['runtime']?.reduce((acc, v) => acc + v, 0) || 0)
+ (run['update-geometries']?.reduce((acc, v) => acc + v, 0) || 0)
+ (run['worker-transfer']?.reduce((acc, v) => acc + v, 0) || 0)
);
});
}
return data.map((run) => {
return (
(run["runtime"]?.[0] || 0) +
(run["update-geometries"]?.[0] || 0) +
(run["worker-transfer"]?.[0] || 0)
(run['runtime']?.[0] || 0)
+ (run['update-geometries']?.[0] || 0)
+ (run['worker-transfer']?.[0] || 0)
);
});
});
function constructPoints(key: string) {
if (key === "total") {
if (key === 'total') {
return totalPoints;
}
return data.map((run) => {
@@ -166,21 +164,21 @@
}
const computedTotalDisplay = $derived.by(() =>
round(showAverage ? totalPerformance.average : totalPerformance.onlyLast),
round(showAverage ? totalPerformance.average : totalPerformance.onlyLast)
);
const computedFps = $derived.by(() =>
Math.floor(
1000 /
(showAverage
1000
/ (showAverage
? totalPerformance.average || 1
: totalPerformance.onlyLast || 1),
),
: totalPerformance.onlyLast || 1)
)
);
</script>
{#if data.length !== 0}
{#if activeType === "cache-hit"}
{#if activeType === 'cache-hit'}
<Monitor
title="Cache Hits"
points={constructPoints(activeType)}
@@ -197,12 +195,12 @@
<div class="p-4 performance-tabler">
<div class="flex items-center gap-2">
<Checkbox id="show-total" bind:value={showAverage} />
<InputCheckbox id="show-total" bind:value={showAverage} />
<label for="show-total">Show Average</label>
</div>
<BarSplit
labels={["worker-transfer", "runtime", "update-geometries"]}
labels={['worker-transfer', 'runtime', 'update-geometries']}
values={splitValues}
/>
@@ -215,14 +213,14 @@
{computedTotalDisplay}<span>ms</span>
</td>
<td
class:active={activeType === "total"}
onclick={() => (activeType = "total")}
class:active={activeType === 'total'}
onclick={() => (activeType = 'total')}
>
total<span>({computedFps}fps)</span>
</td>
</tr>
{#each performanceData as [key, value]}
{#each performanceData as [key, value] (key)}
<tr>
<td>{round(value)}<span>ms</span></td>
<td
@@ -246,27 +244,23 @@
<tbody>
<tr>
<td>{showAverage ? cacheRatio.average : cacheRatio.onlyLast}<span>%</span></td>
<td
>{showAverage ? cacheRatio.average : cacheRatio.onlyLast}<span
>%</span
></td
>
<td
class:active={activeType === "cache-hit"}
onclick={() => (activeType = "cache-hit")}
class:active={activeType === 'cache-hit'}
onclick={() => (activeType = 'cache-hit')}
>
cache hits
</td>
</tr>
{#each nodePerformanceData as [key, value]}
{#each nodePerformanceData as [key, value] (key)}
<tr>
<td>{round(value)}<span>ms</span></td>
<td
class:active={activeType === key}
onclick={() => (activeType = key)}
>
{key.split("/").slice(-1).join("/")}
{key.split('/').slice(-1).join('/')}
</td>
</tr>
{/each}
@@ -278,22 +272,22 @@
<tbody>
<tr>
<td>{humanizeNumber(getLast("total-vertices"))}</td>
<td>{humanizeNumber(getLast('total-vertices'))}</td>
<td>Vertices</td>
</tr>
<tr>
<td>{humanizeNumber(getLast("total-faces"))}</td>
<td>{humanizeNumber(getLast('total-faces'))}</td>
<td>Faces</td>
</tr>
{#each viewerPerformanceData as [key, value]}
{#each viewerPerformanceData as [key, value] (key)}
<tr>
<td>{round(value)}<span>ms</span></td>
<td
class:active={activeType === key}
onclick={() => (activeType = key)}
>
{key.split("/").slice(-1).join("/")}
{key.split('/').slice(-1).join('/')}
</td>
</tr>
{/each}

View File

@@ -10,7 +10,7 @@
const y = 100 - ((point - min) / (max - min)) * 100;
return `${x},${y}`;
})
.join(" ");
.join(' ');
});
</script>
@@ -25,7 +25,7 @@
}
polyline {
fill: none;
stroke: var(--layer-3);
stroke: var(--color-layer-3);
opacity: 1;
stroke-width: 1;
}

View File

@@ -1,19 +1,19 @@
<script lang="ts">
import { humanizeDuration, humanizeNumber } from "$lib/helpers";
import { localState } from "$lib/helpers/localState.svelte";
import SmallGraph from "./SmallGraph.svelte";
import type { PerformanceData, PerformanceStore } from "@nodarium/utils";
import { humanizeDuration, humanizeNumber } from '$lib/helpers';
import { localState } from '$lib/helpers/localState.svelte';
import type { PerformanceData, PerformanceStore } from '@nodarium/utils';
import SmallGraph from './SmallGraph.svelte';
const { store, fps }: { store: PerformanceStore; fps: number[] } = $props();
const open = localState("node.performance.small.open", {
const open = localState('node.performance.small.open', {
runtime: false,
fps: false,
fps: false
});
const vertices = $derived($store?.at(-1)?.["total-vertices"]?.[0] || 0);
const faces = $derived($store?.at(-1)?.["total-faces"]?.[0] || 0);
const runtime = $derived($store?.at(-1)?.["runtime"]?.[0] || 0);
const vertices = $derived($store?.at(-1)?.['total-vertices']?.[0] || 0);
const faces = $derived($store?.at(-1)?.['total-faces']?.[0] || 0);
const runtime = $derived($store?.at(-1)?.['runtime']?.[0] || 0);
function getPoints(data: PerformanceData, key: string) {
return data?.map((run) => run[key]?.[0] || 0) || [];
@@ -24,25 +24,25 @@
<table>
<tbody>
<tr
style="cursor:pointer;"
style="cursor: pointer"
onclick={() => (open.value.runtime = !open.value.runtime)}
>
<td>{open.value.runtime ? "-" : "+"} runtime </td>
<td>{open.value.runtime ? '-' : '+'} runtime</td>
<td>{humanizeDuration(runtime || 1000)}</td>
</tr>
{#if open.value.runtime}
<tr>
<td colspan="2">
<SmallGraph points={getPoints($store, "runtime")} />
<SmallGraph points={getPoints($store, 'runtime')} />
</td>
</tr>
{/if}
<tr
style="cursor:pointer;"
style="cursor: pointer"
onclick={() => (open.value.fps = !open.value.fps)}
>
<td>{open.value.fps ? "-" : "+"} fps </td>
<td>{open.value.fps ? '-' : '+'} fps</td>
<td>
{Math.floor(fps[fps.length - 1])}fps
</td>
@@ -56,12 +56,12 @@
{/if}
<tr>
<td>vertices </td>
<td>vertices</td>
<td>{humanizeNumber(vertices || 0)}</td>
</tr>
<tr>
<td>faces </td>
<td>faces</td>
<td>{humanizeNumber(faces || 0)}</td>
</tr>
</tbody>
@@ -74,14 +74,14 @@
top: 10px;
left: 10px;
z-index: 2;
background: var(--layer-0);
border: solid thin var(--outline);
background: var(--color-layer-0);
border: solid thin var(--color-outline);
border-collapse: collapse;
}
td {
padding: 4px;
padding-inline: 8px;
font-size: 0.8em;
border: solid thin var(--outline);
border: solid thin var(--color-outline);
}
</style>

View File

@@ -1 +1 @@
export { default as PerformanceViewer } from "./PerformanceViewer.svelte";
export { default as PerformanceViewer } from './PerformanceViewer.svelte';

View File

@@ -1,43 +1,40 @@
<script lang="ts">
import type { Graph } from "$lib/types";
import { defaultPlant, plant, lottaFaces } from "$lib/graph-templates";
import type { ProjectManager } from "./project-manager.svelte";
import { defaultPlant, lottaFaces, plant } from '$lib/graph-templates';
import type { Graph } from '$lib/types';
import type { ProjectManager } from './project-manager.svelte';
const { projectManager } = $props<{ projectManager: ProjectManager }>();
let showNewProject = $state(false);
let newProjectName = $state("");
let selectedTemplate = $state("defaultPlant");
let newProjectName = $state('');
let selectedTemplate = $state('defaultPlant');
const templates = [
{
name: "Default Plant",
value: "defaultPlant",
graph: defaultPlant as unknown as Graph,
name: 'Default Plant',
value: 'defaultPlant',
graph: defaultPlant as unknown as Graph
},
{ name: "Plant", value: "plant", graph: plant as unknown as Graph },
{ name: 'Plant', value: 'plant', graph: plant as unknown as Graph },
{
name: "Lotta Faces",
value: "lottaFaces",
graph: lottaFaces as unknown as Graph,
},
name: 'Lotta Faces',
value: 'lottaFaces',
graph: lottaFaces as unknown as Graph
}
];
function handleCreate() {
const template =
templates.find((t) => t.value === selectedTemplate) || templates[0];
const template = templates.find((t) => t.value === selectedTemplate) || templates[0];
projectManager.handleCreateProject(template.graph, newProjectName);
newProjectName = "";
newProjectName = '';
showNewProject = false;
}
</script>
<header
class="flex justify-between px-4 h-[70px] border-b-1 border-[var(--outline)] items-center"
>
<header class="flex justify-between px-4 h-[70px] border-b-1 border-outline items-center">
<h3>Project</h3>
<button
class="px-3 py-1 bg-[var(--layer-0)] rounded"
class="px-3 py-1 bg-layer-0 rounded"
onclick={() => (showNewProject = !showNewProject)}
>
New
@@ -45,19 +42,19 @@
</header>
{#if showNewProject}
<div class="flex flex-col px-4 py-3 border-b-1 border-[var(--outline)] gap-2">
<div class="flex flex-col px-4 py-3 border-b-1 border-outline gap-2">
<input
type="text"
bind:value={newProjectName}
placeholder="Project name"
class="w-full px-2 py-2 bg-gray-800 border border-gray-700 rounded"
onkeydown={(e) => e.key === "Enter" && handleCreate()}
onkeydown={(e) => e.key === 'Enter' && handleCreate()}
/>
<select
bind:value={selectedTemplate}
class="w-full px-2 py-2 bg-gray-800 border border-gray-700 rounded"
>
{#each templates as template}
{#each templates as template (template.name)}
<option value={template.value}>{template.name}</option>
{/each}
</select>
@@ -79,19 +76,21 @@
{#each projectManager.projects as project (project.id)}
<li>
<div
class="w-full text-left px-3 py-2 rounded cursor-pointer {projectManager
class="
w-full text-left px-3 py-2 rounded cursor-pointer {projectManager
.activeProjectId.value === project.id
? 'bg-blue-600'
: 'bg-gray-800 hover:bg-gray-700'}"
: 'bg-gray-800 hover:bg-gray-700'}
"
onclick={() => projectManager.handleSelectProject(project.id!)}
role="button"
tabindex="0"
onkeydown={(e) =>
e.key === "Enter" &&
projectManager.handleSelectProject(project.id!)}
e.key === 'Enter'
&& projectManager.handleSelectProject(project.id!)}
>
<div class="flex justify-between items-center">
<span>{project.meta?.title || "Untitled"}</span>
<span>{project.meta?.title || 'Untitled'}</span>
<button
class="text-red-400 hover:text-red-300"
onclick={() => {

View File

@@ -31,6 +31,7 @@ export async function getGraph(id: number): Promise<Graph | undefined> {
export async function saveGraph(graph: Graph): Promise<Graph> {
const db = await getDB();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
graph.meta = { ...graph.meta, lastModified: new Date().toISOString() };
await db.put(STORE_NAME, graph);
return graph;

View File

@@ -25,7 +25,7 @@ export class ProjectManager {
this.projects = await db.getGraphs();
if (this.activeProjectId.value !== undefined) {
let loadedGraph = await db.getGraph(this.activeProjectId.value);
const loadedGraph = await db.getGraph(this.activeProjectId.value);
if (loadedGraph) {
this.graph = loadedGraph;
}

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import localStore from "$lib/helpers/localStore";
import { T, useTask } from "@threlte/core";
import { OrbitControls } from "@threlte/extras";
import { onMount } from "svelte";
import { Vector3 } from "three";
import type { PerspectiveCamera, Vector3Tuple } from "three";
import type { OrbitControls as OrbitControlsType } from "three/examples/jsm/controls/OrbitControls.js";
import localStore from '$lib/helpers/localStore';
import { T, useTask } from '@threlte/core';
import { OrbitControls } from '@threlte/extras';
import { onMount } from 'svelte';
import { Vector3 } from 'three';
import type { PerspectiveCamera, Vector3Tuple } from 'three';
import type { OrbitControls as OrbitControlsType } from 'three/examples/jsm/controls/OrbitControls.js';
let camera = $state<PerspectiveCamera>();
let controls = $state<OrbitControlsType>();
@@ -20,9 +20,9 @@
const cameraTransform = localStore<{
camera: Vector3Tuple;
target: Vector3Tuple;
}>("nodes.camera.transform", {
}>('nodes.camera.transform', {
camera: [10, 10, 10],
target: [0, 0, 0],
target: [0, 0, 0]
});
function saveCameraState() {
@@ -33,7 +33,7 @@
if (tPos.some((v) => isNaN(v)) || cPos.some((v) => isNaN(v))) return;
$cameraTransform = {
camera: cPos,
target: tPos,
target: tPos
};
}
@@ -54,13 +54,13 @@
$effect(() => {
if (
center &&
controls &&
centerCamera &&
(center.x !== controls.target.x ||
center.y !== controls.target.y ||
center.z !== controls.target.z) &&
!isRunning
center
&& controls
&& centerCamera
&& (center.x !== controls.target.x
|| center.y !== controls.target.y
|| center.z !== controls.target.z)
&& !isRunning
) {
isRunning = true;
task.start();

View File

@@ -1,23 +1,18 @@
<script lang="ts">
import { T, useTask, useThrelte } from "@threlte/core";
import { colors } from '$lib/graph-interface/graph/colors.svelte';
import { T, useTask, useThrelte } from '@threlte/core';
import { Grid, MeshLineGeometry, MeshLineMaterial, Text } from '@threlte/extras';
import {
Grid,
MeshLineGeometry,
MeshLineMaterial,
Text,
} from "@threlte/extras";
import {
type Group,
type BufferGeometry,
Vector3,
type Vector3Tuple,
Box3,
type BufferGeometry,
type Group,
Mesh,
MeshBasicMaterial,
} from "three";
import { appSettings } from "../settings/app-settings.svelte";
import Camera from "./Camera.svelte";
import { colors } from "$lib/graph-interface/graph/colors.svelte";
Vector3,
type Vector3Tuple
} from 'three';
import { appSettings } from '../settings/app-settings.svelte';
import Camera from './Camera.svelte';
const { renderStage, invalidate: _invalidate } = useThrelte();
@@ -32,7 +27,7 @@
lines,
centerCamera,
fps = $bindable(),
scene = $bindable(),
scene = $bindable()
}: Props = $props();
let geometries = $state.raw<BufferGeometry[]>([]);
@@ -43,13 +38,13 @@
fps.push(1 / delta);
fps = fps.slice(-100);
},
{ stage: renderStage, autoInvalidate: false },
{ stage: renderStage, autoInvalidate: false }
);
export const invalidate = function () {
export const invalidate = function() {
if (scene) {
const geos: BufferGeometry[] = [];
scene.traverse(function (child) {
scene.traverse(function(child) {
if (isMesh(child)) {
geos.push(child.geometry);
}
@@ -67,17 +62,29 @@
_invalidate();
};
function isMesh(child: Mesh | any): child is Mesh {
return child.isObject3D && "material" in child;
function isMesh(child: unknown): child is Mesh {
return (
child !== null
&& typeof child === 'object'
&& 'isObject3D' in child
&& child.isObject3D === true
&& 'material' in child
);
}
function isMatCapMaterial(material: any): material is MeshBasicMaterial {
return material.isMaterial && "matcap" in material;
function isMatCapMaterial(material: unknown): material is MeshBasicMaterial {
return (
material !== null
&& typeof material === 'object'
&& 'isMaterial' in material
&& material.isMaterial === true
&& 'matcap' in material
);
}
$effect(() => {
const wireframe = appSettings.value.debug.wireframe;
scene.traverse(function (child) {
scene.traverse(function(child) {
if (isMesh(child) && isMatCapMaterial(child.material) && child.visible) {
child.material.wireframe = wireframe;
}
@@ -89,7 +96,7 @@
return [
geo.attributes.position.array[i],
geo.attributes.position.array[i + 1],
geo.attributes.position.array[i + 2],
geo.attributes.position.array[i + 2]
] as Vector3Tuple;
}
</script>
@@ -98,12 +105,12 @@
{#if appSettings.value.showGrid}
<Grid
cellColor={colors["outline"]}
cellColor={colors['outline']}
cellThickness={0.7}
infiniteGrid
sectionThickness={0.7}
sectionDistance={2}
sectionColor={colors["outline"]}
sectionColor={colors['outline']}
fadeDistance={50}
fadeStrength={10}
fadeOrigin={new Vector3(0, 0, 0)}
@@ -112,9 +119,9 @@
<T.Group>
{#if geometries}
{#each geometries as geo}
{#each geometries as geo (geo.id)}
{#if appSettings.value.debug.showIndices}
{#each geo.attributes.position.array as _, i}
{#each geo.attributes.position.array, i (i)}
{#if i % 3 === 0}
<Text fontSize={0.25} position={getPosition(geo, i)} />
{/if}
@@ -134,7 +141,7 @@
</T.Group>
{#if appSettings.value.debug.showStemLines && lines}
{#each lines as line}
{#each lines as line (line[0].x + '-' + line[0].y + '-' + '' + line[0].z)}
<T.Mesh>
<MeshLineGeometry points={line} />
<MeshLineMaterial width={0.1} color="red" depthTest={false} />

View File

@@ -1,23 +1,20 @@
<script lang="ts">
import { Canvas } from "@threlte/core";
import Scene from "./Scene.svelte";
import { Vector3 } from "three";
import { decodeFloat, splitNestedArray } from "@nodarium/utils";
import type { PerformanceStore } from "@nodarium/utils";
import { appSettings } from "$lib/settings/app-settings.svelte";
import SmallPerformanceViewer from "$lib/performance/SmallPerformanceViewer.svelte";
import { MeshMatcapMaterial, TextureLoader, type Group } from "three";
import {
createGeometryPool,
createInstancedGeometryPool,
} from "./geometryPool";
import SmallPerformanceViewer from '$lib/performance/SmallPerformanceViewer.svelte';
import { appSettings } from '$lib/settings/app-settings.svelte';
import { decodeFloat, splitNestedArray } from '@nodarium/utils';
import type { PerformanceStore } from '@nodarium/utils';
import { Canvas } from '@threlte/core';
import { Vector3 } from 'three';
import { type Group, MeshMatcapMaterial, TextureLoader } from 'three';
import { createGeometryPool, createInstancedGeometryPool } from './geometryPool';
import Scene from './Scene.svelte';
const loader = new TextureLoader();
const matcap = loader.load("/matcap_green.jpg");
matcap.colorSpace = "srgb";
const matcap = loader.load('/matcap_green.jpg');
matcap.colorSpace = 'srgb';
const material = new MeshMatcapMaterial({
color: 0xffffff,
matcap,
matcap
});
let sceneComponent = $state<ReturnType<typeof Scene>>();
@@ -34,7 +31,7 @@
return {
totalFaces: meshes.totalFaces + faces.totalFaces,
totalVertices: meshes.totalVertices + faces.totalVertices,
totalVertices: meshes.totalVertices + faces.totalVertices
};
}
@@ -64,13 +61,12 @@
}
export const update = function update(result: Int32Array) {
console.log({ result });
perf.addPoint("split-result");
perf.addPoint('split-result');
const inputs = splitNestedArray(result);
perf.endPoint();
if (appSettings.value.debug.showStemLines) {
perf.addPoint("create-lines");
perf.addPoint('create-lines');
lines = inputs
.map((input) => {
if (input[0] === 0) {
@@ -81,13 +77,13 @@
perf.endPoint();
}
perf.addPoint("update-geometries");
perf.addPoint('update-geometries');
const { totalVertices, totalFaces } = updateGeometries(inputs, scene);
perf.endPoint();
perf.addPoint("total-vertices", totalVertices);
perf.addPoint("total-faces", totalFaces);
perf.addPoint('total-vertices', totalVertices);
perf.addPoint('total-faces', totalFaces);
sceneComponent?.invalidate();
};
</script>

View File

@@ -1,4 +1,4 @@
import { fastHashArrayBuffer } from "@nodarium/utils";
import { fastHashArrayBuffer } from '@nodarium/utils';
import {
BufferAttribute,
BufferGeometry,
@@ -7,14 +7,14 @@ import {
InstancedMesh,
Material,
Matrix4,
Mesh,
} from "three";
Mesh
} from 'three';
function fastArrayHash(arr: Int32Array) {
const sampleDistance = Math.max(Math.floor(arr.length / 1000), 1);
const sampleCount = Math.floor(arr.length / sampleDistance);
let hash = new Int32Array(sampleCount);
const hash = new Int32Array(sampleCount);
for (let i = 0; i < sampleCount; i++) {
const index = i * sampleDistance;
@@ -28,18 +28,18 @@ export function createGeometryPool(parentScene: Group, material: Material) {
const scene = new Group();
parentScene.add(scene);
let meshes: Mesh[] = [];
const meshes: Mesh[] = [];
let totalVertices = 0;
let totalFaces = 0;
function updateSingleGeometry(
data: Int32Array,
existingMesh: Mesh | null = null,
existingMesh: Mesh | null = null
) {
let hash = fastArrayHash(data);
const hash = fastArrayHash(data);
let geometry = existingMesh ? existingMesh.geometry : new BufferGeometry();
const geometry = existingMesh ? existingMesh.geometry : new BufferGeometry();
if (existingMesh) {
existingMesh.visible = true;
}
@@ -65,8 +65,8 @@ export function createGeometryPool(parentScene: Group, material: Material) {
const vertices = new Float32Array(data.buffer, index * 4, vertexCount * 3);
index = index + vertexCount * 3;
let posAttribute = geometry.getAttribute(
"position",
const posAttribute = geometry.getAttribute(
'position'
) as BufferAttribute | null;
if (posAttribute && posAttribute.count === vertexCount) {
@@ -74,8 +74,8 @@ export function createGeometryPool(parentScene: Group, material: Material) {
posAttribute.needsUpdate = true;
} else {
geometry.setAttribute(
"position",
new Float32BufferAttribute(vertices, 3),
'position',
new Float32BufferAttribute(vertices, 3)
);
}
@@ -83,27 +83,27 @@ export function createGeometryPool(parentScene: Group, material: Material) {
index = index + vertexCount * 3;
if (
geometry.userData?.faceCount !== faceCount ||
geometry.userData?.vertexCount !== vertexCount
geometry.userData?.faceCount !== faceCount
|| geometry.userData?.vertexCount !== vertexCount
) {
// Add data to geometry
geometry.setIndex([...indices]);
}
const normalsAttribute = geometry.getAttribute(
"normal",
'normal'
) as BufferAttribute | null;
if (normalsAttribute && normalsAttribute.count === vertexCount) {
normalsAttribute.set(normals, 0);
normalsAttribute.needsUpdate = true;
} else {
geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3));
}
geometry.userData = {
vertexCount,
faceCount,
hash,
hash
};
if (!existingMesh) {
@@ -119,7 +119,7 @@ export function createGeometryPool(parentScene: Group, material: Material) {
totalFaces = 0;
for (let i = 0; i < Math.max(newData.length, meshes.length); i++) {
const existingMesh = meshes[i];
let input = newData[i];
const input = newData[i];
if (input) {
updateSingleGeometry(input, existingMesh || null);
} else if (existingMesh) {
@@ -127,13 +127,13 @@ export function createGeometryPool(parentScene: Group, material: Material) {
}
}
return { totalVertices, totalFaces };
},
}
};
}
export function createInstancedGeometryPool(
parentScene: Group,
material: Material,
material: Material
) {
const scene = new Group();
parentScene.add(scene);
@@ -144,11 +144,11 @@ export function createInstancedGeometryPool(
function updateSingleInstance(
data: Int32Array,
existingInstance: InstancedMesh | null = null,
existingInstance: InstancedMesh | null = null
) {
let hash = fastArrayHash(data);
const hash = fastArrayHash(data);
let geometry = existingInstance
const geometry = existingInstance
? existingInstance.geometry
: new BufferGeometry();
@@ -169,8 +169,8 @@ export function createInstancedGeometryPool(
const indices = data.subarray(index, indicesEnd);
index = indicesEnd;
if (
geometry.userData?.faceCount !== faceCount ||
geometry.userData?.vertexCount !== vertexCount
geometry.userData?.faceCount !== faceCount
|| geometry.userData?.vertexCount !== vertexCount
) {
// Add data to geometry
geometry.setIndex([...indices]);
@@ -179,34 +179,34 @@ export function createInstancedGeometryPool(
// Vertices
const vertices = new Float32Array(data.buffer, index * 4, vertexCount * 3);
index = index + vertexCount * 3;
let posAttribute = geometry.getAttribute(
"position",
const posAttribute = geometry.getAttribute(
'position'
) as BufferAttribute | null;
if (posAttribute && posAttribute.count === vertexCount) {
posAttribute.set(vertices, 0);
posAttribute.needsUpdate = true;
} else {
geometry.setAttribute(
"position",
new Float32BufferAttribute(vertices, 3),
'position',
new Float32BufferAttribute(vertices, 3)
);
}
const normals = new Float32Array(data.buffer, index * 4, vertexCount * 3);
index = index + vertexCount * 3;
const normalsAttribute = geometry.getAttribute(
"normal",
'normal'
) as BufferAttribute | null;
if (normalsAttribute && normalsAttribute.count === vertexCount) {
normalsAttribute.set(normals, 0);
normalsAttribute.needsUpdate = true;
} else {
geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3));
}
if (
existingInstance &&
instanceCount > existingInstance.geometry.userData.count
existingInstance
&& instanceCount > existingInstance.geometry.userData.count
) {
scene.remove(existingInstance);
instances.splice(instances.indexOf(existingInstance), 1);
@@ -226,12 +226,12 @@ export function createInstancedGeometryPool(
const matrices = new Float32Array(
data.buffer,
index * 4,
instanceCount * 16,
instanceCount * 16
);
for (let i = 0; i < instanceCount; i++) {
const matrix = new Matrix4().fromArray(
matrices.subarray(i * 16, i * 16 + 16),
matrices.subarray(i * 16, i * 16 + 16)
);
existingInstance.setMatrixAt(i, matrix);
}
@@ -241,9 +241,9 @@ export function createInstancedGeometryPool(
faceCount,
count: Math.max(
instanceCount,
existingInstance.geometry.userData.count || 0,
existingInstance.geometry.userData.count || 0
),
hash,
hash
};
existingInstance.instanceMatrix.needsUpdate = true;
@@ -255,7 +255,7 @@ export function createInstancedGeometryPool(
totalFaces = 0;
for (let i = 0; i < Math.max(newData.length, instances.length); i++) {
const existingMesh = instances[i];
let input = newData[i];
const input = newData[i];
if (input) {
updateSingleInstance(input, existingMesh || null);
} else if (existingMesh) {
@@ -263,6 +263,6 @@ export function createInstancedGeometryPool(
}
}
return { totalVertices, totalFaces };
},
}
};
}

View File

@@ -1,4 +1,3 @@
export * from "./runtime-executor"
export * from "./runtime-executor-cache"
export * from "./worker-runtime-executor"
export * from './runtime-executor';
export * from './runtime-executor-cache';
export * from './worker-runtime-executor';

View File

@@ -1,18 +1,18 @@
import type { Graph, RuntimeExecutor } from "@nodarium/types";
import type { Graph, RuntimeExecutor } from '@nodarium/types';
export class RemoteRuntimeExecutor implements RuntimeExecutor {
constructor(private url: string) {}
constructor(private url: string) { }
async execute(graph: Graph, settings: Record<string, any>): Promise<Int32Array> {
const res = await fetch(this.url, { method: "POST", body: JSON.stringify({ graph, settings }) });
async execute(graph: Graph, settings: Record<string, unknown>): Promise<Int32Array> {
const res = await fetch(this.url, {
method: 'POST',
body: JSON.stringify({ graph, settings })
});
if (!res.ok) {
throw new Error(`Failed to execute graph`);
}
return new Int32Array(await res.arrayBuffer());
}
}

View File

@@ -1,4 +1,4 @@
import { type SyncCache } from "@nodarium/types";
import { type SyncCache } from '@nodarium/types';
export class MemoryRuntimeCache implements SyncCache {
private map = new Map<string, unknown>();

View File

@@ -1,4 +1,3 @@
import { RemoteNodeRegistry } from '@nodarium/registry';
import type {
Graph,
NodeDefinition,
@@ -8,133 +7,90 @@ import type {
SyncCache
} from '@nodarium/types';
import {
concatEncodedArrays,
createLogger,
createWasmWrapper,
encodeFloat,
fastHashArrayBuffer,
type PerformanceStore
} from '@nodarium/utils';
import type { RuntimeNode } from './types';
const log = createLogger('runtime-executor');
// log.mute(); // Keep logging enabled for debug info
log.mute();
const remoteRegistry = new RemoteNodeRegistry('');
type WasmExecute = (outputPos: number, args: number[]) => number;
function getValue(input: NodeInput, value?: unknown): number | number[] | Int32Array {
function getValue(input: NodeInput, value?: unknown) {
if (value === undefined && 'value' in input) {
value = input.value;
}
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 (input.type === 'float') {
return encodeFloat(value as number);
}
if (Array.isArray(value)) {
return [0, value.length + 1, ...value, 1, 1];
if (input.type === 'vec3') {
return [
0,
value.length + 1,
...value.map((v) => encodeFloat(v)),
1,
1
] as number[];
}
return [0, value.length + 1, ...value, 1, 1] as number[];
}
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}`);
}
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;
if (typeof value === 'boolean') {
return value ? 1 : 0;
}
return true;
}
export type Pointer = {
start: number;
end: number;
_title?: string;
};
if (typeof value === 'number') {
return value;
}
if (value instanceof Int32Array) {
return value;
}
throw new Error(`Unknown input type ${input.type}`);
}
export class MemoryRuntimeExecutor implements RuntimeExecutor {
private nodes = new Map<string, { definition: NodeDefinition; execute: WasmExecute }>();
private definitionMap: Map<string, NodeDefinition> = new Map();
private offset = 0;
private isRunning = false;
private seed = Math.floor(Math.random() * 100000000);
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 readonly registry: NodeRegistry,
private 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(n => n.type));
log.info(`Loaded ${graph.nodes.length} node types from registry`);
await this.registry.load(graph.nodes.map((node) => node.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`);
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);
}
}
}
return this.map;
return typeMap;
}
private async addMetaData(graph: Graph) {
this.nodes = await this.getNodeDefinitions(graph);
log.info(`Metadata added for ${this.nodes.size} nodes`);
// First, lets check if all nodes have a definition
this.definitionMap = await this.getNodeDefinitions(graph);
const graphNodes = graph.nodes.map(node => {
const n = node as RuntimeNode;
@@ -147,175 +103,174 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
return n;
});
const outputNode = graphNodes.find(n => n.type.endsWith('/output') || n.type.endsWith('/debug'))
?? graphNodes[0];
const nodeMap = new Map(graphNodes.map(n => [n.id, n]));
for (const [parentId, , childId, childInput] of graph.edges) {
const parent = nodeMap.get(parentId);
const child = nodeMap.get(childId);
if (!parent || !child) continue;
parent.state.children.push(child);
child.state.parents.push(parent);
child.state.inputNodes[childInput] = parent;
const outputNode = graphNodes.find((node) => node.type.endsWith('/output'));
if (!outputNode) {
throw new Error('No output node found');
}
const ordered: RuntimeNode[] = [];
const stack = [outputNode];
const nodeMap = new Map(
graphNodes.map((node) => [node.id, node])
);
// 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) {
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 stack = [outputNode];
while (stack.length) {
const node = stack.pop()!;
const node = stack.pop();
if (!node) continue;
for (const parent of node.state.parents) {
parent.state = parent.state || {};
parent.state.depth = node.state.depth + 1;
stack.push(parent);
}
ordered.push(node);
nodes.push(node);
}
log.info(`Output node: ${outputNode.id}, total nodes ordered: ${ordered.length}`);
return [outputNode, ordered] as const;
return [outputNode, nodes] as const;
}
private writeToMemory(value: number | number[] | Int32Array, title?: string): Pointer {
const start = this.offset;
async execute(graph: Graph, settings: Record<string, unknown>) {
this.perf?.addPoint('runtime');
if (typeof value === 'number') {
this.memoryView[this.offset++] = value;
} else {
this.memoryView.set(value, this.offset);
this.offset += value.length;
let a = performance.now();
// 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);
/*
* Here we sort the nodes into buckets, which we then execute one by one
* +-b2-+-b1-+---b0---+
* | | | |
* | n3 | n2 | Output |
* | n6 | n4 | Level |
* | | n5 | |
* | | | |
* +----+----+--------+
*/
// we execute the nodes from the bottom up
const sortedNodes = nodes.sort(
(a, b) => (b.state?.depth || 0) - (a.state?.depth || 0)
);
// here we store the intermediate results of the nodes
const results: Record<string, Int32Array> = {};
if (settings['randomSeed']) {
this.seed = Math.floor(Math.random() * 100000000);
}
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;
}
for (const node of sortedNodes) {
const node_type = this.definitionMap.get(node.type)!;
async execute(graph: Graph, settings: Record<string, unknown>): Promise<Int32Array> {
if (this.isRunning) {
log.info('Executor is already running, skipping execution');
return undefined as unknown as Int32Array;
}
this.isRunning = true;
log.info('Execution started');
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);
if (!node_type || !node.state || !node_type.execute) {
log.warn(`Node ${node.id} has no definition`);
continue;
}
let lastNodePtr: Pointer | undefined = undefined;
a = performance.now();
for (const node of sortedNodes) {
const nodeType = this.nodes.get(node.type);
if (!nodeType) continue;
// Collect the inputs for the node
const inputs = Object.entries(node_type.inputs || {}).map(
([key, input]) => {
if (input.type === 'seed') {
return this.seed;
}
log.info(`Executing node: ${node.id} (type: ${node.type})`);
// If the input is linked to a setting, we use that value
if (input.setting) {
return getValue(input, settings[input.setting]);
}
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}`
// 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 this.writeToMemory(getValue(input), `${node.id}.${key}`);
return results[inputNode.id];
}
);
this.inputPtrs[node.id] = inputs;
const args = inputs.flatMap(p => [p.start * 4, p.end * 4]);
// If the value is stored in the node itself, we use that value
if (node.props?.[key] !== undefined) {
return getValue(input, node.props[key]);
}
log.info(`Executing node ${node.type}/${node.id}`);
const bytesWritten = nodeType.execute(this.offset * 4, args);
if (bytesWritten === -1) {
throw new Error(`Failed to execute node`);
return getValue(input);
}
this.refreshView();
);
b = performance.now();
const outLen = bytesWritten >> 2;
const outputStart = this.offset;
this.perf?.addPoint('collected-inputs', b - a);
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];
log.info(`Node ${node.id} result reused input memory`);
} else {
this.results[node.id] = {
start: outputStart,
end: outputStart + outLen,
_title: `${node.id} ->`
};
this.offset += outLen;
lastNodePtr = this.results[node.id];
log.info(
`Node ${node.id} wrote result to memory: start=${outputStart}, end=${outputStart + outLen
}`
);
try {
a = performance.now();
const encoded_inputs = concatEncodedArrays(inputs);
b = performance.now();
this.perf?.addPoint('encoded-inputs', b - a);
a = performance.now();
const inputHash = `node-${node.id}-${fastHashArrayBuffer(encoded_inputs)}`;
b = performance.now();
this.perf?.addPoint('hash-inputs', b - a);
const cachedValue = this.cache?.get(inputHash);
if (cachedValue !== undefined) {
log.log(`Using cached value for ${node_type.id || node.id}`);
this.perf?.addPoint('cache-hit', 1);
results[node.id] = cachedValue as Int32Array;
continue;
}
this.perf?.addPoint('cache-hit', 0);
log.group(`executing ${node_type.id}-${node.id}`);
log.log(`Inputs:`, inputs);
a = performance.now();
results[node.id] = node_type.execute(encoded_inputs);
log.log('Executed', node.type, node.id);
b = performance.now();
if (this.cache && node.id !== outputNode.id) {
this.cache.set(inputHash, 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);
}
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;
this.perf?.endPoint('runtime');
log.info('Executor state reset');
}
// return the result of the parent of the output node
const res = results[outputNode.id];
if (this.cache) {
this.cache.size = sortedNodes.length * 2;
}
this.perf?.endPoint('runtime');
return res as unknown as Int32Array;
}
getPerformanceData() {

View File

@@ -1,10 +1,10 @@
import type { SerializedNode } from "@nodarium/types";
import type { SerializedNode } from '@nodarium/types';
type RuntimeState = {
depth: number
parents: RuntimeNode[],
children: RuntimeNode[],
inputNodes: Record<string, RuntimeNode>
}
depth: number;
parents: RuntimeNode[];
children: RuntimeNode[];
inputNodes: Record<string, RuntimeNode>;
};
export type RuntimeNode = SerializedNode & { state: RuntimeState }
export type RuntimeNode = SerializedNode & { state: RuntimeState };

View File

@@ -1,13 +1,13 @@
import { MemoryRuntimeExecutor } from "./runtime-executor";
import { RemoteNodeRegistry, IndexDBCache } from "@nodarium/registry";
import type { Graph } from "@nodarium/types";
import { createPerformanceStore } from "@nodarium/utils";
import { MemoryRuntimeCache } from "./runtime-executor-cache";
import { IndexDBCache, RemoteNodeRegistry } from '$lib/node-registry/index';
import type { Graph } from '@nodarium/types';
import { createPerformanceStore } from '@nodarium/utils';
import { MemoryRuntimeExecutor } from './runtime-executor';
import { MemoryRuntimeCache } from './runtime-executor-cache';
const indexDbCache = new IndexDBCache("node-registry");
const nodeRegistry = new RemoteNodeRegistry("", indexDbCache);
const indexDbCache = new IndexDBCache('node-registry');
const nodeRegistry = new RemoteNodeRegistry('', indexDbCache);
const cache = new MemoryRuntimeCache()
const cache = new MemoryRuntimeCache();
const executor = new MemoryRuntimeExecutor(nodeRegistry, cache);
const performanceStore = createPerformanceStore();
@@ -31,11 +31,11 @@ export async function setUseRuntimeCache(useCache: boolean) {
export async function executeGraph(
graph: Graph,
settings: Record<string, unknown>,
settings: Record<string, unknown>
): Promise<Int32Array> {
await nodeRegistry.load(graph.nodes.map((n) => n.type));
performanceStore.startRun();
let res = await executor.execute(graph, settings);
const res = await executor.execute(graph, settings);
performanceStore.stopRun();
return res;
}

View File

@@ -1,9 +1,10 @@
/// <reference types="vite-plugin-comlink/client" />
import type { Graph, RuntimeExecutor } from "@nodarium/types";
import type { Graph, RuntimeExecutor } from '@nodarium/types';
export class WorkerRuntimeExecutor implements RuntimeExecutor {
private worker = new ComlinkWorker<typeof import('./worker-runtime-executor-backend.ts')>(new URL(`./worker-runtime-executor-backend.ts`, import.meta.url));
private worker = new ComlinkWorker<typeof import('./worker-runtime-executor-backend.ts')>(
new URL(`./worker-runtime-executor-backend.ts`, import.meta.url)
);
async execute(graph: Graph, settings: Record<string, unknown>) {
return this.worker.executeGraph(graph, settings);
@@ -18,4 +19,3 @@ export class WorkerRuntimeExecutor implements RuntimeExecutor {
this.worker.setUseRegistryCache(useCache);
}
}

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import NestedSettings from "./NestedSettings.svelte";
import { localState } from "$lib/helpers/localState.svelte";
import type { NodeInput } from "@nodarium/types";
import Input from "@nodarium/ui";
import { localState } from '$lib/helpers/localState.svelte';
import type { NodeInput } from '@nodarium/types';
import Input from '@nodarium/ui';
import { onMount } from 'svelte';
import NestedSettings from './NestedSettings.svelte';
type Button = { type: "button"; callback: () => void; label?: string };
type Button = { type: 'button'; label?: string };
type InputType = NodeInput | Button;
@@ -12,7 +13,7 @@
interface SettingsGroup {
title?: string;
[key: string]: any;
[key: string]: unknown;
}
type SettingsType = Record<string, SettingsNode>;
@@ -31,44 +32,49 @@
};
// Local persistent state for <details> sections
const openSections = localState<Record<string, boolean>>("open-details", {});
const openSections = localState<Record<string, boolean>>('open-details', {});
let { id, key = "", value = $bindable(), type, depth = 0 }: Props = $props();
let { id, key = '', value = $bindable(), type, depth = 0 }: Props = $props();
function isNodeInput(v: SettingsNode | undefined): v is InputType {
return !!v && typeof v === "object" && "type" in v;
return !!v && typeof v === 'object' && 'type' in v;
}
function getDefaultValue(): unknown {
if (key === "" || key === "title") return;
function getDefaultValue(): NodeInput['value'] | undefined {
if (key === '' || key === 'title') return;
const node = type[key];
const node = type[key] as SettingsNode;
const inputValue = value[key];
if (!isNodeInput(node)) return;
const anyNode = node as any;
// select input: use index into options
if (Array.isArray(anyNode.options)) {
if (value?.[key] !== undefined) {
return anyNode.options.indexOf(value[key]);
if ('options' in node && Array.isArray(node.options)) {
if (typeof inputValue === 'string') {
return node.options.indexOf(inputValue);
}
return 0;
}
if (value?.[key] !== undefined) return value[key];
// If the component is supplied with a default value use that
if (inputValue !== undefined && typeof inputValue !== 'object') {
return inputValue;
}
if ("value" in node && anyNode.value !== undefined) {
return anyNode.value;
if ('value' in node) {
const nodeValue = node.value;
if (nodeValue !== null && nodeValue !== undefined) {
return nodeValue;
}
}
switch (node.type) {
case "boolean":
case 'boolean':
return 0;
case "float":
case 'float':
return 0.5;
case "integer":
case "select":
case 'integer':
case 'select':
return 0;
default:
return 0;
@@ -77,52 +83,63 @@
let internalValue = $state(getDefaultValue());
let open = $state(openSections.value[id]);
// Persist <details> open/closed state for groups
if (depth > 0 && !isNodeInput(type[key!])) {
$effect(() => {
if (open !== undefined) {
openSections.value[id] = open;
}
});
}
let open = $state(false);
// Sync internalValue back into `value`
$effect(() => {
if (key === "" || internalValue === undefined) return;
if (key === '' || internalValue === undefined) return;
const node = type[key];
if (
isNodeInput(node) &&
Array.isArray((node as any).options) &&
typeof internalValue === "number"
isNodeInput(node)
&& 'options' in node
&& Array.isArray(node.options)
&& typeof internalValue === 'number'
) {
value[key] = (node as any)?.options?.[internalValue] as any;
} else {
value[key] = internalValue as any;
value[key] = node?.options?.[internalValue];
} else if (internalValue) {
value[key] = internalValue;
}
});
function handleClick() {
const callback = value[key] as unknown as () => void;
callback();
}
onMount(() => {
open = openSections.value[id];
// Persist <details> open/closed state for groups
if (depth > 0 && !isNodeInput(type[key!])) {
$effect(() => {
if (open !== undefined) {
openSections.value[id] = open;
}
});
}
});
</script>
{#if key && isNodeInput(type?.[key])}
{@const inputType = type[key]}
<!-- Leaf input -->
<div class="input input-{type[key].type}" class:first-level={depth === 1}>
{#if type[key].type === "button"}
<button onclick={() => "callback" in type[key] && type[key].callback()}>
{type[key].label || key}
<div class="input input-{inputType.type}" class:first-level={depth === 1}>
{#if inputType.type === 'button'}
<button onclick={handleClick}>
{inputType.label || key}
</button>
{:else}
{#if type[key].label !== ""}
<label for={id}>{type[key].label || key}</label>
{#if inputType.label !== ''}
<label for={id}>{inputType.label || key}</label>
{/if}
<Input {id} input={type[key]} bind:value={internalValue} />
<Input {id} input={inputType} bind:value={internalValue} />
{/if}
</div>
{:else if depth === 0}
<!-- Root: iterate over top-level keys -->
{#each Object.keys(type ?? {}).filter((k) => k !== "title") as childKey}
{#each Object.keys(type ?? {}).filter((k) => k !== 'title') as childKey (childKey)}
<NestedSettings
id={`${id}.${childKey}`}
key={childKey}
@@ -140,7 +157,7 @@
<details bind:open>
<summary><p>{(type[key] as SettingsGroup).title || key}</p></summary>
<div class="content">
{#each Object.keys(type[key] as SettingsGroup).filter((k) => k !== "title") as childKey}
{#each Object.keys(type[key] as SettingsGroup).filter((k) => k !== 'title') as childKey (childKey)}
<NestedSettings
id={`${id}.${childKey}`}
key={childKey}
@@ -194,6 +211,11 @@
padding-left: 1em;
padding-right: 1em;
padding-bottom: 1px;
gap: 3px;
}
.first-level.input-boolean {
gap: 10px;
}
button {
@@ -201,11 +223,10 @@
}
hr {
position: absolute;
margin: 0;
left: 0;
right: 0;
border: none;
border-bottom: solid thin var(--outline);
border-bottom: solid thin var(--color-outline);
}
</style>

View File

@@ -43,11 +43,6 @@ export const AppSettingTypes = {
type: 'boolean',
label: 'Show Help',
value: false
},
showNodeIds: {
type: 'boolean',
label: 'Show Node Ids',
value: false
}
},
debug: {
@@ -137,16 +132,16 @@ export const AppSettingTypes = {
}
} as const;
type SettingsToStore<T> = T extends { value: infer V } ? V extends readonly string[] ? V[number]
: V
: T extends any[] ? {}
type SettingsToStore<T> = T extends { type: 'button' } ? () => void
: T extends { value: infer V } ? V extends readonly string[] ? V[number]
: V
: T extends object ? {
[K in keyof T as T[K] extends object ? K : never]: SettingsToStore<T[K]>;
}
-readonly [K in keyof T as T[K] extends object ? K : never]: SettingsToStore<T[K]>;
}
: never;
export function settingsToStore<T>(settings: T): SettingsToStore<T> {
const result = {} as any;
const result = {} as Record<string, unknown>;
for (const key in settings) {
const value = settings[key];
if (value && typeof value === 'object') {
@@ -157,10 +152,10 @@ export function settingsToStore<T>(settings: T): SettingsToStore<T> {
}
}
}
return result;
return result as SettingsToStore<T>;
}
export let appSettings = localState(
export const appSettings = localState(
'app-settings',
settingsToStore(AppSettingTypes)
);

View File

@@ -1,6 +1,6 @@
import type { NodeInput } from "@nodarium/types";
import type { NodeInput } from '@nodarium/types';
type Button = { type: "button"; label?: string };
type Button = { type: 'button'; label?: string };
export type SettingsStore = {
[key: string]: SettingsStore | string | number | boolean;
@@ -23,5 +23,5 @@ export type SettingsValue = Record<
>;
export function isNodeInput(v: SettingsNode | undefined): v is InputType {
return !!v && "type" in v;
return !!v && 'type' in v;
}

View File

@@ -1,14 +1,14 @@
<script lang="ts">
import { type Snippet } from "svelte";
import { panelState } from "./PanelState.svelte";
import { type Snippet } from 'svelte';
import { type Panel, panelState } from './PanelState.svelte';
const {
id,
icon = "",
title = "",
classes = "",
icon = '',
title = '',
classes = '',
hidden,
children,
children
} = $props<{
id: string;
icon?: string;
@@ -18,7 +18,13 @@
children?: Snippet;
}>();
const panel = panelState.registerPanel(id, icon, classes, hidden);
let panel = $state<Panel>(null!);
$effect(() => {
panelState.unregisterPanel(id);
panel = panelState.registerPanel(id, icon, classes, hidden);
});
$effect(() => {
panel.hidden = hidden;
});
@@ -27,8 +33,8 @@
{#if panelState.activePanel.value === id}
<div class="wrapper" class:hidden>
{#if title}
<header>
<h3>{title}</h3>
<header class="bg-layer-2">
<h3 class="font-bold">{title}</h3>
</header>
{/if}
{@render children?.()}
@@ -37,7 +43,7 @@
<style>
header {
border-bottom: solid thin var(--outline);
border-bottom: solid thin var(--color-outline);
height: 70px;
display: flex;
align-items: center;

View File

@@ -1,6 +1,6 @@
import { localState } from '$lib/helpers/localState.svelte';
type Panel = {
export type Panel = {
icon: string;
classes: string;
hidden?: boolean;
@@ -14,6 +14,10 @@ class PanelState {
return Object.keys(this.panels);
}
public unregisterPanel(id: string) {
delete this.panels[id];
}
public registerPanel(id: string, icon: string, classes: string, hidden: boolean): Panel {
const state = $state({
icon: icon,

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { type Snippet } from "svelte";
import { panelState as state } from "./PanelState.svelte";
import { type Snippet } from 'svelte';
import { panelState as state } from './PanelState.svelte';
const { children } = $props<{ children?: Snippet }>();
</script>
@@ -8,7 +8,6 @@
<div class="wrapper" class:visible={state.activePanel.value}>
<div class="tabs">
<button aria-label="Close" onclick={() => state.toggleOpen()}>
<span class="icon-[tabler--settings]"></span>
<span class="absolute i-[tabler--chevron-left] w-6 h-6 block"></span>
</button>
{#each state.keys as panelId (panelId)}
@@ -19,8 +18,7 @@
class:active={panelId === state.activePanel.value}
onclick={() => (state.activePanel.value = panelId)}
>
<span class={`block w-6 h-6 iconify ${state.panels[panelId].icon}`}
></span>
<span class={`block w-6 h-6 iconify ${state.panels[panelId].icon}`}></span>
</button>
{/if}
{/each}
@@ -40,15 +38,13 @@
height: 100%;
right: 0px;
transform: translateX(calc(100% - 30px));
transition:
transform 0.2s,
background 0.2s ease;
transition: transform 0.2s, background 0.2s ease;
width: 30%;
min-width: 350px;
}
.content {
background: var(--layer-1);
background: var(--color-layer-1);
z-index: 10;
position: relative;
max-height: 100vh;
@@ -58,7 +54,7 @@
.tabs {
display: flex;
flex-direction: column;
border-right: solid thin var(--outline);
border-right: solid thin var(--color-outline);
}
.tabs > button {
@@ -69,9 +65,9 @@
border: none;
display: flex;
align-items: center;
border-bottom: solid thin var(--outline);
border-left: solid thin var(--outline);
background: var(--layer-1);
border-bottom: solid thin var(--color-outline);
border-left: solid thin var(--color-outline);
background: var(--color-layer-1);
}
.tabs > button > span {
@@ -79,7 +75,7 @@
}
.tabs > button.active {
background: var(--layer-2);
background: var(--color-layer-2);
}
.tabs > button.active span {

View File

@@ -1,7 +1,12 @@
<script lang="ts">
import type { NodeInstance, NodeInput } from "@nodarium/types";
import NestedSettings from "$lib/settings/NestedSettings.svelte";
import type { GraphManager } from "$lib/graph-interface/graph-manager.svelte";
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
import NestedSettings from '$lib/settings/NestedSettings.svelte';
import type { NodeId, NodeInput, NodeInstance } from '@nodarium/types';
type InternalNodeInput = NodeInput & {
__node_type?: NodeId;
__node_input: string;
};
type Props = {
manager: GraphManager;
@@ -11,40 +16,44 @@
const { manager, node = $bindable() }: Props = $props();
function filterInputs(inputs?: Record<string, NodeInput>) {
const _inputs = $state.snapshot(inputs);
const _inputs = $state.snapshot(
inputs as Record<string, InternalNodeInput>
);
return Object.fromEntries(
Object.entries(structuredClone(_inputs ?? {}))
.filter(([_key, value]) => {
.filter(([, value]) => {
return value.hidden === true;
})
.map(([key, value]) => {
//@ts-ignore
value.__node_type = node.state?.type.id;
//@ts-ignore
value.__node_type = node.state.type?.id;
value.__node_input = key;
return [key, value];
}),
})
);
}
const nodeDefinition = filterInputs(node.state?.type?.inputs);
const nodeDefinition = filterInputs(node.state.type?.inputs);
type Store = Record<string, number | number[]>;
let store = $state<Store>(createStore(node?.props, nodeDefinition));
function createStore(
props: NodeInstance["props"],
inputs: Record<string, NodeInput>,
props: NodeInstance['props'],
inputs: Record<string, NodeInput>
): Store {
const store: Store = {};
Object.keys(inputs).forEach((key) => {
if (props) {
//@ts-ignore
store[key] = props[key] || inputs[key].value;
const value = props[key] || inputs[key].value;
if (Array.isArray(value) || typeof value === 'number') {
store[key] = value;
} else {
console.error('Wrong error');
}
}
});
return store;
}
let lastPropsHash = "";
let lastPropsHash = '';
function updateNode() {
if (!node || !store) return;
let needsUpdate = false;
@@ -53,7 +62,10 @@
const key = _key as keyof typeof store;
if (node && store) {
needsUpdate = true;
node.props[key] = store[key];
const value = store[key];
if (value !== undefined) {
node.props[key] = value;
}
}
});
@@ -83,5 +95,5 @@
type={nodeDefinition}
/>
{:else}
<p class="mx-4">Node has no settings</p>
<p class="mx-4 mt-4">Node has no settings</p>
{/if}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { NodeInstance } from "@nodarium/types";
import type { GraphManager } from "$lib/graph-interface/graph-manager.svelte";
import ActiveNodeSelected from "./ActiveNodeSelected.svelte";
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
import type { NodeInstance } from '@nodarium/types';
import ActiveNodeSelected from './ActiveNodeSelected.svelte';
type Props = {
manager: GraphManager;
@@ -11,14 +11,16 @@
let { manager, node = $bindable() }: Props = $props();
</script>
<div class='{node?"border-l-2 pl-3.5!":""} bg-layer-2 flex items-center h-[70px] border-b-1 border-l-selected border-b-outline pl-4'>
<h3 class="font-bold">Node Settings</h3>
</div>
{#if node}
{#key node.id}
{#if node}
<ActiveNodeSelected {manager} bind:node />
{:else}
<p class="mx-4">Active Node has no Settings</p>
{/if}
{/key}
{:else}
<p class="mx-4">No node selected</p>
<p class="mx-4 mt-4">No node selected</p>
{/if}

View File

@@ -1,148 +1,148 @@
<script lang="ts" module>
let result:
| { stdev: number; avg: number; duration: number; samples: number[] }
| undefined = $state();
let result:
| { stdev: number; avg: number; duration: number; samples: number[] }
| undefined = $state();
</script>
<script lang="ts">
import { humanizeDuration } from '$lib/helpers';
import { localState } from '$lib/helpers/localState.svelte';
import Monitor from '$lib/performance/Monitor.svelte';
import { Number } from '@nodarium/ui';
import { writable } from 'svelte/store';
import { humanizeDuration } from '$lib/helpers';
import { localState } from '$lib/helpers/localState.svelte';
import Monitor from '$lib/performance/Monitor.svelte';
import { InputNumber } from '@nodarium/ui';
import { writable } from 'svelte/store';
function calculateStandardDeviation(array: number[]) {
const n = array.length;
const mean = array.reduce((a, b) => a + b) / n;
return Math.sqrt(
array.map((x) => Math.pow(x - mean, 2)).reduce((a, b) => a + b) / n
);
}
type Props = {
run: () => Promise<any>;
};
function calculateStandardDeviation(array: number[]) {
const n = array.length;
const mean = array.reduce((a, b) => a + b) / n;
return Math.sqrt(
array.map((x) => Math.pow(x - mean, 2)).reduce((a, b) => a + b) / n
);
}
type Props = {
run: () => Promise<unknown>;
};
const { run }: Props = $props();
const { run }: Props = $props();
let isRunning = $state(false);
let amount = localState<number>('nodes.benchmark.samples', 500);
let samples = $state(0);
let warmUp = writable(0);
let warmUpAmount = 10;
let status = '';
let isRunning = $state(false);
let amount = localState<number>('nodes.benchmark.samples', 500);
let samples = $state(0);
let warmUp = writable(0);
let warmUpAmount = 10;
let status = '';
const copyContent = async (text?: string | number) => {
if (!text) return;
if (typeof text !== 'string') {
text = (Math.floor(text * 100) / 100).toString();
}
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error('Failed to copy: ', err);
}
};
const copyContent = async (text?: string | number) => {
if (!text) return;
if (typeof text !== 'string') {
text = (Math.floor(text * 100) / 100).toString();
}
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error('Failed to copy: ', err);
}
};
async function benchmark() {
if (isRunning) return;
isRunning = true;
result = undefined;
samples = 0;
$warmUp = 0;
async function benchmark() {
if (isRunning) return;
isRunning = true;
result = undefined;
samples = 0;
$warmUp = 0;
await new Promise((r) => setTimeout(r, 50));
await new Promise((r) => setTimeout(r, 50));
// warm up
for (let i = 0; i < warmUpAmount; i++) {
await run();
$warmUp = i + 1;
}
// warm up
for (let i = 0; i < warmUpAmount; i++) {
await run();
$warmUp = i + 1;
}
let a = performance.now();
let results = [];
let a = performance.now();
let results = [];
// perform run
for (let i = 0; i < amount.value; i++) {
const a = performance.now();
await run();
samples = i;
const b = performance.now();
await new Promise((r) => setTimeout(r, 20));
results.push(b - a);
}
result = {
stdev: calculateStandardDeviation(results),
samples: results,
duration: performance.now() - a,
avg: results.reduce((a, b) => a + b) / results.length
};
}
// perform run
for (let i = 0; i < amount.value; i++) {
const a = performance.now();
await run();
samples = i;
const b = performance.now();
await new Promise((r) => setTimeout(r, 20));
results.push(b - a);
}
result = {
stdev: calculateStandardDeviation(results),
samples: results,
duration: performance.now() - a,
avg: results.reduce((a, b) => a + b) / results.length
};
}
</script>
{status}
<div class="wrapper" class:running={isRunning}>
{#if result}
<h3>Finished ({humanizeDuration(result.duration)})</h3>
<div class="monitor-wrapper">
<Monitor points={result.samples} />
</div>
<label for="bench-avg">Average </label>
<button
id="bench-avg"
onkeydown={(ev) => ev.key === 'Enter' && copyContent(result?.avg)}
onclick={() => copyContent(result?.avg)}
>
{Math.floor(result.avg * 100) / 100}
</button>
<i
role="button"
tabindex="0"
onkeydown={(ev) => ev.key === 'Enter' && copyContent(result?.avg)}
onclick={() => copyContent(result?.avg)}
>(click to copy)</i>
<label for="bench-stdev">Standard Deviation σ</label>
<button id="bench-stdev" onclick={() => copyContent(result?.stdev)}>
{Math.floor(result.stdev * 100) / 100}
</button>
<i
role="button"
tabindex="0"
onkeydown={(ev) => ev.key === 'Enter' && copyContent(result?.avg)}
onclick={() => copyContent(result?.stdev + '')}
>(click to copy)</i>
<div>
<button onclick={() => (isRunning = false)}>reset</button>
</div>
{:else if isRunning}
<p>WarmUp ({$warmUp}/{warmUpAmount})</p>
<progress value={$warmUp} max={warmUpAmount}>
{Math.floor(($warmUp / warmUpAmount) * 100)}%
</progress>
<p>Progress ({samples}/{amount.value})</p>
<progress value={samples} max={amount.value}>
{Math.floor((samples / amount.value) * 100)}%
</progress>
{:else}
<label for="bench-samples">Samples</label>
<Number id="bench-sample" bind:value={amount.value} max={1000} />
<button onclick={benchmark} disabled={isRunning}>start</button>
{/if}
{#if result}
<h3>Finished ({humanizeDuration(result.duration)})</h3>
<div class="monitor-wrapper">
<Monitor points={result.samples} />
</div>
<label for="bench-avg">Average </label>
<button
id="bench-avg"
onkeydown={(ev) => ev.key === 'Enter' && copyContent(result?.avg)}
onclick={() => copyContent(result?.avg)}
>
{Math.floor(result.avg * 100) / 100}
</button>
<i
role="button"
tabindex="0"
onkeydown={(ev) => ev.key === 'Enter' && copyContent(result?.avg)}
onclick={() => copyContent(result?.avg)}
>(click to copy)</i>
<label for="bench-stdev">Standard Deviation σ</label>
<button id="bench-stdev" onclick={() => copyContent(result?.stdev)}>
{Math.floor(result.stdev * 100) / 100}
</button>
<i
role="button"
tabindex="0"
onkeydown={(ev) => ev.key === 'Enter' && copyContent(result?.avg)}
onclick={() => copyContent(result?.stdev + '')}
>(click to copy)</i>
<div>
<button onclick={() => (isRunning = false)}>reset</button>
</div>
{:else if isRunning}
<p>WarmUp ({$warmUp}/{warmUpAmount})</p>
<progress value={$warmUp} max={warmUpAmount}>
{Math.floor(($warmUp / warmUpAmount) * 100)}%
</progress>
<p>Progress ({samples}/{amount.value})</p>
<progress value={samples} max={amount.value}>
{Math.floor((samples / amount.value) * 100)}%
</progress>
{:else}
<label for="bench-samples">Samples</label>
<InputNumber id="bench-sample" bind:value={amount.value} max={1000} step={1} />
<button onclick={benchmark} disabled={isRunning}>start</button>
{/if}
</div>
<style>
.wrapper {
padding: 1em;
display: flex;
flex-direction: column;
gap: 1em;
}
.monitor-wrapper {
border: solid thin var(--outline);
border-bottom: none;
}
i {
opacity: 0.5;
font-size: 0.8em;
}
.wrapper {
padding: 1em;
display: flex;
flex-direction: column;
gap: 1em;
}
.monitor-wrapper {
border: solid thin var(--color-outline);
border-bottom: none;
}
i {
opacity: 0.5;
font-size: 0.8em;
}
</style>

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