@volter/editor-threejs 0.5.66 → 0.5.68

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 (99) hide show
  1. package/contributions/animation-timeline.utility.tsx +44 -0
  2. package/contributions/three-integration.service.ts +13 -0
  3. package/package.json +96 -6
  4. package/src/adapter/renderer-config.ts +3 -4
  5. package/src/adapter/three-contract.ts +72 -0
  6. package/src/ecs/object-marks.ts +1 -1
  7. package/src/ecs/user-data.ts +0 -9
  8. package/src/host-hierarchy-objects.ts +31 -0
  9. package/src/kit/animation/three-clips-subject.ts +190 -0
  10. package/src/kit/asset-compare.ts +294 -0
  11. package/src/kit/asset-preview-command.ts +265 -0
  12. package/src/kit/asset-preview-framing.ts +357 -0
  13. package/src/kit/asset-preview.ts +2802 -0
  14. package/src/kit/asset-workflow/model-inspection.ts +830 -0
  15. package/src/kit/authoring/component-instance-root.ts +171 -0
  16. package/src/kit/authoring/design-time-settle.ts +343 -0
  17. package/src/kit/authoring/live-object-transform.ts +62 -0
  18. package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
  19. package/src/kit/authoring/object3d-document-session.ts +1965 -0
  20. package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
  21. package/src/kit/authoring/quarks-particle-systems.ts +19 -0
  22. package/src/kit/authoring/shell-viewport-policy.ts +48 -0
  23. package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
  24. package/src/kit/authoring/three-projection-core.ts +226 -0
  25. package/src/kit/authoring/viewport-pick-context.ts +39 -0
  26. package/src/kit/authoring/viewport-raycast.ts +240 -0
  27. package/src/kit/authoring/world-hidden-viewport.ts +95 -0
  28. package/src/kit/camera-authoring.ts +175 -0
  29. package/src/kit/components/CameraInfo.tsx +56 -0
  30. package/src/kit/components/InspectorObjectPreview.tsx +57 -0
  31. package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
  32. package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
  33. package/src/kit/components/StageHost.tsx +2547 -0
  34. package/src/kit/components/StageOverlays.tsx +21 -0
  35. package/src/kit/components/StatsOverlay.tsx +78 -0
  36. package/src/kit/components/ToolObject3DPreview.tsx +39 -0
  37. package/src/kit/components/ViewportFurniture.tsx +655 -0
  38. package/src/kit/components/ViewportOverlay.tsx +215 -0
  39. package/src/kit/components/ViewportShadingMenu.tsx +340 -0
  40. package/src/kit/components/ViewportViewMenu.tsx +155 -0
  41. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
  42. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  43. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
  44. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
  45. package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  46. package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
  47. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
  48. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  49. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
  50. package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
  51. package/src/kit/components/stage-keyboard.tsx +40 -0
  52. package/src/kit/components/stage-overlay-set.tsx +105 -0
  53. package/src/kit/components/stage-presence-markers.ts +482 -0
  54. package/src/kit/components/stage-transform-chrome.ts +30 -0
  55. package/src/kit/components/stage-transform-tools.tsx +73 -0
  56. package/src/kit/components/stage-view-name.ts +30 -0
  57. package/src/kit/components/standard-viewport-dressing.ts +1042 -0
  58. package/src/kit/components/world-root-binding.ts +64 -0
  59. package/src/kit/constraint-helper.ts +338 -0
  60. package/src/kit/editor-shell-store.ts +814 -0
  61. package/src/kit/editor-viewport.ts +6621 -0
  62. package/src/kit/entity-lod.ts +31 -0
  63. package/src/kit/entity-object.ts +92 -0
  64. package/src/kit/hierarchy-mark-reader.ts +74 -0
  65. package/src/kit/instanced-presentation.ts +164 -0
  66. package/src/kit/live-module-source.ts +230 -0
  67. package/src/kit/model-thumbnail.ts +539 -0
  68. package/src/kit/play-camera-flight.ts +300 -0
  69. package/src/kit/projection/three.ts +898 -0
  70. package/src/kit/reflection-probe-helper.ts +142 -0
  71. package/src/kit/scene-document-viewport.ts +51 -0
  72. package/src/kit/scene-framing.ts +315 -0
  73. package/src/kit/scene-view-fog.ts +89 -0
  74. package/src/kit/spatial-handle-visuals.ts +332 -0
  75. package/src/kit/stories/three-story-model.ts +66 -0
  76. package/src/kit/three-canvas-render.ts +44 -0
  77. package/src/kit/three-hierarchy-row-media.ts +26 -0
  78. package/src/kit/three-inspection-media.ts +73 -0
  79. package/src/kit/three-integration.ts +86 -0
  80. package/src/kit/three-state.ts +33 -0
  81. package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
  82. package/src/kit/three-viewport/camera-fit.ts +41 -0
  83. package/src/kit/three-viewport/interactive-renderer.ts +132 -0
  84. package/src/kit/three-viewport/selection-brackets.ts +355 -0
  85. package/src/kit/three-viewport/selection-outline.ts +333 -0
  86. package/src/kit/three-viewport/skeleton-helper.ts +61 -0
  87. package/src/kit/three-viewport/source-color.ts +197 -0
  88. package/src/kit/three-viewport/studio-environment.ts +96 -0
  89. package/src/kit/trigger-volume-helper.ts +116 -0
  90. package/src/kit/viewport-actions.ts +128 -0
  91. package/src/kit/viewport-authoring-policy.ts +154 -0
  92. package/src/kit/viewport-commands.ts +318 -0
  93. package/src/kit/viewport-hotkeys.ts +119 -0
  94. package/src/kit/viewport-shading-boundary.ts +12 -0
  95. package/src/kit/viewport-status-facet.ts +53 -0
  96. package/src/object3d-contributions.ts +494 -0
  97. package/src/render/viewport-shading.ts +6 -2
  98. package/src/viewport-api.ts +92 -0
  99. package/src/viewport-door.ts +237 -0
