9 Commits

Author SHA1 Message Date
Max Richter
d505098120 feat: add debug node 2026-01-23 04:06:04 +01:00
Max Richter
4006cc2dba Merge remote-tracking branch 'origin/main' into feat/arena-runtime 2026-01-23 02:29:09 +01:00
Max Richter
571bb2a5d3 feat: some shit 2026-01-23 02:28:17 +01:00
Max Richter
ab02a71ca5 fix: added missing wasm memory in node-registry 2026-01-23 01:21:48 +01:00
Max Richter
f7b5ee5941 chore: make sure to specify wasm flags only on wasm build 2026-01-23 01:18:23 +01:00
Max Richter
3203fb8f8e chore: cargo fix 2026-01-23 01:17:11 +01:00
Max Richter
a497a46674 feat: make all nodes work with new runtime 2026-01-23 01:14:09 +01:00
Max Richter
47882a832d feat: first working version of new allocator 2026-01-22 18:48:16 +01:00
Max Richter
841b447ac3 feat: add "*"/any type input for dev page 2026-01-22 15:54:08 +01:00
254 changed files with 8430 additions and 9466 deletions

9
.cargo/config.toml Normal file
View File

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

View File

@@ -10,29 +10,38 @@
"json": { "json": {
// https://dprint.dev/plugins/json/config/ // https://dprint.dev/plugins/json/config/
}, },
"markdown": {}, "markdown": {
"toml": {}, },
"dockerfile": {}, "toml": {
},
"dockerfile": {
},
"ruff": {
},
"jupyter": {
},
"malva": {
},
"markup": { "markup": {
// https://dprint.dev/plugins/markup_fmt/config/ // https://dprint.dev/plugins/markup_fmt/config/
"scriptIndent": true, "scriptIndent": true,
"styleIndent": true, "styleIndent": true,
}, },
"yaml": {}, "yaml": {
},
"graphql": {
},
"exec": { "exec": {
"cwd": "${configDir}", "cwd": "${configDir}",
"commands": [
{ "commands": [{
"command": "rustfmt", "command": "rustfmt",
"exts": [ "exts": ["rs"],
"rs",
],
"cacheKeyFiles": [ "cacheKeyFiles": [
"rustfmt.toml", "rustfmt.toml",
"rust-toolchain.toml", "rust-toolchain.toml",
], ],
}, }],
],
}, },
"excludes": [ "excludes": [
"**/node_modules", "**/node_modules",
@@ -49,8 +58,13 @@
"https://plugins.dprint.dev/markdown-0.20.0.wasm", "https://plugins.dprint.dev/markdown-0.20.0.wasm",
"https://plugins.dprint.dev/toml-0.7.0.wasm", "https://plugins.dprint.dev/toml-0.7.0.wasm",
"https://plugins.dprint.dev/dockerfile-0.3.3.wasm", "https://plugins.dprint.dev/dockerfile-0.3.3.wasm",
"https://plugins.dprint.dev/ruff-0.6.11.wasm",
"https://plugins.dprint.dev/jupyter-0.2.1.wasm",
"https://plugins.dprint.dev/g-plane/malva-v0.15.1.wasm",
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm", "https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm",
"https://plugins.dprint.dev/g-plane/pretty_yaml-v0.5.1.wasm", "https://plugins.dprint.dev/g-plane/pretty_yaml-v0.5.1.wasm",
"https://plugins.dprint.dev/g-plane/pretty_graphql-v0.2.3.wasm",
"https://plugins.dprint.dev/exec-0.6.0.json@a054130d458f124f9b5c91484833828950723a5af3f8ff2bd1523bd47b83b364", "https://plugins.dprint.dev/exec-0.6.0.json@a054130d458f124f9b5c91484833828950723a5af3f8ff2bd1523bd47b83b364",
"https://plugins.dprint.dev/biome-0.11.10.wasm",
], ],
} }

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -1,43 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
mkdir -p app/static
cp CHANGELOG.md app/static/CHANGELOG.md
# Derive branch/tag info
REF_TYPE="${GITHUB_REF_TYPE:-branch}"
REF_NAME="${GITHUB_REF_NAME:-$(basename "$GITHUB_REF")}"
BRANCH="${GITHUB_HEAD_REF:-}"
if [[ -z "$BRANCH" && "$REF_TYPE" == "branch" ]]; then
BRANCH="$REF_NAME"
fi
# Determine last tag and commits since
LAST_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)"
if [[ -n "$LAST_TAG" ]]; then
COMMITS_SINCE_LAST_RELEASE="$(git rev-list --count "${LAST_TAG}..HEAD")"
else
COMMITS_SINCE_LAST_RELEASE="0"
fi
cat >app/static/git.json <<EOF
{
"ref": "${GITHUB_REF:-}",
"ref_name": "${REF_NAME}",
"ref_type": "${REF_TYPE}",
"sha": "${GITHUB_SHA:-}",
"run_number": "${GITHUB_RUN_NUMBER:-}",
"event_name": "${GITHUB_EVENT_NAME:-}",
"workflow": "${GITHUB_WORKFLOW:-}",
"job": "${GITHUB_JOB:-}",
"commit_message": "$(git log -1 --pretty=%B)",
"commit_timestamp": "$(git log -1 --pretty=%cI)",
"branch": "${BRANCH}",
"commits_since_last_release": "${COMMITS_SINCE_LAST_RELEASE}"
}
EOF
pnpm build
cp -R packages/ui/build app/build/ui

View File

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

View File

@@ -1,91 +0,0 @@
#!/bin/sh
set -eu
TAG="$GITHUB_REF_NAME"
VERSION=$(echo "$TAG" | sed 's/^v//')
DATE=$(date +%Y-%m-%d)
echo "🚀 Creating release for $TAG"
# -------------------------------------------------------------------
# 1. Extract release notes from annotated tag
# -------------------------------------------------------------------
# Ensure the local git knows this is an annotated tag with metadata
git fetch origin "refs/tags/$TAG:refs/tags/$TAG" --force
# %(contents) gets the whole message.
# If you want ONLY what you typed after the first line, use %(contents:body)
NOTES=$(git tag -l "$TAG" --format='%(contents)')
if [ -z "$(echo "$NOTES" | tr -d '[:space:]')" ]; then
echo "❌ Tag message is empty or tag is not annotated"
exit 1
fi
git checkout main
# -------------------------------------------------------------------
# 2. Update all package.json versions
# -------------------------------------------------------------------
echo "🔧 Updating package.json versions to $VERSION"
find . -name package.json ! -path "*/node_modules/*" | while read -r file; do
tmp_file="$file.tmp"
jq --arg v "$VERSION" '.version = $v' "$file" >"$tmp_file"
mv "$tmp_file" "$file"
done
# -------------------------------------------------------------------
# 3. Generate commit list since last release
# -------------------------------------------------------------------
LAST_TAG=$(git tag --sort=-creatordate | grep -v "^$TAG$" | head -n 1 || echo "")
if [ -n "$LAST_TAG" ]; then
# Filter out previous 'chore(release)' commits so the list stays clean
COMMITS=$(git log "$LAST_TAG"..HEAD --pretty=format:'* [%h](https://git.max-richter.dev/max/nodarium/commit/%H) %s' | grep -v "chore(release)")
else
COMMITS=$(git log HEAD --pretty=format:'* [%h](https://git.max-richter.dev/max/nodarium/commit/%H) %s' | grep -v "chore(release)")
fi
# -------------------------------------------------------------------
# 4. Update CHANGELOG.md (prepend)
# -------------------------------------------------------------------
tmp_changelog="CHANGELOG.tmp"
{
echo "## $TAG ($DATE)"
echo ""
echo "$NOTES"
echo ""
if [ -n "$COMMITS" ]; then
echo "### All Commits in this version:"
echo "$COMMITS"
echo ""
fi
echo "---"
echo ""
if [ -f CHANGELOG.md ]; then
cat CHANGELOG.md
fi
} >"$tmp_changelog"
mv "$tmp_changelog" CHANGELOG.md
pnpm exec dprint fmt CHANGELOG.md
# -------------------------------------------------------------------
# 5. 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/*")
if git diff --cached --quiet; then
echo "No changes to commit for release $TAG"
else
git commit -m "chore(release): $TAG"
git push origin main
fi
cp CHANGELOG.md app/static/CHANGELOG.md
echo "✅ Release process for $TAG complete"

View File

