Compare commits
165 Commits
v0.0.1
...
feat/group
| Author | SHA1 | Date | |
|---|---|---|---|
|
dab03753a2
|
|||
|
26c7e915ef
|
|||
|
a3a1f6af35
|
|||
|
4615489128
|
|||
|
b23ad01c74
|
|||
|
237d04b4f1
|
|||
|
5b8eabc32d
|
|||
|
7011c3653d
|
|||
|
059022e8a8
|
|||
|
e9dce2e79c
|
|||
|
fd1da58cd9
|
|||
|
b1418f6778
|
|||
|
12572742eb
|
|||
|
7aa9979e35
|
|||
|
fc35a68826
|
|||
|
aba6f03bcc
|
|||
|
2d6fd00fd1
|
|||
|
d231946e50
|
|||
|
e2f4a24f75
|
|||
|
58d39cd101
|
|||
|
7ebb1297ac
|
|||
|
23f65a1c63
|
|||
|
acdc582e95
|
|||
|
7a3e9eb893
|
|||
|
be82312ea0
|
|||
|
84f67e9c33
|
|||
|
491e345c2f
|
|||
|
ba501b211d
|
|||
|
7d76b9e1f7
|
|||
|
5d4e2e9280
|
|||
|
4de15b19c8
|
|||
|
168e6fcc19
|
|||
|
c0eb75d53c
|
|||
|
2ec9bfc3c9
|
|||
|
c97520617a
|
|||
|
6475790176
|
|||
|
580ec73465
|
|||
|
fd98d457a3
|
|||
|
f16ba2601f
|
|||
|
cc6b832f15
|
|||
|
dd5fd5bf17
|
|||
|
38d0fffcf4
|
|||
|
bce06da456
|
|||
|
af585d56ec
|
|||
|
0aa73a27c1
|
|||
|
c1ae70282c
|
|||
|
4c7b03dfb8
|
|||
|
144e8cc797
|
|||
| 12ff9c1518 | |||
| 8d3ffe84ab | |||
|
95ec93eead
|
|||
|
d39185efaf
|
|||
|
81580ccd8c
|
|||
|
bf6f632d27
|
|||
|
|
e098be6013
|
||
|
|
ec13850e1c
|
||
|
|
15e08a8163
|
||
|
|
48cee58ad3
|
||
|
|
3235cae904
|
||
|
|
3f440728fc
|
||
|
|
da09f8ba1e
|
||
|
|
ddc3b4ce35
|
||
|
|
2690fc8712
|
||
|
|
072ab9063b
|
||
|
|
e23cad254d
|
||
|
|
5b5c63c1a9
|
||
|
|
c9021f2383
|
||
| 9eecdd4fb8 | |||
|
|
7e71a41e52
|
||
|
|
07cd9e84eb
|
||
|
|
a31a49ad50
|
||
|
|
850d641a25
|
||
|
|
ee5ca81757
|
||
|
|
22a11832b8
|
||
|
|
b5ce5723fa
|
||
|
|
102130cc77
|
||
|
|
1668a2e6d5
|
||
|
|
b0af83004e | ||
|
|
51de3ced13
|
||
| 8d403ba803 | |||
|
|
6bb301153a
|
||
|
|
02eee5f9bf
|
||
|
|
4f48a519a9
|
||
|
|
97199ac20f
|
||
|
|
f36f0cb230
|
||
|
|
ed3d48e07f
|
||
|
|
c610d6c991
|
||
|
8865b9b032
|
|||
|
235ee5d979
|
|||
|
23a48572f3
|
|||
|
e89a46e146
|
|||
|
cefda41fcf
|
|||
|
21d0f0da5a
|
|||
|
46202451ba
|
|||
|
0f4239d179
|
|||
|
d9c9bb5234
|
|||
|
18802fdc10
|
|||
|
b1cbd23542
|
|||
|
33f10da396
|
|||
|
af5b3b23ba
|
|||
|
64d75b9686
|
|||
|
|
2e6466ceca
|
||
|
|
20d8e2abed
|
||
|
|
715e1d095b
|
||
|
|
07e2826f16
|
||
|
|
e0ad97b003
|
||
|
|
93df4a19ff
|
||
|
|
d661a4e4a9
|
||
|
|
c7f808ce2d
|
||
|
|
72d6cd6ea2
|
||
|
|
615f2d3c48
|
||
|
|
2fadb6802d
|
||
|
|
9271d3a7e4
|
||
|
|
13c83efdb9
|
||
|
|
e44b73bebf
|
||
|
979e9fd922
|
|||
|
544500e7fe
|
|||
|
aaebbc4bc0
|
|||
|
|
894ab70b79 | ||
|
f8a2a95bc1
|
|||
|
c9dd143916
|
|||
|
898dd49aee
|
|||
|
9fb69d760f
|
|||
|
bafbcca2b8
|
|||
|
8ad9e5535c
|
|||
|
|
43a3c54838 | ||
|
11eaeb719b
|
|||
|
74c2978cd1
|
|||
|
4fdc247904
|
|||
|
c3f8b4b5aa
|
|||
|
67591c0572
|
|||
|
de1f9d6ab6
|
|||
|
6acce72fb8
|
|||
|
cf8943b205
|
|||
|
9e03d36482
|
|||
|
fd7268d620
|
|||
|
6358c22a85
|
|||
|
655b6a18b2
|
|||
|
37b2bdc8bd
|
|||
|
94e01d4ea8
|
|||
|
35f5177884
|
|||
|
ac2c61f221
|
|||
|
ef3d46279f
|
|||
|
703da324fa
|
|||
|
1dae472253
|
|||
|
09fdfb88cd
|
|||
|
04b63cc7e2
|
|||
|
cb6a35606d
|
|||
|
9c9f3ba3b7
|
|||
|
08dda2b2cb
|
|||
|
059129a738
|
|||
|
437c9f4a25
|
|||
|
48bf447ce1
|
|||
|
548fa4f0a1
|
|||
|
|
642cca30ad | ||
|
|
419249aca3 | ||
|
c69cb94ac7
|
|||
|
|
4b652d885f | ||
|
381f784775
|
|||
| 91866b4e9a | |||
|
01f1568221
|
|||
|
3e8d2768b3
|
|||
|
16a832779a
|
|||
|
d582915842
|
|||
|
|
caaecd7a02 |
@@ -13,16 +13,12 @@
|
|||||||
"markdown": {},
|
"markdown": {},
|
||||||
"toml": {},
|
"toml": {},
|
||||||
"dockerfile": {},
|
"dockerfile": {},
|
||||||
"ruff": {},
|
|
||||||
"jupyter": {},
|
|
||||||
"malva": {},
|
|
||||||
"markup": {
|
"markup": {
|
||||||
// https://dprint.dev/plugins/markup_fmt/config/
|
// https://dprint.dev/plugins/markup_fmt/config/
|
||||||
"scriptIndent": true,
|
"scriptIndent": true,
|
||||||
"styleIndent": true,
|
"styleIndent": true,
|
||||||
},
|
},
|
||||||
"yaml": {},
|
"yaml": {},
|
||||||
"graphql": {},
|
|
||||||
"exec": {
|
"exec": {
|
||||||
"cwd": "${configDir}",
|
"cwd": "${configDir}",
|
||||||
"commands": [
|
"commands": [
|
||||||
@@ -46,20 +42,18 @@
|
|||||||
"**/*-lock.yaml",
|
"**/*-lock.yaml",
|
||||||
"**/yaml.lock",
|
"**/yaml.lock",
|
||||||
"**/.DS_Store",
|
"**/.DS_Store",
|
||||||
|
"**/.pnpm-store",
|
||||||
|
"**/.cargo",
|
||||||
|
"**/target",
|
||||||
],
|
],
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"https://plugins.dprint.dev/typescript-0.95.13.wasm",
|
"https://plugins.dprint.dev/typescript-0.95.15.wasm",
|
||||||
"https://plugins.dprint.dev/json-0.21.1.wasm",
|
"https://plugins.dprint.dev/json-0.21.1.wasm",
|
||||||
"https://plugins.dprint.dev/markdown-0.20.0.wasm",
|
"https://plugins.dprint.dev/markdown-0.21.1.wasm",
|
||||||
"https://plugins.dprint.dev/toml-0.7.0.wasm",
|
"https://plugins.dprint.dev/toml-0.7.0.wasm",
|
||||||
"https://plugins.dprint.dev/dockerfile-0.3.3.wasm",
|
"https://plugins.dprint.dev/dockerfile-0.3.3.wasm",
|
||||||
"https://plugins.dprint.dev/ruff-0.6.11.wasm",
|
|
||||||
"https://plugins.dprint.dev/jupyter-0.2.1.wasm",
|
|
||||||
"https://plugins.dprint.dev/g-plane/malva-v0.15.1.wasm",
|
|
||||||
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm",
|
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm",
|
||||||
"https://plugins.dprint.dev/g-plane/pretty_yaml-v0.5.1.wasm",
|
"https://plugins.dprint.dev/g-plane/pretty_yaml-v0.6.0.wasm",
|
||||||
"https://plugins.dprint.dev/g-plane/pretty_graphql-v0.2.3.wasm",
|
|
||||||
"https://plugins.dprint.dev/exec-0.6.0.json@a054130d458f124f9b5c91484833828950723a5af3f8ff2bd1523bd47b83b364",
|
"https://plugins.dprint.dev/exec-0.6.0.json@a054130d458f124f9b5c91484833828950723a5af3f8ff2bd1523bd47b83b364",
|
||||||
"https://plugins.dprint.dev/biome-0.11.10.wasm",
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
45
.gitea/scripts/build.sh
Executable file
45
.gitea/scripts/build.sh
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
mkdir -p app/static
|
||||||
|
|
||||||
|
cp CHANGELOG.md app/static/CHANGELOG.md
|
||||||
|
|
||||||
|
# Derive branch/tag info
|
||||||
|
REF_TYPE="${GITHUB_REF_TYPE:-branch}"
|
||||||
|
REF_NAME="${GITHUB_REF_NAME:-$(basename "$GITHUB_REF")}"
|
||||||
|
BRANCH="${GITHUB_HEAD_REF:-}"
|
||||||
|
if [[ -z "$BRANCH" && "$REF_TYPE" == "branch" ]]; then
|
||||||
|
BRANCH="$REF_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine last tag and commits since
|
||||||
|
LAST_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)"
|
||||||
|
if [[ -n "$LAST_TAG" ]]; then
|
||||||
|
COMMITS_SINCE_LAST_RELEASE="$(git rev-list --count "${LAST_TAG}..HEAD")"
|
||||||
|
else
|
||||||
|
COMMITS_SINCE_LAST_RELEASE="0"
|
||||||
|
fi
|
||||||
|
|
||||||
|
commit_message=$(git log -1 --pretty=%B | tr -d '\n' | sed 's/"/\\"/g')
|
||||||
|
|
||||||
|
cat >app/static/git.json <<EOF
|
||||||
|
{
|
||||||
|
"ref": "${GITHUB_REF:-}",
|
||||||
|
"ref_name": "${REF_NAME}",
|
||||||
|
"ref_type": "${REF_TYPE}",
|
||||||
|
"sha": "${GITHUB_SHA:-}",
|
||||||
|
"run_number": "${GITHUB_RUN_NUMBER:-}",
|
||||||
|
"event_name": "${GITHUB_EVENT_NAME:-}",
|
||||||
|
"workflow": "${GITHUB_WORKFLOW:-}",
|
||||||
|
"job": "${GITHUB_JOB:-}",
|
||||||
|
"commit_message": "${commit_message}",
|
||||||
|
"commit_timestamp": "$(git log -1 --pretty=%cI)",
|
||||||
|
"branch": "${BRANCH}",
|
||||||
|
"commits_since_last_release": "${COMMITS_SINCE_LAST_RELEASE}"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
cp -R packages/ui/build app/build/ui
|
||||||
@@ -5,16 +5,21 @@ TAG="$GITHUB_REF_NAME"
|
|||||||
VERSION=$(echo "$TAG" | sed 's/^v//')
|
VERSION=$(echo "$TAG" | sed 's/^v//')
|
||||||
DATE=$(date +%Y-%m-%d)
|
DATE=$(date +%Y-%m-%d)
|
||||||
|
|
||||||
echo "🚀 Creating release for $TAG (safe mode)"
|
echo "🚀 Creating release for $TAG"
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# 1. Extract release notes from annotated tag
|
# 1. Extract release notes from annotated tag
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
|
|
||||||
NOTES=$(git tag -l "$TAG" --format='%(contents)')
|
# Ensure the local git knows this is an annotated tag with metadata
|
||||||
|
git fetch origin "refs/tags/$TAG:refs/tags/$TAG" --force
|
||||||
|
|
||||||
if [ -z "$NOTES" ]; then
|
# %(contents) gets the whole message.
|
||||||
echo "❌ Tag message is empty"
|
# 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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -23,26 +28,40 @@ git checkout main
|
|||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# 2. Update all package.json versions
|
# 2. Update all package.json versions
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
|
|
||||||
echo "🔧 Updating package.json versions to $VERSION"
|
echo "🔧 Updating package.json versions to $VERSION"
|
||||||
|
find . -name package.json ! -path "*/node_modules/*" | while read -r file; do
|
||||||
find . -name package.json ! -path "*/node_modules/*" | while read file; do
|
|
||||||
tmp_file="$file.tmp"
|
tmp_file="$file.tmp"
|
||||||
jq --arg v "$VERSION" '.version = $v' "$file" >"$tmp_file"
|
jq --arg v "$VERSION" '.version = $v' "$file" >"$tmp_file"
|
||||||
mv "$tmp_file" "$file"
|
mv "$tmp_file" "$file"
|
||||||
done
|
done
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# 3. Update CHANGELOG.md (prepend)
|
# 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"
|
tmp_changelog="CHANGELOG.tmp"
|
||||||
{
|
{
|
||||||
echo "## $TAG ($DATE)"
|
echo "# $TAG ($DATE)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "$NOTES"
|
echo "$NOTES"
|
||||||
echo ""
|
echo ""
|
||||||
echo "---"
|
if [ -n "$COMMITS" ]; then
|
||||||
|
echo "---"
|
||||||
|
echo ""
|
||||||
|
echo "$COMMITS"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
if [ -f CHANGELOG.md ]; then
|
if [ -f CHANGELOG.md ]; then
|
||||||
cat CHANGELOG.md
|
cat CHANGELOG.md
|
||||||
@@ -51,27 +70,33 @@ tmp_changelog="CHANGELOG.tmp"
|
|||||||
|
|
||||||
mv "$tmp_changelog" CHANGELOG.md
|
mv "$tmp_changelog" CHANGELOG.md
|
||||||
|
|
||||||
|
pnpm exec dprint fmt CHANGELOG.md
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# 4. Create release commit
|
# 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)
|
||||||
|
|
||||||
git config user.name "release-bot"
|
export GPG_TTY=$(tty)
|
||||||
git config user.email "release-bot@ci"
|
echo "allow-loopback-pinentry" >>~/.gnupg/gpg-agent.conf
|
||||||
|
gpg-connect-agent reloadagent /bye
|
||||||
|
|
||||||
git add CHANGELOG.md $(find . -name package.json ! -path "*/node_modules/*")
|
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')
|
||||||
|
|
||||||
# Skip commit if nothing changed
|
|
||||||
if git diff --cached --quiet; then
|
if git diff --cached --quiet; then
|
||||||
echo "No changes to commit for release $TAG"
|
echo "No changes to commit for release $TAG"
|
||||||
exit 0
|
else
|
||||||
|
git commit -m "chore(release): $TAG"
|
||||||
|
git push origin main
|
||||||
fi
|
fi
|
||||||
|
|
||||||
git commit -m "chore(release): $TAG"
|
echo "✅ Release process for $TAG complete"
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
# 5. Push changes
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
|
|
||||||
git push origin main
|
|
||||||
|
|
||||||
echo "✅ Release commit for $TAG created successfully (tag untouched)"
|
|
||||||
|
|||||||
43
.gitea/scripts/deploy-files.sh
Executable file
43
.gitea/scripts/deploy-files.sh
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "Configuring rclone"
|
||||||
|
|
||||||
|
KEY_FILE="$(mktemp)"
|
||||||
|
echo "${SSH_PRIVATE_KEY}" >"${KEY_FILE}"
|
||||||
|
chmod 600 "${KEY_FILE}"
|
||||||
|
|
||||||
|
mkdir -p ~/.config/rclone
|
||||||
|
cat >~/.config/rclone/rclone.conf <<EOF
|
||||||
|
[sftp-remote]
|
||||||
|
type = sftp
|
||||||
|
host = ${SSH_HOST}
|
||||||
|
user = ${SSH_USER}
|
||||||
|
port = ${SSH_PORT}
|
||||||
|
key_file = ${KEY_FILE}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
|
||||||
|
TARGET_DIR="${REMOTE_DIR}"
|
||||||
|
elif [[ "${GITHUB_EVENT_NAME:-}" == "pull_request" ]]; then
|
||||||
|
SAFE_PR_NAME="${GITHUB_HEAD_REF//\//-}"
|
||||||
|
TARGET_DIR="${REMOTE_DIR}_${SAFE_PR_NAME}"
|
||||||
|
elif [[ "${GITHUB_REF_NAME:-}" == "main" ]]; then
|
||||||
|
TARGET_DIR="${REMOTE_DIR}_main"
|
||||||
|
else
|
||||||
|
SAFE_REF="${GITHUB_REF_NAME//\//-}"
|
||||||
|
TARGET_DIR="${REMOTE_DIR}_${SAFE_REF}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deploying to ${TARGET_DIR}"
|
||||||
|
|
||||||
|
rclone sync \
|
||||||
|
--update \
|
||||||
|
--verbose \
|
||||||
|
--progress \
|
||||||
|
--exclude _astro/** \
|
||||||
|
--stats 2s \
|
||||||
|
--stats-one-line \
|
||||||
|
--transfers 4 \
|
||||||
|
./app/build/ \
|
||||||
|
"sftp-remote:${TARGET_DIR}"
|
||||||
87
.gitea/workflows/benchmark.yaml
Normal file
87
.gitea/workflows/benchmark.yaml
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
name: 📊 Benchmark the Runtime
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["*"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["*"]
|
||||||
|
|
||||||
|
env:
|
||||||
|
PNPM_CACHE_FOLDER: .pnpm-store
|
||||||
|
CARGO_HOME: .cargo
|
||||||
|
CARGO_TARGET_DIR: target
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📑 Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
|
- name: 💾 Setup pnpm Cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ env.PNPM_CACHE_FOLDER }}
|
||||||
|
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-
|
||||||
|
|
||||||
|
- name: 🦀 Cache Cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: 📦 Install Dependencies
|
||||||
|
run: pnpm install --frozen-lockfile --store-dir ${{ env.PNPM_CACHE_FOLDER }}
|
||||||
|
|
||||||
|
- name: 🛠️Build Nodes
|
||||||
|
run: pnpm build:nodes
|
||||||
|
|
||||||
|
- name: 🏃 Execute Runtime
|
||||||
|
run: pnpm run --filter @nodarium/app bench
|
||||||
|
|
||||||
|
- name: 🔑 Setup SSH key
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.GIT_SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -p 2222 -H git.max-richter.dev >> ~/.ssh/known_hosts
|
||||||
|
ssh -vvv -p 2222 -i ~/.ssh/id_ed25519 -T git@git.max-richter.dev
|
||||||
|
|
||||||
|
- name: 📤 Push Results
|
||||||
|
env:
|
||||||
|
BENCH_REPO: "git@git.max-richter.dev:max/nodarium-benchmarks.git"
|
||||||
|
run: |
|
||||||
|
git config --global user.name "nodarium-bot"
|
||||||
|
git config --global user.email "nodarium-bot@max-richter.dev"
|
||||||
|
|
||||||
|
# 2. Clone the benchmarks repo into a temp folder
|
||||||
|
git config --global core.sshCommand "ssh -vv -p 2222 -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes"
|
||||||
|
git clone git@git.max-richter.dev:max/nodarium-benchmarks.git target_bench_repo
|
||||||
|
|
||||||
|
# 3. Create a directory structure based on the branch
|
||||||
|
# This allows the UI to "switch between branches"
|
||||||
|
BRANCH_NAME="${{ github.ref_name }}"
|
||||||
|
DEST_DIR="target_bench_repo/data/$BRANCH_NAME/$(date +%s)"
|
||||||
|
mkdir -p "$DEST_DIR"
|
||||||
|
|
||||||
|
# 4. Copy the new results
|
||||||
|
# Assuming your bench tool outputs a file named 'results.json'
|
||||||
|
cp app/benchmark/out/*.json "$DEST_DIR/"
|
||||||
|
|
||||||
|
# 5. Commit and Push
|
||||||
|
cd target_bench_repo
|
||||||
|
git add .
|
||||||
|
git commit -m "Update benchmarks for $BRANCH_NAME: ${{ github.sha }}"
|
||||||
|
git push origin main
|
||||||
41
.gitea/workflows/build-ci-image.yaml
Normal file
41
.gitea/workflows/build-ci-image.yaml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Build & Push CI Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "Dockerfile.ci"
|
||||||
|
- ".gitea/workflows/build-ci-image.yaml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker BuildX
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
with:
|
||||||
|
config-inline: |
|
||||||
|
[registry."git.max-richter.dev"]
|
||||||
|
https = true
|
||||||
|
insecure = false
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.max-richter.dev
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and Push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile.ci
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
git.max-richter.dev/${{ gitea.repository }}-ci:latest
|
||||||
|
git.max-richter.dev/${{ gitea.repository }}-ci:${{ gitea.sha }}
|
||||||
@@ -1,24 +1,28 @@
|
|||||||
name: 🚀 Release
|
name: 🚀 Lint & Test & Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["*"]
|
branches: ["*"]
|
||||||
tags: ["*"]
|
tags: ["*"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["*"]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PNPM_CACHE_FOLDER: ~/.pnpm-store
|
PNPM_CACHE_FOLDER: .pnpm-store
|
||||||
|
CARGO_HOME: .cargo
|
||||||
|
CARGO_TARGET_DIR: target
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: jimfx/nodes:latest
|
container: git.max-richter.dev/max/nodarium-ci:a56e8f445edb6064ae7a7b3b783fb7445f1b4e69
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📑 Checkout Code
|
- name: 📑 Checkout Code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
- name: 💾 Setup pnpm Cache
|
- name: 💾 Setup pnpm Cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -28,31 +32,52 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-pnpm-
|
${{ 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
|
- name: 📦 Install Dependencies
|
||||||
run: pnpm install --frozen-lockfile --store-dir ${{ env.PNPM_CACHE_FOLDER }}
|
run: pnpm install --frozen-lockfile --store-dir ${{ env.PNPM_CACHE_FOLDER }}
|
||||||
|
|
||||||
- name: 🧹 Lint
|
- name: 🧹 Quality Control
|
||||||
run: pnpm lint
|
run: |
|
||||||
|
pnpm lint
|
||||||
- name: 🎨 Format Check
|
pnpm format:check
|
||||||
run: pnpm format:check
|
pnpm check
|
||||||
|
pnpm build
|
||||||
- name: 🧬 Type Check
|
xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" pnpm test
|
||||||
run: pnpm check
|
|
||||||
|
|
||||||
- name: 🛠️ Build
|
|
||||||
run: pnpm build:deploy
|
|
||||||
|
|
||||||
- name: 🚀 Create Release Commit
|
- name: 🚀 Create Release Commit
|
||||||
if: github.ref_type == 'tag'
|
if: gitea.ref_type == 'tag'
|
||||||
run: ./.gitea/scripts/create-release.sh
|
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
|
- name: 🏷️ Create Gitea Release
|
||||||
if: github.ref_type == 'tag'
|
if: gitea.ref_type == 'tag'
|
||||||
uses: akkuman/gitea-release-action@v1
|
uses: akkuman/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ gitea.ref_name }}
|
||||||
release_name: Release ${{ github.ref_name }}
|
release_name: Release ${{ gitea.ref_name }}
|
||||||
body_path: CHANGELOG.md
|
body_path: CHANGELOG.md
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: 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 }}
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,3 +5,5 @@ node_modules/
|
|||||||
|
|
||||||
/target
|
/target
|
||||||
.direnv/
|
.direnv/
|
||||||
|
|
||||||
|
.pnpm-store/
|
||||||
|
|||||||
198
CHANGELOG.md
Normal file
198
CHANGELOG.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# v0.0.5 (2026-02-13)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Implement debug node with full runtime integration, wildcard (`*`) inputs, variable-height nodes and parameters, and a quick-connect shortcut.
|
||||||
|
- Add color-coded node sockets and edges to visually indicate data types.
|
||||||
|
- Recursively merge `localState` with the initial state to safely handle outdated settings stored in `localStorage` when the settings schema changes.
|
||||||
|
- Clamp the Add Menu to the viewport.
|
||||||
|
- Add application favicon.
|
||||||
|
- Consolidate all developer settings into a single **Advanced Mode** setting.
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Fix InputNumber arrow visibility in the light theme.
|
||||||
|
- Correct changelog formatting issues.
|
||||||
|
|
||||||
|
## Chores
|
||||||
|
|
||||||
|
- Add `pnpm qa` pre-commit command.
|
||||||
|
- Run linting and type checks before build in CI.
|
||||||
|
- Sign release commits with a PGP key.
|
||||||
|
- General formatting, lint/type cleanup, test snapshot updates, and `.gitignore` maintenance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [f16ba26](https://git.max-richter.dev/max/nodarium/commit/f16ba2601ff0e8f0f4454e24689499112a2a257a) fix(ci): still trying to get gpg to work
|
||||||
|
- [cc6b832](https://git.max-richter.dev/max/nodarium/commit/cc6b832f1576356e5453ee4289b02f854152ff9a) fix(ci): trying to get gpg to work
|
||||||
|
- [dd5fd5b](https://git.max-richter.dev/max/nodarium/commit/dd5fd5bf1715d371566bd40419b72ca05e63401e) fix(ci): better add updates to package.json
|
||||||
|
- [38d0fff](https://git.max-richter.dev/max/nodarium/commit/38d0fffcf4ca0a346857c3658ccefdfcdf16e217) chore: update ci image
|
||||||
|
- [bce06da](https://git.max-richter.dev/max/nodarium/commit/bce06da456e3c008851ac006033cfff256015a47) ci: add gpg-agent to ci image
|
||||||
|
- [af585d5](https://git.max-richter.dev/max/nodarium/commit/af585d56ec825662961c8796226ed9d8cb900795) feat: use new ci image with gpg
|
||||||
|
- [0aa73a2](https://git.max-richter.dev/max/nodarium/commit/0aa73a27c1f23bea177ecc66034f8e0384c29a8e) feat: install gpg in ci image
|
||||||
|
- [c1ae702](https://git.max-richter.dev/max/nodarium/commit/c1ae70282cb5d58527180614a80823d80ca478c5) feat: add color to sockets
|
||||||
|
- [4c7b03d](https://git.max-richter.dev/max/nodarium/commit/4c7b03dfb82174317d8ba01f4725af804201154d) feat: add gradient mesh line
|
||||||
|
- [144e8cc](https://git.max-richter.dev/max/nodarium/commit/144e8cc797cfcc5a7a1fd9a0a2098dc99afb6170) fix: correctly highlight possible outputs
|
||||||
|
- [12ff9c1](https://git.max-richter.dev/max/nodarium/commit/12ff9c151873d253ed2e54dcf56aa9c9c4716c7c) Merge pull request 'feat/debug-node' (#41) from feat/debug-node into main
|
||||||
|
- [8d3ffe8](https://git.max-richter.dev/max/nodarium/commit/8d3ffe84ab9ca9e6d6d28333752e34da878fd3ea) Merge branch 'main' into feat/debug-node
|
||||||
|
- [95ec93e](https://git.max-richter.dev/max/nodarium/commit/95ec93eeada9bf062e01e1e77b67b8f0343a51bf) feat: better handle ctrl+shift clicks and selections
|
||||||
|
- [d39185e](https://git.max-richter.dev/max/nodarium/commit/d39185efafc360f49ab9437c0bad1f64665df167) feat: add "pnpm qa" command to check before commit
|
||||||
|
- [81580cc](https://git.max-richter.dev/max/nodarium/commit/81580ccd8c1db30ce83433c4c4df84bd660d3460) fix: cleanup some type errors
|
||||||
|
- [bf6f632](https://git.max-richter.dev/max/nodarium/commit/bf6f632d2772c3da812d5864c401f17e1aa8666a) feat: add shortcut to quick connect to debug
|
||||||
|
- [e098be6](https://git.max-richter.dev/max/nodarium/commit/e098be60135f57cf863904a58489e032ed27e8b4) fix: also execute all nodes before debug node
|
||||||
|
- [ec13850](https://git.max-richter.dev/max/nodarium/commit/ec13850e1c0ca5846da614d25887ff492cf8be04) fix: make debug node work with runtime
|
||||||
|
- [15e08a8](https://git.max-richter.dev/max/nodarium/commit/15e08a816339bdf9de9ecb6a57a7defff42dbe8c) feat: implement debug node
|
||||||
|
- [48cee58](https://git.max-richter.dev/max/nodarium/commit/48cee58ad337c1c6c59a0eb55bf9b5ecd16b99d0) chore: update test snapshots
|
||||||
|
- [3235cae](https://git.max-richter.dev/max/nodarium/commit/3235cae9049e193c242b6091cee9f01e67ee850e) chore: fix lint and typecheck errors
|
||||||
|
- [3f44072](https://git.max-richter.dev/max/nodarium/commit/3f440728fc8a94d59022bb545f418be049a1f1ba) feat: implement variable height for node shader
|
||||||
|
- [da09f8b](https://git.max-richter.dev/max/nodarium/commit/da09f8ba1eda5ed347433d37064a3b4ab49e627e) refactor: move debug node into runtime
|
||||||
|
- [ddc3b4c](https://git.max-richter.dev/max/nodarium/commit/ddc3b4ce357ef1c1e8066c0a52151713d0b6ed95) feat: allow variable height node parameters
|
||||||
|
- [2690fc8](https://git.max-richter.dev/max/nodarium/commit/2690fc871291e73d3d028df9668e8fffb1e77476) chore: gitignore pnpm-store
|
||||||
|
- [072ab90](https://git.max-richter.dev/max/nodarium/commit/072ab9063ba56df0673020eb639548f3a8601e04) feat: add initial debug node
|
||||||
|
- [e23cad2](https://git.max-richter.dev/max/nodarium/commit/e23cad254d610e00f196b7fdb4532f36fd735a4b) feat: add "*" datatype for inputs for debug node
|
||||||
|
- [5b5c63c](https://git.max-richter.dev/max/nodarium/commit/5b5c63c1a9c4ef757382bd4452149dc9777693ff) fix(ui): make arrows on inputnumber visible on lighttheme
|
||||||
|
- [c9021f2](https://git.max-richter.dev/max/nodarium/commit/c9021f2383828f2e2b5594d125165bbc8f70b8e7) refactor: merge all dev settings into one setting
|
||||||
|
- [9eecdd4](https://git.max-richter.dev/max/nodarium/commit/9eecdd4fb85dc60b8196101050334e26732c9a34) Merge pull request 'feat: merge localState recursively with initial' (#38) from feat/debug-node into main
|
||||||
|
- [7e71a41](https://git.max-richter.dev/max/nodarium/commit/7e71a41e5229126d404f56598c624709961dbf3b) feat: merge localState recursively with initial
|
||||||
|
- [07cd9e8](https://git.max-richter.dev/max/nodarium/commit/07cd9e84eb51bc02b7fed39c36cf83caba175ad7) feat: clamp AddMenu to viewport
|
||||||
|
- [a31a49a](https://git.max-richter.dev/max/nodarium/commit/a31a49ad503d69f92f2491dd685729060ea49896) ci: lint and typecheck before build
|
||||||
|
- [850d641](https://git.max-richter.dev/max/nodarium/commit/850d641a25cd0c781478c58c117feaf085bdbc62) chore: pnpm format
|
||||||
|
- [ee5ca81](https://git.max-richter.dev/max/nodarium/commit/ee5ca817573b83cacfa3709e0ae88c6263bc39c1) ci: sign release commits with pgp key
|
||||||
|
- [22a1183](https://git.max-richter.dev/max/nodarium/commit/22a11832b861ae8b44e2d374b55d12937ecab247) fix(ci): correctly format changelog
|
||||||
|
- [b5ce572](https://git.max-richter.dev/max/nodarium/commit/b5ce5723fa4a35443df39a9096d0997f808f0b4f) chore: format favicon svg
|
||||||
|
- [102130c](https://git.max-richter.dev/max/nodarium/commit/102130cc7777ceebcdb3de8466c90cef5b380111) feat: add favicon
|
||||||
|
- [1668a2e](https://git.max-richter.dev/max/nodarium/commit/1668a2e6d59db071ab3da45204c2b7bfcd2150a2) chore: format changelog.md
|
||||||
|
|
||||||
|
# v0.0.4 (2026-02-10)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Added shape and leaf nodes, including rotation support.
|
||||||
|
- Added high-contrast light theme and improved overall node readability.
|
||||||
|
- Enhanced UI with dots background, clearer details, and consistent node coloring.
|
||||||
|
- Improved changelog display and parsing robustness.
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Fixed UI issues (backside rendering, missing types, linter errors).
|
||||||
|
- Improved CI handling of commit messages and changelog placement.
|
||||||
|
|
||||||
|
## Chores
|
||||||
|
|
||||||
|
- Simplified CI quality checks.
|
||||||
|
- Updated dprint linters.
|
||||||
|
- Refactored changelog code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [51de3ce](https://git.max-richter.dev/max/nodarium/commit/51de3ced133af07b9432e1137068ef43ddfecbc9) fix(ci): update changelog before building
|
||||||
|
- [8d403ba](https://git.max-richter.dev/max/nodarium/commit/8d403ba8039a05b687f050993a6afca7fb743e12) Merge pull request 'feat/shape-node' (#36) from feat/shape-node into main
|
||||||
|
- [6bb3011](https://git.max-richter.dev/max/nodarium/commit/6bb301153ac13c31511b6b28ae95c6e0d4c03e9e) Merge remote-tracking branch 'origin/main' into feat/shape-node
|
||||||
|
- [02eee5f](https://git.max-richter.dev/max/nodarium/commit/02eee5f9bf4b1bc813d5d28673c4d5d77b392a92) fix: disable macro logs in wasm
|
||||||
|
- [4f48a51](https://git.max-richter.dev/max/nodarium/commit/4f48a519a950123390530f1b6040e2430a767745) feat(nodes): add rotation to instance node
|
||||||
|
- [97199ac](https://git.max-richter.dev/max/nodarium/commit/97199ac20fb079d6c157962d1a998d63670d8797) feat(nodes): implement leaf node
|
||||||
|
- [f36f0cb](https://git.max-richter.dev/max/nodarium/commit/f36f0cb2305692c7be60889bcde7f91179e18b81) feat(ui): show circles only when hovering InputShape
|
||||||
|
- [ed3d48e](https://git.max-richter.dev/max/nodarium/commit/ed3d48e07fa6db84bbb24db6dbe044cbc36f049f) fix(runtime): correctly encode 2d shape for wasm nodes
|
||||||
|
- [c610d6c](https://git.max-richter.dev/max/nodarium/commit/c610d6c99152d8233235064b81503c2b0dc4ada8) fix(app): show backside in three instances
|
||||||
|
- [8865b9b](https://git.max-richter.dev/max/nodarium/commit/8865b9b032bdf5a1385b4e9db0b1923f0e224fdd) feat(node): initial leaf / shape nodes
|
||||||
|
- [235ee5d](https://git.max-richter.dev/max/nodarium/commit/235ee5d979fbd70b3e0fb6f09a352218c3ff1e6d) fix(app): wrong linter errors in changelog
|
||||||
|
- [23a4857](https://git.max-richter.dev/max/nodarium/commit/23a48572f3913d91d839873cc155a16139c286a6) feat(app): dots background for node interface
|
||||||
|
- [e89a46e](https://git.max-richter.dev/max/nodarium/commit/e89a46e146e9e95de57ffdf55b05d16d6fe975f4) feat(app): add error page
|
||||||
|
- [cefda41](https://git.max-richter.dev/max/nodarium/commit/cefda41fcf3d5d011c9f7598a4f3f37136602dbd) feat(theme): optimize node readability
|
||||||
|
- [21d0f0d](https://git.max-richter.dev/max/nodarium/commit/21d0f0da5a26492fa68ad4897a9b1d9e88857030) feat: add high-contrast-light theme
|
||||||
|
- [4620245](https://git.max-richter.dev/max/nodarium/commit/46202451ba3eea73bd1bc6ef5129b3e26ee9315c) ci: simplify ci quality checks
|
||||||
|
- [0f4239d](https://git.max-richter.dev/max/nodarium/commit/0f4239d179ddedd3d012ca98b5bc3312afbc8f10) ci: simplify ci quality checks
|
||||||
|
- [d9c9bb5](https://git.max-richter.dev/max/nodarium/commit/d9c9bb5234bc8776daf26be99ba77a2145c70649) fix(theme): allow raw html in head style
|
||||||
|
- [18802fd](https://git.max-richter.dev/max/nodarium/commit/18802fdc10294a58425f052a4fde4bcf4be58caf) fix(ui): add missing types
|
||||||
|
- [b1cbd23](https://git.max-richter.dev/max/nodarium/commit/b1cbd235420c99a11154ef6a899cc7e14faf1c37) feat(app): use same color for node outline and header
|
||||||
|
- [33f10da](https://git.max-richter.dev/max/nodarium/commit/33f10da396fdc13edcb8faaee212280102b24f3a) feat(ui): make details stand out
|
||||||
|
- [af5b3b2](https://git.max-richter.dev/max/nodarium/commit/af5b3b23ba18d73d6abec60949fb0c9edfc25ff8) fix: make sure that CHANGELOG.md is in correct place
|
||||||
|
- [64d75b9](https://git.max-richter.dev/max/nodarium/commit/64d75b9686c494075223a0a318297fe59ec99e81) feat(ui): add InputColor and custom theme
|
||||||
|
- [2e6466c](https://git.max-richter.dev/max/nodarium/commit/2e6466ceca1d2131581d1862e93c756affdf6cd6) chore: update dprint linters
|
||||||
|
- [20d8e2a](https://git.max-richter.dev/max/nodarium/commit/20d8e2abedf0de30299d947575afef9c8ffd61d9) feat(theme): improve light theme a bit
|
||||||
|
- [715e1d0](https://git.max-richter.dev/max/nodarium/commit/715e1d095b8a77feb0cf66bbb444baf0f163adcb) feat(theme): merge edge and connection color
|
||||||
|
- [07e2826](https://git.max-richter.dev/max/nodarium/commit/07e2826f16dafa6a07377c9fb591168fa5c2abcf) feat(ui): improve colors of input shape
|
||||||
|
- [e0ad97b](https://git.max-richter.dev/max/nodarium/commit/e0ad97b003fd8cb4d950c03e5488a5accf6a37d0) feat(ui): highlight circle on hover on InputShape
|
||||||
|
- [93df4a1](https://git.max-richter.dev/max/nodarium/commit/93df4a19ff816e2bdfa093594721f0829f84c9e6) fix(ci): handle newline in commit messages for git.json
|
||||||
|
- [d661a4e](https://git.max-richter.dev/max/nodarium/commit/d661a4e4a9dfa6c9c73b5e24a3edcf56e1bbf48c) feat(ui): improve InputShape ux
|
||||||
|
- [c7f808c](https://git.max-richter.dev/max/nodarium/commit/c7f808ce2d52925425b49f92edf49d9557f8901d) wip
|
||||||
|
- [72d6cd6](https://git.max-richter.dev/max/nodarium/commit/72d6cd6ea2886626823e6e86856f19338c7af3c1) feat(ui): add initial InputShape element
|
||||||
|
- [615f2d3](https://git.max-richter.dev/max/nodarium/commit/615f2d3c4866a9e85f3eca398f3f02100c4df355) feat(ui): allow custom snippets in ui section header
|
||||||
|
- [2fadb68](https://git.max-richter.dev/max/nodarium/commit/2fadb6802de640d692fdab7d654311df0d7b4836) refactor: make changelog code simpler
|
||||||
|
- [9271d3a](https://git.max-richter.dev/max/nodarium/commit/9271d3a7e4cb0cc751b635c2adb518de7b4100c7) fix(app): handle error while parsing commit
|
||||||
|
- [13c83ef](https://git.max-richter.dev/max/nodarium/commit/13c83efdb962a6578ade59f10cc574fef0e17534) fix(app): handle error while parsing changelog
|
||||||
|
- [e44b73b](https://git.max-richter.dev/max/nodarium/commit/e44b73bebfb1cc8e872cd2fa7d8b6ff3565df374) feat: optimize changelog display
|
||||||
|
- [979e9fd](https://git.max-richter.dev/max/nodarium/commit/979e9fd92289eba9f77221c563337c00028e4cf5) feat: improve changelog readbility
|
||||||
|
- [544500e](https://git.max-richter.dev/max/nodarium/commit/544500e7fe9ee14412cef76f3c7a32ba6f291656) chore: remove pgp from changelog
|
||||||
|
- [aaebbc4](https://git.max-richter.dev/max/nodarium/commit/aaebbc4bc082ee93c2317ce45071c9bc61b0b77e) fix: some stuff with ci
|
||||||
|
|
||||||
|
# v0.0.3 (2026-02-07)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Edge dragging now highlights valid connection sockets, improving graph editing clarity.
|
||||||
|
- InputNumber supports snapping to predefined values while holding Alt.
|
||||||
|
- Changelog is accessible directly from the sidebar and now includes git metadata and a list of commits.
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Fixed incorrect socket highlighting when an edge already existed.
|
||||||
|
- Corrected initialization of `InputNumber` values outside min/max bounds.
|
||||||
|
- Fixed initialization of nested vec3 inputs.
|
||||||
|
- Multiple CI fixes to ensure reliable builds, correct environment variables, and proper image handling.
|
||||||
|
|
||||||
|
## Maintenance / CI
|
||||||
|
|
||||||
|
- Significant CI and Dockerfile cleanup and optimization.
|
||||||
|
- Improved git metadata generation during builds.
|
||||||
|
- Dependency updates, formatting, and test snapshot updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [f8a2a95](https://git.max-richter.dev/max/nodarium/commit/f8a2a95bc18fa3c8c1db67dc0c2b66db1ff0d866) chore: clean CHANGELOG.md
|
||||||
|
- [c9dd143](https://git.max-richter.dev/max/nodarium/commit/c9dd143916d758991f3ba30723a32c18b6f98bb5) fix(ci): correctly add release notes from tag to changelog
|
||||||
|
- [898dd49](https://git.max-richter.dev/max/nodarium/commit/898dd49aee930350af8645382ef5042765a1fac7) fix(ci): correctly copy changelog to build output
|
||||||
|
- [9fb69d7](https://git.max-richter.dev/max/nodarium/commit/9fb69d760fdf92ecc2448e468242970ec48443b0) feat: show commits since last release in changelog
|
||||||
|
- [bafbcca](https://git.max-richter.dev/max/nodarium/commit/bafbcca2b8a7cd9f76e961349f11ec84d1e4da63) fix: wrong socket was highlighted when dragging node
|
||||||
|
- [8ad9e55](https://git.max-richter.dev/max/nodarium/commit/8ad9e5535cd752ef111504226b4dac57b5adcf3d) feat: highlight possible sockets when dragging edge
|
||||||
|
- [11eaeb7](https://git.max-richter.dev/max/nodarium/commit/11eaeb719be7f34af8db8b7908008a15308c0cac) feat(app): display some git metadata in changelog
|
||||||
|
- [74c2978](https://git.max-richter.dev/max/nodarium/commit/74c2978cd16d2dd95ce1ae8019dfb9098e52b4b6) chore: cleanup git.json a bit
|
||||||
|
- [4fdc247](https://git.max-richter.dev/max/nodarium/commit/4fdc24790490d3f13ee94a557159617f4077a2f9) ci: update build.sh to correct git.json
|
||||||
|
- [c3f8b4b](https://git.max-richter.dev/max/nodarium/commit/c3f8b4b5aad7a525fb11ab14c9236374cb60442d) ci: debug available env vars
|
||||||
|
- [67591c0](https://git.max-richter.dev/max/nodarium/commit/67591c0572b873d8c7cd00db8efb7dac2d6d4de2) chore: pnpm format
|
||||||
|
- [de1f9d6](https://git.max-richter.dev/max/nodarium/commit/de1f9d6ab669b8e699d98b8855e125e21030b5b3) feat(ui): change inputnumber to snap to values when alt is pressed
|
||||||
|
- [6acce72](https://git.max-richter.dev/max/nodarium/commit/6acce72fb8c416cc7f6eec99c2ae94d6529e960c) fix(ui): correctly initialize InputNumber
|
||||||
|
- [cf8943b](https://git.max-richter.dev/max/nodarium/commit/cf8943b2059aa286e41865caf75058d35498daf7) chore: pnpm update
|
||||||
|
- [9e03d36](https://git.max-richter.dev/max/nodarium/commit/9e03d36482bb4f972c384b66b2dcf258f0cd18be) chore: use newest ci image
|
||||||
|
- [fd7268d](https://git.max-richter.dev/max/nodarium/commit/fd7268d6208aede435e1685817ae6b271c68bd83) ci: make dockerfile work
|
||||||
|
- [6358c22](https://git.max-richter.dev/max/nodarium/commit/6358c22a853ec340be5223fabb8289092e4f4afe) ci: use tagged own image for ci
|
||||||
|
- [655b6a1](https://git.max-richter.dev/max/nodarium/commit/655b6a18b282f0cddcc750892e575ee6c311036b) ci: make dockerfile work
|
||||||
|
- [37b2bdc](https://git.max-richter.dev/max/nodarium/commit/37b2bdc8bdbd8ded6b22b89214b49de46f788351) ci: update ci Dockerfile to work
|
||||||
|
- [94e01d4](https://git.max-richter.dev/max/nodarium/commit/94e01d4ea865f15ce06b52827a1ae6906de5be5e) ci: correctly build and push ci image
|
||||||
|
- [35f5177](https://git.max-richter.dev/max/nodarium/commit/35f5177884b62bbf119af1bbf4df61dd0291effb) feat: try to optimize the Dockerfile
|
||||||
|
- [ac2c61f](https://git.max-richter.dev/max/nodarium/commit/ac2c61f2211ba96bbdbb542179905ca776537cec) ci: use actual git url in ci
|
||||||
|
- [ef3d462](https://git.max-richter.dev/max/nodarium/commit/ef3d46279f4ff9c04d80bb2d9a9e7cfec63b224e) fix(ci): build before testing
|
||||||
|
- [703da32](https://git.max-richter.dev/max/nodarium/commit/703da324fabbef0e2c017f0f7a925209fa26bd03) ci: automatically build ci image and store locally
|
||||||
|
- [1dae472](https://git.max-richter.dev/max/nodarium/commit/1dae472253ccb5e3766f2270adc053b922f46738) ci: add a git.json metadata file during build
|
||||||
|
- [09fdfb8](https://git.max-richter.dev/max/nodarium/commit/09fdfb88cd203ace0e36663ebdb2c8c7ba53f190) chore: update test screenshots
|
||||||
|
- [04b63cc](https://git.max-richter.dev/max/nodarium/commit/04b63cc7e2fc4fcfa0973cf40592d11457179db3) feat: add changelog to sidebar
|
||||||
|
- [cb6a356](https://git.max-richter.dev/max/nodarium/commit/cb6a35606dfda50b0c81b04902d7a6c8e59458d2) feat(ci): also cache cargo stuff
|
||||||
|
- [9c9f3ba](https://git.max-richter.dev/max/nodarium/commit/9c9f3ba3b7c94215a86b0a338a5cecdd87b96b28) fix(ci): use GITHUB_instead of GITEA_ for env vars
|
||||||
|
- [08dda2b](https://git.max-richter.dev/max/nodarium/commit/08dda2b2cb4d276846abe30bc260127626bb508a) chore: pnpm format
|
||||||
|
- [059129a](https://git.max-richter.dev/max/nodarium/commit/059129a738d02b8b313bb301a515697c7c4315ac) fix(ci): deploy prs and main
|
||||||
|
- [437c9f4](https://git.max-richter.dev/max/nodarium/commit/437c9f4a252125e1724686edace0f5f006f58439) feat(ci): add list of all commits to changelog entry
|
||||||
|
- [48bf447](https://git.max-richter.dev/max/nodarium/commit/48bf447ce12949d7c29a230806d160840b7847e1) docs: straighten up changelog a bit
|
||||||
|
- [548fa4f](https://git.max-richter.dev/max/nodarium/commit/548fa4f0a1a14adc40a74da1182fa6da81eab3df) fix(app): correctly initialize vec3 inputs in nestedsettings
|
||||||
|
|
||||||
|
# v0.0.2 (2026-02-04)
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- []() fix(ci): actually deploy on tags
|
||||||
|
- []() fix(app): correctly handle false value in settings
|
||||||
|
|
||||||
|
# v0.0.1 (2026-02-03)
|
||||||
|
|
||||||
|
chore: format
|
||||||
16
Cargo.lock
generated
16
Cargo.lock
generated
@@ -62,6 +62,14 @@ version = "1.0.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "leaf"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"nodarium_macros",
|
||||||
|
"nodarium_utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "math"
|
name = "math"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -245,6 +253,14 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shape"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"nodarium_macros",
|
||||||
|
"nodarium_utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stem"
|
name = "stem"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
19
Dockerfile
19
Dockerfile
@@ -1,19 +0,0 @@
|
|||||||
FROM node:24-alpine
|
|
||||||
|
|
||||||
# Install all required packages in one layer
|
|
||||||
RUN apk add --no-cache curl git jq g++ make
|
|
||||||
|
|
||||||
# Set Rust paths
|
|
||||||
ENV RUSTUP_HOME=/usr/local/rustup \
|
|
||||||
CARGO_HOME=/usr/local/cargo \
|
|
||||||
PATH=/usr/local/cargo/bin:$PATH
|
|
||||||
|
|
||||||
# Install Rust, wasm target, and pnpm
|
|
||||||
RUN curl --silent --show-error --location --fail --retry 3 \
|
|
||||||
--proto '=https' --tlsv1.2 \
|
|
||||||
--output /tmp/rustup-init.sh https://sh.rustup.rs \
|
|
||||||
&& sh /tmp/rustup-init.sh -y --no-modify-path --profile minimal \
|
|
||||||
&& rm /tmp/rustup-init.sh \
|
|
||||||
&& rustup target add wasm32-unknown-unknown \
|
|
||||||
&& rm -rf /usr/local/rustup/toolchains/*/share/doc \
|
|
||||||
&& npm i -g pnpm
|
|
||||||
32
Dockerfile.ci
Normal file
32
Dockerfile.ci
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
FROM node:25-bookworm-slim
|
||||||
|
|
||||||
|
ENV RUSTUP_HOME=/usr/local/rustup \
|
||||||
|
CARGO_HOME=/usr/local/cargo \
|
||||||
|
PATH=/usr/local/cargo/bin:$PATH
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ca-certificates=20230311+deb12u1 \
|
||||||
|
gpg=2.2.40-1.1+deb12u2 \
|
||||||
|
gpg-agent=2.2.40-1.1+deb12u2 \
|
||||||
|
curl=7.88.1-10+deb12u14 \
|
||||||
|
git=1:2.39.5-0+deb12u3 \
|
||||||
|
jq=1.6-2.1+deb12u1 \
|
||||||
|
g++=4:12.2.0-3 \
|
||||||
|
rclone=1.60.1+dfsg-2+b5 \
|
||||||
|
xvfb=2:21.1.7-3+deb12u11 \
|
||||||
|
xauth=1:1.1.2-1 \
|
||||||
|
--no-install-recommends \
|
||||||
|
# Install Rust
|
||||||
|
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
|
||||||
|
--default-toolchain stable \
|
||||||
|
--profile minimal \
|
||||||
|
&& rustup target add wasm32-unknown-unknown \
|
||||||
|
# Setup Playwright
|
||||||
|
&& npm i -g pnpm \
|
||||||
|
&& pnpm dlx playwright install --with-deps firefox \
|
||||||
|
# Final Cleanup
|
||||||
|
&& rm -rf /usr/local/rustup/downloads /usr/local/rustup/tmp \
|
||||||
|
&& rm -rf /usr/local/cargo/registry/index /usr/local/cargo/registry/cache \
|
||||||
|
&& rm -rf /usr/local/rustup/toolchains/*/share/doc \
|
||||||
|
&& apt-get purge -y --auto-remove \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
2
app/.gitignore
vendored
2
app/.gitignore
vendored
@@ -27,3 +27,5 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
build/
|
build/
|
||||||
|
|
||||||
|
test-results/
|
||||||
|
|||||||
1
app/benchmark/.gitignore
vendored
Normal file
1
app/benchmark/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
out/
|
||||||
47
app/benchmark/benchmarkRegistry.ts
Normal file
47
app/benchmark/benchmarkRegistry.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NodeDefinition, NodeId, NodeRegistry } from '@nodarium/types';
|
||||||
|
import { createWasmWrapper } from '@nodarium/utils';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
export class BenchmarkRegistry implements NodeRegistry {
|
||||||
|
status: 'loading' | 'ready' | 'error' = 'loading';
|
||||||
|
|
||||||
|
private nodes = new Map<string, NodeDefinition>();
|
||||||
|
|
||||||
|
async load(nodeIds: NodeId[]): Promise<NodeDefinition[]> {
|
||||||
|
const nodes = await Promise.all(nodeIds.map(async id => {
|
||||||
|
const p = resolve('static/nodes/' + id + '.wasm');
|
||||||
|
const file = await readFile(p);
|
||||||
|
const node = createWasmWrapper(file as unknown as ArrayBuffer);
|
||||||
|
const d = node.get_definition();
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
execute: node.execute
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
for (const n of nodes) {
|
||||||
|
this.nodes.set(n.id, n);
|
||||||
|
}
|
||||||
|
this.status = 'ready';
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(id: string, wasmBuffer: ArrayBuffer): Promise<NodeDefinition> {
|
||||||
|
const wasm = createWasmWrapper(wasmBuffer);
|
||||||
|
const d = wasm.get_definition();
|
||||||
|
const node = {
|
||||||
|
...d,
|
||||||
|
execute: wasm.execute
|
||||||
|
};
|
||||||
|
this.nodes.set(id, node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
getNode(id: NodeId | string): NodeDefinition | undefined {
|
||||||
|
return this.nodes.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllNodes(): NodeDefinition[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/benchmark/index.ts
Normal file
56
app/benchmark/index.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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();
|
||||||
95
app/benchmark/templates/default.json
Normal file
95
app/benchmark/templates/default.json
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"settings": { "resolution.circle": 26, "resolution.curve": 39 },
|
||||||
|
"nodes": [
|
||||||
|
{ "id": 9, "position": [220, 80], "type": "max/plantarium/output", "props": {} },
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"position": [95, 80],
|
||||||
|
"type": "max/plantarium/stem",
|
||||||
|
"props": { "amount": 5, "length": 11, "thickness": 0.1 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"position": [195, 80],
|
||||||
|
"type": "max/plantarium/gravity",
|
||||||
|
"props": {
|
||||||
|
"strength": 0.38,
|
||||||
|
"scale": 39,
|
||||||
|
"fixBottom": 0,
|
||||||
|
"directionalStrength": [1, 1, 1],
|
||||||
|
"depth": 1,
|
||||||
|
"curviness": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"position": [120, 80],
|
||||||
|
"type": "max/plantarium/noise",
|
||||||
|
"props": {
|
||||||
|
"strength": 4.9,
|
||||||
|
"scale": 2.2,
|
||||||
|
"fixBottom": 1,
|
||||||
|
"directionalStrength": [1, 1, 1],
|
||||||
|
"depth": 1,
|
||||||
|
"octaves": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"position": [70, 80],
|
||||||
|
"type": "max/plantarium/vec3",
|
||||||
|
"props": { "0": 0, "1": 0, "2": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"position": [45, 80],
|
||||||
|
"type": "max/plantarium/random",
|
||||||
|
"props": { "min": -2, "max": 2 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"position": [170, 80],
|
||||||
|
"type": "max/plantarium/branch",
|
||||||
|
"props": {
|
||||||
|
"length": 1.6,
|
||||||
|
"thickness": 0.69,
|
||||||
|
"amount": 36,
|
||||||
|
"offsetSingle": 0.5,
|
||||||
|
"lowestBranch": 0.46,
|
||||||
|
"highestBranch": 1,
|
||||||
|
"depth": 1,
|
||||||
|
"rotation": 180
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"position": [145, 80],
|
||||||
|
"type": "max/plantarium/gravity",
|
||||||
|
"props": {
|
||||||
|
"strength": 0.38,
|
||||||
|
"scale": 39,
|
||||||
|
"fixBottom": 0,
|
||||||
|
"directionalStrength": [1, 1, 1],
|
||||||
|
"depth": 1,
|
||||||
|
"curviness": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"position": [70, 120],
|
||||||
|
"type": "max/plantarium/random",
|
||||||
|
"props": { "min": 0.073, "max": 0.15 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
[14, 0, 9, "input"],
|
||||||
|
[10, 0, 15, "plant"],
|
||||||
|
[16, 0, 10, "origin"],
|
||||||
|
[17, 0, 16, "0"],
|
||||||
|
[17, 0, 16, "2"],
|
||||||
|
[18, 0, 14, "plant"],
|
||||||
|
[15, 0, 19, "plant"],
|
||||||
|
[19, 0, 18, "plant"],
|
||||||
|
[20, 0, 10, "thickness"]
|
||||||
|
]
|
||||||
|
}
|
||||||
44
app/benchmark/templates/lotta-faces.json
Normal file
44
app/benchmark/templates/lotta-faces.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"settings": { "resolution.circle": 64, "resolution.curve": 64, "randomSeed": false },
|
||||||
|
"nodes": [
|
||||||
|
{ "id": 9, "position": [260, 0], "type": "max/plantarium/output", "props": {} },
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"position": [185, 0],
|
||||||
|
"type": "max/plantarium/stem",
|
||||||
|
"props": { "amount": 64, "length": 12, "thickness": 0.15 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"position": [210, 0],
|
||||||
|
"type": "max/plantarium/noise",
|
||||||
|
"props": { "scale": 1.3, "strength": 5.4 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"position": [235, 0],
|
||||||
|
"type": "max/plantarium/branch",
|
||||||
|
"props": { "length": 0.8, "thickness": 0.8, "amount": 3 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"position": [160, 0],
|
||||||
|
"type": "max/plantarium/vec3",
|
||||||
|
"props": { "0": 0.39, "1": 0, "2": 0.41 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"position": [130, 0],
|
||||||
|
"type": "max/plantarium/random",
|
||||||
|
"props": { "min": -2, "max": 2 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
[18, 0, 19, "plant"],
|
||||||
|
[19, 0, 20, "plant"],
|
||||||
|
[20, 0, 9, "input"],
|
||||||
|
[21, 0, 18, "origin"],
|
||||||
|
[22, 0, 21, "0"],
|
||||||
|
[22, 0, 21, "2"]
|
||||||
|
]
|
||||||
|
}
|
||||||
71
app/benchmark/templates/plant.json
Normal file
71
app/benchmark/templates/plant.json
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"settings": { "resolution.circle": 26, "resolution.curve": 39 },
|
||||||
|
"nodes": [
|
||||||
|
{ "id": 9, "position": [180, 80], "type": "max/plantarium/output", "props": {} },
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"position": [55, 80],
|
||||||
|
"type": "max/plantarium/stem",
|
||||||
|
"props": { "amount": 1, "length": 11, "thickness": 0.71 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"position": [80, 80],
|
||||||
|
"type": "max/plantarium/noise",
|
||||||
|
"props": {
|
||||||
|
"strength": 35,
|
||||||
|
"scale": 4.6,
|
||||||
|
"fixBottom": 1,
|
||||||
|
"directionalStrength": [1, 0.74, 0.083],
|
||||||
|
"depth": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"position": [105, 80],
|
||||||
|
"type": "max/plantarium/branch",
|
||||||
|
"props": {
|
||||||
|
"length": 3,
|
||||||
|
"thickness": 0.6,
|
||||||
|
"amount": 10,
|
||||||
|
"rotation": 180,
|
||||||
|
"offsetSingle": 0.34,
|
||||||
|
"lowestBranch": 0.53,
|
||||||
|
"highestBranch": 1,
|
||||||
|
"depth": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"position": [130, 80],
|
||||||
|
"type": "max/plantarium/noise",
|
||||||
|
"props": {
|
||||||
|
"strength": 8,
|
||||||
|
"scale": 7.7,
|
||||||
|
"fixBottom": 1,
|
||||||
|
"directionalStrength": [1, 0, 1],
|
||||||
|
"depth": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"position": [155, 80],
|
||||||
|
"type": "max/plantarium/gravity",
|
||||||
|
"props": {
|
||||||
|
"strength": 0.11,
|
||||||
|
"scale": 39,
|
||||||
|
"fixBottom": 0,
|
||||||
|
"directionalStrength": [1, 1, 1],
|
||||||
|
"depth": 1,
|
||||||
|
"curviness": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
[10, 0, 11, "plant"],
|
||||||
|
[11, 0, 12, "plant"],
|
||||||
|
[12, 0, 13, "plant"],
|
||||||
|
[13, 0, 14, "plant"],
|
||||||
|
[14, 0, 9, "input"]
|
||||||
|
]
|
||||||
|
}
|
||||||
62
app/e2e/main.test.ts
Normal file
62
app/e2e/main.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('test', async ({ page }) => {
|
||||||
|
// Listen for console messages
|
||||||
|
page.on('console', msg => {
|
||||||
|
console.log(`[Browser Console] ${msg.type()}: ${msg.text()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('http://localhost:4173', { waitUntil: 'load' });
|
||||||
|
|
||||||
|
// await expect(page).toHaveScreenshot();
|
||||||
|
await expect(page.locator('.graph-wrapper')).toHaveScreenshot();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'projects' }).click();
|
||||||
|
await page.getByRole('button', { name: 'New', exact: true }).click();
|
||||||
|
await page.getByRole('combobox').selectOption('2');
|
||||||
|
await page.getByRole('textbox', { name: 'Project name' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Project name' }).fill('Test Project');
|
||||||
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
|
||||||
|
const expectedNodes = [
|
||||||
|
{
|
||||||
|
id: '10',
|
||||||
|
type: 'max/plantarium/stem',
|
||||||
|
props: {
|
||||||
|
amount: 4,
|
||||||
|
length: 4,
|
||||||
|
thickness: 0.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '11',
|
||||||
|
type: 'max/plantarium/noise',
|
||||||
|
props: {
|
||||||
|
scale: 0.5,
|
||||||
|
strength: 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '9',
|
||||||
|
type: 'max/plantarium/output'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const node of expectedNodes) {
|
||||||
|
const wrapper = page.locator(
|
||||||
|
`div.wrapper[data-node-id="${node.id}"][data-node-type="${node.type}"]`
|
||||||
|
);
|
||||||
|
await expect(wrapper).toBeVisible();
|
||||||
|
if ('props' in node) {
|
||||||
|
const props = node.props as unknown as Record<string, number>;
|
||||||
|
for (const propId in node.props) {
|
||||||
|
const expectedValue = props[propId];
|
||||||
|
const inputElement = page.locator(
|
||||||
|
`div.wrapper[data-node-type="${node.type}"][data-node-input="${propId}"] input[type="number"]`
|
||||||
|
);
|
||||||
|
const value = parseFloat(await inputElement.inputValue());
|
||||||
|
expect(value).toBe(expectedValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
BIN
app/e2e/main.test.ts-snapshots/test-1-linux.png
Normal file
BIN
app/e2e/main.test.ts-snapshots/test-1-linux.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -1,22 +1,27 @@
|
|||||||
{
|
{
|
||||||
"name": "@nodarium/app",
|
"name": "@nodarium/app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
"predev": "rm static/CHANGELOG.md && ln -s ../../CHANGELOG.md static/CHANGELOG.md",
|
||||||
"build": "svelte-kit sync && vite build",
|
"build": "svelte-kit sync && vite build",
|
||||||
"test": "vitest",
|
"test:unit": "vitest",
|
||||||
|
"test": "npm run test:unit -- --run && npm run test:e2e",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"format": "dprint fmt -c '../.dprint.jsonc' .",
|
"format": "dprint fmt -c '../.dprint.jsonc' .",
|
||||||
"format:check": "dprint check -c '../.dprint.jsonc' .",
|
"format:check": "dprint check -c '../.dprint.jsonc' .",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"bench": "tsx ./benchmark/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nodarium/ui": "workspace:*",
|
"@nodarium/ui": "workspace:*",
|
||||||
"@nodarium/utils": "workspace:*",
|
"@nodarium/utils": "workspace:*",
|
||||||
"@sveltejs/kit": "^2.50.0",
|
"@nodarium/planty": "workspace:*",
|
||||||
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@threlte/core": "8.3.1",
|
"@threlte/core": "8.3.1",
|
||||||
"@threlte/extras": "9.7.1",
|
"@threlte/extras": "9.7.1",
|
||||||
@@ -24,34 +29,38 @@
|
|||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"jsondiffpatch": "^0.7.3",
|
"jsondiffpatch": "^0.7.3",
|
||||||
|
"micromark": "^4.0.2",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"three": "^0.182.0",
|
"three": "^0.182.0"
|
||||||
"wabt": "^1.0.39"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^2.0.2",
|
"@eslint/compat": "^2.0.2",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@iconify-json/tabler": "^1.2.26",
|
"@iconify-json/tabler": "^1.2.26",
|
||||||
"@iconify/tailwind4": "^1.2.1",
|
"@iconify/tailwind4": "^1.2.1",
|
||||||
"@nodarium/types": "workspace:",
|
"@nodarium/types": "workspace:^",
|
||||||
|
"@playwright/test": "^1.58.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tsconfig/svelte": "^5.0.6",
|
"@tsconfig/svelte": "^5.0.7",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/three": "^0.182.0",
|
"@types/three": "^0.182.0",
|
||||||
|
"@vitest/browser-playwright": "^4.0.18",
|
||||||
"dprint": "^0.51.1",
|
"dprint": "^0.51.1",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-plugin-svelte": "^3.14.0",
|
"eslint-plugin-svelte": "^3.14.0",
|
||||||
"globals": "^17.3.0",
|
"globals": "^17.3.0",
|
||||||
"svelte": "^5.46.4",
|
"svelte": "^5.49.2",
|
||||||
"svelte-check": "^4.3.5",
|
"svelte-check": "^4.3.6",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.54.0",
|
"typescript-eslint": "^8.54.0",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-comlink": "^5.3.0",
|
"vite-plugin-comlink": "^5.3.0",
|
||||||
"vite-plugin-glsl": "^1.5.5",
|
"vite-plugin-glsl": "^1.5.5",
|
||||||
"vite-plugin-wasm": "^3.5.0",
|
"vite-plugin-wasm": "^3.5.0",
|
||||||
"vitest": "^4.0.17"
|
"vitest": "^4.0.18",
|
||||||
|
"vitest-browser-svelte": "^2.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
app/playwright.config.ts
Normal file
20
app/playwright.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
webServer: { command: 'pnpm build && pnpm preview', port: 4173 },
|
||||||
|
testDir: 'e2e',
|
||||||
|
use: {
|
||||||
|
browserName: 'firefox',
|
||||||
|
launchOptions: {
|
||||||
|
firefoxUserPrefs: {
|
||||||
|
// Force WebGL even without a GPU
|
||||||
|
'webgl.force-enabled': true,
|
||||||
|
'webgl.disabled': false,
|
||||||
|
// Use software rendering (Mesa) instead of hardware
|
||||||
|
'layers.acceleration.disabled': true,
|
||||||
|
'gfx.webrender.software': true,
|
||||||
|
'webgl.enable-webgl2': true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@source "../../packages/ui/**/*.svelte";
|
@source "../../packages/ui/**/*.svelte";
|
||||||
|
@source "../../packages/planty/src/lib/**/*.svelte";
|
||||||
|
|
||||||
@plugin "@iconify/tailwind4" {
|
@plugin "@iconify/tailwind4" {
|
||||||
prefix: "i";
|
prefix: "i";
|
||||||
icon-sets: from-folder(custom, "./src/lib/icons");
|
icon-sets: from-folder("custom", "./src/lib/icons");
|
||||||
}
|
}
|
||||||
|
|
||||||
body * {
|
body * {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/svelte.svg" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
<title>Nodes</title>
|
<title>Nodes</title>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ uniform vec3 camPos;
|
|||||||
uniform vec2 zoomLimits;
|
uniform vec2 zoomLimits;
|
||||||
uniform vec3 backgroundColor;
|
uniform vec3 backgroundColor;
|
||||||
uniform vec3 lineColor;
|
uniform vec3 lineColor;
|
||||||
|
uniform int gridType; // 0 = grid lines, 1 = dots
|
||||||
|
|
||||||
// Anti-aliased step: threshold in the same units as `value`
|
// Anti-aliased step: threshold in the same units as `value`
|
||||||
float aaStep(float threshold, float value, float deriv) {
|
float aaStep(float threshold, float value, float deriv) {
|
||||||
@@ -78,35 +79,51 @@ void main(void) {
|
|||||||
float ux = (vUv.x - 0.5) * width + cx * cz;
|
float ux = (vUv.x - 0.5) * width + cx * cz;
|
||||||
float uy = (vUv.y - 0.5) * height - cy * cz;
|
float uy = (vUv.y - 0.5) * height - cy * cz;
|
||||||
|
|
||||||
// extra small grid
|
if(gridType == 0) {
|
||||||
float m1 = grid(ux, uy, divisions * 4.0, thickness * 4.0) * 0.9;
|
// extra small grid
|
||||||
float m2 = grid(ux, uy, divisions * 16.0, thickness * 16.0) * 0.5;
|
float m1 = grid(ux, uy, divisions * 4.0, thickness * 4.0) * 0.9;
|
||||||
float xsmall = max(m1, m2);
|
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;
|
float s3 = circle_grid(ux, uy, cz / 1.6, 1.0) * 0.5;
|
||||||
xsmall = max(xsmall, s3);
|
xsmall = max(xsmall, s3);
|
||||||
|
|
||||||
// small grid
|
// small grid
|
||||||
float c1 = grid(ux, uy, divisions, thickness) * 0.6;
|
float c1 = grid(ux, uy, divisions, thickness) * 0.6;
|
||||||
float c2 = grid(ux, uy, divisions * 2.0, thickness * 2.0) * 0.5;
|
float c2 = grid(ux, uy, divisions * 2.0, thickness * 2.0) * 0.5;
|
||||||
float small = max(c1, c2);
|
float small = max(c1, c2);
|
||||||
|
|
||||||
float s1 = circle_grid(ux, uy, cz * 10.0, 2.0) * 0.5;
|
float s1 = circle_grid(ux, uy, cz * 10.0, 2.0) * 0.5;
|
||||||
small = max(small, s1);
|
small = max(small, s1);
|
||||||
|
|
||||||
// large grid
|
// large grid
|
||||||
float c3 = grid(ux, uy, divisions / 8.0, thickness / 8.0) * 0.5;
|
float c3 = grid(ux, uy, divisions / 8.0, thickness / 8.0) * 0.5;
|
||||||
float c4 = grid(ux, uy, divisions / 2.0, thickness / 4.0) * 0.4;
|
float c4 = grid(ux, uy, divisions / 2.0, thickness / 4.0) * 0.4;
|
||||||
float large = max(c3, c4);
|
float large = max(c3, c4);
|
||||||
|
|
||||||
float s2 = circle_grid(ux, uy, cz * 20.0, 1.0) * 0.4;
|
float s2 = circle_grid(ux, uy, cz * 20.0, 1.0) * 0.4;
|
||||||
large = max(large, s2);
|
large = max(large, s2);
|
||||||
|
|
||||||
float c = mix(large, small, min(nz * 2.0 + 0.05, 1.0));
|
float c = mix(large, small, min(nz * 2.0 + 0.05, 1.0));
|
||||||
c = mix(c, xsmall, clamp((nz - 0.3) / 0.7, 0.0, 1.0));
|
c = mix(c, xsmall, clamp((nz - 0.3) / 0.7, 0.0, 1.0));
|
||||||
|
|
||||||
vec3 color = mix(backgroundColor, lineColor, c);
|
vec3 color = mix(backgroundColor, lineColor, c);
|
||||||
|
|
||||||
|
gl_FragColor = vec4(color, 1.0);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
|
||||||
gl_FragColor = vec4(color, 1.0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,12 @@
|
|||||||
import BackgroundVert from './Background.vert';
|
import BackgroundVert from './Background.vert';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
minZoom: number;
|
minZoom?: number;
|
||||||
maxZoom: number;
|
maxZoom?: number;
|
||||||
cameraPosition: [number, number, number];
|
cameraPosition?: [number, number, number];
|
||||||
width: number;
|
width?: number;
|
||||||
height: number;
|
height?: number;
|
||||||
|
type?: 'grid' | 'dots' | 'none';
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -18,9 +19,18 @@
|
|||||||
maxZoom = 150,
|
maxZoom = 150,
|
||||||
cameraPosition = [0, 1, 0],
|
cameraPosition = [0, 1, 0],
|
||||||
width = globalThis?.innerWidth || 100,
|
width = globalThis?.innerWidth || 100,
|
||||||
height = globalThis?.innerHeight || 100
|
height = globalThis?.innerHeight || 100,
|
||||||
|
type = 'grid'
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const typeMap = new Map([
|
||||||
|
['grid', 0],
|
||||||
|
['dots', 1],
|
||||||
|
['none', 2]
|
||||||
|
]);
|
||||||
|
|
||||||
|
const gridType = $derived(typeMap.get(type) || 0);
|
||||||
|
|
||||||
let bw = $derived(width / cameraPosition[2]);
|
let bw = $derived(width / cameraPosition[2]);
|
||||||
let bh = $derived(height / cameraPosition[2]);
|
let bh = $derived(height / cameraPosition[2]);
|
||||||
</script>
|
</script>
|
||||||
@@ -51,6 +61,9 @@
|
|||||||
},
|
},
|
||||||
dimensions: {
|
dimensions: {
|
||||||
value: [100, 100]
|
value: [100, 100]
|
||||||
|
},
|
||||||
|
gridType: {
|
||||||
|
value: 0
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
uniforms.camPos.value={cameraPosition}
|
uniforms.camPos.value={cameraPosition}
|
||||||
@@ -59,6 +72,7 @@
|
|||||||
uniforms.lineColor.value={appSettings.value.theme && colors['outline']}
|
uniforms.lineColor.value={appSettings.value.theme && colors['outline']}
|
||||||
uniforms.zoomLimits.value={[minZoom, maxZoom]}
|
uniforms.zoomLimits.value={[minZoom, maxZoom]}
|
||||||
uniforms.dimensions.value={[width, height]}
|
uniforms.dimensions.value={[width, height]}
|
||||||
|
uniforms.gridType.value={gridType}
|
||||||
/>
|
/>
|
||||||
</T.Mesh>
|
</T.Mesh>
|
||||||
</T.Group>
|
</T.Group>
|
||||||
|
|||||||
@@ -5,19 +5,33 @@
|
|||||||
import { getGraphManager, getGraphState } from '../graph-state.svelte';
|
import { getGraphManager, getGraphState } from '../graph-state.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
paddingLeft?: number;
|
||||||
|
paddingRight?: number;
|
||||||
|
paddingTop?: number;
|
||||||
|
paddingBottom?: number;
|
||||||
onnode: (n: NodeInstance) => void;
|
onnode: (n: NodeInstance) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { onnode }: Props = $props();
|
const padding = 10;
|
||||||
|
|
||||||
|
const {
|
||||||
|
paddingLeft = padding,
|
||||||
|
paddingRight = padding,
|
||||||
|
paddingTop = padding,
|
||||||
|
paddingBottom = padding,
|
||||||
|
onnode
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const graph = getGraphManager();
|
const graph = getGraphManager();
|
||||||
const graphState = getGraphState();
|
const graphState = getGraphState();
|
||||||
|
|
||||||
let input: HTMLInputElement;
|
let input: HTMLInputElement;
|
||||||
let wrapper: HTMLDivElement;
|
|
||||||
let value = $state<string>();
|
let value = $state<string>();
|
||||||
let activeNodeId = $state<NodeId>();
|
let activeNodeId = $state<NodeId>();
|
||||||
|
|
||||||
|
const MENU_WIDTH = 150;
|
||||||
|
const MENU_HEIGHT = 350;
|
||||||
|
|
||||||
const allNodes = graphState.activeSocket
|
const allNodes = graphState.activeSocket
|
||||||
? graph.getPossibleNodes(graphState.activeSocket)
|
? graph.getPossibleNodes(graphState.activeSocket)
|
||||||
: graph.getNodeDefinitions();
|
: graph.getNodeDefinitions();
|
||||||
@@ -79,19 +93,52 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(() => {
|
onMount(() => {
|
||||||
input.disabled = false;
|
input.disabled = false;
|
||||||
setTimeout(() => input.focus(), 50);
|
setTimeout(() => input.focus(), 50);
|
||||||
|
|
||||||
const rect = wrapper.getBoundingClientRect();
|
|
||||||
const deltaY = rect.bottom - window.innerHeight;
|
|
||||||
const deltaX = rect.right - window.innerWidth;
|
|
||||||
if (deltaY > 0) {
|
|
||||||
wrapper.style.marginTop = `-${deltaY + 30}px`;
|
|
||||||
}
|
|
||||||
if (deltaX > 0) {
|
|
||||||
wrapper.style.marginLeft = `-${deltaX + 30}px`;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -100,7 +147,7 @@
|
|||||||
position.z={graphState.addMenuPosition?.[1]}
|
position.z={graphState.addMenuPosition?.[1]}
|
||||||
transform={false}
|
transform={false}
|
||||||
>
|
>
|
||||||
<div class="add-menu-wrapper" bind:this={wrapper}>
|
<div class="add-menu-wrapper">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<input
|
<input
|
||||||
id="add-menu"
|
id="add-menu"
|
||||||
@@ -136,7 +183,7 @@
|
|||||||
activeNodeId = node.id;
|
activeNodeId = node.id;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{node.id.split('/').at(-1)}
|
{node.meta?.title ?? node.id.split('/').at(-1)}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,19 +2,16 @@
|
|||||||
import { colors } from '../graph/colors.svelte';
|
import { colors } from '../graph/colors.svelte';
|
||||||
|
|
||||||
const circleMaterial = new MeshBasicMaterial({
|
const circleMaterial = new MeshBasicMaterial({
|
||||||
color: colors.edge.clone(),
|
color: colors.outline.clone(),
|
||||||
toneMapped: false
|
toneMapped: false
|
||||||
});
|
});
|
||||||
|
|
||||||
let lineColor = $state(colors.edge.clone().convertSRGBToLinear());
|
|
||||||
|
|
||||||
$effect.root(() => {
|
$effect.root(() => {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (appSettings.value.theme === undefined) {
|
if (appSettings.value.theme === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
circleMaterial.color = colors.edge.clone().convertSRGBToLinear();
|
circleMaterial.color = colors.outline.clone().convertSRGBToLinear();
|
||||||
lineColor = colors.edge.clone().convertSRGBToLinear();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,6 +32,7 @@
|
|||||||
import { CubicBezierCurve } from 'three/src/extras/curves/CubicBezierCurve.js';
|
import { CubicBezierCurve } from 'three/src/extras/curves/CubicBezierCurve.js';
|
||||||
import { Vector2 } from 'three/src/math/Vector2.js';
|
import { Vector2 } from 'three/src/math/Vector2.js';
|
||||||
import { getGraphState } from '../graph-state.svelte';
|
import { getGraphState } from '../graph-state.svelte';
|
||||||
|
import MeshGradientLineMaterial from './MeshGradientLine/MeshGradientLineMaterial.svelte';
|
||||||
|
|
||||||
const graphState = getGraphState();
|
const graphState = getGraphState();
|
||||||
|
|
||||||
@@ -45,12 +43,17 @@
|
|||||||
y2: number;
|
y2: number;
|
||||||
z: number;
|
z: number;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
inputType?: string;
|
||||||
|
outputType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { x1, y1, x2, y2, z, id }: Props = $props();
|
const { x1, y1, x2, y2, z, inputType = 'unknown', outputType = 'unknown', id }: Props = $props();
|
||||||
|
|
||||||
const thickness = $derived(Math.max(0.001, 0.00082 * Math.exp(0.055 * z)));
|
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 points = $state<Vector3[]>([]);
|
||||||
|
|
||||||
let lastId: string | null = null;
|
let lastId: string | null = null;
|
||||||
@@ -106,9 +109,9 @@
|
|||||||
position.z={y1}
|
position.z={y1}
|
||||||
position.y={0.8}
|
position.y={0.8}
|
||||||
rotation.x={-Math.PI / 2}
|
rotation.x={-Math.PI / 2}
|
||||||
material={circleMaterial}
|
|
||||||
>
|
>
|
||||||
<T.CircleGeometry args={[0.5, 16]} />
|
<T.CircleGeometry args={[0.5, 16]} />
|
||||||
|
<T.MeshBasicMaterial color={inputColor} toneMapped={false} />
|
||||||
</T.Mesh>
|
</T.Mesh>
|
||||||
|
|
||||||
<T.Mesh
|
<T.Mesh
|
||||||
@@ -119,6 +122,7 @@
|
|||||||
material={circleMaterial}
|
material={circleMaterial}
|
||||||
>
|
>
|
||||||
<T.CircleGeometry args={[0.5, 16]} />
|
<T.CircleGeometry args={[0.5, 16]} />
|
||||||
|
<T.MeshBasicMaterial color={outputColor} toneMapped={false} />
|
||||||
</T.Mesh>
|
</T.Mesh>
|
||||||
|
|
||||||
{#if graphState.hoveredEdgeId === id}
|
{#if graphState.hoveredEdgeId === id}
|
||||||
@@ -126,7 +130,8 @@
|
|||||||
<MeshLineGeometry {points} />
|
<MeshLineGeometry {points} />
|
||||||
<MeshLineMaterial
|
<MeshLineMaterial
|
||||||
width={thickness * 5}
|
width={thickness * 5}
|
||||||
color={lineColor}
|
color={inputColor}
|
||||||
|
tonemapped={false}
|
||||||
opacity={0.5}
|
opacity={0.5}
|
||||||
transparent
|
transparent
|
||||||
/>
|
/>
|
||||||
@@ -135,5 +140,10 @@
|
|||||||
|
|
||||||
<T.Mesh position.x={x1} position.z={y1} position.y={0.1}>
|
<T.Mesh position.x={x1} position.z={y1} position.y={0.1}>
|
||||||
<MeshLineGeometry {points} />
|
<MeshLineGeometry {points} />
|
||||||
<MeshLineMaterial width={thickness} color={lineColor} />
|
<MeshGradientLineMaterial
|
||||||
|
width={thickness}
|
||||||
|
colorStart={inputColor}
|
||||||
|
colorEnd={outputColor}
|
||||||
|
tonemapped={false}
|
||||||
|
/>
|
||||||
</T.Mesh>
|
</T.Mesh>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { T, useThrelte } from '@threlte/core';
|
||||||
|
import { Color, ShaderMaterial, Vector2 } from 'three';
|
||||||
|
import fragmentShader from './fragment.frag';
|
||||||
|
import type { MeshLineMaterialProps } from './types';
|
||||||
|
import vertexShader from './vertex.vert';
|
||||||
|
|
||||||
|
let {
|
||||||
|
opacity = 1,
|
||||||
|
colorStart = '#ffffff',
|
||||||
|
colorEnd = '#ffffff',
|
||||||
|
dashOffset = 0,
|
||||||
|
dashArray = 0,
|
||||||
|
dashRatio = 0,
|
||||||
|
attenuate = true,
|
||||||
|
width = 1,
|
||||||
|
scaleDown = 0,
|
||||||
|
alphaMap,
|
||||||
|
ref = $bindable(),
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MeshLineMaterialProps = $props();
|
||||||
|
|
||||||
|
let { invalidate, size } = useThrelte();
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
const uniforms = {
|
||||||
|
lineWidth: { value: width },
|
||||||
|
colorStart: { value: new Color(colorStart) },
|
||||||
|
colorEnd: { value: new Color(colorEnd) },
|
||||||
|
opacity: { value: opacity },
|
||||||
|
resolution: { value: new Vector2(1, 1) },
|
||||||
|
sizeAttenuation: { value: attenuate ? 1 : 0 },
|
||||||
|
dashArray: { value: dashArray },
|
||||||
|
useDash: { value: dashArray > 0 ? 1 : 0 },
|
||||||
|
dashOffset: { value: dashOffset },
|
||||||
|
dashRatio: { value: dashRatio },
|
||||||
|
scaleDown: { value: scaleDown / 10 },
|
||||||
|
alphaTest: { value: 0 },
|
||||||
|
alphaMap: { value: alphaMap },
|
||||||
|
useAlphaMap: { value: alphaMap ? 1 : 0 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const material = new ShaderMaterial({ uniforms });
|
||||||
|
|
||||||
|
$effect.pre(() => {
|
||||||
|
uniforms.lineWidth.value = width;
|
||||||
|
invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect.pre(() => {
|
||||||
|
uniforms.opacity.value = opacity;
|
||||||
|
invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect.pre(() => {
|
||||||
|
uniforms.resolution.value.set($size.width, $size.height);
|
||||||
|
invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect.pre(() => {
|
||||||
|
uniforms.sizeAttenuation.value = attenuate ? 1 : 0;
|
||||||
|
invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect.pre(() => {
|
||||||
|
uniforms.dashArray.value = dashArray;
|
||||||
|
uniforms.useDash.value = dashArray > 0 ? 1 : 0;
|
||||||
|
invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect.pre(() => {
|
||||||
|
uniforms.dashOffset.value = dashOffset;
|
||||||
|
invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect.pre(() => {
|
||||||
|
uniforms.dashRatio.value = dashRatio;
|
||||||
|
invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect.pre(() => {
|
||||||
|
uniforms.scaleDown.value = scaleDown / 10;
|
||||||
|
invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect.pre(() => {
|
||||||
|
uniforms.alphaMap.value = alphaMap;
|
||||||
|
uniforms.useAlphaMap.value = alphaMap ? 1 : 0;
|
||||||
|
invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect.pre(() => {
|
||||||
|
uniforms.colorStart.value.set(colorStart);
|
||||||
|
invalidate();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect.pre(() => {
|
||||||
|
uniforms.colorEnd.value.set(colorEnd);
|
||||||
|
invalidate();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<T
|
||||||
|
is={material}
|
||||||
|
bind:ref
|
||||||
|
{fragmentShader}
|
||||||
|
{vertexShader}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{@render children?.({ ref: material })}
|
||||||
|
</T>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
uniform vec3 colorStart;
|
||||||
|
uniform vec3 colorEnd;
|
||||||
|
|
||||||
|
uniform float useDash;
|
||||||
|
uniform float dashArray;
|
||||||
|
uniform float dashOffset;
|
||||||
|
uniform float dashRatio;
|
||||||
|
uniform sampler2D alphaMap;
|
||||||
|
uniform float useAlphaMap;
|
||||||
|
|
||||||
|
varying vec2 vUV;
|
||||||
|
varying vec4 vColor;
|
||||||
|
varying float vCounters;
|
||||||
|
|
||||||
|
vec4 CustomLinearTosRGB( in vec4 value ) {
|
||||||
|
return vec4( mix( pow( value.rgb, vec3( 0.41666 ) ) * 1.055 - vec3( 0.055 ), value.rgb * 12.92, vec3( lessThanEqual( value.rgb, vec3( 0.0031308 ) ) ) ), value.a );
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
|
||||||
|
vec4 c = mix(vec4(colorStart,1.0),vec4(colorEnd, 1.0), vCounters);
|
||||||
|
|
||||||
|
if( useAlphaMap == 1. ) c.a *= texture2D( alphaMap, vUV ).r;
|
||||||
|
|
||||||
|
if( useDash == 1. ){
|
||||||
|
c.a *= ceil(mod(vCounters + dashOffset, dashArray) - (dashArray * dashRatio));
|
||||||
|
}
|
||||||
|
|
||||||
|
gl_FragColor = CustomLinearTosRGB(c);
|
||||||
|
}
|
||||||
68
app/src/lib/graph-interface/edges/MeshGradientLine/types.ts
Normal file
68
app/src/lib/graph-interface/edges/MeshGradientLine/types.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Props } from '@threlte/core';
|
||||||
|
import type { BufferGeometry, Vector3 } from 'three';
|
||||||
|
import type { ColorRepresentation, ShaderMaterial, Texture } from 'three';
|
||||||
|
|
||||||
|
export type MeshLineGeometryProps = Props<BufferGeometry> & {
|
||||||
|
/**
|
||||||
|
* @default []
|
||||||
|
*/
|
||||||
|
points: Vector3[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @default 'none'
|
||||||
|
*/
|
||||||
|
shape?: 'none' | 'taper' | 'custom';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @default () => 1
|
||||||
|
*/
|
||||||
|
shapeFunction?: (p: number) => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MeshLineMaterialProps =
|
||||||
|
& Omit<
|
||||||
|
Props<ShaderMaterial>,
|
||||||
|
'uniforms' | 'fragmentShader' | 'vertexShader'
|
||||||
|
>
|
||||||
|
& {
|
||||||
|
/**
|
||||||
|
* @default 1
|
||||||
|
*/
|
||||||
|
opacity?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @default '#ffffff'
|
||||||
|
*/
|
||||||
|
color?: ColorRepresentation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
dashOffset?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
dashArray?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
dashRatio?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
attenuate?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @default 1
|
||||||
|
*/
|
||||||
|
width?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
scaleDown?: number;
|
||||||
|
alphaMap?: Texture | undefined;
|
||||||
|
};
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
attribute vec3 previous;
|
||||||
|
attribute vec3 next;
|
||||||
|
attribute float side;
|
||||||
|
attribute float width;
|
||||||
|
attribute float counters;
|
||||||
|
|
||||||
|
uniform vec2 resolution;
|
||||||
|
uniform float lineWidth;
|
||||||
|
uniform vec3 color;
|
||||||
|
uniform float opacity;
|
||||||
|
uniform float sizeAttenuation;
|
||||||
|
uniform float scaleDown;
|
||||||
|
|
||||||
|
varying vec2 vUV;
|
||||||
|
varying vec4 vColor;
|
||||||
|
varying float vCounters;
|
||||||
|
|
||||||
|
vec2 intoScreen(vec4 i) {
|
||||||
|
return resolution * (0.5 * i.xy / i.w + 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
float aspect = resolution.y / resolution.x;
|
||||||
|
|
||||||
|
mat4 m = projectionMatrix * modelViewMatrix;
|
||||||
|
|
||||||
|
vec4 currentClip = m * vec4( position, 1.0 );
|
||||||
|
vec4 prevClip = m * vec4( previous, 1.0 );
|
||||||
|
vec4 nextClip = m * vec4( next, 1.0 );
|
||||||
|
|
||||||
|
vec4 currentNormed = currentClip / currentClip.w;
|
||||||
|
vec4 prevNormed = prevClip / prevClip.w;
|
||||||
|
vec4 nextNormed = nextClip / nextClip.w;
|
||||||
|
|
||||||
|
vec2 currentScreen = intoScreen(currentNormed);
|
||||||
|
vec2 prevScreen = intoScreen(prevNormed);
|
||||||
|
vec2 nextScreen = intoScreen(nextNormed);
|
||||||
|
|
||||||
|
float actualWidth = lineWidth * width;
|
||||||
|
|
||||||
|
vec2 dir;
|
||||||
|
if(nextScreen == currentScreen) {
|
||||||
|
dir = normalize( currentScreen - prevScreen );
|
||||||
|
} else if(prevScreen == currentScreen) {
|
||||||
|
dir = normalize( nextScreen - currentScreen );
|
||||||
|
} else {
|
||||||
|
vec2 inDir = currentScreen - prevScreen;
|
||||||
|
vec2 outDir = nextScreen - currentScreen;
|
||||||
|
vec2 fullDir = nextScreen - prevScreen;
|
||||||
|
|
||||||
|
if(length(fullDir) > 0.0) {
|
||||||
|
dir = normalize(fullDir);
|
||||||
|
} else if(length(inDir) > 0.0){
|
||||||
|
dir = normalize(inDir);
|
||||||
|
} else {
|
||||||
|
dir = normalize(outDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vec2 normal = vec2(-dir.y, dir.x);
|
||||||
|
|
||||||
|
if(sizeAttenuation != 0.0) {
|
||||||
|
normal /= currentClip.w;
|
||||||
|
normal *= min(resolution.x, resolution.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scaleDown > 0.0) {
|
||||||
|
float dist = length(nextNormed - prevNormed);
|
||||||
|
normal *= smoothstep(0.0, scaleDown, dist);
|
||||||
|
}
|
||||||
|
|
||||||
|
vec2 offsetInScreen = actualWidth * normal * side * 0.5;
|
||||||
|
|
||||||
|
vec2 withOffsetScreen = currentScreen + offsetInScreen;
|
||||||
|
vec3 withOffsetNormed = vec3((2.0 * withOffsetScreen/resolution - 1.0), currentNormed.z);
|
||||||
|
|
||||||
|
vCounters = counters;
|
||||||
|
vColor = vec4( color, opacity );
|
||||||
|
vUV = uv;
|
||||||
|
|
||||||
|
gl_Position = currentClip.w * vec4(withOffsetNormed, 1.0);
|
||||||
|
|
||||||
|
}
|
||||||
265
app/src/lib/graph-interface/graph-manager.svelte.test.ts
Normal file
265
app/src/lib/graph-interface/graph-manager.svelte.test.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { GraphManager } from './graph-manager.svelte';
|
||||||
|
import {
|
||||||
|
createMockNodeRegistry,
|
||||||
|
mockFloatInputNode,
|
||||||
|
mockFloatOutputNode,
|
||||||
|
mockGeometryOutputNode,
|
||||||
|
mockPathInputNode,
|
||||||
|
mockVec3OutputNode
|
||||||
|
} from './test-utils';
|
||||||
|
|
||||||
|
describe('GraphManager', () => {
|
||||||
|
describe('getPossibleSockets', () => {
|
||||||
|
describe('when dragging an output socket', () => {
|
||||||
|
it('should return compatible input sockets based on type', () => {
|
||||||
|
const registry = createMockNodeRegistry([
|
||||||
|
mockFloatOutputNode,
|
||||||
|
mockFloatInputNode,
|
||||||
|
mockGeometryOutputNode,
|
||||||
|
mockPathInputNode
|
||||||
|
]);
|
||||||
|
|
||||||
|
const manager = new GraphManager(registry);
|
||||||
|
|
||||||
|
const floatInputNode = manager.createNode({
|
||||||
|
type: 'test/node/input',
|
||||||
|
position: [100, 100],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const floatOutputNode = manager.createNode({
|
||||||
|
type: 'test/node/output',
|
||||||
|
position: [0, 0],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(floatInputNode).toBeDefined();
|
||||||
|
expect(floatOutputNode).toBeDefined();
|
||||||
|
|
||||||
|
const possibleSockets = manager.getPossibleSockets({
|
||||||
|
node: floatOutputNode!,
|
||||||
|
index: 0,
|
||||||
|
position: [0, 0]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(possibleSockets.length).toBe(1);
|
||||||
|
const socketNodeIds = possibleSockets.map(([node]) => node.id);
|
||||||
|
expect(socketNodeIds).toContain(floatInputNode!.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude self node from possible sockets', () => {
|
||||||
|
const registry = createMockNodeRegistry([
|
||||||
|
mockFloatOutputNode,
|
||||||
|
mockFloatInputNode
|
||||||
|
]);
|
||||||
|
|
||||||
|
const manager = new GraphManager(registry);
|
||||||
|
|
||||||
|
const floatInputNode = manager.createNode({
|
||||||
|
type: 'test/node/input',
|
||||||
|
position: [100, 100],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(floatInputNode).toBeDefined();
|
||||||
|
|
||||||
|
const possibleSockets = manager.getPossibleSockets({
|
||||||
|
node: floatInputNode!,
|
||||||
|
index: 'value',
|
||||||
|
position: [0, 0]
|
||||||
|
});
|
||||||
|
|
||||||
|
const socketNodeIds = possibleSockets.map(([node]) => node.id);
|
||||||
|
expect(socketNodeIds).not.toContain(floatInputNode!.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude parent nodes from possible sockets when dragging output', () => {
|
||||||
|
const registry = createMockNodeRegistry([
|
||||||
|
mockFloatOutputNode,
|
||||||
|
mockFloatInputNode
|
||||||
|
]);
|
||||||
|
|
||||||
|
const manager = new GraphManager(registry);
|
||||||
|
|
||||||
|
const parentNode = manager.createNode({
|
||||||
|
type: 'test/node/output',
|
||||||
|
position: [0, 0],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const childNode = manager.createNode({
|
||||||
|
type: 'test/node/input',
|
||||||
|
position: [100, 100],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parentNode).toBeDefined();
|
||||||
|
expect(childNode).toBeDefined();
|
||||||
|
|
||||||
|
if (parentNode && childNode) {
|
||||||
|
manager.createEdge(parentNode, 0, childNode, 'value');
|
||||||
|
}
|
||||||
|
|
||||||
|
const possibleSockets = manager.getPossibleSockets({
|
||||||
|
node: parentNode!,
|
||||||
|
index: 0,
|
||||||
|
position: [0, 0]
|
||||||
|
});
|
||||||
|
|
||||||
|
const socketNodeIds = possibleSockets.map(([node]) => node.id);
|
||||||
|
expect(socketNodeIds).not.toContain(childNode!.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return sockets compatible with accepts property', () => {
|
||||||
|
const registry = createMockNodeRegistry([
|
||||||
|
mockGeometryOutputNode,
|
||||||
|
mockPathInputNode
|
||||||
|
]);
|
||||||
|
|
||||||
|
const manager = new GraphManager(registry);
|
||||||
|
|
||||||
|
const geometryOutputNode = manager.createNode({
|
||||||
|
type: 'test/node/geometry',
|
||||||
|
position: [0, 0],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pathInputNode = manager.createNode({
|
||||||
|
type: 'test/node/path',
|
||||||
|
position: [100, 100],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(geometryOutputNode).toBeDefined();
|
||||||
|
expect(pathInputNode).toBeDefined();
|
||||||
|
|
||||||
|
const possibleSockets = manager.getPossibleSockets({
|
||||||
|
node: geometryOutputNode!,
|
||||||
|
index: 0,
|
||||||
|
position: [0, 0]
|
||||||
|
});
|
||||||
|
|
||||||
|
const socketNodeIds = possibleSockets.map(([node]) => node.id);
|
||||||
|
expect(socketNodeIds).toContain(pathInputNode!.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no compatible sockets exist', () => {
|
||||||
|
const registry = createMockNodeRegistry([
|
||||||
|
mockVec3OutputNode,
|
||||||
|
mockFloatInputNode
|
||||||
|
]);
|
||||||
|
|
||||||
|
const manager = new GraphManager(registry);
|
||||||
|
|
||||||
|
const vec3OutputNode = manager.createNode({
|
||||||
|
type: 'test/node/vec3',
|
||||||
|
position: [0, 0],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const floatInputNode = manager.createNode({
|
||||||
|
type: 'test/node/input',
|
||||||
|
position: [100, 100],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(vec3OutputNode).toBeDefined();
|
||||||
|
expect(floatInputNode).toBeDefined();
|
||||||
|
|
||||||
|
const possibleSockets = manager.getPossibleSockets({
|
||||||
|
node: vec3OutputNode!,
|
||||||
|
index: 0,
|
||||||
|
position: [0, 0]
|
||||||
|
});
|
||||||
|
|
||||||
|
const socketNodeIds = possibleSockets.map(([node]) => node.id);
|
||||||
|
expect(socketNodeIds).not.toContain(floatInputNode!.id);
|
||||||
|
expect(possibleSockets.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return socket info with correct socket key for inputs', () => {
|
||||||
|
const registry = createMockNodeRegistry([
|
||||||
|
mockFloatOutputNode,
|
||||||
|
mockFloatInputNode
|
||||||
|
]);
|
||||||
|
|
||||||
|
const manager = new GraphManager(registry);
|
||||||
|
|
||||||
|
const floatOutputNode = manager.createNode({
|
||||||
|
type: 'test/node/output',
|
||||||
|
position: [0, 0],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const floatInputNode = manager.createNode({
|
||||||
|
type: 'test/node/input',
|
||||||
|
position: [100, 100],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(floatOutputNode).toBeDefined();
|
||||||
|
expect(floatInputNode).toBeDefined();
|
||||||
|
|
||||||
|
const possibleSockets = manager.getPossibleSockets({
|
||||||
|
node: floatOutputNode!,
|
||||||
|
index: 0,
|
||||||
|
position: [0, 0]
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchingSocket = possibleSockets.find(([node]) => node.id === floatInputNode!.id);
|
||||||
|
expect(matchingSocket).toBeDefined();
|
||||||
|
expect(matchingSocket![1]).toBe('value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return multiple compatible sockets', () => {
|
||||||
|
const registry = createMockNodeRegistry([
|
||||||
|
mockFloatOutputNode,
|
||||||
|
mockFloatInputNode,
|
||||||
|
mockGeometryOutputNode,
|
||||||
|
mockPathInputNode
|
||||||
|
]);
|
||||||
|
|
||||||
|
const manager = new GraphManager(registry);
|
||||||
|
|
||||||
|
const floatOutputNode = manager.createNode({
|
||||||
|
type: 'test/node/output',
|
||||||
|
position: [0, 0],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const geometryOutputNode = manager.createNode({
|
||||||
|
type: 'test/node/geometry',
|
||||||
|
position: [200, 0],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const floatInputNode = manager.createNode({
|
||||||
|
type: 'test/node/input',
|
||||||
|
position: [100, 100],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pathInputNode = manager.createNode({
|
||||||
|
type: 'test/node/path',
|
||||||
|
position: [300, 100],
|
||||||
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(floatOutputNode).toBeDefined();
|
||||||
|
expect(geometryOutputNode).toBeDefined();
|
||||||
|
expect(floatInputNode).toBeDefined();
|
||||||
|
expect(pathInputNode).toBeDefined();
|
||||||
|
|
||||||
|
const possibleSocketsForFloat = manager.getPossibleSockets({
|
||||||
|
node: floatOutputNode!,
|
||||||
|
index: 0,
|
||||||
|
position: [0, 0]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(possibleSocketsForFloat.length).toBe(1);
|
||||||
|
expect(possibleSocketsForFloat.map(([n]) => n.id)).toContain(floatInputNode!.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,11 +3,14 @@ import { RemoteNodeRegistry } from '$lib/node-registry/index';
|
|||||||
import type {
|
import type {
|
||||||
Edge,
|
Edge,
|
||||||
Graph,
|
Graph,
|
||||||
|
GroupSocket,
|
||||||
NodeDefinition,
|
NodeDefinition,
|
||||||
|
NodeGroupDefinition,
|
||||||
NodeId,
|
NodeId,
|
||||||
NodeInput,
|
NodeInput,
|
||||||
NodeInstance,
|
NodeInstance,
|
||||||
NodeRegistry,
|
NodeRegistry,
|
||||||
|
SerializedNode,
|
||||||
Socket
|
Socket
|
||||||
} from '@nodarium/types';
|
} from '@nodarium/types';
|
||||||
import { fastHashString } from '@nodarium/utils';
|
import { fastHashString } from '@nodarium/utils';
|
||||||
@@ -29,8 +32,9 @@ function areSocketsCompatible(
|
|||||||
output: string | undefined,
|
output: string | undefined,
|
||||||
inputs: string | (string | undefined)[] | undefined
|
inputs: string | (string | undefined)[] | undefined
|
||||||
) {
|
) {
|
||||||
|
if (output === '*') return true;
|
||||||
if (Array.isArray(inputs) && output) {
|
if (Array.isArray(inputs) && output) {
|
||||||
return inputs.includes(output);
|
return inputs.includes('*') || inputs.includes(output);
|
||||||
}
|
}
|
||||||
return inputs === output;
|
return inputs === output;
|
||||||
}
|
}
|
||||||
@@ -55,6 +59,14 @@ function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isVirtualType(type: string): boolean {
|
||||||
|
return type.startsWith('__virtual/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGroupInstanceType(type: string): boolean {
|
||||||
|
return type === '__virtual/group/instance';
|
||||||
|
}
|
||||||
|
|
||||||
export class GraphManager extends EventEmitter<{
|
export class GraphManager extends EventEmitter<{
|
||||||
save: Graph;
|
save: Graph;
|
||||||
result: unknown;
|
result: unknown;
|
||||||
@@ -78,6 +90,12 @@ export class GraphManager extends EventEmitter<{
|
|||||||
|
|
||||||
currentUndoGroup: number | null = null;
|
currentUndoGroup: number | null = null;
|
||||||
|
|
||||||
|
// Group-related state
|
||||||
|
groups: Map<string, NodeGroupDefinition> = new Map();
|
||||||
|
groupNodeDefinitions: Map<string, NodeDefinition> = new Map();
|
||||||
|
currentGroupContext: string | null = null;
|
||||||
|
graphStack: { rootGraph: Graph; groupId: string; cameraPosition: [number, number, number] }[] = $state([]);
|
||||||
|
|
||||||
inputSockets = $derived.by(() => {
|
inputSockets = $derived.by(() => {
|
||||||
const s = new SvelteSet<string>();
|
const s = new SvelteSet<string>();
|
||||||
for (const edge of this.edges) {
|
for (const edge of this.edges) {
|
||||||
@@ -87,37 +105,523 @@ export class GraphManager extends EventEmitter<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
history: HistoryManager = new HistoryManager();
|
history: HistoryManager = new HistoryManager();
|
||||||
|
private serializeFullGraph(): Graph {
|
||||||
|
if (this.graphStack.length === 0) return this.serialize();
|
||||||
|
// Merge the current internal state upward through every stack level.
|
||||||
|
// $state.snapshot strips Svelte reactive proxies so the result can cross
|
||||||
|
// the postMessage boundary to the worker.
|
||||||
|
let merged: Graph = this.serialize();
|
||||||
|
for (let i = this.graphStack.length - 1; i >= 0; i--) {
|
||||||
|
const { rootGraph, groupId } = $state.snapshot(this.graphStack[i]);
|
||||||
|
merged = {
|
||||||
|
...rootGraph,
|
||||||
|
groups: {
|
||||||
|
...rootGraph.groups,
|
||||||
|
[groupId]: {
|
||||||
|
...rootGraph.groups?.[groupId]!,
|
||||||
|
graph: { nodes: merged.nodes, edges: merged.edges }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
execute = throttle(() => {
|
execute = throttle(() => {
|
||||||
if (this.loaded === false) return;
|
if (this.loaded === false) return;
|
||||||
this.emit('result', this.serialize());
|
this.emit('result', this.serializeFullGraph());
|
||||||
}, 10);
|
}, 10);
|
||||||
|
|
||||||
constructor(public registry: NodeRegistry) {
|
constructor(public registry: NodeRegistry) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Group helpers ---
|
||||||
|
|
||||||
|
private buildGroupNodeDefinition(group: NodeGroupDefinition): NodeDefinition {
|
||||||
|
return {
|
||||||
|
id: `__virtual/group/${group.id}` as NodeId,
|
||||||
|
meta: { title: group.name },
|
||||||
|
inputs: Object.fromEntries(
|
||||||
|
group.inputs.map(s => [s.name, { type: s.type, external: true } as NodeInput])
|
||||||
|
),
|
||||||
|
outputs: group.outputs.map(s => s.type),
|
||||||
|
execute(input: Int32Array): Int32Array { return input; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
buildGroupInputNodeDef(group: NodeGroupDefinition): NodeDefinition {
|
||||||
|
return {
|
||||||
|
id: '__virtual/group/input' as NodeId,
|
||||||
|
inputs: {},
|
||||||
|
outputs: group.inputs.map(s => s.type),
|
||||||
|
execute(input: Int32Array): Int32Array { return input; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
buildGroupOutputNodeDef(group: NodeGroupDefinition): NodeDefinition {
|
||||||
|
return {
|
||||||
|
id: '__virtual/group/output' as NodeId,
|
||||||
|
inputs: Object.fromEntries(
|
||||||
|
group.outputs.map(s => [s.name, { type: s.type }])
|
||||||
|
) as Record<string, NodeInput>,
|
||||||
|
outputs: [],
|
||||||
|
execute(input: Int32Array): Int32Array { return input; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNodeTypeWithContext(type: string, props?: Record<string, unknown>): NodeDefinition | undefined {
|
||||||
|
if (type === '__virtual/group/input' && this.currentGroupContext) {
|
||||||
|
const group = this.groups.get(this.currentGroupContext);
|
||||||
|
if (group) return this.buildGroupInputNodeDef(group);
|
||||||
|
}
|
||||||
|
if (type === '__virtual/group/output' && this.currentGroupContext) {
|
||||||
|
const group = this.groups.get(this.currentGroupContext);
|
||||||
|
if (group) return this.buildGroupOutputNodeDef(group);
|
||||||
|
}
|
||||||
|
if (type === '__virtual/group/instance') {
|
||||||
|
const groupId = props?.groupId as string | undefined;
|
||||||
|
if (groupId) return this.groupNodeDefinitions.get(`__virtual/group/${groupId}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return this.groupNodeDefinitions.get(type) || this.registry.getNode(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Group creation ---
|
||||||
|
|
||||||
|
createGroup(nodeIds: number[]): NodeInstance | undefined {
|
||||||
|
if (nodeIds.length === 0) return;
|
||||||
|
|
||||||
|
const selectedNodes = nodeIds
|
||||||
|
.map(id => this.getNode(id))
|
||||||
|
.filter(Boolean) as NodeInstance[];
|
||||||
|
if (selectedNodes.length === 0) return;
|
||||||
|
|
||||||
|
const selectedSet = new Set(nodeIds);
|
||||||
|
|
||||||
|
// Snapshot boundary edges
|
||||||
|
const incomingEdges = this.edges.filter(e =>
|
||||||
|
!selectedSet.has(e[0].id) && selectedSet.has(e[2].id)
|
||||||
|
);
|
||||||
|
const outgoingEdges = this.edges.filter(e =>
|
||||||
|
selectedSet.has(e[0].id) && !selectedSet.has(e[2].id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputs: GroupSocket[] = incomingEdges.map((e, i) => ({
|
||||||
|
name: `input_${i}`,
|
||||||
|
type: e[0].state.type?.outputs?.[e[1]] || '*'
|
||||||
|
}));
|
||||||
|
|
||||||
|
const outputs: GroupSocket[] = outgoingEdges.map((e, i) => ({
|
||||||
|
name: `output_${i}`,
|
||||||
|
type: e[0].state.type?.outputs?.[e[1]] || '*'
|
||||||
|
}));
|
||||||
|
|
||||||
|
const groupId = `grp_${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
const xs = selectedNodes.map(n => n.position[0]);
|
||||||
|
const ys = selectedNodes.map(n => n.position[1]);
|
||||||
|
const minX = Math.min(...xs);
|
||||||
|
const maxX = Math.max(...xs);
|
||||||
|
const avgY = ys.reduce((a, b) => a + b, 0) / ys.length;
|
||||||
|
const centroidX = xs.reduce((a, b) => a + b, 0) / xs.length;
|
||||||
|
|
||||||
|
// Find unique IDs for virtual nodes in the internal graph
|
||||||
|
const existingIds = new Set(selectedNodes.map(n => n.id));
|
||||||
|
let internalInputId = 1;
|
||||||
|
while (existingIds.has(internalInputId)) internalInputId++;
|
||||||
|
existingIds.add(internalInputId);
|
||||||
|
let internalOutputId = internalInputId + 1;
|
||||||
|
while (existingIds.has(internalOutputId)) internalOutputId++;
|
||||||
|
|
||||||
|
const internalNodes: SerializedNode[] = [
|
||||||
|
{
|
||||||
|
id: internalInputId,
|
||||||
|
type: '__virtual/group/input' as NodeId,
|
||||||
|
position: [minX - 25, avgY]
|
||||||
|
},
|
||||||
|
...selectedNodes.map(n => {
|
||||||
|
// Use $state.snapshot to get plain values (no reactive proxies)
|
||||||
|
const props = n.props ? $state.snapshot(n.props) : undefined;
|
||||||
|
const meta = n.meta ? $state.snapshot(n.meta) : undefined;
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
type: n.type,
|
||||||
|
position: [n.position[0], n.position[1]] as [number, number],
|
||||||
|
...(props !== undefined ? { props } : {}),
|
||||||
|
...(meta ? { meta } : {})
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
id: internalOutputId,
|
||||||
|
type: '__virtual/group/output' as NodeId,
|
||||||
|
position: [maxX + 25, avgY]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const internalEdges: Graph['edges'] = [
|
||||||
|
...this.getEdgesBetweenNodes(selectedNodes),
|
||||||
|
...incomingEdges.map((e, i) =>
|
||||||
|
[internalInputId, i, e[2].id, e[3]] as [number, number, number, string]
|
||||||
|
),
|
||||||
|
...outgoingEdges.map((e, i) =>
|
||||||
|
[e[0].id, e[1], internalOutputId, `output_${i}`] as [number, number, number, string]
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
const group: NodeGroupDefinition = {
|
||||||
|
id: groupId,
|
||||||
|
name: 'Group',
|
||||||
|
inputs,
|
||||||
|
outputs,
|
||||||
|
graph: { nodes: internalNodes, edges: internalEdges }
|
||||||
|
};
|
||||||
|
|
||||||
|
this.groups.set(groupId, group);
|
||||||
|
if (!this.graph.groups) this.graph.groups = {};
|
||||||
|
this.graph.groups[groupId] = group;
|
||||||
|
|
||||||
|
const groupNodeDef = this.buildGroupNodeDefinition(group);
|
||||||
|
this.groupNodeDefinitions.set(groupNodeDef.id, groupNodeDef);
|
||||||
|
|
||||||
|
this.startUndoGroup();
|
||||||
|
|
||||||
|
// Remove selected nodes and all their edges
|
||||||
|
for (const node of selectedNodes) {
|
||||||
|
const connectedEdges = this.edges.filter(
|
||||||
|
e => e[0].id === node.id || e[2].id === node.id
|
||||||
|
);
|
||||||
|
for (const e of connectedEdges) {
|
||||||
|
this.removeEdge(e, { applyDeletion: false });
|
||||||
|
}
|
||||||
|
this.nodes.delete(node.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place group instance node (plain object like _init — don't wrap in $state()
|
||||||
|
// to avoid Svelte 5 deeply-proxying the NodeDefinition execute function)
|
||||||
|
const groupNodeId = this.createNodeId();
|
||||||
|
const groupNode = {
|
||||||
|
id: groupNodeId,
|
||||||
|
type: '__virtual/group/instance' as NodeId,
|
||||||
|
position: [centroidX, avgY] as [number, number],
|
||||||
|
props: { groupId },
|
||||||
|
state: { type: groupNodeDef }
|
||||||
|
} as NodeInstance;
|
||||||
|
this.nodes.set(groupNodeId, groupNode);
|
||||||
|
|
||||||
|
// Reconnect boundary edges
|
||||||
|
for (let i = 0; i < incomingEdges.length; i++) {
|
||||||
|
const e = incomingEdges[i];
|
||||||
|
this.createEdge(e[0], e[1], groupNode, inputs[i].name, { applyUpdate: false });
|
||||||
|
}
|
||||||
|
for (let i = 0; i < outgoingEdges.length; i++) {
|
||||||
|
const e = outgoingEdges[i];
|
||||||
|
this.createEdge(groupNode, i, e[2], e[3], { applyUpdate: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveUndoGroup();
|
||||||
|
this.execute();
|
||||||
|
|
||||||
|
return groupNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ungrouping ---
|
||||||
|
|
||||||
|
ungroup(nodeId: number) {
|
||||||
|
const groupNode = this.getNode(nodeId);
|
||||||
|
if (!groupNode || !isGroupInstanceType(groupNode.type)) return;
|
||||||
|
|
||||||
|
const groupId = groupNode.props?.groupId as string | undefined;
|
||||||
|
if (!groupId) return;
|
||||||
|
const group = this.groups.get(groupId);
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
const incomingEdges = this.getEdgesToNode(groupNode);
|
||||||
|
const outgoingEdges = this.getEdgesFromNode(groupNode);
|
||||||
|
|
||||||
|
const inputVirtualId = group.graph.nodes.find(
|
||||||
|
n => n.type === '__virtual/group/input'
|
||||||
|
)?.id;
|
||||||
|
const outputVirtualId = group.graph.nodes.find(
|
||||||
|
n => n.type === '__virtual/group/output'
|
||||||
|
)?.id;
|
||||||
|
|
||||||
|
this.startUndoGroup();
|
||||||
|
|
||||||
|
// Remove the group instance node (and its edges)
|
||||||
|
this.removeNode(groupNode, { restoreEdges: false });
|
||||||
|
|
||||||
|
// Re-insert internal nodes
|
||||||
|
const idMap = new Map<number, number>();
|
||||||
|
const realInternalNodes = group.graph.nodes.filter(
|
||||||
|
n => n.type !== '__virtual/group/input' && n.type !== '__virtual/group/output'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const n of realInternalNodes) {
|
||||||
|
const newId = this.createNodeId();
|
||||||
|
idMap.set(n.id, newId);
|
||||||
|
const nodeType = this.getNodeTypeWithContext(n.type, n.props as Record<string, unknown>);
|
||||||
|
const newNode: NodeInstance = $state({
|
||||||
|
id: newId,
|
||||||
|
type: n.type,
|
||||||
|
position: [...n.position] as [number, number],
|
||||||
|
...(n.props ? { props: { ...n.props } } : {}),
|
||||||
|
state: nodeType ? { type: nodeType } : {}
|
||||||
|
});
|
||||||
|
this.nodes.set(newId, newNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-wire edges
|
||||||
|
for (const e of group.graph.edges) {
|
||||||
|
const fromIsInput = e[0] === inputVirtualId;
|
||||||
|
const toIsOutput = e[2] === outputVirtualId;
|
||||||
|
|
||||||
|
if (fromIsInput) {
|
||||||
|
const inputIdx = e[1];
|
||||||
|
const parentEdge = incomingEdges.find(
|
||||||
|
pe => pe[3] === group.inputs[inputIdx]?.name
|
||||||
|
);
|
||||||
|
if (parentEdge) {
|
||||||
|
const toNode = this.getNode(idMap.get(e[2])!);
|
||||||
|
if (toNode) {
|
||||||
|
this.createEdge(parentEdge[0], parentEdge[1], toNode, e[3], {
|
||||||
|
applyUpdate: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (toIsOutput) {
|
||||||
|
const outputSocketName = e[3];
|
||||||
|
const outputIdx = group.outputs.findIndex(s => s.name === outputSocketName);
|
||||||
|
const parentEdge = outgoingEdges.find(pe => pe[1] === outputIdx);
|
||||||
|
if (parentEdge) {
|
||||||
|
const fromNode = this.getNode(idMap.get(e[0])!);
|
||||||
|
const toNode = this.getNode(parentEdge[2].id);
|
||||||
|
if (fromNode && toNode) {
|
||||||
|
this.createEdge(fromNode, e[1], toNode, parentEdge[3], {
|
||||||
|
applyUpdate: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const fromNode = this.getNode(idMap.get(e[0])!);
|
||||||
|
const toNode = this.getNode(idMap.get(e[2])!);
|
||||||
|
if (fromNode && toNode) {
|
||||||
|
this.createEdge(fromNode, e[1], toNode, e[3], { applyUpdate: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove group definition if no more instances
|
||||||
|
const hasOtherInstances = Array.from(this.nodes.values()).some(
|
||||||
|
n => n.type === '__virtual/group/instance' && (n.props?.groupId as string) === groupId
|
||||||
|
);
|
||||||
|
if (!hasOtherInstances) {
|
||||||
|
this.groups.delete(groupId);
|
||||||
|
this.groupNodeDefinitions.delete(`__virtual/group/${groupId}`);
|
||||||
|
if (this.graph.groups) {
|
||||||
|
delete this.graph.groups[groupId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveUndoGroup();
|
||||||
|
this.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Group socket management (called from inside a group) ---
|
||||||
|
|
||||||
|
addGroupSocket(kind: 'input' | 'output', socketType: string) {
|
||||||
|
if (!this.currentGroupContext) return;
|
||||||
|
const group = this.groups.get(this.currentGroupContext);
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
const arr = kind === 'input' ? group.inputs : group.outputs;
|
||||||
|
const name = `${kind}_${arr.length}`;
|
||||||
|
arr.push({ name, type: socketType });
|
||||||
|
|
||||||
|
this._refreshGroupContext(group);
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeGroupSocket(kind: 'input' | 'output', index: number) {
|
||||||
|
if (!this.currentGroupContext) return;
|
||||||
|
const group = this.groups.get(this.currentGroupContext);
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
const arr = kind === 'input' ? group.inputs : group.outputs;
|
||||||
|
arr.splice(index, 1);
|
||||||
|
|
||||||
|
this._refreshGroupContext(group);
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _refreshGroupContext(group: NodeGroupDefinition) {
|
||||||
|
const groupId = group.id;
|
||||||
|
|
||||||
|
// Keep graph.groups in sync
|
||||||
|
if (this.graph.groups?.[groupId]) {
|
||||||
|
this.graph.groups[groupId] = group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the group node definition (used in parent graph)
|
||||||
|
const groupNodeDef = this.buildGroupNodeDefinition(group);
|
||||||
|
this.groupNodeDefinitions.set(groupNodeDef.id, groupNodeDef);
|
||||||
|
|
||||||
|
// Update virtual input/output nodes in the current internal graph,
|
||||||
|
// and any group instance nodes that reference this group
|
||||||
|
const inputDef = this.buildGroupInputNodeDef(group);
|
||||||
|
const outputDef = this.buildGroupOutputNodeDef(group);
|
||||||
|
for (const node of this.nodes.values()) {
|
||||||
|
if (node.type === '__virtual/group/input') node.state.type = inputDef;
|
||||||
|
if (node.type === '__virtual/group/output') node.state.type = outputDef;
|
||||||
|
if (node.type === '__virtual/group/instance' && (node.props?.groupId as string) === groupId) {
|
||||||
|
node.state.type = groupNodeDef;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Group navigation ---
|
||||||
|
|
||||||
|
enterGroup(nodeId: number, cameraPosition: [number, number, number] = [0, 0, 4]): boolean {
|
||||||
|
const groupNode = this.getNode(nodeId);
|
||||||
|
if (!groupNode || !isGroupInstanceType(groupNode.type)) return false;
|
||||||
|
|
||||||
|
const groupId = groupNode.props?.groupId as string | undefined;
|
||||||
|
if (!groupId) return false;
|
||||||
|
const group = this.groups.get(groupId);
|
||||||
|
if (!group) return false;
|
||||||
|
|
||||||
|
const currentSerialized = this.serialize();
|
||||||
|
this.graphStack.push({ rootGraph: currentSerialized, groupId, cameraPosition });
|
||||||
|
|
||||||
|
this.currentGroupContext = groupId;
|
||||||
|
|
||||||
|
const internalGraph: Graph = {
|
||||||
|
id: this.graph.id,
|
||||||
|
nodes: group.graph.nodes,
|
||||||
|
edges: group.graph.edges,
|
||||||
|
groups: this.graph.groups
|
||||||
|
};
|
||||||
|
|
||||||
|
this.graph = internalGraph;
|
||||||
|
this._init(internalGraph);
|
||||||
|
this.history.reset();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
exitGroup(): [number, number, number] | false {
|
||||||
|
if (this.graphStack.length === 0) return false;
|
||||||
|
|
||||||
|
const { rootGraph, groupId, cameraPosition } = this.graphStack[this.graphStack.length - 1];
|
||||||
|
this.graphStack.pop();
|
||||||
|
|
||||||
|
// Serialize current internal graph state
|
||||||
|
const internalState = this.serialize();
|
||||||
|
|
||||||
|
// Update the group definition in the root graph
|
||||||
|
const updatedRootGraph: Graph = {
|
||||||
|
...rootGraph,
|
||||||
|
groups: {
|
||||||
|
...rootGraph.groups,
|
||||||
|
[groupId]: {
|
||||||
|
...rootGraph.groups?.[groupId]!,
|
||||||
|
graph: {
|
||||||
|
nodes: internalState.nodes,
|
||||||
|
edges: internalState.edges
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.currentGroupContext = this.graphStack.length > 0
|
||||||
|
? this.graphStack[this.graphStack.length - 1].groupId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
this.graph = updatedRootGraph;
|
||||||
|
this._init(updatedRootGraph);
|
||||||
|
this.history.reset();
|
||||||
|
this.save();
|
||||||
|
|
||||||
|
return cameraPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isInsideGroup(): boolean {
|
||||||
|
return this.graphStack.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get breadcrumbs(): { name: string; groupId: string | null }[] {
|
||||||
|
const crumbs: { name: string; groupId: string | null }[] = [
|
||||||
|
{ name: 'Root', groupId: null }
|
||||||
|
];
|
||||||
|
for (const entry of this.graphStack) {
|
||||||
|
const group = this.groups.get(entry.groupId);
|
||||||
|
crumbs.push({ name: group?.name ?? entry.groupId, groupId: entry.groupId });
|
||||||
|
}
|
||||||
|
return crumbs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Serialization ---
|
||||||
|
|
||||||
|
private serializeGroups(): Graph['groups'] | undefined {
|
||||||
|
const src = this.graph.groups;
|
||||||
|
if (!src || Object.keys(src).length === 0) return undefined;
|
||||||
|
const result: NonNullable<Graph['groups']> = {};
|
||||||
|
for (const [id, group] of Object.entries(src)) {
|
||||||
|
result[id] = {
|
||||||
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
|
inputs: group.inputs.map(s => ({ name: s.name, type: s.type })),
|
||||||
|
outputs: group.outputs.map(s => ({ name: s.name, type: s.type })),
|
||||||
|
graph: {
|
||||||
|
nodes: group.graph.nodes.map(n => ({
|
||||||
|
id: n.id,
|
||||||
|
type: n.type,
|
||||||
|
position: [n.position[0], n.position[1]] as [number, number],
|
||||||
|
...(n.props !== undefined ? {
|
||||||
|
props: Object.fromEntries(
|
||||||
|
Object.entries(n.props).map(([k, v]) => [
|
||||||
|
k,
|
||||||
|
Array.isArray(v) ? [...v] : v
|
||||||
|
])
|
||||||
|
)
|
||||||
|
} : {}),
|
||||||
|
...(n.meta ? { meta: { title: n.meta.title, lastModified: n.meta.lastModified } } : {})
|
||||||
|
})),
|
||||||
|
edges: group.graph.edges.map(
|
||||||
|
e => [e[0], e[1], e[2], e[3]] as [number, number, number, string]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
serialize(): Graph {
|
serialize(): Graph {
|
||||||
const nodes = Array.from(this.nodes.values()).map((node) => ({
|
const nodes = Array.from(this.nodes.values()).map((node) => ({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
position: [...node.position],
|
position: [...node.position] as [number, number],
|
||||||
type: node.type,
|
type: node.type,
|
||||||
props: node.props
|
props: node.props ? $state.snapshot(node.props) : undefined
|
||||||
})) as NodeInstance[];
|
}));
|
||||||
const edges = this.edges.map((edge) => [
|
const edges = this.edges.map((edge) => [
|
||||||
edge[0].id,
|
edge[0].id,
|
||||||
edge[1],
|
edge[1],
|
||||||
edge[2].id,
|
edge[2].id,
|
||||||
edge[3]
|
edge[3]
|
||||||
]) as Graph['edges'];
|
]) as Graph['edges'];
|
||||||
|
|
||||||
|
const groups = this.serializeGroups();
|
||||||
|
|
||||||
const serialized = {
|
const serialized = {
|
||||||
id: this.graph.id,
|
id: this.graph.id,
|
||||||
settings: $state.snapshot(this.settings),
|
settings: $state.snapshot(this.settings),
|
||||||
meta: $state.snapshot(this.graph.meta),
|
meta: $state.snapshot(this.graph.meta),
|
||||||
nodes,
|
nodes,
|
||||||
edges
|
edges,
|
||||||
|
...(groups ? { groups } : {})
|
||||||
};
|
};
|
||||||
logger.log('serializing graph', serialized);
|
logger.log('serializing graph', serialized);
|
||||||
return clone($state.snapshot(serialized));
|
return clone(serialized) as Graph;
|
||||||
}
|
}
|
||||||
|
|
||||||
private lastSettingsHash = 0;
|
private lastSettingsHash = 0;
|
||||||
@@ -132,7 +636,12 @@ export class GraphManager extends EventEmitter<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
getNodeDefinitions() {
|
getNodeDefinitions() {
|
||||||
return this.registry.getAllNodes();
|
const all = this.registry.getAllNodes();
|
||||||
|
// Only show the Group node in AddMenu when there's at least one group to assign
|
||||||
|
if (this.groups.size === 0) {
|
||||||
|
return all.filter(n => n.id !== '__virtual/group/instance');
|
||||||
|
}
|
||||||
|
return all;
|
||||||
}
|
}
|
||||||
|
|
||||||
getLinkedNodes(node: NodeInstance) {
|
getLinkedNodes(node: NodeInstance) {
|
||||||
@@ -208,19 +717,14 @@ export class GraphManager extends EventEmitter<{
|
|||||||
const draggedInputs = Object.values(draggedNode.state.type.inputs ?? {});
|
const draggedInputs = Object.values(draggedNode.state.type.inputs ?? {});
|
||||||
const draggedOutputs = draggedNode.state.type.outputs ?? [];
|
const draggedOutputs = draggedNode.state.type.outputs ?? [];
|
||||||
|
|
||||||
// Optimization: Pre-calculate parents to avoid cycles
|
|
||||||
const parentIds = new SvelteSet(this.getParentsOfNode(draggedNode).map(n => n.id));
|
const parentIds = new SvelteSet(this.getParentsOfNode(draggedNode).map(n => n.id));
|
||||||
|
|
||||||
return this.edges.filter((edge) => {
|
return this.edges.filter((edge) => {
|
||||||
const [fromNode, fromSocketIdx, toNode, toSocketKey] = edge;
|
const [fromNode, fromSocketIdx, toNode, toSocketKey] = edge;
|
||||||
|
|
||||||
// 1. Prevent cycles: If the target node is already a parent, we can't drop here
|
|
||||||
if (parentIds.has(toNode.id)) return false;
|
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;
|
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 edgeOutputSocketType = fromNode.state?.type?.outputs?.[fromSocketIdx];
|
||||||
const canPlugIntoDragged = draggedInputs.some(input => {
|
const canPlugIntoDragged = draggedInputs.some(input => {
|
||||||
const acceptedTypes = [input.type, ...(input.accepts || [])];
|
const acceptedTypes = [input.type, ...(input.accepts || [])];
|
||||||
@@ -229,7 +733,6 @@ export class GraphManager extends EventEmitter<{
|
|||||||
|
|
||||||
if (!canPlugIntoDragged) return false;
|
if (!canPlugIntoDragged) return false;
|
||||||
|
|
||||||
// 4. Check if ANY draggedNode.output can plug into edge.target
|
|
||||||
const targetInput = toNode.state?.type?.inputs?.[toSocketKey];
|
const targetInput = toNode.state?.type?.inputs?.[toSocketKey];
|
||||||
const targetAcceptedTypes = [targetInput?.type, ...(targetInput?.accepts || [])];
|
const targetAcceptedTypes = [targetInput?.type, ...(targetInput?.accepts || [])];
|
||||||
|
|
||||||
@@ -266,15 +769,35 @@ export class GraphManager extends EventEmitter<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _init(graph: Graph) {
|
private _init(graph: Graph) {
|
||||||
|
// Rebuild group definitions from the graph
|
||||||
|
this.groups.clear();
|
||||||
|
this.groupNodeDefinitions.clear();
|
||||||
|
if (graph.groups) {
|
||||||
|
for (const [groupId, group] of Object.entries(graph.groups)) {
|
||||||
|
this.groups.set(groupId, group);
|
||||||
|
const def = this.buildGroupNodeDefinition(group);
|
||||||
|
this.groupNodeDefinitions.set(def.id, def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const nodes = new SvelteMap(
|
const nodes = new SvelteMap(
|
||||||
graph.nodes.map((node) => {
|
graph.nodes.map((serialized) => {
|
||||||
const nodeType = this.registry.getNode(node.type);
|
// Migration: old __virtual/group/{groupId} format → __virtual/group/instance with props.groupId
|
||||||
const n = node as NodeInstance;
|
let node = serialized;
|
||||||
if (nodeType) {
|
if (node.type.startsWith('__virtual/group/')
|
||||||
n.state = {
|
&& node.type !== '__virtual/group/input'
|
||||||
type: nodeType
|
&& node.type !== '__virtual/group/output'
|
||||||
};
|
&& node.type !== '__virtual/group/instance') {
|
||||||
|
const oldGroupId = node.type.split('/')[2];
|
||||||
|
node = { ...node, type: '__virtual/group/instance' as NodeId, props: { ...node.props, groupId: oldGroupId } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IMPORTANT: copy the node so we don't mutate the original SerializedNode
|
||||||
|
// (which may be stored in a group definition). Mutating it would add
|
||||||
|
// state.type (with an execute fn) making it non-cloneable.
|
||||||
|
const nodeType = this.getNodeTypeWithContext(node.type, node.props as Record<string, unknown>);
|
||||||
|
const n = { ...node } as NodeInstance;
|
||||||
|
n.state = nodeType ? { type: nodeType } : {};
|
||||||
return [node.id, n];
|
return [node.id, n];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -310,7 +833,10 @@ export class GraphManager extends EventEmitter<{
|
|||||||
|
|
||||||
logger.info('loading graph', { nodes: graph.nodes, edges: graph.edges, id: graph.id });
|
logger.info('loading graph', { nodes: graph.nodes, edges: graph.edges, id: graph.id });
|
||||||
|
|
||||||
const nodeIds = Array.from(new SvelteSet([...graph.nodes.map((n) => n.type)]));
|
// Filter out virtual group types — they are resolved locally, not fetched remotely
|
||||||
|
const nodeIds = Array.from(new SvelteSet([
|
||||||
|
...graph.nodes.map((n) => n.type).filter(t => !isVirtualType(t))
|
||||||
|
]));
|
||||||
await this.registry.load(nodeIds);
|
await this.registry.load(nodeIds);
|
||||||
|
|
||||||
// Fetch all nodes from all collections of the loaded nodes
|
// Fetch all nodes from all collections of the loaded nodes
|
||||||
@@ -331,13 +857,13 @@ export class GraphManager extends EventEmitter<{
|
|||||||
logger.info('loaded node types', this.registry.getAllNodes());
|
logger.info('loaded node types', this.registry.getAllNodes());
|
||||||
|
|
||||||
for (const node of this.graph.nodes) {
|
for (const node of this.graph.nodes) {
|
||||||
|
if (isVirtualType(node.type)) continue;
|
||||||
const nodeType = this.registry.getNode(node.type);
|
const nodeType = this.registry.getNode(node.type);
|
||||||
if (!nodeType) {
|
if (!nodeType) {
|
||||||
logger.error(`Node type not found: ${node.type}`);
|
logger.error(`Node type not found: ${node.type}`);
|
||||||
this.status = 'error';
|
this.status = 'error';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Turn into runtime node
|
|
||||||
const n = node as NodeInstance;
|
const n = node as NodeInstance;
|
||||||
n.state = {};
|
n.state = {};
|
||||||
n.state.type = nodeType;
|
n.state.type = nodeType;
|
||||||
@@ -346,7 +872,6 @@ export class GraphManager extends EventEmitter<{
|
|||||||
// load settings
|
// load settings
|
||||||
const settingTypes: Record<
|
const settingTypes: Record<
|
||||||
string,
|
string,
|
||||||
// Optional metadata to map settings to specific nodes
|
|
||||||
NodeInput & { __node_type: string; __node_input: string }
|
NodeInput & { __node_type: string; __node_input: string }
|
||||||
> = {};
|
> = {};
|
||||||
const settingValues = graph.settings || {};
|
const settingValues = graph.settings || {};
|
||||||
@@ -375,6 +900,10 @@ export class GraphManager extends EventEmitter<{
|
|||||||
this.settings = settingValues;
|
this.settings = settingValues;
|
||||||
this.emit('settings', { types: settingTypes, values: settingValues });
|
this.emit('settings', { types: settingTypes, values: settingValues });
|
||||||
|
|
||||||
|
// Reset navigation
|
||||||
|
this.graphStack = [];
|
||||||
|
this.currentGroupContext = null;
|
||||||
|
|
||||||
this.history.reset();
|
this.history.reset();
|
||||||
this._init(this.graph);
|
this._init(this.graph);
|
||||||
|
|
||||||
@@ -441,9 +970,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
getNodesBetween(from: NodeInstance, to: NodeInstance): NodeInstance[] | undefined {
|
getNodesBetween(from: NodeInstance, to: NodeInstance): NodeInstance[] | undefined {
|
||||||
// < - - - - from
|
|
||||||
const toParents = this.getParentsOfNode(to);
|
const toParents = this.getParentsOfNode(to);
|
||||||
// < - - - - from - - - - to
|
|
||||||
const fromParents = this.getParentsOfNode(from);
|
const fromParents = this.getParentsOfNode(from);
|
||||||
if (toParents.includes(from)) {
|
if (toParents.includes(from)) {
|
||||||
const fromChildren = this.getChildren(from);
|
const fromChildren = this.getChildren(from);
|
||||||
@@ -452,7 +979,6 @@ export class GraphManager extends EventEmitter<{
|
|||||||
const toChildren = this.getChildren(to);
|
const toChildren = this.getChildren(to);
|
||||||
return fromParents.filter((n) => toChildren.includes(n));
|
return fromParents.filter((n) => toChildren.includes(n));
|
||||||
} else {
|
} else {
|
||||||
// these two nodes are not connected
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -506,7 +1032,6 @@ export class GraphManager extends EventEmitter<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
createGraph(nodes: NodeInstance[], edges: [number, number, number, string][]) {
|
createGraph(nodes: NodeInstance[], edges: [number, number, number, string][]) {
|
||||||
// map old ids to new ids
|
|
||||||
const idMap = new SvelteMap<number, number>();
|
const idMap = new SvelteMap<number, number>();
|
||||||
|
|
||||||
let startId = this.createNodeId();
|
let startId = this.createNodeId();
|
||||||
@@ -557,6 +1082,26 @@ export class GraphManager extends EventEmitter<{
|
|||||||
position: NodeInstance['position'];
|
position: NodeInstance['position'];
|
||||||
props: NodeInstance['props'];
|
props: NodeInstance['props'];
|
||||||
}) {
|
}) {
|
||||||
|
if (type === '__virtual/group/instance') {
|
||||||
|
const firstEntry = this.groups.entries().next();
|
||||||
|
if (firstEntry.done) {
|
||||||
|
logger.error('No groups available to create a group node');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [groupId] = firstEntry.value;
|
||||||
|
const groupNodeDef = this.groupNodeDefinitions.get(`__virtual/group/${groupId}`);
|
||||||
|
const node = {
|
||||||
|
id: this.createNodeId(),
|
||||||
|
type: '__virtual/group/instance' as NodeId,
|
||||||
|
position,
|
||||||
|
props: { groupId, ...props },
|
||||||
|
state: { type: groupNodeDef }
|
||||||
|
} as NodeInstance;
|
||||||
|
this.nodes.set(node.id, node);
|
||||||
|
this.save();
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
const nodeType = this.registry.getNode(type);
|
const nodeType = this.registry.getNode(type);
|
||||||
if (!nodeType) {
|
if (!nodeType) {
|
||||||
logger.error(`Node type not found: ${type}`);
|
logger.error(`Node type not found: ${type}`);
|
||||||
@@ -587,7 +1132,6 @@ export class GraphManager extends EventEmitter<{
|
|||||||
): Edge | undefined {
|
): Edge | undefined {
|
||||||
const existingEdges = this.getEdgesToNode(to);
|
const existingEdges = this.getEdgesToNode(to);
|
||||||
|
|
||||||
// check if this exact edge already exists
|
|
||||||
const existingEdge = existingEdges.find(
|
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
|
||||||
);
|
);
|
||||||
@@ -596,7 +1140,6 @@ export class GraphManager extends EventEmitter<{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if socket types match
|
|
||||||
const fromSocketType = from.state?.type?.outputs?.[fromSocket];
|
const fromSocketType = from.state?.type?.outputs?.[fromSocket];
|
||||||
const toSocketType = [to.state?.type?.inputs?.[toSocket]?.type];
|
const toSocketType = [to.state?.type?.inputs?.[toSocket]?.type];
|
||||||
if (to.state?.type?.inputs?.[toSocket]?.accepts) {
|
if (to.state?.type?.inputs?.[toSocket]?.accepts) {
|
||||||
@@ -664,12 +1207,13 @@ export class GraphManager extends EventEmitter<{
|
|||||||
const state = this.serialize();
|
const state = this.serialize();
|
||||||
this.history.save(state);
|
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) {
|
if (this.nodes.size === 0 && this.edges.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't emit save event while navigating inside a group
|
||||||
|
if (this.graphStack.length > 0) return;
|
||||||
|
|
||||||
this.emit('save', state);
|
this.emit('save', state);
|
||||||
logger.log('saving graphs', state);
|
logger.log('saving graphs', state);
|
||||||
}
|
}
|
||||||
@@ -728,9 +1272,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
|
|
||||||
const sockets: [NodeInstance, string | number][] = [];
|
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 SvelteSet(this.getChildren(node).map((n) => n.id));
|
||||||
const nodes = this.getAllNodes().filter(
|
const nodes = this.getAllNodes().filter(
|
||||||
(n) => n.id !== node.id && !children.has(n.id)
|
(n) => n.id !== node.id && !children.has(n.id)
|
||||||
@@ -749,20 +1291,21 @@ 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 SvelteSet(this.getParentsOfNode(node).map((n) => n.id));
|
||||||
const nodes = this.getAllNodes().filter(
|
const nodes = this.getAllNodes().filter(
|
||||||
(n) => n.id !== node.id && !parents.has(n.id)
|
(n) => n.id !== node.id && !parents.has(n.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
// get edges from this socket
|
const edges = new SvelteMap<number, string[]>();
|
||||||
const edges = new SvelteMap(
|
this.getEdgesFromNode(node)
|
||||||
this.getEdgesFromNode(node)
|
.filter((e) => e[1] === index)
|
||||||
.filter((e) => e[1] === index)
|
.forEach((e) => {
|
||||||
.map((e) => [e[2].id, e[3]])
|
if (edges.has(e[2].id)) {
|
||||||
);
|
edges.get(e[2].id)?.push(e[3]);
|
||||||
|
} else {
|
||||||
|
edges.set(e[2].id, [e[3]]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const ownType = nodeType.outputs?.[index];
|
const ownType = nodeType.outputs?.[index];
|
||||||
|
|
||||||
@@ -775,7 +1318,7 @@ export class GraphManager extends EventEmitter<{
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
areSocketsCompatible(ownType, otherType)
|
areSocketsCompatible(ownType, otherType)
|
||||||
&& edges.get(node.id) !== key
|
&& !edges.get(node.id)?.includes(key)
|
||||||
) {
|
) {
|
||||||
sockets.push([node, key]);
|
sockets.push([node, key]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { animate, lerp } from '$lib/helpers';
|
||||||
import type { NodeInstance, Socket } from '@nodarium/types';
|
import type { NodeInstance, Socket } from '@nodarium/types';
|
||||||
import { getContext, setContext } from 'svelte';
|
import { getContext, setContext } from 'svelte';
|
||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import type { OrthographicCamera, Vector3 } from 'three';
|
import type { OrthographicCamera, Vector3 } from 'three';
|
||||||
import type { GraphManager } from './graph-manager.svelte';
|
import type { GraphManager } from './graph-manager.svelte';
|
||||||
|
import { ColorGenerator } from './graph/colors';
|
||||||
|
import { getNodeHeight, getSocketPosition } from './helpers/nodeHelpers';
|
||||||
|
|
||||||
const graphStateKey = Symbol('graph-state');
|
const graphStateKey = Symbol('graph-state');
|
||||||
export function getGraphState() {
|
export function getGraphState() {
|
||||||
@@ -27,7 +30,32 @@ type EdgeData = {
|
|||||||
points: Vector3[];
|
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 {
|
export class GraphState {
|
||||||
|
colors = new ColorGenerator(predefinedColors);
|
||||||
|
|
||||||
constructor(private graph: GraphManager) {
|
constructor(private graph: GraphManager) {
|
||||||
$effect.root(() => {
|
$effect.root(() => {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -71,6 +99,9 @@ export class GraphState {
|
|||||||
edges: [number, number, number, string][];
|
edges: [number, number, number, string][];
|
||||||
} = null;
|
} = null;
|
||||||
|
|
||||||
|
// Saved camera position per group so re-entering restores where you left off
|
||||||
|
groupCameras = new Map<string, [number, number, number]>();
|
||||||
|
|
||||||
cameraBounds = $derived([
|
cameraBounds = $derived([
|
||||||
this.cameraPosition[0] - this.width / this.cameraPosition[2] / 2,
|
this.cameraPosition[0] - this.width / this.cameraPosition[2] / 2,
|
||||||
this.cameraPosition[0] + this.width / this.cameraPosition[2] / 2,
|
this.cameraPosition[0] + this.width / this.cameraPosition[2] / 2,
|
||||||
@@ -83,7 +114,7 @@ export class GraphState {
|
|||||||
addMenuPosition = $state<[number, number] | null>(null);
|
addMenuPosition = $state<[number, number] | null>(null);
|
||||||
|
|
||||||
snapToGrid = $state(false);
|
snapToGrid = $state(false);
|
||||||
showGrid = $state(true);
|
backgroundType = $state<'grid' | 'dots' | 'none'>('grid');
|
||||||
showHelp = $state(false);
|
showHelp = $state(false);
|
||||||
|
|
||||||
cameraDown = [0, 0];
|
cameraDown = [0, 0];
|
||||||
@@ -97,6 +128,9 @@ export class GraphState {
|
|||||||
activeNodeId = $state(-1);
|
activeNodeId = $state(-1);
|
||||||
selectedNodes = new SvelteSet<number>();
|
selectedNodes = new SvelteSet<number>();
|
||||||
activeSocket = $state<Socket | null>(null);
|
activeSocket = $state<Socket | null>(null);
|
||||||
|
safePadding = $state<{ left?: number; right?: number; bottom?: number; top?: number } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
hoveredSocket = $state<Socket | null>(null);
|
hoveredSocket = $state<Socket | null>(null);
|
||||||
possibleSockets = $state<Socket[]>([]);
|
possibleSockets = $state<Socket[]>([]);
|
||||||
possibleSocketIds = $derived(
|
possibleSocketIds = $derived(
|
||||||
@@ -159,44 +193,27 @@ export class GraphState {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSocketPosition(
|
tryConnectToDebugNode(nodeId: number) {
|
||||||
node: NodeInstance,
|
const node = this.graph.nodes.get(nodeId);
|
||||||
index: string | number
|
if (!node) return;
|
||||||
): [number, number] {
|
if (node.type.endsWith('/debug')) return;
|
||||||
if (typeof index === 'number') {
|
if (!node.state.type?.outputs?.length) return;
|
||||||
return [
|
for (const _node of this.graph.nodes.values()) {
|
||||||
(node?.state?.x ?? node.position[0]) + 20,
|
if (_node.type.endsWith('/debug')) {
|
||||||
(node?.state?.y ?? node.position[1]) + 2.5 + 10 * index
|
this.graph.createEdge(node, 0, _node, 'input');
|
||||||
];
|
return;
|
||||||
} 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
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private nodeHeightCache: Record<string, number> = {};
|
const debugNode = this.graph.createNode({
|
||||||
getNodeHeight(nodeTypeId: string) {
|
type: 'max/plantarium/debug',
|
||||||
if (nodeTypeId in this.nodeHeightCache) {
|
position: [node.position[0] + 30, node.position[1]],
|
||||||
return this.nodeHeightCache[nodeTypeId];
|
props: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (debugNode) {
|
||||||
|
this.graph.createEdge(node, 0, debugNode, 'input');
|
||||||
}
|
}
|
||||||
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
|
|
||||||
&& !(node?.inputs?.[p] !== undefined && 'setting' in node.inputs[p])
|
|
||||||
&& node.inputs[p].hidden !== true
|
|
||||||
).length;
|
|
||||||
this.nodeHeightCache[nodeTypeId] = height;
|
|
||||||
return height;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
copyNodes() {
|
copyNodes() {
|
||||||
@@ -226,6 +243,37 @@ export class GraphState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
centerNode(node?: NodeInstance) {
|
||||||
|
const average = [0, 0, 4];
|
||||||
|
if (node) {
|
||||||
|
average[0] = node.position[0] + (this.safePadding?.right || 0) / 10;
|
||||||
|
average[1] = node.position[1];
|
||||||
|
average[2] = 10;
|
||||||
|
} else {
|
||||||
|
for (const node of this.graph.nodes.values()) {
|
||||||
|
average[0] += node.position[0];
|
||||||
|
average[1] += node.position[1];
|
||||||
|
}
|
||||||
|
average[0] = (average[0] / this.graph.nodes.size)
|
||||||
|
+ (this.safePadding?.right || 0) / (average[2] * 2);
|
||||||
|
average[1] /= this.graph.nodes.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
const camX = this.cameraPosition[0];
|
||||||
|
const camY = this.cameraPosition[1];
|
||||||
|
const camZ = this.cameraPosition[2];
|
||||||
|
|
||||||
|
const ease = (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t);
|
||||||
|
const easeZoom = (t: number) => t * t * (3 - 2 * t);
|
||||||
|
|
||||||
|
animate(500, (a: number) => {
|
||||||
|
this.cameraPosition[0] = lerp(camX, average[0], ease(a));
|
||||||
|
this.cameraPosition[1] = lerp(camY, average[1], ease(a));
|
||||||
|
this.cameraPosition[2] = lerp(camZ, average[2], easeZoom(a));
|
||||||
|
if (this.mouseDown) return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pasteNodes() {
|
pasteNodes() {
|
||||||
if (!this.clipboard) return;
|
if (!this.clipboard) return;
|
||||||
|
|
||||||
@@ -256,7 +304,7 @@ export class GraphState {
|
|||||||
if (edge[3] === index) {
|
if (edge[3] === index) {
|
||||||
node = edge[0];
|
node = edge[0];
|
||||||
index = edge[1];
|
index = edge[1];
|
||||||
position = this.getSocketPosition(node, index);
|
position = getSocketPosition(node, index);
|
||||||
this.graph.removeEdge(edge);
|
this.graph.removeEdge(edge);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -276,7 +324,7 @@ export class GraphState {
|
|||||||
return {
|
return {
|
||||||
node,
|
node,
|
||||||
index,
|
index,
|
||||||
position: this.getSocketPosition(node, index)
|
position: getSocketPosition(node, index)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -313,7 +361,7 @@ export class GraphState {
|
|||||||
for (const node of this.graph.nodes.values()) {
|
for (const node of this.graph.nodes.values()) {
|
||||||
const x = node.position[0];
|
const x = node.position[0];
|
||||||
const y = node.position[1];
|
const y = node.position[1];
|
||||||
const height = this.getNodeHeight(node.type);
|
const height = getNodeHeight(node.state.type!);
|
||||||
if (downX > x && downX < x + 20 && downY > y && downY < y + height) {
|
if (downX > x && downX < x + 20 && downY > y && downY < y + height) {
|
||||||
clickedNodeId = node.id;
|
clickedNodeId = node.id;
|
||||||
break;
|
break;
|
||||||
@@ -325,14 +373,12 @@ export class GraphState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isNodeInView(node: NodeInstance) {
|
isNodeInView(node: NodeInstance) {
|
||||||
const height = this.getNodeHeight(node.type);
|
const height = getNodeHeight(node.state.type!);
|
||||||
const width = 20;
|
const width = 20;
|
||||||
return (
|
return node.position[0] > this.cameraBounds[0] - width
|
||||||
node.position[0] > this.cameraBounds[0] - width
|
|
||||||
&& node.position[0] < this.cameraBounds[1]
|
&& node.position[0] < this.cameraBounds[1]
|
||||||
&& node.position[1] > this.cameraBounds[2] - height
|
&& node.position[1] > this.cameraBounds[2] - height
|
||||||
&& node.position[1] < this.cameraBounds[3]
|
&& node.position[1] < this.cameraBounds[3];
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openNodePalette() {
|
openNodePalette() {
|
||||||
|
|||||||
@@ -11,15 +11,18 @@
|
|||||||
import Debug from '../debug/Debug.svelte';
|
import Debug from '../debug/Debug.svelte';
|
||||||
import EdgeEl from '../edges/Edge.svelte';
|
import EdgeEl from '../edges/Edge.svelte';
|
||||||
import { getGraphManager, getGraphState } from '../graph-state.svelte';
|
import { getGraphManager, getGraphState } from '../graph-state.svelte';
|
||||||
|
import { getSocketPosition } from '../helpers/nodeHelpers';
|
||||||
import NodeEl from '../node/Node.svelte';
|
import NodeEl from '../node/Node.svelte';
|
||||||
import { maxZoom, minZoom } from './constants';
|
import { maxZoom, minZoom } from './constants';
|
||||||
import { FileDropEventManager } from './drop.events';
|
import { FileDropEventManager } from './drop.events';
|
||||||
import { MouseEventManager } from './mouse.events';
|
import { MouseEventManager } from './mouse.events';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
keymap
|
keymap,
|
||||||
|
safePadding
|
||||||
}: {
|
}: {
|
||||||
keymap: ReturnType<typeof createKeyMap>;
|
keymap: ReturnType<typeof createKeyMap>;
|
||||||
|
safePadding?: { left?: number; right?: number; bottom?: number; top?: number };
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const graph = getGraphManager();
|
const graph = getGraphManager();
|
||||||
@@ -36,8 +39,8 @@
|
|||||||
return [0, 0, 0, 0];
|
return [0, 0, 0, 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
const pos1 = graphState.getSocketPosition(fromNode, edge[1]);
|
const pos1 = getSocketPosition(fromNode, edge[1]);
|
||||||
const pos2 = graphState.getSocketPosition(toNode, edge[3]);
|
const pos2 = getSocketPosition(toNode, edge[3]);
|
||||||
return [pos1[0], pos1[1], pos2[0], pos2[1]];
|
return [pos1[0], pos1[1], pos2[0], pos2[1]];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +95,16 @@
|
|||||||
graphState.activeSocket = null;
|
graphState.activeSocket = null;
|
||||||
graphState.addMenuPosition = null;
|
graphState.addMenuPosition = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSocketType(node: NodeInstance, index: number | string): string {
|
||||||
|
if (typeof index === 'string') {
|
||||||
|
return node.state.type?.inputs?.[index].type || 'unknown';
|
||||||
|
}
|
||||||
|
if (node.type === '__virtual/group/instance') {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return node.state.type?.outputs?.[index] || 'unknown';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
@@ -132,8 +145,9 @@
|
|||||||
position={graphState.cameraPosition}
|
position={graphState.cameraPosition}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if graphState.showGrid !== false}
|
{#if graphState.backgroundType !== 'none'}
|
||||||
<Background
|
<Background
|
||||||
|
type={graphState.backgroundType}
|
||||||
cameraPosition={graphState.cameraPosition}
|
cameraPosition={graphState.cameraPosition}
|
||||||
{maxZoom}
|
{maxZoom}
|
||||||
{minZoom}
|
{minZoom}
|
||||||
@@ -159,12 +173,20 @@
|
|||||||
|
|
||||||
{#if graph.status === 'idle'}
|
{#if graph.status === 'idle'}
|
||||||
{#if graphState.addMenuPosition}
|
{#if graphState.addMenuPosition}
|
||||||
<AddMenu onnode={handleNodeCreation} />
|
<AddMenu
|
||||||
|
onnode={handleNodeCreation}
|
||||||
|
paddingTop={safePadding?.top}
|
||||||
|
paddingRight={safePadding?.right}
|
||||||
|
paddingBottom={safePadding?.bottom}
|
||||||
|
paddingLeft={safePadding?.left}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if graphState.activeSocket}
|
{#if graphState.activeSocket}
|
||||||
<EdgeEl
|
<EdgeEl
|
||||||
z={graphState.cameraPosition[2]}
|
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]}
|
x1={graphState.activeSocket.position[0]}
|
||||||
y1={graphState.activeSocket.position[1]}
|
y1={graphState.activeSocket.position[1]}
|
||||||
x2={graphState.edgeEndPosition?.[0] ?? graphState.mousePosition[0]}
|
x2={graphState.edgeEndPosition?.[0] ?? graphState.mousePosition[0]}
|
||||||
@@ -177,6 +199,8 @@
|
|||||||
<EdgeEl
|
<EdgeEl
|
||||||
id={graph.getEdgeId(edge)}
|
id={graph.getEdgeId(edge)}
|
||||||
z={graphState.cameraPosition[2]}
|
z={graphState.cameraPosition[2]}
|
||||||
|
inputType={getSocketType(edge[0], edge[1])}
|
||||||
|
outputType={getSocketType(edge[2], edge[3])}
|
||||||
{x1}
|
{x1}
|
||||||
{y1}
|
{y1}
|
||||||
{x2}
|
{x2}
|
||||||
@@ -199,7 +223,6 @@
|
|||||||
<NodeEl
|
<NodeEl
|
||||||
{node}
|
{node}
|
||||||
inView={graphState.isNodeInView(node)}
|
inView={graphState.isNodeInView(node)}
|
||||||
z={graphState.cameraPosition[2]}
|
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,11 +13,13 @@
|
|||||||
settings?: Record<string, unknown>;
|
settings?: Record<string, unknown>;
|
||||||
|
|
||||||
activeNode?: NodeInstance;
|
activeNode?: NodeInstance;
|
||||||
showGrid?: boolean;
|
backgroundType?: 'grid' | 'dots' | 'none';
|
||||||
snapToGrid?: boolean;
|
snapToGrid?: boolean;
|
||||||
showHelp?: boolean;
|
showHelp?: boolean;
|
||||||
settingTypes?: Record<string, unknown>;
|
settingTypes?: Record<string, unknown>;
|
||||||
|
|
||||||
|
safePadding?: { left?: number; right?: number; bottom?: number; top?: number };
|
||||||
|
|
||||||
onsave?: (save: Graph) => void;
|
onsave?: (save: Graph) => void;
|
||||||
onresult?: (result: unknown) => void;
|
onresult?: (result: unknown) => void;
|
||||||
};
|
};
|
||||||
@@ -25,9 +27,10 @@
|
|||||||
let {
|
let {
|
||||||
graph,
|
graph,
|
||||||
registry,
|
registry,
|
||||||
|
safePadding,
|
||||||
settings = $bindable(),
|
settings = $bindable(),
|
||||||
activeNode = $bindable(),
|
activeNode = $bindable(),
|
||||||
showGrid = $bindable(true),
|
backgroundType = $bindable('grid'),
|
||||||
snapToGrid = $bindable(true),
|
snapToGrid = $bindable(true),
|
||||||
showHelp = $bindable(false),
|
showHelp = $bindable(false),
|
||||||
settingTypes = $bindable(),
|
settingTypes = $bindable(),
|
||||||
@@ -41,29 +44,32 @@
|
|||||||
export const manager = new GraphManager(registry);
|
export const manager = new GraphManager(registry);
|
||||||
setGraphManager(manager);
|
setGraphManager(manager);
|
||||||
|
|
||||||
const graphState = new GraphState(manager);
|
export const state = new GraphState(manager);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
graphState.showGrid = showGrid;
|
if (safePadding) {
|
||||||
graphState.snapToGrid = snapToGrid;
|
state.safePadding = safePadding;
|
||||||
graphState.showHelp = showHelp;
|
}
|
||||||
|
state.backgroundType = backgroundType;
|
||||||
|
state.snapToGrid = snapToGrid;
|
||||||
|
state.showHelp = showHelp;
|
||||||
});
|
});
|
||||||
|
|
||||||
setGraphState(graphState);
|
setGraphState(state);
|
||||||
|
|
||||||
setupKeymaps(keymap, manager, graphState);
|
setupKeymaps(keymap, manager, state);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (graphState.activeNodeId !== -1) {
|
if (state.activeNodeId !== -1) {
|
||||||
activeNode = manager.getNode(graphState.activeNodeId);
|
activeNode = manager.getNode(state.activeNodeId);
|
||||||
} else if (activeNode) {
|
} else if (activeNode) {
|
||||||
activeNode = undefined;
|
activeNode = undefined;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!graphState.addMenuPosition) {
|
if (!state.addMenuPosition) {
|
||||||
graphState.edgeEndPosition = null;
|
state.edgeEndPosition = null;
|
||||||
graphState.activeSocket = null;
|
state.activeSocket = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,6 +87,95 @@
|
|||||||
manager.load(graph);
|
manager.load(graph);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function navigateToBreadcrumb(index: number) {
|
||||||
|
const crumbs = manager.breadcrumbs;
|
||||||
|
const depth = crumbs.length - 1 - index;
|
||||||
|
let restoredCamera: [number, number, number] | false = false;
|
||||||
|
for (let i = 0; i < depth; i++) {
|
||||||
|
const groupId = manager.currentGroupContext;
|
||||||
|
if (groupId) {
|
||||||
|
state.groupCameras.set(groupId, [...state.cameraPosition] as [number, number, number]);
|
||||||
|
}
|
||||||
|
restoredCamera = manager.exitGroup();
|
||||||
|
}
|
||||||
|
state.activeNodeId = -1;
|
||||||
|
state.clearSelection();
|
||||||
|
if (restoredCamera !== false) {
|
||||||
|
state.cameraPosition[0] = restoredCamera[0];
|
||||||
|
state.cameraPosition[1] = restoredCamera[1];
|
||||||
|
state.cameraPosition[2] = restoredCamera[2];
|
||||||
|
} else {
|
||||||
|
state.centerNode();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<GraphEl {keymap} />
|
{#if manager.isInsideGroup}
|
||||||
|
<div class="breadcrumb-bar">
|
||||||
|
{#each manager.breadcrumbs as crumb, i}
|
||||||
|
{#if i > 0}
|
||||||
|
<span class="sep">›</span>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="crumb"
|
||||||
|
class:active={i === manager.breadcrumbs.length - 1}
|
||||||
|
onclick={() => navigateToBreadcrumb(i)}
|
||||||
|
>
|
||||||
|
{crumb.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<GraphEl {keymap} {safePadding} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.breadcrumb-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: rgba(10, 15, 28, 0.85);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
pointer-events: all;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sep {
|
||||||
|
opacity: 0.4;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb:hover {
|
||||||
|
color: white;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb.active {
|
||||||
|
color: white;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb.active:hover {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const variables = [
|
|||||||
'outline',
|
'outline',
|
||||||
'active',
|
'active',
|
||||||
'selected',
|
'selected',
|
||||||
'edge'
|
'connection'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function getColor(variable: (typeof variables)[number]) {
|
function getColor(variable: (typeof variables)[number]) {
|
||||||
|
|||||||
44
app/src/lib/graph-interface/graph/colors.ts
Normal file
44
app/src/lib/graph-interface/graph/colors.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
type Color = { hue: number; saturation: number; lightness: number };
|
||||||
|
|
||||||
|
export class ColorGenerator {
|
||||||
|
private colors: Map<string, Color> = new Map();
|
||||||
|
private lightnessLevels = [10, 60];
|
||||||
|
|
||||||
|
constructor(predefined: Record<string, Color>) {
|
||||||
|
for (const [id, colorStr] of Object.entries(predefined)) {
|
||||||
|
this.colors.set(id, colorStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getColor(id: string): string {
|
||||||
|
if (this.colors.has(id)) {
|
||||||
|
return this.colorToHsl(this.colors.get(id)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newColor = this.generateNewColor();
|
||||||
|
this.colors.set(id, newColor);
|
||||||
|
return this.colorToHsl(newColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateNewColor(): Color {
|
||||||
|
const existingHues = Array.from(this.colors.values()).map(c => c.hue).sort();
|
||||||
|
let hue = existingHues[0];
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
while (
|
||||||
|
existingHues.some(h => Math.abs(h - hue) < 30 || Math.abs(h - hue) > 330)
|
||||||
|
&& attempts < 360
|
||||||
|
) {
|
||||||
|
hue = (hue + 30) % 360;
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lightness = 60;
|
||||||
|
|
||||||
|
return { hue, lightness, saturation: 100 };
|
||||||
|
}
|
||||||
|
|
||||||
|
private colorToHsl(c: Color): string {
|
||||||
|
return `hsl(${c.hue}, ${c.saturation}%, ${c.lightness}%)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { type NodeInstance } from '@nodarium/types';
|
|||||||
import type { GraphManager } from '../graph-manager.svelte';
|
import type { GraphManager } from '../graph-manager.svelte';
|
||||||
import { type GraphState } from '../graph-state.svelte';
|
import { type GraphState } from '../graph-state.svelte';
|
||||||
import { snapToGrid as snapPointToGrid } from '../helpers';
|
import { snapToGrid as snapPointToGrid } from '../helpers';
|
||||||
|
import { getNodeHeight } from '../helpers/nodeHelpers';
|
||||||
import { maxZoom, minZoom, zoomSpeed } from './constants';
|
import { maxZoom, minZoom, zoomSpeed } from './constants';
|
||||||
import { EdgeInteractionManager } from './edge.events';
|
import { EdgeInteractionManager } from './edge.events';
|
||||||
|
|
||||||
@@ -166,15 +167,14 @@ export class MouseEventManager {
|
|||||||
|
|
||||||
if (this.state.mouseDown) return;
|
if (this.state.mouseDown) return;
|
||||||
this.state.edgeEndPosition = null;
|
this.state.edgeEndPosition = null;
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
if (event.target instanceof HTMLElement) {
|
if (
|
||||||
if (
|
target.nodeName !== 'CANVAS'
|
||||||
event.target.nodeName !== 'CANVAS'
|
&& !target.classList.contains('node')
|
||||||
&& !event.target.classList.contains('node')
|
&& !target.classList.contains('content')
|
||||||
&& !event.target.classList.contains('content')
|
) {
|
||||||
) {
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mx = event.clientX - this.state.rect.x;
|
const mx = event.clientX - this.state.rect.x;
|
||||||
@@ -189,6 +189,10 @@ export class MouseEventManager {
|
|||||||
|
|
||||||
// if we clicked on a node
|
// if we clicked on a node
|
||||||
if (clickedNodeId !== -1) {
|
if (clickedNodeId !== -1) {
|
||||||
|
if (event.ctrlKey && event.shiftKey) {
|
||||||
|
this.state.tryConnectToDebugNode(clickedNodeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.state.activeNodeId === -1) {
|
if (this.state.activeNodeId === -1) {
|
||||||
this.state.activeNodeId = clickedNodeId;
|
this.state.activeNodeId = clickedNodeId;
|
||||||
// if the selected node is the same as the clicked node
|
// if the selected node is the same as the clicked node
|
||||||
@@ -265,7 +269,7 @@ export class MouseEventManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_socket && smallestDist < 0.9) {
|
if (_socket && smallestDist < 1.5) {
|
||||||
this.state.mousePosition = _socket.position;
|
this.state.mousePosition = _socket.position;
|
||||||
this.state.hoveredSocket = _socket;
|
this.state.hoveredSocket = _socket;
|
||||||
} else {
|
} else {
|
||||||
@@ -290,7 +294,7 @@ export class MouseEventManager {
|
|||||||
if (!node?.state) continue;
|
if (!node?.state) continue;
|
||||||
const x = node.position[0];
|
const x = node.position[0];
|
||||||
const y = node.position[1];
|
const y = node.position[1];
|
||||||
const height = this.state.getNodeHeight(node.type);
|
const height = getNodeHeight(node.state.type!);
|
||||||
if (x > x1 - 20 && x < x2 && y > y1 - height && y < y2) {
|
if (x > x1 - 20 && x < x2 && y > y1 - height && y < y2) {
|
||||||
this.state.selectedNodes?.add(node.id);
|
this.state.selectedNodes?.add(node.id);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export function createNodePath({
|
|||||||
rightBump = false,
|
rightBump = false,
|
||||||
aspectRatio = 1
|
aspectRatio = 1
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
const leftBumpTopY = y + height / 2;
|
||||||
|
const leftBumpBottomY = y - height / 2;
|
||||||
|
|
||||||
return `M0,${cornerTop}
|
return `M0,${cornerTop}
|
||||||
${
|
${
|
||||||
cornerTop
|
cornerTop
|
||||||
@@ -64,9 +67,7 @@ export function createNodePath({
|
|||||||
}
|
}
|
||||||
${
|
${
|
||||||
leftBump
|
leftBump
|
||||||
? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${
|
? ` V${leftBumpTopY} C${depth},${leftBumpTopY} ${depth},${leftBumpBottomY} 0,${leftBumpBottomY}`
|
||||||
y - height / 2
|
|
||||||
}`
|
|
||||||
: ` H0`
|
: ` H0`
|
||||||
}
|
}
|
||||||
Z`.replace(/\s+/g, ' ');
|
Z`.replace(/\s+/g, ' ');
|
||||||
|
|||||||
78
app/src/lib/graph-interface/helpers/nodeHelpers.ts
Normal file
78
app/src/lib/graph-interface/helpers/nodeHelpers.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { NodeDefinition, NodeInstance } from '@nodarium/types';
|
||||||
|
|
||||||
|
export function getParameterHeight(node: NodeDefinition, inputKey: string) {
|
||||||
|
const input = node.inputs?.[inputKey];
|
||||||
|
if (!input) {
|
||||||
|
if (inputKey.startsWith('__virtual')) {
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputKey === 'seed') return 0;
|
||||||
|
if (!node.inputs) return 0;
|
||||||
|
if ('setting' in input) return 0;
|
||||||
|
if (input.hidden) return 0;
|
||||||
|
|
||||||
|
if (input.type === 'shape' && input.external !== true) {
|
||||||
|
return 200;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input?.label !== '' && !input.external && input.type !== 'path'
|
||||||
|
&& input.type !== 'geometry'
|
||||||
|
) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSocketPosition(
|
||||||
|
node: NodeInstance,
|
||||||
|
index: string | number
|
||||||
|
): [number, number] {
|
||||||
|
if (typeof index === 'number') {
|
||||||
|
return [
|
||||||
|
(node?.state?.x ?? node.position[0]) + 20,
|
||||||
|
(node?.state?.y ?? node.position[1]) + 2.5 + 10 * index
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
let height = 5;
|
||||||
|
const nodeType = node.state.type!;
|
||||||
|
const inputs = nodeType.inputs || {};
|
||||||
|
for (const inputKey in inputs) {
|
||||||
|
const h = getParameterHeight(nodeType, inputKey) / 10;
|
||||||
|
if (inputKey === index) {
|
||||||
|
height += h / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
height += h;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
node?.state?.x ?? node.position[0],
|
||||||
|
(node?.state?.y ?? node.position[1]) + height
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeHeightCache: Record<string, number> = {};
|
||||||
|
export function getNodeHeight(node: NodeDefinition) {
|
||||||
|
// Don't cache virtual nodes — their inputs can change dynamically
|
||||||
|
const isVirtual = (node.id as string).startsWith('__virtual/');
|
||||||
|
if (!isVirtual && node.id in nodeHeightCache) {
|
||||||
|
return nodeHeightCache[node.id];
|
||||||
|
}
|
||||||
|
if (!node?.inputs) {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
let height = 5;
|
||||||
|
|
||||||
|
for (const key in node.inputs) {
|
||||||
|
const h = getParameterHeight(node, key) / 10;
|
||||||
|
height += h;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isVirtual) {
|
||||||
|
nodeHeightCache[node.id] = height;
|
||||||
|
}
|
||||||
|
return height;
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { animate, lerp } from '$lib/helpers';
|
|
||||||
import type { createKeyMap } from '$lib/helpers/createKeyMap';
|
import type { createKeyMap } from '$lib/helpers/createKeyMap';
|
||||||
import { panelState } from '$lib/sidebar/PanelState.svelte';
|
import { panelState } from '$lib/sidebar/PanelState.svelte';
|
||||||
import FileSaver from 'file-saver';
|
import FileSaver from 'file-saver';
|
||||||
@@ -46,8 +45,26 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
|
|||||||
|
|
||||||
keymap.addShortcut({
|
keymap.addShortcut({
|
||||||
key: 'Escape',
|
key: 'Escape',
|
||||||
description: 'Deselect nodes',
|
description: 'Deselect nodes / Exit group',
|
||||||
callback: () => {
|
callback: () => {
|
||||||
|
if (graph.isInsideGroup) {
|
||||||
|
const groupId = graph.currentGroupContext;
|
||||||
|
if (groupId) {
|
||||||
|
graphState.groupCameras.set(
|
||||||
|
groupId,
|
||||||
|
[...graphState.cameraPosition] as [number, number, number]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const savedCamera = graph.exitGroup();
|
||||||
|
if (savedCamera !== false) {
|
||||||
|
graphState.activeNodeId = -1;
|
||||||
|
graphState.clearSelection();
|
||||||
|
graphState.cameraPosition[0] = savedCamera[0];
|
||||||
|
graphState.cameraPosition[1] = savedCamera[1];
|
||||||
|
graphState.cameraPosition[2] = savedCamera[2];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
graphState.activeNodeId = -1;
|
graphState.activeNodeId = -1;
|
||||||
graphState.clearSelection();
|
graphState.clearSelection();
|
||||||
graphState.edgeEndPosition = null;
|
graphState.edgeEndPosition = null;
|
||||||
@@ -67,27 +84,7 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
|
|||||||
description: 'Center camera',
|
description: 'Center camera',
|
||||||
callback: () => {
|
callback: () => {
|
||||||
if (!graphState.isBodyFocused()) return;
|
if (!graphState.isBodyFocused()) return;
|
||||||
|
graphState.centerNode(graph.getNode(graphState.activeNodeId));
|
||||||
const average = [0, 0];
|
|
||||||
for (const node of graph.nodes.values()) {
|
|
||||||
average[0] += node.position[0];
|
|
||||||
average[1] += node.position[1];
|
|
||||||
}
|
|
||||||
average[0] = average[0] ? average[0] / graph.nodes.size : 0;
|
|
||||||
average[1] = average[1] ? average[1] / graph.nodes.size : 0;
|
|
||||||
|
|
||||||
const camX = graphState.cameraPosition[0];
|
|
||||||
const camY = graphState.cameraPosition[1];
|
|
||||||
const camZ = graphState.cameraPosition[2];
|
|
||||||
|
|
||||||
const ease = (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t);
|
|
||||||
|
|
||||||
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));
|
|
||||||
if (graphState.mouseDown) return false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,4 +177,80 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
|
|||||||
if (!edge) graph.smartConnect(nodes[1], nodes[0]);
|
if (!edge) graph.smartConnect(nodes[1], nodes[0]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
keymap.addShortcut({
|
||||||
|
key: 'g',
|
||||||
|
ctrl: true,
|
||||||
|
preventDefault: true,
|
||||||
|
description: 'Group selected nodes',
|
||||||
|
callback: () => {
|
||||||
|
if (!graphState.isBodyFocused()) return;
|
||||||
|
const nodeIds = Array.from(
|
||||||
|
new Set([
|
||||||
|
...(graphState.selectedNodes.size > 0 ? graphState.selectedNodes.values() : []),
|
||||||
|
...(graphState.activeNodeId !== -1 ? [graphState.activeNodeId] : [])
|
||||||
|
])
|
||||||
|
);
|
||||||
|
if (nodeIds.length === 0) return;
|
||||||
|
const groupNode = graph.createGroup(nodeIds);
|
||||||
|
if (groupNode) {
|
||||||
|
graphState.selectedNodes.clear();
|
||||||
|
graphState.activeNodeId = groupNode.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
keymap.addShortcut({
|
||||||
|
key: 'g',
|
||||||
|
alt: true,
|
||||||
|
shift: true,
|
||||||
|
preventDefault: true,
|
||||||
|
description: 'Ungroup selected node',
|
||||||
|
callback: () => {
|
||||||
|
if (!graphState.isBodyFocused()) return;
|
||||||
|
const nodeId = graphState.activeNodeId !== -1
|
||||||
|
? graphState.activeNodeId
|
||||||
|
: graphState.selectedNodes.size === 1
|
||||||
|
? [...graphState.selectedNodes.values()][0]
|
||||||
|
: -1;
|
||||||
|
if (nodeId === -1) return;
|
||||||
|
graph.ungroup(nodeId);
|
||||||
|
graphState.activeNodeId = -1;
|
||||||
|
graphState.clearSelection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
keymap.addShortcut({
|
||||||
|
key: 'Tab',
|
||||||
|
preventDefault: true,
|
||||||
|
description: 'Enter focused group node',
|
||||||
|
callback: () => {
|
||||||
|
if (!graphState.isBodyFocused()) return;
|
||||||
|
const entered = graph.enterGroup(
|
||||||
|
graphState.activeNodeId,
|
||||||
|
[...graphState.cameraPosition] as [number, number, number]
|
||||||
|
);
|
||||||
|
if (entered) {
|
||||||
|
graphState.activeNodeId = -1;
|
||||||
|
graphState.clearSelection();
|
||||||
|
// Restore group-specific camera if we've been here before, else snap to center
|
||||||
|
const groupId = graph.currentGroupContext;
|
||||||
|
const saved = groupId ? graphState.groupCameras.get(groupId) : undefined;
|
||||||
|
if (saved) {
|
||||||
|
graphState.cameraPosition[0] = saved[0];
|
||||||
|
graphState.cameraPosition[1] = saved[1];
|
||||||
|
graphState.cameraPosition[2] = saved[2];
|
||||||
|
} else {
|
||||||
|
const nodes = [...graph.nodes.values()];
|
||||||
|
if (nodes.length) {
|
||||||
|
const avgX = nodes.reduce((s, n) => s + n.position[0], 0) / nodes.length;
|
||||||
|
const avgY = nodes.reduce((s, n) => s + n.position[1], 0) / nodes.length;
|
||||||
|
graphState.cameraPosition[0] = avgX;
|
||||||
|
graphState.cameraPosition[1] = avgY;
|
||||||
|
graphState.cameraPosition[2] = 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,88 @@
|
|||||||
|
|
||||||
varying vec2 vUv;
|
varying vec2 vUv;
|
||||||
|
|
||||||
uniform float uWidth;
|
uniform float uWidth;
|
||||||
uniform float uHeight;
|
uniform float uHeight;
|
||||||
|
uniform float uZoom;
|
||||||
|
|
||||||
uniform vec3 uColorDark;
|
uniform vec3 uColorDark;
|
||||||
uniform vec3 uColorBright;
|
uniform vec3 uColorBright;
|
||||||
|
|
||||||
uniform vec3 uStrokeColor;
|
uniform vec3 uStrokeColor;
|
||||||
uniform float uStrokeWidth;
|
|
||||||
|
const float uHeaderHeight = 5.0;
|
||||||
|
uniform float uSectionHeights[16];
|
||||||
|
uniform int uNumSections;
|
||||||
|
|
||||||
float msign(in float x) { return (x < 0.0) ? -1.0 : 1.0; }
|
float msign(in float x) { return (x < 0.0) ? -1.0 : 1.0; }
|
||||||
|
float sdCircle(vec2 p, float r) { return length(p) - r; }
|
||||||
|
|
||||||
vec4 roundedBoxSDF( in vec2 p, in vec2 b, in float r, in float s) {
|
vec4 roundedBoxSDF( in vec2 p, in vec2 b, in float r, in float s) {
|
||||||
vec2 q = abs(p) - b + r;
|
vec2 q = abs(p) - b + r;
|
||||||
float l = b.x + b.y + 1.570796 * r;
|
float l = b.x + b.y + 1.570796 * r;
|
||||||
|
|
||||||
float k1 = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
|
float k1 = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
|
||||||
float k2 = ((q.x > 0.0) ? atan(q.y, q.x) : 1.570796);
|
float k2 = ((q.x > 0.0) ? atan(q.y, q.x) : 1.570796);
|
||||||
float k3 = 3.0 + 2.0 * msign(min(p.x, -p.y)) - msign(p.x);
|
float k3 = 3.0 + 2.0 * msign(min(p.x, -p.y)) - msign(p.x);
|
||||||
float k4 = msign(p.x * p.y);
|
float k4 = msign(p.x * p.y);
|
||||||
float k5 = r * k2 + max(-q.x, 0.0);
|
float k5 = r * k2 + max(-q.x, 0.0);
|
||||||
|
|
||||||
float ra = s * round(k1 / s);
|
float ra = s * round(k1 / s);
|
||||||
float l2 = l + 1.570796 * ra;
|
float l2 = l + 1.570796 * ra;
|
||||||
|
|
||||||
return vec4(k1 - ra, k3 * l2 + k4 * (b.y + ((q.y > 0.0) ? k5 + k2 * ra : q.y)), 4.0 * l2, k1);
|
return vec4(k1 - ra, k3 * l2 + k4 * (b.y + ((q.y > 0.0) ? k5 + k2 * ra : q.y)), 4.0 * l2, k1);
|
||||||
}
|
}
|
||||||
|
|
||||||
void main(){
|
void main(){
|
||||||
|
float strokeWidth = mix(2.0, 0.5, uZoom);
|
||||||
|
|
||||||
float y = (1.0-vUv.y) * uHeight;
|
float borderRadius = 0.5;
|
||||||
|
float dentRadius = 0.8;
|
||||||
|
|
||||||
|
float y = (1.0 - vUv.y) * uHeight;
|
||||||
float x = vUv.x * uWidth;
|
float x = vUv.x * uWidth;
|
||||||
|
|
||||||
vec2 size = vec2(uWidth, uHeight);
|
vec2 size = vec2(uWidth, uHeight);
|
||||||
vec2 uv = (vUv - 0.5) * 2.0;
|
vec2 uvCenter = (vUv - 0.5) * 2.0;
|
||||||
|
|
||||||
float u_border_radius = 0.4;
|
vec4 boxData = roundedBoxSDF(uvCenter * size, size, borderRadius * 2.0, 0.0);
|
||||||
vec4 distance = roundedBoxSDF(uv * size, size, u_border_radius*2.0, 0.0);
|
float sceneSDF = boxData.w;
|
||||||
|
|
||||||
if (distance.w > 0.0 ) {
|
vec2 headerDentPos = vec2(uWidth, uHeaderHeight * 0.5);
|
||||||
// outside
|
float headerDentDist = sdCircle(vec2(x, y) - headerDentPos, dentRadius);
|
||||||
gl_FragColor = vec4(0.0,0.0,0.0, 0.0);
|
sceneSDF = max(sceneSDF, -headerDentDist*2.0);
|
||||||
}else{
|
|
||||||
if (distance.w > -uStrokeWidth || mod(y+5.0, 10.0) < uStrokeWidth/2.0) {
|
float currentYBoundary = uHeaderHeight;
|
||||||
// draw the outer stroke
|
float previousYBoundary = uHeaderHeight;
|
||||||
gl_FragColor = vec4(uStrokeColor, 1.0);
|
|
||||||
}else if (y<5.0){
|
for (int i = 0; i < 16; i++) {
|
||||||
// draw the header
|
if (i >= uNumSections) break;
|
||||||
gl_FragColor = vec4(uColorBright, 1.0);
|
|
||||||
}else{
|
float sectionHeight = uSectionHeights[i];
|
||||||
gl_FragColor = vec4(uColorDark, 1.0);
|
currentYBoundary += sectionHeight;
|
||||||
}
|
|
||||||
|
float centerY = previousYBoundary + (sectionHeight * 0.5);
|
||||||
|
vec2 circlePos = vec2(0.0, centerY);
|
||||||
|
float circleDist = sdCircle(vec2(x, y) - circlePos, dentRadius);
|
||||||
|
|
||||||
|
sceneSDF = max(sceneSDF, -circleDist*2.0);
|
||||||
|
previousYBoundary = currentYBoundary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sceneSDF > 0.05) {
|
||||||
|
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 finalColor = (y < uHeaderHeight) ? uColorBright : uColorDark;
|
||||||
|
bool isDivider = false;
|
||||||
|
|
||||||
|
float dividerY = uHeaderHeight;
|
||||||
|
if (abs(y - dividerY) < strokeWidth * 0.25) isDivider = true;
|
||||||
|
|
||||||
|
for (int i = 0; i < 16; i++) {
|
||||||
|
if (i >= uNumSections - 1) break;
|
||||||
|
dividerY += uSectionHeights[i];
|
||||||
|
if (abs(y - dividerY) < strokeWidth * 0.25) isDivider = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sceneSDF > -strokeWidth || isDivider) {
|
||||||
|
gl_FragColor = vec4(uStrokeColor, 1.0);
|
||||||
|
} else {
|
||||||
|
gl_FragColor = vec4(finalColor, 1.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { type Mesh } from 'three';
|
import { type Mesh } from 'three';
|
||||||
import { getGraphState } from '../graph-state.svelte';
|
import { getGraphState } from '../graph-state.svelte';
|
||||||
import { colors } from '../graph/colors.svelte';
|
import { colors } from '../graph/colors.svelte';
|
||||||
|
import { getNodeHeight, getParameterHeight } from '../helpers/nodeHelpers';
|
||||||
import NodeFrag from './Node.frag';
|
import NodeFrag from './Node.frag';
|
||||||
import NodeVert from './Node.vert';
|
import NodeVert from './Node.vert';
|
||||||
import NodeHtml from './NodeHTML.svelte';
|
import NodeHtml from './NodeHTML.svelte';
|
||||||
@@ -14,9 +15,10 @@
|
|||||||
type Props = {
|
type Props = {
|
||||||
node: NodeInstance;
|
node: NodeInstance;
|
||||||
inView: boolean;
|
inView: boolean;
|
||||||
z: number;
|
|
||||||
};
|
};
|
||||||
let { node = $bindable(), inView, z }: Props = $props();
|
let { node = $bindable(), inView }: Props = $props();
|
||||||
|
|
||||||
|
const nodeType = $derived(node.state.type!);
|
||||||
|
|
||||||
const isActive = $derived(graphState.activeNodeId === node.id);
|
const isActive = $derived(graphState.activeNodeId === node.id);
|
||||||
const isSelected = $derived(graphState.selectedNodes.has(node.id));
|
const isSelected = $derived(graphState.selectedNodes.has(node.id));
|
||||||
@@ -29,9 +31,18 @@
|
|||||||
: colors.outline)
|
: colors.outline)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sectionHeights = $derived(
|
||||||
|
Object
|
||||||
|
.keys(nodeType.inputs || {})
|
||||||
|
.map(key => getParameterHeight(nodeType, key) / 10)
|
||||||
|
.filter(b => !!b)
|
||||||
|
);
|
||||||
|
|
||||||
let meshRef: Mesh | undefined = $state();
|
let meshRef: Mesh | undefined = $state();
|
||||||
|
|
||||||
const height = graphState.getNodeHeight(node.type);
|
const height = getNodeHeight(node.state.type!);
|
||||||
|
|
||||||
|
const zoom = $derived(graphState.cameraPosition[2]);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (meshRef && !node.state?.mesh) {
|
if (meshRef && !node.state?.mesh) {
|
||||||
@@ -39,6 +50,10 @@
|
|||||||
graphState.updateNodePosition(node);
|
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>
|
</script>
|
||||||
|
|
||||||
<T.Mesh
|
<T.Mesh
|
||||||
@@ -47,7 +62,7 @@
|
|||||||
position.y={0.8}
|
position.y={0.8}
|
||||||
rotation.x={-Math.PI / 2}
|
rotation.x={-Math.PI / 2}
|
||||||
bind:ref={meshRef}
|
bind:ref={meshRef}
|
||||||
visible={inView && z < 7}
|
visible={inView && zoom < 7}
|
||||||
>
|
>
|
||||||
<T.PlaneGeometry args={[20, height]} radius={1} />
|
<T.PlaneGeometry args={[20, height]} radius={1} />
|
||||||
<T.ShaderMaterial
|
<T.ShaderMaterial
|
||||||
@@ -58,13 +73,18 @@
|
|||||||
uColorBright: { value: colors['layer-2'] },
|
uColorBright: { value: colors['layer-2'] },
|
||||||
uColorDark: { value: colors['layer-1'] },
|
uColorDark: { value: colors['layer-1'] },
|
||||||
uStrokeColor: { value: colors.outline.clone() },
|
uStrokeColor: { value: colors.outline.clone() },
|
||||||
uStrokeWidth: { value: 1.0 },
|
uSectionHeights: { value: [5, 10] },
|
||||||
|
uNumSections: { value: 2 },
|
||||||
uWidth: { value: 20 },
|
uWidth: { value: 20 },
|
||||||
uHeight: { value: height }
|
uHeight: { value: 200 },
|
||||||
|
uZoom: { value: 1.0 }
|
||||||
}}
|
}}
|
||||||
uniforms.uStrokeColor.value={strokeColor.clone()}
|
uniforms.uZoom.value={zoomValue}
|
||||||
uniforms.uStrokeWidth.value={(7 - z) / 3}
|
uniforms.uHeight.value={height}
|
||||||
|
uniforms.uSectionHeights.value={sectionHeights}
|
||||||
|
uniforms.uNumSections.value={sectionHeights.length}
|
||||||
|
uniforms.uStrokeColor.value={strokeColor}
|
||||||
/>
|
/>
|
||||||
</T.Mesh>
|
</T.Mesh>
|
||||||
|
|
||||||
<NodeHtml bind:node {inView} {isActive} {isSelected} {z} />
|
<NodeHtml bind:node {inView} {isActive} {isSelected} z={zoom} />
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { NodeInstance } from '@nodarium/types';
|
import type { NodeDefinition, NodeInstance } from '@nodarium/types';
|
||||||
import { getGraphState } from '../graph-state.svelte';
|
import { getGraphManager, getGraphState } from '../graph-state.svelte';
|
||||||
import NodeHeader from './NodeHeader.svelte';
|
import NodeHeader from './NodeHeader.svelte';
|
||||||
import NodeParameter from './NodeParameter.svelte';
|
import NodeParameter from './NodeParameter.svelte';
|
||||||
|
|
||||||
let ref: HTMLDivElement;
|
let ref: HTMLDivElement;
|
||||||
|
|
||||||
const graphState = getGraphState();
|
const graphState = getGraphState();
|
||||||
|
const manager = getGraphManager();
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
node: NodeInstance;
|
node: NodeInstance;
|
||||||
@@ -30,9 +31,37 @@
|
|||||||
const zOffset = Math.random() - 0.5;
|
const zOffset = Math.random() - 0.5;
|
||||||
const zLimit = 2 - zOffset;
|
const zLimit = 2 - zOffset;
|
||||||
|
|
||||||
const parameters = Object.entries(node.state?.type?.inputs || {}).filter(
|
function buildParameters(node: NodeInstance, inputs: NodeDefinition['inputs']) {
|
||||||
(p) => p[1].type !== 'seed' && !('setting' in p[1]) && p[1]?.hidden !== true
|
let parameters = Object.entries(inputs || {}).filter(
|
||||||
);
|
(p) => p[1].type !== 'seed' && !('setting' in p[1]) && p[1]?.hidden !== true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (node.type === '__virtual/group/instance') {
|
||||||
|
parameters = [['__virtual/groupId', {
|
||||||
|
type: 'select',
|
||||||
|
value: node.props?.groupId as string,
|
||||||
|
options: [...manager?.groups?.keys()]
|
||||||
|
}], ...parameters];
|
||||||
|
}
|
||||||
|
|
||||||
|
return parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parameters = $derived(buildParameters(node, node?.state?.type?.inputs || {}));
|
||||||
|
|
||||||
|
const currentGroupId = $derived((node.props?.groupId as string) ?? '');
|
||||||
|
|
||||||
|
function onGroupSelect(event: Event) {
|
||||||
|
const select = event.target as HTMLSelectElement;
|
||||||
|
const newGroupId = select.value;
|
||||||
|
if (!manager || newGroupId === currentGroupId) return;
|
||||||
|
const newGroupDef = manager.groupNodeDefinitions.get(`__virtual/group/${newGroupId}`);
|
||||||
|
if (!newGroupDef) return;
|
||||||
|
node.props = { ...(node.props ?? {}), groupId: newGroupId };
|
||||||
|
node.state = { type: newGroupDef };
|
||||||
|
manager.execute();
|
||||||
|
manager.save();
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ('state' in node && !node.state.ref) {
|
if ('state' in node && !node.state.ref) {
|
||||||
@@ -55,6 +84,22 @@
|
|||||||
>
|
>
|
||||||
<NodeHeader {node} />
|
<NodeHeader {node} />
|
||||||
|
|
||||||
|
{#if false && node.type === '__virtual/group/instance'}
|
||||||
|
<div class="group-param">
|
||||||
|
<select
|
||||||
|
value={currentGroupId}
|
||||||
|
onchange={onGroupSelect}
|
||||||
|
onmousedown={(e) => e.stopPropagation()}
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{#each manager?.groups?.entries() ?? [] as [gid, gdef]}
|
||||||
|
<option value={gid}>{gdef.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#each parameters as [key, value], i (key)}
|
{#each parameters as [key, value], i (key)}
|
||||||
<NodeParameter
|
<NodeParameter
|
||||||
bind:node
|
bind:node
|
||||||
@@ -66,6 +111,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.group-param {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-bottom: solid 1px var(--color-layer-2);
|
||||||
|
background: var(--color-layer-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-param select {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--color-layer-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.node {
|
.node {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
user-select: none !important;
|
user-select: none !important;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { NodeInstance } from '@nodarium/types';
|
import { appSettings } from '$lib/settings/app-settings.svelte';
|
||||||
|
import type { NodeInstance, Socket } from '@nodarium/types';
|
||||||
import { getGraphState } from '../graph-state.svelte';
|
import { getGraphState } from '../graph-state.svelte';
|
||||||
import { createNodePath } from '../helpers/index.js';
|
import { createNodePath } from '../helpers/index.js';
|
||||||
|
import { getSocketPosition } from '../helpers/nodeHelpers';
|
||||||
|
|
||||||
const graphState = getGraphState();
|
const graphState = getGraphState();
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@
|
|||||||
graphState.setDownSocket?.({
|
graphState.setDownSocket?.({
|
||||||
node,
|
node,
|
||||||
index: 0,
|
index: 0,
|
||||||
position: graphState.getSocketPosition?.(node, 0)
|
position: getSocketPosition?.(node, 0)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,22 +37,43 @@
|
|||||||
);
|
);
|
||||||
const pathHover = $derived(
|
const pathHover = $derived(
|
||||||
createNodePath({
|
createNodePath({
|
||||||
depth: 8.5,
|
depth: 7,
|
||||||
height: 50,
|
height: 40,
|
||||||
y: 49,
|
y: 49,
|
||||||
cornerTop,
|
cornerTop,
|
||||||
rightBump,
|
rightBump,
|
||||||
aspectRatio
|
aspectRatio
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const socketId = $derived(`${node.id}-${0}`);
|
||||||
|
|
||||||
|
function getSocketType(s: Socket | null) {
|
||||||
|
if (!s) return 'unknown';
|
||||||
|
if (typeof s.index === 'string') {
|
||||||
|
return s.node.state.type?.inputs?.[s.index].type || 'unknown';
|
||||||
|
}
|
||||||
|
return s.node.state.type?.outputs?.[s.index] || 'unknown';
|
||||||
|
}
|
||||||
|
const socketType = $derived(getSocketType(graphState.activeSocket));
|
||||||
|
const hoverColor = $derived(graphState.colors.getColor(socketType));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="wrapper" data-node-id={node.id} data-node-type={node.type}>
|
<div
|
||||||
|
class="wrapper"
|
||||||
|
data-node-id={node.id}
|
||||||
|
data-node-type={node.type}
|
||||||
|
style:--socket-color={hoverColor}
|
||||||
|
class:possible-socket={graphState?.possibleSocketIds.has(socketId)}
|
||||||
|
>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{node.type.split('/').pop()}
|
{#if appSettings.value.debug.advancedMode}
|
||||||
|
<span class="bg-white text-black! mr-2 px-1 rounded-sm opacity-30">{node.id}</span>
|
||||||
|
{/if}
|
||||||
|
{node.state?.type?.meta?.title ?? node.type.split('/').pop()}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="click-target"
|
class="target"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onmousedown={handleMouseDown}
|
onmousedown={handleMouseDown}
|
||||||
@@ -78,7 +101,20 @@
|
|||||||
height: 50px;
|
height: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.click-target {
|
.possible-socket .target::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 100%;
|
||||||
|
box-shadow: 0px 0px 10px var(--socket-color);
|
||||||
|
background-color: var(--socket-color);
|
||||||
|
outline: solid thin var(--socket-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
z-index: -10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0px;
|
right: 0px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -87,11 +123,9 @@
|
|||||||
width: 30px;
|
width: 30px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
/* background: red; */
|
|
||||||
/* opacity: 0.2; */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.click-target:hover + svg path {
|
.target:hover + svg path {
|
||||||
d: var(--hover-path);
|
d: var(--hover-path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,11 +142,16 @@
|
|||||||
|
|
||||||
svg path {
|
svg path {
|
||||||
stroke-width: 0.2px;
|
stroke-width: 0.2px;
|
||||||
transition: d 0.3s ease, fill 0.3s ease;
|
transition:
|
||||||
|
d 0.3s ease,
|
||||||
|
fill 0.3s ease;
|
||||||
fill: var(--color-layer-2);
|
fill: var(--color-layer-2);
|
||||||
stroke: var(--stroke);
|
stroke: var(--stroke);
|
||||||
stroke-width: var(--stroke-width);
|
stroke-width: var(--stroke-width);
|
||||||
d: var(--path);
|
d: var(--path);
|
||||||
|
|
||||||
|
stroke-linejoin: round;
|
||||||
|
shape-rendering: geometricPrecision;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
|
|||||||
@@ -31,11 +31,24 @@
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let value = $state(getDefaultValue());
|
let value = $state(structuredClone($state.snapshot(getDefaultValue())));
|
||||||
|
|
||||||
|
function diffArray(a: number[], b?: number[] | number) {
|
||||||
|
if (!Array.isArray(b)) return true;
|
||||||
|
if (Array.isArray(a) !== Array.isArray(b)) return true;
|
||||||
|
if (a.length !== b.length) return true;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] !== b[i]) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (value !== undefined && node?.props?.[id] !== value) {
|
const a = $state.snapshot(value);
|
||||||
node.props = { ...node.props, [id]: value };
|
const b = $state.snapshot(node?.props?.[id]) as number | number[] | undefined;
|
||||||
|
const isDiff = Array.isArray(a) ? diffArray(a, b) : a !== b;
|
||||||
|
if (value !== undefined && isDiff) {
|
||||||
|
node.props = { ...node.props, [id]: a };
|
||||||
if (graph) {
|
if (graph) {
|
||||||
graph.save();
|
graph.save();
|
||||||
graph.execute();
|
graph.execute();
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { NodeInput, NodeInstance } from '@nodarium/types';
|
import type { NodeInput, NodeInstance, Socket } from '@nodarium/types';
|
||||||
import { getGraphManager, getGraphState } from '../graph-state.svelte';
|
import { getGraphManager, getGraphState } from '../graph-state.svelte';
|
||||||
import { createNodePath } from '../helpers';
|
import { createNodePath } from '../helpers';
|
||||||
|
import { getParameterHeight, getSocketPosition } from '../helpers/nodeHelpers';
|
||||||
import NodeInputEl from './NodeInput.svelte';
|
import NodeInputEl from './NodeInput.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -12,17 +13,18 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const graph = getGraphManager();
|
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();
|
let { node = $bindable(), input, id, isLast }: Props = $props();
|
||||||
|
|
||||||
const inputType = $derived(node?.state?.type?.inputs?.[id]);
|
const nodeType = $derived(node.state.type!);
|
||||||
|
|
||||||
|
const inputType = $derived(nodeType.inputs?.[id]);
|
||||||
|
|
||||||
const socketId = $derived(`${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) {
|
function handleMouseDown(ev: MouseEvent) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@@ -30,28 +32,18 @@
|
|||||||
graphState.setDownSocket({
|
graphState.setDownSocket({
|
||||||
node,
|
node,
|
||||||
index: id,
|
index: id,
|
||||||
position: graphState.getSocketPosition?.(node, id)
|
position: getSocketPosition(node, id)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const leftBump = $derived(node.state?.type?.inputs?.[id].internal !== true);
|
const leftBump = $derived(!id.startsWith('__virtual') && nodeType.inputs?.[id].internal !== true);
|
||||||
const cornerBottom = $derived(isLast ? 5 : 0);
|
const cornerBottom = $derived(isLast ? 5 : 0);
|
||||||
const aspectRatio = 0.5;
|
const aspectRatio = 0.5;
|
||||||
|
|
||||||
const path = $derived(
|
const path = $derived(
|
||||||
createNodePath({
|
|
||||||
depth: 7,
|
|
||||||
height: 20,
|
|
||||||
y: 50.5,
|
|
||||||
cornerBottom,
|
|
||||||
leftBump,
|
|
||||||
aspectRatio
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const pathDisabled = $derived(
|
|
||||||
createNodePath({
|
createNodePath({
|
||||||
depth: 6,
|
depth: 6,
|
||||||
height: 18,
|
height: 2000 / height,
|
||||||
y: 50.5,
|
y: 50.5,
|
||||||
cornerBottom,
|
cornerBottom,
|
||||||
leftBump,
|
leftBump,
|
||||||
@@ -60,41 +52,49 @@
|
|||||||
);
|
);
|
||||||
const pathHover = $derived(
|
const pathHover = $derived(
|
||||||
createNodePath({
|
createNodePath({
|
||||||
depth: 8,
|
depth: 7,
|
||||||
height: 25,
|
height: 2200 / height,
|
||||||
y: 50.5,
|
y: 50.5,
|
||||||
cornerBottom,
|
cornerBottom,
|
||||||
leftBump,
|
leftBump,
|
||||||
aspectRatio
|
aspectRatio
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function getSocketType(s: Socket | null) {
|
||||||
|
if (!s) return 'unknown';
|
||||||
|
if (typeof s.index === 'string') {
|
||||||
|
return s.node.state.type?.inputs?.[s.index].type || 'unknown';
|
||||||
|
}
|
||||||
|
return s.node.state.type?.outputs?.[s.index] || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
const socketType = $derived(getSocketType(graphState.activeSocket));
|
||||||
|
const hoverColor = $derived(graphState.colors.getColor(socketType));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="wrapper"
|
class="wrapper"
|
||||||
data-node-type={node.type}
|
data-node-type={node.type}
|
||||||
data-node-input={id}
|
data-node-input={id}
|
||||||
class:disabled={!graphState?.possibleSocketIds.has(socketId)}
|
style:height="{height}px"
|
||||||
|
style:--socket-color={hoverColor}
|
||||||
|
class:possible-socket={graphState?.possibleSocketIds.has(socketId)}
|
||||||
>
|
>
|
||||||
{#key id && graphId}
|
{#key id && graphId}
|
||||||
<div class="content" class:disabled={graph?.inputSockets?.has(socketId)}>
|
<div class="content" class:disabled={graph?.inputSockets?.has(socketId)}>
|
||||||
{#if inputType?.label !== ''}
|
{#if inputType?.label !== '' && !id.startsWith('__virtual')}
|
||||||
<label for={elementId} title={input.description}>{input.label || id}</label>
|
<label for={elementId} title={input.description}>{input.label || id}</label>
|
||||||
{/if}
|
{/if}
|
||||||
<span
|
|
||||||
class="absolute i-[tabler--help-circle] size-4 block top-2 right-2 opacity-30"
|
|
||||||
title={JSON.stringify(input, null, 2)}
|
|
||||||
></span>
|
|
||||||
{#if inputType?.external !== true}
|
{#if inputType?.external !== true}
|
||||||
<NodeInputEl {graph} {elementId} bind:node {input} {id} />
|
<NodeInputEl {graph} {elementId} bind:node {input} {id} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if node?.state?.type?.inputs?.[id]?.internal !== true}
|
{#if node?.state?.type?.inputs?.[id]?.internal !== true}
|
||||||
<div data-node-socket class="large target"></div>
|
|
||||||
<div
|
<div
|
||||||
data-node-socket
|
data-node-socket
|
||||||
class="small target"
|
class="target"
|
||||||
onmousedown={handleMouseDown}
|
onmousedown={handleMouseDown}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -106,14 +106,9 @@
|
|||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 100 100"
|
viewBox="0 0 100 100"
|
||||||
width="100"
|
|
||||||
height="100"
|
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
style={`
|
style:--path={`path("${path}")`}
|
||||||
--path: path("${path}");
|
style:--hover-path={`path("${pathHover}")`}
|
||||||
--hover-path: path("${pathHover}");
|
|
||||||
--hover-path-disabled: path("${pathDisabled}");
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<path vector-effect="non-scaling-stroke"></path>
|
<path vector-effect="non-scaling-stroke"></path>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -123,42 +118,43 @@
|
|||||||
.wrapper {
|
.wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100px;
|
|
||||||
transform: translateY(-0.5px);
|
transform: translateY(-0.5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.target {
|
.target {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%) translateX(-50%);
|
transform: translateY(-50%) translateX(-50%);
|
||||||
/* background: red; */
|
|
||||||
/* opacity: 0.1; */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.small.target {
|
.possible-socket .target::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
|
border-radius: 100%;
|
||||||
|
box-shadow: 0px 0px 10px var(--socket-color);
|
||||||
|
background-color: var(--socket-color);
|
||||||
|
outline: solid thin var(--socket-color);
|
||||||
|
opacity: 0.5;
|
||||||
|
z-index: -10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.large.target {
|
.target:hover ~ svg path{
|
||||||
width: 60px;
|
d: var(--hover-path);
|
||||||
height: 60px;
|
|
||||||
cursor: unset;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.hovering-sockets) .large.target {
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 10px 20px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
padding-inline: 20px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
justify-content: space-around;
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,19 +175,16 @@
|
|||||||
stroke: var(--stroke);
|
stroke: var(--stroke);
|
||||||
stroke-width: var(--stroke-width);
|
stroke-width: var(--stroke-width);
|
||||||
d: var(--path);
|
d: var(--path);
|
||||||
}
|
|
||||||
|
|
||||||
:global {
|
stroke-linejoin: round;
|
||||||
.hovering-sockets .large:hover ~ svg path {
|
shape-rendering: geometricPrecision;
|
||||||
d: var(--hover-path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content.disabled {
|
.content.disabled {
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.disabled svg path {
|
.possible-socket svg path {
|
||||||
d: var(--hover-path-disabled) !important;
|
d: var(--hover-path);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
86
app/src/lib/graph-interface/test-utils.ts
Normal file
86
app/src/lib/graph-interface/test-utils.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { NodeDefinition, NodeId, NodeRegistry } from '@nodarium/types';
|
||||||
|
|
||||||
|
export function createMockNodeRegistry(nodes: NodeDefinition[]): NodeRegistry {
|
||||||
|
const nodesMap = new Map(nodes.map(n => [n.id, n]));
|
||||||
|
return {
|
||||||
|
status: 'ready' as const,
|
||||||
|
load: async (nodeIds: NodeId[]) => {
|
||||||
|
const loaded: NodeDefinition[] = [];
|
||||||
|
for (const id of nodeIds) {
|
||||||
|
if (nodesMap.has(id)) {
|
||||||
|
loaded.push(nodesMap.get(id)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return loaded;
|
||||||
|
},
|
||||||
|
getNode: (id: string) => nodesMap.get(id as NodeId),
|
||||||
|
getAllNodes: () => Array.from(nodesMap.values()),
|
||||||
|
register: async () => {
|
||||||
|
throw new Error('Not implemented in mock');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockFloatOutputNode: NodeDefinition = {
|
||||||
|
id: 'test/node/output',
|
||||||
|
inputs: {},
|
||||||
|
outputs: ['float'],
|
||||||
|
meta: { title: 'Float Output' },
|
||||||
|
execute: () => new Int32Array()
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockFloatInputNode: NodeDefinition = {
|
||||||
|
id: 'test/node/input',
|
||||||
|
inputs: { value: { type: 'float' } },
|
||||||
|
outputs: [],
|
||||||
|
meta: { title: 'Float Input' },
|
||||||
|
execute: () => new Int32Array()
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockGeometryOutputNode: NodeDefinition = {
|
||||||
|
id: 'test/node/geometry',
|
||||||
|
inputs: {},
|
||||||
|
outputs: ['geometry'],
|
||||||
|
meta: { title: 'Geometry Output' },
|
||||||
|
execute: () => new Int32Array()
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockPathInputNode: NodeDefinition = {
|
||||||
|
id: 'test/node/path',
|
||||||
|
inputs: { input: { type: 'path', accepts: ['geometry'] } },
|
||||||
|
outputs: [],
|
||||||
|
meta: { title: 'Path Input' },
|
||||||
|
execute: () => new Int32Array()
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockVec3OutputNode: NodeDefinition = {
|
||||||
|
id: 'test/node/vec3',
|
||||||
|
inputs: {},
|
||||||
|
outputs: ['vec3'],
|
||||||
|
meta: { title: 'Vec3 Output' },
|
||||||
|
execute: () => new Int32Array()
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockIntegerInputNode: NodeDefinition = {
|
||||||
|
id: 'test/node/integer',
|
||||||
|
inputs: { value: { type: 'integer' } },
|
||||||
|
outputs: [],
|
||||||
|
meta: { title: 'Integer Input' },
|
||||||
|
execute: () => new Int32Array()
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockBooleanOutputNode: NodeDefinition = {
|
||||||
|
id: 'test/node/boolean',
|
||||||
|
inputs: {},
|
||||||
|
outputs: ['boolean'],
|
||||||
|
meta: { title: 'Boolean Output' },
|
||||||
|
execute: () => new Int32Array()
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockBooleanInputNode: NodeDefinition = {
|
||||||
|
id: 'test/node/boolean-input',
|
||||||
|
inputs: { value: { type: 'boolean' } },
|
||||||
|
outputs: [],
|
||||||
|
meta: { title: 'Boolean Input' },
|
||||||
|
execute: () => new Int32Array()
|
||||||
|
};
|
||||||
110
app/src/lib/graph-templates.test.ts
Normal file
110
app/src/lib/graph-templates.test.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { grid } from '$lib/graph-templates/grid';
|
||||||
|
import { tree } from '$lib/graph-templates/tree';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('graph-templates', () => {
|
||||||
|
describe('grid', () => {
|
||||||
|
it('should create a grid graph with nodes and edges', () => {
|
||||||
|
const result = grid(2, 3);
|
||||||
|
expect(result.nodes.length).toBeGreaterThan(0);
|
||||||
|
expect(result.edges.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have output node at the end', () => {
|
||||||
|
const result = grid(1, 1);
|
||||||
|
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
|
||||||
|
expect(outputNode).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create nodes based on grid dimensions', () => {
|
||||||
|
const result = grid(2, 2);
|
||||||
|
const mathNodes = result.nodes.filter(n => n.type === 'max/plantarium/math');
|
||||||
|
expect(mathNodes.length).toBeGreaterThan(0);
|
||||||
|
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
|
||||||
|
expect(outputNode).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have output node at the end', () => {
|
||||||
|
const result = grid(1, 1);
|
||||||
|
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
|
||||||
|
expect(outputNode).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create nodes based on grid dimensions', () => {
|
||||||
|
const result = grid(2, 2);
|
||||||
|
const mathNodes = result.nodes.filter(n => n.type === 'max/plantarium/math');
|
||||||
|
expect(mathNodes.length).toBeGreaterThan(0);
|
||||||
|
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
|
||||||
|
expect(outputNode).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have valid node positions', () => {
|
||||||
|
const result = grid(3, 2);
|
||||||
|
|
||||||
|
result.nodes.forEach(node => {
|
||||||
|
expect(node.position).toHaveLength(2);
|
||||||
|
expect(typeof node.position[0]).toBe('number');
|
||||||
|
expect(typeof node.position[1]).toBe('number');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate valid graph structure', () => {
|
||||||
|
const result = grid(2, 2);
|
||||||
|
|
||||||
|
result.nodes.forEach(node => {
|
||||||
|
expect(typeof node.id).toBe('number');
|
||||||
|
expect(node.type).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
result.edges.forEach(edge => {
|
||||||
|
expect(edge).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tree', () => {
|
||||||
|
it('should create a tree graph with specified depth', () => {
|
||||||
|
const result = tree(0);
|
||||||
|
|
||||||
|
expect(result.nodes.length).toBeGreaterThan(0);
|
||||||
|
expect(result.edges.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have root output node', () => {
|
||||||
|
const result = tree(2);
|
||||||
|
|
||||||
|
const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output');
|
||||||
|
expect(outputNode).toBeDefined();
|
||||||
|
expect(outputNode?.id).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should increase node count with depth', () => {
|
||||||
|
const tree0 = tree(0);
|
||||||
|
const tree1 = tree(1);
|
||||||
|
const tree2 = tree(2);
|
||||||
|
|
||||||
|
expect(tree0.nodes.length).toBeLessThan(tree1.nodes.length);
|
||||||
|
expect(tree1.nodes.length).toBeLessThan(tree2.nodes.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create binary tree structure', () => {
|
||||||
|
const result = tree(2);
|
||||||
|
|
||||||
|
const mathNodes = result.nodes.filter(n => n.type === 'max/plantarium/math');
|
||||||
|
expect(mathNodes.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const edgeCount = result.edges.length;
|
||||||
|
expect(edgeCount).toBe(result.nodes.length - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have valid node positions', () => {
|
||||||
|
const result = tree(3);
|
||||||
|
|
||||||
|
result.nodes.forEach(node => {
|
||||||
|
expect(node.position).toHaveLength(2);
|
||||||
|
expect(typeof node.position[0]).toBe('number');
|
||||||
|
expect(typeof node.position[1]).toBe('number');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,4 +4,6 @@ export { default as lottaFaces } from './lotta-faces.json';
|
|||||||
export { default as lottaNodesAndFaces } from './lotta-nodes-and-faces.json';
|
export { default as lottaNodesAndFaces } from './lotta-nodes-and-faces.json';
|
||||||
export { default as lottaNodes } from './lotta-nodes.json';
|
export { default as lottaNodes } from './lotta-nodes.json';
|
||||||
export { plant } from './plant';
|
export { plant } from './plant';
|
||||||
|
export { default as simple } from './simple.json';
|
||||||
export { tree } from './tree';
|
export { tree } from './tree';
|
||||||
|
export { default as tutorial } from './tutorial.json';
|
||||||
|
|||||||
63
app/src/lib/graph-templates/simple.json
Normal file
63
app/src/lib/graph-templates/simple.json
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"settings": {
|
||||||
|
"resolution.circle": 54,
|
||||||
|
"resolution.curve": 20,
|
||||||
|
"randomSeed": false
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"title": "New Project",
|
||||||
|
"lastModified": "2026-02-03T16:56:40.375Z"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"position": [
|
||||||
|
215,
|
||||||
|
85
|
||||||
|
],
|
||||||
|
"type": "max/plantarium/output",
|
||||||
|
"props": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"position": [
|
||||||
|
165,
|
||||||
|
72.5
|
||||||
|
],
|
||||||
|
"type": "max/plantarium/stem",
|
||||||
|
"props": {
|
||||||
|
"amount": 4,
|
||||||
|
"length": 4,
|
||||||
|
"thickness": 0.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"position": [
|
||||||
|
190,
|
||||||
|
77.5
|
||||||
|
],
|
||||||
|
"type": "max/plantarium/noise",
|
||||||
|
"props": {
|
||||||
|
"plant": 0,
|
||||||
|
"scale": 0.5,
|
||||||
|
"strength": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
[
|
||||||
|
10,
|
||||||
|
0,
|
||||||
|
11,
|
||||||
|
"plant"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
11,
|
||||||
|
0,
|
||||||
|
9,
|
||||||
|
"input"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
24
app/src/lib/graph-templates/tutorial.json
Normal file
24
app/src/lib/graph-templates/tutorial.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"settings": {
|
||||||
|
"resolution.circle": 54,
|
||||||
|
"resolution.curve": 20,
|
||||||
|
"randomSeed": false
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"title": "New Project",
|
||||||
|
"lastModified": "2026-02-03T16:56:40.375Z"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"position": [
|
||||||
|
215,
|
||||||
|
85
|
||||||
|
],
|
||||||
|
"type": "max/plantarium/output",
|
||||||
|
"props": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": []
|
||||||
|
}
|
||||||
145
app/src/lib/helpers.test.ts
Normal file
145
app/src/lib/helpers.test.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
72
app/src/lib/helpers/deepMerge.test.ts
Normal file
72
app/src/lib/helpers/deepMerge.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,37 @@
|
|||||||
import { browser } from '$app/environment';
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
export class LocalStore<T> {
|
export class LocalStore<T> {
|
||||||
value = $state<T>() as T;
|
value = $state<T>() as T;
|
||||||
key = '';
|
key = '';
|
||||||
@@ -10,7 +42,10 @@ export class LocalStore<T> {
|
|||||||
|
|
||||||
if (browser) {
|
if (browser) {
|
||||||
const item = localStorage.getItem(key);
|
const item = localStorage.getItem(key);
|
||||||
if (item) this.value = this.deserialize(item);
|
if (item) {
|
||||||
|
const storedValue = this.deserialize(item);
|
||||||
|
this.value = mergeRecursive(storedValue, value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect.root(() => {
|
$effect.root(() => {
|
||||||
|
|||||||
11
app/src/lib/node-registry/debugNode.ts
Normal file
11
app/src/lib/node-registry/debugNode.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export const debugNode = {
|
||||||
|
id: 'max/plantarium/debug',
|
||||||
|
inputs: {
|
||||||
|
input: {
|
||||||
|
type: '*'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
execute(_data: Int32Array): Int32Array {
|
||||||
|
return _data;
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
25
app/src/lib/node-registry/groupNodes.ts
Normal file
25
app/src/lib/node-registry/groupNodes.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { NodeDefinition } from '@nodarium/types';
|
||||||
|
|
||||||
|
export const groupInputNode: NodeDefinition = {
|
||||||
|
id: '__virtual/group/input',
|
||||||
|
inputs: {},
|
||||||
|
outputs: [],
|
||||||
|
execute(_data: Int32Array): Int32Array { return _data; }
|
||||||
|
} as unknown as NodeDefinition;
|
||||||
|
|
||||||
|
export const groupOutputNode: NodeDefinition = {
|
||||||
|
id: '__virtual/group/output',
|
||||||
|
inputs: {},
|
||||||
|
outputs: [],
|
||||||
|
execute(_data: Int32Array): Int32Array { return _data; }
|
||||||
|
} as unknown as NodeDefinition;
|
||||||
|
|
||||||
|
// Stub registered in the registry so it appears in AddMenu.
|
||||||
|
// Actual inputs/outputs are resolved from props.groupId at runtime.
|
||||||
|
export const groupNode: NodeDefinition = {
|
||||||
|
id: '__virtual/group/instance',
|
||||||
|
meta: { title: 'Group' },
|
||||||
|
inputs: {},
|
||||||
|
outputs: [],
|
||||||
|
execute(_data: Int32Array): Int32Array { return _data; }
|
||||||
|
} as unknown as NodeDefinition;
|
||||||
@@ -15,8 +15,15 @@ export class RemoteNodeRegistry implements NodeRegistry {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private url: string,
|
private url: string,
|
||||||
public cache?: AsyncCache<ArrayBuffer | string>
|
public cache?: AsyncCache<ArrayBuffer | string>,
|
||||||
) {}
|
nodes?: NodeDefinition[]
|
||||||
|
) {
|
||||||
|
if (nodes?.length) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
this.nodes.set(node.id, node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fetchJson(url: string, skipCache = false) {
|
async fetchJson(url: string, skipCache = false) {
|
||||||
const finalUrl = `${this.url}/${url}`;
|
const finalUrl = `${this.url}/${url}`;
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
svg {
|
svg {
|
||||||
height: 124px;
|
height: 126px;
|
||||||
margin: 24px 0px;
|
margin: 24px 0px;
|
||||||
border-top: solid thin var(--color-outline);
|
border-top: solid thin var(--color-outline);
|
||||||
border-bottom: solid thin var(--color-outline);
|
border-bottom: solid thin var(--color-outline);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defaultPlant, lottaFaces, plant } from '$lib/graph-templates';
|
import { defaultPlant, lottaFaces, plant, simple } from '$lib/graph-templates';
|
||||||
import type { Graph } from '$lib/types';
|
import type { Graph } from '$lib/types';
|
||||||
|
import { InputSelect } from '@nodarium/ui';
|
||||||
import type { ProjectManager } from './project-manager.svelte';
|
import type { ProjectManager } from './project-manager.svelte';
|
||||||
|
|
||||||
const { projectManager } = $props<{ projectManager: ProjectManager }>();
|
const { projectManager } = $props<{ projectManager: ProjectManager }>();
|
||||||
|
|
||||||
let showNewProject = $state(false);
|
let showNewProject = $state(false);
|
||||||
let newProjectName = $state('');
|
let newProjectName = $state('');
|
||||||
let selectedTemplate = $state('defaultPlant');
|
|
||||||
|
|
||||||
const templates = [
|
const templates = [
|
||||||
{
|
{
|
||||||
@@ -16,25 +16,27 @@
|
|||||||
graph: defaultPlant as unknown as Graph
|
graph: defaultPlant as unknown as Graph
|
||||||
},
|
},
|
||||||
{ name: 'Plant', value: 'plant', graph: plant as unknown as Graph },
|
{ name: 'Plant', value: 'plant', graph: plant as unknown as Graph },
|
||||||
|
{ name: 'Simple', value: 'simple', graph: simple as unknown as Graph },
|
||||||
{
|
{
|
||||||
name: 'Lotta Faces',
|
name: 'Lotta Faces',
|
||||||
value: 'lottaFaces',
|
value: 'lottaFaces',
|
||||||
graph: lottaFaces as unknown as Graph
|
graph: lottaFaces as unknown as Graph
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
let selectedTemplateIndex = $state(0);
|
||||||
|
|
||||||
function handleCreate() {
|
function handleCreate() {
|
||||||
const template = templates.find((t) => t.value === selectedTemplate) || templates[0];
|
const template = templates[selectedTemplateIndex] || templates[0];
|
||||||
projectManager.handleCreateProject(template.graph, newProjectName);
|
projectManager.handleCreateProject(template.graph, newProjectName);
|
||||||
newProjectName = '';
|
newProjectName = '';
|
||||||
showNewProject = false;
|
showNewProject = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="flex justify-between px-4 h-[70px] border-b-1 border-outline items-center">
|
<header class="flex justify-between px-4 h-[70px] border-b-1 border-outline items-center bg-layer-2">
|
||||||
<h3>Project</h3>
|
<h3>Project</h3>
|
||||||
<button
|
<button
|
||||||
class="px-3 py-1 bg-layer-0 rounded"
|
class="px-3 py-1 bg-layer-1 rounded"
|
||||||
onclick={() => (showNewProject = !showNewProject)}
|
onclick={() => (showNewProject = !showNewProject)}
|
||||||
>
|
>
|
||||||
New
|
New
|
||||||
@@ -42,24 +44,17 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if showNewProject}
|
{#if showNewProject}
|
||||||
<div class="flex flex-col px-4 py-3 border-b-1 border-outline gap-2">
|
<div class="flex flex-col px-4 py-3.5 mt-[1px] border-b-1 border-outline gap-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newProjectName}
|
bind:value={newProjectName}
|
||||||
placeholder="Project name"
|
placeholder="Project name"
|
||||||
class="w-full px-2 py-2 bg-gray-800 border border-gray-700 rounded"
|
class="w-full px-2 py-2 bg-layer-2 rounded"
|
||||||
onkeydown={(e) => e.key === 'Enter' && handleCreate()}
|
onkeydown={(e) => e.key === 'Enter' && handleCreate()}
|
||||||
/>
|
/>
|
||||||
<select
|
<InputSelect options={templates.map(t => t.name)} bind:value={selectedTemplateIndex} />
|
||||||
bind:value={selectedTemplate}
|
|
||||||
class="w-full px-2 py-2 bg-gray-800 border border-gray-700 rounded"
|
|
||||||
>
|
|
||||||
{#each templates as template (template.name)}
|
|
||||||
<option value={template.value}>{template.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<button
|
<button
|
||||||
class="cursor-pointer self-end px-3 py-1 bg-blue-600 rounded"
|
class="cursor-pointer self-end px-3 py-1 bg-selected rounded"
|
||||||
onclick={() => handleCreate()}
|
onclick={() => handleCreate()}
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
@@ -67,20 +62,22 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="p-4 text-white min-h-screen">
|
<div class="text-white min-h-screen">
|
||||||
{#if projectManager.loading}
|
{#if projectManager.loading}
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<ul class="space-y-2">
|
<ul>
|
||||||
{#each projectManager.projects as project (project.id)}
|
{#each projectManager.projects as project (project.id)}
|
||||||
<li>
|
<li>
|
||||||
<div
|
<div
|
||||||
class="
|
class="
|
||||||
w-full text-left px-3 py-2 rounded cursor-pointer {projectManager
|
h-[70px] border-b-1 border-b-outline
|
||||||
|
flex
|
||||||
|
w-full text-left px-3 py-2 cursor-pointer {projectManager
|
||||||
.activeProjectId.value === project.id
|
.activeProjectId.value === project.id
|
||||||
? 'bg-blue-600'
|
? 'border-l-2 border-l-selected pl-2.5!'
|
||||||
: 'bg-gray-800 hover:bg-gray-700'}
|
: ''}
|
||||||
"
|
"
|
||||||
onclick={() => projectManager.handleSelectProject(project.id!)}
|
onclick={() => projectManager.handleSelectProject(project.id!)}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -89,10 +86,10 @@
|
|||||||
e.key === 'Enter'
|
e.key === 'Enter'
|
||||||
&& projectManager.handleSelectProject(project.id!)}
|
&& projectManager.handleSelectProject(project.id!)}
|
||||||
>
|
>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center grow">
|
||||||
<span>{project.meta?.title || 'Untitled'}</span>
|
<span>{project.meta?.title || 'Untitled'}</span>
|
||||||
<button
|
<button
|
||||||
class="text-red-400 hover:text-red-300"
|
class="text-layer-1! bg-red-500 w-7 text-xl rounded-sm cursor-pointer opacity-20 hover:opacity-80"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
projectManager.handleDeleteProject(project.id!);
|
projectManager.handleDeleteProject(project.id!);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export class ProjectManager {
|
|||||||
|
|
||||||
g.id = id;
|
g.id = id;
|
||||||
if (!g.meta) g.meta = {};
|
if (!g.meta) g.meta = {};
|
||||||
if (!g.meta.title) g.meta.title = title;
|
g.meta.title = title;
|
||||||
|
|
||||||
db.saveGraph(g);
|
db.saveGraph(g);
|
||||||
this.projects = [...this.projects, g];
|
this.projects = [...this.projects, g];
|
||||||
|
|||||||
19
app/src/lib/result-viewer/Debug.svelte
Normal file
19
app/src/lib/result-viewer/Debug.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { T } from '@threlte/core';
|
||||||
|
import type { Group } from 'three';
|
||||||
|
import { updateDebugScene } from './debug';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
debugData?: Record<number, { type: string; data: Int32Array }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let group = $state<Group>(null!);
|
||||||
|
const { debugData }: Props = $props();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!group || !debugData) return;
|
||||||
|
updateDebugScene(group, $state.snapshot(debugData));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<T.Group bind:ref={group} />
|
||||||
@@ -1,33 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { colors } from '$lib/graph-interface/graph/colors.svelte';
|
import { colors } from '$lib/graph-interface/graph/colors.svelte';
|
||||||
import { T, useTask, useThrelte } from '@threlte/core';
|
import { T, useTask, useThrelte } from '@threlte/core';
|
||||||
import { Grid, MeshLineGeometry, MeshLineMaterial, Text } from '@threlte/extras';
|
import { Grid } from '@threlte/extras';
|
||||||
import {
|
import { Box3, type BufferGeometry, type Group, Mesh, MeshBasicMaterial, Vector3 } from 'three';
|
||||||
Box3,
|
|
||||||
type BufferGeometry,
|
|
||||||
type Group,
|
|
||||||
Mesh,
|
|
||||||
MeshBasicMaterial,
|
|
||||||
Vector3,
|
|
||||||
type Vector3Tuple
|
|
||||||
} from 'three';
|
|
||||||
import { appSettings } from '../settings/app-settings.svelte';
|
import { appSettings } from '../settings/app-settings.svelte';
|
||||||
import Camera from './Camera.svelte';
|
import Camera from './Camera.svelte';
|
||||||
|
import Debug from './Debug.svelte';
|
||||||
|
|
||||||
const { renderStage, invalidate: _invalidate } = useThrelte();
|
const { renderStage, invalidate: _invalidate } = useThrelte();
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
fps: number[];
|
fps: number[];
|
||||||
lines: Vector3[][];
|
debugData?: Record<number, { type: string; data: Int32Array }>;
|
||||||
scene: Group;
|
scene: Group;
|
||||||
centerCamera: boolean;
|
centerCamera: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
lines,
|
|
||||||
centerCamera,
|
centerCamera,
|
||||||
fps = $bindable(),
|
fps = $bindable(),
|
||||||
scene = $bindable()
|
scene = $bindable(),
|
||||||
|
debugData
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let geometries = $state.raw<BufferGeometry[]>([]);
|
let geometries = $state.raw<BufferGeometry[]>([]);
|
||||||
@@ -91,18 +84,12 @@
|
|||||||
});
|
});
|
||||||
_invalidate();
|
_invalidate();
|
||||||
});
|
});
|
||||||
|
|
||||||
function getPosition(geo: BufferGeometry, i: number) {
|
|
||||||
return [
|
|
||||||
geo.attributes.position.array[i],
|
|
||||||
geo.attributes.position.array[i + 1],
|
|
||||||
geo.attributes.position.array[i + 2]
|
|
||||||
] as Vector3Tuple;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Camera {center} {centerCamera} />
|
<Camera {center} {centerCamera} />
|
||||||
|
|
||||||
|
<Debug {debugData} />
|
||||||
|
|
||||||
{#if appSettings.value.showGrid}
|
{#if appSettings.value.showGrid}
|
||||||
<Grid
|
<Grid
|
||||||
cellColor={colors['outline']}
|
cellColor={colors['outline']}
|
||||||
@@ -116,35 +103,4 @@
|
|||||||
fadeOrigin={new Vector3(0, 0, 0)}
|
fadeOrigin={new Vector3(0, 0, 0)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
<T.Group bind:ref={scene}></T.Group>
|
||||||
<T.Group>
|
|
||||||
{#if geometries}
|
|
||||||
{#each geometries as geo (geo.id)}
|
|
||||||
{#if appSettings.value.debug.showIndices}
|
|
||||||
{#each geo.attributes.position.array, i (i)}
|
|
||||||
{#if i % 3 === 0}
|
|
||||||
<Text fontSize={0.25} position={getPosition(geo, i)} />
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if appSettings.value.debug.showVertices}
|
|
||||||
<T.Points visible={true}>
|
|
||||||
<T is={geo} />
|
|
||||||
<T.PointsMaterial size={0.25} />
|
|
||||||
</T.Points>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<T.Group bind:ref={scene}></T.Group>
|
|
||||||
</T.Group>
|
|
||||||
|
|
||||||
{#if appSettings.value.debug.showStemLines && lines}
|
|
||||||
{#each lines as line (line[0].x + '-' + line[0].y + '-' + '' + line[0].z)}
|
|
||||||
<T.Mesh>
|
|
||||||
<MeshLineGeometry points={line} />
|
|
||||||
<MeshLineMaterial width={0.1} color="red" depthTest={false} />
|
|
||||||
</T.Mesh>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SmallPerformanceViewer from '$lib/performance/SmallPerformanceViewer.svelte';
|
import SmallPerformanceViewer from '$lib/performance/SmallPerformanceViewer.svelte';
|
||||||
import { appSettings } from '$lib/settings/app-settings.svelte';
|
import { appSettings } from '$lib/settings/app-settings.svelte';
|
||||||
import { decodeFloat, splitNestedArray } from '@nodarium/utils';
|
import { splitNestedArray } from '@nodarium/utils';
|
||||||
import type { PerformanceStore } from '@nodarium/utils';
|
import type { PerformanceStore } from '@nodarium/utils';
|
||||||
import { Canvas } from '@threlte/core';
|
import { Canvas } from '@threlte/core';
|
||||||
import { Vector3 } from 'three';
|
import { DoubleSide } from 'three';
|
||||||
import { type Group, MeshMatcapMaterial, TextureLoader } from 'three';
|
import { type Group, MeshMatcapMaterial, TextureLoader } from 'three';
|
||||||
import { createGeometryPool, createInstancedGeometryPool } from './geometryPool';
|
import { createGeometryPool, createInstancedGeometryPool } from './geometryPool';
|
||||||
import Scene from './Scene.svelte';
|
import Scene from './Scene.svelte';
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
matcap.colorSpace = 'srgb';
|
matcap.colorSpace = 'srgb';
|
||||||
const material = new MeshMatcapMaterial({
|
const material = new MeshMatcapMaterial({
|
||||||
color: 0xffffff,
|
color: 0xffffff,
|
||||||
matcap
|
matcap,
|
||||||
|
side: DoubleSide
|
||||||
});
|
});
|
||||||
|
|
||||||
let sceneComponent = $state<ReturnType<typeof Scene>>();
|
let sceneComponent = $state<ReturnType<typeof Scene>>();
|
||||||
@@ -22,6 +23,11 @@
|
|||||||
|
|
||||||
let geometryPool: ReturnType<typeof createGeometryPool>;
|
let geometryPool: ReturnType<typeof createGeometryPool>;
|
||||||
let instancePool: ReturnType<typeof createInstancedGeometryPool>;
|
let instancePool: ReturnType<typeof createInstancedGeometryPool>;
|
||||||
|
|
||||||
|
export function invalidate() {
|
||||||
|
sceneComponent?.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
export function updateGeometries(inputs: Int32Array[], group: Group) {
|
export function updateGeometries(inputs: Int32Array[], group: Group) {
|
||||||
geometryPool = geometryPool || createGeometryPool(group, material);
|
geometryPool = geometryPool || createGeometryPool(group, material);
|
||||||
instancePool = instancePool || createInstancedGeometryPool(group, material);
|
instancePool = instancePool || createInstancedGeometryPool(group, material);
|
||||||
@@ -39,44 +45,16 @@
|
|||||||
scene: Group;
|
scene: Group;
|
||||||
centerCamera: boolean;
|
centerCamera: boolean;
|
||||||
perf: PerformanceStore;
|
perf: PerformanceStore;
|
||||||
|
debugData?: Record<number, { type: string; data: Int32Array }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { scene = $bindable(), centerCamera, perf }: Props = $props();
|
let { scene = $bindable(), centerCamera, debugData, perf }: Props = $props();
|
||||||
|
|
||||||
let lines = $state<Vector3[][]>([]);
|
|
||||||
|
|
||||||
function createLineGeometryFromEncodedData(encodedData: Int32Array) {
|
|
||||||
const positions: Vector3[] = [];
|
|
||||||
|
|
||||||
const amount = (encodedData.length - 1) / 4;
|
|
||||||
|
|
||||||
for (let i = 0; i < amount; i++) {
|
|
||||||
const x = decodeFloat(encodedData[2 + i * 4 + 0]);
|
|
||||||
const y = decodeFloat(encodedData[2 + i * 4 + 1]);
|
|
||||||
const z = decodeFloat(encodedData[2 + i * 4 + 2]);
|
|
||||||
positions.push(new Vector3(x, y, z));
|
|
||||||
}
|
|
||||||
|
|
||||||
return positions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const update = function update(result: Int32Array) {
|
export const update = function update(result: Int32Array) {
|
||||||
perf.addPoint('split-result');
|
perf.addPoint('split-result');
|
||||||
const inputs = splitNestedArray(result);
|
const inputs = splitNestedArray(result);
|
||||||
perf.endPoint();
|
perf.endPoint();
|
||||||
|
|
||||||
if (appSettings.value.debug.showStemLines) {
|
|
||||||
perf.addPoint('create-lines');
|
|
||||||
lines = inputs
|
|
||||||
.map((input) => {
|
|
||||||
if (input[0] === 0) {
|
|
||||||
return createLineGeometryFromEncodedData(input);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Boolean) as Vector3[][];
|
|
||||||
perf.endPoint();
|
|
||||||
}
|
|
||||||
|
|
||||||
perf.addPoint('update-geometries');
|
perf.addPoint('update-geometries');
|
||||||
|
|
||||||
const { totalVertices, totalFaces } = updateGeometries(inputs, scene);
|
const { totalVertices, totalFaces } = updateGeometries(inputs, scene);
|
||||||
@@ -88,7 +66,7 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if appSettings.value.debug.showPerformancePanel}
|
{#if appSettings.value.debug.advancedMode}
|
||||||
<SmallPerformanceViewer {fps} store={perf} />
|
<SmallPerformanceViewer {fps} store={perf} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -96,8 +74,8 @@
|
|||||||
<Canvas>
|
<Canvas>
|
||||||
<Scene
|
<Scene
|
||||||
bind:this={sceneComponent}
|
bind:this={sceneComponent}
|
||||||
{lines}
|
|
||||||
{centerCamera}
|
{centerCamera}
|
||||||
|
{debugData}
|
||||||
bind:scene
|
bind:scene
|
||||||
bind:fps
|
bind:fps
|
||||||
/>
|
/>
|
||||||
|
|||||||
90
app/src/lib/result-viewer/debug.ts
Normal file
90
app/src/lib/result-viewer/debug.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { splitNestedArray } from '@nodarium/utils';
|
||||||
|
import {
|
||||||
|
BufferGeometry,
|
||||||
|
type Group,
|
||||||
|
InstancedMesh,
|
||||||
|
Line,
|
||||||
|
LineBasicMaterial,
|
||||||
|
Matrix4,
|
||||||
|
MeshBasicMaterial,
|
||||||
|
SphereGeometry,
|
||||||
|
Vector3
|
||||||
|
} from 'three';
|
||||||
|
|
||||||
|
function writePath(scene: Group, data: Int32Array): Vector3[] {
|
||||||
|
const positions: Vector3[] = [];
|
||||||
|
const f32 = new Float32Array(data.buffer);
|
||||||
|
|
||||||
|
for (let i = 2; i + 2 < f32.length; i += 4) {
|
||||||
|
const vec = new Vector3(f32[i], f32[i + 1], f32[i + 2]);
|
||||||
|
positions.push(vec);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path line
|
||||||
|
if (positions.length >= 2) {
|
||||||
|
const geometry = new BufferGeometry().setFromPoints(positions);
|
||||||
|
const line = new Line(
|
||||||
|
geometry,
|
||||||
|
new LineBasicMaterial({ color: 0xff0000, depthTest: false })
|
||||||
|
);
|
||||||
|
scene.add(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instanced spheres at points
|
||||||
|
if (positions.length > 0) {
|
||||||
|
const sphereGeometry = new SphereGeometry(0.05, 8, 8); // keep low-poly
|
||||||
|
const sphereMaterial = new MeshBasicMaterial({
|
||||||
|
color: 0xff0000,
|
||||||
|
depthTest: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const spheres = new InstancedMesh(
|
||||||
|
sphereGeometry,
|
||||||
|
sphereMaterial,
|
||||||
|
positions.length
|
||||||
|
);
|
||||||
|
|
||||||
|
const matrix = new Matrix4();
|
||||||
|
for (let i = 0; i < positions.length; i++) {
|
||||||
|
matrix.makeTranslation(
|
||||||
|
positions[i].x,
|
||||||
|
positions[i].y,
|
||||||
|
positions[i].z
|
||||||
|
);
|
||||||
|
spheres.setMatrixAt(i, matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
spheres.instanceMatrix.needsUpdate = true;
|
||||||
|
scene.add(spheres);
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearGroup(group: Group) {
|
||||||
|
for (let i = group.children.length - 1; i >= 0; i--) {
|
||||||
|
const child = group.children[i];
|
||||||
|
group.remove(child);
|
||||||
|
// optional but correct: free GPU memory
|
||||||
|
// @ts-expect-error three.js runtime fields
|
||||||
|
child.geometry?.dispose?.();
|
||||||
|
// @ts-expect-error three.js runtime fields
|
||||||
|
child.material?.dispose?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDebugScene(
|
||||||
|
group: Group,
|
||||||
|
data: Record<number, { type: string; data: Int32Array }>
|
||||||
|
) {
|
||||||
|
clearGroup(group);
|
||||||
|
return Object.entries(data || {}).map(([, d]) => {
|
||||||
|
switch (d.type) {
|
||||||
|
case 'path':
|
||||||
|
splitNestedArray(d.data)
|
||||||
|
.forEach(p => writePath(group, p));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
return (_g: Group) => {};
|
||||||
|
}).flat();
|
||||||
|
}
|
||||||
@@ -6,6 +6,142 @@ import type {
|
|||||||
RuntimeExecutor,
|
RuntimeExecutor,
|
||||||
SyncCache
|
SyncCache
|
||||||
} from '@nodarium/types';
|
} from '@nodarium/types';
|
||||||
|
|
||||||
|
function isGroupInstanceType(type: string): boolean {
|
||||||
|
return type === '__virtual/group/instance';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandGroups(graph: Graph): Graph {
|
||||||
|
if (!graph.groups || Object.keys(graph.groups).length === 0) {
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nodes = [...graph.nodes];
|
||||||
|
let edges = [...graph.edges];
|
||||||
|
const groups = graph.groups;
|
||||||
|
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
const node = nodes[i];
|
||||||
|
if (!isGroupInstanceType(node.type)) continue;
|
||||||
|
|
||||||
|
const groupId = (node.props as Record<string, unknown> | undefined)?.groupId as string | undefined;
|
||||||
|
if (!groupId) continue;
|
||||||
|
const group = groups[groupId];
|
||||||
|
if (!group) continue;
|
||||||
|
|
||||||
|
changed = true;
|
||||||
|
|
||||||
|
// Recursively expand nested groups inside this group's internal graph
|
||||||
|
const expandedInternal = expandGroups({
|
||||||
|
id: 0,
|
||||||
|
nodes: group.graph.nodes,
|
||||||
|
edges: group.graph.edges,
|
||||||
|
groups
|
||||||
|
});
|
||||||
|
|
||||||
|
const ID_PREFIX = node.id * 1000000;
|
||||||
|
const idMap = new Map<number, number>();
|
||||||
|
|
||||||
|
const inputVirtualNode = expandedInternal.nodes.find(
|
||||||
|
n => n.type === '__virtual/group/input'
|
||||||
|
);
|
||||||
|
const outputVirtualNode = expandedInternal.nodes.find(
|
||||||
|
n => n.type === '__virtual/group/output'
|
||||||
|
);
|
||||||
|
|
||||||
|
const realInternalNodes = expandedInternal.nodes.filter(
|
||||||
|
n => n.type !== '__virtual/group/input' && n.type !== '__virtual/group/output'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const n of realInternalNodes) {
|
||||||
|
idMap.set(n.id, ID_PREFIX + n.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentIncomingEdges = edges.filter(e => e[2] === node.id);
|
||||||
|
const parentOutgoingEdges = edges.filter(e => e[0] === node.id);
|
||||||
|
|
||||||
|
// Edges from/to virtual nodes in the expanded internal graph
|
||||||
|
const edgesFromInput = expandedInternal.edges.filter(
|
||||||
|
e => e[0] === inputVirtualNode?.id
|
||||||
|
);
|
||||||
|
const edgesToOutput = expandedInternal.edges.filter(
|
||||||
|
e => e[2] === outputVirtualNode?.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const newEdges: Graph['edges'] = [];
|
||||||
|
|
||||||
|
// Short-circuit: parent source → internal target (via group input)
|
||||||
|
for (const parentEdge of parentIncomingEdges) {
|
||||||
|
const socketName = parentEdge[3];
|
||||||
|
const socketIdx = group.inputs.findIndex(s => s.name === socketName);
|
||||||
|
if (socketIdx === -1) continue;
|
||||||
|
|
||||||
|
for (const internalEdge of edgesFromInput.filter(e => e[1] === socketIdx)) {
|
||||||
|
const remappedId = idMap.get(internalEdge[2]);
|
||||||
|
if (remappedId !== undefined) {
|
||||||
|
newEdges.push([parentEdge[0], parentEdge[1], remappedId, internalEdge[3]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short-circuit: internal source → parent target (via group output)
|
||||||
|
for (const parentEdge of parentOutgoingEdges) {
|
||||||
|
const outputIdx = parentEdge[1];
|
||||||
|
const outputSocketName = group.outputs[outputIdx]?.name;
|
||||||
|
if (!outputSocketName) continue;
|
||||||
|
|
||||||
|
for (const internalEdge of edgesToOutput.filter(e => e[3] === outputSocketName)) {
|
||||||
|
const remappedId = idMap.get(internalEdge[0]);
|
||||||
|
if (remappedId !== undefined) {
|
||||||
|
newEdges.push([remappedId, internalEdge[1], parentEdge[2], parentEdge[3]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remap internal-to-internal edges
|
||||||
|
const internalEdges = expandedInternal.edges.filter(
|
||||||
|
e => e[0] !== inputVirtualNode?.id
|
||||||
|
&& e[0] !== outputVirtualNode?.id
|
||||||
|
&& e[2] !== inputVirtualNode?.id
|
||||||
|
&& e[2] !== outputVirtualNode?.id
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const e of internalEdges) {
|
||||||
|
const fromId = idMap.get(e[0]);
|
||||||
|
const toId = idMap.get(e[2]);
|
||||||
|
if (fromId !== undefined && toId !== undefined) {
|
||||||
|
newEdges.push([fromId, e[1], toId, e[3]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the group node
|
||||||
|
nodes.splice(i, 1);
|
||||||
|
|
||||||
|
// Add remapped internal nodes
|
||||||
|
for (const n of realInternalNodes) {
|
||||||
|
nodes.push({ ...n, id: idMap.get(n.id)! });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove group node's edges and add short-circuit edges
|
||||||
|
const groupEdgeKeys = new Set([
|
||||||
|
...parentIncomingEdges.map(e => `${e[0]}-${e[1]}-${e[2]}-${e[3]}`),
|
||||||
|
...parentOutgoingEdges.map(e => `${e[0]}-${e[1]}-${e[2]}-${e[3]}`)
|
||||||
|
]);
|
||||||
|
edges = edges.filter(
|
||||||
|
e => !groupEdgeKeys.has(`${e[0]}-${e[1]}-${e[2]}-${e[3]}`)
|
||||||
|
);
|
||||||
|
edges.push(...newEdges);
|
||||||
|
|
||||||
|
break; // Restart loop with updated nodes array
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...graph, nodes, edges };
|
||||||
|
}
|
||||||
import {
|
import {
|
||||||
concatEncodedArrays,
|
concatEncodedArrays,
|
||||||
createLogger,
|
createLogger,
|
||||||
@@ -28,7 +164,7 @@ function getValue(input: NodeInput, value?: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
if (input.type === 'vec3') {
|
if (input.type === 'vec3' || input.type === 'shape') {
|
||||||
return [
|
return [
|
||||||
0,
|
0,
|
||||||
value.length + 1,
|
value.length + 1,
|
||||||
@@ -59,6 +195,7 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
|
|||||||
private definitionMap: Map<string, NodeDefinition> = new Map();
|
private definitionMap: Map<string, NodeDefinition> = new Map();
|
||||||
|
|
||||||
private seed = Math.floor(Math.random() * 100000000);
|
private seed = Math.floor(Math.random() * 100000000);
|
||||||
|
private debugData: Record<number, { type: string; data: Int32Array }> = {};
|
||||||
|
|
||||||
perf?: PerformanceStore;
|
perf?: PerformanceStore;
|
||||||
|
|
||||||
@@ -74,7 +211,11 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
|
|||||||
throw new Error('Node registry is not ready');
|
throw new Error('Node registry is not ready');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.registry.load(graph.nodes.map((node) => node.type));
|
// Only load non-virtual types (virtual nodes are resolved locally)
|
||||||
|
const nonVirtualTypes = graph.nodes
|
||||||
|
.map(node => node.type)
|
||||||
|
.filter(t => !t.startsWith('__virtual/'));
|
||||||
|
await this.registry.load(nonVirtualTypes as any);
|
||||||
|
|
||||||
const typeMap = new Map<string, NodeDefinition>();
|
const typeMap = new Map<string, NodeDefinition>();
|
||||||
for (const node of graph.nodes) {
|
for (const node of graph.nodes) {
|
||||||
@@ -124,10 +265,10 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes = [];
|
const nodes = new Map<number, RuntimeNode>();
|
||||||
|
|
||||||
// loop through all the nodes and assign each nodes its depth
|
// loop through all the nodes and assign each nodes its depth
|
||||||
const stack = [outputNode];
|
const stack = [outputNode, ...graphNodes.filter(n => n.type.endsWith('/debug'))];
|
||||||
while (stack.length) {
|
while (stack.length) {
|
||||||
const node = stack.pop();
|
const node = stack.pop();
|
||||||
if (!node) continue;
|
if (!node) continue;
|
||||||
@@ -136,16 +277,34 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
|
|||||||
parent.state.depth = node.state.depth + 1;
|
parent.state.depth = node.state.depth + 1;
|
||||||
stack.push(parent);
|
stack.push(parent);
|
||||||
}
|
}
|
||||||
nodes.push(node);
|
nodes.set(node.id, node);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [outputNode, nodes] as const;
|
for (const node of graphNodes) {
|
||||||
|
if (node.type.endsWith('/debug')) {
|
||||||
|
node.state = node.state || {};
|
||||||
|
const parent = node.state.parents[0];
|
||||||
|
if (parent) {
|
||||||
|
node.state.depth = parent.state.depth - 1;
|
||||||
|
parent.state.debugNode = true;
|
||||||
|
}
|
||||||
|
nodes.set(node.id, node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _nodes = [...nodes.values()];
|
||||||
|
|
||||||
|
return [outputNode, _nodes] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(graph: Graph, settings: Record<string, unknown>) {
|
async execute(graph: Graph, settings: Record<string, unknown>) {
|
||||||
this.perf?.addPoint('runtime');
|
this.perf?.addPoint('runtime');
|
||||||
|
|
||||||
let a = performance.now();
|
let a = performance.now();
|
||||||
|
this.debugData = {};
|
||||||
|
|
||||||
|
// Expand group nodes into a flat graph before execution
|
||||||
|
graph = expandGroups(graph);
|
||||||
|
|
||||||
// Then we add some metadata to the graph
|
// Then we add some metadata to the graph
|
||||||
const [outputNode, nodes] = await this.addMetaData(graph);
|
const [outputNode, nodes] = await this.addMetaData(graph);
|
||||||
@@ -237,6 +396,12 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
|
|||||||
log.log(`Using cached value for ${node_type.id || node.id}`);
|
log.log(`Using cached value for ${node_type.id || node.id}`);
|
||||||
this.perf?.addPoint('cache-hit', 1);
|
this.perf?.addPoint('cache-hit', 1);
|
||||||
results[node.id] = cachedValue as Int32Array;
|
results[node.id] = cachedValue as Int32Array;
|
||||||
|
if (node.state.debugNode && node_type.outputs) {
|
||||||
|
this.debugData[node.id] = {
|
||||||
|
type: node_type.outputs[0],
|
||||||
|
data: cachedValue
|
||||||
|
};
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.perf?.addPoint('cache-hit', 0);
|
this.perf?.addPoint('cache-hit', 0);
|
||||||
@@ -245,6 +410,12 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
|
|||||||
log.log(`Inputs:`, inputs);
|
log.log(`Inputs:`, inputs);
|
||||||
a = performance.now();
|
a = performance.now();
|
||||||
results[node.id] = node_type.execute(encoded_inputs);
|
results[node.id] = node_type.execute(encoded_inputs);
|
||||||
|
if (node.state.debugNode && node_type.outputs) {
|
||||||
|
this.debugData[node.id] = {
|
||||||
|
type: node_type.outputs[0],
|
||||||
|
data: results[node.id]
|
||||||
|
};
|
||||||
|
}
|
||||||
log.log('Executed', node.type, node.id);
|
log.log('Executed', node.type, node.id);
|
||||||
b = performance.now();
|
b = performance.now();
|
||||||
|
|
||||||
@@ -273,6 +444,10 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
|
|||||||
return res as unknown as Int32Array;
|
return res as unknown as Int32Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDebugData() {
|
||||||
|
return this.debugData;
|
||||||
|
}
|
||||||
|
|
||||||
getPerformanceData() {
|
getPerformanceData() {
|
||||||
return this.perf?.get();
|
return this.perf?.get();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ type RuntimeState = {
|
|||||||
parents: RuntimeNode[];
|
parents: RuntimeNode[];
|
||||||
children: RuntimeNode[];
|
children: RuntimeNode[];
|
||||||
inputNodes: Record<string, RuntimeNode>;
|
inputNodes: Record<string, RuntimeNode>;
|
||||||
|
debugNode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RuntimeNode = SerializedNode & { state: RuntimeState };
|
export type RuntimeNode = SerializedNode & { state: RuntimeState };
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
|
import { debugNode } from '$lib/node-registry/debugNode';
|
||||||
|
import { groupInputNode, groupNode, groupOutputNode } from '$lib/node-registry/groupNodes';
|
||||||
import { IndexDBCache, RemoteNodeRegistry } from '$lib/node-registry/index';
|
import { IndexDBCache, RemoteNodeRegistry } from '$lib/node-registry/index';
|
||||||
import type { Graph } from '@nodarium/types';
|
import type { Graph } from '@nodarium/types';
|
||||||
import { createPerformanceStore } from '@nodarium/utils';
|
import { createPerformanceStore } from '@nodarium/utils';
|
||||||
import { MemoryRuntimeExecutor } from './runtime-executor';
|
import { expandGroups, MemoryRuntimeExecutor } from './runtime-executor';
|
||||||
import { MemoryRuntimeCache } from './runtime-executor-cache';
|
import { MemoryRuntimeCache } from './runtime-executor-cache';
|
||||||
|
|
||||||
const indexDbCache = new IndexDBCache('node-registry');
|
const indexDbCache = new IndexDBCache('node-registry');
|
||||||
const nodeRegistry = new RemoteNodeRegistry('', indexDbCache);
|
const nodeRegistry = new RemoteNodeRegistry('', indexDbCache, [
|
||||||
|
debugNode,
|
||||||
|
groupInputNode,
|
||||||
|
groupOutputNode,
|
||||||
|
groupNode
|
||||||
|
]);
|
||||||
|
|
||||||
const cache = new MemoryRuntimeCache();
|
const cache = new MemoryRuntimeCache();
|
||||||
const executor = new MemoryRuntimeExecutor(nodeRegistry, cache);
|
const executor = new MemoryRuntimeExecutor(nodeRegistry, cache);
|
||||||
@@ -33,7 +40,13 @@ export async function executeGraph(
|
|||||||
graph: Graph,
|
graph: Graph,
|
||||||
settings: Record<string, unknown>
|
settings: Record<string, unknown>
|
||||||
): Promise<Int32Array> {
|
): Promise<Int32Array> {
|
||||||
await nodeRegistry.load(graph.nodes.map((n) => n.type));
|
// Expand groups before loading types so we only load real (non-virtual) node types
|
||||||
|
const expandedGraph = expandGroups(graph);
|
||||||
|
await nodeRegistry.load(
|
||||||
|
expandedGraph.nodes
|
||||||
|
.map(n => n.type)
|
||||||
|
.filter(t => !t.startsWith('__virtual/')) as any
|
||||||
|
);
|
||||||
performanceStore.startRun();
|
performanceStore.startRun();
|
||||||
const res = await executor.execute(graph, settings);
|
const res = await executor.execute(graph, settings);
|
||||||
performanceStore.stopRun();
|
performanceStore.stopRun();
|
||||||
@@ -43,3 +56,7 @@ export async function executeGraph(
|
|||||||
export function getPerformanceData() {
|
export function getPerformanceData() {
|
||||||
return performanceStore.get();
|
return performanceStore.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDebugData() {
|
||||||
|
return executor.getDebugData();
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ export class WorkerRuntimeExecutor implements RuntimeExecutor {
|
|||||||
new URL(`./worker-runtime-executor-backend.ts`, import.meta.url)
|
new URL(`./worker-runtime-executor-backend.ts`, import.meta.url)
|
||||||
);
|
);
|
||||||
|
|
||||||
async execute(graph: Graph, settings: Record<string, unknown>) {
|
execute(graph: Graph, settings: Record<string, unknown>) {
|
||||||
return this.worker.executeGraph(graph, settings);
|
return this.worker.executeGraph(graph, settings);
|
||||||
}
|
}
|
||||||
async getPerformanceData() {
|
getPerformanceData() {
|
||||||
return this.worker.getPerformanceData();
|
return this.worker.getPerformanceData();
|
||||||
}
|
}
|
||||||
|
getDebugData() {
|
||||||
|
return this.worker.getDebugData();
|
||||||
|
}
|
||||||
set useRuntimeCache(useCache: boolean) {
|
set useRuntimeCache(useCache: boolean) {
|
||||||
this.worker.setUseRuntimeCache(useCache);
|
this.worker.setUseRuntimeCache(useCache);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,14 @@
|
|||||||
key?: string;
|
key?: string;
|
||||||
value: SettingsValue;
|
value: SettingsValue;
|
||||||
type: SettingsType;
|
type: SettingsType;
|
||||||
|
onButtonClick?: (id: string) => void;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Local persistent state for <details> sections
|
// Local persistent state for <details> sections
|
||||||
const openSections = localState<Record<string, boolean>>('open-details', {});
|
const openSections = localState<Record<string, boolean>>('open-details', {});
|
||||||
|
|
||||||
let { id, key = '', value = $bindable(), type, depth = 0 }: Props = $props();
|
let { id, key = '', value = $bindable(), type, onButtonClick, depth = 0 }: Props = $props();
|
||||||
|
|
||||||
function isNodeInput(v: SettingsNode | undefined): v is InputType {
|
function isNodeInput(v: SettingsNode | undefined): v is InputType {
|
||||||
return !!v && typeof v === 'object' && 'type' in v;
|
return !!v && typeof v === 'object' && 'type' in v;
|
||||||
@@ -56,6 +57,10 @@
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(inputValue) && node.type === 'vec3') {
|
||||||
|
return inputValue;
|
||||||
|
}
|
||||||
|
|
||||||
// If the component is supplied with a default value use that
|
// If the component is supplied with a default value use that
|
||||||
if (inputValue !== undefined && typeof inputValue !== 'object') {
|
if (inputValue !== undefined && typeof inputValue !== 'object') {
|
||||||
return inputValue;
|
return inputValue;
|
||||||
@@ -98,16 +103,11 @@
|
|||||||
&& typeof internalValue === 'number'
|
&& typeof internalValue === 'number'
|
||||||
) {
|
) {
|
||||||
value[key] = node?.options?.[internalValue];
|
value[key] = node?.options?.[internalValue];
|
||||||
} else if (internalValue) {
|
} else if (internalValue !== undefined) {
|
||||||
value[key] = internalValue;
|
value[key] = internalValue;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleClick() {
|
|
||||||
const callback = value[key] as unknown as () => void;
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
open = openSections.value[id];
|
open = openSections.value[id];
|
||||||
|
|
||||||
@@ -124,10 +124,9 @@
|
|||||||
|
|
||||||
{#if key && isNodeInput(type?.[key])}
|
{#if key && isNodeInput(type?.[key])}
|
||||||
{@const inputType = type[key]}
|
{@const inputType = type[key]}
|
||||||
<!-- Leaf input -->
|
|
||||||
<div class="input input-{inputType.type}" class:first-level={depth === 1}>
|
<div class="input input-{inputType.type}" class:first-level={depth === 1}>
|
||||||
{#if inputType.type === 'button'}
|
{#if inputType.type === 'button'}
|
||||||
<button onclick={handleClick}>
|
<button onclick={() => onButtonClick?.(id)}>
|
||||||
{inputType.label || key}
|
{inputType.label || key}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -138,9 +137,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if depth === 0}
|
{:else if depth === 0}
|
||||||
<!-- Root: iterate over top-level keys -->
|
|
||||||
{#each Object.keys(type ?? {}).filter((k) => k !== 'title') as childKey (childKey)}
|
{#each Object.keys(type ?? {}).filter((k) => k !== 'title') as childKey (childKey)}
|
||||||
<NestedSettings
|
<NestedSettings
|
||||||
|
{onButtonClick}
|
||||||
id={`${id}.${childKey}`}
|
id={`${id}.${childKey}`}
|
||||||
key={childKey}
|
key={childKey}
|
||||||
bind:value
|
bind:value
|
||||||
@@ -150,7 +149,6 @@
|
|||||||
{/each}
|
{/each}
|
||||||
<hr />
|
<hr />
|
||||||
{:else if key && type?.[key]}
|
{:else if key && type?.[key]}
|
||||||
<!-- Group -->
|
|
||||||
{#if depth > 0}
|
{#if depth > 0}
|
||||||
<hr />
|
<hr />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -159,6 +157,7 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
{#each Object.keys(type[key] as SettingsGroup).filter((k) => k !== 'title') as childKey (childKey)}
|
{#each Object.keys(type[key] as SettingsGroup).filter((k) => k !== 'title') as childKey (childKey)}
|
||||||
<NestedSettings
|
<NestedSettings
|
||||||
|
{onButtonClick}
|
||||||
id={`${id}.${childKey}`}
|
id={`${id}.${childKey}`}
|
||||||
key={childKey}
|
key={childKey}
|
||||||
bind:value={value[key] as SettingsValue}
|
bind:value={value[key] as SettingsValue}
|
||||||
@@ -210,7 +209,7 @@
|
|||||||
.first-level.input {
|
.first-level.input {
|
||||||
padding-left: 1em;
|
padding-left: 1em;
|
||||||
padding-right: 1em;
|
padding-right: 1em;
|
||||||
padding-bottom: 1px;
|
padding-bottom: 0.5px;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +219,9 @@
|
|||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
background: var(--color-layer-2);
|
||||||
|
padding-block: 5px;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
hr {
|
hr {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const themes = [
|
|||||||
'catppuccin',
|
'catppuccin',
|
||||||
'solarized',
|
'solarized',
|
||||||
'high-contrast',
|
'high-contrast',
|
||||||
|
'high-contrast-light',
|
||||||
'nord',
|
'nord',
|
||||||
'dracula'
|
'dracula'
|
||||||
] as const;
|
] as const;
|
||||||
@@ -27,12 +28,17 @@ export const AppSettingTypes = {
|
|||||||
label: 'Center Camera',
|
label: 'Center Camera',
|
||||||
value: true
|
value: true
|
||||||
},
|
},
|
||||||
|
clippy: {
|
||||||
|
type: 'button',
|
||||||
|
label: '🌱 Open Planty'
|
||||||
|
},
|
||||||
nodeInterface: {
|
nodeInterface: {
|
||||||
title: 'Node Interface',
|
title: 'Node Interface',
|
||||||
showNodeGrid: {
|
backgroundType: {
|
||||||
type: 'boolean',
|
type: 'select',
|
||||||
label: 'Show Grid',
|
label: 'Background',
|
||||||
value: true
|
options: ['grid', 'dots', 'none'],
|
||||||
|
value: 'grid'
|
||||||
},
|
},
|
||||||
snapToGrid: {
|
snapToGrid: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
@@ -57,34 +63,9 @@ export const AppSettingTypes = {
|
|||||||
label: 'Execute in WebWorker',
|
label: 'Execute in WebWorker',
|
||||||
value: true
|
value: true
|
||||||
},
|
},
|
||||||
showIndices: {
|
advancedMode: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
label: 'Show Indices',
|
label: 'Advanced Mode',
|
||||||
value: false
|
|
||||||
},
|
|
||||||
showPerformancePanel: {
|
|
||||||
type: 'boolean',
|
|
||||||
label: 'Show Performance Panel',
|
|
||||||
value: false
|
|
||||||
},
|
|
||||||
showBenchmarkPanel: {
|
|
||||||
type: 'boolean',
|
|
||||||
label: 'Show Benchmark Panel',
|
|
||||||
value: false
|
|
||||||
},
|
|
||||||
showVertices: {
|
|
||||||
type: 'boolean',
|
|
||||||
label: 'Show Vertices',
|
|
||||||
value: false
|
|
||||||
},
|
|
||||||
showStemLines: {
|
|
||||||
type: 'boolean',
|
|
||||||
label: 'Show Stem Lines',
|
|
||||||
value: false
|
|
||||||
},
|
|
||||||
showGraphJson: {
|
|
||||||
type: 'boolean',
|
|
||||||
label: 'Show Graph Source',
|
|
||||||
value: false
|
value: false
|
||||||
},
|
},
|
||||||
cache: {
|
cache: {
|
||||||
@@ -132,9 +113,8 @@ export const AppSettingTypes = {
|
|||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type SettingsToStore<T> = T extends { type: 'button' } ? () => void
|
type SettingsToStore<T> = T extends { value: infer V } ? V extends readonly string[] ? V[number]
|
||||||
: T extends { value: infer V } ? V extends readonly string[] ? V[number]
|
: V
|
||||||
: V
|
|
||||||
: T extends object ? {
|
: T extends object ? {
|
||||||
-readonly [K in keyof T as T[K] extends object ? K : never]: SettingsToStore<T[K]>;
|
-readonly [K in keyof T as T[K] extends object ? K : never]: SettingsToStore<T[K]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
<div class="wrapper" class:hidden>
|
<div class="wrapper" class:hidden>
|
||||||
{#if title}
|
{#if title}
|
||||||
<header class="bg-layer-2">
|
<header class="bg-layer-2">
|
||||||
<h3 class="font-bold">{title}</h3>
|
<h3>{title}</h3>
|
||||||
</header>
|
</header>
|
||||||
{/if}
|
{/if}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
import { type Snippet } from 'svelte';
|
import { type Snippet } from 'svelte';
|
||||||
import { panelState as state } from './PanelState.svelte';
|
import { panelState as state } from './PanelState.svelte';
|
||||||
|
|
||||||
const { children } = $props<{ children?: Snippet }>();
|
let { children, open = $bindable(false) } = $props<{ children?: Snippet; open?: boolean }>();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
open = !!state.activePanel.value;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="wrapper" class:visible={state.activePanel.value}>
|
<div class="wrapper" class:visible={state.activePanel.value}>
|
||||||
|
|||||||
@@ -42,11 +42,13 @@
|
|||||||
const store: Store = {};
|
const store: Store = {};
|
||||||
Object.keys(inputs).forEach((key) => {
|
Object.keys(inputs).forEach((key) => {
|
||||||
if (props) {
|
if (props) {
|
||||||
const value = props[key] || inputs[key].value;
|
const value = props[key] !== undefined ? props[key] : inputs[key].value;
|
||||||
if (Array.isArray(value) || typeof value === 'number') {
|
if (Array.isArray(value) || typeof value === 'number') {
|
||||||
store[key] = value;
|
store[key] = value;
|
||||||
|
} else if (typeof value === 'boolean') {
|
||||||
|
store[key] = value ? 1 : 0;
|
||||||
} else {
|
} else {
|
||||||
console.error('Wrong error');
|
console.error('Wrong error', { value });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -94,6 +96,4 @@
|
|||||||
bind:value={store}
|
bind:value={store}
|
||||||
type={nodeDefinition}
|
type={nodeDefinition}
|
||||||
/>
|
/>
|
||||||
{:else}
|
|
||||||
<p class="mx-4 mt-4">Node has no settings</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -5,22 +5,27 @@
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
manager: GraphManager;
|
manager: GraphManager;
|
||||||
node: NodeInstance | undefined;
|
node: NodeInstance;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { manager, node = $bindable() }: Props = $props();
|
let { manager, node = $bindable() }: Props = $props();
|
||||||
|
|
||||||
|
const inputs = $derived(node?.state?.type?.inputs || {});
|
||||||
|
|
||||||
|
const hasSettings = $derived(
|
||||||
|
Object.values(inputs).find(entry => {
|
||||||
|
return entry.hidden === true;
|
||||||
|
}) !== undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
$inspect({ inputs, hasSettings });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class='{node?"border-l-2 pl-3.5!":""} bg-layer-2 flex items-center h-[70px] border-b-1 border-l-selected border-b-outline pl-4'>
|
{#key node.id}
|
||||||
<h3 class="font-bold">Node Settings</h3>
|
{#if node && hasSettings}
|
||||||
</div>
|
<div class="border-l-2 pl-3.5! bg-layer-2 flex items-center h-[70px] border-b-1 border-l-selected border-b-outline pl-4">
|
||||||
|
<h3>Node Settings</h3>
|
||||||
{#if node}
|
</div>
|
||||||
{#key node.id}
|
<ActiveNodeSelected {manager} bind:node />
|
||||||
{#if node}
|
{/if}
|
||||||
<ActiveNodeSelected {manager} bind:node />
|
{/key}
|
||||||
{/if}
|
|
||||||
{/key}
|
|
||||||
{:else}
|
|
||||||
<p class="mx-4 mt-4">No node selected</p>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
185
app/src/lib/sidebar/panels/Changelog.svelte
Normal file
185
app/src/lib/sidebar/panels/Changelog.svelte
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Details } from '@nodarium/ui';
|
||||||
|
import { micromark } from 'micromark';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
git?: Record<string, string>;
|
||||||
|
changelog?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
git,
|
||||||
|
changelog
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const typeMap = new Map([
|
||||||
|
['fix', 'border-l-red-800'],
|
||||||
|
['feat', 'border-l-green-800'],
|
||||||
|
['chore', 'border-l-gray-800'],
|
||||||
|
['docs', 'border-l-blue-800'],
|
||||||
|
['refactor', 'border-l-purple-800'],
|
||||||
|
['ci', 'border-l-red-400']
|
||||||
|
]);
|
||||||
|
|
||||||
|
function detectCommitType(commit: string) {
|
||||||
|
for (const key of typeMap.keys()) {
|
||||||
|
if (commit.startsWith(key)) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCommit(line?: string) {
|
||||||
|
if (!line) return;
|
||||||
|
|
||||||
|
const regex = /^\s*-\s*\[([a-f0-9]+)\]\((https?:\/\/[^\s)]+)\)\s+(.+)$/;
|
||||||
|
|
||||||
|
const match = line.match(regex);
|
||||||
|
if (!match) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, sha, link, description] = match;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sha,
|
||||||
|
link,
|
||||||
|
description,
|
||||||
|
type: detectCommitType(description)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChangelog(md: string) {
|
||||||
|
return md.split(/^# v/gm)
|
||||||
|
.filter(l => !!l.length)
|
||||||
|
.map(release => {
|
||||||
|
const [firstLine, ...rest] = release.split('\n');
|
||||||
|
const title = firstLine.trim();
|
||||||
|
|
||||||
|
const blocks = rest
|
||||||
|
.join('\n')
|
||||||
|
.split('---');
|
||||||
|
|
||||||
|
const commits = blocks.length > 1
|
||||||
|
? blocks
|
||||||
|
.at(-1)
|
||||||
|
?.split('\n')
|
||||||
|
?.map(line => parseCommit(line))
|
||||||
|
?.filter(c => !!c)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const description = (
|
||||||
|
blocks.length > 1
|
||||||
|
? blocks
|
||||||
|
.slice(0, -1)
|
||||||
|
.join('\n')
|
||||||
|
: blocks[0]
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: micromark(description),
|
||||||
|
title,
|
||||||
|
commits
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="changelog" class="p-4 font-mono text-text overflow-y-auto max-h-full space-y-5">
|
||||||
|
{#if git}
|
||||||
|
<div class="mb-4 p-3 bg-layer-2 text-xs rounded">
|
||||||
|
<p><strong>Branch:</strong> {git.branch}</p>
|
||||||
|
<p>
|
||||||
|
<strong>Commit:</strong>
|
||||||
|
<a
|
||||||
|
href="https://git.max-richter.dev/max/nodarium/commit/{git.sha}"
|
||||||
|
class="link"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{git.sha.slice(0, 7)}
|
||||||
|
</a>
|
||||||
|
– {git.commit_message}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Commits since last release:</strong>
|
||||||
|
{git.commits_since_last_release}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Timestamp:</strong>
|
||||||
|
{new Date(git.commit_timestamp).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if changelog}
|
||||||
|
{#each parseChangelog(changelog) as release (release)}
|
||||||
|
<Details title={release.title}>
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
|
<div id="description" class="pb-5">{@html release.description}</div>
|
||||||
|
|
||||||
|
{#if release?.commits?.length}
|
||||||
|
<Details
|
||||||
|
title="All Commits"
|
||||||
|
class="commits"
|
||||||
|
>
|
||||||
|
{#each release.commits as commit (commit)}
|
||||||
|
<p class="py-1 leading-7 text-xs border-b-1 border-l-1 border-b-outline last:border-b-0 -ml-2 pl-2 {typeMap.get(commit.type)}">
|
||||||
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
|
<a href={commit.link} class="link" target="_blank">{commit.sha}</a>
|
||||||
|
{commit.description}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</Details>
|
||||||
|
{/if}
|
||||||
|
</Details>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="postcss">
|
||||||
|
@reference "tailwindcss";
|
||||||
|
|
||||||
|
#changelog :global(.commits) {
|
||||||
|
margin-left: -16px;
|
||||||
|
margin-right: -16px;
|
||||||
|
border-radius: 0px 0px 2px 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#changelog :global(details > div){
|
||||||
|
padding-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#changelog :global(.commits > div) {
|
||||||
|
padding-bottom: 0px;
|
||||||
|
padding-top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#description :global(h2) {
|
||||||
|
@apply font-bold mt-4 mb-1;
|
||||||
|
}
|
||||||
|
#description :global(h2:first-child) {
|
||||||
|
margin-top: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#description :global(ul) {
|
||||||
|
padding-left: 1em;
|
||||||
|
}
|
||||||
|
#description :global(li),
|
||||||
|
#description :global(p) {
|
||||||
|
@apply text-xs!;
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
#changelog :global(details > details[open] > summary){
|
||||||
|
margin-bottom: 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: #60a5fa;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
{
|
{
|
||||||
...g,
|
...g,
|
||||||
nodes: g.nodes.map((n: object) => ({ ...n, tmp: undefined }))
|
nodes: g.nodes.map((n: object) => ({ ...n, tmp: undefined, state: undefined }))
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
|
|||||||
148
app/src/lib/sidebar/panels/GroupContextPanel.svelte
Normal file
148
app/src/lib/sidebar/panels/GroupContextPanel.svelte
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { GraphManager } from '$lib/graph-interface/graph-manager.svelte';
|
||||||
|
import { InputSelect } from '@nodarium/ui';
|
||||||
|
|
||||||
|
type Props = { manager: GraphManager; groupId: string };
|
||||||
|
const { manager, groupId }: Props = $props();
|
||||||
|
|
||||||
|
$inspect({ groupId });
|
||||||
|
|
||||||
|
const group = $derived(manager.groups.get(groupId));
|
||||||
|
|
||||||
|
const COMMON_TYPES = ['plant', 'float', 'int', 'vec3', 'bool'];
|
||||||
|
let selectedTypeIdx = $state(0);
|
||||||
|
let customType = $state('');
|
||||||
|
|
||||||
|
function rename(e: Event) {
|
||||||
|
if (!group) return;
|
||||||
|
const name = (e.target as HTMLInputElement).value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
group.name = name;
|
||||||
|
if (manager.graph.groups?.[groupId]) manager.graph.groups[groupId].name = name;
|
||||||
|
const def = manager.groupNodeDefinitions.get(`__virtual/group/${groupId}`);
|
||||||
|
if (def?.meta) def.meta.title = name;
|
||||||
|
manager.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSocket() {
|
||||||
|
const type = customType.trim() || COMMON_TYPES[selectedTypeIdx];
|
||||||
|
if (!type) return;
|
||||||
|
manager.addGroupSocket('input', type);
|
||||||
|
customType = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSocket(index: number) {
|
||||||
|
manager.removeGroupSocket('input', index);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bg-layer-2 flex items-center h-[70px] border-b-1 border-outline pl-4">
|
||||||
|
<h3>Group Settings</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 py-3 flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="section-label">Group name</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={group?.name ?? ''}
|
||||||
|
onchange={rename}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
placeholder="Group name"
|
||||||
|
class="bg-layer-2 text-text rounded-[5px] px-2 py-1.5 text-sm w-full box-border outline outline-1 outline-outline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="section-label">Inputs</span>
|
||||||
|
|
||||||
|
{#if (group?.inputs?.length ?? 0) === 0}
|
||||||
|
<p class="text-sm opacity-40 italic m-0">No inputs yet</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="socket-list">
|
||||||
|
{#each group?.inputs ?? [] as socket, i}
|
||||||
|
<li class="socket-item">
|
||||||
|
<span class="flex-1 opacity-80 text-sm">{socket.name}</span>
|
||||||
|
<span class="text-xs opacity-45 italic">{socket.type}</span>
|
||||||
|
<button class="remove-btn" onclick={() => removeSocket(i)} title="Remove">×</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex gap-1.5 items-center">
|
||||||
|
<InputSelect options={COMMON_TYPES} bind:value={selectedTypeIdx} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="custom type…"
|
||||||
|
bind:value={customType}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'Enter') addSocket();
|
||||||
|
}}
|
||||||
|
class="bg-layer-2 text-text rounded-[5px] px-2 py-1 text-sm flex-1 min-w-0 outline outline-1 outline-outline"
|
||||||
|
/>
|
||||||
|
<button class="add-btn" onclick={addSocket}>+ Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.section-label {
|
||||||
|
font-size: 0.72em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--color-layer-2);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
outline: 1px solid var(--color-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.4;
|
||||||
|
padding: 0 2px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
background: var(--color-layer-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: none;
|
||||||
|
outline: 1px solid var(--color-outline);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.4em 0.7em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn:hover {
|
||||||
|
outline-color: var(--color-selected);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
240
app/src/lib/tutorial/tutorial-config.ts
Normal file
240
app/src/lib/tutorial/tutorial-config.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import type { PlantyConfig } from '@nodarium/planty';
|
||||||
|
|
||||||
|
export const tutorialConfig: PlantyConfig = {
|
||||||
|
id: 'nodarium-tutorial',
|
||||||
|
avatar: {
|
||||||
|
name: 'Planty',
|
||||||
|
defaultPosition: 'bottom-right'
|
||||||
|
},
|
||||||
|
start: 'intro',
|
||||||
|
nodes: {
|
||||||
|
// ── Entry ──────────────────────────────────────────────────────────────
|
||||||
|
intro: {
|
||||||
|
position: 'center',
|
||||||
|
text:
|
||||||
|
"# Hi, I'm Planty! 🌱\nI'll show you around Nodarium — a tool for building 3D plants by connecting nodes together.\nHow much detail do you want?",
|
||||||
|
choices: [
|
||||||
|
{ label: '🌱 Show me the basics', next: 'tour_canvas' },
|
||||||
|
{ label: '🤓 I want the technical details', next: 'tour_canvas_nerd' },
|
||||||
|
{ label: 'Skip the tour for now', next: null }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Simple path ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
tour_canvas: {
|
||||||
|
position: 'bottom-left',
|
||||||
|
action: 'setup-default',
|
||||||
|
highlight: { selector: '.graph-wrapper', padding: 12 },
|
||||||
|
text:
|
||||||
|
'This is the **graph canvas**. Nodes connect together to build a plant — the 3D model updates automatically whenever you make a change.\nEach node does one specific job: grow stems, add noise, produce output.',
|
||||||
|
next: 'tour_viewer'
|
||||||
|
},
|
||||||
|
|
||||||
|
tour_viewer: {
|
||||||
|
position: 'top-left',
|
||||||
|
highlight: { selector: '.cell:first-child', padding: 8 },
|
||||||
|
text:
|
||||||
|
'This is the **3D viewer** — the live preview of your plant.\nLeft-click to rotate · right-click to pan · scroll to zoom.',
|
||||||
|
next: 'try_params'
|
||||||
|
},
|
||||||
|
|
||||||
|
try_params: {
|
||||||
|
position: 'bottom-right',
|
||||||
|
highlight: { selector: '.graph-wrapper', padding: 12 },
|
||||||
|
text:
|
||||||
|
'Click a node to select it. Its settings appear **directly on the node** — try changing a value and watch the plant update.\nThe sidebar shows extra hidden settings for the selected node.',
|
||||||
|
next: 'start_building'
|
||||||
|
},
|
||||||
|
|
||||||
|
start_building: {
|
||||||
|
position: 'center',
|
||||||
|
action: 'load-tutorial-template',
|
||||||
|
text:
|
||||||
|
"Now let's build your own plant from scratch!\nI've loaded a blank project — it only has an **Output** node. Your goal: connect nodes to make a plant.",
|
||||||
|
next: 'add_stem_node'
|
||||||
|
},
|
||||||
|
|
||||||
|
add_stem_node: {
|
||||||
|
position: 'bottom-right',
|
||||||
|
highlight: { selector: '.graph-wrapper', padding: 12 },
|
||||||
|
text:
|
||||||
|
"Open the **Add Menu** with **Shift+A** or **right-click** on the canvas.\nAdd a **Stem** node, then connect its output socket to the Output node's input.",
|
||||||
|
next: 'add_noise_node'
|
||||||
|
},
|
||||||
|
|
||||||
|
add_noise_node: {
|
||||||
|
position: 'bottom-right',
|
||||||
|
highlight: { selector: '.graph-wrapper', padding: 12 },
|
||||||
|
text:
|
||||||
|
'Add a **Noise** node the same way.\nConnect: Stem → Noise input, then Noise output → Output.\nThis makes the stems grow in organic, curved shapes.',
|
||||||
|
next: 'add_random_node'
|
||||||
|
},
|
||||||
|
|
||||||
|
add_random_node: {
|
||||||
|
position: 'bottom-right',
|
||||||
|
highlight: { selector: '.graph-wrapper', padding: 12 },
|
||||||
|
text:
|
||||||
|
"Let's add some randomness! Add a **Random** node and connect its output to the **thickness** or **length** input of the Stem node.\nThe default min/max range is small — **Ctrl+drag** any number field to exceed its normal limits.",
|
||||||
|
next: 'prompt_regenerate'
|
||||||
|
},
|
||||||
|
|
||||||
|
prompt_regenerate: {
|
||||||
|
position: 'bottom-right',
|
||||||
|
highlight: { selector: '.graph-wrapper', padding: 12 },
|
||||||
|
text:
|
||||||
|
'Now press **R** to regenerate. Each press gives the Random node a new value — your plant changes every run!',
|
||||||
|
next: 'tour_sidebar'
|
||||||
|
},
|
||||||
|
|
||||||
|
tour_sidebar: {
|
||||||
|
position: 'right',
|
||||||
|
highlight: { selector: '.tabs', padding: 4 },
|
||||||
|
text:
|
||||||
|
'The **sidebar** holds all your tools:\n⚙️ Settings · ⌨️ Shortcuts · 📦 Export · 📁 Projects · 📊 Graph Settings\nEnable **Advanced Mode** in Settings to unlock performance and benchmark panels.',
|
||||||
|
next: 'save_project'
|
||||||
|
},
|
||||||
|
|
||||||
|
save_project: {
|
||||||
|
position: 'right',
|
||||||
|
text:
|
||||||
|
'Your work is saved in the **Projects panel** — rename it, create new projects, or switch between them anytime.',
|
||||||
|
next: 'congrats'
|
||||||
|
},
|
||||||
|
|
||||||
|
congrats: {
|
||||||
|
position: 'center',
|
||||||
|
text:
|
||||||
|
"# You're all set! 🎉\nYou know how to build plants, tweak parameters, and save your work.\nWant to explore more?",
|
||||||
|
choices: [
|
||||||
|
{ label: '🔗 How do node connections work?', next: 'connections_intro' },
|
||||||
|
{ label: '💡 Ideas for improving this plant', next: 'improvements_hint' },
|
||||||
|
{ label: '⌨️ Keyboard shortcuts', next: 'shortcuts_tour' },
|
||||||
|
{ label: "I'm ready to build!", next: null }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Technical / nerd path ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
tour_canvas_nerd: {
|
||||||
|
position: 'bottom-left',
|
||||||
|
action: 'setup-default',
|
||||||
|
highlight: { selector: '.graph-wrapper', padding: 12 },
|
||||||
|
text:
|
||||||
|
"The **graph canvas** renders a directed acyclic graph. Each node is an individual **WASM module** executed in isolation — inputs in, output out. The 3D model updates automatically on every change.\nI've loaded a starter graph so you can see it in action.",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
label: '🔍 Explore Node Sourcecode',
|
||||||
|
action: 'open-github-nodes'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
next: 'tour_viewer_nerd'
|
||||||
|
},
|
||||||
|
|
||||||
|
tour_viewer_nerd: {
|
||||||
|
position: 'top-left',
|
||||||
|
highlight: { selector: '.cell:first-child', padding: 8 },
|
||||||
|
text:
|
||||||
|
'The **3D viewer** uses `@threlte/core` (Svelte + Three.js). Mesh data streams from WASM execution results. OrbitControls: left-drag rotate, right-drag pan, scroll zoom.',
|
||||||
|
next: 'tour_runtime_nerd'
|
||||||
|
},
|
||||||
|
|
||||||
|
tour_runtime_nerd: {
|
||||||
|
position: 'bottom-right',
|
||||||
|
text:
|
||||||
|
'By default, nodes execute in a **WebWorker** for better performance. You can switch to main-thread execution by disabling **Debug → Execute in WebWorker** in Settings.\nEnable **Advanced Mode** to unlock the Performance and Benchmark panels.',
|
||||||
|
next: 'start_building'
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Deep dives (shared between paths) ─────────────────────────────────
|
||||||
|
|
||||||
|
connections_intro: {
|
||||||
|
position: 'bottom-right',
|
||||||
|
text:
|
||||||
|
'Node sockets are **type-checked**. The coloured dots tell you what kind of data flows through:\n🔵 `number` · 🟢 `vec3` · 🟣 `shape` · ⚪ `*` (wildcard)',
|
||||||
|
next: 'connections_rules'
|
||||||
|
},
|
||||||
|
|
||||||
|
connections_rules: {
|
||||||
|
position: 'right',
|
||||||
|
text:
|
||||||
|
'Drag from an output socket to an input socket to connect them.\n• Types must match (or use `*`)\n• No circular loops\n• Optional inputs can stay empty\nInvalid connections snap back automatically.',
|
||||||
|
choices: [
|
||||||
|
{ label: '🔧 Node parameters', next: 'params_intro' },
|
||||||
|
{ label: '🐛 Debug node', next: 'debug_intro' },
|
||||||
|
{ label: 'Start building!', next: null }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
params_intro: {
|
||||||
|
position: 'bottom-right',
|
||||||
|
highlight: { selector: '.graph-wrapper', padding: 12 },
|
||||||
|
text:
|
||||||
|
'Click any node to select it. Basic settings are shown **on the node itself**.\nThe sidebar under *Graph Settings → Active Node* shows the full list:\n**Number** — drag or type · **Vec3** — X/Y/Z · **Select** — dropdown · **Color** — picker',
|
||||||
|
next: 'params_tip'
|
||||||
|
},
|
||||||
|
|
||||||
|
params_tip: {
|
||||||
|
position: 'right',
|
||||||
|
text:
|
||||||
|
'Pro tips:\n• Parameters can be connected from other nodes — drag an edge to the input socket\n• The **Random Seed** in Graph Settings gives you the same result every run\n• **f** key smart-connects two selected nodes · **Ctrl+Delete** removes a node and restores its edges',
|
||||||
|
choices: [
|
||||||
|
{ label: '🔗 How connections work', next: 'connections_intro' },
|
||||||
|
{ label: '💡 Plant improvement ideas', next: 'improvements_hint' },
|
||||||
|
{ label: 'Start building!', next: null }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
debug_intro: {
|
||||||
|
position: 'bottom-right',
|
||||||
|
text:
|
||||||
|
'Add a **Debug node** from the Add Menu (Shift+A or right-click). It accepts `*` wildcard inputs — connect any socket to inspect the data flowing through.\nEnable **Advanced Mode** in Settings to also see Performance and Graph Source panels.',
|
||||||
|
next: 'debug_done'
|
||||||
|
},
|
||||||
|
|
||||||
|
debug_done: {
|
||||||
|
position: 'center',
|
||||||
|
text: 'The Debug node is your best friend when building complex graphs.\nAnything else?',
|
||||||
|
choices: [
|
||||||
|
{ label: '🔗 Connection types', next: 'connections_intro' },
|
||||||
|
{ label: '🔧 Node parameters', next: 'params_intro' },
|
||||||
|
{ label: 'Start building!', next: null }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
shortcuts_tour: {
|
||||||
|
position: 'bottom-right',
|
||||||
|
text:
|
||||||
|
'**Essential shortcuts:**\n`R` — Regenerate\n`Shift+A` / right-click — Add node\n`f` — Smart-connect selected nodes\n`.` — Center camera\n`Ctrl+Z` / `Ctrl+Y` — Undo / Redo\n`Delete` — Remove selected · `Ctrl+Delete` — Remove and restore edges',
|
||||||
|
next: 'shortcuts_done'
|
||||||
|
},
|
||||||
|
|
||||||
|
shortcuts_done: {
|
||||||
|
position: 'right',
|
||||||
|
text:
|
||||||
|
'All shortcuts are also listed in the sidebar under the ⌨️ icon.\nReady to build something?',
|
||||||
|
choices: [
|
||||||
|
{ label: '🔗 Node connections', next: 'connections_intro' },
|
||||||
|
{ label: '🔧 Parameters', next: 'params_intro' },
|
||||||
|
{ label: "Let's build! 🌿", next: null }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
export_tour: {
|
||||||
|
position: 'right',
|
||||||
|
text:
|
||||||
|
'Export your 3D model from the **📦 Export** panel:\n**GLB** — standard for 3D apps (Blender, Three.js)\n**OBJ** — legacy format · **STL** — 3D printing · **PNG** — screenshot',
|
||||||
|
next: 'congrats'
|
||||||
|
},
|
||||||
|
|
||||||
|
improvements_hint: {
|
||||||
|
position: 'center',
|
||||||
|
text:
|
||||||
|
'# Ideas to grow your plant 🌿\n• Add a **Vec3** node → connect to *origin* on the Stem to spread stems across 3D space\n• Use a **Random** node on a parameter so each run produces a unique shape\n• Chain **multiple Stem nodes** with different settings for complex branching\n• Add a **Gravity** or **Branch** node for even more organic results',
|
||||||
|
choices: [
|
||||||
|
{ label: '⌨️ Keyboard shortcuts', next: 'shortcuts_tour' },
|
||||||
|
{ label: "Let's build! 🌿", next: null }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
20
app/src/routes/+error.svelte
Normal file
20
app/src/routes/+error.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="w-screen h-screen flex flex-col items-center justify-center">
|
||||||
|
<div class="outline-1 outline-outline bg-layer-2">
|
||||||
|
<h1 class="p-8 text-3xl">@nodarium/error</h1>
|
||||||
|
<hr>
|
||||||
|
<pre class="p-8">{JSON.stringify(page.error, null, 2)}</pre>
|
||||||
|
<hr>
|
||||||
|
<div class="flex p-4">
|
||||||
|
<button
|
||||||
|
class="bg-layer-2 outline-1 outline-outline p-3 px-6 rounded-sm cursor-pointer"
|
||||||
|
on:click={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
@@ -1 +1,28 @@
|
|||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
|
|
||||||
|
export async function load({ fetch }) {
|
||||||
|
async function fetchChangelog() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/CHANGELOG.md');
|
||||||
|
return await res.text();
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed to fetch CHANGELOG.md', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGitInfo() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/git.json');
|
||||||
|
return await res.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed to fetch git.json', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
git: await fetchGitInfo(),
|
||||||
|
changelog: await fetchChangelog()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
import Grid from '$lib/grid';
|
import Grid from '$lib/grid';
|
||||||
import { debounceAsyncFunction } from '$lib/helpers';
|
import { debounceAsyncFunction } from '$lib/helpers';
|
||||||
import { createKeyMap } from '$lib/helpers/createKeyMap';
|
import { createKeyMap } from '$lib/helpers/createKeyMap';
|
||||||
|
import { debugNode } from '$lib/node-registry/debugNode.js';
|
||||||
|
import { groupInputNode, groupNode, groupOutputNode } from '$lib/node-registry/groupNodes.js';
|
||||||
import { IndexDBCache, RemoteNodeRegistry } from '$lib/node-registry/index';
|
import { IndexDBCache, RemoteNodeRegistry } from '$lib/node-registry/index';
|
||||||
import NodeStore from '$lib/node-store/NodeStore.svelte';
|
import NodeStore from '$lib/node-store/NodeStore.svelte';
|
||||||
import PerformanceViewer from '$lib/performance/PerformanceViewer.svelte';
|
import PerformanceViewer from '$lib/performance/PerformanceViewer.svelte';
|
||||||
@@ -17,19 +19,32 @@
|
|||||||
import Panel from '$lib/sidebar/Panel.svelte';
|
import Panel from '$lib/sidebar/Panel.svelte';
|
||||||
import ActiveNodeSettings from '$lib/sidebar/panels/ActiveNodeSettings.svelte';
|
import ActiveNodeSettings from '$lib/sidebar/panels/ActiveNodeSettings.svelte';
|
||||||
import BenchmarkPanel from '$lib/sidebar/panels/BenchmarkPanel.svelte';
|
import BenchmarkPanel from '$lib/sidebar/panels/BenchmarkPanel.svelte';
|
||||||
|
import Changelog from '$lib/sidebar/panels/Changelog.svelte';
|
||||||
import ExportSettings from '$lib/sidebar/panels/ExportSettings.svelte';
|
import ExportSettings from '$lib/sidebar/panels/ExportSettings.svelte';
|
||||||
import GraphSource from '$lib/sidebar/panels/GraphSource.svelte';
|
import GraphSource from '$lib/sidebar/panels/GraphSource.svelte';
|
||||||
|
import GroupContextPanel from '$lib/sidebar/panels/GroupContextPanel.svelte';
|
||||||
import Keymap from '$lib/sidebar/panels/Keymap.svelte';
|
import Keymap from '$lib/sidebar/panels/Keymap.svelte';
|
||||||
|
import { panelState } from '$lib/sidebar/PanelState.svelte';
|
||||||
import Sidebar from '$lib/sidebar/Sidebar.svelte';
|
import Sidebar from '$lib/sidebar/Sidebar.svelte';
|
||||||
|
import { tutorialConfig } from '$lib/tutorial/tutorial-config';
|
||||||
|
import { Planty } from '@nodarium/planty';
|
||||||
import type { Graph, NodeInstance } from '@nodarium/types';
|
import type { Graph, NodeInstance } from '@nodarium/types';
|
||||||
import { createPerformanceStore } from '@nodarium/utils';
|
import { createPerformanceStore } from '@nodarium/utils';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import type { Group } from 'three';
|
import type { Group } from 'three';
|
||||||
|
|
||||||
let performanceStore = createPerformanceStore();
|
let performanceStore = createPerformanceStore();
|
||||||
|
let planty = $state<ReturnType<typeof Planty>>();
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
|
||||||
const registryCache = new IndexDBCache('node-registry');
|
const registryCache = new IndexDBCache('node-registry');
|
||||||
const nodeRegistry = new RemoteNodeRegistry('', registryCache);
|
|
||||||
|
const nodeRegistry = new RemoteNodeRegistry('', registryCache, [
|
||||||
|
debugNode,
|
||||||
|
groupInputNode,
|
||||||
|
groupOutputNode,
|
||||||
|
groupNode
|
||||||
|
]);
|
||||||
const workerRuntime = new WorkerRuntimeExecutor();
|
const workerRuntime = new WorkerRuntimeExecutor();
|
||||||
const runtimeCache = new MemoryRuntimeCache();
|
const runtimeCache = new MemoryRuntimeCache();
|
||||||
const memoryRuntime = new MemoryRuntimeExecutor(nodeRegistry, runtimeCache);
|
const memoryRuntime = new MemoryRuntimeExecutor(nodeRegistry, runtimeCache);
|
||||||
@@ -60,8 +75,10 @@
|
|||||||
let activeNode = $state<NodeInstance | undefined>(undefined);
|
let activeNode = $state<NodeInstance | undefined>(undefined);
|
||||||
let scene = $state<Group>(null!);
|
let scene = $state<Group>(null!);
|
||||||
|
|
||||||
|
let sidebarOpen = $state(false);
|
||||||
let graphInterface = $state<ReturnType<typeof GraphInterface>>(null!);
|
let graphInterface = $state<ReturnType<typeof GraphInterface>>(null!);
|
||||||
let viewerComponent = $state<ReturnType<typeof Viewer>>();
|
let viewerComponent = $state<ReturnType<typeof Viewer>>();
|
||||||
|
let debugData = $state<Record<number, { type: string; data: Int32Array }>>();
|
||||||
const manager = $derived(graphInterface?.manager);
|
const manager = $derived(graphInterface?.manager);
|
||||||
|
|
||||||
async function randomGenerate() {
|
async function randomGenerate() {
|
||||||
@@ -101,6 +118,7 @@
|
|||||||
|
|
||||||
if (appSettings.value.debug.useWorker) {
|
if (appSettings.value.debug.useWorker) {
|
||||||
let perfData = await runtime.getPerformanceData();
|
let perfData = await runtime.getPerformanceData();
|
||||||
|
debugData = await runtime.getDebugData();
|
||||||
let lastRun = perfData?.at(-1);
|
let lastRun = perfData?.at(-1);
|
||||||
if (lastRun?.total) {
|
if (lastRun?.total) {
|
||||||
lastRun.runtime = lastRun.total;
|
lastRun.runtime = lastRun.total;
|
||||||
@@ -122,35 +140,113 @@
|
|||||||
|
|
||||||
const handleUpdate = debounceAsyncFunction(update);
|
const handleUpdate = debounceAsyncFunction(update);
|
||||||
|
|
||||||
onMount(() => {
|
function handleSettingsButton(id: string) {
|
||||||
appSettings.value.debug.stressTest = {
|
switch (id) {
|
||||||
...appSettings.value.debug.stressTest,
|
case 'general.clippy':
|
||||||
loadGrid: () => {
|
planty?.start();
|
||||||
|
break;
|
||||||
|
case 'general.debug.stressTest.loadGrid':
|
||||||
manager.load(
|
manager.load(
|
||||||
templates.grid(
|
templates.grid(
|
||||||
appSettings.value.debug.stressTest.amount,
|
appSettings.value.debug.stressTest.amount,
|
||||||
appSettings.value.debug.stressTest.amount
|
appSettings.value.debug.stressTest.amount
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
},
|
break;
|
||||||
loadTree: () => {
|
case 'general.debug.stressTest.loadTree':
|
||||||
manager.load(templates.tree(appSettings.value.debug.stressTest.amount));
|
manager.load(templates.tree(appSettings.value.debug.stressTest.amount));
|
||||||
},
|
break;
|
||||||
lottaFaces: () => {
|
case 'general.debug.stressTest.lottaFaces':
|
||||||
manager.load(templates.lottaFaces as unknown as Graph);
|
manager.load(templates.lottaFaces as unknown as Graph);
|
||||||
},
|
break;
|
||||||
lottaNodes: () => {
|
case 'general.debug.stressTest.lottaNodes':
|
||||||
manager.load(templates.lottaNodes as unknown as Graph);
|
manager.load(templates.lottaNodes as unknown as Graph);
|
||||||
},
|
break;
|
||||||
lottaNodesAndFaces: () => {
|
case 'general.debug.stressTest.lottaNodesAndFaces':
|
||||||
manager.load(templates.lottaNodesAndFaces as unknown as Graph);
|
manager.load(templates.lottaNodesAndFaces as unknown as Graph);
|
||||||
}
|
break;
|
||||||
};
|
default:
|
||||||
});
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document onkeydown={applicationKeymap.handleKeyboardEvent} />
|
<svelte:document onkeydown={applicationKeymap.handleKeyboardEvent} />
|
||||||
|
|
||||||
|
<Planty
|
||||||
|
bind:this={planty}
|
||||||
|
config={tutorialConfig}
|
||||||
|
actions={{
|
||||||
|
'setup-default': () => {
|
||||||
|
const ts = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
pm.handleCreateProject(
|
||||||
|
structuredClone(templates.defaultPlant) as unknown as Graph,
|
||||||
|
`Tutorial Project (${ts})`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
'load-tutorial-template': () => {
|
||||||
|
if (!pm.graph) return;
|
||||||
|
const g = structuredClone(templates.tutorial) as unknown as Graph;
|
||||||
|
g.id = pm.graph.id;
|
||||||
|
g.meta = { ...pm.graph.meta };
|
||||||
|
pm.graph = g;
|
||||||
|
pm.saveGraph(g);
|
||||||
|
graphInterface.state.centerNode(graphInterface.manager.getAllNodes()[0]);
|
||||||
|
},
|
||||||
|
'open-github-nodes': () => {
|
||||||
|
window.open(
|
||||||
|
'https://github.com/jim-fx/nodarium/tree/main/nodes/max/plantarium',
|
||||||
|
'__blank'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
hooks={{
|
||||||
|
'action:add_stem_node': (cb) => {
|
||||||
|
const unsub = manager.on('save', () => {
|
||||||
|
const allNodes = graphInterface.manager.getAllNodes();
|
||||||
|
const stemNode = allNodes.find(n => n.type === 'max/plantarium/stem');
|
||||||
|
if (stemNode && graphInterface.manager.edges.length) {
|
||||||
|
unsub();
|
||||||
|
(cb as () => void)();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
'action:add_noise_node': (cb) => {
|
||||||
|
const unsub = manager.on('save', () => {
|
||||||
|
const allNodes = graphInterface.manager.getAllNodes();
|
||||||
|
const noiseNode = allNodes.find(n => n.type === 'max/plantarium/noise');
|
||||||
|
if (noiseNode && graphInterface.manager.edges.length > 1) {
|
||||||
|
unsub();
|
||||||
|
(cb as () => void)();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
'action:add_random_node': (cb) => {
|
||||||
|
const unsub = manager.on('save', () => {
|
||||||
|
const allNodes = graphInterface.manager.getAllNodes();
|
||||||
|
const noiseNode = allNodes.find(n => n.type === 'max/plantarium/random');
|
||||||
|
if (noiseNode && graphInterface.manager.edges.length > 2) {
|
||||||
|
unsub();
|
||||||
|
(cb as () => void)();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
'action:prompt_regenerate': (cb) => {
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'r') {
|
||||||
|
window.removeEventListener('keydown', handleKeydown);
|
||||||
|
(cb as () => void)();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeydown);
|
||||||
|
},
|
||||||
|
'before:save_project': () => panelState.setActivePanel('projects'),
|
||||||
|
'before:export_tour': () => panelState.setActivePanel('exports'),
|
||||||
|
'before:shortcuts_tour': () => panelState.setActivePanel('shortcuts'),
|
||||||
|
'after:save_project': () => panelState.setActivePanel('graph-settings'),
|
||||||
|
'before:tour_runtime_nerd': () => panelState.setActivePanel('general')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="wrapper manager-{manager?.status}">
|
<div class="wrapper manager-{manager?.status}">
|
||||||
<header></header>
|
<header></header>
|
||||||
<Grid.Row>
|
<Grid.Row>
|
||||||
@@ -159,6 +255,7 @@
|
|||||||
bind:scene
|
bind:scene
|
||||||
bind:this={viewerComponent}
|
bind:this={viewerComponent}
|
||||||
perf={performanceStore}
|
perf={performanceStore}
|
||||||
|
debugData={debugData}
|
||||||
centerCamera={appSettings.value.centerCamera}
|
centerCamera={appSettings.value.centerCamera}
|
||||||
/>
|
/>
|
||||||
</Grid.Cell>
|
</Grid.Cell>
|
||||||
@@ -168,7 +265,8 @@
|
|||||||
graph={pm.graph}
|
graph={pm.graph}
|
||||||
bind:this={graphInterface}
|
bind:this={graphInterface}
|
||||||
registry={nodeRegistry}
|
registry={nodeRegistry}
|
||||||
showGrid={appSettings.value.nodeInterface.showNodeGrid}
|
safePadding={{ right: sidebarOpen ? 330 : undefined }}
|
||||||
|
backgroundType={appSettings.value.nodeInterface.backgroundType}
|
||||||
snapToGrid={appSettings.value.nodeInterface.snapToGrid}
|
snapToGrid={appSettings.value.nodeInterface.snapToGrid}
|
||||||
bind:activeNode
|
bind:activeNode
|
||||||
bind:showHelp={appSettings.value.nodeInterface.showHelp}
|
bind:showHelp={appSettings.value.nodeInterface.showHelp}
|
||||||
@@ -178,10 +276,11 @@
|
|||||||
onresult={(result) => handleUpdate(result as Graph)}
|
onresult={(result) => handleUpdate(result as Graph)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<Sidebar>
|
<Sidebar bind:open={sidebarOpen}>
|
||||||
<Panel id="general" title="General" icon="i-[tabler--settings]">
|
<Panel id="general" title="General" icon="i-[tabler--settings]">
|
||||||
<NestedSettings
|
<NestedSettings
|
||||||
id="general"
|
id="general"
|
||||||
|
onButtonClick={handleSettingsButton}
|
||||||
bind:value={appSettings.value}
|
bind:value={appSettings.value}
|
||||||
type={AppSettingTypes}
|
type={AppSettingTypes}
|
||||||
/>
|
/>
|
||||||
@@ -201,17 +300,19 @@
|
|||||||
<Panel id="exports" title="Exporter" icon="i-[tabler--package-export]">
|
<Panel id="exports" title="Exporter" icon="i-[tabler--package-export]">
|
||||||
<ExportSettings {scene} />
|
<ExportSettings {scene} />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel
|
{#if 0 > 1}
|
||||||
id="node-store"
|
<Panel
|
||||||
title="Node Store"
|
id="node-store"
|
||||||
icon="i-[tabler--database] bg-green-400"
|
title="Node Store"
|
||||||
>
|
icon="i-[tabler--database] bg-green-400"
|
||||||
<NodeStore registry={nodeRegistry} />
|
>
|
||||||
</Panel>
|
<NodeStore registry={nodeRegistry} />
|
||||||
|
</Panel>
|
||||||
|
{/if}
|
||||||
<Panel
|
<Panel
|
||||||
id="performance"
|
id="performance"
|
||||||
title="Performance"
|
title="Performance"
|
||||||
hidden={!appSettings.value.debug.showPerformancePanel}
|
hidden={!appSettings.value.debug.advancedMode}
|
||||||
icon="i-[tabler--brand-speedtest] bg-red-400"
|
icon="i-[tabler--brand-speedtest] bg-red-400"
|
||||||
>
|
>
|
||||||
{#if $performanceStore}
|
{#if $performanceStore}
|
||||||
@@ -224,7 +325,7 @@
|
|||||||
<Panel
|
<Panel
|
||||||
id="graph-source"
|
id="graph-source"
|
||||||
title="Graph Source"
|
title="Graph Source"
|
||||||
hidden={!appSettings.value.debug.showGraphJson}
|
hidden={!appSettings.value.debug.advancedMode}
|
||||||
icon="i-[tabler--code]"
|
icon="i-[tabler--code]"
|
||||||
>
|
>
|
||||||
<GraphSource graph={pm.graph ?? manager?.serialize()} />
|
<GraphSource graph={pm.graph ?? manager?.serialize()} />
|
||||||
@@ -232,7 +333,7 @@
|
|||||||
<Panel
|
<Panel
|
||||||
id="benchmark"
|
id="benchmark"
|
||||||
title="Benchmark"
|
title="Benchmark"
|
||||||
hidden={!appSettings.value.debug.showBenchmarkPanel}
|
hidden={!appSettings.value.debug.advancedMode}
|
||||||
icon="i-[tabler--graph] bg-red-400"
|
icon="i-[tabler--graph] bg-red-400"
|
||||||
>
|
>
|
||||||
<BenchmarkPanel run={randomGenerate} />
|
<BenchmarkPanel run={randomGenerate} />
|
||||||
@@ -247,7 +348,27 @@
|
|||||||
type={graphSettingTypes}
|
type={graphSettingTypes}
|
||||||
bind:value={graphSettings}
|
bind:value={graphSettings}
|
||||||
/>
|
/>
|
||||||
<ActiveNodeSettings {manager} bind:node={activeNode} />
|
{#if activeNode?.id}
|
||||||
|
<ActiveNodeSettings {manager} bind:node={activeNode} />
|
||||||
|
{/if}
|
||||||
|
{#if manager?.isInsideGroup}
|
||||||
|
<GroupContextPanel
|
||||||
|
{manager}
|
||||||
|
groupId={manager.currentGroupContext!}
|
||||||
|
/>
|
||||||
|
{:else if activeNode?.type === '__virtual/group/instance'}
|
||||||
|
<GroupContextPanel
|
||||||
|
{manager}
|
||||||
|
groupId={activeNode?.props?.groupId as string}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</Panel>
|
||||||
|
<Panel
|
||||||
|
id="changelog"
|
||||||
|
title="Changelog"
|
||||||
|
icon="i-[tabler--file-text-spark] bg-green-400"
|
||||||
|
>
|
||||||
|
<Changelog git={data.git} changelog={data.changelog} />
|
||||||
</Panel>
|
</Panel>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</Grid.Cell>
|
</Grid.Cell>
|
||||||
@@ -257,6 +378,25 @@
|
|||||||
<style>
|
<style>
|
||||||
header {
|
header {
|
||||||
background-color: var(--color-layer-1);
|
background-color: var(--color-layer-1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tutorial-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
import Sidebar from '$lib/sidebar/Sidebar.svelte';
|
import Sidebar from '$lib/sidebar/Sidebar.svelte';
|
||||||
import { type NodeId, type NodeInstance } from '@nodarium/types';
|
import { type NodeId, type NodeInstance } from '@nodarium/types';
|
||||||
import { concatEncodedArrays, createWasmWrapper, encodeNestedArray } from '@nodarium/utils';
|
import { concatEncodedArrays, createWasmWrapper, encodeNestedArray } from '@nodarium/utils';
|
||||||
import Code from './Code.svelte';
|
|
||||||
|
|
||||||
const registryCache = new IndexDBCache('node-registry');
|
const registryCache = new IndexDBCache('node-registry');
|
||||||
const nodeRegistry = new RemoteNodeRegistry('', registryCache);
|
const nodeRegistry = new RemoteNodeRegistry('', registryCache);
|
||||||
@@ -78,11 +77,7 @@
|
|||||||
</Grid.Cell>
|
</Grid.Cell>
|
||||||
|
|
||||||
<Grid.Cell>
|
<Grid.Cell>
|
||||||
<div class="h-screen w-[80vw] overflow-y-auto">
|
<div class="h-screen w-[80vw] overflow-y-auto"></div>
|
||||||
{#if nodeWasm}
|
|
||||||
<Code wasm={nodeWasm} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</Grid.Cell>
|
</Grid.Cell>
|
||||||
</Grid.Row>
|
</Grid.Row>
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import wabtInit from 'wabt';
|
|
||||||
|
|
||||||
const { wasm } = $props<{ wasm: ArrayBuffer }>();
|
|
||||||
|
|
||||||
async function toWat(arrayBuffer: ArrayBuffer) {
|
|
||||||
const wabt = await wabtInit();
|
|
||||||
|
|
||||||
const module = wabt.readWasm(new Uint8Array(arrayBuffer), {
|
|
||||||
readDebugNames: true
|
|
||||||
});
|
|
||||||
|
|
||||||
module.generateNames();
|
|
||||||
module.applyNames();
|
|
||||||
|
|
||||||
return module.toText({ foldExprs: false, inlineExport: false });
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#await toWat(wasm)}
|
|
||||||
<p>Converting to WAT</p>
|
|
||||||
{:then c}
|
|
||||||
<pre>
|
|
||||||
<code class="text-gray-50">{c}</code>
|
|
||||||
</pre>
|
|
||||||
{/await}
|
|
||||||
2
app/static/.gitignore
vendored
2
app/static/.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
nodes/
|
nodes/
|
||||||
|
CHANGELOG.md
|
||||||
|
git.json
|
||||||
|
|||||||
19
app/static/favicon.svg
Normal file
19
app/static/favicon.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M55.2154 77.6602L52.2788 78.3944L49.2607 60.6933L52.2788 33.0404L47.6293 18.0312L45.9162 18.6838L41.6745 28.7987L31.6412 33.4483H20.2211L21.6894 24.0675L31.6412 15.9919L45.9162 15.1762L49.7501 15.9919L55.2154 32.6326L54.5628 38.5873L64.8409 33.0404L69.5721 37.69L80.1764 43.1553L84.1734 52.8624L83.113 64.1193L73.8954 61.8353L66.3092 52.4545V38.5873L64.1068 36.7112L54.155 42.4212L52.2788 60.6933L55.2154 77.6602Z"
|
||||||
|
fill="url(#paint0_linear)"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="paint0_linear"
|
||||||
|
x1="34.3903"
|
||||||
|
y1="15.1762"
|
||||||
|
x2="52.1972"
|
||||||
|
y2="78.3944"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<stop stop-color="#4CAF7B" />
|
||||||
|
<stop offset="1" stop-color="#347452" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 861 B |
@@ -1,19 +0,0 @@
|
|||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
aria-hidden="true"
|
|
||||||
role="img"
|
|
||||||
class="iconify iconify--logos"
|
|
||||||
width="26.6"
|
|
||||||
height="32"
|
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
viewBox="0 0 256 308"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="#FF3E00"
|
|
||||||
d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"
|
|
||||||
></path><path
|
|
||||||
fill="#FFF"
|
|
||||||
d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,26 +0,0 @@
|
|||||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z"
|
|
||||||
fill="#FFC131"
|
|
||||||
/>
|
|
||||||
<ellipse
|
|
||||||
cx="84.1426"
|
|
||||||
cy="147"
|
|
||||||
rx="22"
|
|
||||||
ry="22"
|
|
||||||
transform="rotate(180 84.1426 147)"
|
|
||||||
fill="#24C8DB"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z"
|
|
||||||
fill="#FFC131"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z"
|
|
||||||
fill="#24C8DB"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,37 +0,0 @@
|
|||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
aria-hidden="true"
|
|
||||||
role="img"
|
|
||||||
class="iconify iconify--logos"
|
|
||||||
width="31.88"
|
|
||||||
height="32"
|
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
viewBox="0 0 256 257"
|
|
||||||
>
|
|
||||||
<defs><linearGradient
|
|
||||||
id="IconifyId1813088fe1fbc01fb466"
|
|
||||||
x1="-.828%"
|
|
||||||
x2="57.636%"
|
|
||||||
y1="7.652%"
|
|
||||||
y2="78.411%"
|
|
||||||
><stop offset="0%" stop-color="#41D1FF"></stop><stop
|
|
||||||
offset="100%"
|
|
||||||
stop-color="#BD34FE"
|
|
||||||
></stop></linearGradient><linearGradient
|
|
||||||
id="IconifyId1813088fe1fbc01fb467"
|
|
||||||
x1="43.376%"
|
|
||||||
x2="50.316%"
|
|
||||||
y1="2.242%"
|
|
||||||
y2="89.03%"
|
|
||||||
><stop offset="0%" stop-color="#FFEA83"></stop><stop
|
|
||||||
offset="8.333%"
|
|
||||||
stop-color="#FFDD35"
|
|
||||||
></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path
|
|
||||||
fill="url(#IconifyId1813088fe1fbc01fb466)"
|
|
||||||
d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"
|
|
||||||
></path><path
|
|
||||||
fill="url(#IconifyId1813088fe1fbc01fb467)"
|
|
||||||
d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,9 +1,11 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
|
import path from 'path';
|
||||||
import comlink from 'vite-plugin-comlink';
|
import comlink from 'vite-plugin-comlink';
|
||||||
import glsl from 'vite-plugin-glsl';
|
import glsl from 'vite-plugin-glsl';
|
||||||
import wasm from 'vite-plugin-wasm';
|
import wasm from 'vite-plugin-wasm';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
@@ -18,7 +20,43 @@ export default defineConfig({
|
|||||||
comlink()
|
comlink()
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@nodarium/planty': path.resolve(__dirname, '../packages/planty/src/lib/index.ts')
|
||||||
|
}
|
||||||
|
},
|
||||||
ssr: {
|
ssr: {
|
||||||
noExternal: ['three']
|
noExternal: ['three']
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
chunkSizeWarningLimit: 2000
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
expect: { requireAssertions: true },
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
extends: './vite.config.ts',
|
||||||
|
test: {
|
||||||
|
name: 'client',
|
||||||
|
browser: {
|
||||||
|
enabled: true,
|
||||||
|
provider: playwright(),
|
||||||
|
instances: [{ browser: 'firefox', headless: true }]
|
||||||
|
},
|
||||||
|
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
||||||
|
exclude: ['src/lib/server/**']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
extends: './vite.config.ts',
|
||||||
|
test: {
|
||||||
|
name: 'server',
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||||
|
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
312
docs/LLM.md
Normal file
312
docs/LLM.md
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
# Nodarium - LLM Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Nodarium is a **WebAssembly-based visual programming language** for creating procedural 3D plants. The app features a node-based interface where users connect WASM modules to generate plant models in real-time. Currently used to develop https://nodes.max-richter.dev, a procedural modelling tool for 3D plants.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
#### 1. Node System (`app/static/nodes/`)
|
||||||
|
|
||||||
|
WASM-based nodes that perform computations. All nodes must implement the NodeDefinition interface.
|
||||||
|
|
||||||
|
- **Node Storage**: `app/static/nodes/max/plantarium/`
|
||||||
|
- `box.wasm` - Box geometry node
|
||||||
|
- `branch.wasm` - Branch generation
|
||||||
|
- `float.wasm` - Float value node
|
||||||
|
- `gravity.wasm` - Gravity/physics node
|
||||||
|
- `instance.wasm` - Instance rendering
|
||||||
|
- `leaf.wasm` - Leaf geometry
|
||||||
|
- `math.wasm` - Math operations
|
||||||
|
- `noise.wasm` - Noise generation
|
||||||
|
- `output.wasm` - Output node
|
||||||
|
- `random.wasm` - Random value generation
|
||||||
|
- `rotate.wasm` - Rotation node
|
||||||
|
- `shape.wasm` - Shape geometry
|
||||||
|
- `stem.wasm` - Stem generation
|
||||||
|
- `triangle.wasm` - Triangle geometry
|
||||||
|
- `vec3.wasm` - Vector3 node
|
||||||
|
|
||||||
|
- **Node Registry**: `app/src/lib/node-registry.ts`
|
||||||
|
- Loads and manages WASM nodes
|
||||||
|
- `getNodeWasm()` - Creates WASM wrapper from bytes
|
||||||
|
- `getNode()` - Retrieves node definition
|
||||||
|
|
||||||
|
- **Debug Node**: `app/src/lib/node-registry/debugNode.js`
|
||||||
|
- Special debug node with wildcard inputs
|
||||||
|
- Variable-height nodes and parameters
|
||||||
|
- Quick-connect shortcut
|
||||||
|
|
||||||
|
#### 2. Graph Interface
|
||||||
|
|
||||||
|
Visual node editor built with Svelte 5.
|
||||||
|
|
||||||
|
- **Main Wrapper**: `app/src/lib/graph-interface/graph/Wrapper.svelte`
|
||||||
|
- Entry point for graph interface
|
||||||
|
- Manages GraphManager and GraphState
|
||||||
|
|
||||||
|
- **GraphManager**: `app/src/lib/graph-interface/graph-manager.svelte.ts`
|
||||||
|
- Core entity managing the node graph
|
||||||
|
- Handles node connections and execution flow
|
||||||
|
|
||||||
|
- **GraphState**: `app/src/lib/graph-interface/graph-state.svelte.ts`
|
||||||
|
- Tracks UI state (selection, snapping, help, active nodes)
|
||||||
|
|
||||||
|
- **Graph Components**:
|
||||||
|
- `app/src/lib/graph-interface/graph/` - Graph rendering
|
||||||
|
- `app/src/lib/graph-interface/node/` - Node rendering
|
||||||
|
- `app/src/lib/graph-interface/edges/` - Edge rendering
|
||||||
|
- `app/src/lib/graph-interface/components/` - UI components (AddMenu, Socket, etc.)
|
||||||
|
- `app/src/lib/graph-interface/debug/` - Debug overlays
|
||||||
|
- `app/src/lib/graph-interface/background/` - Grid/dots backgrounds
|
||||||
|
|
||||||
|
- **Helpers**:
|
||||||
|
- `app/src/lib/helpers/` - Utility functions
|
||||||
|
- `app/src/lib/helpers/createKeyMap.ts` - Keyboard shortcuts
|
||||||
|
|
||||||
|
#### 3. Runtime Execution
|
||||||
|
|
||||||
|
Performs graph execution via WASM nodes.
|
||||||
|
|
||||||
|
- **Runtime Executors** (`app/src/lib/runtime/`):
|
||||||
|
- **MemoryRuntime**: Direct WASM execution in main thread
|
||||||
|
- **WorkerRuntime**: WebWorker-based execution for performance
|
||||||
|
- Both implement the RuntimeExecutor interface
|
||||||
|
|
||||||
|
- **Runtime Cache**: `app/src/lib/runtime/cache.ts`
|
||||||
|
- Memory-based caching for graph execution
|
||||||
|
|
||||||
|
- **Execution Flow**:
|
||||||
|
1. Graph serialized from graph interface
|
||||||
|
2. Runtime executes nodes in topological order
|
||||||
|
3. Results passed through connected edges
|
||||||
|
4. Final mesh output rendered
|
||||||
|
|
||||||
|
#### 4. 3D Viewer (`app/src/lib/result-viewer/`)
|
||||||
|
|
||||||
|
Three.js-based rendering for 3D output.
|
||||||
|
|
||||||
|
- **Viewer**: `app/src/lib/result-viewer/Viewer.svelte`
|
||||||
|
- Renders generated 3D meshes
|
||||||
|
- Uses @threlte/core (Svelte-Three.js wrapper)
|
||||||
|
|
||||||
|
#### 5. Application Structure (`app/src/routes/`)
|
||||||
|
|
||||||
|
SvelteKit application routing.
|
||||||
|
|
||||||
|
- **Main Page**: `app/src/routes/+page.svelte`
|
||||||
|
- Combines GraphInterface + 3D Viewer
|
||||||
|
- Manages runtime selection (memory vs worker)
|
||||||
|
- Handles settings and performance tracking
|
||||||
|
|
||||||
|
- **Layout**: `app/src/routes/+layout.svelte`
|
||||||
|
- Application shell
|
||||||
|
|
||||||
|
- **Server**: `app/src/routes/+layout.server.ts`
|
||||||
|
- Loads git metadata and changelog
|
||||||
|
|
||||||
|
#### 6. Settings System (`app/src/lib/settings/`)
|
||||||
|
|
||||||
|
Application and graph settings.
|
||||||
|
|
||||||
|
- **App Settings**: `app/src/lib/settings/app-settings.svelte.ts`
|
||||||
|
- Debug mode, themes, node interface options
|
||||||
|
|
||||||
|
- **NestedSettings**: `app/src/lib/settings/NestedSettings.svelte`
|
||||||
|
- Recursive settings UI component
|
||||||
|
|
||||||
|
#### 7. Sidebar Panels (`app/src/lib/sidebar/`)
|
||||||
|
|
||||||
|
- `app/src/lib/sidebar/Sidebar.svelte` - Main sidebar
|
||||||
|
- `app/src/lib/sidebar/panels/` - Individual panels:
|
||||||
|
- `ActiveNodeSettings.svelte` - Selected node properties
|
||||||
|
- `BenchmarkPanel.svelte` - Performance benchmarking
|
||||||
|
- `Changelog.svelte` - Version history
|
||||||
|
- `ExportSettings.svelte` - Export options
|
||||||
|
- `GraphSource.svelte` - Graph JSON view
|
||||||
|
- `Keymap.svelte` - Keyboard shortcuts
|
||||||
|
|
||||||
|
#### 8. Project Management (`app/src/lib/project-manager/`)
|
||||||
|
|
||||||
|
- `app/src/lib/project-manager/project-manager.svelte` - Project save/load
|
||||||
|
- Uses IndexedDB for persistence
|
||||||
|
|
||||||
|
#### 9. Node Store (`app/src/lib/node-store/`)
|
||||||
|
|
||||||
|
- `app/src/lib/node-store/NodeStore.svelte`
|
||||||
|
- Remote node registry management
|
||||||
|
- IndexDBCache for offline storage
|
||||||
|
|
||||||
|
#### 10. Graph Templates (`app/src/lib/graph-templates/`)
|
||||||
|
|
||||||
|
Pre-built graph templates for testing:
|
||||||
|
|
||||||
|
- Grid, Tree, LottaFaces, LottaNodes, LottaNodesAndFaces
|
||||||
|
|
||||||
|
## Key Types (`app/src/lib/types.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface NodeDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
inputs: Socket[];
|
||||||
|
outputs: Socket[];
|
||||||
|
parameters: Parameter[];
|
||||||
|
execute: (inputs: any[], parameters: any[]) => any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Socket {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string; // datatype (e.g., "number", "vec3", "*")
|
||||||
|
defaultValue?: any;
|
||||||
|
optional?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Parameter {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
defaultValue: any;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
options?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Graph {
|
||||||
|
nodes: NodeInstance[];
|
||||||
|
edges: Edge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeInstance {
|
||||||
|
id: number;
|
||||||
|
nodeId: string;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
parameters: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Edge {
|
||||||
|
id: number;
|
||||||
|
fromNode: number;
|
||||||
|
fromSocket: string;
|
||||||
|
toNode: number;
|
||||||
|
toSocket: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js
|
||||||
|
- pnpm
|
||||||
|
- Rust
|
||||||
|
- wasm-pack
|
||||||
|
|
||||||
|
### Build Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pnpm i
|
||||||
|
|
||||||
|
# Build WASM nodes
|
||||||
|
pnpm build:nodes
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
cd app && pnpm dev
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
cd app && pnpm test
|
||||||
|
|
||||||
|
# Lint and typecheck
|
||||||
|
cd app && pnpm lint
|
||||||
|
cd app && pnpm check
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
cd app && pnpm format
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating New Nodes
|
||||||
|
|
||||||
|
See `docs/DEVELOPING_NODES.md` for detailed instructions on creating custom WASM nodes.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Current Features
|
||||||
|
|
||||||
|
- Visual node-based programming with real-time 3D preview
|
||||||
|
- WebAssembly nodes for high-performance computation
|
||||||
|
- Debug node with wildcard inputs and runtime integration
|
||||||
|
- Color-coded node sockets and edges (indicating data types)
|
||||||
|
- Variable-height nodes and parameters
|
||||||
|
- Edge dragging with valid socket highlighting
|
||||||
|
- InputNumber snapping to predefined values (Alt+click)
|
||||||
|
- Project save/load with IndexedDB
|
||||||
|
- Performance monitoring and benchmarking
|
||||||
|
- Changelog viewer
|
||||||
|
- Advanced mode settings
|
||||||
|
|
||||||
|
### UI Components
|
||||||
|
|
||||||
|
- **InputNumber**: Numeric input with arrow controls
|
||||||
|
- **InputColor**: Color picker
|
||||||
|
- **InputShape**: Shape selector with preview
|
||||||
|
- **InputSelect**: Dropdown with options
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
nodarium/
|
||||||
|
├── app/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── lib/
|
||||||
|
│ │ │ ├── config.ts
|
||||||
|
│ │ │ ├── graph-interface/ # Node editor
|
||||||
|
│ │ │ ├── graph-manager.svelte.ts
|
||||||
|
│ │ │ ├── graph-state.svelte.ts
|
||||||
|
│ │ │ ├── graph-templates/ # Test templates
|
||||||
|
│ │ │ ├── grid/
|
||||||
|
│ │ │ ├── helpers/
|
||||||
|
│ │ │ ├── node-registry.ts
|
||||||
|
│ │ │ ├── node-registry/ # Node loading
|
||||||
|
│ │ │ ├── node-store/
|
||||||
|
│ │ │ ├── performance/
|
||||||
|
│ │ │ ├── project-manager/
|
||||||
|
│ │ │ ├── result-viewer/ # 3D viewer
|
||||||
|
│ │ │ ├── runtime/ # Execution
|
||||||
|
│ │ │ ├── settings/ # App settings
|
||||||
|
│ │ │ ├── sidebar/
|
||||||
|
│ │ │ └── types.ts
|
||||||
|
│ │ └── routes/
|
||||||
|
│ │ ├── +page.svelte
|
||||||
|
│ │ └── +layout.svelte
|
||||||
|
│ ├── static/
|
||||||
|
│ │ └── nodes/
|
||||||
|
│ │ └── max/
|
||||||
|
│ │ └── plantarium/ # WASM nodes
|
||||||
|
│ └── package.json
|
||||||
|
├── docs/
|
||||||
|
│ ├── ARCHITECTURE.md
|
||||||
|
│ ├── DEVELOPING_NODES.md
|
||||||
|
│ ├── NODE_DEFINITION.md
|
||||||
|
│ └── PLANTARIUM.md
|
||||||
|
├── nodes/ # WASM node source (Rust)
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release Process
|
||||||
|
|
||||||
|
1. Create annotated tag:
|
||||||
|
```bash
|
||||||
|
git tag -a v1.0.0 -m "Release notes"
|
||||||
|
git push origin v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
2. CI workflow:
|
||||||
|
- Runs lint, format check, type check
|
||||||
|
- Builds project
|
||||||
|
- Updates package.json versions
|
||||||
|
- Generates CHANGELOG.md
|
||||||
|
- Creates Gitea release
|
||||||
@@ -28,6 +28,13 @@
|
|||||||
"value": 1,
|
"value": 1,
|
||||||
"hidden": true
|
"hidden": true
|
||||||
},
|
},
|
||||||
|
"rotation": {
|
||||||
|
"type": "float",
|
||||||
|
"min": 0,
|
||||||
|
"max": 1,
|
||||||
|
"value": 0.5,
|
||||||
|
"hidden": true
|
||||||
|
},
|
||||||
"depth": {
|
"depth": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"min": 1,
|
"min": 1,
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
use glam::{Mat4, Quat, Vec3};
|
use glam::{Mat4, Quat, Vec3};
|
||||||
use nodarium_macros::nodarium_execute;
|
use nodarium_macros::{nodarium_execute, nodarium_definition_file};
|
||||||
use nodarium_macros::nodarium_definition_file;
|
|
||||||
use nodarium_utils::{
|
use nodarium_utils::{
|
||||||
concat_args, evaluate_float, evaluate_int,
|
concat_args, evaluate_float, evaluate_int,
|
||||||
geometry::{
|
geometry::{create_instance_data, wrap_geometry_data, wrap_instance_data, wrap_path},
|
||||||
create_instance_data, wrap_geometry_data, wrap_instance_data, wrap_path,
|
split_args,
|
||||||
},
|
|
||||||
log, split_args,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
nodarium_definition_file!("src/input.json");
|
nodarium_definition_file!("src/input.json");
|
||||||
@@ -15,13 +12,13 @@ nodarium_definition_file!("src/input.json");
|
|||||||
pub fn execute(input: &[i32]) -> Vec<i32> {
|
pub fn execute(input: &[i32]) -> Vec<i32> {
|
||||||
let args = split_args(input);
|
let args = split_args(input);
|
||||||
let mut inputs = split_args(args[0]);
|
let mut inputs = split_args(args[0]);
|
||||||
log!("WASM(instance): inputs: {:?}", inputs);
|
|
||||||
|
|
||||||
let mut geo_data = args[1].to_vec();
|
let mut geo_data = args[1].to_vec();
|
||||||
let geo = wrap_geometry_data(&mut geo_data);
|
let geo = wrap_geometry_data(&mut geo_data);
|
||||||
|
|
||||||
let mut transforms: Vec<Mat4> = Vec::new();
|
let mut transforms: Vec<Mat4> = Vec::new();
|
||||||
|
|
||||||
|
// Find max depth
|
||||||
let mut max_depth = 0;
|
let mut max_depth = 0;
|
||||||
for path_data in inputs.iter() {
|
for path_data in inputs.iter() {
|
||||||
if path_data[2] != 0 {
|
if path_data[2] != 0 {
|
||||||
@@ -30,7 +27,8 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
|
|||||||
max_depth = max_depth.max(path_data[3]);
|
max_depth = max_depth.max(path_data[3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let depth = evaluate_int(args[5]);
|
let rotation = evaluate_float(args[5]);
|
||||||
|
let depth = evaluate_int(args[6]);
|
||||||
|
|
||||||
for path_data in inputs.iter() {
|
for path_data in inputs.iter() {
|
||||||
if path_data[3] < (max_depth - depth + 1) {
|
if path_data[3] < (max_depth - depth + 1) {
|
||||||
@@ -38,24 +36,34 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let amount = evaluate_int(args[2]);
|
let amount = evaluate_int(args[2]);
|
||||||
|
|
||||||
let lowest_instance = evaluate_float(args[3]);
|
let lowest_instance = evaluate_float(args[3]);
|
||||||
let highest_instance = evaluate_float(args[4]);
|
let highest_instance = evaluate_float(args[4]);
|
||||||
|
|
||||||
let path = wrap_path(path_data);
|
let path = wrap_path(path_data);
|
||||||
|
|
||||||
for i in 0..amount {
|
for i in 0..amount {
|
||||||
let alpha =
|
let alpha = lowest_instance
|
||||||
lowest_instance + (i as f32 / amount as f32) * (highest_instance - lowest_instance);
|
+ (i as f32 / (amount - 1) as f32) * (highest_instance - lowest_instance);
|
||||||
|
|
||||||
let point = path.get_point_at(alpha);
|
let point = path.get_point_at(alpha);
|
||||||
let direction = path.get_direction_at(alpha);
|
let tangent = path.get_direction_at(alpha);
|
||||||
|
let size = point[3] + 0.01;
|
||||||
|
|
||||||
|
let axis_rotation = Quat::from_axis_angle(
|
||||||
|
Vec3::from_slice(&tangent).normalize(),
|
||||||
|
i as f32 * rotation,
|
||||||
|
);
|
||||||
|
|
||||||
|
let path_rotation = Quat::from_rotation_arc(Vec3::Y, Vec3::from_slice(&tangent).normalize());
|
||||||
|
|
||||||
|
let rotation = path_rotation * axis_rotation;
|
||||||
|
|
||||||
let transform = Mat4::from_scale_rotation_translation(
|
let transform = Mat4::from_scale_rotation_translation(
|
||||||
Vec3::new(point[3], point[3], point[3]),
|
Vec3::new(size, size, size),
|
||||||
Quat::from_xyzw(direction[0], direction[1], direction[2], 1.0).normalize(),
|
rotation,
|
||||||
Vec3::from_slice(&point),
|
Vec3::from_slice(&point),
|
||||||
);
|
);
|
||||||
|
|
||||||
transforms.push(transform);
|
transforms.push(transform);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,11 +75,11 @@ pub fn execute(input: &[i32]) -> Vec<i32> {
|
|||||||
);
|
);
|
||||||
let mut instances = wrap_instance_data(&mut instance_data);
|
let mut instances = wrap_instance_data(&mut instance_data);
|
||||||
instances.set_geometry(geo);
|
instances.set_geometry(geo);
|
||||||
(0..transforms.len()).for_each(|i| {
|
|
||||||
instances.set_transformation_matrix(i, &transforms[i].to_cols_array());
|
|
||||||
});
|
|
||||||
|
|
||||||
log!("WASM(instance): geo: {:?}", instance_data);
|
for (i, transform) in transforms.iter().enumerate() {
|
||||||
|
instances.set_transformation_matrix(i, &transform.to_cols_array());
|
||||||
|
}
|
||||||
|
|
||||||
inputs.push(&instance_data);
|
inputs.push(&instance_data);
|
||||||
|
|
||||||
concat_args(inputs)
|
concat_args(inputs)
|
||||||
|
|||||||
6
nodes/max/plantarium/leaf/.gitignore
vendored
Normal file
6
nodes/max/plantarium/leaf/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/target
|
||||||
|
**/*.rs.bk
|
||||||
|
Cargo.lock
|
||||||
|
bin/
|
||||||
|
pkg/
|
||||||
|
wasm-pack.log
|
||||||
12
nodes/max/plantarium/leaf/Cargo.toml
Normal file
12
nodes/max/plantarium/leaf/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "leaf"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["Max Richter <jim-x@web.de>"]
|
||||||
|
edition = "2018"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nodarium_macros = { version = "0.1.0", path = "../../../../packages/macros" }
|
||||||
|
nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" }
|
||||||
24
nodes/max/plantarium/leaf/src/input.json
Normal file
24
nodes/max/plantarium/leaf/src/input.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"id": "max/plantarium/leaf",
|
||||||
|
"outputs": [
|
||||||
|
"geometry"
|
||||||
|
],
|
||||||
|
"inputs": {
|
||||||
|
"shape": {
|
||||||
|
"type": "shape",
|
||||||
|
"external": true
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"type": "float",
|
||||||
|
"value": 1
|
||||||
|
},
|
||||||
|
"xResolution": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "The amount of stems to produce",
|
||||||
|
"min": 1,
|
||||||
|
"max": 64,
|
||||||
|
"value": 1,
|
||||||
|
"hidden": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
166
nodes/max/plantarium/leaf/src/lib.rs
Normal file
166
nodes/max/plantarium/leaf/src/lib.rs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
use std::convert::TryInto;
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
use nodarium_macros::nodarium_definition_file;
|
||||||
|
use nodarium_macros::nodarium_execute;
|
||||||
|
use nodarium_utils::encode_float;
|
||||||
|
use nodarium_utils::evaluate_float;
|
||||||
|
use nodarium_utils::evaluate_int;
|
||||||
|
use nodarium_utils::log;
|
||||||
|
use nodarium_utils::wrap_arg;
|
||||||
|
use nodarium_utils::{split_args, decode_float};
|
||||||
|
|
||||||
|
nodarium_definition_file!("src/input.json");
|
||||||
|
|
||||||
|
fn calculate_y(x: f32) -> f32 {
|
||||||
|
let term1 = (x * PI * 2.0).sin().abs();
|
||||||
|
let term2 = (x * 2.0 * PI + (PI / 2.0)).sin() / 2.0;
|
||||||
|
term1 + term2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper vector math functions
|
||||||
|
fn vec_sub(a: &[f32; 3], b: &[f32; 3]) -> [f32; 3] {
|
||||||
|
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vec_cross(a: &[f32; 3], b: &[f32; 3]) -> [f32; 3] {
|
||||||
|
[
|
||||||
|
a[1] * b[2] - a[2] * b[1],
|
||||||
|
a[2] * b[0] - a[0] * b[2],
|
||||||
|
a[0] * b[1] - a[1] * b[0],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vec_normalize(v: &[f32; 3]) -> [f32; 3] {
|
||||||
|
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||||
|
if len == 0.0 { [0.0, 0.0, 0.0] } else { [v[0]/len, v[1]/len, v[2]/len] }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[nodarium_execute]
|
||||||
|
pub fn execute(input: &[i32]) -> Vec<i32> {
|
||||||
|
let args = split_args(input);
|
||||||
|
let input_path = split_args(args[0])[0];
|
||||||
|
let size = evaluate_float(args[1]);
|
||||||
|
let width_resolution = evaluate_int(args[2]).max(3) as usize;
|
||||||
|
let path_length = (input_path.len() - 4) / 2;
|
||||||
|
|
||||||
|
let slice_count = path_length;
|
||||||
|
let face_amount = (slice_count - 1) * (width_resolution - 1) * 2;
|
||||||
|
let position_amount = slice_count * width_resolution;
|
||||||
|
|
||||||
|
let out_length =
|
||||||
|
3 // metadata
|
||||||
|
+ face_amount * 3 // indices
|
||||||
|
+ position_amount * 3 // positions
|
||||||
|
+ position_amount * 3; // normals
|
||||||
|
|
||||||
|
let mut out = vec![0 as i32; out_length];
|
||||||
|
|
||||||
|
log!("face_amount={:?} position_amount={:?}", face_amount, position_amount);
|
||||||
|
|
||||||
|
out[0] = 1;
|
||||||
|
out[1] = position_amount.try_into().unwrap();
|
||||||
|
out[2] = face_amount.try_into().unwrap();
|
||||||
|
let mut offset = 3;
|
||||||
|
|
||||||
|
// Writing Indices
|
||||||
|
let mut idx = 0;
|
||||||
|
for i in 0..(slice_count - 1) {
|
||||||
|
let base0 = (i * width_resolution) as i32;
|
||||||
|
let base1 = ((i + 1) * width_resolution) as i32;
|
||||||
|
|
||||||
|
for j in 0..(width_resolution - 1) {
|
||||||
|
let a = base0 + j as i32;
|
||||||
|
let b = base0 + j as i32 + 1;
|
||||||
|
let c = base1 + j as i32;
|
||||||
|
let d = base1 + j as i32 + 1;
|
||||||
|
|
||||||
|
// triangle 1
|
||||||
|
out[offset + idx + 0] = a;
|
||||||
|
out[offset + idx + 1] = b;
|
||||||
|
out[offset + idx + 2] = c;
|
||||||
|
|
||||||
|
// triangle 2
|
||||||
|
out[offset + idx + 3] = b;
|
||||||
|
out[offset + idx + 4] = d;
|
||||||
|
out[offset + idx + 5] = c;
|
||||||
|
|
||||||
|
idx += 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += face_amount * 3;
|
||||||
|
|
||||||
|
// Writing Positions
|
||||||
|
let width = 50.0;
|
||||||
|
let mut positions = vec![[0.0f32; 3]; position_amount];
|
||||||
|
for i in 0..slice_count {
|
||||||
|
let ax = i as f32 / (slice_count -1) as f32;
|
||||||
|
|
||||||
|
let px = decode_float(input_path[2 + i * 2 + 0]);
|
||||||
|
let pz = decode_float(input_path[2 + i * 2 + 1]);
|
||||||
|
|
||||||
|
|
||||||
|
for j in 0..width_resolution {
|
||||||
|
let alpha = j as f32 / (width_resolution - 1) as f32;
|
||||||
|
let x = 2.0 * (-px * (alpha - 0.5) + alpha * width);
|
||||||
|
let py = calculate_y(alpha-0.5)*5.0*(ax*PI).sin();
|
||||||
|
let pz_val = pz - 100.0;
|
||||||
|
|
||||||
|
let pos_idx = i * width_resolution + j;
|
||||||
|
positions[pos_idx] = [x - width, py, pz_val];
|
||||||
|
|
||||||
|
let flat_idx = offset + pos_idx * 3;
|
||||||
|
out[flat_idx + 0] = encode_float((x - width) * size);
|
||||||
|
out[flat_idx + 1] = encode_float(py * size);
|
||||||
|
out[flat_idx + 2] = encode_float(pz_val * size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writing Normals
|
||||||
|
offset += position_amount * 3;
|
||||||
|
let mut normals = vec![[0.0f32; 3]; position_amount];
|
||||||
|
|
||||||
|
for i in 0..(slice_count - 1) {
|
||||||
|
for j in 0..(width_resolution - 1) {
|
||||||
|
let a = i * width_resolution + j;
|
||||||
|
let b = i * width_resolution + j + 1;
|
||||||
|
let c = (i + 1) * width_resolution + j;
|
||||||
|
let d = (i + 1) * width_resolution + j + 1;
|
||||||
|
|
||||||
|
// triangle 1: a,b,c
|
||||||
|
let u = vec_sub(&positions[b], &positions[a]);
|
||||||
|
let v = vec_sub(&positions[c], &positions[a]);
|
||||||
|
let n1 = vec_cross(&u, &v);
|
||||||
|
|
||||||
|
// triangle 2: b,d,c
|
||||||
|
let u2 = vec_sub(&positions[d], &positions[b]);
|
||||||
|
let v2 = vec_sub(&positions[c], &positions[b]);
|
||||||
|
let n2 = vec_cross(&u2, &v2);
|
||||||
|
|
||||||
|
for &idx in &[a, b, c] {
|
||||||
|
normals[idx][0] += n1[0];
|
||||||
|
normals[idx][1] += n1[1];
|
||||||
|
normals[idx][2] += n1[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
for &idx in &[b, d, c] {
|
||||||
|
normals[idx][0] += n2[0];
|
||||||
|
normals[idx][1] += n2[1];
|
||||||
|
normals[idx][2] += n2[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalize and write to output
|
||||||
|
for i in 0..position_amount {
|
||||||
|
let n = vec_normalize(&normals[i]);
|
||||||
|
let flat_idx = offset + i * 3;
|
||||||
|
out[flat_idx + 0] = encode_float(n[0]);
|
||||||
|
out[flat_idx + 1] = encode_float(n[1]);
|
||||||
|
out[flat_idx + 2] = encode_float(n[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap_arg(&out)
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user