@@ -0,0 +1,444 @@
1
+ import { faCircleHalfStroke, faSliders } from '@fortawesome/free-solid-svg-icons';
2
+ import { Checkbox, NumberInput, themeVars } from '@volter/editor-sdk/widgets';
3
+ import { EffectComposer, EffectPass, LUT3DEffect, RenderPass } from 'postprocessing';
4
+ import { useEffect, useMemo, useRef, useState } from 'react';
5
+ import * as THREE from 'three';
6
+ import { LUTCubeLoader } from 'three/addons/loaders/LUTCubeLoader.js';
7
+ import { readProjectTextFile } from '@volter/editor-sdk/kit/editor-api';
8
+ import type { InspectionSection } from '@volter/editor-sdk/kit/inspection-model';
9
+ import { CONTRIBUTED_SECTION_ORDER, PROPERTIES_SECTION_ORDER } from '@volter/editor-sdk/kit/inspection-model';
10
+ import { AssetEditorShell } from '@volter/editor-sdk/kit/components/AssetEditorShell';
11
+ import { subscribeProjectAsset } from '@volter/editor-sdk/kit/components/asset-editor-persistence';
12
+
13
+ interface CubeLut {
14
+ readonly title: string | null;
15
+ readonly size: number;
16
+ readonly domainMin: THREE.Vector3;
17
+ readonly domainMax: THREE.Vector3;
18
+ readonly texture3D: THREE.Data3DTexture;
19
+ }
20
+
21
+ interface PreviewSettings {
22
+ readonly intensity: number;
23
+ readonly split: number;
24
+ readonly tetrahedral: boolean;
25
+ }
26
+
27
+ function basename(path: string): string {
28
+ return path.split('/').pop() ?? path;
29
+ }
30
+
31
+ function clamp(value: number, min: number, max: number): number {
32
+ return Math.min(max, Math.max(min, value));
33
+ }
34
+
35
+ function vectorLabel(value: THREE.Vector3): string {
36
+ return [value.x, value.y, value.z].map((component) => component.toFixed(2)).join(', ');
37
+ }
38
+
39
+ function buildComparisonScene(): {
40
+ scene: THREE.Scene;
41
+ camera: THREE.PerspectiveCamera;
42
+ dispose: () => void;
43
+ } {
44
+ const scene = new THREE.Scene();
45
+ scene.background = new THREE.Color('#171a22');
46
+ const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100);
47
+ camera.position.set(6.6, 4.6, 8.8);
48
+ camera.lookAt(0, 0.7, 0);
49
+
50
+ scene.add(new THREE.HemisphereLight('#dce8ff', '#322a27', 2.1));
51
+ const key = new THREE.DirectionalLight('#fff3db', 3.8);
52
+ key.position.set(4, 7, 5);
53
+ scene.add(key);
54
+
55
+ const geometries: THREE.BufferGeometry[] = [];
56
+ const materials: THREE.Material[] = [];
57
+ const addMesh = (
58
+ geometry: THREE.BufferGeometry,
59
+ material: THREE.Material,
60
+ position: readonly [number, number, number],
61
+ ): THREE.Mesh => {
62
+ geometries.push(geometry);
63
+ materials.push(material);
64
+ const mesh = new THREE.Mesh(geometry, material);
65
+ mesh.position.set(...position);
66
+ mesh.castShadow = true;
67
+ mesh.receiveShadow = true;
68
+ scene.add(mesh);
69
+ return mesh;
70
+ };
71
+
72
+ const swatches = [
73
+ ['#e64d45', -2.4],
74
+ ['#e5ad42', -1.2],
75
+ ['#55b86a', 0],
76
+ ['#47a9d8', 1.2],
77
+ ['#8a68d7', 2.4],
78
+ ] as const;
79
+ for (const [color, x] of swatches) {
80
+ addMesh(
81
+ new THREE.SphereGeometry(0.48, 48, 32),
82
+ new THREE.MeshStandardMaterial({ color, roughness: 0.3, metalness: 0.08 }),
83
+ [x, 0.65, 0],
84
+ );
85
+ }
86
+
87
+ const neutralValues = [0.06, 0.18, 0.5, 0.82, 1];
88
+ neutralValues.forEach((value, index) => {
89
+ const channel = Math.round(value * 255);
90
+ addMesh(
91
+ new THREE.BoxGeometry(0.78, 0.78, 0.78),
92
+ new THREE.MeshStandardMaterial({
93
+ color: new THREE.Color(`rgb(${channel}, ${channel}, ${channel})`),
94
+ roughness: 0.72,
95
+ }),
96
+ [-2.4 + index * 1.2, 1.85, -0.1],
97
+ );
98
+ });
99
+
100
+ const floor = addMesh(
101
+ new THREE.PlaneGeometry(9, 6),
102
+ new THREE.MeshStandardMaterial({ color: '#777b86', roughness: 0.92 }),
103
+ [0, 0.08, -1.8],
104
+ );
105
+ floor.rotation.x = -Math.PI / 2;
106
+
107
+ return {
108
+ scene,
109
+ camera,
110
+ dispose: () => {
111
+ for (const geometry of geometries) geometry.dispose();
112
+ for (const material of materials) material.dispose();
113
+ },
114
+ };
115
+ }
116
+
117
+ function LutPreview({
118
+ lut,
119
+ settingsRef,
120
+ }: {
121
+ lut: CubeLut;
122
+ settingsRef: React.RefObject<PreviewSettings>;
123
+ }) {
124
+ const hostRef = useRef<HTMLDivElement>(null);
125
+
126
+ useEffect(() => {
127
+ const host = hostRef.current;
128
+ if (!host) return;
129
+ let disposed = false;
130
+ let frame = 0;
131
+ const canvas = document.createElement('canvas');
132
+ canvas.setAttribute('aria-label', 'LUT before and after preview');
133
+ canvas.style.width = '100%';
134
+ canvas.style.height = '100%';
135
+ canvas.style.display = 'block';
136
+ host.replaceChildren(canvas);
137
+
138
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
139
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
140
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
141
+ renderer.toneMappingExposure = 1;
142
+ renderer.shadowMap.enabled = true;
143
+
144
+ const comparison = buildComparisonScene();
145
+ const composer = new EffectComposer(renderer);
146
+ const lutEffect = new LUT3DEffect(lut.texture3D, { tetrahedralInterpolation: true });
147
+ composer.addPass(new RenderPass(comparison.scene, comparison.camera));
148
+ composer.addPass(new EffectPass(comparison.camera, lutEffect));
149
+
150
+ const size = { width: 1, height: 1 };
151
+ const draw = () => {
152
+ const settings = settingsRef.current;
153
+ const divider = Math.round(size.width * clamp(settings.split, 0.05, 0.95));
154
+ lutEffect.blendMode.opacity.value = clamp(settings.intensity, 0, 1);
155
+ lutEffect.tetrahedralInterpolation = settings.tetrahedral;
156
+
157
+ renderer.setScissorTest(false);
158
+ renderer.setRenderTarget(null);
159
+ renderer.clear();
160
+ renderer.setScissorTest(true);
161
+ renderer.setScissor(0, 0, divider, size.height);
162
+ renderer.render(comparison.scene, comparison.camera);
163
+ renderer.setScissor(divider, 0, size.width - divider, size.height);
164
+ composer.render();
165
+ renderer.setScissorTest(false);
166
+ };
167
+ const resize = () => {
168
+ const bounds = host.getBoundingClientRect();
169
+ size.width = Math.max(1, Math.floor(bounds.width));
170
+ size.height = Math.max(1, Math.floor(bounds.height));
171
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
172
+ renderer.setSize(size.width, size.height, false);
173
+ composer.setSize(size.width, size.height);
174
+ comparison.camera.aspect = size.width / size.height;
175
+ comparison.camera.updateProjectionMatrix();
176
+ };
177
+ resize();
178
+ draw();
179
+ const observer = new ResizeObserver(() => {
180
+ resize();
181
+ // ResizeObserver still fires for a hidden tab; repaint the still frame
182
+ // because requestAnimationFrame may remain suspended there.
183
+ draw();
184
+ });
185
+ observer.observe(host);
186
+
187
+ const render = () => {
188
+ if (disposed) return;
189
+ draw();
190
+ frame = requestAnimationFrame(render);
191
+ };
192
+ frame = requestAnimationFrame(render);
193
+
194
+ return () => {
195
+ disposed = true;
196
+ cancelAnimationFrame(frame);
197
+ observer.disconnect();
198
+ composer.dispose();
199
+ lutEffect.dispose();
200
+ comparison.dispose();
201
+ renderer.dispose();
202
+ canvas.remove();
203
+ };
204
+ }, [lut, settingsRef]);
205
+
206
+ return (
207
+ <div
208
+ ref={hostRef}
209
+ data-testid="lut-preview"
210
+ style={{ width: '100%', height: '100%', minHeight: 260, background: '#171a22' }}
211
+ />
212
+ );
213
+ }
214
+
215
+ function PreviewControls({
216
+ settings,
217
+ onChange,
218
+ }: {
219
+ settings: PreviewSettings;
220
+ onChange: (next: PreviewSettings) => void;
221
+ }) {
222
+ return (
223
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 4 }}>
224
+ <label style={{ display: 'flex', flexDirection: 'column', gap: 5, fontSize: 11 }}>
225
+ Intensity
226
+ <NumberInput
227
+ value={settings.intensity}
228
+ onChange={(intensity) => onChange({ ...settings, intensity: clamp(intensity, 0, 1) })}
229
+ step={0.01}
230
+ precision={2}
231
+ />
232
+ </label>
233
+ <label style={{ display: 'flex', flexDirection: 'column', gap: 5, fontSize: 11 }}>
234
+ Comparison split
235
+ <NumberInput
236
+ value={settings.split}
237
+ onChange={(split) => onChange({ ...settings, split: clamp(split, 0.05, 0.95) })}
238
+ step={0.01}
239
+ precision={2}
240
+ />
241
+ </label>
242
+ <label style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 11 }}>
243
+ <Checkbox
244
+ checked={settings.tetrahedral}
245
+ onChange={(event) => onChange({ ...settings, tetrahedral: event.target.checked })}
246
+ />
247
+ Tetrahedral interpolation
248
+ </label>
249
+ <div style={{ color: themeVars.content.muted, fontSize: 10, lineHeight: 1.45 }}>
250
+ Preview controls are session-only. Bind the LUT and accepted intensity in the owning TSX
251
+ post-processing composition.
252
+ </div>
253
+ </div>
254
+ );
255
+ }
256
+
257
+ async function loadCubeLut(assetPath: string): Promise<CubeLut> {
258
+ const source = await readProjectTextFile(assetPath);
259
+ if (source === null) throw new Error(`CUBE LUT not found: ${assetPath}`);
260
+ return new LUTCubeLoader().parse(source) as CubeLut;
261
+ }
262
+
263
+ export function LutAssetDocument({
264
+ documentId,
265
+ assetPath,
266
+ active = true,
267
+ }: {
268
+ documentId: string;
269
+ assetPath: string;
270
+ active?: boolean;
271
+ }) {
272
+ const [revision, setRevision] = useState(0);
273
+ const [lut, setLut] = useState<CubeLut | null>(null);
274
+ const [loadError, setLoadError] = useState<string | null>(null);
275
+ const [settings, setSettings] = useState<PreviewSettings>({
276
+ intensity: 1,
277
+ split: 0.5,
278
+ tetrahedral: true,
279
+ });
280
+ const settingsRef = useRef(settings);
281
+ settingsRef.current = settings;
282
+
283
+ useEffect(() => {
284
+ if (!active) return;
285
+ return subscribeProjectAsset(assetPath, () => setRevision((value) => value + 1));
286
+ }, [active, assetPath]);
287
+
288
+ useEffect(() => {
289
+ if (!active) return;
290
+ let cancelled = false;
291
+ let owned: CubeLut | null = null;
292
+ setLut(null);
293
+ setLoadError(null);
294
+ void loadCubeLut(assetPath).then(
295
+ (next) => {
296
+ owned = next;
297
+ if (cancelled) {
298
+ next.texture3D.dispose();
299
+ return;
300
+ }
301
+ setLut(next);
302
+ },
303
+ (cause) => {
304
+ if (!cancelled) setLoadError(cause instanceof Error ? cause.message : String(cause));
305
+ },
306
+ );
307
+ return () => {
308
+ cancelled = true;
309
+ owned?.texture3D.dispose();
310
+ };
311
+ }, [active, assetPath, revision]);
312
+
313
+ const sections = useMemo<InspectionSection[]>(
314
+ () => [
315
+ {
316
+ id: 'lut-profile',
317
+ title: 'Color Grade',
318
+ icon: faCircleHalfStroke,
319
+ order: PROPERTIES_SECTION_ORDER,
320
+ body: {
321
+ kind: 'custom',
322
+ ...(lut
323
+ ? {
324
+ data: {
325
+ title: lut.title,
326
+ size: lut.size,
327
+ domainMin: lut.domainMin.toArray(),
328
+ domainMax: lut.domainMax.toArray(),
329
+ },
330
+ }
331
+ : {}),
332
+ render: () => (
333
+ <div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '6px 10px' }}>
334
+ <span style={{ color: themeVars.content.muted }}>Title</span>
335
+ <span>{lut?.title || basename(assetPath)}</span>
336
+ <span style={{ color: themeVars.content.muted }}>Cube</span>
337
+ <span>{lut ? `${lut.size}³` : '—'}</span>
338
+ <span style={{ color: themeVars.content.muted }}>Domain</span>
339
+ <span>
340
+ {lut ? `${vectorLabel(lut.domainMin)} → ${vectorLabel(lut.domainMax)}` : '—'}
341
+ </span>
342
+ </div>
343
+ ),
344
+ },
345
+ },
346
+ {
347
+ id: 'lut-preview-settings',
348
+ title: 'Preview',
349
+ icon: faSliders,
350
+ order: CONTRIBUTED_SECTION_ORDER,
351
+ body: {
352
+ kind: 'custom',
353
+ data: { ...settings },
354
+ render: () => <PreviewControls settings={settings} onChange={setSettings} />,
355
+ },
356
+ },
357
+ ],
358
+ [assetPath, lut, settings],
359
+ );
360
+
361
+ if (loadError) {
362
+ return (
363
+ <AssetEditorShell
364
+ documentId={documentId}
365
+ active={active}
366
+ type="color grade"
367
+ title={basename(assetPath)}
368
+ status="CUBE LUT unavailable"
369
+ >
370
+ <div role="alert" style={{ padding: 20, color: themeVars.semantic.danger }}>
371
+ {loadError}
372
+ </div>
373
+ </AssetEditorShell>
374
+ );
375
+ }
376
+
377
+ if (!lut) {
378
+ return (
379
+ <AssetEditorShell
380
+ documentId={documentId}
381
+ active={active}
382
+ type="color grade"
383
+ title={basename(assetPath)}
384
+ status="Loading CUBE LUT"
385
+ >
386
+ <div style={{ padding: 20, color: themeVars.content.muted }}>Loading color grade…</div>
387
+ </AssetEditorShell>
388
+ );
389
+ }
390
+
391
+ return (
392
+ <AssetEditorShell
393
+ documentId={documentId}
394
+ active={active}
395
+ type="color grade"
396
+ title={basename(assetPath)}
397
+ sections={sections}
398
+ status={`CUBE LUT · ${lut.size}³`}
399
+ fill
400
+ >
401
+ <div
402
+ style={{
403
+ position: 'relative',
404
+ width: '100%',
405
+ height: '100%',
406
+ minHeight: 0,
407
+ background: '#171a22',
408
+ }}
409
+ >
410
+ <LutPreview lut={lut} settingsRef={settingsRef} />
411
+ <div
412
+ style={{
413
+ position: 'absolute',
414
+ left: 12,
415
+ top: 12,
416
+ padding: '4px 7px',
417
+ borderRadius: 4,
418
+ background: 'rgba(12,14,20,0.72)',
419
+ color: '#f2f4f8',
420
+ fontSize: 10,
421
+ pointerEvents: 'none',
422
+ }}
423
+ >
424
+ Original
425
+ </div>
426
+ <div
427
+ style={{
428
+ position: 'absolute',
429
+ right: 12,
430
+ top: 12,
431
+ padding: '4px 7px',
432
+ borderRadius: 4,
433
+ background: 'rgba(12,14,20,0.72)',
434
+ color: '#f2f4f8',
435
+ fontSize: 10,
436
+ pointerEvents: 'none',
437
+ }}
438
+ >
439
+ {lut.title || 'Graded'}
440
+ </div>
441
+ </div>
442
+ </AssetEditorShell>
443
+ );
444
+ }
@@ -0,0 +1,105 @@
1
+ import { themeVars } from '@volter/editor-sdk/widgets';
2
+ import { useCallback, useEffect, useState } from 'react';
3
+ import type * as THREE from 'three';
4
+ import {
5
+ disposeProjectAssetModel,
6
+ loadProjectAssetModelSource,
7
+ type ProjectAssetModelSource,
8
+ } from '../../asset-preview';
9
+ import {
10
+ createModelPreviewSource,
11
+ loadModelThumbnailObject,
12
+ modelThumbnailFormat,
13
+ } from '../../model-thumbnail';
14
+ import { Object3DDocumentViewport } from '../StageHost';
15
+
16
+ /**
17
+ * File-backed models use the same native Object3D document host as source
18
+ * factories. Asset-specific diagnostics and provenance live in Inspector
19
+ * contributions; this center document owns only loading and native lifetime.
20
+ *
21
+ * A GLB that carries clips also gets a WRITER: the surgical clip persistence
22
+ * appends accessors to the file's own BIN chunk, so editing a key in the
23
+ * animation workspace saves back into the asset instead of being read-only.
24
+ * When that binding refuses, its reason — not a generic one — is what the
25
+ * workspace shows.
26
+ */
27
+ export function ModelAssetDocument({
28
+ documentId,
29
+ assetPath,
30
+ displayName,
31
+ active,
32
+ }: {
33
+ readonly documentId: string;
34
+ readonly assetPath: string;
35
+ readonly displayName: string;
36
+ readonly active: boolean;
37
+ }) {
38
+ const [source, setSource] = useState<ProjectAssetModelSource | null>(null);
39
+ const [error, setError] = useState<string | null>(null);
40
+
41
+ useEffect(() => {
42
+ if (!active) {
43
+ setSource(null);
44
+ return;
45
+ }
46
+ const controller = new AbortController();
47
+ let owned: THREE.Object3D | null = null;
48
+ setSource(null);
49
+ setError(null);
50
+ const format = modelThumbnailFormat(assetPath);
51
+ const loading: Promise<ProjectAssetModelSource> =
52
+ format === 'glb' || format === 'gltf' || format === 'spz'
53
+ ? loadProjectAssetModelSource(assetPath, controller.signal)
54
+ : format
55
+ ? loadModelThumbnailObject({ url: assetPath, format })
56
+ .then(createModelPreviewSource)
57
+ .then((root) => ({ root, sourceBytes: null }))
58
+ : Promise.reject(new Error(`No native Three.js model loader supports ${assetPath}.`));
59
+ void loading.then(
60
+ (loaded) => {
61
+ if (controller.signal.aborted) {
62
+ disposeProjectAssetModel(loaded.root);
63
+ return;
64
+ }
65
+ owned = loaded.root;
66
+ setSource(loaded);
67
+ },
68
+ (cause) => {
69
+ if (!controller.signal.aborted)
70
+ setError(cause instanceof Error ? cause.message : String(cause));
71
+ },
72
+ );
73
+ return () => {
74
+ controller.abort();
75
+ if (owned) disposeProjectAssetModel(owned);
76
+ };
77
+ }, [active, assetPath]);
78
+
79
+ const model = source?.root ?? null;
80
+ const build = useCallback(() => {
81
+ if (!model) throw new Error(`Model asset ${assetPath} is not loaded.`);
82
+ return { root: model, animations: model.animations, dispose() {} };
83
+ }, [assetPath, model]);
84
+ if (error)
85
+ return (
86
+ <div role="alert" style={{ padding: 20, color: themeVars.semantic.danger }}>
87
+ {error}
88
+ </div>
89
+ );
90
+ if (!model)
91
+ return (
92
+ <div style={{ padding: 20, color: themeVars.content.muted }}>Loading {displayName}…</div>
93
+ );
94
+ return (
95
+ <Object3DDocumentViewport
96
+ documentId={documentId}
97
+ sourcePath={assetPath}
98
+ displayName={displayName}
99
+ build={build}
100
+ active={active}
101
+ assetType="model"
102
+ modelSource={{ kind: 'project-file', path: assetPath }}
103
+ />
104
+ );
105
+ }