diff --git a/CHANGELOG.md b/CHANGELOG.md
index ce6b14fc1..5551b04f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,29 @@
per release, newest first. HTML comments like this one are stripped before
rendering, so maintainer notes stay out of the user-facing window. -->
+## 1.51.0 — characters and motion 🕺
+
+- **Clips are their own files.** A `.tpanim` is a small clip file in the Explorer (VRM Animation inside, so it opens in
+ VRM tools too). Import `.vrma`, `.bvh`, animation `.glb` or `.fbx`, then drop a clip on a character: it plays on every
+ peer, the bytes travel once and the model never changes.
+- **Any clip on any humanoid.** VRM, Mixamo, Meshy, UniRig, KayKit and Quaternius rigs map onto the VRM 1.0 humanoid by
+ themselves (fix the map by hand if you like). *Apply clip…* fits a clip to each character's skeleton.
+- **Free CC0 packs.** Characters (10 avatars) and Motion (87 clips: locomotion, jumps, sitting, swimming, combat…),
+ in the Asset Browser's new Motion tab, plus an Avatars tab reading the Open Source Avatars registry.
+- **VRM characters** with spring bones, expressions (a Set Expression node too) and their licence in the Inspector.
+- **Game-ready clips.** In place or moving, *Loop it*, trim, split, join with a crossfade, *Extract clips* from a model;
+ a Crossfade on Play Animation and a new **Blend by Speed** node, so characters stop snapping between poses.
+- **Animation states.** A window per character with states and transitions. The one-click **Locomotion preset** makes
+ even a fresh VRM walk, run and jump under the Player controller, filling missing clips from the Motion pack. New
+ nodes: Set Animation Parameter, On Animation State.
+- **Skeleton fix-ups.** Rename, move, add, delete and mirror bones; automatic weights, smoothing and a weight brush.
+- **Download a character with its clips.** One GLB for Blender, Unity, Unreal and Godot (default), model + clip files,
+ or the model alone; one clip as `.tpanim`, `.glb`, `.vrma` or `.bvh`.
+- **Animate a model's named parts** and attach things to its sockets (Attach to Socket node); agents get the
+ `animate_parts` tool.
+- **Licence warnings.** Everything records where it came from. Licences that ask for credit or restrict sharing warn you
+ in Download, Export and Publish, and never block.
+
## 1.50.0 — talk to your agent 💬
- **Chat with the AI agents in your session.** The AI assistant window lists them next to your own assistant: pick
diff --git a/package-lock.json b/package-lock.json
index ee0a343b2..ff9a87b70 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"@gltf-transform/functions": "^4.5.1",
"@jitl/quickjs-wasmfile-release-sync": "0.32.0",
"@lucide/svelte": "^1.61.0",
+ "@pixiv/three-vrm": "3.5.5",
"@shaderfrog/core": "^4.0.2",
"@shaderfrog/glsl-parser": "^7.0.1",
"@threlte/core": "^8.6.1",
@@ -42,6 +43,7 @@
"devDependencies": {
"@axe-core/playwright": "4.14.0",
"@iwer/devui": "2.7.0",
+ "@pixiv/three-vrm-animation": "^3.5.5",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
@@ -956,6 +958,167 @@
"url": "https://github.com/sponsors/oxc-project"
}
},
+ "node_modules/@pixiv/three-vrm": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm/-/three-vrm-3.5.5.tgz",
+ "integrity": "sha512-RPXy7jYAXs704NIpZlosB0U2ENu21G9DrqGWdQgRe8dShaCo1ugpj+6BVPRCy91nt+MPMA96j5rbsSzEl0HlQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/three-vrm-core": "3.5.5",
+ "@pixiv/three-vrm-materials-hdr-emissive-multiplier": "3.5.5",
+ "@pixiv/three-vrm-materials-mtoon": "3.5.5",
+ "@pixiv/three-vrm-materials-v0compat": "3.5.5",
+ "@pixiv/three-vrm-node-constraint": "3.5.5",
+ "@pixiv/three-vrm-springbone": "3.5.5"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-animation": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-animation/-/three-vrm-animation-3.5.5.tgz",
+ "integrity": "sha512-Q0kEtmftxfv0iCUHQkJH2jAr1zmT9ywYXnAzhxnLPMYCsx0Kken3J3U2GJF9PdGZmLlKpoK0LFEXVdD/L7ZA4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/three-vrm-core": "3.5.5",
+ "@pixiv/types-vrmc-vrm-1.0": "3.5.5",
+ "@pixiv/types-vrmc-vrm-animation-1.0": "3.5.5"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-core": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-core/-/three-vrm-core-3.5.5.tgz",
+ "integrity": "sha512-jjaXZbMVRsk2HAGVxqDLmSJyGOJqbMcJpuTHR1TY/TN1FxiKdAzSqdr/ZUcczYipNf8IL9vDT9NX2XmWj+ybRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrm-0.0": "3.5.5",
+ "@pixiv/types-vrmc-vrm-1.0": "3.5.5"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-materials-hdr-emissive-multiplier": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-hdr-emissive-multiplier/-/three-vrm-materials-hdr-emissive-multiplier-3.5.5.tgz",
+ "integrity": "sha512-XnHEqpY4ctYMucpeRcptdphOe0RrW7V5fUvojasD8Ja4MTjjrjCNFxsAvp3+HBkPZbkTM2sDCjJBJNzxN1scrA==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": "3.5.5"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-materials-mtoon": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-mtoon/-/three-vrm-materials-mtoon-3.5.5.tgz",
+ "integrity": "sha512-7lThHf4OKBOsVenD+R6L6HffNUlKO3Twnhr3+z77MnUsSzsOanjVKJtjSIly9QJ6hgl8YJmI6RnxCKqXcpplog==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrm-0.0": "3.5.5",
+ "@pixiv/types-vrmc-materials-mtoon-1.0": "3.5.5"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-materials-v0compat": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-v0compat/-/three-vrm-materials-v0compat-3.5.5.tgz",
+ "integrity": "sha512-NjsTK4P4qdcr+RoL2gEUNgF2CY/aplNvyHHxI99N7D6CxDZ2H93FQ9aIuLCABj5b+4CIvldWMCcnS7YuLIr3Lw==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrm-0.0": "3.5.5",
+ "@pixiv/types-vrmc-materials-mtoon-1.0": "3.5.5"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-node-constraint": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-node-constraint/-/three-vrm-node-constraint-3.5.5.tgz",
+ "integrity": "sha512-+ShEXioZ5Ctf0EZ5ZViZ4n0jhV/qtJRH0Juw8nRnMNODZXDSr+B3An2lrfRoMhowCzG63GiUG4lXWV9noMXosA==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrmc-node-constraint-1.0": "3.5.5"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/three-vrm-springbone": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-springbone/-/three-vrm-springbone-3.5.5.tgz",
+ "integrity": "sha512-GWAWrIegGH/u1ZA+Es03Qzc2yafrQOrhtal9jzUIAgU8Wm2DYuqAABOY+du/9RpBRK8D4T8DBPvRGQqvfrQ/uA==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrm-0.0": "3.5.5",
+ "@pixiv/types-vrmc-springbone-1.0": "3.5.5",
+ "@pixiv/types-vrmc-springbone-extended-collider-1.0": "3.5.5"
+ },
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/@pixiv/types-vrm-0.0": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrm-0.0/-/types-vrm-0.0-3.5.5.tgz",
+ "integrity": "sha512-2S/0jswMjJTwL9ky8YzDR0CIaP6ZmE+HFuyW/0h+SA+awELf+J7WGgC8lTLM1MXzokO7Z2YVljU2F0fEhlKDIQ==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0/-/types-vrmc-materials-hdr-emissive-multiplier-1.0-3.5.5.tgz",
+ "integrity": "sha512-IuHw9ehXt2bymqfdFV+f2cCXo8kvJUfh0Zuut8qmKXoEb7HVyLGIQyS4TcQHG5yY22UTWHI1Axo5tQv35lPTgQ==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-materials-mtoon-1.0": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-materials-mtoon-1.0/-/types-vrmc-materials-mtoon-1.0-3.5.5.tgz",
+ "integrity": "sha512-GFVuksAJVhrWTXYmI/ucNsE611/d8N6BoZrzhUAAINndt98loyzapW2+DJu+GsUBO5yxd+vLsL0FB16XD9+TGg==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-node-constraint-1.0": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-node-constraint-1.0/-/types-vrmc-node-constraint-1.0-3.5.5.tgz",
+ "integrity": "sha512-8EbJ1uzkn9yJQarCChmpG12+mc2c7PimHvQ8H6/tMh/hqp5NtJND0z2lR2KeVxw9A3M+TpF+xgImhLBxGjqwzQ==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-springbone-1.0": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-springbone-1.0/-/types-vrmc-springbone-1.0-3.5.5.tgz",
+ "integrity": "sha512-gRQ5Y7PaGiMefvOrJ3hvoSPqpA67jiwyhwh8ItjsPi3yri/Hn3CeXplW3Cbx3ZPbfu+C3Q2MCRa2TqAUOjeMRA==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-springbone-extended-collider-1.0": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-springbone-extended-collider-1.0/-/types-vrmc-springbone-extended-collider-1.0-3.5.5.tgz",
+ "integrity": "sha512-+6RxLaAvVguP7upjS/OT4viNPWvac1HBRQY1nmdvfYNQuCMdrMsCZqvy2af0Fcjnda4V9JbKFIpZDC0lrw1+uQ==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-vrm-1.0": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-vrm-1.0/-/types-vrmc-vrm-1.0-3.5.5.tgz",
+ "integrity": "sha512-WnemHHo2375BcBdumEhvrupZzsMSzTfdWlokmV9sUX2oC+OAQqJFfBjLF8ppkOhzHpl4MAPJqEfuhBJtNPm9ww==",
+ "license": "MIT"
+ },
+ "node_modules/@pixiv/types-vrmc-vrm-animation-1.0": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-vrm-animation-1.0/-/types-vrmc-vrm-animation-1.0-3.5.5.tgz",
+ "integrity": "sha512-pyow6wBLdRZbLQbtHqu8uvYG+NXJbwIiLVK6pM6UvtaYotecOv43t6qIdMLYuieV+F+bEJcdfOLFZZVolrOOcQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@pixiv/types-vrmc-vrm-1.0": "3.5.5"
+ }
+ },
"node_modules/@pmndrs/handle": {
"version": "6.6.32",
"resolved": "https://registry.npmjs.org/@pmndrs/handle/-/handle-6.6.32.tgz",
diff --git a/package.json b/package.json
index f39e70ff7..6d6eb26cd 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
"devDependencies": {
"@axe-core/playwright": "4.14.0",
"@iwer/devui": "2.7.0",
+ "@pixiv/three-vrm-animation": "^3.5.5",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
@@ -58,6 +59,7 @@
"@gltf-transform/functions": "^4.5.1",
"@jitl/quickjs-wasmfile-release-sync": "0.32.0",
"@lucide/svelte": "^1.61.0",
+ "@pixiv/three-vrm": "3.5.5",
"@shaderfrog/core": "^4.0.2",
"@shaderfrog/glsl-parser": "^7.0.1",
"@threlte/core": "^8.6.1",
diff --git a/src/App.svelte b/src/App.svelte
index 4f4e9fb4b..6cb26de69 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -32,6 +32,8 @@
// panel, so it sits with ConfirmModal where every entry point can reach it.
import StorageModal from './components/menu/StorageModal.svelte'
import ReplaceModelModal from './components/menu/ReplaceModelModal.svelte'
+ // 57: Apply clip… / Download… / the Skeleton view — a tiny host, the dialogs load on open
+ import CharacterDialogs from './components/characters/CharacterDialogs.svelte'
import PlaceholderTooltip from './components/menu/PlaceholderTooltip.svelte'
import Flow from './components/Flow.svelte'
import FlowCode from './components/editors/FlowCode.svelte'
@@ -405,11 +407,15 @@ import { startMusicToolbox } from './lib/musicToolbox'
const files = [...(event.dataTransfer?.files ?? [])]
if (files.length === 0) return
const skipped = []
- const modelExt = /\.(glb|gltf|obj|stl|fbx)$/
+ const modelExt = /\.(glb|gltf|obj|stl|fbx|vrm)$/
// 17-D2: a .mtl / texture dropped ALONGSIDE a model is its companion, not
// an unsupported file — importFile pairs them up.
const companionExt = /\.(mtl|png|jpe?g|webp|bmp|gif)$/i
const companions = files.filter((file) => companionExt.test(file.name))
+ // 57 M1: animation clip files go to the Explorer (converted to .tpanim) and onto the rig under the cursor
+ const clipExt = /\.(tpanim|vrma|bvh)$/i
+ const clipFiles = files.filter((file) => clipExt.test(file.name))
+ if (clipFiles.length) import('./lib/explorerDrop').then((m) => m.dropClipFiles(clipFiles, event.clientX, event.clientY))
// 60 Q5: a drop of nothing but images is not a model's companions — they used to be
// dropped silently. Ask what they are for: a reference image to build over, or the Explorer.
const imageExt = /\.(png|jpe?g|webp|bmp|gif)$/i
@@ -419,6 +425,7 @@ import { startMusicToolbox } from './lib/musicToolbox'
}
files.forEach((file) => {
const name = file.name.toLowerCase()
+ if (clipFiles.includes(file)) return
if (modelExt.test(name)) importFile(file, file.name.replace(modelExt, ''), undefined, undefined, companions)
else if (name.endsWith('.json')) load(file)
else if (!companions.includes(file)) skipped.push(file.name)
@@ -479,6 +486,7 @@ import { startMusicToolbox } from './lib/musicToolbox'
+
diff --git a/src/components/characters/ApplyClipDialog.svelte b/src/components/characters/ApplyClipDialog.svelte
new file mode 100644
index 000000000..1a595f063
--- /dev/null
+++ b/src/components/characters/ApplyClipDialog.svelte
@@ -0,0 +1,184 @@
+
+
+ {
+ if (e.key === 'Escape') close();
+ }}
+ oncancel={close}
+ outsideclose
+ width="sm"
+ id="apply-clip-dialog"
+>
+
+
+ Plays the clip on each character you tick: it is fitted to that character's own skeleton on
+ every peer. The models themselves never change.
+
+
Clip
+ {#if items.length}
+
+ {:else}
+
+ No clips yet — import an animated model or a clip file.
+
+ {/if}
+
Characters
+
+ {#if !targets.length}
+
+ No rigged characters in the scene. Import a .vrm, .glb or .fbx with a skeleton.
+
+ {/if}
+ {#each targets as t (t.uuid)}
+
+
+ (picked = { ...picked, [t.uuid]: v })}
+ />
+ {t.name}
+
+
+ {rigLabel(t.rig)}{t.ok ? '' : ' · not mapped'}
+
+
+ {/each}
+
+
+ {#snippet footer()}
+
+ Cancel
+
+ {count > 1 ? 'Apply to ' + count : 'Apply'}
+
+
+ {/snippet}
+
+
+
diff --git a/src/components/characters/CharacterDialogs.svelte b/src/components/characters/CharacterDialogs.svelte
new file mode 100644
index 000000000..f469078db
--- /dev/null
+++ b/src/components/characters/CharacterDialogs.svelte
@@ -0,0 +1,21 @@
+
+
+{#if $applyClipRequest}
+ {#await import('./ApplyClipDialog.svelte') then m}
+
+ {/await}
+{/if}
+{#if $downloadRequest}
+ {#await import('./DownloadDialog.svelte') then m}
+
+ {/await}
+{/if}
+{#if $skeletonTarget}
+ {#await import('./SkeletonWindow.svelte') then m}
+
+ {/await}
+{/if}
diff --git a/src/components/characters/DownloadDialog.svelte b/src/components/characters/DownloadDialog.svelte
new file mode 100644
index 000000000..ab51ee1e6
--- /dev/null
+++ b/src/components/characters/DownloadDialog.svelte
@@ -0,0 +1,253 @@
+
+
+ {
+ if (e.key === 'Escape') close();
+ }}
+ oncancel={close}
+ outsideclose
+ width="sm"
+ id="download-dialog"
+>
+
+
+ {#if single}
+
+ {/if}
+ {#if !single || mode === 'model'}
+
Layout
+
+ {#if layout !== 'c'}
+
Clips
+
+ {#each plans as p (p.uuid)}
+ {#if plans.length > 1}{p.name} {/if}
+ {#if !p.clips.length}No clips — apply some with Character ▸ Apply clip… {/if}
+ {#each p.clips as c (c.name)}
+
+
+ (ticked = { ...ticked, [key(p.uuid, c.name)]: v })}
+ />
+ {c.name}
+ {c.applied ? 'applied · baked onto this skeleton' : 'its own'} · {c.duration.toFixed(
+ 2
+ )}s
+
+ {/each}
+ {/each}
+
+ {/if}
+ {:else}
+
Clip
+
+ {#each single.clips as c (c.name)}{c.name} {/each}
+
+
Format
+
+ {#each CLIP_FORMATS as f (f.id)}
+ {@const off = f.needs === 'humanoid' && !single.humanoid}
+
+
+ {f.label}
+
+ {/each}
+ {#each CONVERT_FORMATS as f (f.id)}
+
+
+ {f.label} — needs an AI box with the convert capability; use .glb or .bvh meanwhile
+
+ {/each}
+
+ {/if}
+ {#if single && mode === 'model' && !single.glb}
+
+ This model was a {single.kind.toUpperCase()} file — it downloads as glTF (materials may look simpler).
+
+ {/if}
+
+ {#snippet footer()}
+
+ Cancel
+ Download
+
+ {/snippet}
+
+
+
diff --git a/src/components/characters/SkeletonWindow.svelte b/src/components/characters/SkeletonWindow.svelte
new file mode 100644
index 000000000..24ed20785
--- /dev/null
+++ b/src/components/characters/SkeletonWindow.svelte
@@ -0,0 +1,517 @@
+
+
+ {
+ if (e.key === 'Escape') close();
+ }}
+ oncancel={close}
+ outsideclose={false}
+ width="sm"
+ frame={false}
+ class="sk-floating"
+ id="skeleton-window"
+>
+ {#snippet bar()}
+ (tab = id)}
+ />
+ {/snippet}
+ {#if info}
+
+ {#if tab === 'map'}
+
+ {human?.ok
+ ? 'A full humanoid'
+ : 'Missing: ' + (human?.missing ?? REQUIRED_BONES).map(boneLabel).join(', ')} — {Object.keys(
+ human?.map ?? {}
+ ).length} of 55 bones mapped ({human?.via === 'file'
+ ? 'from the file'
+ : human?.via === 'structure'
+ ? 'from its shape — check it'
+ : 'from bone names'}). A fix here changes no file.
+
+
+ {#each rows as b (b.name)}
+ {@const cur = human?.map[b.name] ?? ''}
+ {@const fixed = human ? b.name in human.overrides : false}
+
+ {boneLabel(b.name)}{b.required ? ' *' : ''}
+ pickMap(b.name, e.currentTarget.value)}
+ aria-label={'Rig bone for ' + boneLabel(b.name)}
+ >
+ {human?.detected[b.name]
+ ? 'detected: ' + human.detected[b.name]
+ : 'detected: —'}
+ — not mapped —
+ {#each boneNames as n (n)}{n} {/each}
+
+
+ {/each}
+
+
+ (showFingers = !showFingers)}
+ >{showFingers ? 'Hide fingers' : 'Show fingers'}
+ applyHumanoidConfig(uuid, { ...configOf(uuid), map: undefined })}
+ >Reset fixes
+
+ {:else}
+
+ {#each info.bones as b (b.name)}
+ pickedBone.set(b.name)}
+ >
+ {b.name}
+ {#if mappedAs[b.name]}{boneLabel(mappedAs[b.name])} {/if}
+
+ {/each}
+
+ {#if !info.editable}
+
+ This model is a {info.kind.toUpperCase()} file: rig fix-ups edit GLB / VRM files. Download
+ it as GLB and import that.
+
+ {:else if tab === 'bones'}
+ {#if $pickedBone}
+
+
+ rig.renameBone(uuid, $pickedBone, renameTo.trim())}>Rename
+
+
+ Rest
+ {#each [0, 1, 2] as k (k)}
+
+ (restT = restT.map((v, i) => (i === k ? +e.currentTarget.value : v)))}
+ />
+ {/each}
+ rig.moveBone(uuid, $pickedBone, restT)}>Move
+
+
+
+ rig.addBone(uuid, $pickedBone, newChild.trim())}>Add child
+ rig.deleteBone(uuid, $pickedBone)}>Delete
+
+ {:else}
+
Pick a bone in the list.
+ {/if}
+
+ rig.mirrorBones(uuid, 'left')}>Mirror left → right
+ rig.mirrorBones(uuid, 'right')}>Mirror right → left
+
+ {:else}
+
+ rig.autoWeights(uuid)}>Automatic weights
+ rig.smoothAll(uuid)}>Smooth
+ rig.normalizeAll(uuid)}>Normalize
+
+
+
+ Weight brush for {$pickedBone || '(pick a bone)'} — paint on the model
+
+ {#if brushOn}
+
+ Radius
+
+
+ Strength
+ erase
+
+ {/if}
+
+ Test pose
+
+ {bend}°
+
+
+ Bends the picked bone here only, to see the weights work. Each stroke is saved and sent
+ when you lift.
+
+ {/if}
+ {/if}
+
+ {/if}
+
+
+
diff --git a/src/components/editors/AnimStatesWindow.svelte b/src/components/editors/AnimStatesWindow.svelte
new file mode 100644
index 000000000..ebeb4d40d
--- /dev/null
+++ b/src/components/editors/AnimStatesWindow.svelte
@@ -0,0 +1,292 @@
+
+
+
+
+
+ {graph ? 'Replace with the Locomotion preset' : 'Locomotion preset'}
+ {#if graph}Remove {/if}
+
+ {#if !clips.length}
+
This model has no clips yet — the preset fills a humanoid's from the Motion pack; drag clips onto any other model first.
+ {/if}
+
+ {#if graph}
+
States {reading ? '— now: ' + stateName(reading.state) : ''}
+ {#each graph.states as st, i (st.id)}
+
+ edit((g) => (g.states[i].name = e.currentTarget.value.trim() || st.id))}
+ />
+ {#if st.blend}
+ blend · {st.blend.param}
+ {#each ['idle', 'walk', 'run'] as slot (slot)}
+ edit((g) => (g.states[i].blend[slot] = e.currentTarget.value))}>
+ {slot}: auto
+ {#each clips as c (c)}{c} {/each}
+
+ {/each}
+ {:else}
+ edit((g) => (g.states[i].clip = e.currentTarget.value))}>
+ (no clip)
+ {#each clips as c (c)}{c} {/each}
+
+ edit((g) => (e.currentTarget.checked ? delete g.states[i].loop : (g.states[i].loop = false)))} /> loop
+ {/if}
+ {#if graph.entry === st.id}entry {:else} edit((g) => (g.entry = st.id))}>entry {/if}
+ edit((g) => {
+ g.states.splice(i, 1);
+ g.transitions = g.transitions.filter((/** @type {any} */ t) => t.from !== st.id && t.to !== st.id);
+ })}>✕
+
+ {/each}
+
+ edit((g) => g.states.push({ id: newId('State'), name: 'State', clip: clips[0] ?? '' }))}>+ State
+ edit((g) => g.states.push({ id: newId('Move'), name: 'Move', blend: { param: 'speed', idle: '', walk: '', run: '', walkSpeed: 1.4, runSpeed: 3.5 } }))}>+ Blend by speed
+
+
+
Transitions
+ {#each graph.transitions as tr, ti (ti)}
+
+
+ edit((g) => (g.transitions[ti].from = e.currentTarget.value))}>
+ Any state
+ {#each graph.states as s (s.id)}{s.name} {/each}
+
+ →
+ edit((g) => (g.transitions[ti].to = e.currentTarget.value))}>
+ {#each graph.states as s (s.id)}{s.name} {/each}
+
+ fade edit((g) => (g.transitions[ti].fade = Number(e.currentTarget.value) || 0))} /> s
+ edit((g) => g.transitions.splice(ti, 1))}>✕
+
+ {#each tr.when as c, ci (ci)}
+
+ {ci ? 'and' : 'when'}
+ edit((g) => (g.transitions[ti].when[ci].param = e.currentTarget.value))}>
+ {#each paramNames.includes(c.param) ? paramNames : [c.param, ...paramNames] as p (p)}{p} {/each}
+
+ edit((g) => (g.transitions[ti].when[ci].op = e.currentTarget.value))}>
+ {#each OPS as op (op)}{op} {/each}
+
+ {#if !['true', 'false', 'trigger'].includes(c.op)}
+ edit((g) => (g.transitions[ti].when[ci].value = Number(e.currentTarget.value) || 0))} />
+ {/if}
+ edit((g) => g.transitions[ti].when.splice(ci, 1))}>✕
+
+ {/each}
+
edit((g) => g.transitions[ti].when.push({ param: paramNames[0] ?? 'speed', op: '>', value: 0 }))}>+ condition
+
+ {/each}
+ {#if graph.states.length > 1}
+
edit((g) => g.transitions.push({ from: g.states[0].id, to: g.states[1].id, fade: 0.2, when: [] }))}>+ Transition
+ {/if}
+
+
Parameters {graph.driver === 'motion' ? '— speed, vy, grounded come from how it moves' : ''}
+ {#each graph.params as p, pi (p.name)}
+
+ {p.name}
+ {p.type}
+ {fmtValue(reading?.params?.[p.name])}
+ {#if p.type === 'trigger'}
+ runtime?.fireAnimTrigger(uuid, p.name)} title="Fire it on every peer">Fire
+ {/if}
+ edit((g) => g.params.splice(pi, 1))}>✕
+
+ {/each}
+
+ edit((g) => g.params.push({ name: 'param' + (g.params.length + 1), type: 'float', default: 0 }))}>+ Number
+ edit((g) => g.params.push({ name: 'flag' + (g.params.length + 1), type: 'bool', default: false }))}>+ Yes/no
+ edit((g) => g.params.push({ name: 'trigger' + (g.params.length + 1), type: 'trigger' }))}>+ Trigger
+
+ edit((g) => (g.driver = e.currentTarget.checked ? 'motion' : 'none'))} /> from its motion
+
+
+ {/if}
+
+
+
+
diff --git a/src/components/editors/AnimationWindow.svelte b/src/components/editors/AnimationWindow.svelte
index 16db34a5c..76c152257 100644
--- a/src/components/editors/AnimationWindow.svelte
+++ b/src/components/editors/AnimationWindow.svelte
@@ -14,7 +14,7 @@
// undo entry and ONE broadcast instead of dozens.
import { get } from 'svelte/store';
import { untrack } from 'svelte';
- import { selectedObject } from '../../stores/sceneStore';
+ import { selectedObject, objectsGroup } from '../../stores/sceneStore';
import { animationClose } from '../../stores/appStore.js';
import {
animations, playback, playheads, CHANNELS, EASINGS, STEPPED, channelLabel, isRotChannel,
@@ -33,7 +33,9 @@
// posed from the synced clock) — the window used to ignore them entirely, so
// a rigged model showed "no movements yet" and its own animations were
// reachable only from the Inspector.
- import { animatedObjects, setAnimationState, clipInfo, behaviorOf } from '$lib/animatedImports';
+ import { animatedObjects, setAnimationState, clipInfo, behaviorOf, restRowsOf } from '$lib/animatedImports';
+ import { openApplyClip } from '$lib/characterUi.js';
+ import ClipToolsPanel from './ClipToolsPanel.svelte'; // 57 M3/M8
import Icon from '../ui/Icon.svelte';
import { minimalScroll } from '$lib/ui/minimalScroll.js';
import ContextMenu from '../ContextMenu.svelte';
@@ -58,6 +60,13 @@
// live-follow the primary selection (keeps a truthy [] before the first select)
const target = $derived($selectedObject && $selectedObject.uuid ? $selectedObject : null);
+ // 57 M12: the MODEL a selected part belongs to (its top-level object), or null for a top-level object
+ const partOf = $derived.by(() => {
+ let o = target;
+ if (!o || !o.parent || o.parent === $objectsGroup) return null;
+ while (o.parent && o.parent !== $objectsGroup) o = o.parent;
+ return o.parent === $objectsGroup ? o : null;
+ });
// the ACTIVE clip of the object's set, normalized (a v1 save migrates on read)
const anim = $derived.by(() => {
$animations; // the store is the dependency; activeClip reads it with get()
@@ -2034,8 +2043,11 @@
+
Imported clips
+
+ {#if target && !behavior && restRowsOf(target.uuid).length}
+ target && openApplyClip({ src: { uuid: target.uuid, clip: clipState?.clip ?? clips[0]?.name } })}
+ >Apply to…
+ {/if}
{clips.length}
@@ -2085,6 +2106,15 @@
{/if}
+
+ {#if target && clipState && !behavior}
+ {#if !clips.length}
+
+ No clips yet — drag an animation clip (.tpanim) from the Explorer onto the model.
+
+ {/if}
+
+ {/if}
{#if authoredClips.length}
@@ -2132,6 +2162,20 @@
onpointermove={doClipsResize}
onpointerup={endClipsResize}
>
+
+ {#if partOf}
+ {
+ const active = authoredClips.find((c) => c.active);
+ if (!active || !partOf) return;
+ const m = await import('$lib/tpanim/clipEdit.js');
+ await m.partsKeysToClip(partOf.uuid, active.name);
+ }}>Save as a clip file on {partOf.name || 'the model'}
+ {/if}
{/if}
@@ -2145,7 +2189,7 @@
+
-
+
{#if !tracks.length}
{clips.length
diff --git a/src/components/editors/AssetBrowser.svelte b/src/components/editors/AssetBrowser.svelte
index b9ed92684..422d30904 100644
--- a/src/components/editors/AssetBrowser.svelte
+++ b/src/components/editors/AssetBrowser.svelte
@@ -17,7 +17,11 @@
import { bottomDockable } from '$lib/bottomDockDrop';
import { focusStack } from '$lib/windowFocus';
import { minimalScroll } from '$lib/ui/minimalScroll.js';
- import { assetBrowserOpen, assetBrowserKind, assetBrowserRes } from '$lib/cc0/assetBrowserState.js';
+ import {
+ assetBrowserOpen,
+ assetBrowserKind,
+ assetBrowserRes
+ } from '$lib/cc0/assetBrowserState.js';
import {
CC0_KINDS,
CC0_SITES,
@@ -31,8 +35,22 @@
filterRows,
creditLine
} from '$lib/cc0/cc0Core.js';
- import { phList, phCategories, acgPage, acgCategories, forgetCc0Memo } from '$lib/cc0/cc0Fetch.js';
- import { importCc0, cc0Jobs, rowKey, heldItemFor, placeItem, applyPresetToSelection, errorText } from '$lib/cc0/cc0Import.js';
+ import {
+ phList,
+ phCategories,
+ acgPage,
+ acgCategories,
+ forgetCc0Memo
+ } from '$lib/cc0/cc0Fetch.js';
+ import {
+ importCc0,
+ cc0Jobs,
+ rowKey,
+ heldItemFor,
+ placeItem,
+ applyPresetToSelection,
+ errorText
+ } from '$lib/cc0/cc0Import.js';
import { explorerItems, revealItem } from '$lib/explorer';
import { editEnvSky } from '$lib/environment';
import { materialPresets } from '$lib/materialPresets';
@@ -65,9 +83,12 @@
/** a load in flight is superseded by the next one (tab switch, a new search) */
let ticket = 0;
- const filtered = $derived(kind.source === 'polyhaven' ? filterRows(rows, { query, category }) : rows);
- const visible = $derived(kind.source === 'polyhaven' ? filtered.slice(0, shown) : filtered);
- const more = $derived(kind.source === 'polyhaven' ? filtered.length > shown : rows.length < acgTotal);
+ /** 57 M5: the Avatars tab lists CC-BY collections only when asked (their credit is kept) */
+ let includeCcBy = $state(false);
+ const listed = $derived(kind.source === 'polyhaven' || kind.source === 'osa' || kind.source === 'motion');
+ const filtered = $derived(listed ? filterRows(rows, { query, category }) : rows);
+ const visible = $derived(listed ? filtered.slice(0, shown) : filtered);
+ const more = $derived(listed ? filtered.length > shown : rows.length < acgTotal);
const selectionCount = $derived(($selectedObjects ?? []).length);
/** @param {boolean} [fresh] */
@@ -79,13 +100,34 @@
staleWhy = '';
try {
if (kind.source === 'polyhaven') {
- const [list, cats] = await Promise.all([phList(kind.phType ?? '', fresh), phCategories(kind.phType ?? '', fresh).catch(() => null)]);
+ const [list, cats] = await Promise.all([
+ phList(kind.phType ?? '', fresh),
+ phCategories(kind.phType ?? '', fresh).catch(() => null)
+ ]);
if (my !== ticket) return;
rows = normalizePolyHaven(list.data, /** @type {any} */ (kind.key));
categories = cats ? normalizePhCategories(cats.data) : [];
noteStale(list);
+ } else if (kind.source === 'osa') {
+ // 57 M5: the Open Source Avatars registry (VRM avatars; CC0 unless CC-BY is asked for)
+ const { osaList } = await import('$lib/avatars/osaRegistry.js');
+ const list = await osaList({ fresh, includeCcBy });
+ if (my !== ticket) return;
+ rows = list.rows;
+ categories = list.categories;
+ noteStale(list);
+ } else if (kind.source === 'motion') {
+ // 57 M5: the Motion pack (humanoid .tpanim clips, CC0)
+ const { motionRows } = await import('$lib/tpanim/motionKit.js');
+ const list = await motionRows(fresh);
+ if (my !== ticket) return;
+ rows = list.rows;
+ categories = list.categories;
} else {
- const [page, cats] = await Promise.all([acgPage({ query: query.trim(), category, fresh }), acgCategories(fresh).catch(() => null)]);
+ const [page, cats] = await Promise.all([
+ acgPage({ query: query.trim(), category, fresh }),
+ acgCategories(fresh).catch(() => null)
+ ]);
if (my !== ticket) return;
rows = normalizeAmbientCg(page.data);
acgTotal = ambientCgTotal(page.data);
@@ -172,7 +214,11 @@
/** the preset this browser made for a material row, if it is still in the library
* @param {any} row @param {any[]} list */
function heldPreset(row, list) {
- return list.find((p) => p.payload?.source?.site === row.source && p.payload?.source?.id === row.id) ?? null;
+ return (
+ list.find(
+ (p) => p.payload?.source?.site === row.source && p.payload?.source?.id === row.id
+ ) ?? null
+ );
}
/** @param {any} row @param {any[]} _items (an unused argument so the card re-derives on a Library change) */
const heldItem = (row, _items) => heldItemFor(row);
@@ -184,10 +230,21 @@
try {
const made = await importCc0(row, $assetBrowserRes, opts);
if (made.item) {
- if (row.kind === 'hdris') showToast((opts.applySky === false ? 'Added ' : 'Lighting the scene with ') + made.item.name);
- else showToast('Added ' + made.item.name + ' to the Library', [{ label: 'Show', action: () => show(made.item) }]);
+ if (row.kind === 'hdris')
+ showToast(
+ (opts.applySky === false ? 'Added ' : 'Lighting the scene with ') + made.item.name
+ );
+ else
+ showToast('Added ' + made.item.name + ' to the Library', [
+ { label: 'Show', action: () => show(made.item) }
+ ]);
} else if (made.preset) {
- showToast('Saved ' + made.preset.name + ' to your materials' + (opts.applyToSelection && selectionCount ? ' and applied it' : ''));
+ showToast(
+ 'Saved ' +
+ made.preset.name +
+ ' to your materials' +
+ (opts.applyToSelection && selectionCount ? ' and applied it' : '')
+ );
}
return made;
} catch (err) {
@@ -209,6 +266,61 @@
if (held) await placeItem(held);
}
+ /** 57 M5: a Motion pack clip goes into the Explorer, a file like any other clip @param {any} row */
+ async function addMotion(row) {
+ const key = rowKey(row);
+ placing = { ...placing, [key]: true };
+ cardError = { ...cardError, [key]: '' };
+ try {
+ const { addMotionClip } = await import('$lib/tpanim/motionKit.js');
+ await addMotionClip(row.id);
+ showToast(row.name + ' added to the Explorer: drag it onto a character to play it');
+ } catch (err) {
+ cardError = { ...cardError, [key]: errorText(err) };
+ } finally {
+ placing = { ...placing, [key]: false };
+ }
+ }
+ /** 57 M5: an avatar goes straight into the scene as a VRM character, its credit kept @type {Record
} */
+ let placing = $state({});
+ /** @param {any} row */
+ async function placeAvatar(row) {
+ const key = rowKey(row);
+ placing = { ...placing, [key]: true };
+ cardError = { ...cardError, [key]: '' };
+ try {
+ const res = await fetch(row.vrm);
+ if (!res.ok) throw new Error('the avatar file is unreachable (HTTP ' + res.status + ')');
+ const bytes = await res.arrayBuffer();
+ const { importFile } = await import('$lib/fileHandler.svelte.js');
+ const { osaProvenance } = await import('$lib/avatars/osaRegistry.js');
+ const uuid = await importFile(
+ new File([bytes], row.name + '.vrm'),
+ row.name,
+ 'vrm',
+ undefined,
+ undefined,
+ { provenance: osaProvenance(row) }
+ );
+ if (uuid)
+ showToast(
+ 'Placed ' +
+ row.name +
+ (row.licence === 'CC0-1.0'
+ ? ' (CC0)'
+ : ' — ' +
+ row.licence +
+ ': credit ' +
+ (row.authors[0] || row.project) +
+ ' where you share it')
+ );
+ } catch (err) {
+ cardError = { ...cardError, [key]: errorText(err) };
+ } finally {
+ placing = { ...placing, [key]: false };
+ }
+ }
+
/** @param {any} row */
async function useSky(row) {
const held = heldItemFor(row);
@@ -253,19 +365,37 @@
id="asset-browser-window"
class="ui-panel tp-ui tp-window tp-themed fixed flex flex-col overflow-hidden outline-hidden"
tabindex="-1"
- use:dragWindow={{ key: 'assetBrowserWindow', defaultRect: { left: 160, top: 96 }, resizable: true, minW: 300, minH: 260 }}
+ use:dragWindow={{
+ key: 'assetBrowserWindow',
+ defaultRect: { left: 160, top: 96 },
+ resizable: true,
+ minW: 300,
+ minH: 260
+ }}
use:bottomDockable={{ key: 'assets' }}
use:focusStack={'assets'}
style="z-index: var(--z-window); width: 560px; height: 600px"
- data-keywords="cc0 free assets poly haven ambientcg models hdri sky textures materials download"
+ data-keywords="cc0 free assets poly haven ambientcg models hdri sky textures materials download avatars vrm characters"
>
-
+
Free assets (CC0)
-
- assetBrowserOpen.set(false)}
+ assetBrowserOpen.set(false)}
>
@@ -279,23 +409,57 @@
onchange={(v) => assetBrowserKind.set(v)}
/>
- onSearch('')} />
+ onSearch('')}
+ />
({ value: c.id, name: c.label + ' (' + c.count + ')' }))]}
+ items={[
+ { value: '', name: 'All categories' },
+ ...categories.map((c) => ({ value: c.id, name: c.label + ' (' + c.count + ')' }))
+ ]}
onchange={onCategory}
/>
({ value: r, label: r, title: r === '1k' ? 'Small files — right for a Quest or a phone' : r === '2k' ? 'The desktop default' : 'Big files: slow to share with peers' }))}
+ options={RESOLUTIONS.map((r) => ({
+ value: r,
+ label: r,
+ title:
+ r === '1k'
+ ? 'Small files — right for a Quest or a phone'
+ : r === '2k'
+ ? 'The desktop default'
+ : 'Big files: slow to share with peers'
+ }))}
value={$assetBrowserRes}
onchange={(v) => assetBrowserRes.set(v)}
/>
+ {#if kind.source === 'osa'}
+
+ load()}
+ /> Include CC-BY
+
+ {/if}
@@ -303,59 +467,171 @@
{!online ? 'You are offline. ' : ''}{staleAt ? 'Showing the list saved ' + when(staleAt) + '.' : ''}
+ >{!online ? 'You are offline. ' : ''}{staleAt
+ ? 'Showing the list saved ' + when(staleAt) + '.'
+ : ''}
{!online ? 'Downloads need a connection.' : staleWhy}
{/if}
-
+
{#if listError}
{listError}
-
Try again
+
Try again
{:else if loading && !rows.length}
-
Loading {kind.label.toLowerCase()}…
+
+ Loading {kind.label.toLowerCase()}…
+
{:else if !visible.length}
-
Nothing matches{query ? ' “' + query + '”' : ''}. Try another word or category.
+
+ Nothing matches{query ? ' “' + query + '”' : ''}. Try another word or category.
+
{/if}
{#each visible as row (rowKey(row))}
{@const key = rowKey(row)}
{@const job = $cc0Jobs[key]}
- {@const item = row.kind === 'models' || row.kind === 'hdris' ? heldItem(row, $explorerItems) : null}
- {@const preset = row.kind === 'textures' || row.kind === 'materials' ? heldPreset(row, $materialPresets) : null}
-
+ {@const item =
+ row.kind === 'models' || row.kind === 'hdris'
+ ? heldItem(row, $explorerItems)
+ : row.kind === 'motion'
+ ? $explorerItems.find((/** @type {any} */ it) => it.kind === 'anim' && it.name === row.name + '.tpanim')
+ : null}
+ {@const preset =
+ row.kind === 'textures' || row.kind === 'materials'
+ ? heldPreset(row, $materialPresets)
+ : null}
+
{#if row.thumb}
-
/** @type {any} */ (e.currentTarget).classList.add('ab-noimg')} />
+
/** @type {any} */ (e.currentTarget).classList.add('ab-noimg')}
+ />
+ {:else if row.kind === 'motion'}
+
{/if}
- {#if item || preset}
{/if}
+ {#if item || preset}
{/if}
{row.name}
- {row.authors.join(', ') || CC0_SITES[/** @type {'polyhaven'|'ambientcg'} */ (row.source)].label}
+
+ {row.authors.join(', ') ||
+ CC0_SITES[/** @type {'polyhaven'|'ambientcg'} */ (row.source)].label}{row.source ===
+ 'osa'
+ ? ' · ' + (row.licence === 'CC0-1.0' ? 'CC0' : row.licence)
+ : ''}
+
{#if job}
-
-
+
+
-
{job.phase} {job.total ? mb(job.done) + ' / ' + mb(job.total) : ''}
+
+ {job.phase}
+ {job.total ? mb(job.done) + ' / ' + mb(job.total) : ''}
+
{:else}
- {#if row.kind === 'models'}
+ {#if row.kind === 'motion'}
+ addMotion(row)}
+ >{item ? 'In the Explorer' : placing[key] ? 'Adding…' : 'Add to Explorer'}
+ {:else if row.kind === 'avatars'}
+ placeAvatar(row)}
+ >{placing[key] ? 'Placing…' : 'Add to scene'}
+ {:else if row.kind === 'models'}
{#if item}
- place(row)}>Add to scene
- show(item)}>Library
+ place(row)}>Add to scene
+ show(item)}>Library
{:else}
- bring(row)}>Add to Library
+ bring(row)}>Add to Library
{/if}
{:else if row.kind === 'hdris'}
- useSky(row)}>Use as sky
+ useSky(row)}>Use as sky
{:else}
{#if !preset}
- bring(row)}>Add
+ bring(row)}>Add
{/if}
1 ? 's' : '') : 'Select an object first'}
+ title={selectionCount
+ ? 'Put it on the selected object' + (selectionCount > 1 ? 's' : '')
+ : 'Select an object first'}
onclick={() => applyMaterial(row, preset)}>Apply
{/if}
@@ -380,7 +658,8 @@
variant="ghost"
size="sm"
disabled={loading}
- onclick={() => (kind.source === 'polyhaven' ? (shown += PAGE) : loadMoreAcg())}>Show more (kind.source === 'polyhaven' ? (shown += PAGE) : loadMoreAcg())}
+ >Show more
{/if}
@@ -388,8 +667,15 @@
{/if}
@@ -402,6 +688,13 @@
gap: 6px;
padding: 6px 0 4px;
}
+ .ab-ccby {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ color: var(--text-muted);
+ }
.ab-filters :global(.ab-cat) {
min-width: 120px;
flex: 1 1 140px;
diff --git a/src/components/editors/ClipToolsPanel.svelte b/src/components/editors/ClipToolsPanel.svelte
new file mode 100644
index 000000000..a9045358b
--- /dev/null
+++ b/src/components/editors/ClipToolsPanel.svelte
@@ -0,0 +1,313 @@
+
+
+
+
diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte
index bc92c92c2..18e3dd833 100644
--- a/src/components/editors/Explorer.svelte
+++ b/src/components/editors/Explorer.svelte
@@ -1083,6 +1083,7 @@
prefab: 'boxes',
'prefab-folder': 'folder', // 37 R4: a folder inside the Prefabs view
scene: 'map', // 21-F4: a level (.tpscene)
+ anim: 'person-standing', // 57 M1: an animation clip (.tpanim)
tpchat: 'message-square' // 56 C10: a chat of the scene (Scene ▸ chats)
};
// semantic icon colors (ui.css classes over the --icon-* theme tokens)
@@ -1095,11 +1096,12 @@
text: 'Text and config',
object: '3D models',
prefab: 'Prefabs',
- scene: 'Scenes'
+ scene: 'Scenes',
+ anim: 'Animation clips'
};
/** R22-R7: the filter's fixed order. Every kind the Explorer can hold, so the menu is
* a statement about the app rather than about this library's current contents. */
- const FILTER_KINDS = ['image', 'object', 'audio', 'video', 'text', 'scene', 'prefab'];
+ const FILTER_KINDS = ['image', 'object', 'anim', 'audio', 'video', 'text', 'scene', 'prefab'];
const KIND_COLORS: Record = {
image: 'ico-image',
audio: 'ico-audio',
@@ -1109,6 +1111,7 @@
prefab: 'ico-prefab',
'prefab-folder': 'ico-folder',
scene: 'ico-prefab', // 21-F4: levels share the prefab tint
+ anim: 'ico-object', // 57 M1: a clip belongs with the models it moves
tpchat: 'ico-doc' // 56 C10
};
@@ -6636,6 +6639,24 @@
url: URL.createObjectURL(blob),
onClose: () => gridEl?.focus()
});
+ } else if (item.kind === 'anim') {
+ // 57 M1: a clip PLAYS on the selected character (the drop's assign, without the drag)
+ const pick = selectedSet()[0];
+ if (!pick) {
+ showToast('Select a character, then double-click the clip — or drag it onto one');
+ return;
+ }
+ const { assignClipAsset } = await import('$lib/tpanim/clipAssets.js');
+ const { objectsGroup } = await import('../../stores/sceneStore.js');
+ const { hasAnimatedImport } = await import('$lib/animatedImports');
+ const group: any = get(objectsGroup);
+ let root = group?.getObjectByProperty('uuid', pick);
+ // the rig the selection belongs to; else (a parts model) its top-level object
+ let rig = root;
+ while (rig && !hasAnimatedImport(rig.uuid)) rig = rig.parent;
+ while (!rig && root && root.parent && root.parent !== group) root = root.parent;
+ const target = rig ?? root;
+ if (target) assignClipAsset(target.uuid, { hash: item.hash, name: item.name }, { play: true });
} else if (item.kind === 'object' && !item.packEntry) {
// R22 round 12 (user): "double click on 3d objects should open same preview as
// when opening image". ONE window for every kind, so the arrows walk from a
diff --git a/src/components/editors/Nodes.svelte b/src/components/editors/Nodes.svelte
index a4134fa56..f3ace980d 100644
--- a/src/components/editors/Nodes.svelte
+++ b/src/components/editors/Nodes.svelte
@@ -67,6 +67,7 @@
import KeyPressNode from './nodes/KeyPressNode.svelte';
import GamepadNode from './nodes/GamepadNode.svelte';
import PlayAnimNode from './nodes/PlayAnimNode.svelte';
+ import BlendSpeedNode from './nodes/BlendSpeedNode.svelte'; // 57 M3
import TimelineSeqNode from './nodes/TimelineSeqNode.svelte';
import AnimStateNode from './nodes/AnimStateNode.svelte';
import OnHitNode from './nodes/OnHitNode.svelte';
@@ -263,11 +264,16 @@
gamepadbutton: GamepadNode,
gamepadaxis: GamepadNode,
playanim: PlayAnimNode, // 17-E A5
+ blendspeed: BlendSpeedNode, // 57 M3
+ setanimparam: AnimationNode, // 57 M11 (spec-driven: named inputs + text/select/range)
+ onanimstate: AnimationNode, // 57 M11 (the onrest precedent: a trigger with params)
+ attachsocket: AnimationNode, // 57 M12
animfinished: OnClickNode, // 17-E: a pulse when a clip ends
animmarker: OnClickNode, // 17-E F5: a pulse at a named point in a clip
animstate: AnimStateNode, // 17-E F3: the readable half of it
playseq: TimelineSeqNode, // 48 S2: Play Sequence
onseqevent: TimelineSeqNode, // 48 S2: On Sequence Event
+ setexpression: AnimationNode, // 57 M4: spec-driven (expression text + weight)
// A3: ONE generic card for the whole HUD group (the ShaderNode precedent)
hudscreen: HudNode,
hudtext: HudNode,
diff --git a/src/components/editors/nodes/BlendSpeedNode.svelte b/src/components/editors/nodes/BlendSpeedNode.svelte
new file mode 100644
index 000000000..ea83b8d86
--- /dev/null
+++ b/src/components/editors/nodes/BlendSpeedNode.svelte
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+ speed (m/s) — unwired: its own
+ {#each SLOTS as slot (slot.key)}
+
+ {slot.key}
+ setNodeData(id, { [slot.key]: e.currentTarget.value.trim() })}
+ />
+
+ {/each}
+
+ {#each suggestions as name (name)}
+
+ {/each}
+
+ {#each [{ key: 'walkSpeed', label: 'walk speed', min: 0.2, max: 4, step: 0.1, unit: 'm/s', fallback: 1.4 }, { key: 'runSpeed', label: 'run speed', min: 0.5, max: 10, step: 0.1, unit: 'm/s', fallback: 3.5 }, { key: 'crossfade', label: 'crossfade', min: 0, max: 1, step: 0.05, unit: 's', fallback: 0.25 }] as r (r.key)}
+
+ {r.label} {Number(data[r.key] ?? r.fallback).toFixed(2)} {r.unit}
+ setNodeData(id, { [r.key]: +e.currentTarget.value })}
+ />
+
+ {/each}
+
+
diff --git a/src/components/editors/nodes/PlayAnimNode.svelte b/src/components/editors/nodes/PlayAnimNode.svelte
index 5b2f0abc5..fa6035cad 100644
--- a/src/components/editors/nodes/PlayAnimNode.svelte
+++ b/src/components/editors/nodes/PlayAnimNode.svelte
@@ -81,6 +81,20 @@
on:input={(e) => setNodeData(id, { speed: +e.currentTarget.value })}
/>
+
+
+ crossfade {(data.crossfade ?? 0).toFixed(2)} s
+ setNodeData(id, { crossfade: +e.currentTarget.value })}
+ />
+
{running ? 'playing' : 'idle'}
diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte
index d2177978c..c0be4d568 100644
--- a/src/components/menu/Inspector.svelte
+++ b/src/components/menu/Inspector.svelte
@@ -97,7 +97,8 @@
import { nameOf } from '$lib/lockControl';
import { geometrySpec } from '$lib/geometryParams';
import { LIGHT_PARAMS, SHADOW_TYPES, SHADOW_SIZES, setShadowMapSize, cappedShadowSize } from '$lib/lightParams';
- import { animatedObjects, setAnimationState } from '$lib/animatedImports';
+ import { animatedObjects, setAnimationState, restRowsOf } from '$lib/animatedImports';
+ import CharacterSection from './inspector/CharacterSection.svelte';
import { captureAutoKey, playheadOf } from '$lib/animationPreview';
import { moveObjectToGroup, selectObject, flyTo, setPickThrough } from '$lib/objectActions';
import { listPhysicsObjects, enablePhysicsOnSelection, setPhysicsFor, PHYSICS_MATERIALS } from '$lib/physics';
@@ -3197,7 +3198,7 @@
{/if}
- {#if $animatedObjects[$selectedObject.uuid]}
+ {#if $animatedObjects[$selectedObject.uuid]?.clips?.length}
{@const anim = $animatedObjects[$selectedObject.uuid]}
{/if}
+
+ {#if $animatedObjects[$selectedObject.uuid] && restRowsOf($selectedObject.uuid).length}
+
+ {/if}
+
{#if $publishSlot}
{:else}
@@ -66,9 +69,9 @@
{/if}
{:else if $publishExportTab === 'export'}
-
+
{:else if $publishExportTab === 'gallery'}
-
+
{:else}
{/if}
diff --git a/src/components/menu/Sidebar.svelte b/src/components/menu/Sidebar.svelte
index d2ca4b72b..c7cd63a3f 100644
--- a/src/components/menu/Sidebar.svelte
+++ b/src/components/menu/Sidebar.svelte
@@ -165,7 +165,7 @@
{/if}
-
importModelFiles(e.target.files)} accept=".gltf, .glb, .obj, .stl, .fbx, .mtl, .png, .jpg, .jpeg, .webp" />
+
importModelFiles(e.target.files)} accept=".gltf, .glb, .obj, .stl, .fbx, .vrm, .mtl, .png, .jpg, .jpeg, .webp" />
load(e.target.files[0])} accept=".json, .tpscene, .tp" />