@volter/editor-threejs 0.5.65 → 0.5.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +2 -0
- package/contributions/animation-mixers.service.ts +20 -0
- package/contributions/animation-timeline.utility.tsx +44 -0
- package/contributions/three-integration.service.ts +13 -0
- package/dist-node/serving.mjs +405 -0
- package/package.json +113 -5
- package/serving/animation-live-module.ts +70 -0
- package/serving/animation-stamp.ts +88 -0
- package/serving/index.ts +14 -0
- package/serving/model-import-conversion.ts +344 -0
- package/src/adapter/ingest/scene-capture.ts +1 -23
- package/src/adapter/renderer-config.ts +3 -4
- package/src/adapter/three-contract.ts +72 -0
- package/src/animation/live-mixers.ts +55 -0
- package/src/ecs/object-marks.ts +1 -1
- package/src/ecs/user-data.ts +0 -16
- package/src/host-hierarchy-objects.ts +31 -0
- package/src/kit/animation/three-clips-subject.ts +190 -0
- package/src/kit/asset-compare.ts +294 -0
- package/src/kit/asset-preview-command.ts +265 -0
- package/src/kit/asset-preview-framing.ts +357 -0
- package/src/kit/asset-preview.ts +2802 -0
- package/src/kit/asset-workflow/model-inspection.ts +830 -0
- package/src/kit/authoring/component-instance-root.ts +171 -0
- package/src/kit/authoring/design-time-settle.ts +343 -0
- package/src/kit/authoring/live-object-transform.ts +62 -0
- package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
- package/src/kit/authoring/object3d-document-session.ts +1965 -0
- package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
- package/src/kit/authoring/quarks-particle-systems.ts +19 -0
- package/src/kit/authoring/shell-viewport-policy.ts +48 -0
- package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
- package/src/kit/authoring/three-projection-core.ts +226 -0
- package/src/kit/authoring/viewport-pick-context.ts +39 -0
- package/src/kit/authoring/viewport-raycast.ts +240 -0
- package/src/kit/authoring/world-hidden-viewport.ts +95 -0
- package/src/kit/camera-authoring.ts +175 -0
- package/src/kit/components/CameraInfo.tsx +56 -0
- package/src/kit/components/InspectorObjectPreview.tsx +57 -0
- package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
- package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
- package/src/kit/components/StageHost.tsx +2547 -0
- package/src/kit/components/StageOverlays.tsx +21 -0
- package/src/kit/components/StatsOverlay.tsx +78 -0
- package/src/kit/components/ToolObject3DPreview.tsx +39 -0
- package/src/kit/components/ViewportFurniture.tsx +655 -0
- package/src/kit/components/ViewportOverlay.tsx +215 -0
- package/src/kit/components/ViewportShadingMenu.tsx +340 -0
- package/src/kit/components/ViewportViewMenu.tsx +155 -0
- package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
- package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
- package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
- package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
- package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
- package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
- package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
- package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
- package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
- package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
- package/src/kit/components/stage-keyboard.tsx +40 -0
- package/src/kit/components/stage-overlay-set.tsx +105 -0
- package/src/kit/components/stage-presence-markers.ts +482 -0
- package/src/kit/components/stage-transform-chrome.ts +30 -0
- package/src/kit/components/stage-transform-tools.tsx +73 -0
- package/src/kit/components/stage-view-name.ts +30 -0
- package/src/kit/components/standard-viewport-dressing.ts +1042 -0
- package/src/kit/components/world-root-binding.ts +64 -0
- package/src/kit/constraint-helper.ts +338 -0
- package/src/kit/editor-shell-store.ts +814 -0
- package/src/kit/editor-viewport.ts +6621 -0
- package/src/kit/entity-lod.ts +31 -0
- package/src/kit/entity-object.ts +92 -0
- package/src/kit/hierarchy-mark-reader.ts +74 -0
- package/src/kit/instanced-presentation.ts +164 -0
- package/src/kit/live-module-source.ts +230 -0
- package/src/kit/model-thumbnail.ts +539 -0
- package/src/kit/play-camera-flight.ts +300 -0
- package/src/kit/projection/three.ts +898 -0
- package/src/kit/reflection-probe-helper.ts +142 -0
- package/src/kit/scene-document-viewport.ts +51 -0
- package/src/kit/scene-framing.ts +315 -0
- package/src/kit/scene-view-fog.ts +89 -0
- package/src/kit/spatial-handle-visuals.ts +332 -0
- package/src/kit/stories/three-story-model.ts +66 -0
- package/src/kit/three-canvas-render.ts +44 -0
- package/src/kit/three-hierarchy-row-media.ts +26 -0
- package/src/kit/three-inspection-media.ts +73 -0
- package/src/kit/three-integration.ts +86 -0
- package/src/kit/three-state.ts +33 -0
- package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
- package/src/kit/three-viewport/camera-fit.ts +41 -0
- package/src/kit/three-viewport/interactive-renderer.ts +132 -0
- package/src/kit/three-viewport/selection-brackets.ts +355 -0
- package/src/kit/three-viewport/selection-outline.ts +333 -0
- package/src/kit/three-viewport/skeleton-helper.ts +61 -0
- package/src/kit/three-viewport/source-color.ts +197 -0
- package/src/kit/three-viewport/studio-environment.ts +96 -0
- package/src/kit/trigger-volume-helper.ts +116 -0
- package/src/kit/viewport-actions.ts +128 -0
- package/src/kit/viewport-authoring-policy.ts +154 -0
- package/src/kit/viewport-commands.ts +318 -0
- package/src/kit/viewport-hotkeys.ts +119 -0
- package/src/kit/viewport-shading-boundary.ts +12 -0
- package/src/kit/viewport-status-facet.ts +53 -0
- package/src/object3d-contributions.ts +494 -0
- package/src/render/viewport-shading.ts +6 -2
- package/src/viewport/content-bounds.ts +38 -4
- package/src/viewport/environment.ts +16 -0
- package/src/viewport-api.ts +92 -0
- package/src/viewport-door.ts +237 -0
- package/src/animation/animation-clock.ts +0 -479
- package/src/animation/runtime-inspection.ts +0 -45
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mixers a running world animates with, as the editor reads them: the registry the served
|
|
3
|
+
* animation stamp fills (`serving/animation-live-module.ts`) in the game's graph. An instrument
|
|
4
|
+
* that drives time on an object asks here for the mixer the game already made for it, so the
|
|
5
|
+
* skeleton keeps one writer.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type * as THREE from 'three';
|
|
9
|
+
|
|
10
|
+
export interface LiveMixer {
|
|
11
|
+
/** The call site that made it (`src/prefabs/Player.tsx:358:37`). */
|
|
12
|
+
readonly key: string;
|
|
13
|
+
readonly mixer: THREE.AnimationMixer;
|
|
14
|
+
/** Every clip the game has played through it, and every clip `useAnimations` was handed. */
|
|
15
|
+
readonly clips: ReadonlyMap<string, THREE.AnimationClip>;
|
|
16
|
+
/** The objects its actions animate. */
|
|
17
|
+
readonly roots: ReadonlySet<unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Registry {
|
|
21
|
+
readonly mixers: Map<unknown, LiveMixer>;
|
|
22
|
+
readonly listeners: Set<() => void>;
|
|
23
|
+
version: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const REGISTRY = Symbol.for('volter.three.animation.live');
|
|
27
|
+
|
|
28
|
+
function registry(): Registry {
|
|
29
|
+
const holder = globalThis as unknown as Record<symbol, Registry | undefined>;
|
|
30
|
+
return (holder[REGISTRY] ??= { mixers: new Map(), listeners: new Set(), version: 0 });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The live mixer that animates `object`, or null when the game animates it with none. */
|
|
34
|
+
export function liveMixerFor(object: THREE.Object3D): LiveMixer | null {
|
|
35
|
+
for (const entry of registry().mixers.values()) {
|
|
36
|
+
if (entry.clips.size === 0) continue;
|
|
37
|
+
if (entry.mixer.getRoot() === object || entry.roots.has(object)) return entry;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every mixer the running world's code has made, in the order it made them. */
|
|
43
|
+
export function liveMixers(): LiveMixer[] {
|
|
44
|
+
return [...registry().mixers.values()];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function subscribeLiveMixers(listener: () => void): () => void {
|
|
48
|
+
const listeners = registry().listeners;
|
|
49
|
+
listeners.add(listener);
|
|
50
|
+
return () => listeners.delete(listener);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function liveMixersVersion(): number {
|
|
54
|
+
return registry().version;
|
|
55
|
+
}
|
package/src/ecs/object-marks.ts
CHANGED
|
@@ -33,7 +33,7 @@ export type EditorHelperType =
|
|
|
33
33
|
| 'trigger-volumes'
|
|
34
34
|
| 'skeletons'
|
|
35
35
|
// A package's own helper kind, shown through the editor's viewport door
|
|
36
|
-
// (
|
|
36
|
+
// (the viewport door's `setViewportHelper`): the host lists the kinds it toggles by name,
|
|
37
37
|
// any other follows the master Helpers toggle.
|
|
38
38
|
| (string & {});
|
|
39
39
|
|
package/src/ecs/user-data.ts
CHANGED
|
@@ -40,13 +40,6 @@
|
|
|
40
40
|
* - `gaussianSplat` — native Spark splat metadata used for renderer discovery,
|
|
41
41
|
* inspector facts, bounds, and deterministic disposal.
|
|
42
42
|
*
|
|
43
|
-
* Animation (load-bearing — ED5 disposal contract):
|
|
44
|
-
* - `_animMixer` — THREE.AnimationMixer driving this subtree's clips.
|
|
45
|
-
* - `_animClips` — Map<string, AnimationClip> discovered on the GLTF.
|
|
46
|
-
* - `_availableClips` — string[] of clip names discovered on the GLTF
|
|
47
|
-
* (inspector dropdown; same names as `_animClips`' keys).
|
|
48
|
-
* - `_animationRuntime` — format-neutral live native mixer/action inspection.
|
|
49
|
-
*
|
|
50
43
|
* Disposal contract:
|
|
51
44
|
* - `__sharedGeometry` — `true` when a mesh's geometry is shared/cached and MUST
|
|
52
45
|
* NOT be disposed by per-object cleanup (P0.2 contract).
|
|
@@ -119,7 +112,6 @@ import type { ConstraintMark } from '../adapter/constraint';
|
|
|
119
112
|
import type { Object3DAuthoringSubjectMark } from '../adapter/object3d-authoring-subject';
|
|
120
113
|
import type { ReflectionProbeMark } from '../adapter/reflection-probe';
|
|
121
114
|
import type { TriggerVolumeMark } from '../adapter/trigger-volume';
|
|
122
|
-
import type { AnimationRuntimeInspection } from '../animation/runtime-inspection';
|
|
123
115
|
import { ObjectMarkKeys, type ObjectMarkSchema } from './object-marks';
|
|
124
116
|
|
|
125
117
|
export type { EditorHelperType } from './object-marks';
|
|
@@ -140,10 +132,6 @@ export interface UserDataSchema extends ObjectMarkSchema {
|
|
|
140
132
|
_camera: THREE.Camera;
|
|
141
133
|
_particleSystem: ParticleSystem;
|
|
142
134
|
gaussianSplat: { src: string; numSplats: number };
|
|
143
|
-
_animMixer: THREE.AnimationMixer;
|
|
144
|
-
_animClips: Map<string, THREE.AnimationClip>;
|
|
145
|
-
_availableClips: string[];
|
|
146
|
-
_animationRuntime: AnimationRuntimeInspection;
|
|
147
135
|
__sharedGeometry: boolean;
|
|
148
136
|
__shadeOrig: THREE.Material | THREE.Material[];
|
|
149
137
|
__shadeUnlit: THREE.Material[];
|
|
@@ -194,10 +182,6 @@ export const UserDataKeys = {
|
|
|
194
182
|
_camera: '_camera',
|
|
195
183
|
_particleSystem: '_particleSystem',
|
|
196
184
|
gaussianSplat: 'gaussianSplat',
|
|
197
|
-
_animMixer: '_animMixer',
|
|
198
|
-
_animClips: '_animClips',
|
|
199
|
-
_availableClips: '_availableClips',
|
|
200
|
-
_animationRuntime: '_animationRuntime',
|
|
201
185
|
__sharedGeometry: '__sharedGeometry',
|
|
202
186
|
__shadeOrig: '__shadeOrig',
|
|
203
187
|
__shadeUnlit: '__shadeUnlit',
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live objects behind the hierarchy's node ids, as a media
|
|
3
|
+
* integration provides them: the Three integration answers from the session store's Three half,
|
|
4
|
+
* and a lane that reads a scene's objects (the navmesh bake, Play's camera flight) asks here.
|
|
5
|
+
* With none registered there are no objects.
|
|
6
|
+
*/
|
|
7
|
+
import type * as THREE from 'three';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The world node IS the entity, so a contribution inspecting a live behavior resolves the
|
|
11
|
+
* hierarchy's node id to the object itself. The map is the authored viewport's in Edit and the
|
|
12
|
+
* adopted live scene's in Play; a reader must not hold an object across the hierarchy's
|
|
13
|
+
* `subscribe` firings.
|
|
14
|
+
*/
|
|
15
|
+
export interface HostHierarchyObjects {
|
|
16
|
+
object(id: string): THREE.Object3D | null;
|
|
17
|
+
objects(): ReadonlyMap<string, THREE.Object3D>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let registered: HostHierarchyObjects | null = null;
|
|
21
|
+
|
|
22
|
+
export function registerHostHierarchyObjects(provider: HostHierarchyObjects): () => void {
|
|
23
|
+
registered = provider;
|
|
24
|
+
return () => {
|
|
25
|
+
if (registered === provider) registered = null;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function hostHierarchyObjects(): HostHierarchyObjects | null {
|
|
30
|
+
return registered;
|
|
31
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT A THREE WORLD CAN SHOW AT A TIME — the stage transport's subjects for
|
|
3
|
+
* an ordinary three/R3F stage (WORK.md §The stage transport and the animation
|
|
4
|
+
* door, Step 3).
|
|
5
|
+
*
|
|
6
|
+
* TWO DISCOVERIES, because a three world carries clips in two different
|
|
7
|
+
* places and only one of them is the obvious one:
|
|
8
|
+
*
|
|
9
|
+
* 1. `object.animations` — what a GLTF's own `AnimationClip[]` lands in when
|
|
10
|
+
* something assigns it. A subject here owns a NEW `AnimationMixer` on that
|
|
11
|
+
* object, because nothing else is playing it.
|
|
12
|
+
* 2. `liveMixerFor(object)` — the mixer the world's OWN code made for it
|
|
13
|
+
* (`new THREE.AnimationMixer(…)`, drei's `useAnimations`), found through
|
|
14
|
+
* the served animation stamp (`@volter/editor-threejs/animation/live-mixers`).
|
|
15
|
+
* A subject here drives THAT mixer; minting a second mixer over the same
|
|
16
|
+
* object would give one skeleton two writers.
|
|
17
|
+
*
|
|
18
|
+
* DISCOVERY 2 IS NOT OPTIONAL, and this is the measurement that says so:
|
|
19
|
+
* three's `GLTFLoader` never assigns `scene.animations`, so an R3F character
|
|
20
|
+
* loaded with `useGLTF` has an EMPTY `animations` array on its Object3D. A
|
|
21
|
+
* scan that looked only at `animations` would find nothing on exactly the
|
|
22
|
+
* worlds a person most wants to scrub.
|
|
23
|
+
*
|
|
24
|
+
* THE MIXER IS THREE'S OWN and every call here is three's own API — this is a
|
|
25
|
+
* subject over `AnimationMixer`, never a wrapper around it. `setTime` is the
|
|
26
|
+
* one write; the transport owns the position.
|
|
27
|
+
*/
|
|
28
|
+
import { liveMixerFor, subscribeLiveMixers } from '@volter/editor-threejs/animation/live-mixers';
|
|
29
|
+
import * as THREE from 'three';
|
|
30
|
+
import type { StageTransport } from '@volter/editor-sdk/kit/animation/stage-transport';
|
|
31
|
+
|
|
32
|
+
/** The display rate a three clip is shown at. Three clips carry a duration in
|
|
33
|
+
* SECONDS and no rate of their own, so the transport needs one to convert a
|
|
34
|
+
* frame for a look that asks in frames. 30 is three's own editor convention
|
|
35
|
+
* (`AnimationClip.CreateFromMorphTargetSequence` defaults to it). */
|
|
36
|
+
const THREE_DISPLAY_FPS = 30;
|
|
37
|
+
|
|
38
|
+
function clamp(value: number, min: number, max: number): number {
|
|
39
|
+
return value < min ? min : value > max ? max : value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A subject over an object's OWN clips, on a mixer this subject mints and
|
|
44
|
+
* owns. `setClip` swaps which clip is showing: the chosen action plays and
|
|
45
|
+
* every other action is stopped. Nothing but the transport ticks this mixer,
|
|
46
|
+
* so the action moves only when the transport samples it — and it must NOT
|
|
47
|
+
* be paused: three advances a paused action by zero, so every scrub would
|
|
48
|
+
* show the clip's first frame.
|
|
49
|
+
*/
|
|
50
|
+
function attachOwnClips(object: THREE.Object3D, transport: StageTransport): (() => void) | null {
|
|
51
|
+
const clips = object.animations;
|
|
52
|
+
if (clips.length === 0) return null;
|
|
53
|
+
const mixer = new THREE.AnimationMixer(object);
|
|
54
|
+
let current = clips[0];
|
|
55
|
+
if (!current) return null;
|
|
56
|
+
|
|
57
|
+
const show = (clip: THREE.AnimationClip): void => {
|
|
58
|
+
mixer.stopAllAction();
|
|
59
|
+
const action = mixer.clipAction(clip);
|
|
60
|
+
action.paused = false;
|
|
61
|
+
action.play();
|
|
62
|
+
current = clip;
|
|
63
|
+
};
|
|
64
|
+
show(current);
|
|
65
|
+
|
|
66
|
+
const detach = transport.attach({
|
|
67
|
+
id: object.uuid,
|
|
68
|
+
label: object.name || current.name || 'Clip',
|
|
69
|
+
range: () => ({ start: 0, end: current?.duration ?? 0, fps: THREE_DISPLAY_FPS }),
|
|
70
|
+
seek: (seconds) => {
|
|
71
|
+
mixer.setTime(clamp(seconds, 0, current?.duration ?? 0));
|
|
72
|
+
},
|
|
73
|
+
clips: () => clips.map((clip) => ({ id: clip.name, label: clip.name })),
|
|
74
|
+
setClip: (id) => {
|
|
75
|
+
const next = clips.find((clip) => clip.name === id);
|
|
76
|
+
if (next) show(next);
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
detach();
|
|
82
|
+
mixer.stopAllAction();
|
|
83
|
+
mixer.uncacheRoot(object);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A subject over a mixer the world's own code made. It drives that mixer and
|
|
89
|
+
* never mints one; `clips` are the ones the world has played through it.
|
|
90
|
+
*/
|
|
91
|
+
function attachInspected(object: THREE.Object3D, transport: StageTransport): (() => void) | null {
|
|
92
|
+
const inspection = liveMixerFor(object);
|
|
93
|
+
if (!inspection) return null;
|
|
94
|
+
let current = inspection.clips.values().next().value;
|
|
95
|
+
if (!current) return null;
|
|
96
|
+
|
|
97
|
+
const show = (clip: THREE.AnimationClip): void => {
|
|
98
|
+
inspection.mixer.stopAllAction();
|
|
99
|
+
const action = inspection.mixer.clipAction(clip);
|
|
100
|
+
action.paused = false;
|
|
101
|
+
action.play();
|
|
102
|
+
current = clip;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const detach = transport.attach({
|
|
106
|
+
id: object.uuid,
|
|
107
|
+
label: object.name || current.name || 'Animation',
|
|
108
|
+
range: () => ({ start: 0, end: current?.duration ?? 0, fps: THREE_DISPLAY_FPS }),
|
|
109
|
+
seek: (seconds) => {
|
|
110
|
+
inspection.mixer.setTime(clamp(seconds, 0, current?.duration ?? 0));
|
|
111
|
+
},
|
|
112
|
+
clips: () => [...inspection.clips.entries()].map(([name, clip]) => ({ id: name, label: clip.name || name })),
|
|
113
|
+
setClip: (id) => {
|
|
114
|
+
const next = inspection.clips.get(id);
|
|
115
|
+
if (next) show(next);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Deliberately NOT stopping the mixer's actions on detach: this subject
|
|
120
|
+
// borrowed a mixer the world owns, and a borrower does not decide what the
|
|
121
|
+
// owner is playing when it leaves.
|
|
122
|
+
return detach;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* One scan of a mounted root, and the handle that keeps it current.
|
|
127
|
+
*
|
|
128
|
+
* `refresh()` DIFFS by object uuid rather than re-attaching: a rescan of an
|
|
129
|
+
* unchanged tree must not detach and re-attach every subject, because that
|
|
130
|
+
* would reset which clip each one is showing under a person's hands. The
|
|
131
|
+
* caller decides when to rescan (the stage host throttles it and skips it
|
|
132
|
+
* while the world is driving time).
|
|
133
|
+
*/
|
|
134
|
+
export interface ClipSubjectScan {
|
|
135
|
+
refresh(): void;
|
|
136
|
+
dispose(): void;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function scanClipSubjects(
|
|
140
|
+
root: THREE.Object3D | (() => THREE.Object3D | null),
|
|
141
|
+
transport: StageTransport,
|
|
142
|
+
options: {
|
|
143
|
+
/** Rescan when the game registers or drops a mixer, while this answers true. */
|
|
144
|
+
readonly rescanOnLiveMixers?: () => boolean;
|
|
145
|
+
} = {},
|
|
146
|
+
): ClipSubjectScan {
|
|
147
|
+
const attached = new Map<string, () => void>();
|
|
148
|
+
|
|
149
|
+
const refresh = (): void => {
|
|
150
|
+
const seen = new Set<string>();
|
|
151
|
+
// A getter for a stage whose rendered scene is not fixed at construction:
|
|
152
|
+
// a world stage draws the session store's scene once the world mounts.
|
|
153
|
+
const current = typeof root === 'function' ? root() : root;
|
|
154
|
+
current?.traverse((object) => {
|
|
155
|
+
if (attached.has(object.uuid)) {
|
|
156
|
+
seen.add(object.uuid);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// The inspection wins when an object has both: it means a world binding
|
|
160
|
+
// is already driving that skeleton, and a second mixer over it would
|
|
161
|
+
// give one object two writers.
|
|
162
|
+
const detach = attachInspected(object, transport) ?? attachOwnClips(object, transport);
|
|
163
|
+
if (!detach) return;
|
|
164
|
+
attached.set(object.uuid, detach);
|
|
165
|
+
seen.add(object.uuid);
|
|
166
|
+
});
|
|
167
|
+
for (const [uuid, detach] of [...attached]) {
|
|
168
|
+
if (seen.has(uuid)) continue;
|
|
169
|
+
detach();
|
|
170
|
+
attached.delete(uuid);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
refresh();
|
|
175
|
+
const rescanWhen = options.rescanOnLiveMixers;
|
|
176
|
+
const stopMixers = rescanWhen
|
|
177
|
+
? subscribeLiveMixers(() => {
|
|
178
|
+
if (rescanWhen()) refresh();
|
|
179
|
+
})
|
|
180
|
+
: () => {};
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
refresh,
|
|
184
|
+
dispose: () => {
|
|
185
|
+
stopMixers();
|
|
186
|
+
for (const detach of attached.values()) detach();
|
|
187
|
+
attached.clear();
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B8.4 — the Asset Lab COMPARE surface (`vgai screenshot <model.glb>
|
|
3
|
+
* --compare <ref.glb>`): renders the project asset AND a caller-supplied reference GLB
|
|
4
|
+
* with matched orthographic front + side framing, then scores their
|
|
5
|
+
* silhouettes (IoU) and composes review overlays, so an agent can
|
|
6
|
+
* numerically converge a procedural character toward a reference.
|
|
7
|
+
*
|
|
8
|
+
* Framing is DELIBERATELY bounding-box based (equal-height normalization,
|
|
9
|
+
* shared ground plane, centered footprint) rather than the humanoid shot
|
|
10
|
+
* set's Mixamo-skeleton anchors: the reference may carry any rig (UE joint
|
|
11
|
+
* names, no rig at all), and the bone-anchored path throws on missing joints
|
|
12
|
+
* by design. Both models are yaw-normalized to FACE the front camera —
|
|
13
|
+
* forward read from GLB `extras`/`userData.forward` when present (our
|
|
14
|
+
* generated rigs persist `[0,0,-1]`), else the glTF +Z convention, with an
|
|
15
|
+
* explicit reference override for models whose extras lie.
|
|
16
|
+
*
|
|
17
|
+
* Pure scoring math lives in `asset-compare-core.ts` (unit-tested in node);
|
|
18
|
+
* this module owns only loading, scene assembly, and WebGL capture. Fixed
|
|
19
|
+
* cameras + fixed resolution + flat unlit override ⇒ identical IoU numbers
|
|
20
|
+
* across runs (a stated acceptance gate).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { markHostRenderer } from '@volter/editor-threejs/viewport/renderer-ownership';
|
|
24
|
+
import * as THREE from 'three';
|
|
25
|
+
import {
|
|
26
|
+
ASSET_COMPARE_VIEWS,
|
|
27
|
+
type AssetCompareView,
|
|
28
|
+
COMPARE_FRAME_CENTER_Y,
|
|
29
|
+
COMPARE_FRAME_HALF_HEIGHT,
|
|
30
|
+
forwardYawRadians,
|
|
31
|
+
maskIoU,
|
|
32
|
+
normalizedPlacement,
|
|
33
|
+
overlayRgba,
|
|
34
|
+
silhouetteMaskFromRgba,
|
|
35
|
+
} from '@volter/editor-sdk/kit/asset-compare-core';
|
|
36
|
+
import {
|
|
37
|
+
checkedDimension,
|
|
38
|
+
createAssetPreviewSnapshot,
|
|
39
|
+
disposeAssetPreviewSnapshot,
|
|
40
|
+
disposeProjectAssetModel,
|
|
41
|
+
loadProjectAssetModel,
|
|
42
|
+
measureAssetPreview,
|
|
43
|
+
parseGlbBytesModel,
|
|
44
|
+
pngBase64,
|
|
45
|
+
readModelForward,
|
|
46
|
+
} from './asset-preview';
|
|
47
|
+
|
|
48
|
+
export type { AssetCompareView } from '@volter/editor-sdk/kit/asset-compare-core';
|
|
49
|
+
// The forward detection moved to `asset-preview.ts` (the shot sets need it
|
|
50
|
+
// too); re-exported here so compare-mode consumers keep their import path.
|
|
51
|
+
export { readModelForward } from './asset-preview';
|
|
52
|
+
|
|
53
|
+
export interface AssetCompareImage {
|
|
54
|
+
base64: string;
|
|
55
|
+
mimeType: 'image/png';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface AssetCompareViewResult {
|
|
59
|
+
view: AssetCompareView;
|
|
60
|
+
/** Silhouette intersection-over-union in [0, 1]. */
|
|
61
|
+
iou: number;
|
|
62
|
+
/** Both silhouettes in distinct colors (orange asset / cyan ref / white agreement). */
|
|
63
|
+
overlay: AssetCompareImage;
|
|
64
|
+
/** The asset's own silhouette (white on black), as captured for scoring. */
|
|
65
|
+
asset: AssetCompareImage;
|
|
66
|
+
/** The reference's silhouette (white on black), as captured for scoring. */
|
|
67
|
+
ref: AssetCompareImage;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface AssetCompareCapture {
|
|
71
|
+
width: number;
|
|
72
|
+
height: number;
|
|
73
|
+
views: AssetCompareViewResult[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface AssetCompareOptions {
|
|
77
|
+
width?: number;
|
|
78
|
+
height?: number;
|
|
79
|
+
/** Override the reference GLB's forward vector (its facing in the ground
|
|
80
|
+
* plane). Defaults to the GLB's own `userData.forward` extras, else +Z. */
|
|
81
|
+
refForward?: [number, number, number];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const SILHOUETTE_BACKGROUND = 0x000000;
|
|
85
|
+
const SILHOUETTE_FOREGROUND = 0xffffff;
|
|
86
|
+
const COMPARE_CAMERA_DISTANCE = 10;
|
|
87
|
+
|
|
88
|
+
/** The base64-GLB decoder moved to `asset-preview.ts` (`parseGlbBytesModel`)
|
|
89
|
+
* when the module-look lane needed the same wire-carried bytes; this keeps
|
|
90
|
+
* compare's own error wording. */
|
|
91
|
+
function parseRefGlb(base64: string): Promise<THREE.Object3D> {
|
|
92
|
+
return parseGlbBytesModel(base64, 'Compare reference GLB');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Snapshot + face-the-camera yaw + equal-height ground-aligned placement,
|
|
96
|
+
* as one disposable wrapper ready to drop into a silhouette scene. */
|
|
97
|
+
function buildNormalizedSubject(
|
|
98
|
+
source: THREE.Object3D,
|
|
99
|
+
forward: [number, number, number],
|
|
100
|
+
): { wrapper: THREE.Object3D; dispose: () => void } {
|
|
101
|
+
const snapshot = createAssetPreviewSnapshot(source);
|
|
102
|
+
const pivot = new THREE.Group();
|
|
103
|
+
pivot.rotation.y = forwardYawRadians(forward);
|
|
104
|
+
pivot.add(snapshot);
|
|
105
|
+
const wrapper = new THREE.Group();
|
|
106
|
+
wrapper.add(pivot);
|
|
107
|
+
wrapper.updateWorldMatrix(true, true);
|
|
108
|
+
const bounds = measureAssetPreview(wrapper, 'front');
|
|
109
|
+
const min: [number, number, number] = [
|
|
110
|
+
bounds.center.x - bounds.size.x / 2,
|
|
111
|
+
bounds.center.y - bounds.size.y / 2,
|
|
112
|
+
bounds.center.z - bounds.size.z / 2,
|
|
113
|
+
];
|
|
114
|
+
const max: [number, number, number] = [
|
|
115
|
+
bounds.center.x + bounds.size.x / 2,
|
|
116
|
+
bounds.center.y + bounds.size.y / 2,
|
|
117
|
+
bounds.center.z + bounds.size.z / 2,
|
|
118
|
+
];
|
|
119
|
+
const placement = normalizedPlacement(min, max);
|
|
120
|
+
wrapper.scale.setScalar(placement.scale);
|
|
121
|
+
wrapper.position.set(...placement.position);
|
|
122
|
+
wrapper.updateWorldMatrix(true, true);
|
|
123
|
+
return { wrapper, dispose: () => disposeAssetPreviewSnapshot(snapshot) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function compareCamera(view: AssetCompareView, aspect: number): THREE.OrthographicCamera {
|
|
127
|
+
const halfHeight = COMPARE_FRAME_HALF_HEIGHT;
|
|
128
|
+
const halfWidth = halfHeight * aspect;
|
|
129
|
+
const camera = new THREE.OrthographicCamera(
|
|
130
|
+
-halfWidth,
|
|
131
|
+
halfWidth,
|
|
132
|
+
halfHeight,
|
|
133
|
+
-halfHeight,
|
|
134
|
+
0.01,
|
|
135
|
+
COMPARE_CAMERA_DISTANCE * 4,
|
|
136
|
+
);
|
|
137
|
+
const direction = view === 'front' ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(1, 0, 0);
|
|
138
|
+
camera.position
|
|
139
|
+
.set(0, COMPARE_FRAME_CENTER_Y, 0)
|
|
140
|
+
.addScaledVector(direction, COMPARE_CAMERA_DISTANCE);
|
|
141
|
+
camera.up.set(0, 1, 0);
|
|
142
|
+
camera.lookAt(0, COMPARE_FRAME_CENTER_Y, 0);
|
|
143
|
+
camera.updateProjectionMatrix();
|
|
144
|
+
camera.updateMatrixWorld(true);
|
|
145
|
+
return camera;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface SilhouetteShot {
|
|
149
|
+
image: AssetCompareImage;
|
|
150
|
+
mask: Uint8Array;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Compare an already-loaded asset model against an already-loaded reference:
|
|
155
|
+
* the shared capture core behind both the asset-path and entity entry
|
|
156
|
+
* points. Neither input is mutated (both are snapshotted).
|
|
157
|
+
*/
|
|
158
|
+
export function captureAssetComparePreview(
|
|
159
|
+
assetSource: THREE.Object3D,
|
|
160
|
+
refSource: THREE.Object3D,
|
|
161
|
+
options: AssetCompareOptions = {},
|
|
162
|
+
): AssetCompareCapture {
|
|
163
|
+
const width = checkedDimension(options.width);
|
|
164
|
+
const height = checkedDimension(options.height);
|
|
165
|
+
const aspect = width / height;
|
|
166
|
+
const disposers: Array<() => void> = [];
|
|
167
|
+
let renderer: THREE.WebGLRenderer | null = null;
|
|
168
|
+
try {
|
|
169
|
+
const asset = buildNormalizedSubject(assetSource, readModelForward(assetSource));
|
|
170
|
+
disposers.push(asset.dispose);
|
|
171
|
+
const ref = buildNormalizedSubject(
|
|
172
|
+
refSource,
|
|
173
|
+
options.refForward ?? readModelForward(refSource),
|
|
174
|
+
);
|
|
175
|
+
disposers.push(ref.dispose);
|
|
176
|
+
|
|
177
|
+
const override = new THREE.MeshBasicMaterial({ color: SILHOUETTE_FOREGROUND });
|
|
178
|
+
disposers.push(() => override.dispose());
|
|
179
|
+
const sceneFor = (subject: THREE.Object3D): THREE.Scene => {
|
|
180
|
+
const scene = new THREE.Scene();
|
|
181
|
+
scene.background = new THREE.Color(SILHOUETTE_BACKGROUND);
|
|
182
|
+
scene.overrideMaterial = override;
|
|
183
|
+
scene.add(subject);
|
|
184
|
+
return scene;
|
|
185
|
+
};
|
|
186
|
+
const assetScene = sceneFor(asset.wrapper);
|
|
187
|
+
const refScene = sceneFor(ref.wrapper);
|
|
188
|
+
|
|
189
|
+
// Antialiasing off: silhouette masks want a crisp, deterministic
|
|
190
|
+
// coverage decision per pixel, not blended edge ramps.
|
|
191
|
+
const activeRenderer = markHostRenderer(
|
|
192
|
+
new THREE.WebGLRenderer({
|
|
193
|
+
antialias: false,
|
|
194
|
+
alpha: false,
|
|
195
|
+
preserveDrawingBuffer: true,
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
renderer = activeRenderer;
|
|
199
|
+
activeRenderer.setPixelRatio(1);
|
|
200
|
+
activeRenderer.toneMapping = THREE.NoToneMapping;
|
|
201
|
+
activeRenderer.setClearColor(SILHOUETTE_BACKGROUND, 1);
|
|
202
|
+
activeRenderer.setSize(width, height, false);
|
|
203
|
+
|
|
204
|
+
const readCanvas = document.createElement('canvas');
|
|
205
|
+
readCanvas.width = width;
|
|
206
|
+
readCanvas.height = height;
|
|
207
|
+
const readContext = readCanvas.getContext('2d');
|
|
208
|
+
if (!readContext) throw new Error('Unable to create the compare readback canvas.');
|
|
209
|
+
|
|
210
|
+
const captureSilhouette = (
|
|
211
|
+
scene: THREE.Scene,
|
|
212
|
+
camera: THREE.Camera,
|
|
213
|
+
label: string,
|
|
214
|
+
view: AssetCompareView,
|
|
215
|
+
): SilhouetteShot => {
|
|
216
|
+
activeRenderer.render(scene, camera);
|
|
217
|
+
readContext.clearRect(0, 0, width, height);
|
|
218
|
+
readContext.drawImage(activeRenderer.domElement, 0, 0);
|
|
219
|
+
const rgba = readContext.getImageData(0, 0, width, height).data;
|
|
220
|
+
const mask = silhouetteMaskFromRgba(rgba, width * height);
|
|
221
|
+
if (!mask.some((value) => value !== 0)) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Compare ${view} view rendered an empty ${label} silhouette — nothing to score.`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
image: { base64: pngBase64(readCanvas), mimeType: 'image/png' },
|
|
228
|
+
mask,
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const overlayCanvas = document.createElement('canvas');
|
|
233
|
+
overlayCanvas.width = width;
|
|
234
|
+
overlayCanvas.height = height;
|
|
235
|
+
const overlayContext = overlayCanvas.getContext('2d');
|
|
236
|
+
if (!overlayContext) throw new Error('Unable to create the compare overlay canvas.');
|
|
237
|
+
|
|
238
|
+
const views: AssetCompareViewResult[] = [];
|
|
239
|
+
for (const view of ASSET_COMPARE_VIEWS) {
|
|
240
|
+
const camera = compareCamera(view, aspect);
|
|
241
|
+
const assetShot = captureSilhouette(assetScene, camera, 'asset', view);
|
|
242
|
+
const refShot = captureSilhouette(refScene, camera, 'reference', view);
|
|
243
|
+
const stats = maskIoU(assetShot.mask, refShot.mask);
|
|
244
|
+
overlayContext.putImageData(
|
|
245
|
+
new ImageData(overlayRgba(assetShot.mask, refShot.mask), width, height),
|
|
246
|
+
0,
|
|
247
|
+
0,
|
|
248
|
+
);
|
|
249
|
+
views.push({
|
|
250
|
+
view,
|
|
251
|
+
iou: stats.iou,
|
|
252
|
+
overlay: { base64: pngBase64(overlayCanvas), mimeType: 'image/png' },
|
|
253
|
+
asset: assetShot.image,
|
|
254
|
+
ref: refShot.image,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return { width, height, views };
|
|
258
|
+
} finally {
|
|
259
|
+
renderer?.dispose();
|
|
260
|
+
renderer?.forceContextLoss();
|
|
261
|
+
for (const dispose of disposers.reverse()) dispose();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The `--asset <path> --compare` entry point: loads the project GLB the
|
|
266
|
+
* same bounded/validated way the other asset-path captures do, parses the
|
|
267
|
+
* relayed reference GLB bytes, and scores them. */
|
|
268
|
+
export async function captureModelComparePreview(
|
|
269
|
+
assetPath: string,
|
|
270
|
+
refGlbBase64: string,
|
|
271
|
+
options: AssetCompareOptions = {},
|
|
272
|
+
): Promise<AssetCompareCapture> {
|
|
273
|
+
const model = await loadProjectAssetModel(assetPath);
|
|
274
|
+
try {
|
|
275
|
+
return await captureEntityComparePreview(model, refGlbBase64, options);
|
|
276
|
+
} finally {
|
|
277
|
+
disposeProjectAssetModel(model);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The `--entity <id> --compare` entry point (also the shared tail of the
|
|
282
|
+
* asset-path leg): parses the reference and runs the capture core. */
|
|
283
|
+
export async function captureEntityComparePreview(
|
|
284
|
+
entity: THREE.Object3D,
|
|
285
|
+
refGlbBase64: string,
|
|
286
|
+
options: AssetCompareOptions = {},
|
|
287
|
+
): Promise<AssetCompareCapture> {
|
|
288
|
+
const refModel = await parseRefGlb(refGlbBase64);
|
|
289
|
+
try {
|
|
290
|
+
return captureAssetComparePreview(entity, refModel, options);
|
|
291
|
+
} finally {
|
|
292
|
+
disposeProjectAssetModel(refModel);
|
|
293
|
+
}
|
|
294
|
+
}
|