4 Commits

Author SHA1 Message Date
Max Richter
aea2cbceba feat: updating some things
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 10m47s
2026-01-18 15:39:10 +01:00
Max Richter
cb89b16dd8 chore: cleanup some code 2026-01-18 15:18:11 +01:00
Max Richter
4217621574 feat(ui): remove storybook 2026-01-18 15:17:56 +01:00
Max Richter
22f285eaff chore: pnpm update 2026-01-18 15:17:27 +01:00
351 changed files with 10789 additions and 16303 deletions

View File

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

View File

@@ -1,59 +0,0 @@
{
"$schema": "https://dprint.dev/schemas/v0.json",
"indentWidth": 2,
"lineWidth": 100,
"typescript": {
// https://dprint.dev/plugins/typescript/config/
"quoteStyle": "preferSingle",
"trailingCommas": "never",
},
"json": {
// https://dprint.dev/plugins/json/config/
},
"markdown": {},
"toml": {},
"dockerfile": {},
"markup": {
// https://dprint.dev/plugins/markup_fmt/config/
"scriptIndent": true,
"styleIndent": true,
},
"yaml": {},
"exec": {
"cwd": "${configDir}",
"commands": [
{
"command": "rustfmt",
"exts": [
"rs",
],
"cacheKeyFiles": [
"rustfmt.toml",
"rust-toolchain.toml",
],
},
],
},
"excludes": [
"**/node_modules",
"**/build",
"**/.svelte-kit",
"**/package",
"**/*-lock.yaml",
"**/yaml.lock",
"**/.DS_Store",
"**/.pnpm-store",
"**/.cargo",
"**/target",
],
"plugins": [
"https://plugins.dprint.dev/typescript-0.95.15.wasm",
"https://plugins.dprint.dev/json-0.21.1.wasm",
"https://plugins.dprint.dev/markdown-0.21.1.wasm",
"https://plugins.dprint.dev/toml-0.7.0.wasm",
"https://plugins.dprint.dev/dockerfile-0.3.3.wasm",
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm",
"https://plugins.dprint.dev/g-plane/pretty_yaml-v0.6.0.wasm",
"https://plugins.dprint.dev/exec-0.6.0.json@a054130d458f124f9b5c91484833828950723a5af3f8ff2bd1523bd47b83b364",
],
}

1
.envrc
View File

@@ -1 +0,0 @@
use flake

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -1,45 +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
commit_message=$(git log -1 --pretty=%B | tr -d '\n' | sed 's/"/\\"/g')
cat >app/static/git.json <<EOF
{
"ref": "${GITHUB_REF:-}",
"ref_name": "${REF_NAME}",
"ref_type": "${REF_TYPE}",
"sha": "${GITHUB_SHA:-}",
"run_number": "${GITHUB_RUN_NUMBER:-}",
"event_name": "${GITHUB_EVENT_NAME:-}",
"workflow": "${GITHUB_WORKFLOW:-}",
"job": "${GITHUB_JOB:-}",
"commit_message": "${commit_message}",
"commit_timestamp": "$(git log -1 --pretty=%cI)",
"branch": "${BRANCH}",
"commits_since_last_release": "${COMMITS_SINCE_LAST_RELEASE}"
}
EOF
pnpm build
cp -R packages/ui/build app/build/ui

View File

@@ -1,102 +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)' | sed '/-----BEGIN PGP SIGNATURE-----/,/-----END PGP SIGNATURE-----/d')
if [ -z "$(echo "$NOTES" | tr -d '[:space:]')" ]; then
echo "❌ Tag message is empty or tag is not annotated"
exit 1
fi
git checkout main
# -------------------------------------------------------------------
# 2. Update all package.json versions
# -------------------------------------------------------------------
echo "🔧 Updating package.json versions to $VERSION"
find . -name package.json ! -path "*/node_modules/*" | while read -r file; do
tmp_file="$file.tmp"
jq --arg v "$VERSION" '.version = $v' "$file" >"$tmp_file"
mv "$tmp_file" "$file"
done
# -------------------------------------------------------------------
# 3. Generate commit list since last release
# -------------------------------------------------------------------
LAST_TAG=$(git tag --sort=-creatordate | grep -v "^$TAG$" | head -n 1 || echo "")
if [ -n "$LAST_TAG" ]; then
# Filter out previous 'chore(release)' commits so the list stays clean
COMMITS=$(git log "$LAST_TAG"..HEAD --pretty=format:'* [%h](https://git.max-richter.dev/max/nodarium/commit/%H) %s' | grep -v "chore(release)")
else
COMMITS=$(git log HEAD --pretty=format:'* [%h](https://git.max-richter.dev/max/nodarium/commit/%H) %s' | grep -v "chore(release)")
fi
# -------------------------------------------------------------------
# 4. Update CHANGELOG.md (prepend)
# -------------------------------------------------------------------
tmp_changelog="CHANGELOG.tmp"
{
echo "# $TAG ($DATE)"
echo ""
echo "$NOTES"
echo ""
if [ -n "$COMMITS" ]; then
echo "---"
echo ""
echo "$COMMITS"
echo ""
fi
echo ""
if [ -f CHANGELOG.md ]; then
cat CHANGELOG.md
fi
} >"$tmp_changelog"
mv "$tmp_changelog" CHANGELOG.md
pnpm exec dprint fmt CHANGELOG.md
# -------------------------------------------------------------------
# 5. Setup GPG signing
# -------------------------------------------------------------------
echo "$BOT_PGP_PRIVATE_KEY" | base64 -d | gpg --batch --import
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
export GPG_TTY=$(tty)
echo "allow-loopback-pinentry" >>~/.gnupg/gpg-agent.conf
gpg-connect-agent reloadagent /bye
git config user.name "nodarium-bot"
git config user.email "nodarium-bot@max-richter.dev"
git config --global user.signingkey "$GPG_KEY_ID"
git config --global commit.gpgsign true
# -------------------------------------------------------------------
# 6. Create release commit
# -------------------------------------------------------------------
git add CHANGELOG.md $(git ls-files '**/package.json')
if git diff --cached --quiet; then
echo "No changes to commit for release $TAG"
else
git commit -m "chore(release): $TAG"
git push origin main
fi
echo "✅ Release process for $TAG complete"

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,59 +0,0 @@
name: 📊 Benchmark the Runtime
on:
push:
branches: ["*"]
pull_request:
branches: ["*"]
env:
PNPM_CACHE_FOLDER: .pnpm-store
CARGO_HOME: .cargo
CARGO_TARGET_DIR: target
jobs:
release:
runs-on: ubuntu-latest
container: git.max-richter.dev/max/nodarium-ci:bce06da456e3c008851ac006033cfff256015a47
steps:
- name: 📑 Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITEA_TOKEN }}
- name: 💾 Setup pnpm Cache
uses: actions/cache@v4
with:
path: ${{ env.PNPM_CACHE_FOLDER }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: 🦀 Cache Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: 📦 Install Dependencies
run: pnpm install --frozen-lockfile --store-dir ${{ env.PNPM_CACHE_FOLDER }}
- name: 🛠Build Nodes
run: pnpm build:nodes
- name: 🏃 Execute Runtime
run: pnpm run --filter @nodarium/app bench
- name: 📤 Upload Benchmark Results
uses: actions/upload-artifact@v3
with:
name: benchmark-data
path: app/benchmark/out/
compression: 9

View File

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

View File

@@ -1,83 +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:bce06da456e3c008851ac006033cfff256015a47
steps:
- name: 📑 Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITEA_TOKEN }}
- name: 💾 Setup pnpm Cache
uses: actions/cache@v4
with:
path: ${{ env.PNPM_CACHE_FOLDER }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: 🦀 Cache Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: 📦 Install Dependencies
run: pnpm install --frozen-lockfile --store-dir ${{ env.PNPM_CACHE_FOLDER }}
- name: 🧹 Quality Control
run: |
pnpm lint
pnpm format:check
pnpm check
pnpm build
xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" pnpm test
- name: 🚀 Create Release Commit
if: gitea.ref_type == 'tag'
run: ./.gitea/scripts/create-release.sh
env:
BOT_PGP_PRIVATE_KEY: ${{ secrets.BOT_PGP_PRIVATE_KEY }}
- name: 🛠️ Build
run: ./.gitea/scripts/build.sh
- name: 🏷️ Create Gitea Release
if: gitea.ref_type == 'tag'
uses: akkuman/gitea-release-action@v1
with:
tag_name: ${{ gitea.ref_name }}
release_name: Release ${{ gitea.ref_name }}
body_path: CHANGELOG.md
draft: false
prerelease: false
- name: 🚀 Deploy Changed Files via rclone
run: ./.gitea/scripts/deploy-files.sh
env:
REMOTE_DIR: ${{ vars.REMOTE_DIR }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_HOST: ${{ vars.SSH_HOST }}
SSH_PORT: ${{ vars.SSH_PORT }}
SSH_USER: ${{ vars.SSH_USER }}

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 }}

3
.gitignore vendored
View File

@@ -4,6 +4,3 @@ node_modules/
# Added by cargo
/target
.direnv/
.pnpm-store/

View File

