diff --git a/package-lock.json b/package-lock.json index 1a04546db..b1027df7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@gltf-transform/extensions": "^4.5.1", "@gltf-transform/functions": "^4.5.1", "@lucide/svelte": "^1.48.0", + "@pixiv/three-vrm": "3.5.5", "@shaderfrog/core": "^4.0.2", "@shaderfrog/glsl-parser": "^7.0.1", "@threlte/core": "^8.6.1", @@ -37,6 +38,7 @@ "devDependencies": { "@axe-core/playwright": "4.13.0", "@iwer/devui": "2.6.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", @@ -933,6 +935,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 e46cc71aa..cde337aad 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "devDependencies": { "@axe-core/playwright": "4.13.0", "@iwer/devui": "2.6.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", @@ -56,6 +57,7 @@ "@gltf-transform/extensions": "^4.5.1", "@gltf-transform/functions": "^4.5.1", "@lucide/svelte": "^1.48.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 db386f8ce..3a1c40c32 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -29,6 +29,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' @@ -383,7 +385,7 @@ 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 @@ -447,6 +449,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. +

+ + {#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)} +
+ + + {rigLabel(t.rig)}{t.ok ? '' : ' · not mapped'} + +
+ {/each} +
+
+ {#snippet footer()} +
+ + +
+ {/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)} + + {/each} + {/each} +
+ {/if} + {:else} + Clip + + Format +
+ {#each CLIP_FORMATS as f (f.id)} + {@const off = f.needs === 'humanoid' && !single.humanoid} + + {/each} + {#each CONVERT_FORMATS as f (f.id)} + + {/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()} +
+ + +
+ {/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} + + {/each} +
+
+ + +
+ {:else} +
+ {#each info.bones as b (b.name)} + + {/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} +
+ + +
+
+ Rest + {#each [0, 1, 2] as k (k)} + + (restT = restT.map((v, i) => (i === k ? +e.currentTarget.value : v)))} + /> + {/each} + +
+
+ + + +
+ {:else} +

Pick a bone in the list.

+ {/if} +
+ + +
+ {:else} +
+ + + +
+ + {#if brushOn} +
+ Radius +
+
+ Strength + +
+ {/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/AnimationWindow.svelte b/src/components/editors/AnimationWindow.svelte index cfa1efd65..52e18eec0 100644 --- a/src/components/editors/AnimationWindow.svelte +++ b/src/components/editors/AnimationWindow.svelte @@ -33,7 +33,8 @@ // 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 Icon from '../ui/Icon.svelte'; import { minimalScroll } from '$lib/ui/minimalScroll.js'; import ContextMenu from '../ContextMenu.svelte'; @@ -2037,6 +2038,15 @@
Imported clips + + {#if target && !behavior && restRowsOf(target.uuid).length} + + {/if} {clips.length}
diff --git a/src/components/editors/AssetBrowser.svelte b/src/components/editors/AssetBrowser.svelte index b9ed92684..fb0a52e81 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'); + 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,27 @@ 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 { - 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 +207,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 +223,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 +259,46 @@ if (held) await placeItem(held); } + /** 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 +343,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" > -
+
@@ -279,23 +387,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'} + + {/if}
@@ -303,59 +445,154 @@

{/if} -
+
{#if listError} {:else if loading && !rows.length} -

+

+

{: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) : 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')} + /> {/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 === 'avatars'} + + {:else if row.kind === 'models'} {#if item} - - + + {:else} - + {/if} {:else if row.kind === 'hdris'} - + {:else} {#if !preset} - + {/if} {/if} @@ -380,7 +619,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 +628,15 @@

    Every asset here is CC0 (public domain) — - Poly Haven and - ambientCG. Credits stay with each file. + Poly Haven + and + ambientCG{kind.source === 'osa' ? '' : '.'} Credits stay with each file. + {#if kind.source === 'osa'}Avatars: the Open Source Avatars registry — CC0 collections, CC-BY ones on request (their credit is kept).{/if}

    {/if} @@ -402,6 +649,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/Explorer.svelte b/src/components/editors/Explorer.svelte index 9826739b1..c347d0b7f 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -1072,7 +1072,8 @@ object: 'box', prefab: 'boxes', 'prefab-folder': 'folder', // 37 R4: a folder inside the Prefabs view - scene: 'map' // 21-F4: a level (.tpscene) + scene: 'map', // 21-F4: a level (.tpscene) + anim: 'person-standing' // 57 M1: an animation clip (.tpanim) }; // semantic icon colors (ui.css classes over the --icon-* theme tokens) /** R22-R7: what a kind is CALLED in the filter. The store values are internal @@ -1084,11 +1085,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', @@ -1097,7 +1099,8 @@ object: 'ico-object', prefab: 'ico-prefab', 'prefab-folder': 'ico-folder', - scene: 'ico-prefab' // 21-F4: levels share the prefab tint + scene: 'ico-prefab', // 21-F4: levels share the prefab tint + anim: 'ico-object' // 57 M1: a clip belongs with the models it moves }; // ---- R22 round 13 P3: WHERE AM I, when it is not the library ---------------------- @@ -6528,6 +6531,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 dcdabb388..f7b64402a 100644 --- a/src/components/editors/Nodes.svelte +++ b/src/components/editors/Nodes.svelte @@ -267,6 +267,7 @@ 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/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index e37d9de07..45831c0b5 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -92,7 +92,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'; @@ -3188,7 +3189,7 @@
{/if} - {#if $animatedObjects[$selectedObject.uuid]} + {#if $animatedObjects[$selectedObject.uuid]?.clips?.length} {@const anim = $animatedObjects[$selectedObject.uuid]}
@@ -3224,6 +3225,10 @@
{/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" />