@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,539 @@
1
+ /**
2
+ * Offscreen Three.js renderer that generates thumbnail images for supported 3D source formats.
3
+ *
4
+ * Usage:
5
+ * const renderer = getModelThumbnailRenderer();
6
+ * const dataUrl = await renderer.render('/assets/models/Robot.glb');
7
+ * // dataUrl is a PNG data-URL string suitable for <img src="...">
8
+ *
9
+ * - Singleton: one renderer is shared across all callers.
10
+ * - Deduplicating: concurrent requests for the same URL share one render pass.
11
+ * - Cached: once rendered, the data URL is returned immediately.
12
+ * - Self-cleaning: each loaded object is disposed after the thumbnail is captured.
13
+ */
14
+
15
+ import { registerAssetThumbnailRenderer } from '@volter/editor-sdk/kit/asset-thumbnails';
16
+ import type { SparkRenderer, SplatMesh } from '@sparkjsdev/spark';
17
+ import { markHostRenderer } from '@volter/editor-threejs/viewport/renderer-ownership';
18
+ import { loadSplat } from '@volter/editor-threejs/asset-loaders';
19
+ import { hasUserData } from '@volter/editor-threejs/ecs/user-data';
20
+ import { gltfLoader } from '@volter/editor-threejs/loader';
21
+ import { disposeSparkRendererWhenIdle } from '@volter/editor-threejs/render/spark-renderer-lifecycle';
22
+ import * as THREE from 'three';
23
+ import {
24
+ applyStudioEnvironment,
25
+ STUDIO_AMBIENT_WITH_ENVIRONMENT,
26
+ } from './three-viewport/studio-environment';
27
+
28
+ const SIZE = 128;
29
+
30
+ let instance: ModelThumbnailRenderer | null = null;
31
+
32
+ export type ModelThumbnailFormat =
33
+ | 'glb'
34
+ | 'gltf'
35
+ | 'fbx'
36
+ | 'obj'
37
+ | 'dae'
38
+ | 'stl'
39
+ | 'ply'
40
+ | 'spz'
41
+ | 'bvh'
42
+ | '3ds';
43
+
44
+ export interface ModelThumbnailSpec {
45
+ url: string;
46
+ format?: ModelThumbnailFormat;
47
+ materialUrl?: string;
48
+ background?: 'transparent' | 'neutral';
49
+ output?: 'png' | 'webp';
50
+ }
51
+
52
+ const MODEL_THUMBNAIL_FORMATS = new Set<ModelThumbnailFormat>([
53
+ 'glb',
54
+ 'gltf',
55
+ 'fbx',
56
+ 'obj',
57
+ 'dae',
58
+ 'stl',
59
+ 'ply',
60
+ 'spz',
61
+ 'bvh',
62
+ '3ds',
63
+ ]);
64
+
65
+ export function modelThumbnailFormat(url: string): ModelThumbnailFormat | undefined {
66
+ const pathname = url.split(/[?#]/, 1)[0]!.toLowerCase();
67
+ const extension = pathname.slice(pathname.lastIndexOf('.') + 1) as ModelThumbnailFormat;
68
+ return MODEL_THUMBNAIL_FORMATS.has(extension) ? extension : undefined;
69
+ }
70
+
71
+ export class ModelThumbnailRenderer {
72
+ private renderer: THREE.WebGLRenderer;
73
+ private scene: THREE.Scene;
74
+ private camera: THREE.PerspectiveCamera;
75
+ private cache = new Map<string, string>();
76
+ private pending = new Map<string, Promise<string>>();
77
+ private sparkRenderer: SparkRenderer | null = null;
78
+ constructor() {
79
+ const canvas = document.createElement('canvas');
80
+ canvas.width = SIZE;
81
+ canvas.height = SIZE;
82
+
83
+ this.renderer = markHostRenderer(
84
+ new THREE.WebGLRenderer({
85
+ canvas,
86
+ alpha: true,
87
+ antialias: true,
88
+ preserveDrawingBuffer: true,
89
+ }),
90
+ );
91
+ this.renderer.setSize(SIZE, SIZE);
92
+ this.renderer.setPixelRatio(1);
93
+ this.renderer.outputColorSpace = THREE.SRGBColorSpace;
94
+ this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
95
+ this.renderer.toneMappingExposure = 1.0;
96
+
97
+ this.scene = new THREE.Scene();
98
+
99
+ // The neutral studio IBL, so a PBR metal has something to reflect — a
100
+ // `metalness: 1` material has no diffuse term and thumbnails it black
101
+ // otherwise. `scene.background` is untouched: the thumbnail canvas stays
102
+ // alpha, as every caller composites it. The bake lives as long as this
103
+ // renderer does (there is no teardown path for either — one instance,
104
+ // held for the editor's lifetime by `getModelThumbnailRenderer`).
105
+ applyStudioEnvironment(this.scene, this.renderer);
106
+
107
+ // Soft ambient fill, scaled back now that the environment carries the
108
+ // ambient term properly.
109
+ this.scene.add(new THREE.AmbientLight(0xffffff, 0.8 * STUDIO_AMBIENT_WITH_ENVIRONMENT));
110
+
111
+ // Key light (upper-right-front)
112
+ const key = new THREE.DirectionalLight(0xffffff, 1.5);
113
+ key.position.set(3, 5, 4);
114
+ this.scene.add(key);
115
+
116
+ // Fill light (lower-left, dimmer, warm)
117
+ const fill = new THREE.DirectionalLight(0xffeedd, 0.4);
118
+ fill.position.set(-3, -1, 2);
119
+ this.scene.add(fill);
120
+
121
+ this.camera = new THREE.PerspectiveCamera(35, 1, 0.01, 1000);
122
+ }
123
+
124
+ /** Render a thumbnail for the given model URL. Returns a PNG data-URL. */
125
+ async render(input: string | ModelThumbnailSpec): Promise<string> {
126
+ const spec: ModelThumbnailSpec = typeof input === 'string' ? { url: input } : input;
127
+ const format = spec.format ?? modelThumbnailFormat(spec.url);
128
+ if (!format) throw new Error(`No thumbnail loader for ${spec.url}`);
129
+ const resolved: ModelThumbnailSpec & { format: ModelThumbnailFormat } = { ...spec, format };
130
+ const key = JSON.stringify(resolved);
131
+ const cached = this.cache.get(key);
132
+ if (cached) return cached;
133
+
134
+ // Deduplicate concurrent requests for the same URL
135
+ const inflight = this.pending.get(key);
136
+ if (inflight) return inflight;
137
+
138
+ const promise = this._render(resolved);
139
+ this.pending.set(key, promise);
140
+ try {
141
+ const dataUrl = await promise;
142
+ this.cache.set(key, dataUrl);
143
+ return dataUrl;
144
+ } finally {
145
+ this.pending.delete(key);
146
+ }
147
+ }
148
+
149
+ private async _render(
150
+ spec: ModelThumbnailSpec & { format: ModelThumbnailFormat },
151
+ ): Promise<string> {
152
+ const group = createModelPreviewSource(await loadModelThumbnailObject(spec));
153
+
154
+ this.scene.add(group);
155
+ if (spec.format === 'spz' && !this.sparkRenderer) {
156
+ const { SparkRenderer } = await import('@sparkjsdev/spark');
157
+ this.sparkRenderer = new SparkRenderer({ renderer: this.renderer, enableLod: false });
158
+ this.scene.add(this.sparkRenderer);
159
+ }
160
+
161
+ // Auto-frame the camera around the model
162
+ const box = modelPreviewBounds(group);
163
+ if (box.isEmpty()) {
164
+ this.scene.remove(group);
165
+ throw new Error('Model contains no previewable geometry.');
166
+ }
167
+ const center = box.getCenter(new THREE.Vector3());
168
+ const sphere = box.getBoundingSphere(new THREE.Sphere());
169
+ const fovRad = (this.camera.fov * Math.PI) / 180;
170
+ const dist = (Math.max(sphere.radius, 0.0001) / Math.sin(fovRad / 2)) * 1.18;
171
+ const direction = new THREE.Vector3(0.65, 0.45, 0.65).normalize();
172
+
173
+ // Position camera at a 3/4 angle (slightly above, rotated 30° from front)
174
+ this.camera.position.set(
175
+ center.x + dist * direction.x,
176
+ center.y + dist * direction.y,
177
+ center.z + dist * direction.z,
178
+ );
179
+ this.camera.lookAt(center);
180
+ this.camera.near = dist * 0.01;
181
+ this.camera.far = dist * 20;
182
+ this.camera.updateProjectionMatrix();
183
+
184
+ // Render
185
+ if (spec.background === 'neutral') this.renderer.setClearColor(0x20242a, 1);
186
+ else this.renderer.setClearColor(0x000000, 0);
187
+ this.renderer.clear();
188
+ this.renderer.render(this.scene, this.camera);
189
+ const outputCanvas = strengthenThinThumbnailSilhouette(
190
+ this.renderer.domElement,
191
+ spec.background === 'neutral',
192
+ );
193
+ const dataUrl =
194
+ spec.output === 'webp'
195
+ ? outputCanvas.toDataURL('image/webp', 0.86)
196
+ : outputCanvas.toDataURL('image/png');
197
+
198
+ // Clean up the loaded GLTF to free GPU memory
199
+ this.scene.remove(group);
200
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Disposal enumerates the texture slots used by the supported Three.js materials.
201
+ group.traverse((obj) => {
202
+ if (hasUserData(obj, 'gaussianSplat')) (obj as SplatMesh).dispose();
203
+ const renderable = obj as THREE.Mesh | THREE.Line | THREE.Points;
204
+ if (renderable.geometry) renderable.geometry.dispose();
205
+ if (renderable.material) {
206
+ const materials = Array.isArray(renderable.material)
207
+ ? renderable.material
208
+ : [renderable.material];
209
+ for (const mat of materials) {
210
+ // Dispose any textures attached to the material
211
+ if ('map' in mat && mat.map instanceof THREE.Texture) mat.map.dispose();
212
+ if ('normalMap' in mat && mat.normalMap instanceof THREE.Texture) mat.normalMap.dispose();
213
+ if ('emissiveMap' in mat && mat.emissiveMap instanceof THREE.Texture)
214
+ mat.emissiveMap.dispose();
215
+ if ('aoMap' in mat && mat.aoMap instanceof THREE.Texture) mat.aoMap.dispose();
216
+ if ('roughnessMap' in mat && mat.roughnessMap instanceof THREE.Texture)
217
+ mat.roughnessMap.dispose();
218
+ if ('metalnessMap' in mat && mat.metalnessMap instanceof THREE.Texture)
219
+ mat.metalnessMap.dispose();
220
+ mat.dispose();
221
+ }
222
+ }
223
+ });
224
+
225
+ return dataUrl;
226
+ }
227
+
228
+ dispose() {
229
+ if (this.sparkRenderer) {
230
+ this.scene.remove(this.sparkRenderer);
231
+ disposeSparkRendererWhenIdle(this.sparkRenderer);
232
+ this.sparkRenderer = null;
233
+ }
234
+ this.renderer.dispose();
235
+ this.renderer.forceContextLoss();
236
+ this.cache.clear();
237
+ instance = null;
238
+ }
239
+ }
240
+
241
+ function strengthenThinThumbnailSilhouette(
242
+ source: HTMLCanvasElement,
243
+ neutralBackground: boolean,
244
+ ): HTMLCanvasElement {
245
+ const canvas = document.createElement('canvas');
246
+ canvas.width = source.width;
247
+ canvas.height = source.height;
248
+ const context = canvas.getContext('2d', { willReadFrequently: true });
249
+ if (!context) return source;
250
+ context.drawImage(source, 0, 0);
251
+ const image = context.getImageData(0, 0, canvas.width, canvas.height);
252
+ const visible = new Uint8Array(canvas.width * canvas.height);
253
+ let visibleCount = 0;
254
+ for (let pixel = 0; pixel < visible.length; pixel++) {
255
+ const offset = pixel * 4;
256
+ const isVisible = neutralBackground
257
+ ? Math.abs(image.data[offset]! - 32) > 3 ||
258
+ Math.abs(image.data[offset + 1]! - 36) > 3 ||
259
+ Math.abs(image.data[offset + 2]! - 42) > 3
260
+ : image.data[offset + 3]! > 8;
261
+ if (isVisible) {
262
+ visible[pixel] = 1;
263
+ visibleCount++;
264
+ }
265
+ }
266
+ const ratio = visibleCount / visible.length;
267
+ if (ratio < 0.0001) return canvas;
268
+ const original = new Uint8ClampedArray(image.data);
269
+ if (neutralBackground) {
270
+ for (let pixel = 0; pixel < visible.length; pixel++) {
271
+ if (!visible[pixel]) continue;
272
+ const offset = pixel * 4;
273
+ const luminance =
274
+ original[offset]! * 0.2126 +
275
+ original[offset + 1]! * 0.7152 +
276
+ original[offset + 2]! * 0.0722;
277
+ if (luminance >= 88) continue;
278
+ const lift = 88 - luminance;
279
+ image.data[offset] = Math.min(255, original[offset]! + lift);
280
+ image.data[offset + 1] = Math.min(255, original[offset + 1]! + lift);
281
+ image.data[offset + 2] = Math.min(255, original[offset + 2]! + lift);
282
+ }
283
+ }
284
+ const dilationPasses = ratio < 0.004 ? 2 : ratio < 0.02 ? 1 : 0;
285
+ let currentVisible = visible;
286
+ for (let pass = 0; pass < dilationPasses; pass++) {
287
+ const nextVisible = currentVisible.slice();
288
+ const passPixels = new Uint8ClampedArray(image.data);
289
+ for (let y = 1; y < canvas.height - 1; y++) {
290
+ for (let x = 1; x < canvas.width - 1; x++) {
291
+ const pixel = y * canvas.width + x;
292
+ if (currentVisible[pixel]) continue;
293
+ const neighbor = [pixel - 1, pixel + 1, pixel - canvas.width, pixel + canvas.width].find(
294
+ (candidate) => currentVisible[candidate],
295
+ );
296
+ if (neighbor === undefined) continue;
297
+ const targetOffset = pixel * 4;
298
+ const sourceOffset = neighbor * 4;
299
+ image.data.set(passPixels.subarray(sourceOffset, sourceOffset + 4), targetOffset);
300
+ nextVisible[pixel] = 1;
301
+ }
302
+ }
303
+ currentVisible = nextVisible;
304
+ }
305
+ context.putImageData(image, 0, 0);
306
+ return canvas;
307
+ }
308
+
309
+ /** Bind-pose geometry bounds avoid malformed/outlier skin transforms shrinking a preview to a dot. */
310
+ export function modelPreviewBounds(model: THREE.Object3D): THREE.Box3 {
311
+ model.updateWorldMatrix(true, true);
312
+ const bounds = new THREE.Box3();
313
+ const candidate = new THREE.Box3();
314
+ model.traverseVisible((object) => {
315
+ if (hasUserData(object, 'gaussianSplat')) {
316
+ candidate.copy((object as SplatMesh).getBoundingBox()).applyMatrix4(object.matrixWorld);
317
+ bounds.union(candidate);
318
+ return;
319
+ }
320
+ const renderable = object as THREE.Mesh | THREE.Line | THREE.Points;
321
+ if (!renderable.geometry) return;
322
+ if (!renderable.geometry.boundingBox) renderable.geometry.computeBoundingBox();
323
+ if (!renderable.geometry.boundingBox) return;
324
+ candidate.copy(renderable.geometry.boundingBox).applyMatrix4(object.matrixWorld);
325
+ bounds.union(candidate);
326
+ });
327
+ return bounds;
328
+ }
329
+
330
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: This format dispatcher keeps each Three.js loader path visible in one place.
331
+ export async function loadModelThumbnailObject(
332
+ spec: ModelThumbnailSpec & { format: ModelThumbnailFormat },
333
+ ): Promise<THREE.Object3D> {
334
+ switch (spec.format) {
335
+ case 'glb':
336
+ case 'gltf': {
337
+ const gltf = await gltfLoader.loadAsync(spec.url);
338
+ gltf.scene.animations = [...gltf.animations];
339
+ return gltf.scene;
340
+ }
341
+ case 'fbx': {
342
+ const { FBXLoader } = await import('three/addons/loaders/FBXLoader.js');
343
+ return new FBXLoader().loadAsync(spec.url);
344
+ }
345
+ case 'obj': {
346
+ const { OBJLoader } = await import('three/addons/loaders/OBJLoader.js');
347
+ const materialUrl = spec.materialUrl ?? (await discoverObjMaterial(spec.url));
348
+ const loader = new OBJLoader();
349
+ if (materialUrl) {
350
+ const { MTLLoader } = await import('three/addons/loaders/MTLLoader.js');
351
+ const materials = await new MTLLoader().loadAsync(materialUrl);
352
+ materials.preload();
353
+ loader.setMaterials(materials);
354
+ }
355
+ return loader.loadAsync(spec.url);
356
+ }
357
+ case 'dae': {
358
+ const { ColladaLoader } = await import('three/addons/loaders/ColladaLoader.js');
359
+ const collada = await new ColladaLoader().loadAsync(spec.url);
360
+ const animations = (collada as unknown as { animations?: THREE.AnimationClip[] }).animations;
361
+ if (animations) collada.scene.animations = [...animations];
362
+ return collada.scene;
363
+ }
364
+ case 'stl': {
365
+ const { STLLoader } = await import('three/addons/loaders/STLLoader.js');
366
+ const geometry = await new STLLoader().loadAsync(spec.url);
367
+ return new THREE.Mesh(
368
+ geometry,
369
+ new THREE.MeshStandardMaterial({ color: 0x75a7d8, roughness: 0.75 }),
370
+ );
371
+ }
372
+ case 'ply': {
373
+ const { PLYLoader } = await import('three/addons/loaders/PLYLoader.js');
374
+ const geometry = await new PLYLoader().loadAsync(spec.url);
375
+ if (geometry.index || geometry.hasAttribute('normal')) {
376
+ if (!geometry.hasAttribute('normal')) geometry.computeVertexNormals();
377
+ return new THREE.Mesh(
378
+ geometry,
379
+ new THREE.MeshStandardMaterial({
380
+ color: geometry.hasAttribute('color') ? 0xffffff : 0xb8d09b,
381
+ vertexColors: geometry.hasAttribute('color'),
382
+ roughness: 0.8,
383
+ side: THREE.DoubleSide,
384
+ }),
385
+ );
386
+ }
387
+ geometry.computeBoundingBox();
388
+ const diagonal = geometry.boundingBox?.getSize(new THREE.Vector3()).length() ?? 1;
389
+ return new THREE.Points(
390
+ geometry,
391
+ new THREE.PointsMaterial({
392
+ color: geometry.hasAttribute('color') ? 0xffffff : 0xb8d09b,
393
+ vertexColors: geometry.hasAttribute('color'),
394
+ size: Math.max(diagonal / 220, 0.0001),
395
+ sizeAttenuation: true,
396
+ }),
397
+ );
398
+ }
399
+ case 'spz':
400
+ return loadSplat(spec.url);
401
+ case 'bvh': {
402
+ const { BVHLoader } = await import('three/addons/loaders/BVHLoader.js');
403
+ const result = await new BVHLoader().loadAsync(spec.url);
404
+ const root = result.skeleton.bones[0];
405
+ if (!root) throw new Error('BVH contains no skeleton root.');
406
+ const group = new THREE.Group();
407
+ group.animations = [result.clip];
408
+ group.add(root);
409
+ return group;
410
+ }
411
+ case '3ds': {
412
+ const { TDSLoader } = await import('three/addons/loaders/TDSLoader.js');
413
+ return new TDSLoader().loadAsync(spec.url);
414
+ }
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Give animation-only model files an honest renderable representation.
420
+ * FBX/glTF animation exports commonly contain a complete bone hierarchy and
421
+ * clips but no mesh. They are valid assets, so Asset Editor visualizes their
422
+ * current skeleton instead of misreporting them as an empty source.
423
+ */
424
+ export function createModelPreviewSource(model: THREE.Object3D): THREE.Object3D {
425
+ if (hasRenderableModelContent(model)) return model;
426
+
427
+ model.updateWorldMatrix(true, true);
428
+ const helper = new THREE.SkeletonHelper(model);
429
+ const positions = helper.geometry.getAttribute('position');
430
+ if (!positions || positions.count === 0) {
431
+ helper.dispose();
432
+ return model;
433
+ }
434
+
435
+ helper.name = 'Asset Editor skeleton preview';
436
+ helper.frustumCulled = false;
437
+ const previewRoot = new THREE.Group();
438
+ previewRoot.name = model.name;
439
+ previewRoot.animations = [...model.animations];
440
+ previewRoot.add(model, helper);
441
+ previewRoot.updateWorldMatrix(true, true);
442
+ // SkeletonHelper updates its dynamic line vertices from updateMatrixWorld,
443
+ // while the read-only preview measurer intentionally calls updateWorldMatrix.
444
+ // Materialize the current pose once so framing sees the real skeleton span.
445
+ helper.updateMatrixWorld(true);
446
+ return previewRoot;
447
+ }
448
+
449
+ function hasRenderableModelContent(model: THREE.Object3D): boolean {
450
+ let renderable = false;
451
+ model.traverseVisible((object) => {
452
+ if (!renderable && objectHasRenderableModelContent(object)) renderable = true;
453
+ });
454
+ return renderable;
455
+ }
456
+
457
+ function objectHasRenderableModelContent(object: THREE.Object3D): boolean {
458
+ const sprite = object as THREE.Sprite;
459
+ if (sprite.isSprite) return sprite.material.visible;
460
+ const mesh = object as THREE.Mesh;
461
+ const line = object as THREE.Line;
462
+ const points = object as THREE.Points;
463
+ if (!mesh.isMesh && !line.isLine && !points.isPoints) return false;
464
+ const drawable = object as THREE.Mesh;
465
+ const instanced = object as THREE.InstancedMesh;
466
+ if (instanced.isInstancedMesh && instanced.count === 0) return false;
467
+ const materials = Array.isArray(drawable.material) ? drawable.material : [drawable.material];
468
+ if (materials.length === 0 || materials.every((material) => !material?.visible)) return false;
469
+ const positions = drawable.geometry?.getAttribute('position');
470
+ return Boolean(positions && positions.count > 0 && drawable.geometry.drawRange.count !== 0);
471
+ }
472
+
473
+ async function discoverObjMaterial(url: string): Promise<string | undefined> {
474
+ const pathname = url.split(/[?#]/, 1)[0]!;
475
+ const materialUrl = `${pathname.slice(0, pathname.lastIndexOf('.'))}.mtl`;
476
+ try {
477
+ const response = await fetch(materialUrl, { method: 'HEAD' });
478
+ return response.ok ? materialUrl : undefined;
479
+ } catch {
480
+ return undefined;
481
+ }
482
+ }
483
+
484
+ /** Normalize an upstream/source preview image through the same thumbnail canvas. */
485
+ export async function renderImageThumbnail(
486
+ url: string,
487
+ options: { output?: 'png' | 'webp'; background?: 'transparent' | 'neutral' } = {},
488
+ ): Promise<string> {
489
+ const image = await new Promise<HTMLImageElement>((resolve, reject) => {
490
+ const element = new Image();
491
+ element.onload = () => resolve(element);
492
+ element.onerror = () => reject(new Error(`Failed to load preview image: ${url}`));
493
+ element.src = url;
494
+ });
495
+ const canvas = document.createElement('canvas');
496
+ canvas.width = SIZE;
497
+ canvas.height = SIZE;
498
+ const context = canvas.getContext('2d');
499
+ if (!context) throw new Error('2D thumbnail canvas is unavailable.');
500
+ if (options.background === 'neutral') {
501
+ context.fillStyle = '#20242a';
502
+ context.fillRect(0, 0, SIZE, SIZE);
503
+ }
504
+ const scale = Math.min(SIZE / image.naturalWidth, SIZE / image.naturalHeight);
505
+ const width = image.naturalWidth * scale;
506
+ const height = image.naturalHeight * scale;
507
+ context.drawImage(image, (SIZE - width) / 2, (SIZE - height) / 2, width, height);
508
+ return options.output === 'webp'
509
+ ? canvas.toDataURL('image/webp', 0.86)
510
+ : canvas.toDataURL('image/png');
511
+ }
512
+
513
+ /** Get (or create) the shared thumbnail renderer. */
514
+ export function getModelThumbnailRenderer(): ModelThumbnailRenderer {
515
+ if (!instance) instance = new ModelThumbnailRenderer();
516
+ return instance;
517
+ }
518
+
519
+ // A Fast Refresh invalidation creates a fresh module singleton. Release the
520
+ // previous module's offscreen WebGL context first instead of silently leaking
521
+ // one context per AssetBrowser edit.
522
+ if (import.meta.hot) {
523
+ import.meta.hot.dispose(() => instance?.dispose());
524
+ }
525
+
526
+ /** This renderer, as the asset browser's thumbnail for every model format it
527
+ * loads (`@volter/editor-sdk/kit/asset-thumbnails`). */
528
+ export function registerModelThumbnails(): () => void {
529
+ return registerAssetThumbnailRenderer({
530
+ accepts: (url) => modelThumbnailFormat(url) !== undefined,
531
+ render: (url) =>
532
+ getModelThumbnailRenderer().render({
533
+ url,
534
+ format: modelThumbnailFormat(url)!,
535
+ background: 'neutral',
536
+ output: 'webp',
537
+ }),
538
+ });
539
+ }