@@ -1,198 +0,0 @@
# v0.0.5 (2026-02-13)
## Features
- Implement debug node with full runtime integration, wildcard (`*`) inputs, variable-height nodes and parameters, and a quick-connect shortcut.
- Add color-coded node sockets and edges to visually indicate data types.
- Recursively merge `localState` with the initial state to safely handle outdated settings stored in `localStorage` when the settings schema changes.
- Clamp the Add Menu to the viewport.
- Add application favicon.
- Consolidate all developer settings into a single **Advanced Mode** setting.
## Fixes
- Fix InputNumber arrow visibility in the light theme.
- Correct changelog formatting issues.
## Chores
- Add `pnpm qa` pre-commit command.
- Run linting and type checks before build in CI.
- Sign release commits with a PGP key.
- General formatting, lint/type cleanup, test snapshot updates, and `.gitignore` maintenance.
---
- [f16ba26](https://git.max-richter.dev/max/nodarium/commit/f16ba2601ff0e8f0f4454e24689499112a2a257a) fix(ci): still trying to get gpg to work
- [cc6b832](https://git.max-richter.dev/max/nodarium/commit/cc6b832f1576356e5453ee4289b02f854152ff9a) fix(ci): trying to get gpg to work
- [dd5fd5b](https://git.max-richter.dev/max/nodarium/commit/dd5fd5bf1715d371566bd40419b72ca05e63401e) fix(ci): better add updates to package.json
- [38d0fff](https://git.max-richter.dev/max/nodarium/commit/38d0fffcf4ca0a346857c3658ccefdfcdf16e217) chore: update ci image
- [bce06da](https://git.max-richter.dev/max/nodarium/commit/bce06da456e3c008851ac006033cfff256015a47) ci: add gpg-agent to ci image
- [af585d5](https://git.max-richter.dev/max/nodarium/commit/af585d56ec825662961c8796226ed9d8cb900795) feat: use new ci image with gpg
- [0aa73a2](https://git.max-richter.dev/max/nodarium/commit/0aa73a27c1f23bea177ecc66034f8e0384c29a8e) feat: install gpg in ci image
- [c1ae702](https://git.max-richter.dev/max/nodarium/commit/c1ae70282cb5d58527180614a80823d80ca478c5) feat: add color to sockets
- [4c7b03d](https://git.max-richter.dev/max/nodarium/commit/4c7b03dfb82174317d8ba01f4725af804201154d) feat: add gradient mesh line
- [144e8cc](https://git.max-richter.dev/max/nodarium/commit/144e8cc797cfcc5a7a1fd9a0a2098dc99afb6170) fix: correctly highlight possible outputs
- [12ff9c1](https://git.max-richter.dev/max/nodarium/commit/12ff9c151873d253ed2e54dcf56aa9c9c4716c7c) Merge pull request 'feat/debug-node' (#41) from feat/debug-node into main
- [8d3ffe8](https://git.max-richter.dev/max/nodarium/commit/8d3ffe84ab9ca9e6d6d28333752e34da878fd3ea) Merge branch 'main' into feat/debug-node
- [95ec93e](https://git.max-richter.dev/max/nodarium/commit/95ec93eeada9bf062e01e1e77b67b8f0343a51bf) feat: better handle ctrl+shift clicks and selections
- [d39185e](https://git.max-richter.dev/max/nodarium/commit/d39185efafc360f49ab9437c0bad1f64665df167) feat: add "pnpm qa" command to check before commit
- [81580cc](https://git.max-richter.dev/max/nodarium/commit/81580ccd8c1db30ce83433c4c4df84bd660d3460) fix: cleanup some type errors
- [bf6f632](https://git.max-richter.dev/max/nodarium/commit/bf6f632d2772c3da812d5864c401f17e1aa8666a) feat: add shortcut to quick connect to debug
- [e098be6](https://git.max-richter.dev/max/nodarium/commit/e098be60135f57cf863904a58489e032ed27e8b4) fix: also execute all nodes before debug node
- [ec13850](https://git.max-richter.dev/max/nodarium/commit/ec13850e1c0ca5846da614d25887ff492cf8be04) fix: make debug node work with runtime
- [15e08a8](https://git.max-richter.dev/max/nodarium/commit/15e08a816339bdf9de9ecb6a57a7defff42dbe8c) feat: implement debug node
- [48cee58](https://git.max-richter.dev/max/nodarium/commit/48cee58ad337c1c6c59a0eb55bf9b5ecd16b99d0) chore: update test snapshots
- [3235cae](https://git.max-richter.dev/max/nodarium/commit/3235cae9049e193c242b6091cee9f01e67ee850e) chore: fix lint and typecheck errors
- [3f44072](https://git.max-richter.dev/max/nodarium/commit/3f440728fc8a94d59022bb545f418be049a1f1ba) feat: implement variable height for node shader
- [da09f8b](https://git.max-richter.dev/max/nodarium/commit/da09f8ba1eda5ed347433d37064a3b4ab49e627e) refactor: move debug node into runtime
- [ddc3b4c](https://git.max-richter.dev/max/nodarium/commit/ddc3b4ce357ef1c1e8066c0a52151713d0b6ed95) feat: allow variable height node parameters
- [2690fc8](https://git.max-richter.dev/max/nodarium/commit/2690fc871291e73d3d028df9668e8fffb1e77476) chore: gitignore pnpm-store
- [072ab90](https://git.max-richter.dev/max/nodarium/commit/072ab9063ba56df0673020eb639548f3a8601e04) feat: add initial debug node
- [e23cad2](https://git.max-richter.dev/max/nodarium/commit/e23cad254d610e00f196b7fdb4532f36fd735a4b) feat: add "*" datatype for inputs for debug node
- [5b5c63c](https://git.max-richter.dev/max/nodarium/commit/5b5c63c1a9c4ef757382bd4452149dc9777693ff) fix(ui): make arrows on inputnumber visible on lighttheme
- [c9021f2](https://git.max-richter.dev/max/nodarium/commit/c9021f2383828f2e2b5594d125165bbc8f70b8e7) refactor: merge all dev settings into one setting
- [9eecdd4](https://git.max-richter.dev/max/nodarium/commit/9eecdd4fb85dc60b8196101050334e26732c9a34) Merge pull request 'feat: merge localState recursively with initial' (#38) from feat/debug-node into main
- [7e71a41](https://git.max-richter.dev/max/nodarium/commit/7e71a41e5229126d404f56598c624709961dbf3b) feat: merge localState recursively with initial
- [07cd9e8](https://git.max-richter.dev/max/nodarium/commit/07cd9e84eb51bc02b7fed39c36cf83caba175ad7) feat: clamp AddMenu to viewport
- [a31a49a](https://git.max-richter.dev/max/nodarium/commit/a31a49ad503d69f92f2491dd685729060ea49896) ci: lint and typecheck before build
- [850d641](https://git.max-richter.dev/max/nodarium/commit/850d641a25cd0c781478c58c117feaf085bdbc62) chore: pnpm format
- [ee5ca81](https://git.max-richter.dev/max/nodarium/commit/ee5ca817573b83cacfa3709e0ae88c6263bc39c1) ci: sign release commits with pgp key
- [22a1183](https://git.max-richter.dev/max/nodarium/commit/22a11832b861ae8b44e2d374b55d12937ecab247) fix(ci): correctly format changelog
- [b5ce572](https://git.max-richter.dev/max/nodarium/commit/b5ce5723fa4a35443df39a9096d0997f808f0b4f) chore: format favicon svg
- [102130c](https://git.max-richter.dev/max/nodarium/commit/102130cc7777ceebcdb3de8466c90cef5b380111) feat: add favicon
- [1668a2e](https://git.max-richter.dev/max/nodarium/commit/1668a2e6d59db071ab3da45204c2b7bfcd2150a2) chore: format changelog.md
# v0.0.4 (2026-02-10)
## Features
- Added shape and leaf nodes, including rotation support.
- Added high-contrast light theme and improved overall node readability.
- Enhanced UI with dots background, clearer details, and consistent node coloring.
- Improved changelog display and parsing robustness.
## Fixes
- Fixed UI issues (backside rendering, missing types, linter errors).
- Improved CI handling of commit messages and changelog placement.
## Chores
- Simplified CI quality checks.
- Updated dprint linters.
- Refactored changelog code.
---
- [51de3ce](https://git.max-richter.dev/max/nodarium/commit/51de3ced133af07b9432e1137068ef43ddfecbc9) fix(ci): update changelog before building
- [8d403ba](https://git.max-richter.dev/max/nodarium/commit/8d403ba8039a05b687f050993a6afca7fb743e12) Merge pull request 'feat/shape-node' (#36) from feat/shape-node into main
- [6bb3011](https://git.max-richter.dev/max/nodarium/commit/6bb301153ac13c31511b6b28ae95c6e0d4c03e9e) Merge remote-tracking branch 'origin/main' into feat/shape-node
- [02eee5f](https://git.max-richter.dev/max/nodarium/commit/02eee5f9bf4b1bc813d5d28673c4d5d77b392a92) fix: disable macro logs in wasm
- [4f48a51](https://git.max-richter.dev/max/nodarium/commit/4f48a519a950123390530f1b6040e2430a767745) feat(nodes): add rotation to instance node
- [97199ac](https://git.max-richter.dev/max/nodarium/commit/97199ac20fb079d6c157962d1a998d63670d8797) feat(nodes): implement leaf node
- [f36f0cb](https://git.max-richter.dev/max/nodarium/commit/f36f0cb2305692c7be60889bcde7f91179e18b81) feat(ui): show circles only when hovering InputShape
- [ed3d48e](https://git.max-richter.dev/max/nodarium/commit/ed3d48e07fa6db84bbb24db6dbe044cbc36f049f) fix(runtime): correctly encode 2d shape for wasm nodes
- [c610d6c](https://git.max-richter.dev/max/nodarium/commit/c610d6c99152d8233235064b81503c2b0dc4ada8) fix(app): show backside in three instances
- [8865b9b](https://git.max-richter.dev/max/nodarium/commit/8865b9b032bdf5a1385b4e9db0b1923f0e224fdd) feat(node): initial leaf / shape nodes
- [235ee5d](https://git.max-richter.dev/max/nodarium/commit/235ee5d979fbd70b3e0fb6f09a352218c3ff1e6d) fix(app): wrong linter errors in changelog
- [23a4857](https://git.max-richter.dev/max/nodarium/commit/23a48572f3913d91d839873cc155a16139c286a6) feat(app): dots background for node interface
- [e89a46e](https://git.max-richter.dev/max/nodarium/commit/e89a46e146e9e95de57ffdf55b05d16d6fe975f4) feat(app): add error page
- [cefda41](https://git.max-richter.dev/max/nodarium/commit/cefda41fcf3d5d011c9f7598a4f3f37136602dbd) feat(theme): optimize node readability
- [21d0f0d](https://git.max-richter.dev/max/nodarium/commit/21d0f0da5a26492fa68ad4897a9b1d9e88857030) feat: add high-contrast-light theme
- [4620245](https://git.max-richter.dev/max/nodarium/commit/46202451ba3eea73bd1bc6ef5129b3e26ee9315c) ci: simplify ci quality checks
- [0f4239d](https://git.max-richter.dev/max/nodarium/commit/0f4239d179ddedd3d012ca98b5bc3312afbc8f10) ci: simplify ci quality checks
- [d9c9bb5](https://git.max-richter.dev/max/nodarium/commit/d9c9bb5234bc8776daf26be99ba77a2145c70649) fix(theme): allow raw html in head style
- [18802fd](https://git.max-richter.dev/max/nodarium/commit/18802fdc10294a58425f052a4fde4bcf4be58caf) fix(ui): add missing types
- [b1cbd23](https://git.max-richter.dev/max/nodarium/commit/b1cbd235420c99a11154ef6a899cc7e14faf1c37) feat(app): use same color for node outline and header
- [33f10da](https://git.max-richter.dev/max/nodarium/commit/33f10da396fdc13edcb8faaee212280102b24f3a) feat(ui): make details stand out
- [af5b3b2](https://git.max-richter.dev/max/nodarium/commit/af5b3b23ba18d73d6abec60949fb0c9edfc25ff8) fix: make sure that CHANGELOG.md is in correct place
- [64d75b9](https://git.max-richter.dev/max/nodarium/commit/64d75b9686c494075223a0a318297fe59ec99e81) feat(ui): add InputColor and custom theme
- [2e6466c](https://git.max-richter.dev/max/nodarium/commit/2e6466ceca1d2131581d1862e93c756affdf6cd6) chore: update dprint linters
- [20d8e2a](https://git.max-richter.dev/max/nodarium/commit/20d8e2abedf0de30299d947575afef9c8ffd61d9) feat(theme): improve light theme a bit
- [715e1d0](https://git.max-richter.dev/max/nodarium/commit/715e1d095b8a77feb0cf66bbb444baf0f163adcb) feat(theme): merge edge and connection color
- [07e2826](https://git.max-richter.dev/max/nodarium/commit/07e2826f16dafa6a07377c9fb591168fa5c2abcf) feat(ui): improve colors of input shape
- [e0ad97b](https://git.max-richter.dev/max/nodarium/commit/e0ad97b003fd8cb4d950c03e5488a5accf6a37d0) feat(ui): highlight circle on hover on InputShape
- [93df4a1](https://git.max-richter.dev/max/nodarium/commit/93df4a19ff816e2bdfa093594721f0829f84c9e6) fix(ci): handle newline in commit messages for git.json
- [d661a4e](https://git.max-richter.dev/max/nodarium/commit/d661a4e4a9dfa6c9c73b5e24a3edcf56e1bbf48c) feat(ui): improve InputShape ux
- [c7f808c](https://git.max-richter.dev/max/nodarium/commit/c7f808ce2d52925425b49f92edf49d9557f8901d) wip
- [72d6cd6](https://git.max-richter.dev/max/nodarium/commit/72d6cd6ea2886626823e6e86856f19338c7af3c1) feat(ui): add initial InputShape element
- [615f2d3](https://git.max-richter.dev/max/nodarium/commit/615f2d3c4866a9e85f3eca398f3f02100c4df355) feat(ui): allow custom snippets in ui section header
- [2fadb68](https://git.max-richter.dev/max/nodarium/commit/2fadb6802de640d692fdab7d654311df0d7b4836) refactor: make changelog code simpler
- [9271d3a](https://git.max-richter.dev/max/nodarium/commit/9271d3a7e4cb0cc751b635c2adb518de7b4100c7) fix(app): handle error while parsing commit
- [13c83ef](https://git.max-richter.dev/max/nodarium/commit/13c83efdb962a6578ade59f10cc574fef0e17534) fix(app): handle error while parsing changelog
- [e44b73b](https://git.max-richter.dev/max/nodarium/commit/e44b73bebfb1cc8e872cd2fa7d8b6ff3565df374) feat: optimize changelog display
- [979e9fd](https://git.max-richter.dev/max/nodarium/commit/979e9fd92289eba9f77221c563337c00028e4cf5) feat: improve changelog readbility
- [544500e](https://git.max-richter.dev/max/nodarium/commit/544500e7fe9ee14412cef76f3c7a32ba6f291656) chore: remove pgp from changelog
- [aaebbc4](https://git.max-richter.dev/max/nodarium/commit/aaebbc4bc082ee93c2317ce45071c9bc61b0b77e) fix: some stuff with ci
# v0.0.3 (2026-02-07)
## Features
- Edge dragging now highlights valid connection sockets, improving graph editing clarity.
- InputNumber supports snapping to predefined values while holding Alt.
- Changelog is accessible directly from the sidebar and now includes git metadata and a list of commits.
## Fixes
- Fixed incorrect socket highlighting when an edge already existed.
- Corrected initialization of `InputNumber` values outside min/max bounds.
- Fixed initialization of nested vec3 inputs.
- Multiple CI fixes to ensure reliable builds, correct environment variables, and proper image handling.
## Maintenance / CI
- Significant CI and Dockerfile cleanup and optimization.
- Improved git metadata generation during builds.
- Dependency updates, formatting, and test snapshot updates.
---
- [f8a2a95](https://git.max-richter.dev/max/nodarium/commit/f8a2a95bc18fa3c8c1db67dc0c2b66db1ff0d866) chore: clean CHANGELOG.md
- [c9dd143](https://git.max-richter.dev/max/nodarium/commit/c9dd143916d758991f3ba30723a32c18b6f98bb5) fix(ci): correctly add release notes from tag to changelog
- [898dd49](https://git.max-richter.dev/max/nodarium/commit/898dd49aee930350af8645382ef5042765a1fac7) fix(ci): correctly copy changelog to build output
- [9fb69d7](https://git.max-richter.dev/max/nodarium/commit/9fb69d760fdf92ecc2448e468242970ec48443b0) feat: show commits since last release in changelog
- [bafbcca](https://git.max-richter.dev/max/nodarium/commit/bafbcca2b8a7cd9f76e961349f11ec84d1e4da63) fix: wrong socket was highlighted when dragging node
- [8ad9e55](https://git.max-richter.dev/max/nodarium/commit/8ad9e5535cd752ef111504226b4dac57b5adcf3d) feat: highlight possible sockets when dragging edge
- [11eaeb7](https://git.max-richter.dev/max/nodarium/commit/11eaeb719be7f34af8db8b7908008a15308c0cac) feat(app): display some git metadata in changelog
- [74c2978](https://git.max-richter.dev/max/nodarium/commit/74c2978cd16d2dd95ce1ae8019dfb9098e52b4b6) chore: cleanup git.json a bit
- [4fdc247](https://git.max-richter.dev/max/nodarium/commit/4fdc24790490d3f13ee94a557159617f4077a2f9) ci: update build.sh to correct git.json
- [c3f8b4b](https://git.max-richter.dev/max/nodarium/commit/c3f8b4b5aad7a525fb11ab14c9236374cb60442d) ci: debug available env vars
- [67591c0](https://git.max-richter.dev/max/nodarium/commit/67591c0572b873d8c7cd00db8efb7dac2d6d4de2) chore: pnpm format
- [de1f9d6](https://git.max-richter.dev/max/nodarium/commit/de1f9d6ab669b8e699d98b8855e125e21030b5b3) feat(ui): change inputnumber to snap to values when alt is pressed
- [6acce72](https://git.max-richter.dev/max/nodarium/commit/6acce72fb8c416cc7f6eec99c2ae94d6529e960c) fix(ui): correctly initialize InputNumber
- [cf8943b](https://git.max-richter.dev/max/nodarium/commit/cf8943b2059aa286e41865caf75058d35498daf7) chore: pnpm update
- [9e03d36](https://git.max-richter.dev/max/nodarium/commit/9e03d36482bb4f972c384b66b2dcf258f0cd18be) chore: use newest ci image
- [fd7268d](https://git.max-richter.dev/max/nodarium/commit/fd7268d6208aede435e1685817ae6b271c68bd83) ci: make dockerfile work
- [6358c22](https://git.max-richter.dev/max/nodarium/commit/6358c22a853ec340be5223fabb8289092e4f4afe) ci: use tagged own image for ci
- [655b6a1](https://git.max-richter.dev/max/nodarium/commit/655b6a18b282f0cddcc750892e575ee6c311036b) ci: make dockerfile work
- [37b2bdc](https://git.max-richter.dev/max/nodarium/commit/37b2bdc8bdbd8ded6b22b89214b49de46f788351) ci: update ci Dockerfile to work
- [94e01d4](https://git.max-richter.dev/max/nodarium/commit/94e01d4ea865f15ce06b52827a1ae6906de5be5e) ci: correctly build and push ci image
- [35f5177](https://git.max-richter.dev/max/nodarium/commit/35f5177884b62bbf119af1bbf4df61dd0291effb) feat: try to optimize the Dockerfile
- [ac2c61f](https://git.max-richter.dev/max/nodarium/commit/ac2c61f2211ba96bbdbb542179905ca776537cec) ci: use actual git url in ci
- [ef3d462](https://git.max-richter.dev/max/nodarium/commit/ef3d46279f4ff9c04d80bb2d9a9e7cfec63b224e) fix(ci): build before testing
- [703da32](https://git.max-richter.dev/max/nodarium/commit/703da324fabbef0e2c017f0f7a925209fa26bd03) ci: automatically build ci image and store locally
- [1dae472](https://git.max-richter.dev/max/nodarium/commit/1dae472253ccb5e3766f2270adc053b922f46738) ci: add a git.json metadata file during build
- [09fdfb8](https://git.max-richter.dev/max/nodarium/commit/09fdfb88cd203ace0e36663ebdb2c8c7ba53f190) chore: update test screenshots
- [04b63cc](https://git.max-richter.dev/max/nodarium/commit/04b63cc7e2fc4fcfa0973cf40592d11457179db3) feat: add changelog to sidebar
- [cb6a356](https://git.max-richter.dev/max/nodarium/commit/cb6a35606dfda50b0c81b04902d7a6c8e59458d2) feat(ci): also cache cargo stuff
- [9c9f3ba](https://git.max-richter.dev/max/nodarium/commit/9c9f3ba3b7c94215a86b0a338a5cecdd87b96b28) fix(ci): use GITHUB_instead of GITEA_ for env vars
- [08dda2b](https://git.max-richter.dev/max/nodarium/commit/08dda2b2cb4d276846abe30bc260127626bb508a) chore: pnpm format
- [059129a](https://git.max-richter.dev/max/nodarium/commit/059129a738d02b8b313bb301a515697c7c4315ac) fix(ci): deploy prs and main
- [437c9f4](https://git.max-richter.dev/max/nodarium/commit/437c9f4a252125e1724686edace0f5f006f58439) feat(ci): add list of all commits to changelog entry
- [48bf447](https://git.max-richter.dev/max/nodarium/commit/48bf447ce12949d7c29a230806d160840b7847e1) docs: straighten up changelog a bit
- [548fa4f](https://git.max-richter.dev/max/nodarium/commit/548fa4f0a1a14adc40a74da1182fa6da81eab3df) fix(app): correctly initialize vec3 inputs in nestedsettings
# v0.0.2 (2026-02-04)
## Fixes
---
- []() fix(ci): actually deploy on tags
- []() fix(app): correctly handle false value in settings
# v0.0.1 (2026-02-03)
chore: format

365
Cargo.lock generated
View File

@@ -1,96 +1,176 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
version = 3
[[package]]
name = "autocfg"
version = "1.5.0"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80"
[[package]]
name = "box"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "branch"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"glam",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "debug"
version = "0.1.0"
name = "bumpalo"
version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"nodarium_macros",
"nodarium_utils",
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "float"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
]
[[package]]
name = "glam"
version = "0.30.10"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9"
[[package]]
name = "gravity"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"glam",
"nodarium_macros",
"nodarium_utils",
]
[[package]]
name = "instance"
version = "0.1.0"
dependencies = [
"glam",
"nodarium_macros",
"nodarium_utils",
"noise",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "itoa"
version = "1.0.17"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[package]]
name = "leaf"
version = "0.1.0"
name = "js-sys"
version = "0.3.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d"
dependencies = [
"nodarium_macros",
"nodarium_utils",
"wasm-bindgen",
]
[[package]]
name = "log"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
[[package]]
name = "math"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "memchr"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
name = "max-plantarium-triangle"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "max-plantarium-vec3"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "nodarium_instance"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"glam",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "nodarium_macros"
@@ -100,7 +180,7 @@ dependencies = [
"quote",
"serde",
"serde_json",
"syn",
"syn 1.0.109",
]
[[package]]
@@ -115,19 +195,29 @@ dependencies = [
name = "nodarium_utils"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"glam",
"noise 0.9.0",
"noise",
"serde",
"serde_json",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "noise"
name = "nodes-noise"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"glam",
"nodarium_macros",
"nodarium_utils",
"noise 0.9.0",
"noise",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
@@ -143,35 +233,48 @@ dependencies = [
[[package]]
name = "num-traits"
version = "0.2.19"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "output"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"glam",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "proc-macro2"
version = "1.0.105"
version = "1.0.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.43"
version = "1.0.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
dependencies = [
"proc-macro2",
]
@@ -204,84 +307,103 @@ dependencies = [
name = "random"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
]
[[package]]
name = "rotate"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"glam",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "ryu"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1"
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]]
name = "serde"
version = "1.0.228"
version = "1.0.198"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
checksum = "9846a40c979031340571da2545a4e5b7c4163bdae79b301d5f86d03979451fcc"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
name = "serde-wasm-bindgen"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
checksum = "e3b4c031cd0d9014307d82b8abf653c0290fbdaeb4c02d00c63cf52f728628bf"
dependencies = [
"serde_derive",
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
version = "1.0.198"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
checksum = "e88edab869b01783ba905e7d0153f9fc1a6505a96e4ad3018011eedb838566d9"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.60",
]
[[package]]
name = "serde_json"
version = "1.0.149"
version = "1.0.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shape"
version = "0.1.0"
dependencies = [
"nodarium_macros",
"nodarium_utils",
]
[[package]]
name = "stem"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "syn"
version = "2.0.114"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
@@ -289,30 +411,119 @@ dependencies = [
]
[[package]]
name = "triangle"
version = "0.1.0"
name = "syn"
version = "2.0.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3"
dependencies = [
"nodarium_macros",
"nodarium_utils",
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.22"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "vec3"
version = "0.1.0"
name = "wasm-bindgen"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
dependencies = [
"nodarium_macros",
"nodarium_utils",
"serde",
"cfg-if",
"wasm-bindgen-macro",
]
[[package]]
name = "zmij"
version = "1.0.15"
name = "wasm-bindgen-backend"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2"
checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
dependencies = [
"bumpalo",
"log",
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.60",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0"
dependencies = [
"cfg-if",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
[[package]]
name = "wasm-bindgen-test"
version = "0.3.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9bf62a58e0780af3e852044583deee40983e5886da43a271dd772379987667b"
dependencies = [
"console_error_panic_hook",
"js-sys",
"scoped-tls",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test-macro",
]
[[package]]
name = "wasm-bindgen-test-macro"
version = "0.3.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f89739351a2e03cb94beb799d47fb2cac01759b40ec441f7de39b00cbf7ef0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
]
[[package]]
name = "web-sys"
version = "0.3.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef"
dependencies = [
"js-sys",
"wasm-bindgen",
]

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM node:24-alpine
RUN apk add --no-cache --update curl rclone g++
RUN RUSTUP_URL="https://sh.rustup.rs" \
&& curl --silent --show-error --location --fail --retry 3 --proto '=https' --tlsv1.2 --output /tmp/rustup-linux-install.sh $RUSTUP_URL \
&& sh /tmp/rustup-linux-install.sh -y
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 default stable \
&& rustup target add wasm32-unknown-unknown \
&& cargo install wasm-pack \
&& npm i -g pnpm

View File

@@ -1,32 +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 \
gpg=2.2.40-1.1+deb12u2 \
gpg-agent=2.2.40-1.1+deb12u2 \
curl=7.88.1-10+deb12u14 \
git=1:2.39.5-0+deb12u3 \
jq=1.6-2.1+deb12u1 \
g++=4:12.2.0-3 \
rclone=1.60.1+dfsg-2+b5 \
xvfb=2:21.1.7-3+deb12u11 \
xauth=1:1.1.2-1 \
--no-install-recommends \
# Install Rust
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
--default-toolchain stable \
--profile minimal \
&& rustup target add wasm32-unknown-unknown \
# Setup Playwright
&& npm i -g pnpm \
&& pnpm dlx playwright install --with-deps firefox \
# Final Cleanup
&& rm -rf /usr/local/rustup/downloads /usr/local/rustup/tmp \
&& rm -rf /usr/local/cargo/registry/index /usr/local/cargo/registry/cache \
&& rm -rf /usr/local/rustup/toolchains/*/share/doc \
&& apt-get purge -y --auto-remove \
&& rm -rf /var/lib/apt/lists/*

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
PUBLIC_ANALYTIC_SCRIPT=""

2
app/.gitignore vendored
View File

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

View File

@@ -1,33 +0,0 @@
FROM jimfx/nodes:latest AS builder
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json Cargo.lock Cargo.toml ./
COPY packages/ ./packages/
COPY nodes/ ./nodes/
COPY app/package.json ./app/
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
--mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
pnpm install --frozen-lockfile
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
--mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
pnpm build:nodes && \
pnpm --filter @nodarium/app... build
FROM nginx:alpine AS runner
RUN rm /etc/nginx/conf.d/default.conf
COPY app/docker/app.conf /etc/nginx/conf.d/app.conf
COPY --from=builder /app/app/build /app
COPY --from=builder /app/packages/ui/build /app/ui
EXPOSE 80

View File

@@ -1 +0,0 @@
out/

View File

@@ -1,47 +0,0 @@
import { NodeDefinition, NodeId, NodeRegistry } from '@nodarium/types';
import { createWasmWrapper } from '@nodarium/utils';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
export class BenchmarkRegistry implements NodeRegistry {
status: 'loading' | 'ready' | 'error' = 'loading';
private nodes = new Map<string, NodeDefinition>();
async load(nodeIds: NodeId[]): Promise<NodeDefinition[]> {
const nodes = await Promise.all(nodeIds.map(async id => {
const p = resolve('static/nodes/' + id + '.wasm');
const file = await readFile(p);
const node = createWasmWrapper(file as unknown as ArrayBuffer);
const d = node.get_definition();
return {
...d,
execute: node.execute
};
}));
for (const n of nodes) {
this.nodes.set(n.id, n);
}
this.status = 'ready';
return nodes;
}
async register(id: string, wasmBuffer: ArrayBuffer): Promise<NodeDefinition> {
const wasm = createWasmWrapper(wasmBuffer);
const d = wasm.get_definition();
const node = {
...d,
execute: wasm.execute
};
this.nodes.set(id, node);
return node;
}
getNode(id: NodeId | string): NodeDefinition | undefined {
return this.nodes.get(id);
}
getAllNodes(): NodeDefinition[] {
return [];
}
}

View File

@@ -1,56 +0,0 @@
import type { Graph, Graph as GraphType, NodeId } from '@nodarium/types';
import { createLogger, createPerformanceStore } from '@nodarium/utils';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { MemoryRuntimeExecutor } from '../src/lib/runtime/runtime-executor.ts';
import { BenchmarkRegistry } from './benchmarkRegistry.ts';
import defaultPlantTemplate from './templates/default.json' assert { type: 'json' };
import lottaFacesTemplate from './templates/lotta-faces.json' assert { type: 'json' };
import plantTemplate from './templates/plant.json' assert { type: 'json' };
const registry = new BenchmarkRegistry();
const r = new MemoryRuntimeExecutor(registry);
const perfStore = createPerformanceStore();
const log = createLogger('bench');
const templates: Record<string, Graph> = {
'plant': plantTemplate as unknown as GraphType,
'lotta-faces': lottaFacesTemplate as unknown as GraphType,
'default': defaultPlantTemplate as unknown as GraphType
};
async function run(g: GraphType, amount: number) {
await registry.load(plantTemplate.nodes.map(n => n.type) as NodeId[]);
log.log('loaded ' + g.nodes.length + ' nodes');
log.log('warming up');
// Warm up the runtime? maybe this does something?
for (let index = 0; index < 10; index++) {
await r.execute(g, { randomSeed: true });
}
log.log('executing');
r.perf = perfStore;
for (let i = 0; i < amount; i++) {
r.perf?.startRun();
await r.execute(g, { randomSeed: true });
r.perf?.stopRun();
}
log.log('finished');
return r.perf.get();
}
async function main() {
const outPath = resolve('benchmark/out/');
await mkdir(outPath, { recursive: true });
for (const key in templates) {
log.log('executing ' + key);
const perfData = await run(templates[key], 100);
await writeFile(resolve(outPath, key + '.json'), JSON.stringify(perfData));
await new Promise(res => setTimeout(res, 200));
}
}
main();

View File

@@ -1,95 +0,0 @@
{
"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,44 +0,0 @@
{
"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"]
]
}

View File

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

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: 43 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,65 +1,46 @@
{
"name": "@nodarium/app",
"private": true,
"version": "0.0.5",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"predev": "rm static/CHANGELOG.md && ln -s ../../CHANGELOG.md static/CHANGELOG.md",
"build": "svelte-kit sync && vite build",
"test:unit": "vitest",
"test": "npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test",
"preview": "vite preview",
"format": "dprint fmt -c '../.dprint.jsonc' .",
"format:check": "dprint check -c '../.dprint.jsonc' .",
"lint": "eslint .",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"bench": "tsx ./benchmark/index.ts"
"test": "vitest",
"preview": "vite preview"
},
"dependencies": {
"@nodarium/ui": "workspace:*",
"@nodarium/utils": "workspace:*",
"@sveltejs/kit": "^2.50.2",
"@tailwindcss/vite": "^4.1.18",
"@nodarium/registry": "link:../packages/registry",
"@nodarium/ui": "link:../packages/ui",
"@nodarium/utils": "link:../packages/utils",
"@sveltejs/kit": "^2.50.0",
"@threlte/core": "8.3.1",
"@threlte/extras": "9.7.1",
"@types/three": "^0.182.0",
"@unocss/reset": "^66.6.0",
"comlink": "^4.4.2",
"file-saver": "^2.0.5",
"idb": "^8.0.3",
"jsondiffpatch": "^0.7.3",
"micromark": "^4.0.2",
"tailwindcss": "^4.1.18",
"three": "^0.182.0"
},
"devDependencies": {
"@eslint/compat": "^2.0.2",
"@eslint/js": "^9.39.2",
"@iconify-json/tabler": "^1.2.26",
"@iconify/tailwind4": "^1.2.1",
"@nodarium/types": "workspace:^",
"@playwright/test": "^1.58.1",
"@nodarium/types": "link:../packages/types",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tsconfig/svelte": "^5.0.7",
"@tsconfig/svelte": "^5.0.6",
"@types/file-saver": "^2.0.7",
"@types/three": "^0.182.0",
"@vitest/browser-playwright": "^4.0.18",
"dprint": "^0.51.1",
"eslint": "^9.39.2",
"eslint-plugin-svelte": "^3.14.0",
"globals": "^17.3.0",
"svelte": "^5.49.2",
"svelte-check": "^4.3.6",
"@unocss/preset-icons": "^66.6.0",
"svelte": "^5.46.4",
"svelte-check": "^4.3.5",
"tslib": "^2.8.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"unocss": "^66.6.0",
"vite": "^7.3.1",
"vite-plugin-comlink": "^5.3.0",
"vite-plugin-glsl": "^1.5.5",
"vite-plugin-wasm": "^3.5.0",
"vitest": "^4.0.18",
"vitest-browser-svelte": "^2.0.2"
"vitest": "^4.0.17"
}
}

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
}
}
}
});

1576
app/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

View File

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

View File

@@ -11,7 +11,6 @@ uniform vec3 camPos;
uniform vec2 zoomLimits;
uniform vec3 backgroundColor;
uniform vec3 lineColor;
uniform int gridType; // 0 = grid lines, 1 = dots
// Anti-aliased step: threshold in the same units as `value`
float aaStep(float threshold, float value, float deriv) {
@@ -79,51 +78,35 @@ void main(void) {
float ux = (vUv.x - 0.5) * width + cx * cz;
float uy = (vUv.y - 0.5) * height - cy * cz;
if(gridType == 0) {
// extra small grid
float m1 = grid(ux, uy, divisions * 4.0, thickness * 4.0) * 0.9;
float m2 = grid(ux, uy, divisions * 16.0, thickness * 16.0) * 0.5;
float xsmall = max(m1, m2);
float s3 = circle_grid(ux, uy, cz / 1.6, 1.0) * 0.5;
xsmall = max(xsmall, s3);
// extra small grid
float m1 = grid(ux, uy, divisions * 4.0, thickness * 4.0) * 0.9;
float m2 = grid(ux, uy, divisions * 16.0, thickness * 16.0) * 0.5;
float xsmall = max(m1, m2);
float s3 = circle_grid(ux, uy, cz / 1.6, 1.0) * 0.5;
xsmall = max(xsmall, s3);
// small grid
float c1 = grid(ux, uy, divisions, thickness) * 0.6;
float c2 = grid(ux, uy, divisions * 2.0, thickness * 2.0) * 0.5;
float small = max(c1, c2);
// small grid
float c1 = grid(ux, uy, divisions, thickness) * 0.6;
float c2 = grid(ux, uy, divisions * 2.0, thickness * 2.0) * 0.5;
float small = max(c1, c2);
float s1 = circle_grid(ux, uy, cz * 10.0, 2.0) * 0.5;
small = max(small, s1);
float s1 = circle_grid(ux, uy, cz * 10.0, 2.0) * 0.5;
small = max(small, s1);
// large grid
float c3 = grid(ux, uy, divisions / 8.0, thickness / 8.0) * 0.5;
float c4 = grid(ux, uy, divisions / 2.0, thickness / 4.0) * 0.4;
float large = max(c3, c4);
// large grid
float c3 = grid(ux, uy, divisions / 8.0, thickness / 8.0) * 0.5;
float c4 = grid(ux, uy, divisions / 2.0, thickness / 4.0) * 0.4;
float large = max(c3, c4);
float s2 = circle_grid(ux, uy, cz * 20.0, 1.0) * 0.4;
large = max(large, s2);
float s2 = circle_grid(ux, uy, cz * 20.0, 1.0) * 0.4;
large = max(large, s2);
float c = mix(large, small, min(nz * 2.0 + 0.05, 1.0));
c = mix(c, xsmall, clamp((nz - 0.3) / 0.7, 0.0, 1.0));
float c = mix(large, small, min(nz * 2.0 + 0.05, 1.0));
c = mix(c, xsmall, clamp((nz - 0.3) / 0.7, 0.0, 1.0));
vec3 color = mix(backgroundColor, lineColor, c);
gl_FragColor = vec4(color, 1.0);
} else {
float large = circle_grid(ux, uy, cz * 20.0, 1.0) * 0.4;
float medium = circle_grid(ux, uy, cz * 10.0, 1.0) * 0.6;
float small = circle_grid(ux, uy, cz * 2.5, 1.0) * 0.8;
float c = mix(large, medium, min(nz * 2.0 + 0.05, 1.0));
c = mix(c, small, clamp((nz - 0.3) / 0.7, 0.0, 1.0));
vec3 color = mix(backgroundColor, lineColor, c);
gl_FragColor = vec4(color, 1.0);
}
vec3 color = mix(backgroundColor, lineColor, c);
gl_FragColor = vec4(color, 1.0);
}

View File

@@ -1,17 +1,16 @@
<script lang="ts">
import { appSettings } from '$lib/settings/app-settings.svelte';
import { T } from '@threlte/core';
import { colors } from '../graph/colors.svelte';
import BackgroundFrag from './Background.frag';
import BackgroundVert from './Background.vert';
import { T } from "@threlte/core";
import BackgroundVert from "./Background.vert";
import BackgroundFrag from "./Background.frag";
import { colors } from "../graph/colors.svelte";
import { appSettings } from "$lib/settings/app-settings.svelte";
type Props = {
minZoom?: number;
maxZoom?: number;
cameraPosition?: [number, number, number];
width?: number;
height?: number;
type?: 'grid' | 'dots' | 'none';
minZoom: number;
maxZoom: number;
cameraPosition: [number, number, number];
width: number;
height: number;
};
let {
@@ -20,17 +19,8 @@
cameraPosition = [0, 1, 0],
width = globalThis?.innerWidth || 100,
height = globalThis?.innerHeight || 100,
type = 'grid'
}: Props = $props();
const typeMap = new Map([
['grid', 0],
['dots', 1],
['none', 2]
]);
const gridType = $derived(typeMap.get(type) || 0);
let bw = $derived(width / cameraPosition[2]);
let bh = $derived(height / cameraPosition[2]);
</script>
@@ -48,31 +38,27 @@
fragmentShader={BackgroundFrag}
uniforms={{
camPos: {
value: [0, 1, 0]
value: [0, 1, 0],
},
backgroundColor: {
value: colors['layer-0']
value: colors["layer-0"],
},
lineColor: {
value: colors['outline']
value: colors["outline"],
},
zoomLimits: {
value: [2, 50]
value: [2, 50],
},
dimensions: {
value: [100, 100]
value: [100, 100],
},
gridType: {
value: 0
}
}}
uniforms.camPos.value={cameraPosition}
uniforms.backgroundColor.value={appSettings.value.theme
&& colors['layer-0']}
uniforms.lineColor.value={appSettings.value.theme && colors['outline']}
uniforms.backgroundColor.value={appSettings.value.theme &&
colors["layer-0"]}
uniforms.lineColor.value={appSettings.value.theme && colors["outline"]}
uniforms.zoomLimits.value={[minZoom, maxZoom]}
uniforms.dimensions.value={[width, height]}
uniforms.gridType.value={gridType}
/>
</T.Mesh>
</T.Group>

View File

@@ -1,26 +1,14 @@
<script lang="ts">
import type { NodeId, NodeInstance } from '@nodarium/types';
import { HTML } from '@threlte/extras';
import { onMount } from 'svelte';
import { getGraphManager, getGraphState } from '../graph-state.svelte';
import { HTML } from "@threlte/extras";
import { onMount } from "svelte";
import type { NodeInstance, NodeId } from "@nodarium/types";
import { getGraphManager, getGraphState } from "../graph-state.svelte";
type Props = {
paddingLeft?: number;
paddingRight?: number;
paddingTop?: number;
paddingBottom?: number;
onnode: (n: NodeInstance) => void;
};
const padding = 10;
const {
paddingLeft = padding,
paddingRight = padding,
paddingTop = padding,
paddingBottom = padding,
onnode
}: Props = $props();
const { onnode }: Props = $props();
const graph = getGraphManager();
const graphState = getGraphState();
@@ -29,18 +17,15 @@
let value = $state<string>();
let activeNodeId = $state<NodeId>();
const MENU_WIDTH = 150;
const MENU_HEIGHT = 350;
const allNodes = graphState.activeSocket
? graph.getPossibleNodes(graphState.activeSocket)
: graph.getNodeDefinitions();
function filterNodes() {
return allNodes.filter((node) => node.id.includes(value ?? ''));
return allNodes.filter((node) => node.id.includes(value ?? ""));
}
const nodes = $derived(value === '' ? allNodes : filterNodes());
const nodes = $derived(value === "" ? allNodes : filterNodes());
$effect(() => {
if (nodes) {
if (activeNodeId === undefined) {
@@ -54,38 +39,38 @@
}
});
function handleNodeCreation(nodeType: NodeInstance['type']) {
function handleNodeCreation(nodeType: NodeInstance["type"]) {
if (!graphState.addMenuPosition) return;
onnode?.({
id: -1,
type: nodeType,
position: [...graphState.addMenuPosition],
props: {},
state: {}
state: {},
});
}
function handleKeyDown(event: KeyboardEvent) {
event.stopImmediatePropagation();
if (event.key === 'Escape') {
if (event.key === "Escape") {
graphState.addMenuPosition = null;
return;
}
if (event.key === 'ArrowDown') {
if (event.key === "ArrowDown") {
const index = nodes.findIndex((node) => node.id === activeNodeId);
activeNodeId = nodes[(index + 1) % nodes.length].id;
return;
}
if (event.key === 'ArrowUp') {
if (event.key === "ArrowUp") {
const index = nodes.findIndex((node) => node.id === activeNodeId);
activeNodeId = nodes[(index - 1 + nodes.length) % nodes.length].id;
return;
}
if (event.key === 'Enter') {
if (event.key === "Enter") {
if (activeNodeId && graphState.addMenuPosition) {
handleNodeCreation(activeNodeId);
}
@@ -93,49 +78,6 @@
}
}
function clampAddMenuPosition() {
if (!graphState.addMenuPosition) return;
const camX = graphState.cameraPosition[0];
const camY = graphState.cameraPosition[1];
const zoom = graphState.cameraPosition[2];
const halfViewportWidth = (graphState.width / 2) / zoom;
const halfViewportHeight = (graphState.height / 2) / zoom;
const halfMenuWidth = (MENU_WIDTH / 2) / zoom;
const halfMenuHeight = (MENU_HEIGHT / 2) / zoom;
const minX = camX - halfViewportWidth - halfMenuWidth + paddingLeft / zoom;
const maxX = camX + halfViewportWidth - halfMenuWidth - paddingRight / zoom;
const minY = camY - halfViewportHeight - halfMenuHeight + paddingTop / zoom;
const maxY = camY + halfViewportHeight - halfMenuHeight - paddingBottom / zoom;
const clampedX = Math.max(
minX + halfMenuWidth,
Math.min(graphState.addMenuPosition[0], maxX - halfMenuWidth)
);
const clampedY = Math.max(
minY + halfMenuHeight,
Math.min(graphState.addMenuPosition[1], maxY - halfMenuHeight)
);
if (clampedX !== graphState.addMenuPosition[0] || clampedY !== graphState.addMenuPosition[1]) {
graphState.addMenuPosition = [clampedX, clampedY];
}
}
$effect(() => {
const pos = graphState.addMenuPosition;
const zoom = graphState.cameraPosition[2];
const width = graphState.width;
const height = graphState.height;
if (pos && zoom && width && height) {
clampAddMenuPosition();
}
});
onMount(() => {
input.disabled = false;
setTimeout(() => input.focus(), 50);
@@ -163,14 +105,14 @@
</div>
<div class="content">
{#each nodes as node (node.id)}
{#each nodes as node}
<div
class="result"
role="treeitem"
tabindex="0"
aria-selected={node.id === activeNodeId}
onkeydown={(event) => {
if (event.key === 'Enter') {
if (event.key === "Enter") {
handleNodeCreation(node.id);
}
}}
@@ -183,7 +125,7 @@
activeNodeId = node.id;
}}
>
{node.id.split('/').at(-1)}
{node.id.split("/").at(-1)}
</div>
{/each}
</div>
@@ -196,7 +138,7 @@
}
input {
background: var(--color-layer-0);
background: var(--layer-0);
font-family: var(--font-family);
border: none;
border-radius: 5px;
@@ -215,30 +157,28 @@
.add-menu-wrapper {
position: absolute;
background: var(--color-layer-1);
background: var(--layer-1);
border-radius: 7px;
overflow: hidden;
border: solid 2px var(--color-layer-2);
border: solid 2px var(--layer-2);
width: 150px;
}
.content {
min-height: none;
width: 100%;
color: var(--text-color);
max-height: 300px;
overflow-y: auto;
}
.result {
padding: 1em 0.9em;
border-bottom: solid 1px var(--color-layer-2);
border-bottom: solid 1px var(--layer-2);
opacity: 0.7;
font-size: 0.9em;
cursor: pointer;
}
.result[aria-selected="true"] {
background: var(--color-layer-2);
background: var(--layer-2);
opacity: 1;
}
</style>

View File

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

View File

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

View File

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

View File

@@ -1,65 +1,19 @@
<script lang="ts">
import type { Box } from '@nodarium/types';
import { T } from '@threlte/core';
import { MeshLineGeometry, MeshLineMaterial } from '@threlte/extras';
import { Color, Vector3 } from 'three';
import { lines, points, rects } from './store.js';
type Line = {
points: Vector3[];
color?: Color;
};
function getEachKey(value: Vector3 | Box | Line): string {
if ('x' in value) {
return [value.x, value.y, value.z].join('-');
}
if ('minX' in value) {
return [value.maxX, value.minX, value.maxY, value.minY].join('-');
}
if ('points' in value) {
return getEachKey(value.points[Math.floor(value.points.length / 2)]);
}
return '';
}
import { MeshLineGeometry, MeshLineMaterial } from '@threlte/extras';
import { points, lines } from './store.js';
import { T } from '@threlte/core';
</script>
{#each $points as point (getEachKey(point))}
<T.Mesh
position.x={point.x}
position.y={point.y}
position.z={point.z}
rotation.x={-Math.PI / 2}
>
<T.CircleGeometry args={[0.2, 32]} />
<T.MeshBasicMaterial color="red" />
</T.Mesh>
{#each $points as point}
<T.Mesh position.x={point.x} position.y={point.y} position.z={point.z} rotation.x={-Math.PI / 2}>
<T.CircleGeometry args={[0.2, 32]} />
<T.MeshBasicMaterial color="red" />
</T.Mesh>
{/each}
{#each $rects as rect, i (getEachKey(rect))}
<T.Mesh
position.x={(rect.minX + rect.maxX) / 2}
position.y={0}
position.z={(rect.minY + rect.maxY) / 2}
rotation.x={-Math.PI / 2}
>
<T.PlaneGeometry args={[rect.maxX - rect.minX, rect.maxY - rect.minY]} />
<T.MeshBasicMaterial
color={new Color().setHSL((i * 1.77) % 1, 1, 0.5)}
opacity={0.9}
/>
</T.Mesh>
{/each}
{#each $lines as line (getEachKey(line))}
<T.Mesh position.y={1}>
<MeshLineGeometry points={line.points} />
<MeshLineMaterial
color={line.color || 'red'}
linewidth={1}
attenuate={false}
/>
</T.Mesh>
{#each $lines as line}
<T.Mesh>
<MeshLineGeometry points={line} />
<MeshLineMaterial color="red" linewidth={1} attenuate={false} />
</T.Mesh>
{/each}

View File

@@ -1,8 +1,5 @@
import type { Box } from '@nodarium/types';
import type { Color } from 'three';
import { Vector3 } from 'three/src/math/Vector3.js';
import Component from './Debug.svelte';
import { lines, points, rects } from './store';
import { Vector3 } from "three/src/math/Vector3.js";
import { lines, points } from "./store";
export function debugPosition(x: number, y: number) {
points.update((p) => {
@@ -11,27 +8,18 @@ export function debugPosition(x: number, y: number) {
});
}
export function debugRect(rect: Box) {
rects.update((r) => {
r.push(rect);
return r;
});
}
export function clear() {
points.set([]);
lines.set([]);
rects.set([]);
}
export function debugLine(points: Vector3[], color?: Color) {
export function debugLine(line: Vector3[]) {
lines.update((l) => {
l.push({ points, color });
l.push(line);
return l;
});
}
export default Component;
export function clearLines() {
lines.set([]);
}
import Component from "./Debug.svelte";
export default Component

View File

@@ -1,8 +1,6 @@
import type { Box } from '@nodarium/types';
import { writable } from 'svelte/store';
import type { Color } from 'three';
import { Vector3 } from 'three/src/math/Vector3.js';
import { writable } from "svelte/store";
import { Vector3 } from "three/src/math/Vector3.js";
export const points = writable<Vector3[]>([]);
export const rects = writable<Box[]>([]);
export const lines = writable<{ points: Vector3[]; color?: Color }[]>([]);
export const lines = writable<Vector3[][]>([]);

View File

@@ -1,17 +1,18 @@
<script module lang="ts">
import { colors } from '../graph/colors.svelte';
import { colors } from "../graph/colors.svelte";
const circleMaterial = new MeshBasicMaterial({
color: colors.outline.clone(),
toneMapped: false
color: colors.edge.clone(),
toneMapped: false,
});
let lineColor = $state(colors.edge.clone().convertSRGBToLinear());
$effect.root(() => {
$effect(() => {
if (appSettings.value.theme === undefined) {
return;
}
circleMaterial.color = colors.outline.clone().convertSRGBToLinear();
appSettings.value.theme;
circleMaterial.color = colors.edge.clone().convertSRGBToLinear();
lineColor = colors.edge.clone().convertSRGBToLinear();
});
});
@@ -19,22 +20,17 @@
new Vector2(0, 0),
new Vector2(0, 0),
new Vector2(0, 0),
new Vector2(0, 0)
new Vector2(0, 0),
);
</script>
<script lang="ts">
import { appSettings } from '$lib/settings/app-settings.svelte';
import { T } from '@threlte/core';
import { MeshLineGeometry, MeshLineMaterial } from '@threlte/extras';
import { onDestroy } from 'svelte';
import { MeshBasicMaterial, Vector3 } from 'three';
import { CubicBezierCurve } from 'three/src/extras/curves/CubicBezierCurve.js';
import { Vector2 } from 'three/src/math/Vector2.js';
import { getGraphState } from '../graph-state.svelte';
import MeshGradientLineMaterial from './MeshGradientLine/MeshGradientLineMaterial.svelte';
const graphState = getGraphState();
import { T } from "@threlte/core";
import { MeshLineGeometry, MeshLineMaterial } from "@threlte/extras";
import { MeshBasicMaterial, Vector3 } from "three";
import { CubicBezierCurve } from "three/src/extras/curves/CubicBezierCurve.js";
import { Vector2 } from "three/src/math/Vector2.js";
import { appSettings } from "$lib/settings/app-settings.svelte";
type Props = {
x1: number;
@@ -42,33 +38,27 @@
x2: number;
y2: number;
z: number;
id?: string;
inputType?: string;
outputType?: string;
};
const { x1, y1, x2, y2, z, inputType = 'unknown', outputType = 'unknown', id }: Props = $props();
const { x1, y1, x2, y2, z }: Props = $props();
const thickness = $derived(Math.max(0.001, 0.00082 * Math.exp(0.055 * z)));
const inputColor = $derived(graphState.colors.getColor(inputType));
const outputColor = $derived(graphState.colors.getColor(outputType));
let points = $state<Vector3[]>([]);
let lastId: string | null = null;
const curveId = $derived(`${x1}-${y1}-${x2}-${y2}`);
function update() {
const new_x = x2 - x1;
const new_y = y2 - y1;
const curveId = `${x1}-${y1}-${x2}-${y2}`;
if (lastId === curveId) {
return;
}
lastId = curveId;
const length = Math.floor(
Math.sqrt(Math.pow(new_x, 2) + Math.pow(new_y, 2)) / 4
Math.sqrt(Math.pow(new_x, 2) + Math.pow(new_y, 2)) / 4,
);
const samples = Math.max(length * 16, 10);
@@ -82,15 +72,6 @@
.getPoints(samples)
.map((p) => new Vector3(p.x, 0, p.y))
.flat();
if (id) {
graphState.setEdgeGeometry(
id,
x1,
y1,
$state.snapshot(points) as unknown as Vector3[]
);
}
}
$effect(() => {
@@ -98,10 +79,6 @@
update();
}
});
onDestroy(() => {
if (id) graphState.removeEdgeGeometry(id);
});
</script>
<T.Mesh
@@ -109,9 +86,9 @@
position.z={y1}
position.y={0.8}
rotation.x={-Math.PI / 2}
material={circleMaterial}
>
<T.CircleGeometry args={[0.5, 16]} />
<T.MeshBasicMaterial color={inputColor} toneMapped={false} />
</T.Mesh>
<T.Mesh
@@ -122,28 +99,9 @@
material={circleMaterial}
>
<T.CircleGeometry args={[0.5, 16]} />
<T.MeshBasicMaterial color={outputColor} toneMapped={false} />
</T.Mesh>
{#if graphState.hoveredEdgeId === id}
<T.Mesh position.x={x1} position.z={y1} position.y={0.1}>
<MeshLineGeometry {points} />
<MeshLineMaterial
width={thickness * 5}
color={inputColor}
tonemapped={false}
opacity={0.5}
transparent
/>
</T.Mesh>
{/if}
<T.Mesh position.x={x1} position.z={y1} position.y={0.1}>
<MeshLineGeometry {points} />
<MeshGradientLineMaterial
width={thickness}
colorStart={inputColor}
colorEnd={outputColor}
tonemapped={false}
/>
<MeshLineMaterial width={thickness} color={lineColor} />
</T.Mesh>

View File

@@ -1,112 +0,0 @@
<script lang="ts">
import { T, useThrelte } from '@threlte/core';
import { Color, ShaderMaterial, Vector2 } from 'three';
import fragmentShader from './fragment.frag';
import type { MeshLineMaterialProps } from './types';
import vertexShader from './vertex.vert';
let {
opacity = 1,
colorStart = '#ffffff',
colorEnd = '#ffffff',
dashOffset = 0,
dashArray = 0,
dashRatio = 0,
attenuate = true,
width = 1,
scaleDown = 0,
alphaMap,
ref = $bindable(),
children,
...props
}: MeshLineMaterialProps = $props();
let { invalidate, size } = useThrelte();
// svelte-ignore state_referenced_locally
const uniforms = {
lineWidth: { value: width },
colorStart: { value: new Color(colorStart) },
colorEnd: { value: new Color(colorEnd) },
opacity: { value: opacity },
resolution: { value: new Vector2(1, 1) },
sizeAttenuation: { value: attenuate ? 1 : 0 },
dashArray: { value: dashArray },
useDash: { value: dashArray > 0 ? 1 : 0 },
dashOffset: { value: dashOffset },
dashRatio: { value: dashRatio },
scaleDown: { value: scaleDown / 10 },
alphaTest: { value: 0 },
alphaMap: { value: alphaMap },
useAlphaMap: { value: alphaMap ? 1 : 0 }
};
const material = new ShaderMaterial({ uniforms });
$effect.pre(() => {
uniforms.lineWidth.value = width;
invalidate();
});
$effect.pre(() => {
uniforms.opacity.value = opacity;
invalidate();
});
$effect.pre(() => {
uniforms.resolution.value.set($size.width, $size.height);
invalidate();
});
$effect.pre(() => {
uniforms.sizeAttenuation.value = attenuate ? 1 : 0;
invalidate();
});
$effect.pre(() => {
uniforms.dashArray.value = dashArray;
uniforms.useDash.value = dashArray > 0 ? 1 : 0;
invalidate();
});
$effect.pre(() => {
uniforms.dashOffset.value = dashOffset;
invalidate();
});
$effect.pre(() => {
uniforms.dashRatio.value = dashRatio;
invalidate();
});
$effect.pre(() => {
uniforms.scaleDown.value = scaleDown / 10;
invalidate();
});
$effect.pre(() => {
uniforms.alphaMap.value = alphaMap;
uniforms.useAlphaMap.value = alphaMap ? 1 : 0;
invalidate();
});
$effect.pre(() => {
uniforms.colorStart.value.set(colorStart);
invalidate();
});
$effect.pre(() => {
uniforms.colorEnd.value.set(colorEnd);
invalidate();
});
</script>
<T
is={material}
bind:ref
{fragmentShader}
{vertexShader}
{...props}
>
{@render children?.({ ref: material })}
</T>

View File

@@ -1,30 +0,0 @@
uniform vec3 colorStart;
uniform vec3 colorEnd;
uniform float useDash;
uniform float dashArray;
uniform float dashOffset;
uniform float dashRatio;
uniform sampler2D alphaMap;
uniform float useAlphaMap;
varying vec2 vUV;
varying vec4 vColor;
varying float vCounters;
vec4 CustomLinearTosRGB( in vec4 value ) {
return vec4( mix( pow( value.rgb, vec3( 0.41666 ) ) * 1.055 - vec3( 0.055 ), value.rgb * 12.92, vec3( lessThanEqual( value.rgb, vec3( 0.0031308 ) ) ) ), value.a );
}
void main() {
vec4 c = mix(vec4(colorStart,1.0),vec4(colorEnd, 1.0), vCounters);
if( useAlphaMap == 1. ) c.a *= texture2D( alphaMap, vUV ).r;
if( useDash == 1. ){
c.a *= ceil(mod(vCounters + dashOffset, dashArray) - (dashArray * dashRatio));
}
gl_FragColor = CustomLinearTosRGB(c);
}

View File

@@ -1,68 +0,0 @@
import type { Props } from '@threlte/core';
import type { BufferGeometry, Vector3 } from 'three';
import type { ColorRepresentation, ShaderMaterial, Texture } from 'three';
export type MeshLineGeometryProps = Props<BufferGeometry> & {
/**
* @default []
*/
points: Vector3[];
/**
* @default 'none'
*/
shape?: 'none' | 'taper' | 'custom';
/**
* @default () => 1
*/
shapeFunction?: (p: number) => number;
};
export type MeshLineMaterialProps =
& Omit<
Props<ShaderMaterial>,
'uniforms' | 'fragmentShader' | 'vertexShader'
>
& {
/**
* @default 1
*/
opacity?: number;
/**
* @default '#ffffff'
*/
color?: ColorRepresentation;
/**
* @default 0
*/
dashOffset?: number;
/**
* @default 0
*/
dashArray?: number;
/**
* @default 0
*/
dashRatio?: number;
/**
* @default true
*/
attenuate?: boolean;
/**
* @default 1
*/
width?: number;
/**
* @default 0
*/
scaleDown?: number;
alphaMap?: Texture | undefined;
};

View File

@@ -1,83 +0,0 @@
attribute vec3 previous;
attribute vec3 next;
attribute float side;
attribute float width;
attribute float counters;
uniform vec2 resolution;
uniform float lineWidth;
uniform vec3 color;
uniform float opacity;
uniform float sizeAttenuation;
uniform float scaleDown;
varying vec2 vUV;
varying vec4 vColor;
varying float vCounters;
vec2 intoScreen(vec4 i) {
return resolution * (0.5 * i.xy / i.w + 0.5);
}
void main() {
float aspect = resolution.y / resolution.x;
mat4 m = projectionMatrix * modelViewMatrix;
vec4 currentClip = m * vec4( position, 1.0 );
vec4 prevClip = m * vec4( previous, 1.0 );
vec4 nextClip = m * vec4( next, 1.0 );
vec4 currentNormed = currentClip / currentClip.w;
vec4 prevNormed = prevClip / prevClip.w;
vec4 nextNormed = nextClip / nextClip.w;
vec2 currentScreen = intoScreen(currentNormed);
vec2 prevScreen = intoScreen(prevNormed);
vec2 nextScreen = intoScreen(nextNormed);
float actualWidth = lineWidth * width;
vec2 dir;
if(nextScreen == currentScreen) {
dir = normalize( currentScreen - prevScreen );
} else if(prevScreen == currentScreen) {
dir = normalize( nextScreen - currentScreen );
} else {
vec2 inDir = currentScreen - prevScreen;
vec2 outDir = nextScreen - currentScreen;
vec2 fullDir = nextScreen - prevScreen;
if(length(fullDir) > 0.0) {
dir = normalize(fullDir);
} else if(length(inDir) > 0.0){
dir = normalize(inDir);
} else {
dir = normalize(outDir);
}
}
vec2 normal = vec2(-dir.y, dir.x);
if(sizeAttenuation != 0.0) {
normal /= currentClip.w;
normal *= min(resolution.x, resolution.y);
}
if (scaleDown > 0.0) {
float dist = length(nextNormed - prevNormed);
normal *= smoothstep(0.0, scaleDown, dist);
}
vec2 offsetInScreen = actualWidth * normal * side * 0.5;
vec2 withOffsetScreen = currentScreen + offsetInScreen;
vec3 withOffsetNormed = vec3((2.0 * withOffsetScreen/resolution - 1.0), currentNormed.z);
vCounters = counters;
vColor = vec4( color, opacity );
vUV = uv;
gl_Position = currentClip.w * vec4(withOffsetNormed, 1.0);
}

View File

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

View File

@@ -1,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,70 +1,68 @@
import throttle from '$lib/helpers/throttle';
import { RemoteNodeRegistry } from '$lib/node-registry/index';
import type {
Edge,
Graph,
NodeDefinition,
NodeId,
NodeInput,
NodeInstance,
NodeDefinition,
NodeInput,
NodeRegistry,
Socket
} from '@nodarium/types';
import { fastHashString } from '@nodarium/utils';
import { createLogger } from '@nodarium/utils';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import EventEmitter from './helpers/EventEmitter';
import { HistoryManager } from './history-manager';
NodeId,
Socket,
} from "@nodarium/types";
import { fastHashString } from "@nodarium/utils";
import { SvelteMap } from "svelte/reactivity";
import EventEmitter from "./helpers/EventEmitter";
import { createLogger } from "@nodarium/utils";
import throttle from "$lib/helpers/throttle";
import { HistoryManager } from "./history-manager";
const logger = createLogger('graph-manager');
const logger = createLogger("graph-manager");
logger.mute();
const remoteRegistry = new RemoteNodeRegistry('');
const clone =
"structuredClone" in self
? self.structuredClone
: (args: any) => JSON.parse(JSON.stringify(args));
const clone = 'structuredClone' in self
? self.structuredClone
: (args: unknown) => JSON.parse(JSON.stringify(args));
export function areSocketsCompatible(
function areSocketsCompatible(
output: string | undefined,
inputs: string | (string | undefined)[] | undefined
inputs: string | (string | undefined)[] | undefined,
) {
if (output === '*') return true;
if (Array.isArray(inputs) && output) {
return inputs.includes('*') || inputs.includes(output);
return inputs.includes(output);
}
return inputs === output || inputs === '*';
return inputs === output;
}
function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) {
if (firstEdge[0].id !== secondEdge[0].id) {
return false;
}
if (firstEdge[1] !== secondEdge[1]) {
return false;
return false
}
if (firstEdge[2].id !== secondEdge[2].id) {
return false;
return false
}
if (firstEdge[3] !== secondEdge[3]) {
return false;
return false
}
return true;
return true
}
export class GraphManager extends EventEmitter<{
save: Graph;
result: unknown;
result: any;
settings: {
types: Record<string, NodeInput>;
values: Record<string, unknown>;
};
}> {
status = $state<'loading' | 'idle' | 'error'>();
status = $state<"loading" | "idle" | "error">();
loaded = false;
graph: Graph = { id: 0, nodes: [], edges: [] };
@@ -80,7 +78,7 @@ export class GraphManager extends EventEmitter<{
currentUndoGroup: number | null = null;
inputSockets = $derived.by(() => {
const s = new SvelteSet<string>();
const s = new Set<string>();
for (const edge of this.edges) {
s.add(`${edge[2].id}-${edge[3]}`);
}
@@ -90,7 +88,7 @@ export class GraphManager extends EventEmitter<{
history: HistoryManager = new HistoryManager();
execute = throttle(() => {
if (this.loaded === false) return;
this.emit('result', this.serialize());
this.emit("result", this.serialize());
}, 10);
constructor(public registry: NodeRegistry) {
@@ -102,28 +100,27 @@ export class GraphManager extends EventEmitter<{
id: node.id,
position: [...node.position],
type: node.type,
props: node.props
props: node.props,
})) as NodeInstance[];
const edges = this.edges.map((edge) => [
edge[0].id,
edge[1],
edge[2].id,
edge[3]
]) as Graph['edges'];
edge[3],
]) as Graph["edges"];
const serialized = {
id: this.graph.id,
settings: $state.snapshot(this.settings),
meta: $state.snapshot(this.graph.meta),
nodes,
edges
edges,
};
logger.log('serializing graph', serialized);
logger.log("serializing graph", serialized);
return clone($state.snapshot(serialized));
}
private lastSettingsHash = 0;
setSettings(settings: Record<string, unknown>) {
const hash = fastHashString(JSON.stringify(settings));
let hash = fastHashString(JSON.stringify(settings));
if (hash === this.lastSettingsHash) return;
this.lastSettingsHash = hash;
@@ -137,7 +134,7 @@ export class GraphManager extends EventEmitter<{
}
getLinkedNodes(node: NodeInstance) {
const nodes = new SvelteSet<NodeInstance>();
const nodes = new Set<NodeInstance>();
const stack = [node];
while (stack.length) {
const n = stack.pop();
@@ -151,97 +148,6 @@ export class GraphManager extends EventEmitter<{
return [...nodes.values()];
}
getEdgeId(e: Edge) {
return `${e[0].id}-${e[1]}-${e[2].id}-${e[3]}`;
}
getEdgeById(id: string): Edge | undefined {
return this.edges.find((e) => this.getEdgeId(e) === id);
}
dropNodeOnEdge(nodeId: number, edge: Edge) {
const draggedNode = this.getNode(nodeId);
if (!draggedNode || !draggedNode.state?.type) return;
const [fromNode, fromSocketIdx, toNode, toSocketKey] = edge;
const draggedInputs = Object.entries(draggedNode.state.type.inputs ?? {});
const draggedOutputs = draggedNode.state.type.outputs ?? [];
const edgeOutputSocketType = fromNode.state?.type?.outputs?.[fromSocketIdx];
const targetInput = toNode.state?.type?.inputs?.[toSocketKey];
const targetAcceptedTypes = [targetInput?.type, ...(targetInput?.accepts || [])];
const bestInputEntry = draggedInputs.find(([, input]) => {
const accepted = [input.type, ...(input.accepts || [])];
return areSocketsCompatible(edgeOutputSocketType, accepted);
});
const bestOutputIdx = draggedOutputs.findIndex(outputType =>
areSocketsCompatible(outputType, targetAcceptedTypes)
);
if (!bestInputEntry || bestOutputIdx === -1) {
logger.error('Could not find compatible sockets for drop');
return;
}
this.startUndoGroup();
this.removeEdge(edge, { applyDeletion: false });
this.createEdge(fromNode, fromSocketIdx, draggedNode, bestInputEntry[0], {
applyUpdate: false
});
this.createEdge(draggedNode, bestOutputIdx, toNode, toSocketKey, {
applyUpdate: false
});
this.saveUndoGroup();
this.execute();
}
getPossibleDropOnEdges(nodeId: number): Edge[] {
const draggedNode = this.getNode(nodeId);
if (!draggedNode || !draggedNode.state?.type) return [];
const draggedInputs = Object.values(draggedNode.state.type.inputs ?? {});
const draggedOutputs = draggedNode.state.type.outputs ?? [];
// Optimization: Pre-calculate parents to avoid cycles
const parentIds = new SvelteSet(this.getParentsOfNode(draggedNode).map(n => n.id));
return this.edges.filter((edge) => {
const [fromNode, fromSocketIdx, toNode, toSocketKey] = edge;
// 1. Prevent cycles: If the target node is already a parent, we can't drop here
if (parentIds.has(toNode.id)) return false;
// 2. Prevent self-dropping: Don't drop on edges already connected to this node
if (fromNode.id === nodeId || toNode.id === nodeId) return false;
// 3. Check if edge.source can plug into ANY draggedNode.input
const edgeOutputSocketType = fromNode.state?.type?.outputs?.[fromSocketIdx];
const canPlugIntoDragged = draggedInputs.some(input => {
const acceptedTypes = [input.type, ...(input.accepts || [])];
return areSocketsCompatible(edgeOutputSocketType, acceptedTypes);
});
if (!canPlugIntoDragged) return false;
// 4. Check if ANY draggedNode.output can plug into edge.target
const targetInput = toNode.state?.type?.inputs?.[toSocketKey];
const targetAcceptedTypes = [targetInput?.type, ...(targetInput?.accepts || [])];
const draggedCanPlugIntoTarget = draggedOutputs.some(outputType =>
areSocketsCompatible(outputType, targetAcceptedTypes)
);
return draggedCanPlugIntoTarget;
});
}
getEdgesBetweenNodes(nodes: NodeInstance[]): [number, number, number, string][] {
const edges = [];
for (const node of nodes) {
@@ -249,14 +155,14 @@ export class GraphManager extends EventEmitter<{
for (const child of children) {
if (nodes.includes(child)) {
const edge = this.edges.find(
(e) => e[0].id === node.id && e[2].id === child.id
(e) => e[0].id === node.id && e[2].id === child.id,
);
if (edge) {
edges.push([edge[0].id, edge[1], edge[2].id, edge[3]] as [
number,
number,
number,
string
string,
]);
}
}
@@ -267,17 +173,24 @@ export class GraphManager extends EventEmitter<{
}
private _init(graph: Graph) {
const nodes = new SvelteMap(
const nodes = new Map(
graph.nodes.map((node) => {
return [node.id, node as NodeInstance];
})
const nodeType = this.registry.getNode(node.type);
const n = node as NodeInstance;
if (nodeType) {
n.state = {
type: nodeType,
};
}
return [node.id, n];
}),
);
this.edges = graph.edges.map((edge) => {
const edges = graph.edges.map((edge) => {
const from = nodes.get(edge[0]);
const to = nodes.get(edge[2]);
if (!from || !to) {
throw new Error('Edge references non-existing node');
throw new Error("Edge references non-existing node");
}
from.state.children = from.state.children || [];
from.state.children.push(to);
@@ -286,6 +199,8 @@ export class GraphManager extends EventEmitter<{
return [from, edge[1], to, edge[3]] as Edge;
});
this.edges = [...edges];
this.nodes.clear();
for (const [id, node] of nodes) {
this.nodes.set(id, node);
@@ -294,65 +209,26 @@ export class GraphManager extends EventEmitter<{
this.execute();
}
private async loadAllCollections() {
// Fetch all nodes from all collections of the loaded nodes
const nodeIds = Array.from(new Set([...this.graph.nodes.map((n) => n.type)]));
const allCollections = new Set<`${string}/${string}`>();
for (const id of nodeIds) {
const [user, collection] = id.split('/');
allCollections.add(`${user}/${collection}`);
}
const allCollectionIds = await Promise
.all([...allCollections]
.map(async (collection) =>
remoteRegistry
.fetchCollection(collection)
.then((collection: { nodes: { id: NodeId }[] }) => {
return collection.nodes.map(n => n.id.replace(/\.wasm$/, '') as NodeId);
})
));
const missingNodeIds = [...new Set(allCollectionIds.flat())];
this.registry.load(missingNodeIds);
}
async load(graph: Graph) {
const a = performance.now();
this.loaded = false;
this.graph = graph;
this.status = 'loading';
this.status = "loading";
this.id = graph.id;
logger.info('loading graph', { nodes: graph.nodes, edges: graph.edges, id: graph.id });
logger.info("loading graph", $state.snapshot(graph));
const nodeIds = Array.from(new SvelteSet([...graph.nodes.map((n) => n.type)]));
const nodeIds = Array.from(new Set([...graph.nodes.map((n) => n.type)]));
await this.registry.load(nodeIds);
// Fetch all nodes from all collections of the loaded nodes
const allCollections = new SvelteSet<`${string}/${string}`>();
for (const id of nodeIds) {
const [user, collection] = id.split('/');
allCollections.add(`${user}/${collection}`);
}
for (const collection of allCollections) {
remoteRegistry
.fetchCollection(collection)
.then((collection: { nodes: { id: NodeId }[] }) => {
const ids = collection.nodes.map((n) => n.id);
return this.registry.load(ids);
});
}
logger.info('loaded node types', this.registry.getAllNodes());
logger.info("loaded node types", this.registry.getAllNodes());
for (const node of this.graph.nodes) {
const nodeType = this.registry.getNode(node.type);
if (!nodeType) {
logger.error(`Node type not found: ${node.type}`);
this.status = 'error';
this.status = "error";
return;
}
// Turn into runtime node
@@ -372,16 +248,16 @@ export class GraphManager extends EventEmitter<{
for (const type of types) {
if (type.inputs) {
for (const key in type.inputs) {
const settingId = type.inputs[key].setting;
let settingId = type.inputs[key].setting;
if (settingId) {
settingTypes[settingId] = {
__node_type: type.id,
__node_input: key,
...type.inputs[key]
...type.inputs[key],
};
if (
settingValues[settingId] === undefined
&& 'value' in type.inputs[key]
settingValues[settingId] === undefined &&
"value" in type.inputs[key]
) {
settingValues[settingId] = type.inputs[key].value;
}
@@ -391,20 +267,18 @@ export class GraphManager extends EventEmitter<{
}
this.settings = settingValues;
this.emit('settings', { types: settingTypes, values: settingValues });
this.emit("settings", { types: settingTypes, values: settingValues });
this.history.reset();
this._init(this.graph);
this.save();
this.status = 'idle';
this.status = "idle";
this.loaded = true;
logger.log(`Graph loaded in ${performance.now() - a}ms`);
setTimeout(() => this.execute(), 100);
this.loadAllCollections(); // lazily load all nodes from all collections
}
getAllNodes() {
@@ -429,13 +303,13 @@ export class GraphManager extends EventEmitter<{
const settingValues = this.settings;
if (nodeType.inputs) {
for (const key in nodeType.inputs) {
const settingId = nodeType.inputs[key].setting;
let settingId = nodeType.inputs[key].setting;
if (settingId) {
settingTypes[settingId] = nodeType.inputs[key];
if (
settingValues
&& settingValues?.[settingId] === undefined
&& 'value' in nodeType.inputs[key]
settingValues &&
settingValues?.[settingId] === undefined &&
"value" in nodeType.inputs[key]
) {
settingValues[settingId] = nodeType.inputs[key].value;
}
@@ -445,7 +319,7 @@ export class GraphManager extends EventEmitter<{
this.settings = settingValues;
this.settingTypes = settingTypes;
this.emit('settings', { types: settingTypes, values: settingValues });
this.emit("settings", { types: settingTypes, values: settingValues });
}
getChildren(node: NodeInstance) {
@@ -494,7 +368,7 @@ export class GraphManager extends EventEmitter<{
const inputType = to?.state?.type?.inputs?.[toSocket]?.type;
if (outputType === inputType) {
this.createEdge(from, fromSocket, to, toSocket, {
applyUpdate: false
applyUpdate: false,
});
continue;
}
@@ -511,10 +385,10 @@ export class GraphManager extends EventEmitter<{
const inputs = Object.entries(to.state?.type?.inputs ?? {});
const outputs = from.state?.type?.outputs ?? [];
for (let i = 0; i < inputs.length; i++) {
const [inputName, input] = inputs[i];
const [inputName, input] = inputs[0];
for (let o = 0; o < outputs.length; o++) {
const output = outputs[o];
if (input.type === output || input.type === '*') {
const output = outputs[0];
if (input.type === output) {
return this.createEdge(from, o, to, inputName);
}
}
@@ -527,9 +401,9 @@ export class GraphManager extends EventEmitter<{
createGraph(nodes: NodeInstance[], edges: [number, number, number, string][]) {
// 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()
nodes = nodes.map((node) => {
const id = startId++;
@@ -546,7 +420,7 @@ export class GraphManager extends EventEmitter<{
const to = nodes.find((n) => n.id === idMap.get(edge[2]));
if (!from || !to) {
throw new Error('Edge references non-existing node');
throw new Error("Edge references non-existing node");
}
to.state.parents = to.state.parents || [];
@@ -571,11 +445,11 @@ export class GraphManager extends EventEmitter<{
createNode({
type,
position,
props = {}
props = {},
}: {
type: NodeInstance['type'];
position: NodeInstance['position'];
props: NodeInstance['props'];
type: NodeInstance["type"];
position: NodeInstance["position"];
props: NodeInstance["props"];
}) {
const nodeType = this.registry.getNode(type);
if (!nodeType) {
@@ -588,14 +462,14 @@ export class GraphManager extends EventEmitter<{
type,
position,
state: { type: nodeType },
props
props,
});
this.nodes.set(node.id, node);
this.save();
return node;
return node
}
createEdge(
@@ -603,38 +477,36 @@ export class GraphManager extends EventEmitter<{
fromSocket: number,
to: NodeInstance,
toSocket: string,
{ applyUpdate = true } = {}
{ applyUpdate = true } = {},
): Edge | undefined {
const existingEdges = this.getEdgesToNode(to);
// check if this exact edge already exists
const existingEdge = existingEdges.find(
(e) => e[0].id === from.id && e[1] === fromSocket && e[3] === toSocket
(e) => e[0].id === from.id && e[1] === fromSocket && e[3] === toSocket,
);
if (existingEdge) {
logger.error('Edge already exists', existingEdge);
logger.error("Edge already exists", existingEdge);
return;
}
const fromType = from.state.type || this.registry.getNode(from.type);
const toType = to.state.type || this.registry.getNode(to.type);
// check if socket types match
const fromSocketType = fromType?.outputs?.[fromSocket];
const toSocketType = [toType?.inputs?.[toSocket]?.type];
if (toType?.inputs?.[toSocket]?.accepts) {
toSocketType.push(...(toType?.inputs?.[toSocket]?.accepts || []));
const fromSocketType = from.state?.type?.outputs?.[fromSocket];
const toSocketType = [to.state?.type?.inputs?.[toSocket]?.type];
if (to.state?.type?.inputs?.[toSocket]?.accepts) {
toSocketType.push(...(to?.state?.type?.inputs?.[toSocket]?.accepts || []));
}
if (!areSocketsCompatible(fromSocketType, toSocketType)) {
logger.error(
`Socket types do not match: ${fromSocketType} !== ${toSocketType}`
`Socket types do not match: ${fromSocketType} !== ${toSocketType}`,
);
return;
}
const edgeToBeReplaced = this.edges.find(
(e) => e[2].id === to.id && e[3] === toSocket
(e) => e[2].id === to.id && e[3] === toSocket,
);
if (edgeToBeReplaced) {
this.removeEdge(edgeToBeReplaced, { applyDeletion: false });
@@ -661,7 +533,7 @@ export class GraphManager extends EventEmitter<{
const nextState = this.history.undo();
if (nextState) {
this._init(nextState);
this.emit('save', this.serialize());
this.emit("save", this.serialize());
}
}
@@ -669,7 +541,7 @@ export class GraphManager extends EventEmitter<{
const nextState = this.history.redo();
if (nextState) {
this._init(nextState);
this.emit('save', this.serialize());
this.emit("save", this.serialize());
}
}
@@ -686,15 +558,8 @@ export class GraphManager extends EventEmitter<{
if (this.currentUndoGroup) return;
const state = this.serialize();
this.history.save(state);
// This is some stupid race condition where the graph-manager emits a save event
// when the graph is not fully loaded
if (this.nodes.size === 0 && this.edges.length === 0) {
return;
}
this.emit('save', state);
logger.log('saving graphs', state);
this.emit("save", state);
logger.log("saving graphs", state);
}
getParentsOfNode(node: NodeInstance) {
@@ -702,7 +567,7 @@ export class GraphManager extends EventEmitter<{
const stack = node.state?.parents?.slice(0);
while (stack?.length) {
if (parents.length > 1000000) {
logger.warn('Infinite loop detected');
logger.warn("Infinite loop detected");
break;
}
const parent = stack.pop();
@@ -721,49 +586,46 @@ export class GraphManager extends EventEmitter<{
return [];
}
const definitions = typeof socket.index === 'string'
const definitions = typeof socket.index === "string"
? allDefinitions.filter(s => {
return s.outputs?.find(_s =>
Object
.values(nodeType?.inputs || {})
.map(s => s.type)
.includes(_s as NodeInput['type'])
);
return s.outputs?.find(_s => Object
.values(nodeType?.inputs || {})
.map(s => s.type)
.includes(_s as NodeInput["type"])
)
})
: allDefinitions.filter(s =>
Object
.values(s.inputs ?? {})
.find(s => {
if (s.hidden) return false;
if (nodeType.outputs?.includes(s.type)) {
return true;
}
return s.accepts?.find(a => nodeType.outputs?.includes(a));
})
);
: allDefinitions.filter(s => Object
.values(s.inputs ?? {})
.find(s => {
if (s.hidden) return false;
if (nodeType.outputs?.includes(s.type)) {
return true
}
return s.accepts?.find(a => nodeType.outputs?.includes(a))
}))
return definitions
return definitions;
}
getPossibleSockets({ node, index }: Socket): [NodeInstance, string | number][] {
const nodeType = this.registry.getNode(node.type);
const nodeType = node?.state?.type;
if (!nodeType) return [];
console.log({ index });
const sockets: [NodeInstance, string | number][] = [];
// if index is a string, we are an input looking for outputs
if (typeof index === 'string') {
if (typeof index === "string") {
// 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(
(n) => n.id !== node.id && !children.has(n.id)
(n) => n.id !== node.id && !children.has(n.id),
);
const ownType = nodeType?.inputs?.[index].type;
for (const node of nodes) {
const nodeType = this.registry.getNode(node.type);
const nodeType = node?.state?.type;
const inputs = nodeType?.outputs;
if (!inputs) continue;
for (let index = 0; index < inputs.length; index++) {
@@ -772,38 +634,34 @@ export class GraphManager extends EventEmitter<{
}
}
}
} else if (typeof index === 'number') {
} else if (typeof index === "number") {
// if index is a number, we are an output looking for inputs
// filter out self and parent nodes
const parents = new SvelteSet(this.getParentsOfNode(node).map((n) => n.id));
const parents = new Set(this.getParentsOfNode(node).map((n) => n.id));
const nodes = this.getAllNodes().filter(
(n) => n.id !== node.id && !parents.has(n.id)
(n) => n.id !== node.id && !parents.has(n.id),
);
const edges = new SvelteMap<number, string[]>();
this.getEdgesFromNode(node)
.filter((e) => e[1] === index)
.forEach((e) => {
if (edges.has(e[2].id)) {
edges.get(e[2].id)?.push(e[3]);
} else {
edges.set(e[2].id, [e[3]]);
}
});
// get edges from this socket
const edges = new Map(
this.getEdgesFromNode(node)
.filter((e) => e[1] === index)
.map((e) => [e[2].id, e[3]]),
);
const ownType = nodeType.outputs?.[index];
for (const node of nodes) {
const inputs = this.registry.getNode(node.type)?.inputs;
const inputs = node?.state?.type?.inputs;
if (!inputs) continue;
for (const key in inputs) {
const otherType = [inputs[key].type];
otherType.push(...(inputs[key].accepts || []));
if (
areSocketsCompatible(ownType, otherType)
&& !edges.get(node.id)?.includes(key)
areSocketsCompatible(ownType, otherType) &&
edges.get(node.id) !== key
) {
sockets.push([node, key]);
}
@@ -811,13 +669,12 @@ export class GraphManager extends EventEmitter<{
}
}
console.log(`Found ${sockets.length} possible sockets`, sockets);
return sockets;
}
removeEdge(
edge: Edge,
{ applyDeletion = true }: { applyDeletion?: boolean } = {}
{ applyDeletion = true }: { applyDeletion?: boolean } = {},
) {
const id0 = edge[0].id;
const sid0 = edge[1];
@@ -825,20 +682,21 @@ export class GraphManager extends EventEmitter<{
const sid2 = edge[3];
const _edge = this.edges.find(
(e) => e[0].id === id0 && e[1] === sid0 && e[2].id === id2 && e[3] === sid2
(e) =>
e[0].id === id0 && e[1] === sid0 && e[2].id === id2 && e[3] === sid2,
);
if (!_edge) return;
if (edge[0].state.children) {
edge[0].state.children = edge[0].state.children.filter(
(n: NodeInstance) => n.id !== id2
(n: NodeInstance) => n.id !== id2,
);
}
if (edge[2].state.parents) {
edge[2].state.parents = edge[2].state.parents.filter(
(n: NodeInstance) => n.id !== id0
(n: NodeInstance) => n.id !== id0,
);
}
@@ -847,6 +705,7 @@ export class GraphManager extends EventEmitter<{
this.execute();
this.save();
}
}
getEdgesToNode(node: NodeInstance) {

View File

@@ -1,70 +1,36 @@
import type { NodeInstance, Socket } from '@nodarium/types';
import { getContext, setContext } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type { OrthographicCamera, Vector3 } from 'three';
import type { GraphManager } from './graph-manager.svelte';
import { ColorGenerator } from './graph/colors';
import { getNodeHeight, getSocketPosition } from './helpers/nodeHelpers';
import type { NodeInstance, Socket } from "@nodarium/types";
import { getContext, setContext } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import type { GraphManager } from "./graph-manager.svelte";
import type { OrthographicCamera } from "three";
const graphStateKey = Symbol('graph-state');
const graphStateKey = Symbol("graph-state");
export function getGraphState() {
return getContext<GraphState>(graphStateKey);
}
export function setGraphState(graphState: GraphState) {
return setContext(graphStateKey, graphState);
return setContext(graphStateKey, graphState)
}
const graphManagerKey = Symbol('graph-manager');
const graphManagerKey = Symbol("graph-manager");
export function getGraphManager() {
return getContext<GraphManager>(graphManagerKey);
return getContext<GraphManager>(graphManagerKey)
}
export function setGraphManager(manager: GraphManager) {
return setContext(graphManagerKey, manager);
}
type EdgeData = {
x1: number;
y1: number;
points: Vector3[];
};
const predefinedColors = {
path: {
hue: 80,
lightness: 20,
saturation: 80
},
float: {
hue: 70,
lightness: 10,
saturation: 0
},
geometry: {
hue: 0,
lightness: 50,
saturation: 70
},
'*': {
hue: 200,
lightness: 20,
saturation: 100
}
} as const;
export class GraphState {
colors = new ColorGenerator(predefinedColors);
constructor(private graph: GraphManager) {
$effect.root(() => {
$effect(() => {
localStorage.setItem(
'cameraPosition',
`[${this.cameraPosition[0]},${this.cameraPosition[1]},${this.cameraPosition[2]}]`
);
});
});
const storedPosition = localStorage.getItem('cameraPosition');
localStorage.setItem("cameraPosition", `[${this.cameraPosition[0]},${this.cameraPosition[1]},${this.cameraPosition[2]}]`)
})
})
const storedPosition = localStorage.getItem("cameraPosition")
if (storedPosition) {
try {
const d = JSON.parse(storedPosition);
@@ -72,7 +38,7 @@ export class GraphState {
this.cameraPosition[1] = d[1];
this.cameraPosition[2] = d[2];
} catch (e) {
console.log('Failed to parsed stored camera position', e);
console.log("Failed to parsed stored camera position", e);
}
}
}
@@ -80,18 +46,13 @@ export class GraphState {
width = $state(100);
height = $state(100);
hoveredEdgeId = $state<string | null>(null);
edges = new SvelteMap<string, EdgeData>();
wrapper = $state<HTMLDivElement>(null!);
rect: DOMRect = $derived(
(this.wrapper && this.width && this.height)
? this.wrapper.getBoundingClientRect()
: new DOMRect(0, 0, 0, 0)
(this.wrapper && this.width && this.height) ? this.wrapper.getBoundingClientRect() : new DOMRect(0, 0, 0, 0),
);
camera = $state<OrthographicCamera>(null!);
cameraPosition: [number, number, number] = $state([140, 100, 3.5]);
cameraPosition: [number, number, number] = $state([0, 0, 4]);
clipboard: null | {
nodes: NodeInstance[];
@@ -102,7 +63,7 @@ export class GraphState {
this.cameraPosition[0] - this.width / this.cameraPosition[2] / 2,
this.cameraPosition[0] + this.width / this.cameraPosition[2] / 2,
this.cameraPosition[1] - this.height / this.cameraPosition[2] / 2,
this.cameraPosition[1] + this.height / this.cameraPosition[2] / 2
this.cameraPosition[1] + this.height / this.cameraPosition[2] / 2,
]);
boxSelection = $state(false);
@@ -110,8 +71,8 @@ export class GraphState {
addMenuPosition = $state<[number, number] | null>(null);
snapToGrid = $state(false);
backgroundType = $state<'grid' | 'dots' | 'none'>('grid');
showHelp = $state(false);
showGrid = $state(true)
showHelp = $state(false)
cameraDown = [0, 0];
mouseDownNodeId = -1;
@@ -127,49 +88,33 @@ export class GraphState {
hoveredSocket = $state<Socket | null>(null);
possibleSockets = $state<Socket[]>([]);
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() {
return $state.snapshot(this.edges);
}
clearSelection() {
this.selectedNodes.clear();
}
isBodyFocused = () => document?.activeElement?.nodeName !== 'INPUT';
setEdgeGeometry(edgeId: string, x1: number, y1: number, points: Vector3[]) {
this.edges.set(edgeId, { x1, y1, points });
}
removeEdgeGeometry(edgeId: string) {
this.edges.delete(edgeId);
}
getEdgeData() {
return this.edges;
}
isBodyFocused = () => document?.activeElement?.nodeName !== "INPUT";
updateNodePosition(node: NodeInstance) {
if (
node.state.x === node.position[0]
&& node.state.y === node.position[1]
node.state.x === node.position[0] &&
node.state.y === node.position[1]
) {
delete node.state.x;
delete node.state.y;
}
if (node.state['x'] !== undefined && node.state['y'] !== undefined) {
if (node.state["x"] !== undefined && node.state["y"] !== undefined) {
if (node.state.ref) {
node.state.ref.style.setProperty('--nx', `${node.state.x * 10}px`);
node.state.ref.style.setProperty('--ny', `${node.state.y * 10}px`);
node.state.ref.style.setProperty("--nx", `${node.state.x * 10}px`);
node.state.ref.style.setProperty("--ny", `${node.state.y * 10}px`);
}
} else {
if (node.state.ref) {
node.state.ref.style.setProperty('--nx', `${node.position[0] * 10}px`);
node.state.ref.style.setProperty('--ny', `${node.position[1] * 10}px`);
node.state.ref.style.setProperty("--nx", `${node.position[0] * 10}px`);
node.state.ref.style.setProperty("--ny", `${node.position[1] * 10}px`);
}
}
}
@@ -182,40 +127,58 @@ export class GraphState {
return 4;
} else if (z > 11) {
return 2;
} else {
}
return 1;
}
tryConnectToDebugNode(nodeId: number) {
const node = this.graph.nodes.get(nodeId);
if (!node) return;
if (node.type.endsWith('/debug')) return;
if (!node.state.type?.outputs?.length) return;
for (const _node of this.graph.nodes.values()) {
if (_node.type.endsWith('/debug')) {
this.graph.createEdge(node, 0, _node, 'input');
return;
}
}
const debugNode = this.graph.createNode({
type: 'max/plantarium/debug',
position: [node.position[0] + 30, node.position[1]],
props: {}
});
if (debugNode) {
this.graph.createEdge(node, 0, debugNode, 'input');
getSocketPosition(
node: NodeInstance,
index: string | number,
): [number, number] {
if (typeof index === "number") {
return [
(node?.state?.x ?? node.position[0]) + 20,
(node?.state?.y ?? node.position[1]) + 2.5 + 10 * index,
];
} else {
const _index = Object.keys(node.state?.type?.inputs || {}).indexOf(index);
return [
node?.state?.x ?? node.position[0],
(node?.state?.y ?? node.position[1]) + 10 + 10 * _index,
];
}
}
copyNodes() {
if (this.activeNodeId === -1 && !this.selectedNodes?.size) {
return;
private nodeHeightCache: Record<string, number> = {};
getNodeHeight(nodeTypeId: string) {
if (nodeTypeId in this.nodeHeightCache) {
return this.nodeHeightCache[nodeTypeId];
}
const node = this.graph.getNodeType(nodeTypeId);
if (!node?.inputs) {
return 5;
}
const height =
5 +
10 *
Object.keys(node.inputs).filter(
(p) =>
p !== "seed" &&
node?.inputs &&
!("setting" in node?.inputs?.[p]) &&
node.inputs[p].hidden !== true,
).length;
this.nodeHeightCache[nodeTypeId] = height;
return height;
}
copyNodes() {
if (this.activeNodeId === -1 && !this.selectedNodes?.size)
return;
let nodes = [
this.activeNodeId,
...(this.selectedNodes?.values() || [])
...(this.selectedNodes?.values() || []),
]
.map((id) => this.graph.getNode(id))
.filter(b => !!b);
@@ -225,14 +188,14 @@ export class GraphState {
...node,
position: [
this.mousePosition[0] - node.position[0],
this.mousePosition[1] - node.position[1]
this.mousePosition[1] - node.position[1],
],
tmp: undefined
tmp: undefined,
}));
this.clipboard = {
nodes: nodes,
edges: edges
edges: edges,
};
}
@@ -254,19 +217,20 @@ export class GraphState {
}
}
setDownSocket(socket: Socket) {
this.activeSocket = socket;
let { node, index, position } = socket;
// if the socket is an input socket -> remove existing edges
if (typeof index === 'string') {
// remove existing edge
if (typeof index === "string") {
const edges = this.graph.getEdgesToNode(node);
for (const edge of edges) {
if (edge[3] === index) {
node = edge[0];
index = edge[1];
position = getSocketPosition(node, index);
position = this.getSocketPosition(node, index);
this.graph.removeEdge(edge);
break;
}
@@ -277,7 +241,7 @@ export class GraphState {
this.activeSocket = {
node,
index,
position
position,
};
this.possibleSockets = this.graph
@@ -286,31 +250,32 @@ export class GraphState {
return {
node,
index,
position: getSocketPosition(node, index)
position: this.getSocketPosition(node, index),
};
});
}
};
projectScreenToWorld(x: number, y: number): [number, number] {
return [
this.cameraPosition[0]
+ (x - this.width / 2) / this.cameraPosition[2],
this.cameraPosition[1]
+ (y - this.height / 2) / this.cameraPosition[2]
this.cameraPosition[0] +
(x - this.width / 2) / this.cameraPosition[2],
this.cameraPosition[1] +
(y - this.height / 2) / this.cameraPosition[2],
];
}
getNodeIdFromEvent(event: MouseEvent) {
let clickedNodeId = -1;
const mx = event.clientX - this.rect.x;
const my = event.clientY - this.rect.y;
let mx = event.clientX - this.rect.x;
let my = event.clientY - this.rect.y;
if (event.button === 0) {
// check if the clicked element is a node
if (event.target instanceof HTMLElement) {
const nodeElement = event.target.closest('.node');
const nodeId = nodeElement?.getAttribute?.('data-node-id');
const nodeElement = event.target.closest(".node");
const nodeId = nodeElement?.getAttribute?.("data-node-id");
if (nodeId) {
clickedNodeId = parseInt(nodeId, 10);
}
@@ -323,7 +288,7 @@ export class GraphState {
for (const node of this.graph.nodes.values()) {
const x = node.position[0];
const y = node.position[1];
const height = getNodeHeight(node.state.type!);
const height = this.getNodeHeight(node.type);
if (downX > x && downX < x + 20 && downY > y && downY < y + height) {
clickedNodeId = node.id;
break;
@@ -335,15 +300,13 @@ export class GraphState {
}
isNodeInView(node: NodeInstance) {
const height = getNodeHeight(node.state.type!);
const height = this.getNodeHeight(node.type);
const width = 20;
return node.position[0] > this.cameraBounds[0] - width
&& node.position[0] < this.cameraBounds[1]
&& node.position[1] > this.cameraBounds[2] - height
&& node.position[1] < this.cameraBounds[3];
}
openNodePalette() {
this.addMenuPosition = [this.mousePosition[0], this.mousePosition[1]];
}
return (
node.position[0] > this.cameraBounds[0] - width &&
node.position[0] < this.cameraBounds[1] &&
node.position[1] > this.cameraBounds[2] - height &&
node.position[1] < this.cameraBounds[3]
);
};
}

View File

@@ -1,28 +1,23 @@
<script lang="ts">
import type { Edge, NodeInstance } from '@nodarium/types';
import { Canvas } from '@threlte/core';
import { HTML } from '@threlte/extras';
import { createKeyMap } from '../../helpers/createKeyMap';
import Background from '../background/Background.svelte';
import AddMenu from '../components/AddMenu.svelte';
import BoxSelection from '../components/BoxSelection.svelte';
import Camera from '../components/Camera.svelte';
import HelpView from '../components/HelpView.svelte';
import Debug from '../debug/Debug.svelte';
import EdgeEl from '../edges/Edge.svelte';
import { getGraphManager, getGraphState } from '../graph-state.svelte';
import { getSocketPosition } from '../helpers/nodeHelpers';
import NodeEl from '../node/Node.svelte';
import { maxZoom, minZoom } from './constants';
import { FileDropEventManager } from './drop.events';
import { MouseEventManager } from './mouse.events';
import type { Edge, NodeInstance } from "@nodarium/types";
import { createKeyMap } from "../../helpers/createKeyMap";
import AddMenu from "../components/AddMenu.svelte";
import Background from "../background/Background.svelte";
import BoxSelection from "../components/BoxSelection.svelte";
import EdgeEl from "../edges/Edge.svelte";
import NodeEl from "../node/Node.svelte";
import Camera from "../components/Camera.svelte";
import { Canvas } from "@threlte/core";
import HelpView from "../components/HelpView.svelte";
import { getGraphManager, getGraphState } from "../graph-state.svelte";
import { HTML } from "@threlte/extras";
import { FileDropEventManager, MouseEventManager } from "./events";
import { maxZoom, minZoom } from "./constants";
const {
keymap,
addMenuPadding
}: {
keymap: ReturnType<typeof createKeyMap>;
addMenuPadding?: { left?: number; right?: number; bottom?: number; top?: number };
} = $props();
const graph = getGraphManager();
@@ -39,8 +34,8 @@
return [0, 0, 0, 0];
}
const pos1 = getSocketPosition(fromNode, edge[1]);
const pos2 = getSocketPosition(toNode, edge[3]);
const pos1 = graphState.getSocketPosition(fromNode, edge[1]);
const pos2 = graphState.getSocketPosition(toNode, edge[3]);
return [pos1[0], pos1[1], pos2[0], pos2[1]];
}
@@ -48,18 +43,19 @@
const newNode = graph.createNode({
type: node.type,
position: node.position,
props: node.props
props: node.props,
});
if (!newNode) return;
if (graphState.activeSocket) {
if (typeof graphState.activeSocket.index === 'number') {
const socketType = graphState.activeSocket.node.state?.type?.outputs?.[
graphState.activeSocket.index
];
if (typeof graphState.activeSocket.index === "number") {
const socketType =
graphState.activeSocket.node.state?.type?.outputs?.[
graphState.activeSocket.index
];
const input = Object.entries(newNode?.state?.type?.inputs || {}).find(
(inp) => inp[1].type === socketType
(inp) => inp[1].type === socketType,
);
if (input) {
@@ -67,17 +63,18 @@
graphState.activeSocket.node,
graphState.activeSocket.index,
newNode,
input[0]
input[0],
);
}
} else {
const socketType = graphState.activeSocket.node.state?.type?.inputs?.[
graphState.activeSocket.index
];
const socketType =
graphState.activeSocket.node.state?.type?.inputs?.[
graphState.activeSocket.index
];
const output = newNode.state?.type?.outputs?.find((out) => {
if (socketType?.type === out) return true;
if ((socketType?.accepts as string[])?.includes(out)) return true;
if (socketType?.accepts?.includes(out as any)) return true;
return false;
});
@@ -86,7 +83,7 @@
newNode,
output.indexOf(output),
graphState.activeSocket.node,
graphState.activeSocket.index
graphState.activeSocket.index,
);
}
}
@@ -95,25 +92,17 @@
graphState.activeSocket = null;
graphState.addMenuPosition = null;
}
function getSocketType(node: NodeInstance, index: number | string): string {
if (typeof index === 'string') {
return node.state.type?.inputs?.[index].type || 'unknown';
}
return node.state.type?.outputs?.[index] || 'unknown';
}
</script>
<svelte:window
onmousemove={(ev) => mouseEvents.handleWindowMouseMove(ev)}
onmouseup={(ev) => mouseEvents.handleWindowMouseUp(ev)}
onmousemove={(ev) => mouseEvents.handleMouseMove(ev)}
onmouseup={(ev) => mouseEvents.handleMouseUp(ev)}
/>
<div
onwheel={(ev) => mouseEvents.handleMouseScroll(ev)}
bind:this={graphState.wrapper}
class="graph-wrapper"
style="height: 100%"
class:is-panning={graphState.isPanning}
class:is-hovering={graphState.hoveredNodeId !== -1}
aria-label="Graph"
@@ -123,7 +112,6 @@
bind:clientHeight={graphState.height}
onkeydown={(ev) => keymap.handleKeyboardEvent(ev)}
onmousedown={(ev) => mouseEvents.handleMouseDown(ev)}
oncontextmenu={(ev) => mouseEvents.handleContextMenu(ev)}
{...fileDropEvents.getEventListenerProps()}
>
<input
@@ -142,9 +130,8 @@
position={graphState.cameraPosition}
/>
{#if graphState.backgroundType !== 'none'}
{#if graphState.showGrid !== false}
<Background
type={graphState.backgroundType}
cameraPosition={graphState.cameraPosition}
{maxZoom}
{minZoom}
@@ -157,33 +144,27 @@
<BoxSelection
cameraPosition={graphState.cameraPosition}
p1={{
x: graphState.cameraPosition[0]
+ (graphState.mouseDown[0] - graphState.width / 2)
/ graphState.cameraPosition[2],
y: graphState.cameraPosition[1]
+ (graphState.mouseDown[1] - graphState.height / 2)
/ graphState.cameraPosition[2]
x:
graphState.cameraPosition[0] +
(graphState.mouseDown[0] - graphState.width / 2) /
graphState.cameraPosition[2],
y:
graphState.cameraPosition[1] +
(graphState.mouseDown[1] - graphState.height / 2) /
graphState.cameraPosition[2],
}}
p2={{ x: graphState.mousePosition[0], y: graphState.mousePosition[1] }}
/>
{/if}
{#if graph.status === 'idle'}
{#if graph.status === "idle"}
{#if graphState.addMenuPosition}
<AddMenu
onnode={handleNodeCreation}
paddingTop={addMenuPadding?.top}
paddingRight={addMenuPadding?.right}
paddingBottom={addMenuPadding?.bottom}
paddingLeft={addMenuPadding?.left}
/>
<AddMenu onnode={handleNodeCreation} />
{/if}
{#if graphState.activeSocket}
<EdgeEl
z={graphState.cameraPosition[2]}
inputType={getSocketType(graphState.activeSocket.node, graphState.activeSocket.index)}
outputType={getSocketType(graphState.activeSocket.node, graphState.activeSocket.index)}
x1={graphState.activeSocket.position[0]}
y1={graphState.activeSocket.position[1]}
x2={graphState.edgeEndPosition?.[0] ?? graphState.mousePosition[0]}
@@ -191,27 +172,14 @@
/>
{/if}
{#each graph.edges as edge (edge)}
{#each graph.edges as edge}
{@const [x1, y1, x2, y2] = getEdgePosition(edge)}
<EdgeEl
id={graph.getEdgeId(edge)}
z={graphState.cameraPosition[2]}
inputType={getSocketType(edge[0], edge[1])}
outputType={getSocketType(edge[2], edge[3])}
{x1}
{y1}
{x2}
{y2}
/>
<EdgeEl z={graphState.cameraPosition[2]} {x1} {y1} {x2} {y2} />
{/each}
<Debug />
<HTML transform={false}>
<HTML>
<div
role="tree"
id="graph"
tabindex="0"
class="wrapper"
style:transform={`scale(${graphState.cameraPosition[2] * 0.1})`}
class:hovering-sockets={graphState.activeSocket}
@@ -220,13 +188,14 @@
<NodeEl
{node}
inView={graphState.isNodeInView(node)}
z={graphState.cameraPosition[2]}
/>
{/each}
</div>
</HTML>
{:else if graph.status === 'loading'}
{:else if graph.status === "loading"}
<span>Loading</span>
{:else if graph.status === 'error'}
{:else if graph.status === "error"}
<span>Error</span>
{/if}
</Canvas>
@@ -264,7 +233,7 @@
z-index: 1;
width: 100%;
height: 100%;
background: var(--color-layer-2);
background: var(--layer-2);
opacity: 0;
}
input:disabled {
@@ -284,8 +253,8 @@
border-radius: 5px;
width: calc(100% - 20px);
height: calc(100% - 25px);
border: dashed 4px var(--color-layer-2);
background: var(--color-layer-1);
border: dashed 4px var(--layer-2);
background: var(--layer-1);
opacity: 0.5;
}
</style>

View File

@@ -1,53 +1,52 @@
<script lang="ts">
import { createKeyMap } from '$lib/helpers/createKeyMap';
import type { Graph, NodeInstance, NodeRegistry } from '@nodarium/types';
import { GraphManager } from '../graph-manager.svelte';
import { GraphState, setGraphManager, setGraphState } from '../graph-state.svelte';
import { setupKeymaps } from '../keymaps';
import GraphEl from './Graph.svelte';
import type { Graph, NodeInstance, NodeRegistry } from "@nodarium/types";
import GraphEl from "./Graph.svelte";
import { GraphManager } from "../graph-manager.svelte";
import { createKeyMap } from "$lib/helpers/createKeyMap";
import {
GraphState,
setGraphManager,
setGraphState,
} from "../graph-state.svelte";
import { setupKeymaps } from "../keymaps";
type Props = {
graph?: Graph;
graph: Graph;
registry: NodeRegistry;
settings?: Record<string, unknown>;
settings?: Record<string, any>;
activeNode?: NodeInstance;
backgroundType?: 'grid' | 'dots' | 'none';
showGrid?: boolean;
snapToGrid?: boolean;
showHelp?: boolean;
settingTypes?: Record<string, unknown>;
addMenuPadding?: { left?: number; right?: number; bottom?: number; top?: number };
settingTypes?: Record<string, any>;
onsave?: (save: Graph) => void;
onresult?: (result: unknown) => void;
onresult?: (result: any) => void;
};
let {
graph,
registry,
addMenuPadding,
settings = $bindable(),
activeNode = $bindable(),
backgroundType = $bindable('grid'),
showGrid = $bindable(true),
snapToGrid = $bindable(true),
showHelp = $bindable(false),
settings = $bindable(),
settingTypes = $bindable(),
onsave,
onresult
onresult,
}: Props = $props();
export const keymap = createKeyMap([]);
// svelte-ignore state_referenced_locally
export const manager = new GraphManager(registry);
setGraphManager(manager);
const graphState = new GraphState(manager);
$effect(() => {
graphState.backgroundType = backgroundType;
graphState.showGrid = showGrid;
graphState.snapToGrid = snapToGrid;
graphState.showHelp = showHelp;
});
@@ -71,20 +70,22 @@
}
});
manager.on('settings', (_settings) => {
$effect(() => {
if (settingTypes && settings) {
manager.setSettings(settings);
}
});
manager.on("settings", (_settings) => {
settingTypes = { ...settingTypes, ..._settings.types };
settings = _settings.values;
});
manager.on('result', (result) => onresult?.(result));
manager.on("result", (result) => onresult?.(result));
manager.on('save', (save) => onsave?.(save));
manager.on("save", (save) => onsave?.(save));
$effect(() => {
if (graph) {
manager.load(graph);
}
});
manager.load(graph);
</script>
<GraphEl {keymap} {addMenuPadding} />
<GraphEl {keymap} />

View File

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

View File

@@ -1,44 +0,0 @@
type Color = { hue: number; saturation: number; lightness: number };
export class ColorGenerator {
private colors: Map<string, Color> = new Map();
private lightnessLevels = [10, 60];
constructor(predefined: Record<string, Color>) {
for (const [id, colorStr] of Object.entries(predefined)) {
this.colors.set(id, colorStr);
}
}
public getColor(id: string): string {
if (this.colors.has(id)) {
return this.colorToHsl(this.colors.get(id)!);
}
const newColor = this.generateNewColor();
this.colors.set(id, newColor);
return this.colorToHsl(newColor);
}
private generateNewColor(): Color {
const existingHues = Array.from(this.colors.values()).map(c => c.hue).sort();
let hue = existingHues[0];
let attempts = 0;
while (
existingHues.some(h => Math.abs(h - hue) < 30 || Math.abs(h - hue) > 330)
&& attempts < 360
) {
hue = (hue + 30) % 360;
attempts++;
}
const lightness = 60;
return { hue, lightness, saturation: 100 };
}
private colorToHsl(c: Color): string {
return `hsl(${c.hue}, ${c.saturation}%, ${c.lightness}%)`;
}
}

View File

@@ -1,109 +0,0 @@
import { GraphSchema, type NodeId } from '@nodarium/types';
import type { GraphManager } from '../graph-manager.svelte';
import type { GraphState } from '../graph-state.svelte';
export class FileDropEventManager {
constructor(
private graph: GraphManager,
private state: GraphState
) {}
handleFileDrop(event: DragEvent) {
event.preventDefault();
this.state.isDragging = false;
if (!event.dataTransfer) return;
const nodeId = event.dataTransfer.getData('data/node-id') as NodeId;
let mx = event.clientX - this.state.rect.x;
let my = event.clientY - this.state.rect.y;
if (nodeId) {
const nodeOffsetX = event.dataTransfer.getData('data/node-offset-x');
const nodeOffsetY = event.dataTransfer.getData('data/node-offset-y');
if (nodeOffsetX && nodeOffsetY) {
mx += parseInt(nodeOffsetX);
my += parseInt(nodeOffsetY);
}
let props = {};
const rawNodeProps = event.dataTransfer.getData('data/node-props');
if (rawNodeProps) {
try {
props = JSON.parse(rawNodeProps);
} catch (e) {
console.error('Failed to parse node dropped', e);
}
}
const pos = this.state.projectScreenToWorld(mx, my);
this.graph.registry.load([nodeId]).then(() => {
this.graph.createNode({
type: nodeId,
props,
position: pos
});
});
} else if (event.dataTransfer.files.length) {
const file = event.dataTransfer.files[0];
if (file.type === 'application/wasm') {
const reader = new FileReader();
reader.onload = async (e) => {
const buffer = e.target?.result;
if (buffer?.constructor === ArrayBuffer) {
const nodeType = await this.graph.registry.register(nodeId, buffer);
this.graph.createNode({
type: nodeType.id,
props: {},
position: this.state.projectScreenToWorld(mx, my)
});
}
};
reader.readAsArrayBuffer(file);
} else if (file.type === 'application/json') {
const reader = new FileReader();
reader.onload = (e) => {
const buffer = e.target?.result as ArrayBuffer;
if (buffer) {
const state = GraphSchema.parse(JSON.parse(buffer.toString()));
this.graph.load(state);
}
};
reader.readAsText(file);
}
}
}
handleMouseLeave() {
this.state.isDragging = false;
this.state.isPanning = false;
}
handleDragEnter(e: DragEvent) {
e.preventDefault();
this.state.isDragging = true;
this.state.isPanning = false;
}
handleDragOver(e: DragEvent) {
e.preventDefault();
this.state.isDragging = true;
this.state.isPanning = false;
}
handleDragEnd(e: DragEvent) {
e.preventDefault();
this.state.isDragging = true;
this.state.isPanning = false;
}
getEventListenerProps() {
return {
ondragenter: (ev: DragEvent) => this.handleDragEnter(ev),
ondragover: (ev: DragEvent) => this.handleDragOver(ev),
ondragexit: (ev: DragEvent) => this.handleDragEnd(ev),
ondrop: (ev: DragEvent) => this.handleFileDrop(ev),
onmouseleave: () => this.handleMouseLeave()
};
}
}

View File

@@ -1,117 +0,0 @@
import type { Box } from '@nodarium/types';
import type { GraphManager } from '../graph-manager.svelte';
import type { GraphState } from '../graph-state.svelte';
import { distanceFromPointToSegment } from '../helpers';
export class EdgeInteractionManager {
constructor(
private graph: GraphManager,
private state: GraphState
) {}
private MIN_DISTANCE = 3;
private _boundingBoxes = new Map<string, Box>();
handleMouseDown() {
this._boundingBoxes.clear();
const possibleEdges = this.graph
.getPossibleDropOnEdges(this.state.activeNodeId)
.map(e => this.graph.getEdgeId(e));
const edges = this.state.getEdges();
for (const edge of edges) {
const edgeId = edge[0];
if (!possibleEdges.includes(edgeId)) {
edges.delete(edgeId);
}
}
for (const [edgeId, data] of edges) {
const points = data.points;
let minX = points[0].x + data.x1;
let maxX = points[0].x + data.x1;
let minY = points[0].z + data.y1;
let maxY = points[0].z + data.y1;
// Iterate through all points to find the true bounds
for (let i = 0; i < points.length; i++) {
const x = data.x1 + points[i].x;
const y = data.y1 + points[i].z;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
const boundingBox = {
minX: minX - this.MIN_DISTANCE,
maxX: maxX + this.MIN_DISTANCE,
minY: minY - this.MIN_DISTANCE,
maxY: maxY + this.MIN_DISTANCE
};
this._boundingBoxes.set(edgeId, boundingBox);
}
}
handleMouseMove() {
const [mouseX, mouseY] = this.state.mousePosition;
const hoveredEdgeIds: string[] = [];
const edges = this.state.getEdges();
// Check if mouse is inside any bounding box
for (const [edgeId, box] of this._boundingBoxes) {
const isInside = mouseX >= box.minX
&& mouseX <= box.maxX
&& mouseY >= box.minY
&& mouseY <= box.maxY;
if (isInside) {
hoveredEdgeIds.push(edgeId);
}
}
let hoveredEdgeId: string | null = null;
let hoveredEdgeDistance = Infinity;
const DENSITY = 10; // higher DENSITY = less points checked (yes density might not be the best name :-)
for (const edgeId of hoveredEdgeIds) {
const edge = edges.get(edgeId)!;
for (let i = 0; i < edge.points.length - DENSITY; i += DENSITY) {
const pointAx = edge.points[i].x + edge.x1;
const pointAy = edge.points[i].z + edge.y1;
const pointBx = edge.points[i + DENSITY].x + edge.x1;
const pointBy = edge.points[i + DENSITY].z + edge.y1;
const distance = distanceFromPointToSegment(
pointAx,
pointAy,
pointBx,
pointBy,
mouseX,
mouseY
);
if (distance < this.MIN_DISTANCE) {
if (distance < hoveredEdgeDistance) {
hoveredEdgeDistance = distance;
hoveredEdgeId = edgeId;
}
}
}
}
this.state.hoveredEdgeId = hoveredEdgeId;
}
handleMouseUp() {
if (this.state.hoveredEdgeId) {
const edge = this.graph.getEdgeById(this.state.hoveredEdgeId);
if (edge) {
this.graph.dropNodeOnEdge(this.state.activeNodeId, edge);
}
this.state.hoveredEdgeId = null;
}
}
}

View File

@@ -1,24 +1,127 @@
import { animate, lerp } from '$lib/helpers';
import { type NodeInstance } from '@nodarium/types';
import type { GraphManager } from '../graph-manager.svelte';
import { type GraphState } from '../graph-state.svelte';
import { snapToGrid as snapPointToGrid } from '../helpers';
import { getNodeHeight } from '../helpers/nodeHelpers';
import { maxZoom, minZoom, zoomSpeed } from './constants';
import { EdgeInteractionManager } from './edge.events';
import { GraphSchema, type NodeId, type NodeInstance } from "@nodarium/types";
import type { GraphManager } from "../graph-manager.svelte";
import type { GraphState } from "../graph-state.svelte";
import { animate, lerp } from "$lib/helpers";
import { snapToGrid as snapPointToGrid } from "../helpers";
import { maxZoom, minZoom, zoomSpeed } from "./constants";
export class MouseEventManager {
edgeInteractionManager: EdgeInteractionManager;
export class FileDropEventManager {
constructor(
private graph: GraphManager,
private state: GraphState
) {
this.edgeInteractionManager = new EdgeInteractionManager(graph, state);
) { }
handleFileDrop(event: DragEvent) {
event.preventDefault();
this.state.isDragging = false;
if (!event.dataTransfer) return;
const nodeId = event.dataTransfer.getData("data/node-id") as NodeId;
let mx = event.clientX - this.state.rect.x;
let my = event.clientY - this.state.rect.y;
if (nodeId) {
let nodeOffsetX = event.dataTransfer.getData("data/node-offset-x");
let nodeOffsetY = event.dataTransfer.getData("data/node-offset-y");
if (nodeOffsetX && nodeOffsetY) {
mx += parseInt(nodeOffsetX);
my += parseInt(nodeOffsetY);
}
let props = {};
let rawNodeProps = event.dataTransfer.getData("data/node-props");
if (rawNodeProps) {
try {
props = JSON.parse(rawNodeProps);
} catch (e) { }
}
const pos = this.state.projectScreenToWorld(mx, my);
this.graph.registry.load([nodeId]).then(() => {
this.graph.createNode({
type: nodeId,
props,
position: pos,
});
});
} else if (event.dataTransfer.files.length) {
const file = event.dataTransfer.files[0];
if (file.type === "application/wasm") {
const reader = new FileReader();
reader.onload = async (e) => {
const buffer = e.target?.result;
if (buffer?.constructor === ArrayBuffer) {
const nodeType = await this.graph.registry.register(buffer);
this.graph.createNode({
type: nodeType.id,
props: {},
position: this.state.projectScreenToWorld(mx, my),
});
}
};
reader.readAsArrayBuffer(file);
} else if (file.type === "application/json") {
const reader = new FileReader();
reader.onload = (e) => {
const buffer = e.target?.result as ArrayBuffer;
if (buffer) {
const state = GraphSchema.parse(JSON.parse(buffer.toString()));
this.graph.load(state);
}
};
reader.readAsText(file);
}
}
}
handleWindowMouseUp(event: MouseEvent) {
this.edgeInteractionManager.handleMouseUp();
handleMouseLeave() {
this.state.isDragging = false;
this.state.isPanning = false;
}
handleDragEnter(e: DragEvent) {
e.preventDefault();
this.state.isDragging = true;
this.state.isPanning = false;
}
handleDragOver(e: DragEvent) {
e.preventDefault();
this.state.isDragging = true;
this.state.isPanning = false;
}
handleDragEnd(e: DragEvent) {
e.preventDefault();
this.state.isDragging = true;
this.state.isPanning = false;
}
getEventListenerProps() {
return {
ondragenter: (ev: DragEvent) => this.handleDragEnter(ev),
ondragover: (ev: DragEvent) => this.handleDragOver(ev),
ondragexit: (ev: DragEvent) => this.handleDragEnd(ev),
ondrop: (ev: DragEvent) => this.handleFileDrop(ev),
onmouseleave: () => this.handleMouseLeave(),
}
}
}
export class MouseEventManager {
constructor(
private graph: GraphManager,
private state: GraphState
) { }
handleMouseUp(event: MouseEvent) {
this.state.isPanning = false;
if (!this.state.mouseDown) return;
@@ -42,23 +145,25 @@ export class MouseEventManager {
const snapLevel = this.state.getSnapLevel();
activeNode.position[0] = snapPointToGrid(
activeNode?.state?.x ?? activeNode.position[0],
5 / snapLevel
5 / snapLevel,
);
activeNode.position[1] = snapPointToGrid(
activeNode?.state?.y ?? activeNode.position[1],
5 / snapLevel
5 / snapLevel,
);
} else {
activeNode.position[0] = activeNode?.state?.x ?? activeNode.position[0];
activeNode.position[1] = activeNode?.state?.y ?? activeNode.position[1];
}
const nodes = [
...[...(this.state.selectedNodes?.values() || [])].map((id) => this.graph.getNode(id))
...[...(this.state.selectedNodes?.values() || [])].map((id) =>
this.graph.getNode(id),
),
] as NodeInstance[];
const vec = [
activeNode.position[0] - (activeNode?.state.x || 0),
activeNode.position[1] - (activeNode?.state.y || 0)
activeNode.position[1] - (activeNode?.state.y || 0),
];
for (const node of nodes) {
@@ -74,9 +179,9 @@ export class MouseEventManager {
animate(500, (a: number) => {
for (const node of nodes) {
if (
node?.state
&& node.state['x'] !== undefined
&& node.state['y'] !== undefined
node?.state &&
node.state["x"] !== undefined &&
node.state["y"] !== undefined
) {
node.state.x = lerp(node.state.x, node.position[0], a);
node.state.y = lerp(node.state.y, node.position[1], a);
@@ -90,24 +195,24 @@ export class MouseEventManager {
this.graph.save();
} else if (this.state.hoveredSocket && this.state.activeSocket) {
if (
typeof this.state.hoveredSocket.index === 'number'
&& typeof this.state.activeSocket.index === 'string'
typeof this.state.hoveredSocket.index === "number" &&
typeof this.state.activeSocket.index === "string"
) {
this.graph.createEdge(
this.state.hoveredSocket.node,
this.state.hoveredSocket.index || 0,
this.state.activeSocket.node,
this.state.activeSocket.index
this.state.activeSocket.index,
);
} else if (
typeof this.state.activeSocket.index == 'number'
&& typeof this.state.hoveredSocket.index === 'string'
typeof this.state.activeSocket.index == "number" &&
typeof this.state.hoveredSocket.index === "string"
) {
this.graph.createEdge(
this.state.activeSocket.node,
this.state.activeSocket.index || 0,
this.state.hoveredSocket.node,
this.state.hoveredSocket.index
this.state.hoveredSocket.index,
);
}
this.graph.save();
@@ -115,18 +220,18 @@ export class MouseEventManager {
// Handle automatic adding of nodes on ctrl+mouseUp
this.state.edgeEndPosition = [
this.state.mousePosition[0],
this.state.mousePosition[1]
this.state.mousePosition[1],
];
if (typeof this.state.activeSocket.index === 'number') {
if (typeof this.state.activeSocket.index === "number") {
this.state.addMenuPosition = [
this.state.mousePosition[0],
this.state.mousePosition[1] - 25 / this.state.cameraPosition[2]
this.state.mousePosition[1] - 25 / this.state.cameraPosition[2],
];
} else {
this.state.addMenuPosition = [
this.state.mousePosition[0] - 155 / this.state.cameraPosition[2],
this.state.mousePosition[1] - 25 / this.state.cameraPosition[2]
this.state.mousePosition[1] - 25 / this.state.cameraPosition[2],
];
}
return;
@@ -134,11 +239,11 @@ export class MouseEventManager {
// check if camera moved
if (
clickedNodeId === -1
&& !this.state.boxSelection
&& this.state.cameraDown[0] === this.state.cameraPosition[0]
&& this.state.cameraDown[1] === this.state.cameraPosition[1]
&& this.state.isBodyFocused()
clickedNodeId === -1 &&
!this.state.boxSelection &&
this.state.cameraDown[0] === this.state.cameraPosition[0] &&
this.state.cameraDown[1] === this.state.cameraPosition[1] &&
this.state.isBodyFocused()
) {
this.state.activeNodeId = -1;
this.state.clearSelection();
@@ -152,33 +257,23 @@ export class MouseEventManager {
this.state.addMenuPosition = null;
}
handleContextMenu(event: MouseEvent) {
if (!this.state.addMenuPosition) {
event.preventDefault();
this.state.openNodePalette();
}
}
handleMouseDown(event: MouseEvent) {
// Right click
if (event.button === 2) {
return;
}
if (this.state.mouseDown) return;
this.state.edgeEndPosition = null;
const target = event.target as HTMLElement;
if (
target.nodeName !== 'CANVAS'
&& !target.classList.contains('node')
&& !target.classList.contains('content')
) {
return;
if (event.target instanceof HTMLElement) {
if (
event.target.nodeName !== "CANVAS" &&
!event.target.classList.contains("node") &&
!event.target.classList.contains("content")
) {
return;
}
}
const mx = event.clientX - this.state.rect.x;
const my = event.clientY - this.state.rect.y;
let mx = event.clientX - this.state.rect.x;
let my = event.clientY - this.state.rect.y;
this.state.mouseDown = [mx, my];
this.state.cameraDown[0] = this.state.cameraPosition[0];
@@ -189,15 +284,11 @@ export class MouseEventManager {
// if we clicked on a node
if (clickedNodeId !== -1) {
if (event.ctrlKey && event.shiftKey) {
this.state.tryConnectToDebugNode(clickedNodeId);
return;
}
if (this.state.activeNodeId === -1) {
this.state.activeNodeId = clickedNodeId;
// if the selected node is the same as the clicked node
} else if (this.state.activeNodeId === clickedNodeId) {
// $activeNodeId = -1;
//$activeNodeId = -1;
// if the clicked node is different from the selected node and secondary
} else if (event.ctrlKey) {
this.state.selectedNodes.add(this.state.activeNodeId);
@@ -221,7 +312,6 @@ export class MouseEventManager {
this.state.activeNodeId = clickedNodeId;
this.state.clearSelection();
}
this.edgeInteractionManager.handleMouseDown();
} else if (event.ctrlKey) {
this.state.boxSelection = true;
}
@@ -245,9 +335,10 @@ export class MouseEventManager {
this.state.edgeEndPosition = null;
}
handleWindowMouseMove(event: MouseEvent) {
const mx = event.clientX - this.state.rect.x;
const my = event.clientY - this.state.rect.y;
handleMouseMove(event: MouseEvent) {
let mx = event.clientX - this.state.rect.x;
let my = event.clientY - this.state.rect.y;
this.state.mousePosition = this.state.projectScreenToWorld(mx, my);
this.state.hoveredNodeId = this.state.getNodeIdFromEvent(event);
@@ -260,8 +351,8 @@ export class MouseEventManager {
let _socket;
for (const socket of this.state.possibleSockets) {
const dist = Math.sqrt(
(socket.position[0] - this.state.mousePosition[0]) ** 2
+ (socket.position[1] - this.state.mousePosition[1]) ** 2
(socket.position[0] - this.state.mousePosition[0]) ** 2 +
(socket.position[1] - this.state.mousePosition[1]) ** 2,
);
if (dist < smallestDist) {
smallestDist = dist;
@@ -269,7 +360,7 @@ export class MouseEventManager {
}
}
if (_socket && smallestDist < 1.5) {
if (_socket && smallestDist < 0.9) {
this.state.mousePosition = _socket.position;
this.state.hoveredSocket = _socket;
} else {
@@ -284,7 +375,7 @@ export class MouseEventManager {
event.stopPropagation();
const mouseD = this.state.projectScreenToWorld(
this.state.mouseDown[0],
this.state.mouseDown[1]
this.state.mouseDown[1],
);
const x1 = Math.min(mouseD[0], this.state.mousePosition[0]);
const x2 = Math.max(mouseD[0], this.state.mousePosition[0]);
@@ -294,7 +385,7 @@ export class MouseEventManager {
if (!node?.state) continue;
const x = node.position[0];
const y = node.position[1];
const height = getNodeHeight(node.state.type!);
const height = this.state.getNodeHeight(node.type);
if (x > x1 - 20 && x < x2 && y > y1 - height && y < y2) {
this.state.selectedNodes?.add(node.id);
} else {
@@ -306,7 +397,6 @@ export class MouseEventManager {
// here we are handling dragging of nodes
if (this.state.activeNodeId !== -1 && this.state.mouseDownNodeId !== -1) {
this.edgeInteractionManager.handleMouseMove();
const node = this.graph.getNode(this.state.activeNodeId);
if (!node || event.buttons !== 1) return;
@@ -315,8 +405,10 @@ export class MouseEventManager {
const oldX = node.state.downX || 0;
const oldY = node.state.downY || 0;
let newX = oldX + (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2];
let newY = oldY + (my - this.state.mouseDown[1]) / this.state.cameraPosition[2];
let newX =
oldX + (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2];
let newY =
oldY + (my - this.state.mouseDown[1]) / this.state.cameraPosition[2];
if (event.ctrlKey) {
const snapLevel = this.state.getSnapLevel();
@@ -356,19 +448,23 @@ export class MouseEventManager {
// here we are handling panning of camera
this.state.isPanning = true;
const newX = this.state.cameraDown[0]
- (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2];
const newY = this.state.cameraDown[1]
- (my - this.state.mouseDown[1]) / this.state.cameraPosition[2];
let newX =
this.state.cameraDown[0] -
(mx - this.state.mouseDown[0]) / this.state.cameraPosition[2];
let newY =
this.state.cameraDown[1] -
(my - this.state.mouseDown[1]) / this.state.cameraPosition[2];
this.state.cameraPosition[0] = newX;
this.state.cameraPosition[1] = newY;
}
handleMouseScroll(event: WheelEvent) {
const bodyIsFocused = document.activeElement === document.body
|| document.activeElement === this.state.wrapper
|| document?.activeElement?.id === 'graph';
const bodyIsFocused =
document.activeElement === document.body ||
document.activeElement === this.state.wrapper ||
document?.activeElement?.id === "graph";
if (!bodyIsFocused) return;
// Define zoom speed and clamp it between -1 and 1
@@ -383,20 +479,21 @@ export class MouseEventManager {
maxZoom,
isNegative
? this.state.cameraPosition[2] / delta
: this.state.cameraPosition[2] * delta
)
: this.state.cameraPosition[2] * delta,
),
);
// Calculate the ratio of the new zoom to the original zoom
const zoomRatio = newZoom / this.state.cameraPosition[2];
// Update camera position and zoom level
this.state.cameraPosition[0] = this.state.mousePosition[0]
- (this.state.mousePosition[0] - this.state.cameraPosition[0])
/ zoomRatio;
this.state.cameraPosition[1] = this.state.mousePosition[1]
- (this.state.mousePosition[1] - this.state.cameraPosition[1])
/ zoomRatio;
this.state.cameraPosition[2] = newZoom;
this.state.cameraPosition[0] = this.state.mousePosition[0] -
(this.state.mousePosition[0] - this.state.cameraPosition[0]) /
zoomRatio;
this.state.cameraPosition[1] = this.state.mousePosition[1] -
(this.state.mousePosition[1] - this.state.cameraPosition[1]) /
zoomRatio,
this.state.cameraPosition[2] = newZoom;
}
}

View File

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

View File

@@ -8,7 +8,7 @@ export function lerp(a: number, b: number, t: number) {
export function animate(
duration: number,
callback: (progress: number) => void | false
callback: (progress: number) => void | false,
) {
const start = performance.now();
const loop = (time: number) => {
@@ -33,53 +33,59 @@ export function createNodePath({
cornerBottom = 0,
leftBump = false,
rightBump = false,
aspectRatio = 1
aspectRatio = 1,
} = {}) {
const leftBumpTopY = y + height / 2;
const leftBumpBottomY = y - height / 2;
return `M0,${cornerTop}
${
cornerTop
? ` V${cornerTop}
cornerTop
? ` V${cornerTop}
Q0,0 ${cornerTop * aspectRatio},0
H${100 - cornerTop * aspectRatio}
Q100,0 100,${cornerTop}
`
: ` V0
: ` V0
H100
`
}
}
V${y - height / 2}
${
rightBump
? ` C${100 - depth},${y - height / 2} ${100 - depth},${y + height / 2} 100,${y + height / 2}`
: ` H100`
}
rightBump
? ` C${100 - depth},${y - height / 2} ${100 - depth},${y + height / 2} 100,${y + height / 2}`
: ` H100`
}
${
cornerBottom
? ` V${100 - cornerBottom}
cornerBottom
? ` V${100 - cornerBottom}
Q100,100 ${100 - cornerBottom * aspectRatio},100
H${cornerBottom * aspectRatio}
Q0,100 0,${100 - cornerBottom}
`
: `${leftBump ? `V100 H0` : `V100`}`
}
: `${leftBump ? `V100 H0` : `V100`}`
}
${
leftBump
? ` V${leftBumpTopY} C${depth},${leftBumpTopY} ${depth},${leftBumpBottomY} 0,${leftBumpBottomY}`
: ` H0`
}
Z`.replace(/\s+/g, ' ');
leftBump
? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${y - height / 2}`
: ` H0`
}
Z`.replace(/\s+/g, " ");
}
export const clone: <T>(v: T) => T = 'structedClone' in globalThis
? globalThis.structuredClone
: (obj) => JSON.parse(JSON.stringify(obj));
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 function withSubComponents<A, B extends Record<string, unknown>>(
export const clone: <T>(v: T) => T =
"structedClone" in globalThis
? globalThis.structuredClone
: (obj) => JSON.parse(JSON.stringify(obj));
export function withSubComponents<A, B extends Record<string, any>>(
component: A,
subcomponents: B
subcomponents: B,
): A & B {
Object.keys(subcomponents).forEach((key) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -87,27 +93,3 @@ export function withSubComponents<A, B extends Record<string, unknown>>(
});
return component as A & B;
}
export function distanceFromPointToSegment(
x1: number,
y1: number,
x2: number,
y2: number,
x0: number,
y0: number
): number {
const dx = x2 - x1;
const dy = y2 - y1;
if (dx === 0 && dy === 0) {
return Math.hypot(x0 - x1, y0 - y1);
}
const t = ((x0 - x1) * dx + (y0 - y1) * dy) / (dx * dx + dy * dy);
const clampedT = Math.max(0, Math.min(1, t));
const px = x1 + clampedT * dx;
const py = y1 + clampedT * dy;
return Math.hypot(x0 - px, y0 - py);
}

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> {
return v !== null && typeof v === 'object' && 'subscribe' in v && 'set' in v;
return v !== null && typeof v === "object" && "subscribe" in v && "set" in v;
}
const storeIds: Map<string, ReturnType<typeof createLocalStore>> = new Map();
const HAS_LOCALSTORAGE = 'localStorage' in globalThis;
const HAS_LOCALSTORAGE = "localStorage" in globalThis;
function createLocalStore<T>(key: string, initialValue: T | Writable<T>) {
let store: Writable<T>;
if (HAS_LOCALSTORAGE) {
@@ -35,15 +36,18 @@ function createLocalStore<T>(key: string, initialValue: T | Writable<T>) {
subscribe: store.subscribe,
set: store.set,
update: store.update
};
}
}
export default function localStore<T>(key: string, initialValue: T | Writable<T>): Writable<T> {
if (storeIds.has(key)) return storeIds.get(key) as Writable<T>;
const store = createLocalStore(key, initialValue);
const store = createLocalStore(key, initialValue)
storeIds.set(key, store);
return store;
return store
}

View File

@@ -1,71 +0,0 @@
import type { NodeDefinition, NodeInstance } from '@nodarium/types';
export function getParameterHeight(node: NodeDefinition, inputKey: string) {
const input = node.inputs?.[inputKey];
if (!input) {
return 0;
}
if (inputKey === 'seed') return 0;
if (!node.inputs) return 0;
if ('setting' in input) return 0;
if (input.hidden) return 0;
if (input.type === 'shape' && input.external !== true) {
return 200;
}
if (
input?.label !== '' && !input.external && input.type !== 'path'
&& input.type !== 'geometry'
) {
return 100;
}
return 50;
}
export function getSocketPosition(
node: NodeInstance,
index: string | number
): [number, number] {
if (typeof index === 'number') {
return [
(node?.state?.x ?? node.position[0]) + 20,
(node?.state?.y ?? node.position[1]) + 2.5 + 10 * index
];
} else {
let height = 5;
const nodeType = node.state.type!;
const inputs = nodeType.inputs || {};
for (const inputKey in inputs) {
const h = getParameterHeight(nodeType, inputKey) / 10;
if (inputKey === index) {
height += h / 2;
break;
}
height += h;
}
return [
node?.state?.x ?? node.position[0],
(node?.state?.y ?? node.position[1]) + height
];
}
}
const nodeHeightCache: Record<string, number> = {};
export function getNodeHeight(node: NodeDefinition) {
if (node.id in nodeHeightCache) {
return nodeHeightCache[node.id];
}
if (!node?.inputs) {
return 5;
}
let height = 5;
for (const key in node.inputs) {
const h = getParameterHeight(node, key) / 10;
height += h;
}
nodeHeightCache[node.id] = height;
return height;
}

View File

@@ -1,21 +1,21 @@
import type { Graph } from '@nodarium/types';
import { createLogger } from '@nodarium/utils';
import { create, type Delta } from 'jsondiffpatch';
import { clone } from './helpers/index.js';
import { create, type Delta } from "jsondiffpatch";
import type { Graph } from "@nodarium/types";
import { clone } from "./helpers/index.js";
import { createLogger } from "@nodarium/utils";
const diff = create({
objectHash: function(obj, index) {
objectHash: function (obj, index) {
if (obj === null) return obj;
if ('id' in obj) return obj.id as string;
if ('_id' in obj) return obj._id as string;
if ("id" in obj) return obj.id as string;
if ("_id" in obj) return obj._id as string;
if (Array.isArray(obj)) {
return obj.join('-');
return obj.join("-");
}
return '$$index:' + index;
}
return "$$index:" + index;
},
});
const log = createLogger('history');
const log = createLogger("history");
log.mute();
export class HistoryManager {
@@ -26,7 +26,7 @@ export class HistoryManager {
private opts = {
debounce: 400,
maxHistory: 100
maxHistory: 100,
};
constructor({ maxHistory = 100, debounce = 100 } = {}) {
@@ -40,12 +40,12 @@ export class HistoryManager {
if (!this.state) {
this.state = clone(state);
this.initialState = this.state;
log.log('initial state saved');
log.log("initial state saved");
} else {
const newState = state;
const delta = diff.diff(this.state, newState);
if (delta) {
log.log('saving state');
log.log("saving state");
// Add the delta to history
if (this.index < this.history.length - 1) {
// Clear the history after the current index if new changes are made
@@ -61,7 +61,7 @@ export class HistoryManager {
}
this.state = newState;
} else {
log.log('no changes');
log.log("no changes");
}
}
}
@@ -75,7 +75,7 @@ export class HistoryManager {
undo() {
if (this.index === -1 && this.initialState) {
log.log('reached start, loading initial state');
log.log("reached start, loading initial state");
return clone(this.initialState);
} else {
const delta = this.history[this.index];
@@ -95,7 +95,7 @@ export class HistoryManager {
this.state = nextState;
return clone(nextState);
} else {
log.log('reached end');
log.log("reached end");
}
}
}

View File

@@ -1,15 +1,16 @@
import { animate, lerp } from '$lib/helpers';
import type { createKeyMap } from '$lib/helpers/createKeyMap';
import { panelState } from '$lib/sidebar/PanelState.svelte';
import FileSaver from 'file-saver';
import type { GraphManager } from './graph-manager.svelte';
import type { GraphState } from './graph-state.svelte';
import { animate, lerp } from "$lib/helpers";
import type { createKeyMap } from "$lib/helpers/createKeyMap";
import FileSaver from "file-saver";
import type { GraphManager } from "./graph-manager.svelte";
import type { GraphState } from "./graph-state.svelte";
type Keymap = ReturnType<typeof createKeyMap>;
export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: GraphState) {
keymap.addShortcut({
key: 'l',
description: 'Select linked nodes',
key: "l",
description: "Select linked nodes",
callback: () => {
const activeNode = graph.getNode(graphState.activeNodeId);
if (activeNode) {
@@ -19,52 +20,56 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
graphState.selectedNodes.add(node.id);
}
}
}
},
});
keymap.addShortcut({
key: '?',
description: 'Toggle Help',
key: "?",
description: "Toggle Help",
callback: () => {
panelState.setActivePanel('shortcuts');
}
// TODO: fix this
// showHelp = !showHelp;
},
});
keymap.addShortcut({
key: 'c',
key: "c",
ctrl: true,
description: 'Copy active nodes',
callback: () => graphState.copyNodes()
description: "Copy active nodes",
callback: () => graphState.copyNodes(),
});
keymap.addShortcut({
key: 'v',
key: "v",
ctrl: true,
description: 'Paste nodes',
callback: () => graphState.pasteNodes()
description: "Paste nodes",
callback: () => graphState.pasteNodes(),
});
keymap.addShortcut({
key: 'Escape',
description: 'Deselect nodes',
key: "Escape",
description: "Deselect nodes",
callback: () => {
graphState.activeNodeId = -1;
graphState.clearSelection();
graphState.edgeEndPosition = null;
(document.activeElement as HTMLElement)?.blur();
}
},
});
keymap.addShortcut({
key: 'A',
key: "A",
shift: true,
description: 'Add new Node',
callback: () => graphState.openNodePalette()
description: "Add new Node",
callback: () => {
graphState.addMenuPosition = [graphState.mousePosition[0], graphState.mousePosition[1]];
},
});
keymap.addShortcut({
key: '.',
description: 'Center camera',
key: ".",
description: "Center camera",
callback: () => {
if (!graphState.isBodyFocused()) return;
@@ -85,67 +90,67 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
animate(500, (a: number) => {
graphState.cameraPosition[0] = lerp(camX, average[0], ease(a));
graphState.cameraPosition[1] = lerp(camY, average[1], ease(a));
graphState.cameraPosition[2] = lerp(camZ, 2, ease(a));
graphState.cameraPosition[2] = lerp(camZ, 2, ease(a))
if (graphState.mouseDown) return false;
});
}
},
});
keymap.addShortcut({
key: 'a',
key: "a",
ctrl: true,
preventDefault: true,
description: 'Select all nodes',
description: "Select all nodes",
callback: () => {
if (!graphState.isBodyFocused()) return;
for (const node of graph.nodes.keys()) {
graphState.selectedNodes.add(node);
}
}
},
});
keymap.addShortcut({
key: 'z',
key: "z",
ctrl: true,
description: 'Undo',
description: "Undo",
callback: () => {
if (!graphState.isBodyFocused()) return;
graph.undo();
for (const node of graph.nodes.values()) {
graphState.updateNodePosition(node);
}
}
},
});
keymap.addShortcut({
key: 'y',
key: "y",
ctrl: true,
description: 'Redo',
description: "Redo",
callback: () => {
graph.redo();
for (const node of graph.nodes.values()) {
graphState.updateNodePosition(node);
}
}
},
});
keymap.addShortcut({
key: 's',
key: "s",
ctrl: true,
description: 'Save',
description: "Save",
preventDefault: true,
callback: () => {
const state = graph.serialize();
const blob = new Blob([JSON.stringify(state)], {
type: 'application/json;charset=utf-8'
type: "application/json;charset=utf-8",
});
FileSaver.saveAs(blob, 'nodarium-graph.json');
}
FileSaver.saveAs(blob, "nodarium-graph.json");
},
});
keymap.addShortcut({
key: ['Delete', 'Backspace', 'x'],
description: 'Delete selected nodes',
key: ["Delete", "Backspace", "x"],
description: "Delete selected nodes",
callback: (event) => {
if (!graphState.isBodyFocused()) return;
graph.startUndoGroup();
@@ -166,18 +171,20 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
graphState.clearSelection();
}
graph.saveUndoGroup();
}
},
});
keymap.addShortcut({
key: 'f',
description: 'Smart Connect Nodes',
key: "f",
description: "Smart Connect Nodes",
callback: () => {
const nodes = [...graphState.selectedNodes.values()]
.map((g) => graph.getNode(g))
.filter((n) => !!n);
const edge = graph.smartConnect(nodes[0], nodes[1]);
if (!edge) graph.smartConnect(nodes[1], nodes[0]);
}
},
});
}

View File

@@ -1,88 +1,56 @@
varying vec2 vUv;
uniform float uWidth;
uniform float uHeight;
uniform float uZoom;
uniform vec3 uColorDark;
uniform vec3 uColorBright;
uniform vec3 uStrokeColor;
const float uHeaderHeight = 5.0;
uniform float uSectionHeights[16];
uniform int uNumSections;
uniform vec3 uStrokeColor;
uniform float uStrokeWidth;
float msign(in float x) { return (x < 0.0) ? -1.0 : 1.0; }
float sdCircle(vec2 p, float r) { return length(p) - r; }
vec4 roundedBoxSDF( in vec2 p, in vec2 b, in float r, in float s) {
vec2 q = abs(p) - b + r;
float l = b.x + b.y + 1.570796 * r;
float k1 = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
float k2 = ((q.x > 0.0) ? atan(q.y, q.x) : 1.570796);
float k3 = 3.0 + 2.0 * msign(min(p.x, -p.y)) - msign(p.x);
float k4 = msign(p.x * p.y);
float k5 = r * k2 + max(-q.x, 0.0);
float ra = s * round(k1 / s);
float l2 = l + 1.570796 * ra;
return vec4(k1 - ra, k3 * l2 + k4 * (b.y + ((q.y > 0.0) ? k5 + k2 * ra : q.y)), 4.0 * l2, k1);
}
void main(){
float strokeWidth = mix(2.0, 0.5, uZoom);
float borderRadius = 0.5;
float dentRadius = 0.8;
float y = (1.0 - vUv.y) * uHeight;
float y = (1.0-vUv.y) * uHeight;
float x = vUv.x * uWidth;
vec2 size = vec2(uWidth, uHeight);
vec2 uvCenter = (vUv - 0.5) * 2.0;
vec2 uv = (vUv - 0.5) * 2.0;
vec4 boxData = roundedBoxSDF(uvCenter * size, size, borderRadius * 2.0, 0.0);
float sceneSDF = boxData.w;
float u_border_radius = 0.4;
vec4 distance = roundedBoxSDF(uv * size, size, u_border_radius*2.0, 0.0);
vec2 headerDentPos = vec2(uWidth, uHeaderHeight * 0.5);
float headerDentDist = sdCircle(vec2(x, y) - headerDentPos, dentRadius);
sceneSDF = max(sceneSDF, -headerDentDist*2.0);
float currentYBoundary = uHeaderHeight;
float previousYBoundary = uHeaderHeight;
for (int i = 0; i < 16; i++) {
if (i >= uNumSections) break;
float sectionHeight = uSectionHeights[i];
currentYBoundary += sectionHeight;
float centerY = previousYBoundary + (sectionHeight * 0.5);
vec2 circlePos = vec2(0.0, centerY);
float circleDist = sdCircle(vec2(x, y) - circlePos, dentRadius);
sceneSDF = max(sceneSDF, -circleDist*2.0);
previousYBoundary = currentYBoundary;
}
if (sceneSDF > 0.05) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
return;
}
vec3 finalColor = (y < uHeaderHeight) ? uColorBright : uColorDark;
bool isDivider = false;
float dividerY = uHeaderHeight;
if (abs(y - dividerY) < strokeWidth * 0.25) isDivider = true;
for (int i = 0; i < 16; i++) {
if (i >= uNumSections - 1) break;
dividerY += uSectionHeights[i];
if (abs(y - dividerY) < strokeWidth * 0.25) isDivider = true;
}
if (sceneSDF > -strokeWidth || isDivider) {
gl_FragColor = vec4(uStrokeColor, 1.0);
} else {
gl_FragColor = vec4(finalColor, 1.0);
if (distance.w > 0.0 ) {
// outside
gl_FragColor = vec4(0.0,0.0,0.0, 0.0);
}else{
if (distance.w > -uStrokeWidth || mod(y+5.0, 10.0) < uStrokeWidth/2.0) {
// draw the outer stroke
gl_FragColor = vec4(uStrokeColor, 1.0);
}else if (y<5.0){
// draw the header
gl_FragColor = vec4(uColorBright, 1.0);
}else{
gl_FragColor = vec4(uColorDark, 1.0);
}
}
}

View File

@@ -1,48 +1,37 @@
<script lang="ts">
import { appSettings } from '$lib/settings/app-settings.svelte';
import type { NodeInstance } from '@nodarium/types';
import { T } from '@threlte/core';
import { type Mesh } from 'three';
import { getGraphState } from '../graph-state.svelte';
import { colors } from '../graph/colors.svelte';
import { getNodeHeight, getParameterHeight } from '../helpers/nodeHelpers';
import NodeFrag from './Node.frag';
import NodeVert from './Node.vert';
import NodeHtml from './NodeHTML.svelte';
import type { NodeInstance } from "@nodarium/types";
import { getGraphState } from "../graph-state.svelte";
import { T } from "@threlte/core";
import { type Mesh } from "three";
import NodeFrag from "./Node.frag";
import NodeVert from "./Node.vert";
import NodeHtml from "./NodeHTML.svelte";
import { colors } from "../graph/colors.svelte";
import { appSettings } from "$lib/settings/app-settings.svelte";
const graphState = getGraphState();
type Props = {
node: NodeInstance;
inView: boolean;
z: number;
};
let { node = $bindable(), inView }: Props = $props();
const nodeType = $derived(node.state.type!);
let { node = $bindable(), inView, z }: Props = $props();
const isActive = $derived(graphState.activeNodeId === node.id);
const isSelected = $derived(graphState.selectedNodes.has(node.id));
const strokeColor = $derived(
appSettings.value.theme
&& (isSelected
appSettings.value.theme &&
(isSelected
? colors.selected
: isActive
? colors.active
: colors.outline)
);
const sectionHeights = $derived(
Object
.keys(nodeType.inputs || {})
.map(key => getParameterHeight(nodeType, key) / 10)
.filter(b => !!b)
? colors.active
: colors.outline),
);
let meshRef: Mesh | undefined = $state();
const height = getNodeHeight(node.state.type!);
const zoom = $derived(graphState.cameraPosition[2]);
const height = graphState.getNodeHeight(node.type);
$effect(() => {
if (meshRef && !node.state?.mesh) {
@@ -50,10 +39,6 @@
graphState.updateNodePosition(node);
}
});
const zoomValue = $derived(
(Math.log(graphState.cameraPosition[2]) - Math.log(1)) / (Math.log(40) - Math.log(1))
);
// const zoomValue = (graphState.cameraPosition[2] - 1) / 39;
</script>
<T.Mesh
@@ -62,7 +47,7 @@
position.y={0.8}
rotation.x={-Math.PI / 2}
bind:ref={meshRef}
visible={inView && zoom < 7}
visible={inView && z < 7}
>
<T.PlaneGeometry args={[20, height]} radius={1} />
<T.ShaderMaterial
@@ -70,21 +55,16 @@
fragmentShader={NodeFrag}
transparent
uniforms={{
uColorBright: { value: colors['layer-2'] },
uColorDark: { value: colors['layer-1'] },
uColorBright: { value: colors["layer-2"] },
uColorDark: { value: colors["layer-1"] },
uStrokeColor: { value: colors.outline.clone() },
uSectionHeights: { value: [5, 10] },
uNumSections: { value: 2 },
uStrokeWidth: { value: 1.0 },
uWidth: { value: 20 },
uHeight: { value: 200 },
uZoom: { value: 1.0 }
uHeight: { value: height },
}}
uniforms.uZoom.value={zoomValue}
uniforms.uHeight.value={height}
uniforms.uSectionHeights.value={sectionHeights}
uniforms.uNumSections.value={sectionHeights.length}
uniforms.uStrokeColor.value={strokeColor}
uniforms.uStrokeColor.value={strokeColor.clone()}
uniforms.uStrokeWidth.value={(7 - z) / 3}
/>
</T.Mesh>
<NodeHtml bind:node {inView} {isActive} {isSelected} z={zoom} />
<NodeHtml bind:node {inView} {isActive} {isSelected} {z} />

View File

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

View File

@@ -1,9 +1,7 @@
<script lang="ts">
import { appSettings } from '$lib/settings/app-settings.svelte';
import type { NodeInstance, Socket } from '@nodarium/types';
import { getGraphState } from '../graph-state.svelte';
import { createNodePath } from '../helpers/index.js';
import { getSocketPosition } from '../helpers/nodeHelpers';
import { getGraphState } from "../graph-state.svelte";
import { createNodePath } from "../helpers/index.js";
import type { NodeInstance } from "@nodarium/types";
const graphState = getGraphState();
@@ -12,73 +10,47 @@
function handleMouseDown(event: MouseEvent) {
event.stopPropagation();
event.preventDefault();
if ('state' in node) {
if ("state" in node) {
graphState.setDownSocket?.({
node,
index: 0,
position: getSocketPosition?.(node, 0)
position: graphState.getSocketPosition?.(node, 0),
});
}
}
const cornerTop = 10;
const rightBump = $derived(!!node?.state?.type?.outputs?.length);
const rightBump = !!node?.state?.type?.outputs?.length;
const aspectRatio = 0.25;
const path = $derived(
createNodePath({
depth: 5.5,
height: 34,
y: 49,
cornerTop,
rightBump,
aspectRatio
})
);
const pathHover = $derived(
createNodePath({
depth: 7,
height: 40,
y: 49,
cornerTop,
rightBump,
aspectRatio
})
);
const socketId = $derived(`${node.id}-${0}`);
function getSocketType(s: Socket | null) {
if (!s) return 'unknown';
if (typeof s.index === 'string') {
return s.node.state.type?.inputs?.[s.index].type || 'unknown';
}
return s.node.state.type?.outputs?.[s.index] || 'unknown';
}
const socketType = $derived(getSocketType(graphState.activeSocket));
const hoverColor = $derived(graphState.colors.getColor(socketType));
const path = createNodePath({
depth: 5.5,
height: 34,
y: 49,
cornerTop,
rightBump,
aspectRatio,
});
const pathHover = createNodePath({
depth: 8.5,
height: 50,
y: 49,
cornerTop,
rightBump,
aspectRatio,
});
</script>
<div
class="wrapper"
data-node-id={node.id}
data-node-type={node.type}
style:--socket-color={hoverColor}
class:possible-socket={graphState?.possibleSocketIds.has(socketId)}
>
<div class="wrapper" data-node-id={node.id} data-node-type={node.type}>
<div class="content">
{#if appSettings.value.debug.advancedMode}
<span class="bg-white text-black! mr-2 px-1 rounded-sm opacity-30">{node.id}</span>
{/if}
{node.type.split('/').pop()}
{node.type.split("/").pop()}
</div>
<div
class="target"
class="click-target"
role="button"
tabindex="0"
onmousedown={handleMouseDown}
>
</div>
></div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
@@ -90,7 +62,8 @@
--hover-path: path("${pathHover}");
`}
>
<path vector-effect="non-scaling-stroke" stroke="white" stroke-width="0.1"></path>
<path vector-effect="non-scaling-stroke" stroke="white" stroke-width="0.1"
></path>
</svg>
</div>
@@ -101,20 +74,7 @@
height: 50px;
}
.possible-socket .target::before {
content: "";
position: absolute;
width: 30px;
height: 30px;
border-radius: 100%;
box-shadow: 0px 0px 10px var(--socket-color);
background-color: var(--socket-color);
outline: solid thin var(--socket-color);
opacity: 0.7;
z-index: -10;
}
.target {
.click-target {
position: absolute;
right: 0px;
top: 50%;
@@ -123,9 +83,11 @@
width: 30px;
z-index: 100;
border-radius: 50%;
/* background: red; */
/* opacity: 0.2; */
}
.target:hover + svg path {
.click-target:hover + svg path {
d: var(--hover-path);
}
@@ -145,13 +107,10 @@
transition:
d 0.3s ease,
fill 0.3s ease;
fill: var(--color-layer-2);
fill: var(--layer-2);
stroke: var(--stroke);
stroke-width: var(--stroke-width);
d: var(--path);
stroke-linejoin: round;
shape-rendering: geometricPrecision;
}
.content {

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { NodeInput, NodeInstance } from '@nodarium/types';
import { Input } from '@nodarium/ui';
import type { GraphManager } from '../graph-manager.svelte';
import type { NodeInstance, NodeInput } from "@nodarium/types";
import { Input } from "@nodarium/ui";
import type { GraphManager } from "../graph-manager.svelte";
type Props = {
node: NodeInstance;
@@ -16,39 +16,25 @@
input,
id,
elementId = `input-${Math.random().toString(36).substring(7)}`,
graph
graph,
}: Props = $props();
function getDefaultValue() {
if (node?.props?.[id] !== undefined) return node?.props?.[id] as number;
if ('value' in input && input?.value !== undefined) {
if ("value" in input && input?.value !== undefined)
return input?.value as number;
}
if (input.type === 'boolean') return 0;
if (input.type === 'float') return 0.5;
if (input.type === 'integer') return 0;
if (input.type === 'select') return 0;
if (input.type === "boolean") return 0;
if (input.type === "float") return 0.5;
if (input.type === "integer") return 0;
if (input.type === "select") return 0;
return 0;
}
let value = $state(structuredClone($state.snapshot(getDefaultValue())));
function diffArray(a: number[], b?: number[] | number) {
if (!Array.isArray(b)) return true;
if (Array.isArray(a) !== Array.isArray(b)) return true;
if (a.length !== b.length) return true;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return true;
}
return false;
}
let value = $state(getDefaultValue());
$effect(() => {
const a = $state.snapshot(value);
const b = $state.snapshot(node?.props?.[id]);
const isDiff = Array.isArray(a) ? diffArray(a, b) : a !== b;
if (value !== undefined && isDiff) {
node.props = { ...node.props, [id]: a };
if (value !== undefined && node?.props?.[id] !== value) {
node.props = { ...node.props, [id]: value };
if (graph) {
graph.save();
graph.execute();

View File

@@ -1,9 +1,8 @@
<script lang="ts">
import type { NodeInput, NodeInstance, Socket } from '@nodarium/types';
import { getGraphManager, getGraphState } from '../graph-state.svelte';
import { createNodePath } from '../helpers';
import { getParameterHeight, getSocketPosition } from '../helpers/nodeHelpers';
import NodeInputEl from './NodeInput.svelte';
import type { NodeInput, NodeInstance } from "@nodarium/types";
import { createNodePath } from "../helpers";
import NodeInputEl from "./NodeInput.svelte";
import { getGraphManager, getGraphState } from "../graph-state.svelte";
type Props = {
node: NodeInstance;
@@ -13,18 +12,17 @@
};
const graph = getGraphManager();
const graphState = getGraphState();
const graphId = graph?.id;
const elementId = `input-${Math.random().toString(36).substring(7)}`;
let { node = $bindable(), input, id, isLast }: Props = $props();
const nodeType = $derived(node.state.type!);
const inputType = node?.state?.type?.inputs?.[id]!;
const inputType = $derived(nodeType.inputs?.[id]);
const socketId = `${node.id}-${id}`;
const socketId = $derived(`${node.id}-${id}`);
const height = $derived(getParameterHeight(nodeType, id));
const graphState = getGraphState();
const graphId = graph?.id;
const elementId = `input-${Math.random().toString(36).substring(7)}`;
function handleMouseDown(ev: MouseEvent) {
ev.preventDefault();
@@ -32,83 +30,79 @@
graphState.setDownSocket({
node,
index: id,
position: getSocketPosition(node, id)
position: graphState.getSocketPosition?.(node, id),
});
}
const leftBump = $derived(nodeType.inputs?.[id].internal !== true);
const cornerBottom = $derived(isLast ? 5 : 0);
const leftBump = node.state?.type?.inputs?.[id].internal !== true;
const cornerBottom = isLast ? 5 : 0;
const aspectRatio = 0.5;
const path = $derived(
createNodePath({
depth: 6,
height: 2000 / height,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio
})
);
const pathHover = $derived(
createNodePath({
depth: 7,
height: 2200 / height,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio
})
);
function getSocketType(s: Socket | null) {
if (!s) return 'unknown';
if (typeof s.index === 'string') {
return s.node.state.type?.inputs?.[s.index].type || 'unknown';
}
return s.node.state.type?.outputs?.[s.index] || 'unknown';
}
const socketType = $derived(getSocketType(graphState.activeSocket));
const hoverColor = $derived(graphState.colors.getColor(socketType));
const path = createNodePath({
depth: 7,
height: 20,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio,
});
const pathDisabled = createNodePath({
depth: 6,
height: 18,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio,
});
const pathHover = createNodePath({
depth: 8,
height: 25,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio,
});
</script>
<div
class="wrapper"
data-node-type={node.type}
data-node-input={id}
style:height="{height}px"
style:--socket-color={hoverColor}
class:possible-socket={graphState?.possibleSocketIds.has(socketId)}
class:disabled={!graphState?.possibleSocketIds.has(socketId)}
>
{#key id && graphId}
<div class="content" class:disabled={graph?.inputSockets?.has(socketId)}>
{#if inputType?.label !== ''}
<label for={elementId} title={input.description}>{input.label || id}</label>
{#if inputType.label !== ""}
<label for={elementId}>{input.label || id}</label>
{/if}
{#if inputType?.external !== true}
{#if inputType.external !== true}
<NodeInputEl {graph} {elementId} bind:node {input} {id} />
{/if}
</div>
{#if node?.state?.type?.inputs?.[id]?.internal !== true}
<div data-node-socket class="large target"></div>
<div
data-node-socket
class="target"
class="small target"
onmousedown={handleMouseDown}
role="button"
tabindex="0"
>
</div>
></div>
{/if}
{/key}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
width="100"
height="100"
preserveAspectRatio="none"
style:--path={`path("${path}")`}
style:--hover-path={`path("${pathHover}")`}
style={`
--path: path("${path}");
--hover-path: path("${pathHover}");
--hover-path-disabled: path("${pathDisabled}");
`}
>
<path vector-effect="non-scaling-stroke"></path>
</svg>
@@ -118,43 +112,42 @@
.wrapper {
position: relative;
width: 100%;
height: 100px;
transform: translateY(-0.5px);
}
.target {
width: 30px;
height: 30px;
position: absolute;
border-radius: 50%;
top: 50%;
transform: translateY(-50%) translateX(-50%);
/* background: red; */
/* opacity: 0.1; */
}
.possible-socket .target::before {
content: "";
position: absolute;
.small.target {
width: 30px;
height: 30px;
border-radius: 100%;
box-shadow: 0px 0px 10px var(--socket-color);
background-color: var(--socket-color);
outline: solid thin var(--socket-color);
opacity: 0.5;
z-index: -10;
}
.target:hover ~ svg path{
d: var(--hover-path);
.large.target {
width: 60px;
height: 60px;
cursor: unset;
pointer-events: none;
}
:global(.hovering-sockets) .large.target {
pointer-events: all;
}
.content {
position: relative;
padding: 10px 20px;
display: flex;
flex-direction: column;
padding-inline: 20px;
height: 100%;
justify-content: center;
gap: 10px;
justify-content: space-around;
box-sizing: border-box;
}
@@ -170,21 +163,29 @@
}
svg path {
transition: d 0.3s ease, fill 0.3s ease;
fill: var(--color-layer-1);
transition:
d 0.3s ease,
fill 0.3s ease;
fill: var(--layer-1);
stroke: var(--stroke);
stroke-width: var(--stroke-width);
d: var(--path);
}
stroke-linejoin: round;
shape-rendering: geometricPrecision;
:global {
.hovering-sockets .large:hover ~ svg path {
d: var(--hover-path);
}
}
.content.disabled {
opacity: 0.2;
}
.content.disabled > * {
pointer-events: none;
}
.possible-socket svg path {
d: var(--hover-path);
.disabled svg path {
d: var(--hover-path-disabled) !important;
}
</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) {
const graph: Graph = {
id: Math.floor(Math.random() * 100000),
edges: [],
nodes: []
nodes: [],
};
const amount = width * height;
@@ -17,18 +18,19 @@ export function grid(width: number, height: number) {
id: i,
position: [x * 30, y * 40],
props: i == 0 ? { value: 0 } : { op_type: 0, a: 1, b: 0.05 },
type: i == 0 ? 'max/plantarium/float' : 'max/plantarium/math'
type: i == 0 ? "max/plantarium/float" : "max/plantarium/math",
});
graph.edges.push([i, 0, i + 1, i === amount - 1 ? 'input' : 'a']);
graph.edges.push([i, 0, i + 1, i === amount - 1 ? "input" : "a",]);
}
graph.nodes.push({
id: amount,
position: [width * 30, (height - 1) * 40],
type: 'max/plantarium/output',
props: {}
type: "max/plantarium/output",
props: {},
});
return graph;
}

View File

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

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

View File

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

View File

@@ -1,24 +1,22 @@
<script lang="ts">
import { getContext, type Snippet } from 'svelte';
import { getContext } from "svelte";
let index = $state(-1);
let index = -1;
let wrapper: HTMLDivElement;
const { children } = $props<{ children?: Snippet }>();
$: if (index === -1) {
index = getContext<() => number>("registerCell")();
}
$effect(() => {
if (index === -1) {
index = getContext<() => number>('registerCell')();
}
});
const sizes = getContext<{ value: string[] }>('sizes');
const sizes = getContext<{ value: string[] }>("sizes");
let downSizes: string[] = [];
let downWidth = 0;
let mouseDown = false;
let startX = 0;
function handleMouseDown(event: MouseEvent) {
downSizes = [...sizes.value];
mouseDown = true;
startX = event.clientX;
downWidth = wrapper.getBoundingClientRect().width;
@@ -33,8 +31,8 @@
</script>
<svelte:window
onmouseup={() => (mouseDown = false)}
onmousemove={handleMouseMove}
on:mouseup={() => (mouseDown = false)}
on:mousemove={handleMouseMove}
/>
{#if index > 0}
@@ -42,13 +40,12 @@
class="seperator"
role="button"
tabindex="0"
onmousedown={handleMouseDown}
>
</div>
on:mousedown={handleMouseDown}
></div>
{/if}
<div class="cell" bind:this={wrapper}>
{@render children?.()}
<slot />
</div>
<style>
@@ -62,7 +59,7 @@
cursor: ew-resize;
height: 100%;
width: 1px;
background: var(--color-outline);
background: var(--outline);
}
.seperator::before {
content: "";

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { withSubComponents } from '$lib/helpers';
import Cell from './Cell.svelte';
import Grid from './Grid.svelte';
import Row from './Row.svelte';
import { withSubComponents } from "$lib/helpers";
import Grid from "./Grid.svelte";
import Row from "./Row.svelte";
import Cell from "./Cell.svelte";
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 = {
key: string | string[];
shift?: boolean;
ctrl?: boolean;
alt?: boolean;
preventDefault?: boolean;
description?: string;
callback: (event: KeyboardEvent) => void;
};
function getShortcutId(shortcut: ShortCut) {
return `${shortcut.key}${shortcut.shift ? '+shift' : ''}${shortcut.ctrl ? '+ctrl' : ''}${
shortcut.alt ? '+alt' : ''
}`;
type Shortcut = {
key: string | string[],
shift?: boolean,
ctrl?: boolean,
alt?: boolean,
preventDefault?: boolean,
description?: string,
callback: (event: KeyboardEvent) => void
}
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])));
return {
handleKeyboardEvent: (event: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement;
if (activeElement?.tagName === 'INPUT' || activeElement?.tagName === 'TEXTAREA') return;
if (activeElement?.tagName === "INPUT" || activeElement?.tagName === "TEXTAREA") return;
const key = [...get(store).values()].find(k => {
if (Array.isArray(k.key) ? !k.key.includes(event.key) : k.key !== event.key) return false;
if ('shift' in k && k.shift !== event.shiftKey) return false;
if ('ctrl' in k && k.ctrl !== event.ctrlKey) return false;
if ('alt' in k && k.alt !== event.altKey) return false;
if ("shift" in k && k.shift !== event.shiftKey) return false;
if ("ctrl" in k && k.ctrl !== event.ctrlKey) return false;
if ("alt" in k && k.alt !== event.altKey) return false;
return true;
});
if (key && key.preventDefault) event.preventDefault();
key?.callback(event);
},
addShortcut: (shortcut: ShortCut) => {
addShortcut: (shortcut: Shortcut) => {
if (Array.isArray(shortcut.key)) {
for (const k of shortcut.key) {
store.update(shortcuts => {
@@ -53,5 +52,6 @@ export function createKeyMap(keys: ShortCut[]) {
}
},
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 {
callback(1);
}
};
}
requestAnimationFrame(loop);
}
@@ -30,11 +30,10 @@ export function createNodePath({
cornerBottom = 0,
leftBump = false,
rightBump = false,
aspectRatio = 1
aspectRatio = 1,
} = {}) {
return `M0,${cornerTop}
${
cornerTop
${cornerTop
? ` V${cornerTop}
Q0,0 ${cornerTop * aspectRatio},0
H${100 - cornerTop * aspectRatio}
@@ -43,45 +42,40 @@ export function createNodePath({
: ` V0
H100
`
}
}
V${y - height / 2}
${
rightBump
${rightBump
? ` C${100 - depth},${y - height / 2} ${100 - depth},${y + height / 2} 100,${y + height / 2}`
: ` H100`
}
${
cornerBottom
}
${cornerBottom
? ` V${100 - cornerBottom}
Q100,100 ${100 - cornerBottom * aspectRatio},100
H${cornerBottom * aspectRatio}
Q0,100 0,${100 - cornerBottom}
`
: `${leftBump ? `V100 H0` : `V100`}`
}
${
leftBump
? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${
y - height / 2
}`
}
${leftBump
? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${y - height / 2}`
: ` H0`
}
Z`.replace(/\s+/g, ' ');
}
Z`.replace(/\s+/g, " ");
}
export const debounce = (fn: () => void, ms = 300) => {
export const debounce = (fn: Function, ms = 300) => {
let timeoutId: ReturnType<typeof setTimeout>;
return function(this: unknown, ...args: unknown[]) {
return function (this: any, ...args: any[]) {
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
? globalThis.structuredClone
: (obj) => JSON.parse(JSON.stringify(obj));
export const clone: <T>(v: T) => T = "structedClone" in globalThis ? globalThis.structuredClone : (obj) => JSON.parse(JSON.stringify(obj));
export function withSubComponents<A, B extends Record<string, unknown>>(
export function withSubComponents<A, B extends Record<string, any>>(
component: A,
subcomponents: B
): A & B {
@@ -93,7 +87,7 @@ export function withSubComponents<A, B extends Record<string, unknown>>(
}
export function humanizeNumber(number: number): string {
const suffixes = ['', 'K', 'M', 'B', 'T'];
const suffixes = ["", "K", "M", "B", "T"];
if (number < 1000) {
return number.toString();
}
@@ -110,15 +104,11 @@ export function humanizeDuration(durationInMilliseconds: number) {
const millisecondsPerHour = 3600000;
const millisecondsPerDay = 86400000;
const days = Math.floor(durationInMilliseconds / millisecondsPerDay);
const hours = Math.floor((durationInMilliseconds % millisecondsPerDay) / millisecondsPerHour);
const minutes = Math.floor(
(durationInMilliseconds % millisecondsPerHour) / millisecondsPerMinute
);
const seconds = Math.floor(
(durationInMilliseconds % millisecondsPerMinute) / millisecondsPerSecond
);
const millis = durationInMilliseconds % millisecondsPerSecond;
let days = Math.floor(durationInMilliseconds / millisecondsPerDay);
let hours = Math.floor((durationInMilliseconds % millisecondsPerDay) / millisecondsPerHour);
let minutes = Math.floor((durationInMilliseconds % millisecondsPerHour) / millisecondsPerMinute);
let seconds = Math.floor((durationInMilliseconds % millisecondsPerMinute) / millisecondsPerSecond);
let millis = durationInMilliseconds % millisecondsPerSecond;
let durationString = '';
@@ -141,10 +131,32 @@ export function humanizeDuration(durationInMilliseconds: number) {
return durationString.trim();
}
export function debounceAsyncFunction<T extends (...args: never[]) => Promise<unknown>>(
asyncFn: T
): T {
// export function debounceAsyncFunction<T extends any[], R>(
// func: (...args: T) => Promise<R>
// ): (...args: T) => Promise<R> {
// let timeoutId: ReturnType<typeof setTimeout> | null = null;
// let lastPromise: Promise<R> | null = null;
// let lastReject: ((reason?: any) => void) | null = null;
//
// return (...args: T): Promise<R> => {
// if (timeoutId) {
// clearTimeout(timeoutId);
// if (lastReject) {
// lastReject(new Error("Debounced: Previous call was canceled."));
// }
// }
//
// return new Promise<R>((resolve, reject) => {
// lastReject = reject;
// timeoutId = setTimeout(() => {
// timeoutId = null;
// lastReject = null;
// lastPromise = func(...args).then(resolve, reject);
// }, 300); // Default debounce time is 300ms; you can make this configurable.
// });
// };
// }
export function debounceAsyncFunction<T extends (...args: any[]) => Promise<any>>(asyncFn: T): T {
let isRunning = false;
let latestArgs: Parameters<T> | null = null;
let resolveNext: (() => void) | null = null;
@@ -165,7 +177,7 @@ export function debounceAsyncFunction<T extends (...args: never[]) => Promise<un
try {
// Execute with the latest arguments
const result = await asyncFn(...latestArgs!);
return result as ReturnType<T>;
return result;
} finally {
// Allow the next execution
isRunning = false;
@@ -178,18 +190,48 @@ export function debounceAsyncFunction<T extends (...args: never[]) => Promise<un
}) as T;
}
export function withArgsChangeOnly<T extends unknown[], R>(
func: (...args: T) => R
): (...args: T) => R {
// export function debounceAsyncFunction<T extends any[], R>(func: (...args: T) => Promise<R>): (...args: T) => Promise<R> {
// let currentPromise: Promise<R> | null = null;
// let nextArgs: T | null = null;
// let resolveNext: ((result: R) => void) | null = null;
//
// const debouncedFunction = async (...args: T): Promise<R> => {
// if (currentPromise) {
// // Store the latest arguments and create a new promise to resolve them later
// nextArgs = args;
// return new Promise<R>((resolve) => {
// resolveNext = resolve;
// });
// } else {
// // Execute the function immediately
// try {
// currentPromise = func(...args);
// const result = await currentPromise;
// return result;
// } finally {
// currentPromise = null;
// // If there are stored arguments, call the function again with the latest arguments
// if (nextArgs) {
// const argsToUse = nextArgs;
// const resolver = resolveNext;
// nextArgs = null;
// resolveNext = null;
// resolver!(await debouncedFunction(...argsToUse));
// }
// }
// }
// };
//
// return debouncedFunction;
// }
export function withArgsChangeOnly<T extends any[], R>(func: (...args: T) => R): (...args: T) => R {
let lastArgs: T | undefined = undefined;
let lastResult: R;
return (...args: T): R => {
// Check if arguments are the same as last call
if (
lastArgs && args.length === lastArgs.length
&& args.every((val, index) => val === lastArgs?.[index])
) {
if (lastArgs && args.length === lastArgs.length && args.every((val, index) => val === lastArgs?.[index])) {
return lastResult; // Return cached result if arguments haven't changed
}
@@ -199,3 +241,4 @@ export function withArgsChangeOnly<T extends unknown[], R>(
return lastResult; // Return new result
};
}

View File

@@ -1,40 +1,8 @@
import { browser } from '$app/environment';
function mergeRecursive<T>(current: T, initial: T): T {
if (typeof initial === 'number') {
if (typeof current === 'number') return current;
return initial;
}
if (typeof initial === 'boolean') {
if (typeof current === 'boolean') return current;
return initial;
}
if (Array.isArray(initial)) {
if (Array.isArray(current)) return current;
return initial;
}
if (typeof initial === 'object' && initial) {
const merged = initial;
if (typeof current === 'object' && current) {
for (const key of Object.keys(initial)) {
if (key in current) {
// @ts-expect-error It's safe dont worry about it
merged[key] = mergeRecursive(current[key], initial[key]);
}
}
}
return merged;
}
return current;
}
import { browser } from "$app/environment";
export class LocalStore<T> {
value = $state<T>() as T;
key = '';
key = "";
constructor(key: string, value: T) {
this.key = key;
@@ -42,10 +10,7 @@ export class LocalStore<T> {
if (browser) {
const item = localStorage.getItem(key);
if (item) {
const storedValue = this.deserialize(item);
this.value = mergeRecursive(storedValue, value);
}
if (item) this.value = this.deserialize(item);
}
$effect.root(() => {

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> {
return v !== null && typeof v === 'object' && 'subscribe' in v && 'set' in v;
return v !== null && typeof v === "object" && "subscribe" in v && "set" in v;
}
const storeIds: Map<string, ReturnType<typeof createLocalStore>> = new Map();
const HAS_LOCALSTORAGE = 'localStorage' in globalThis;
const HAS_LOCALSTORAGE = "localStorage" in globalThis;
function createLocalStore<T>(key: string, initialValue: T | Writable<T>) {
let store: Writable<T>;
if (HAS_LOCALSTORAGE) {
@@ -35,15 +36,18 @@ function createLocalStore<T>(key: string, initialValue: T | Writable<T>) {
subscribe: store.subscribe,
set: store.set,
update: store.update
};
}
}
export default function localStore<T>(key: string, initialValue: T | Writable<T>): Writable<T> {
if (storeIds.has(key)) return storeIds.get(key) as Writable<T>;
const store = createLocalStore(key, initialValue);
const store = createLocalStore(key, initialValue)
storeIds.set(key, store);
return store;
return store
}

View File

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

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