@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.
Files changed (112) hide show
  1. package/NOTICE +2 -0
  2. package/contributions/animation-mixers.service.ts +20 -0
  3. package/contributions/animation-timeline.utility.tsx +44 -0
  4. package/contributions/three-integration.service.ts +13 -0
  5. package/dist-node/serving.mjs +405 -0
  6. package/package.json +113 -5
  7. package/serving/animation-live-module.ts +70 -0
  8. package/serving/animation-stamp.ts +88 -0
  9. package/serving/index.ts +14 -0
  10. package/serving/model-import-conversion.ts +344 -0
  11. package/src/adapter/ingest/scene-capture.ts +1 -23
  12. package/src/adapter/renderer-config.ts +3 -4
  13. package/src/adapter/three-contract.ts +72 -0
  14. package/src/animation/live-mixers.ts +55 -0
  15. package/src/ecs/object-marks.ts +1 -1
  16. package/src/ecs/user-data.ts +0 -16
  17. package/src/host-hierarchy-objects.ts +31 -0
  18. package/src/kit/animation/three-clips-subject.ts +190 -0
  19. package/src/kit/asset-compare.ts +294 -0
  20. package/src/kit/asset-preview-command.ts +265 -0
  21. package/src/kit/asset-preview-framing.ts +357 -0
  22. package/src/kit/asset-preview.ts +2802 -0
  23. package/src/kit/asset-workflow/model-inspection.ts +830 -0
  24. package/src/kit/authoring/component-instance-root.ts +171 -0
  25. package/src/kit/authoring/design-time-settle.ts +343 -0
  26. package/src/kit/authoring/live-object-transform.ts +62 -0
  27. package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
  28. package/src/kit/authoring/object3d-document-session.ts +1965 -0
  29. package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
  30. package/src/kit/authoring/quarks-particle-systems.ts +19 -0
  31. package/src/kit/authoring/shell-viewport-policy.ts +48 -0
  32. package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
  33. package/src/kit/authoring/three-projection-core.ts +226 -0
  34. package/src/kit/authoring/viewport-pick-context.ts +39 -0
  35. package/src/kit/authoring/viewport-raycast.ts +240 -0
  36. package/src/kit/authoring/world-hidden-viewport.ts +95 -0
  37. package/src/kit/camera-authoring.ts +175 -0
  38. package/src/kit/components/CameraInfo.tsx +56 -0
  39. package/src/kit/components/InspectorObjectPreview.tsx +57 -0
  40. package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
  41. package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
  42. package/src/kit/components/StageHost.tsx +2547 -0
  43. package/src/kit/components/StageOverlays.tsx +21 -0
  44. package/src/kit/components/StatsOverlay.tsx +78 -0
  45. package/src/kit/components/ToolObject3DPreview.tsx +39 -0
  46. package/src/kit/components/ViewportFurniture.tsx +655 -0
  47. package/src/kit/components/ViewportOverlay.tsx +215 -0
  48. package/src/kit/components/ViewportShadingMenu.tsx +340 -0
  49. package/src/kit/components/ViewportViewMenu.tsx +155 -0
  50. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
  51. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  52. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
  53. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
  54. package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  55. package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
  56. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
  57. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  58. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
  59. package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
  60. package/src/kit/components/stage-keyboard.tsx +40 -0
  61. package/src/kit/components/stage-overlay-set.tsx +105 -0
  62. package/src/kit/components/stage-presence-markers.ts +482 -0
  63. package/src/kit/components/stage-transform-chrome.ts +30 -0
  64. package/src/kit/components/stage-transform-tools.tsx +73 -0
  65. package/src/kit/components/stage-view-name.ts +30 -0
  66. package/src/kit/components/standard-viewport-dressing.ts +1042 -0
  67. package/src/kit/components/world-root-binding.ts +64 -0
  68. package/src/kit/constraint-helper.ts +338 -0
  69. package/src/kit/editor-shell-store.ts +814 -0
  70. package/src/kit/editor-viewport.ts +6621 -0
  71. package/src/kit/entity-lod.ts +31 -0
  72. package/src/kit/entity-object.ts +92 -0
  73. package/src/kit/hierarchy-mark-reader.ts +74 -0
  74. package/src/kit/instanced-presentation.ts +164 -0
  75. package/src/kit/live-module-source.ts +230 -0
  76. package/src/kit/model-thumbnail.ts +539 -0
  77. package/src/kit/play-camera-flight.ts +300 -0
  78. package/src/kit/projection/three.ts +898 -0
  79. package/src/kit/reflection-probe-helper.ts +142 -0
  80. package/src/kit/scene-document-viewport.ts +51 -0
  81. package/src/kit/scene-framing.ts +315 -0
  82. package/src/kit/scene-view-fog.ts +89 -0
  83. package/src/kit/spatial-handle-visuals.ts +332 -0
  84. package/src/kit/stories/three-story-model.ts +66 -0
  85. package/src/kit/three-canvas-render.ts +44 -0
  86. package/src/kit/three-hierarchy-row-media.ts +26 -0
  87. package/src/kit/three-inspection-media.ts +73 -0
  88. package/src/kit/three-integration.ts +86 -0
  89. package/src/kit/three-state.ts +33 -0
  90. package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
  91. package/src/kit/three-viewport/camera-fit.ts +41 -0
  92. package/src/kit/three-viewport/interactive-renderer.ts +132 -0
  93. package/src/kit/three-viewport/selection-brackets.ts +355 -0
  94. package/src/kit/three-viewport/selection-outline.ts +333 -0
  95. package/src/kit/three-viewport/skeleton-helper.ts +61 -0
  96. package/src/kit/three-viewport/source-color.ts +197 -0
  97. package/src/kit/three-viewport/studio-environment.ts +96 -0
  98. package/src/kit/trigger-volume-helper.ts +116 -0
  99. package/src/kit/viewport-actions.ts +128 -0
  100. package/src/kit/viewport-authoring-policy.ts +154 -0
  101. package/src/kit/viewport-commands.ts +318 -0
  102. package/src/kit/viewport-hotkeys.ts +119 -0
  103. package/src/kit/viewport-shading-boundary.ts +12 -0
  104. package/src/kit/viewport-status-facet.ts +53 -0
  105. package/src/object3d-contributions.ts +494 -0
  106. package/src/render/viewport-shading.ts +6 -2
  107. package/src/viewport/content-bounds.ts +38 -4
  108. package/src/viewport/environment.ts +16 -0
  109. package/src/viewport-api.ts +92 -0
  110. package/src/viewport-door.ts +237 -0
  111. package/src/animation/animation-clock.ts +0 -479
  112. 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
+ }
@@ -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
- // (`host.viewport.setHelper`): the host lists the kinds it toggles by name,
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
 
@@ -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
+ }