@@ -1,43 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
echo "Configuring rclone"
KEY_FILE="$(mktemp)"
echo "${SSH_PRIVATE_KEY}" >"${KEY_FILE}"
chmod 600 "${KEY_FILE}"
mkdir -p ~/.config/rclone
cat >~/.config/rclone/rclone.conf <<EOF
[sftp-remote]
type = sftp
host = ${SSH_HOST}
user = ${SSH_USER}
port = ${SSH_PORT}
key_file = ${KEY_FILE}
EOF
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
TARGET_DIR="${REMOTE_DIR}"
elif [[ "${GITHUB_EVENT_NAME:-}" == "pull_request" ]]; then
SAFE_PR_NAME="${GITHUB_HEAD_REF//\//-}"
TARGET_DIR="${REMOTE_DIR}_${SAFE_PR_NAME}"
elif [[ "${GITHUB_REF_NAME:-}" == "main" ]]; then
TARGET_DIR="${REMOTE_DIR}_main"
else
SAFE_REF="${GITHUB_REF_NAME//\//-}"
TARGET_DIR="${REMOTE_DIR}_${SAFE_REF}"
fi
echo "Deploying to ${TARGET_DIR}"
rclone sync \
--update \
--verbose \
--progress \
--exclude _astro/** \
--stats 2s \
--stats-one-line \
--transfers 4 \
./app/build/ \
"sftp-remote:${TARGET_DIR}"

View File

@@ -1,39 +0,0 @@
name: Build & Push CI Image
on:
push:
paths:
- "Dockerfile.ci"
- ".gitea/workflows/build-ci-image.yaml"
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v2
with:
config-inline: |
[registry."git.max-richter.dev"]
https = true
insecure = false
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.max-richter.dev
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and Push
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.ci
push: true
tags: |
git.max-richter.dev/${{ gitea.repository }}-ci:latest
git.max-richter.dev/${{ gitea.repository }}-ci:${{ github.sha }}

View File

@@ -1,76 +0,0 @@
name: 🚀 Lint & Test & Deploy
on:
push:
branches: ["*"]
tags: ["*"]
pull_request:
branches: ["*"]
env:
PNPM_CACHE_FOLDER: /.pnpm-store
CARGO_HOME: /.cargo
CARGO_TARGET_DIR: target
jobs:
release:
runs-on: ubuntu-latest
container: git.max-richter.dev/max/nodarium-ci:fd7268d6208aede435e1685817ae6b271c68bd83
steps:
- name: 📑 Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITEA_TOKEN }}
- name: 💾 Setup pnpm Cache
uses: actions/cache@v4
with:
path: ${{ env.PNPM_CACHE_FOLDER }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: 🦀 Cache Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: 📦 Install Dependencies
run: pnpm install --frozen-lockfile --store-dir ${{ env.PNPM_CACHE_FOLDER }}
- name: 🧹 Quality Control
run: ./.gitea/scripts/ci-checks.sh
- name: 🛠️ Build
run: ./.gitea/scripts/build.sh
- name: 🚀 Create Release Commit
if: gitea.ref_type == 'tag'
run: ./.gitea/scripts/create-release.sh
- name: 🏷️ Create Gitea Release
if: gitea.ref_type == 'tag'
uses: akkuman/gitea-release-action@v1
with:
tag_name: ${{ gitea.ref_name }}
release_name: Release ${{ gitea.ref_name }}
body_path: CHANGELOG.md
draft: false
prerelease: false
- name: 🚀 Deploy Changed Files via rclone
run: ./.gitea/scripts/deploy-files.sh
env:
REMOTE_DIR: ${{ vars.REMOTE_DIR }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_HOST: ${{ vars.SSH_HOST }}
SSH_PORT: ${{ vars.SSH_PORT }}
SSH_USER: ${{ vars.SSH_USER }}

1
.github/graphics/nodes.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

38
.github/workflows/deploy.yaml vendored Normal file
View File

@@ -0,0 +1,38 @@
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 }}

View File

@@ -1,13 +0,0 @@
## v0.0.2 (2026-02-04)
fix(ci): actually deploy on tags
fix(app): correctly handle false value in settings
-> This caused a bug where random seed could not be false.
---
## v0.0.1 (2026-02-03)
chore: format
---

8
Cargo.lock generated
View File

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

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:24-alpine
RUN apk add --no-cache --update curl rclone g++
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
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

View File

@@ -1,30 +0,0 @@
FROM node:25-bookworm-slim
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN apt-get update && apt-get install -y \
ca-certificates=20230311+deb12u1 \
curl=7.88.1-10+deb12u14 \
git=1:2.39.5-0+deb12u3 \
jq=1.6-2.1+deb12u1 \
g++=4:12.2.0-3 \
rclone=1.60.1+dfsg-2+b5 \
xvfb=2:21.1.7-3+deb12u11 \
xauth=1:1.1.2-1 \
--no-install-recommends \
# Install Rust
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
--default-toolchain stable \
--profile minimal \
&& rustup target add wasm32-unknown-unknown \
# Setup Playwright
&& npm i -g pnpm \
&& pnpm dlx playwright install --with-deps firefox \
# Final Cleanup
&& rm -rf /usr/local/rustup/downloads /usr/local/rustup/tmp \
&& rm -rf /usr/local/cargo/registry/index /usr/local/cargo/registry/cache \
&& rm -rf /usr/local/rustup/toolchains/*/share/doc \
&& apt-get purge -y --auto-remove \
&& rm -rf /var/lib/apt/lists/*

View File

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

View File

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

227
SUMMARY.md Normal file
View File

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

294
SUMMARY_RUNTIME.md Normal file
View File

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

2
app/.gitignore vendored
View File

@@ -27,5 +27,3 @@ dist-ssr
*.sln *.sln
*.sw? *.sw?
build/ build/
test-results/

View File

@@ -25,8 +25,20 @@ FROM nginx:alpine AS runner
RUN rm /etc/nginx/conf.d/default.conf RUN rm /etc/nginx/conf.d/default.conf
COPY app/docker/app.conf /etc/nginx/conf.d/app.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 --from=builder /app/app/build /app COPY --from=builder /app/app/build /app
COPY --from=builder /app/packages/ui/build /app/ui
EXPOSE 80 EXPOSE 80

View File

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

View File

@@ -1,62 +0,0 @@
import { expect, test } from '@playwright/test';
test('test', async ({ page }) => {
// Listen for console messages
page.on('console', msg => {
console.log(`[Browser Console] ${msg.type()}: ${msg.text()}`);
});
await page.goto('http://localhost:4173', { waitUntil: 'load' });
// await expect(page).toHaveScreenshot();
await expect(page.locator('.graph-wrapper')).toHaveScreenshot();
await page.getByRole('button', { name: 'projects' }).click();
await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('combobox').selectOption('2');
await page.getByRole('textbox', { name: 'Project name' }).click();
await page.getByRole('textbox', { name: 'Project name' }).fill('Test Project');
await page.getByRole('button', { name: 'Create' }).click();
const expectedNodes = [
{
id: '10',
type: 'max/plantarium/stem',
props: {
amount: 50,
length: 4,
thickness: 1
}
},
{
id: '11',
type: 'max/plantarium/noise',
props: {
scale: 0.5,
strength: 5
}
},
{
id: '9',
type: 'max/plantarium/output'
}
];
for (const node of expectedNodes) {
const wrapper = page.locator(
`div.wrapper[data-node-id="${node.id}"][data-node-type="${node.type}"]`
);
await expect(wrapper).toBeVisible();
if ('props' in node) {
const props = node.props as unknown as Record<string, number>;
for (const propId in node.props) {
const expectedValue = props[propId];
const inputElement = page.locator(
`div.wrapper[data-node-type="${node.type}"][data-node-input="${propId}"] input[type="number"]`
);
const value = parseFloat(await inputElement.inputValue());
expect(value).toBe(expectedValue);
}
}
}
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

View File

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

@@ -1,24 +1,19 @@
{ {
"name": "@nodarium/app", "name": "@nodarium/app",
"private": true, "private": true,
"version": "0.0.3", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "svelte-kit sync && vite build", "build": "svelte-kit sync && vite build",
"test:unit": "vitest", "test": "vitest",
"test": "npm run test:unit -- --run && npm run test:e2e", "preview": "vite preview"
"test:e2e": "playwright test",
"preview": "vite preview",
"format": "dprint fmt -c '../.dprint.jsonc' .",
"format:check": "dprint check -c '../.dprint.jsonc' .",
"lint": "eslint .",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@nodarium/registry": "workspace:*",
"@nodarium/ui": "workspace:*", "@nodarium/ui": "workspace:*",
"@nodarium/utils": "workspace:*", "@nodarium/utils": "workspace:*",
"@sveltejs/kit": "^2.50.2", "@sveltejs/kit": "^2.50.0",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@threlte/core": "8.3.1", "@threlte/core": "8.3.1",
"@threlte/extras": "9.7.1", "@threlte/extras": "9.7.1",
@@ -27,35 +22,26 @@
"idb": "^8.0.3", "idb": "^8.0.3",
"jsondiffpatch": "^0.7.3", "jsondiffpatch": "^0.7.3",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"three": "^0.182.0" "three": "^0.182.0",
"wabt": "^1.0.39"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.0.2",
"@eslint/js": "^9.39.2",
"@iconify-json/tabler": "^1.2.26", "@iconify-json/tabler": "^1.2.26",
"@iconify/tailwind4": "^1.2.1", "@iconify/tailwind4": "^1.2.1",
"@nodarium/types": "workspace:^", "@nodarium/types": "workspace:",
"@playwright/test": "^1.58.1",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tsconfig/svelte": "^5.0.7", "@tsconfig/svelte": "^5.0.6",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/three": "^0.182.0", "@types/three": "^0.182.0",
"@vitest/browser-playwright": "^4.0.18", "svelte": "^5.46.4",
"dprint": "^0.51.1", "svelte-check": "^4.3.5",
"eslint": "^9.39.2",
"eslint-plugin-svelte": "^3.14.0",
"globals": "^17.3.0",
"svelte": "^5.49.2",
"svelte-check": "^4.3.6",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-plugin-comlink": "^5.3.0", "vite-plugin-comlink": "^5.3.0",
"vite-plugin-glsl": "^1.5.5", "vite-plugin-glsl": "^1.5.5",
"vite-plugin-wasm": "^3.5.0", "vite-plugin-wasm": "^3.5.0",
"vitest": "^4.0.18", "vitest": "^4.0.17"
"vitest-browser-svelte": "^2.0.2"
} }
} }

View File

@@ -1,20 +0,0 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: { command: 'pnpm build && pnpm preview', port: 4173 },
testDir: 'e2e',
use: {
browserName: 'firefox',
launchOptions: {
firefoxUserPrefs: {
// Force WebGL even without a GPU
'webgl.force-enabled': true,
'webgl.disabled': false,
// Use software rendering (Mesa) instead of hardware
'layers.acceleration.disabled': true,
'gfx.webrender.software': true,
'webgl.enable-webgl2': true
}
}
}
});

View File

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

View File

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

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; export const ANALYTIC_SCRIPT = PUBLIC_ANALYTIC_SCRIPT;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,265 +0,0 @@
import { describe, expect, it } from 'vitest';
import { GraphManager } from './graph-manager.svelte';
import {
createMockNodeRegistry,
mockFloatInputNode,
mockFloatOutputNode,
mockGeometryOutputNode,
mockPathInputNode,
mockVec3OutputNode
} from './test-utils';
describe('GraphManager', () => {
describe('getPossibleSockets', () => {
describe('when dragging an output socket', () => {
it('should return compatible input sockets based on type', () => {
const registry = createMockNodeRegistry([
mockFloatOutputNode,
mockFloatInputNode,
mockGeometryOutputNode,
mockPathInputNode
]);
const manager = new GraphManager(registry);
const floatInputNode = manager.createNode({
type: 'test/node/input',
position: [100, 100],
props: {}
});
const floatOutputNode = manager.createNode({
type: 'test/node/output',
position: [0, 0],
props: {}
});
expect(floatInputNode).toBeDefined();
expect(floatOutputNode).toBeDefined();
const possibleSockets = manager.getPossibleSockets({
node: floatOutputNode!,
index: 0,
position: [0, 0]
});
expect(possibleSockets.length).toBe(1);
const socketNodeIds = possibleSockets.map(([node]) => node.id);
expect(socketNodeIds).toContain(floatInputNode!.id);
});
it('should exclude self node from possible sockets', () => {
const registry = createMockNodeRegistry([
mockFloatOutputNode,
mockFloatInputNode
]);
const manager = new GraphManager(registry);
const floatInputNode = manager.createNode({
type: 'test/node/input',
position: [100, 100],
props: {}
});
expect(floatInputNode).toBeDefined();
const possibleSockets = manager.getPossibleSockets({
node: floatInputNode!,
index: 'value',
position: [0, 0]
});
const socketNodeIds = possibleSockets.map(([node]) => node.id);
expect(socketNodeIds).not.toContain(floatInputNode!.id);
});
it('should exclude parent nodes from possible sockets when dragging output', () => {
const registry = createMockNodeRegistry([
mockFloatOutputNode,
mockFloatInputNode
]);
const manager = new GraphManager(registry);
const parentNode = manager.createNode({
type: 'test/node/output',
position: [0, 0],
props: {}
});
const childNode = manager.createNode({
type: 'test/node/input',
position: [100, 100],
props: {}
});
expect(parentNode).toBeDefined();
expect(childNode).toBeDefined();
if (parentNode && childNode) {
manager.createEdge(parentNode, 0, childNode, 'value');
}
const possibleSockets = manager.getPossibleSockets({
node: parentNode!,
index: 0,
position: [0, 0]
});
const socketNodeIds = possibleSockets.map(([node]) => node.id);
expect(socketNodeIds).not.toContain(childNode!.id);
});
it('should return sockets compatible with accepts property', () => {
const registry = createMockNodeRegistry([
mockGeometryOutputNode,
mockPathInputNode
]);
const manager = new GraphManager(registry);
const geometryOutputNode = manager.createNode({
type: 'test/node/geometry',
position: [0, 0],
props: {}
});
const pathInputNode = manager.createNode({
type: 'test/node/path',
position: [100, 100],
props: {}
});
expect(geometryOutputNode).toBeDefined();
expect(pathInputNode).toBeDefined();
const possibleSockets = manager.getPossibleSockets({
node: geometryOutputNode!,
index: 0,
position: [0, 0]
});
const socketNodeIds = possibleSockets.map(([node]) => node.id);
expect(socketNodeIds).toContain(pathInputNode!.id);
});
it('should return empty array when no compatible sockets exist', () => {
const registry = createMockNodeRegistry([
mockVec3OutputNode,
mockFloatInputNode
]);
const manager = new GraphManager(registry);
const vec3OutputNode = manager.createNode({
type: 'test/node/vec3',
position: [0, 0],
props: {}
});
const floatInputNode = manager.createNode({
type: 'test/node/input',
position: [100, 100],
props: {}
});
expect(vec3OutputNode).toBeDefined();
expect(floatInputNode).toBeDefined();
const possibleSockets = manager.getPossibleSockets({
node: vec3OutputNode!,
index: 0,
position: [0, 0]
});
const socketNodeIds = possibleSockets.map(([node]) => node.id);
expect(socketNodeIds).not.toContain(floatInputNode!.id);
expect(possibleSockets.length).toBe(0);
});
it('should return socket info with correct socket key for inputs', () => {
const registry = createMockNodeRegistry([
mockFloatOutputNode,
mockFloatInputNode
]);
const manager = new GraphManager(registry);
const floatOutputNode = manager.createNode({
type: 'test/node/output',
position: [0, 0],
props: {}
});
const floatInputNode = manager.createNode({
type: 'test/node/input',
position: [100, 100],
props: {}
});
expect(floatOutputNode).toBeDefined();
expect(floatInputNode).toBeDefined();
const possibleSockets = manager.getPossibleSockets({
node: floatOutputNode!,
index: 0,
position: [0, 0]
});
const matchingSocket = possibleSockets.find(([node]) => node.id === floatInputNode!.id);
expect(matchingSocket).toBeDefined();
expect(matchingSocket![1]).toBe('value');
});
it('should return multiple compatible sockets', () => {
const registry = createMockNodeRegistry([
mockFloatOutputNode,
mockFloatInputNode,
mockGeometryOutputNode,
mockPathInputNode
]);
const manager = new GraphManager(registry);
const floatOutputNode = manager.createNode({
type: 'test/node/output',
position: [0, 0],
props: {}
});
const geometryOutputNode = manager.createNode({
type: 'test/node/geometry',
position: [200, 0],
props: {}
});
const floatInputNode = manager.createNode({
type: 'test/node/input',
position: [100, 100],
props: {}
});
const pathInputNode = manager.createNode({
type: 'test/node/path',
position: [300, 100],
props: {}
});
expect(floatOutputNode).toBeDefined();
expect(geometryOutputNode).toBeDefined();
expect(floatInputNode).toBeDefined();
expect(pathInputNode).toBeDefined();
const possibleSocketsForFloat = manager.getPossibleSockets({
node: floatOutputNode!,
index: 0,
position: [0, 0]
});
expect(possibleSocketsForFloat.length).toBe(1);
expect(possibleSocketsForFloat.map(([n]) => n.id)).toContain(floatInputNode!.id);
});
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -177,8 +177,8 @@ export class MouseEventManager {
} }
} }
const mx = event.clientX - this.state.rect.x; let mx = event.clientX - this.state.rect.x;
const my = event.clientY - this.state.rect.y; let my = event.clientY - this.state.rect.y;
this.state.mouseDown = [mx, my]; this.state.mouseDown = [mx, my];
this.state.cameraDown[0] = this.state.cameraPosition[0]; this.state.cameraDown[0] = this.state.cameraPosition[0];
@@ -242,8 +242,8 @@ export class MouseEventManager {
} }
handleWindowMouseMove(event: MouseEvent) { handleWindowMouseMove(event: MouseEvent) {
const mx = event.clientX - this.state.rect.x; let mx = event.clientX - this.state.rect.x;
const my = event.clientY - this.state.rect.y; let my = event.clientY - this.state.rect.y;
this.state.mousePosition = this.state.projectScreenToWorld(mx, my); this.state.mousePosition = this.state.projectScreenToWorld(mx, my);
this.state.hoveredNodeId = this.state.getNodeIdFromEvent(event); this.state.hoveredNodeId = this.state.getNodeIdFromEvent(event);
@@ -265,7 +265,7 @@ export class MouseEventManager {
} }
} }
if (_socket && smallestDist < 1.5) { if (_socket && smallestDist < 0.9) {
this.state.mousePosition = _socket.position; this.state.mousePosition = _socket.position;
this.state.hoveredSocket = _socket; this.state.hoveredSocket = _socket;
} else { } else {
@@ -352,9 +352,9 @@ export class MouseEventManager {
// here we are handling panning of camera // here we are handling panning of camera
this.state.isPanning = true; this.state.isPanning = true;
const newX = this.state.cameraDown[0] let newX = this.state.cameraDown[0]
- (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2]; - (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2];
const newY = this.state.cameraDown[1] let newY = this.state.cameraDown[1]
- (my - this.state.mouseDown[1]) / this.state.cameraPosition[2]; - (my - this.state.mouseDown[1]) / this.state.cameraPosition[2];
this.state.cameraPosition[0] = newX; this.state.cameraPosition[0] = newX;
@@ -392,7 +392,6 @@ export class MouseEventManager {
/ zoomRatio; / zoomRatio;
this.state.cameraPosition[1] = this.state.mousePosition[1] this.state.cameraPosition[1] = this.state.mousePosition[1]
- (this.state.mousePosition[1] - this.state.cameraPosition[1]) - (this.state.mousePosition[1] - this.state.cameraPosition[1])
/ zoomRatio; / zoomRatio, this.state.cameraPosition[2] = newZoom;
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 EventMap = Record<string, unknown>;
type EventKey<T extends EventMap> = string & keyof T; type EventKey<T extends EventMap> = string & keyof T;
type EventReceiver<T> = (params: T, stuff?: Record<string, unknown>) => unknown; type EventReceiver<T> = (params: T, stuff?: Record<string, unknown>) => unknown;
export default class EventEmitter< export default class EventEmitter<
T extends EventMap = { [key: string]: unknown } T extends EventMap = { [key: string]: unknown },
> { > {
index = 0; index = 0;
public eventMap: T = {} as T; public eventMap: T = {} as T;
@@ -32,11 +32,11 @@ export default class EventEmitter<
public on<K extends EventKey<T>>( public on<K extends EventKey<T>>(
event: K, event: K,
cb: EventReceiver<T[K]>, cb: EventReceiver<T[K]>,
throttleTimer = 0 throttleTimer = 0,
) { ) {
if (throttleTimer > 0) cb = throttle(cb, throttleTimer); if (throttleTimer > 0) cb = throttle(cb, throttleTimer);
const cbs = Object.assign(this.cbs, { const cbs = Object.assign(this.cbs, {
[event]: [...(this.cbs[event] || []), cb] [event]: [...(this.cbs[event] || []), cb],
}); });
this.cbs = cbs; this.cbs = cbs;
@@ -54,10 +54,10 @@ export default class EventEmitter<
*/ */
public once<K extends EventKey<T>>( public once<K extends EventKey<T>>(
event: K, event: K,
cb: EventReceiver<T[K]> cb: EventReceiver<T[K]>,
): () => void { ): () => void {
const cbsOnce = Object.assign(this.cbsOnce, { const cbsOnce = Object.assign(this.cbsOnce, {
[event]: [...(this.cbsOnce[event] || []), cb] [event]: [...(this.cbsOnce[event] || []), cb],
}); });
this.cbsOnce = cbsOnce; this.cbsOnce = cbsOnce;

View File

@@ -36,8 +36,7 @@ export function createNodePath({
aspectRatio = 1 aspectRatio = 1
} = {}) { } = {}) {
return `M0,${cornerTop} return `M0,${cornerTop}
${ ${cornerTop
cornerTop
? ` V${cornerTop} ? ` V${cornerTop}
Q0,0 ${cornerTop * aspectRatio},0 Q0,0 ${cornerTop * aspectRatio},0
H${100 - cornerTop * aspectRatio} H${100 - cornerTop * aspectRatio}
@@ -48,13 +47,11 @@ export function createNodePath({
` `
} }
V${y - height / 2} V${y - height / 2}
${ ${rightBump
rightBump
? ` C${100 - depth},${y - height / 2} ${100 - depth},${y + height / 2} 100,${y + height / 2}` ? ` C${100 - depth},${y - height / 2} ${100 - depth},${y + height / 2} 100,${y + height / 2}`
: ` H100` : ` H100`
} }
${ ${cornerBottom
cornerBottom
? ` V${100 - cornerBottom} ? ` V${100 - cornerBottom}
Q100,100 ${100 - cornerBottom * aspectRatio},100 Q100,100 ${100 - cornerBottom * aspectRatio},100
H${cornerBottom * aspectRatio} H${cornerBottom * aspectRatio}
@@ -62,21 +59,26 @@ export function createNodePath({
` `
: `${leftBump ? `V100 H0` : `V100`}` : `${leftBump ? `V100 H0` : `V100`}`
} }
${ ${leftBump
leftBump ? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${y - height / 2}`
? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${
y - height / 2
}`
: ` H0` : ` H0`
} }
Z`.replace(/\s+/g, ' '); 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 export const clone: <T>(v: T) => T = 'structedClone' in globalThis
? globalThis.structuredClone ? globalThis.structuredClone
: (obj) => JSON.parse(JSON.stringify(obj)); : (obj) => JSON.parse(JSON.stringify(obj));
export function withSubComponents<A, B extends Record<string, unknown>>( export function withSubComponents<A, B extends Record<string, any>>(
component: A, component: A,
subcomponents: B subcomponents: B
): A & B { ): A & B {

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { NodeInstance } from '@nodarium/types'; import { getGraphState } from "../graph-state.svelte";
import { getGraphState } from '../graph-state.svelte'; import { createNodePath } from "../helpers/index.js";
import { createNodePath } from '../helpers/index.js'; import type { NodeInstance } from "@nodarium/types";
import { appSettings } from "$lib/settings/app-settings.svelte";
const graphState = getGraphState(); const graphState = getGraphState();
@@ -10,52 +11,52 @@
function handleMouseDown(event: MouseEvent) { function handleMouseDown(event: MouseEvent) {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
if ('state' in node) { if ("state" in node) {
graphState.setDownSocket?.({ graphState.setDownSocket?.({
node, node,
index: 0, index: 0,
position: graphState.getSocketPosition?.(node, 0) position: graphState.getSocketPosition?.(node, 0),
}); });
} }
} }
const cornerTop = 10; const cornerTop = 10;
const rightBump = $derived(!!node?.state?.type?.outputs?.length); const rightBump = !!node?.state?.type?.outputs?.length;
const aspectRatio = 0.25; const aspectRatio = 0.25;
const path = $derived( const path = createNodePath({
createNodePath({
depth: 5.5, depth: 5.5,
height: 34, height: 34,
y: 49, y: 49,
cornerTop, cornerTop,
rightBump, rightBump,
aspectRatio aspectRatio,
}) });
); const pathHover = createNodePath({
const pathHover = $derived( depth: 8.5,
createNodePath({ height: 50,
depth: 7,
height: 40,
y: 49, y: 49,
cornerTop, cornerTop,
rightBump, rightBump,
aspectRatio aspectRatio,
}) });
);
</script> </script>
<div class="wrapper" data-node-id={node.id} data-node-type={node.type}> <div class="wrapper" data-node-id={node.id} data-node-type={node.type}>
<div class="content"> <div class="content">
{node.type.split('/').pop()} {#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()}
</div> </div>
<div <div
class="click-target" class="click-target"
role="button" role="button"
tabindex="0" tabindex="0"
onmousedown={handleMouseDown} onmousedown={handleMouseDown}
> ></div>
</div>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100" viewBox="0 0 100 100"
@@ -67,7 +68,8 @@
--hover-path: path("${pathHover}"); --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> </svg>
</div> </div>
@@ -108,14 +110,13 @@
svg path { svg path {
stroke-width: 0.2px; stroke-width: 0.2px;
transition: d 0.3s ease, fill 0.3s ease; transition:
fill: var(--color-layer-2); d 0.3s ease,
fill 0.3s ease;
fill: var(--layer-2);
stroke: var(--stroke); stroke: var(--stroke);
stroke-width: var(--stroke-width); stroke-width: var(--stroke-width);
d: var(--path); d: var(--path);
stroke-linejoin: round;
shape-rendering: geometricPrecision;
} }
.content { .content {

View File

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

View File

@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { NodeInput, NodeInstance } from '@nodarium/types'; import type { NodeInput, NodeInstance } from "@nodarium/types";
import { getGraphManager, getGraphState } from '../graph-state.svelte'; import { createNodePath } from "../helpers";
import { createNodePath } from '../helpers'; import NodeInputEl from "./NodeInput.svelte";
import NodeInputEl from './NodeInput.svelte'; import { getGraphManager, getGraphState } from "../graph-state.svelte";
type Props = { type Props = {
node: NodeInstance; node: NodeInstance;
@@ -15,9 +15,9 @@
let { node = $bindable(), input, id, isLast }: Props = $props(); let { node = $bindable(), input, id, isLast }: Props = $props();
const inputType = $derived(node?.state?.type?.inputs?.[id]); const inputType = node?.state?.type?.inputs?.[id]!;
const socketId = $derived(`${node.id}-${id}`); const socketId = `${node.id}-${id}`;
const graphState = getGraphState(); const graphState = getGraphState();
const graphId = graph?.id; const graphId = graph?.id;
@@ -30,65 +30,71 @@
graphState.setDownSocket({ graphState.setDownSocket({
node, node,
index: id, index: id,
position: graphState.getSocketPosition?.(node, id) position: graphState.getSocketPosition?.(node, id),
}); });
} }
const leftBump = $derived(node.state?.type?.inputs?.[id].internal !== true); const leftBump = node.state?.type?.inputs?.[id].internal !== true;
const cornerBottom = $derived(isLast ? 5 : 0); const cornerBottom = isLast ? 5 : 0;
const aspectRatio = 0.5; const aspectRatio = 0.5;
const path = $derived( const path = createNodePath({
createNodePath({
depth: 6,
height: 18,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio
})
);
const pathHover = $derived(
createNodePath({
depth: 7, depth: 7,
height: 20, height: 20,
y: 50.5, y: 50.5,
cornerBottom, cornerBottom,
leftBump, leftBump,
aspectRatio 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,
});
</script> </script>
<div <div
class="wrapper" class="wrapper"
data-node-type={node.type} data-node-type={node.type}
data-node-input={id} data-node-input={id}
class:possible-socket={graphState?.possibleSocketIds.has(socketId)} class:disabled={!graphState?.possibleSocketIds.has(socketId)}
> >
{#key id && graphId} {#key id && graphId}
<div class="content" class:disabled={graph?.inputSockets?.has(socketId)}> <div class="content" class:disabled={graph?.inputSockets?.has(socketId)}>
{#if inputType?.label !== ''} {#if inputType.label !== ""}
<label for={elementId} title={input.description}>{input.label || id}</label> <label for={elementId} title={input.description}
>{input.label || id}</label
>
{/if} {/if}
<span <span
class="absolute i-[tabler--help-circle] size-4 block top-2 right-2 opacity-30" class="absolute i-[tabler--help-circle] size-4 block top-2 right-2 opacity-30"
title={JSON.stringify(input, null, 2)} title={JSON.stringify(input, null, 2)}
></span> ></span>
{#if inputType?.external !== true} {#if inputType.external !== true}
<NodeInputEl {graph} {elementId} bind:node {input} {id} /> <NodeInputEl {graph} {elementId} bind:node {input} {id} />
{/if} {/if}
</div> </div>
{#if node?.state?.type?.inputs?.[id]?.internal !== true} {#if node?.state?.type?.inputs?.[id]?.internal !== true}
<div data-node-socket class="large target"></div>
<div <div
data-node-socket data-node-socket
class="target" class="small target"
onmousedown={handleMouseDown} onmousedown={handleMouseDown}
role="button" role="button"
tabindex="0" tabindex="0"
> ></div>
</div>
{/if} {/if}
{/key} {/key}
@@ -101,6 +107,7 @@
style={` style={`
--path: path("${path}"); --path: path("${path}");
--hover-path: path("${pathHover}"); --hover-path: path("${pathHover}");
--hover-path-disabled: path("${pathDisabled}");
`} `}
> >
<path vector-effect="non-scaling-stroke"></path> <path vector-effect="non-scaling-stroke"></path>
@@ -116,22 +123,28 @@
} }
.target { .target {
width: 30px;
height: 30px;
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
top: 50%; top: 50%;
transform: translateY(-50%) translateX(-50%); transform: translateY(-50%) translateX(-50%);
/* background: red; */
/* opacity: 0.1; */
} }
.possible-socket .target { .small.target {
box-shadow: 0px 0px 10px rgba(255, 255, 255, 0.5); width: 30px;
background-color: rgba(255, 255, 255, 0.2); height: 30px;
z-index: -10;
} }
.target:hover ~ svg path{ .large.target {
d: var(--hover-path); width: 60px;
height: 60px;
cursor: unset;
pointer-events: none;
}
:global(.hovering-sockets) .large.target {
pointer-events: all;
} }
.content { .content {
@@ -156,21 +169,26 @@
} }
svg path { svg path {
transition: d 0.3s ease, fill 0.3s ease; transition:
fill: var(--color-layer-1); d 0.3s ease,
fill 0.3s ease;
fill: var(--layer-1);
stroke: var(--stroke); stroke: var(--stroke);
stroke-width: var(--stroke-width); stroke-width: var(--stroke-width);
d: var(--path); d: var(--path);
}
stroke-linejoin: round; :global {
shape-rendering: geometricPrecision; .hovering-sockets .large:hover ~ svg path {
d: var(--hover-path);
}
} }
.content.disabled { .content.disabled {
opacity: 0.2; opacity: 0.2;
} }
.possible-socket svg path { .disabled svg path {
d: var(--hover-path); d: var(--hover-path-disabled) !important;
} }
</style> </style>

View File

@@ -1,86 +0,0 @@
import type { NodeDefinition, NodeId, NodeRegistry } from '@nodarium/types';
export function createMockNodeRegistry(nodes: NodeDefinition[]): NodeRegistry {
const nodesMap = new Map(nodes.map(n => [n.id, n]));
return {
status: 'ready' as const,
load: async (nodeIds: NodeId[]) => {
const loaded: NodeDefinition[] = [];
for (const id of nodeIds) {
if (nodesMap.has(id)) {
loaded.push(nodesMap.get(id)!);
}
}
return loaded;
},
getNode: (id: string) => nodesMap.get(id as NodeId),
getAllNodes: () => Array.from(nodesMap.values()),
register: async () => {
throw new Error('Not implemented in mock');
}
};
}
export const mockFloatOutputNode: NodeDefinition = {
id: 'test/node/output',
inputs: {},
outputs: ['float'],
meta: { title: 'Float Output' },
execute: () => new Int32Array()
};
export const mockFloatInputNode: NodeDefinition = {
id: 'test/node/input',
inputs: { value: { type: 'float' } },
outputs: [],
meta: { title: 'Float Input' },
execute: () => new Int32Array()
};
export const mockGeometryOutputNode: NodeDefinition = {
id: 'test/node/geometry',
inputs: {},
outputs: ['geometry'],
meta: { title: 'Geometry Output' },
execute: () => new Int32Array()
};
export const mockPathInputNode: NodeDefinition = {
id: 'test/node/path',
inputs: { input: { type: 'path', accepts: ['geometry'] } },
outputs: [],
meta: { title: 'Path Input' },
execute: () => new Int32Array()
};
export const mockVec3OutputNode: NodeDefinition = {
id: 'test/node/vec3',
inputs: {},
outputs: ['vec3'],
meta: { title: 'Vec3 Output' },
execute: () => new Int32Array()
};
export const mockIntegerInputNode: NodeDefinition = {
id: 'test/node/integer',
inputs: { value: { type: 'integer' } },
outputs: [],
meta: { title: 'Integer Input' },
execute: () => new Int32Array()
};
export const mockBooleanOutputNode: NodeDefinition = {
id: 'test/node/boolean',
inputs: {},
outputs: ['boolean'],
meta: { title: 'Boolean Output' },
execute: () => new Int32Array()
};
export const mockBooleanInputNode: NodeDefinition = {
id: 'test/node/boolean-input',
inputs: { value: { type: 'boolean' } },
outputs: [],
meta: { title: 'Boolean Input' },
execute: () => new Int32Array()
};

View File

@@ -1,110 +0,0 @@
import { grid } from '$lib/graph-templates/grid';
import { tree } from '$lib/graph-templates/tree';
import { describe, expect, it } from 'vitest';
describe('graph-templates', () => {
describe('grid', () => {
it('should create a grid graph with nodes and edges', () => {
const result = grid(2, 3);
expect(result.nodes.length).toBeGreaterThan(0);
expect(result.edges.length).toBeGreaterThan(0);
});
it('should have output node at the end', () => {
const result = grid(1, 1);
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
expect(outputNode).toBeDefined();
});
it('should create nodes based on grid dimensions', () => {
const result = grid(2, 2);
const mathNodes = result.nodes.filter(n => n.type === 'max/plantarium/math');
expect(mathNodes.length).toBeGreaterThan(0);
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
expect(outputNode).toBeDefined();
});
it('should have output node at the end', () => {
const result = grid(1, 1);
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
expect(outputNode).toBeDefined();
});
it('should create nodes based on grid dimensions', () => {
const result = grid(2, 2);
const mathNodes = result.nodes.filter(n => n.type === 'max/plantarium/math');
expect(mathNodes.length).toBeGreaterThan(0);
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
expect(outputNode).toBeDefined();
});
it('should have valid node positions', () => {
const result = grid(3, 2);
result.nodes.forEach(node => {
expect(node.position).toHaveLength(2);
expect(typeof node.position[0]).toBe('number');
expect(typeof node.position[1]).toBe('number');
});
});
it('should generate valid graph structure', () => {
const result = grid(2, 2);
result.nodes.forEach(node => {
expect(typeof node.id).toBe('number');
expect(node.type).toBeTruthy();
});
result.edges.forEach(edge => {
expect(edge).toHaveLength(4);
});
});
});
describe('tree', () => {
it('should create a tree graph with specified depth', () => {
const result = tree(0);
expect(result.nodes.length).toBeGreaterThan(0);
expect(result.edges.length).toBeGreaterThan(0);
});
it('should have root output node', () => {
const result = tree(2);
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
expect(outputNode).toBeDefined();
expect(outputNode?.id).toBe(0);
});
it('should increase node count with depth', () => {
const tree0 = tree(0);
const tree1 = tree(1);
const tree2 = tree(2);
expect(tree0.nodes.length).toBeLessThan(tree1.nodes.length);
expect(tree1.nodes.length).toBeLessThan(tree2.nodes.length);
});
it('should create binary tree structure', () => {
const result = tree(2);
const mathNodes = result.nodes.filter(n => n.type === 'max/plantarium/math');
expect(mathNodes.length).toBeGreaterThan(0);
const edgeCount = result.edges.length;
expect(edgeCount).toBe(result.nodes.length - 1);
});
it('should have valid node positions', () => {
const result = tree(3);
result.nodes.forEach(node => {
expect(node.position).toHaveLength(2);
expect(typeof node.position[0]).toBe('number');
expect(typeof node.position[1]).toBe('number');
});
});
});
});

View File

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

View File

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

View File

@@ -1,44 +1 @@
{ {"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,71 +1,12 @@
export const plant = { export const plant = {
'settings': { 'resolution.circle': 26, 'resolution.curve': 39 }, "settings": { "resolution.circle": 26, "resolution.curve": 39 },
'nodes': [ "nodes": [
{ 'id': 9, 'position': [180, 80], 'type': 'max/plantarium/output', 'props': {} }, { "id": 9, "position": [180, 80], "type": "max/plantarium/output", "props": {} },
{ { "id": 10, "position": [55, 80], "type": "max/plantarium/stem", "props": { "amount": 1, "length": 11, "thickness": 0.71 } },
'id': 10, { "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 } },
'position': [55, 80], { "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 } },
'type': 'max/plantarium/stem', { "id": 13, "position": [130, 80], "type": "max/plantarium/noise", "props": { "strength": 8, "scale": 7.7, "fixBottom": 1, "directionalStrength": [1, 0, 1], "depth": 1 } },
'props': { 'amount': 1, 'length': 11, 'thickness': 0.71 } { "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 } }
},
{
'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': [ "edges": [[10, 0, 11, "plant"], [11, 0, 12, "plant"], [12, 0, 13, "plant"], [13, 0, 14, "plant"], [14, 0, 9, "input"]]
[10, 0, 11, 'plant'], }
[11, 0, 12, 'plant'],
[12, 0, 13, 'plant'],
[13, 0, 14, 'plant'],
[14, 0, 9, 'input']
]
};

View File

@@ -1,63 +0,0 @@
{
"id": 0,
"settings": {
"resolution.circle": 54,
"resolution.curve": 20,
"randomSeed": true
},
"meta": {
"title": "New Project",
"lastModified": "2026-02-03T16:56:40.375Z"
},
"nodes": [
{
"id": 9,
"position": [
215,
85
],
"type": "max/plantarium/output",
"props": {}
},
{
"id": 10,
"position": [
165,
72.5
],
"type": "max/plantarium/stem",
"props": {
"amount": 50,
"length": 4,
"thickness": 1
}
},
{
"id": 11,
"position": [
190,
77.5
],
"type": "max/plantarium/noise",
"props": {
"plant": 0,
"scale": 0.5,
"strength": 5
}
}
],
"edges": [
[
10,
0,
11,
"plant"
],
[
11,
0,
9,
"input"
]
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,145 +0,0 @@
import { clone, debounce, humanizeDuration, humanizeNumber, lerp, snapToGrid } from '$lib/helpers';
import { describe, expect, it } from 'vitest';
describe('helpers', () => {
describe('snapToGrid', () => {
it('should snap to nearest grid point', () => {
expect(snapToGrid(5, 10)).toBe(10);
expect(snapToGrid(15, 10)).toBe(20);
expect(snapToGrid(0, 10)).toBe(0);
expect(snapToGrid(-10, 10)).toBe(-10);
});
it('should snap exact midpoint values', () => {
expect(snapToGrid(5, 10)).toBe(10);
});
it('should use default grid size of 10', () => {
expect(snapToGrid(5)).toBe(10);
expect(snapToGrid(15)).toBe(20);
});
it('should handle values exactly on grid', () => {
expect(snapToGrid(10, 10)).toBe(10);
expect(snapToGrid(20, 10)).toBe(20);
});
});
describe('lerp', () => {
it('should linearly interpolate between two values', () => {
expect(lerp(0, 100, 0)).toBe(0);
expect(lerp(0, 100, 0.5)).toBe(50);
expect(lerp(0, 100, 1)).toBe(100);
});
it('should handle negative values', () => {
expect(lerp(-50, 50, 0.5)).toBe(0);
expect(lerp(-100, 0, 0.5)).toBe(-50);
});
it('should handle t values outside 0-1 range', () => {
expect(lerp(0, 100, -0.5)).toBe(-50);
expect(lerp(0, 100, 1.5)).toBe(150);
});
});
describe('humanizeNumber', () => {
it('should return unchanged numbers below 1000', () => {
expect(humanizeNumber(0)).toBe('0');
expect(humanizeNumber(999)).toBe('999');
});
it('should add K suffix for thousands', () => {
expect(humanizeNumber(1000)).toBe('1K');
expect(humanizeNumber(1500)).toBe('1.5K');
expect(humanizeNumber(999999)).toBe('1000K');
});
it('should add M suffix for millions', () => {
expect(humanizeNumber(1000000)).toBe('1M');
expect(humanizeNumber(2500000)).toBe('2.5M');
});
it('should add B suffix for billions', () => {
expect(humanizeNumber(1000000000)).toBe('1B');
});
});
describe('humanizeDuration', () => {
it('should return ms for very short durations', () => {
expect(humanizeDuration(100)).toBe('100ms');
expect(humanizeDuration(999)).toBe('999ms');
});
it('should format seconds', () => {
expect(humanizeDuration(1000)).toBe('1s');
expect(humanizeDuration(1500)).toBe('1s500ms');
expect(humanizeDuration(59000)).toBe('59s');
});
it('should format minutes', () => {
expect(humanizeDuration(60000)).toBe('1m');
expect(humanizeDuration(90000)).toBe('1m 30s');
});
it('should format hours', () => {
expect(humanizeDuration(3600000)).toBe('1h');
expect(humanizeDuration(3661000)).toBe('1h 1m 1s');
});
it('should format days', () => {
expect(humanizeDuration(86400000)).toBe('1d');
expect(humanizeDuration(90061000)).toBe('1d 1h 1m 1s');
});
it('should handle zero', () => {
expect(humanizeDuration(0)).toBe('0ms');
});
});
describe('debounce', () => {
it('should return a function', () => {
const fn = debounce(() => {}, 100);
expect(typeof fn).toBe('function');
});
it('should only call once when invoked multiple times within delay', () => {
let callCount = 0;
const fn = debounce(() => {
callCount++;
}, 100);
fn();
const firstCall = callCount;
fn();
fn();
expect(callCount).toBe(firstCall);
});
});
describe('clone', () => {
it('should deep clone objects', () => {
const original = { a: 1, b: { c: 2 } };
const cloned = clone(original);
expect(cloned).toEqual(original);
expect(cloned).not.toBe(original);
expect(cloned.b).not.toBe(original.b);
});
it('should handle arrays', () => {
const original = [1, 2, [3, 4]];
const cloned = clone(original);
expect(cloned).toEqual(original);
expect(cloned).not.toBe(original);
expect(cloned[2]).not.toBe(original[2]);
});
it('should handle primitives', () => {
expect(clone(42)).toBe(42);
expect(clone('hello')).toBe('hello');
expect(clone(true)).toBe(true);
expect(clone(null)).toBe(null);
});
});
});

View File

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

View File

@@ -1,72 +0,0 @@
import { isObject, mergeDeep } from '$lib/helpers/deepMerge';
import { describe, expect, it } from 'vitest';
describe('deepMerge', () => {
describe('isObject', () => {
it('should return true for plain objects', () => {
expect(isObject({})).toBe(true);
expect(isObject({ a: 1 })).toBe(true);
});
it('should return false for non-objects', () => {
expect(isObject([])).toBe(false);
expect(isObject('string')).toBe(false);
expect(isObject(42)).toBe(false);
expect(isObject(undefined)).toBe(false);
});
});
describe('mergeDeep', () => {
it('should merge two flat objects', () => {
const target = { a: 1, b: 2 };
const source = { b: 3, c: 4 };
const result = mergeDeep(target, source);
expect(result).toEqual({ a: 1, b: 3, c: 4 });
});
it('should deeply merge nested objects', () => {
const target = { a: { x: 1 }, b: { y: 2 } };
const source = { a: { y: 2 }, c: { z: 3 } };
const result = mergeDeep(target, source);
expect(result).toEqual({
a: { x: 1, y: 2 },
b: { y: 2 },
c: { z: 3 }
});
});
it('should handle multiple sources', () => {
const target = { a: 1 };
const source1 = { b: 2 };
const source2 = { c: 3 };
const result = mergeDeep(target, source1, source2);
expect(result).toEqual({ a: 1, b: 2, c: 3 });
});
it('should return target if no sources provided', () => {
const target = { a: 1 };
const result = mergeDeep(target);
expect(result).toBe(target);
});
it('should overwrite non-object values', () => {
const target = { a: { b: 1 } };
const source = { a: 'string' };
const result = mergeDeep(target, source);
expect(result.a).toBe('string');
});
it('should handle arrays by replacing', () => {
const target = { a: [1, 2] };
const source = { a: [3, 4] };
const result = mergeDeep(target, source);
expect(result.a).toEqual([3, 4]);
});
});
});

View File

@@ -18,7 +18,7 @@ export function animate(duration: number, callback: (progress: number) => void |
} else { } else {
callback(1); callback(1);
} }
}; }
requestAnimationFrame(loop); requestAnimationFrame(loop);
} }
@@ -30,11 +30,10 @@ export function createNodePath({
cornerBottom = 0, cornerBottom = 0,
leftBump = false, leftBump = false,
rightBump = false, rightBump = false,
aspectRatio = 1 aspectRatio = 1,
} = {}) { } = {}) {
return `M0,${cornerTop} return `M0,${cornerTop}
${ ${cornerTop
cornerTop
? ` V${cornerTop} ? ` V${cornerTop}
Q0,0 ${cornerTop * aspectRatio},0 Q0,0 ${cornerTop * aspectRatio},0
H${100 - cornerTop * aspectRatio} H${100 - cornerTop * aspectRatio}
@@ -45,13 +44,11 @@ export function createNodePath({
` `
} }
V${y - height / 2} V${y - height / 2}
${ ${rightBump
rightBump
? ` C${100 - depth},${y - height / 2} ${100 - depth},${y + height / 2} 100,${y + height / 2}` ? ` C${100 - depth},${y - height / 2} ${100 - depth},${y + height / 2} 100,${y + height / 2}`
: ` H100` : ` H100`
} }
${ ${cornerBottom
cornerBottom
? ` V${100 - cornerBottom} ? ` V${100 - cornerBottom}
Q100,100 ${100 - cornerBottom * aspectRatio},100 Q100,100 ${100 - cornerBottom * aspectRatio},100
H${cornerBottom * aspectRatio} H${cornerBottom * aspectRatio}
@@ -59,29 +56,26 @@ export function createNodePath({
` `
: `${leftBump ? `V100 H0` : `V100`}` : `${leftBump ? `V100 H0` : `V100`}`
} }
${ ${leftBump
leftBump ? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${y - height / 2}`
? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${
y - height / 2
}`
: ` H0` : ` H0`
} }
Z`.replace(/\s+/g, ' '); Z`.replace(/\s+/g, " ");
} }
export const debounce = (fn: () => void, ms = 300) => { export const debounce = (fn: Function, ms = 300) => {
let timeoutId: ReturnType<typeof setTimeout>; let timeoutId: ReturnType<typeof setTimeout>;
return function(this: unknown, ...args: unknown[]) { return function (this: any, ...args: any[]) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args as []), ms); timeoutId = setTimeout(() => fn.apply(this, args), ms);
}; };
}; };
export const clone: <T>(v: T) => T = 'structedClone' in globalThis export const clone: <T>(v: T) => T = "structedClone" in globalThis ? globalThis.structuredClone : (obj) => JSON.parse(JSON.stringify(obj));
? globalThis.structuredClone
: (obj) => JSON.parse(JSON.stringify(obj));
export function withSubComponents<A, B extends Record<string, unknown>>(
export function withSubComponents<A, B extends Record<string, any>>(
component: A, component: A,
subcomponents: B subcomponents: B
): A & B { ): A & B {
@@ -93,7 +87,7 @@ export function withSubComponents<A, B extends Record<string, unknown>>(
} }
export function humanizeNumber(number: number): string { export function humanizeNumber(number: number): string {
const suffixes = ['', 'K', 'M', 'B', 'T']; const suffixes = ["", "K", "M", "B", "T"];
if (number < 1000) { if (number < 1000) {
return number.toString(); return number.toString();
} }
@@ -110,15 +104,11 @@ export function humanizeDuration(durationInMilliseconds: number) {
const millisecondsPerHour = 3600000; const millisecondsPerHour = 3600000;
const millisecondsPerDay = 86400000; const millisecondsPerDay = 86400000;
const days = Math.floor(durationInMilliseconds / millisecondsPerDay); let days = Math.floor(durationInMilliseconds / millisecondsPerDay);
const hours = Math.floor((durationInMilliseconds % millisecondsPerDay) / millisecondsPerHour); let hours = Math.floor((durationInMilliseconds % millisecondsPerDay) / millisecondsPerHour);
const minutes = Math.floor( let minutes = Math.floor((durationInMilliseconds % millisecondsPerHour) / millisecondsPerMinute);
(durationInMilliseconds % millisecondsPerHour) / millisecondsPerMinute let seconds = Math.floor((durationInMilliseconds % millisecondsPerMinute) / millisecondsPerSecond);
); let millis = durationInMilliseconds % millisecondsPerSecond;
const seconds = Math.floor(
(durationInMilliseconds % millisecondsPerMinute) / millisecondsPerSecond
);
const millis = durationInMilliseconds % millisecondsPerSecond;
let durationString = ''; let durationString = '';
@@ -141,10 +131,32 @@ export function humanizeDuration(durationInMilliseconds: number) {
return durationString.trim(); return durationString.trim();
} }
// export function debounceAsyncFunction<T extends any[], R>(
export function debounceAsyncFunction<T extends (...args: never[]) => Promise<unknown>>( // func: (...args: T) => Promise<R>
asyncFn: T // ): (...args: T) => Promise<R> {
): T { // 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 {
let isRunning = false; let isRunning = false;
let latestArgs: Parameters<T> | null = null; let latestArgs: Parameters<T> | null = null;
let resolveNext: (() => void) | null = null; let resolveNext: (() => void) | null = null;
@@ -165,7 +177,7 @@ export function debounceAsyncFunction<T extends (...args: never[]) => Promise<un
try { try {
// Execute with the latest arguments // Execute with the latest arguments
const result = await asyncFn(...latestArgs!); const result = await asyncFn(...latestArgs!);
return result as ReturnType<T>; return result;
} finally { } finally {
// Allow the next execution // Allow the next execution
isRunning = false; isRunning = false;
@@ -178,18 +190,48 @@ export function debounceAsyncFunction<T extends (...args: never[]) => Promise<un
}) as T; }) as T;
} }
export function withArgsChangeOnly<T extends unknown[], R>( // export function debounceAsyncFunction<T extends any[], R>(func: (...args: T) => Promise<R>): (...args: T) => Promise<R> {
func: (...args: T) => R // let currentPromise: Promise<R> | null = null;
): (...args: T) => R { // 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 {
let lastArgs: T | undefined = undefined; let lastArgs: T | undefined = undefined;
let lastResult: R; let lastResult: R;
return (...args: T): R => { return (...args: T): R => {
// Check if arguments are the same as last call // Check if arguments are the same as last call
if ( if (lastArgs && args.length === lastArgs.length && args.every((val, index) => val === lastArgs?.[index])) {
lastArgs && args.length === lastArgs.length
&& args.every((val, index) => val === lastArgs?.[index])
) {
return lastResult; // Return cached result if arguments haven't changed return lastResult; // Return cached result if arguments haven't changed
} }
@@ -199,3 +241,4 @@ export function withArgsChangeOnly<T extends unknown[], R>(
return lastResult; // Return new result 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> { export class LocalStore<T> {
value = $state<T>() as T; value = $state<T>() as T;
key = ''; key = "";
constructor(key: string, value: T) { constructor(key: string, value: T) {
this.key = key; this.key = key;

View File

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

View File

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

View File

@@ -1,10 +1,6 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <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="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="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" /> <rect x="2" y="14" width="5" height="7" rx="1" stroke="currentColor" stroke-width="2"/>
<path <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"/>
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> </svg>

Before

Width:  |  Height:  |  Size: 509 B

After

Width:  |  Height:  |  Size: 496 B

View File

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

View File

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

View File

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

View File

@@ -1,44 +1,39 @@
<script lang="ts"> <script lang="ts">
import NodeHtml from '$lib/graph-interface/node/NodeHTML.svelte'; import NodeHtml from "$lib/graph-interface/node/NodeHTML.svelte";
import type { NodeDefinition, NodeId, NodeInstance } from '@nodarium/types'; import type { NodeDefinition, NodeId, NodeInstance } from "@nodarium/types";
import { onMount } from 'svelte';
const { node }: { node: NodeDefinition } = $props(); const { node }: { node: NodeDefinition } = $props();
let dragging = $state(false); let dragging = $state(false);
let nodeData = $state<NodeInstance>(null!); let nodeData = $state<NodeInstance>({
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.setData(
'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()
);
}
onMount(() => {
nodeData = {
id: 0, id: 0,
type: node.id as unknown as NodeId, type: node.id as unknown as NodeId,
position: [0, 0] as [number, number], position: [0, 0] as [number, number],
props: {}, props: {},
state: { state: {
type: node type: node,
} },
};
}); });
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.setData(
"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(),
);
}
</script> </script>
<div class="node-wrapper" class:dragging> <div class="node-wrapper" class:dragging>
@@ -51,9 +46,7 @@
tabindex="0" tabindex="0"
ondragstart={handleDragStart} ondragstart={handleDragStart}
> >
{#if nodeData} <NodeHtml bind:node={nodeData} inView={true} position={"relative"} z={5} />
<NodeHtml bind:node={nodeData} inView={true} position="relative" z={5} />
{/if}
</div> </div>
</div> </div>
@@ -68,7 +61,7 @@
} }
.dragging { .dragging {
border: dashed 2px var(--color-outline); border: dashed 2px var(--outline);
} }
.node-wrapper > div { .node-wrapper > div {
opacity: 1; opacity: 1;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,42 +1,43 @@
<script lang="ts"> <script lang="ts">
import { defaultPlant, lottaFaces, plant, simple } from '$lib/graph-templates'; import type { Graph } from "$lib/types";
import type { Graph } from '$lib/types'; import { defaultPlant, plant, lottaFaces } from "$lib/graph-templates";
import { InputSelect } from '@nodarium/ui'; import type { ProjectManager } from "./project-manager.svelte";
import type { ProjectManager } from './project-manager.svelte';
const { projectManager } = $props<{ projectManager: ProjectManager }>(); const { projectManager } = $props<{ projectManager: ProjectManager }>();
let showNewProject = $state(false); let showNewProject = $state(false);
let newProjectName = $state(''); let newProjectName = $state("");
let selectedTemplate = $state("defaultPlant");
const templates = [ const templates = [
{ {
name: 'Default Plant', name: "Default Plant",
value: 'defaultPlant', value: "defaultPlant",
graph: defaultPlant as unknown as Graph 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: 'Simple', value: 'simple', graph: simple as unknown as Graph },
{ {
name: 'Lotta Faces', name: "Lotta Faces",
value: 'lottaFaces', value: "lottaFaces",
graph: lottaFaces as unknown as Graph graph: lottaFaces as unknown as Graph,
} },
]; ];
let selectedTemplateIndex = $state(0);
function handleCreate() { function handleCreate() {
const template = templates[selectedTemplateIndex] || templates[0]; const template =
templates.find((t) => t.value === selectedTemplate) || templates[0];
projectManager.handleCreateProject(template.graph, newProjectName); projectManager.handleCreateProject(template.graph, newProjectName);
newProjectName = ''; newProjectName = "";
showNewProject = false; showNewProject = false;
} }
</script> </script>
<header class="flex justify-between px-4 h-[70px] border-b-1 border-outline items-center bg-layer-2"> <header
class="flex justify-between px-4 h-[70px] border-b-1 border-[var(--outline)] items-center"
>
<h3>Project</h3> <h3>Project</h3>
<button <button
class="px-3 py-1 bg-layer-1 rounded" class="px-3 py-1 bg-[var(--layer-0)] rounded"
onclick={() => (showNewProject = !showNewProject)} onclick={() => (showNewProject = !showNewProject)}
> >
New New
@@ -44,17 +45,24 @@
</header> </header>
{#if showNewProject} {#if showNewProject}
<div class="flex flex-col px-4 py-3.5 mt-[1px] border-b-1 border-outline gap-3"> <div class="flex flex-col px-4 py-3 border-b-1 border-[var(--outline)] gap-2">
<input <input
type="text" type="text"
bind:value={newProjectName} bind:value={newProjectName}
placeholder="Project name" placeholder="Project name"
class="w-full px-2 py-2 bg-layer-2 rounded" 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()}
/> />
<InputSelect options={templates.map(t => t.name)} bind:value={selectedTemplateIndex} /> <select
bind:value={selectedTemplate}
class="w-full px-2 py-2 bg-gray-800 border border-gray-700 rounded"
>
{#each templates as template}
<option value={template.value}>{template.name}</option>
{/each}
</select>
<button <button
class="cursor-pointer self-end px-3 py-1 bg-selected rounded" class="cursor-pointer self-end px-3 py-1 bg-blue-600 rounded"
onclick={() => handleCreate()} onclick={() => handleCreate()}
> >
Create Create
@@ -62,34 +70,30 @@
</div> </div>
{/if} {/if}
<div class="text-white min-h-screen"> <div class="p-4 text-white min-h-screen">
{#if projectManager.loading} {#if projectManager.loading}
<p>Loading...</p> <p>Loading...</p>
{/if} {/if}
<ul> <ul class="space-y-2">
{#each projectManager.projects as project (project.id)} {#each projectManager.projects as project (project.id)}
<li> <li>
<div <div
class=" class="w-full text-left px-3 py-2 rounded cursor-pointer {projectManager
h-[70px] border-b-1 border-b-outline
flex
w-full text-left px-3 py-2 cursor-pointer {projectManager
.activeProjectId.value === project.id .activeProjectId.value === project.id
? 'border-l-2 border-l-selected pl-2.5!' ? 'bg-blue-600'
: ''} : 'bg-gray-800 hover:bg-gray-700'}"
"
onclick={() => projectManager.handleSelectProject(project.id!)} onclick={() => projectManager.handleSelectProject(project.id!)}
role="button" role="button"
tabindex="0" tabindex="0"
onkeydown={(e) => onkeydown={(e) =>
e.key === 'Enter' e.key === "Enter" &&
&& projectManager.handleSelectProject(project.id!)} projectManager.handleSelectProject(project.id!)}
> >
<div class="flex justify-between items-center grow"> <div class="flex justify-between items-center">
<span>{project.meta?.title || 'Untitled'}</span> <span>{project.meta?.title || "Untitled"}</span>
<button <button
class="text-layer-1! bg-red-500 w-7 text-xl rounded-sm cursor-pointer opacity-20 hover:opacity-80" class="text-red-400 hover:text-red-300"
onclick={() => { onclick={() => {
projectManager.handleDeleteProject(project.id!); projectManager.handleDeleteProject(project.id!);
}} }}

View File

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

View File

@@ -25,7 +25,7 @@ export class ProjectManager {
this.projects = await db.getGraphs(); this.projects = await db.getGraphs();
if (this.activeProjectId.value !== undefined) { if (this.activeProjectId.value !== undefined) {
const loadedGraph = await db.getGraph(this.activeProjectId.value); let loadedGraph = await db.getGraph(this.activeProjectId.value);
if (loadedGraph) { if (loadedGraph) {
this.graph = loadedGraph; this.graph = loadedGraph;
} }
@@ -54,7 +54,7 @@ export class ProjectManager {
g.id = id; g.id = id;
if (!g.meta) g.meta = {}; if (!g.meta) g.meta = {};
g.meta.title = title; if (!g.meta.title) g.meta.title = title;
db.saveGraph(g); db.saveGraph(g);
this.projects = [...this.projects, g]; this.projects = [...this.projects, g];

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
export * from './runtime-executor'; export * from "./runtime-executor"
export * from './runtime-executor-cache'; export * from "./runtime-executor-cache"
export * from './worker-runtime-executor'; 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 { export class RemoteRuntimeExecutor implements RuntimeExecutor {
constructor(private url: string) {}
async execute(graph: Graph, settings: Record<string, unknown>): Promise<Int32Array> { constructor(private url: string) { }
const res = await fetch(this.url, {
method: 'POST', async execute(graph: Graph, settings: Record<string, any>): Promise<Int32Array> {
body: JSON.stringify({ graph, settings })
}); const res = await fetch(this.url, { method: "POST", body: JSON.stringify({ graph, settings }) });
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to execute graph`); throw new Error(`Failed to execute graph`);
} }
return new Int32Array(await res.arrayBuffer()); 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 { export class MemoryRuntimeCache implements SyncCache {
private map = new Map<string, unknown>(); private map = new Map<string, unknown>();

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