@myned-ai/gsplat-flame-avatar-renderer 1.0.4 → 1.0.6
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/README.md +128 -40
- package/dist/gsplat-flame-avatar-renderer.cjs.js +3478 -722
- package/dist/gsplat-flame-avatar-renderer.cjs.min.js +2 -0
- package/dist/gsplat-flame-avatar-renderer.cjs.min.js.map +1 -0
- package/dist/gsplat-flame-avatar-renderer.esm.js +3439 -724
- package/dist/gsplat-flame-avatar-renderer.esm.min.js +2 -0
- package/dist/gsplat-flame-avatar-renderer.esm.min.js.map +1 -0
- package/package.json +11 -14
- package/src/core/SplatMesh.js +53 -46
- package/src/core/Viewer.js +47 -196
- package/src/errors/ApplicationError.js +185 -0
- package/src/errors/index.js +17 -0
- package/src/flame/FlameAnimator.js +282 -57
- package/src/loaders/PlyLoader.js +302 -44
- package/src/materials/SplatMaterial.js +13 -10
- package/src/materials/SplatMaterial3D.js +72 -27
- package/src/renderer/AnimationManager.js +8 -5
- package/src/renderer/GaussianSplatRenderer.js +668 -217
- package/src/utils/BlobUrlManager.js +294 -0
- package/src/utils/EventEmitter.js +349 -0
- package/src/utils/LoaderUtils.js +2 -1
- package/src/utils/Logger.js +171 -0
- package/src/utils/ObjectPool.js +248 -0
- package/src/utils/RenderLoop.js +306 -0
- package/src/utils/Util.js +59 -18
- package/src/utils/ValidationUtils.js +331 -0
- package/src/utils/index.js +10 -1
- package/dist/gsplat-flame-avatar-renderer.cjs.js.map +0 -1
- package/dist/gsplat-flame-avatar-renderer.esm.js.map +0 -1
|
@@ -28,12 +28,32 @@ import { AnimationManager } from './AnimationManager.js';
|
|
|
28
28
|
import { Viewer } from '../core/Viewer.js';
|
|
29
29
|
import { SceneFormat } from '../enums/SceneFormat.js';
|
|
30
30
|
|
|
31
|
+
// Import new utilities and error classes
|
|
32
|
+
import { getLogger } from '../utils/Logger.js';
|
|
33
|
+
import {
|
|
34
|
+
ValidationError,
|
|
35
|
+
NetworkError,
|
|
36
|
+
AssetLoadError,
|
|
37
|
+
InitializationError,
|
|
38
|
+
ResourceDisposedError
|
|
39
|
+
} from '../errors/index.js';
|
|
40
|
+
import {
|
|
41
|
+
validateUrl,
|
|
42
|
+
validateDOMElement,
|
|
43
|
+
validateHexColor,
|
|
44
|
+
validateCallback
|
|
45
|
+
} from '../utils/ValidationUtils.js';
|
|
46
|
+
import { BlobUrlManager } from '../utils/BlobUrlManager.js';
|
|
47
|
+
import { tempVector3A } from '../utils/ObjectPool.js';
|
|
48
|
+
|
|
49
|
+
// Create logger for this module
|
|
50
|
+
const logger = getLogger('GaussianSplatRenderer');
|
|
51
|
+
|
|
31
52
|
// Configuration objects - these would normally be loaded from the ZIP
|
|
32
53
|
const charactorConfig = {
|
|
33
54
|
camPos: { x: 0, y: 1.8, z: 1 },
|
|
34
55
|
camRot: { x: -10, y: 0, z: 0 },
|
|
35
|
-
backgroundColor: 'ffffff'
|
|
36
|
-
useFlame: 'false' // Match compact file default - use non-FLAME mode
|
|
56
|
+
backgroundColor: 'ffffff'
|
|
37
57
|
};
|
|
38
58
|
|
|
39
59
|
const motionConfig = {
|
|
@@ -56,35 +76,74 @@ const animationConfig = {
|
|
|
56
76
|
* GaussianSplatRenderer - Main rendering class
|
|
57
77
|
*/
|
|
58
78
|
export class GaussianSplatRenderer {
|
|
59
|
-
// Static canvas element shared across instances
|
|
60
|
-
static _canvas = (typeof document !== 'undefined') ? document.createElement('canvas') : null;
|
|
61
|
-
|
|
62
|
-
// Singleton instance
|
|
63
|
-
static instance = undefined;
|
|
64
|
-
|
|
65
79
|
/**
|
|
66
|
-
* Factory method to create
|
|
80
|
+
* Factory method to create a new renderer instance
|
|
81
|
+
*
|
|
67
82
|
* @param {HTMLElement} container - DOM container for canvas
|
|
68
83
|
* @param {string} assetPath - URL to character ZIP file
|
|
69
|
-
* @param {object} options - Configuration options
|
|
70
|
-
* @
|
|
84
|
+
* @param {object} [options={}] - Configuration options
|
|
85
|
+
* @param {Function} [options.downloadProgress] - Download progress callback (0-1)
|
|
86
|
+
* @param {Function} [options.loadProgress] - Load progress callback (0-1)
|
|
87
|
+
* @param {Function} [options.getChatState] - Chat state provider function
|
|
88
|
+
* @param {Function} [options.getExpressionData] - Expression data provider function
|
|
89
|
+
* @param {string} [options.backgroundColor] - Background color (hex string)
|
|
90
|
+
* @returns {Promise<GaussianSplatRenderer>} Renderer instance
|
|
91
|
+
* @throws {ValidationError} If parameters are invalid
|
|
92
|
+
* @throws {NetworkError} If asset download fails
|
|
93
|
+
* @throws {AssetLoadError} If asset loading/parsing fails
|
|
94
|
+
* @throws {InitializationError} If renderer initialization fails
|
|
71
95
|
*/
|
|
72
|
-
static async
|
|
73
|
-
if (this.instance !== undefined) {
|
|
74
|
-
return this.instance;
|
|
75
|
-
}
|
|
96
|
+
static async create(container, assetPath, options = {}) {
|
|
76
97
|
|
|
77
98
|
try {
|
|
99
|
+
// Validate required parameters
|
|
100
|
+
validateDOMElement(container, 'container');
|
|
101
|
+
validateUrl(assetPath);
|
|
102
|
+
|
|
103
|
+
// Validate optional callbacks
|
|
104
|
+
if (options.downloadProgress) {
|
|
105
|
+
validateCallback(options.downloadProgress, 'options.downloadProgress', false);
|
|
106
|
+
}
|
|
107
|
+
if (options.loadProgress) {
|
|
108
|
+
validateCallback(options.loadProgress, 'options.loadProgress', false);
|
|
109
|
+
}
|
|
110
|
+
if (options.getChatState) {
|
|
111
|
+
validateCallback(options.getChatState, 'options.getChatState', false);
|
|
112
|
+
}
|
|
113
|
+
if (options.getExpressionData) {
|
|
114
|
+
validateCallback(options.getExpressionData, 'options.getExpressionData', false);
|
|
115
|
+
}
|
|
116
|
+
if (options.backgroundColor) {
|
|
117
|
+
validateHexColor(options.backgroundColor, 'options.backgroundColor');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
logger.info('Initializing GaussianSplatRenderer', { assetPath });
|
|
121
|
+
|
|
78
122
|
const characterPath = assetPath;
|
|
79
|
-
|
|
123
|
+
|
|
80
124
|
// Parse character name from path
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
125
|
+
let characterName;
|
|
126
|
+
try {
|
|
127
|
+
const url = new URL(characterPath, typeof window !== 'undefined' ? window.location.href : undefined);
|
|
128
|
+
const pathname = url.pathname;
|
|
129
|
+
const matches = pathname.match(/\/([^/]+?)\.zip/);
|
|
130
|
+
characterName = matches?.[1];
|
|
131
|
+
|
|
132
|
+
if (!characterName) {
|
|
133
|
+
throw new ValidationError(
|
|
134
|
+
'Character model name could not be extracted from path. Expected format: /path/name.zip',
|
|
135
|
+
'assetPath'
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error instanceof ValidationError) {
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
throw new ValidationError(
|
|
143
|
+
`Invalid asset path format: ${error.message}`,
|
|
144
|
+
'assetPath',
|
|
145
|
+
error
|
|
146
|
+
);
|
|
88
147
|
}
|
|
89
148
|
|
|
90
149
|
// Show progress
|
|
@@ -93,28 +152,72 @@ export class GaussianSplatRenderer {
|
|
|
93
152
|
}
|
|
94
153
|
|
|
95
154
|
// Download ZIP file
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
155
|
+
logger.info('Downloading asset ZIP', { path: characterPath });
|
|
156
|
+
let characterZipResponse;
|
|
157
|
+
try {
|
|
158
|
+
characterZipResponse = await fetch(characterPath);
|
|
159
|
+
if (!characterZipResponse.ok) {
|
|
160
|
+
throw new NetworkError(
|
|
161
|
+
`Failed to download asset: ${characterZipResponse.statusText}`,
|
|
162
|
+
characterZipResponse.status
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error instanceof NetworkError) {
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
throw new NetworkError(
|
|
170
|
+
`Network error downloading asset: ${error.message}`,
|
|
171
|
+
0,
|
|
172
|
+
error
|
|
173
|
+
);
|
|
99
174
|
}
|
|
100
175
|
|
|
101
176
|
// Report download progress
|
|
102
177
|
if (options.downloadProgress) {
|
|
103
|
-
|
|
178
|
+
try {
|
|
179
|
+
options.downloadProgress(1.0);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
logger.warn('Error in downloadProgress callback', error);
|
|
182
|
+
}
|
|
104
183
|
}
|
|
105
184
|
|
|
106
185
|
if (options.loadProgress) {
|
|
107
|
-
|
|
186
|
+
try {
|
|
187
|
+
options.loadProgress(0.1);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
logger.warn('Error in loadProgress callback', error);
|
|
190
|
+
}
|
|
108
191
|
}
|
|
109
192
|
|
|
110
193
|
if (typeof NProgress !== 'undefined') {
|
|
111
194
|
NProgress.done();
|
|
112
195
|
}
|
|
113
196
|
|
|
114
|
-
|
|
115
|
-
|
|
197
|
+
// Parse array buffer
|
|
198
|
+
let arrayBuffer;
|
|
199
|
+
try {
|
|
200
|
+
arrayBuffer = await characterZipResponse.arrayBuffer();
|
|
201
|
+
} catch (error) {
|
|
202
|
+
throw new NetworkError(
|
|
203
|
+
`Failed to read response data: ${error.message}`,
|
|
204
|
+
0,
|
|
205
|
+
error
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
116
209
|
// Load ZIP with imported JSZip
|
|
117
|
-
|
|
210
|
+
logger.debug('Unpacking ZIP archive');
|
|
211
|
+
let zipData;
|
|
212
|
+
try {
|
|
213
|
+
zipData = await JSZip.loadAsync(arrayBuffer);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
throw new AssetLoadError(
|
|
216
|
+
`Failed to unpack ZIP archive: ${error.message}`,
|
|
217
|
+
characterPath,
|
|
218
|
+
error
|
|
219
|
+
);
|
|
220
|
+
}
|
|
118
221
|
|
|
119
222
|
// Find folder name in ZIP
|
|
120
223
|
let fileName = '';
|
|
@@ -125,100 +228,235 @@ export class GaussianSplatRenderer {
|
|
|
125
228
|
});
|
|
126
229
|
|
|
127
230
|
if (!fileName) {
|
|
128
|
-
throw new
|
|
231
|
+
throw new AssetLoadError(
|
|
232
|
+
'No folder found in ZIP archive. Expected ZIP to contain a folder with model files.',
|
|
233
|
+
characterPath
|
|
234
|
+
);
|
|
129
235
|
}
|
|
130
236
|
|
|
237
|
+
logger.debug('Found model folder in ZIP', { fileName });
|
|
238
|
+
|
|
131
239
|
// Create renderer instance
|
|
240
|
+
logger.debug('Creating GaussianSplatRenderer instance');
|
|
132
241
|
const renderer = new GaussianSplatRenderer(container, zipData);
|
|
133
242
|
|
|
134
|
-
// Setup camera position
|
|
135
|
-
const cameraPos =
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
243
|
+
// Setup camera position (use object pool for temp vectors)
|
|
244
|
+
const cameraPos = tempVector3A.set(
|
|
245
|
+
charactorConfig.camPos?.x ?? 0,
|
|
246
|
+
charactorConfig.camPos?.y ?? 0,
|
|
247
|
+
charactorConfig.camPos?.z ?? 1
|
|
248
|
+
);
|
|
139
249
|
|
|
140
|
-
const cameraRotation = new Vector3(
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
250
|
+
const cameraRotation = new Vector3(
|
|
251
|
+
charactorConfig.camRot?.x ?? 0,
|
|
252
|
+
charactorConfig.camRot?.y ?? 0,
|
|
253
|
+
charactorConfig.camRot?.z ?? 0
|
|
254
|
+
);
|
|
144
255
|
|
|
145
|
-
|
|
256
|
+
logger.debug('Camera setup', {
|
|
257
|
+
position: { x: cameraPos.x, y: cameraPos.y, z: cameraPos.z },
|
|
258
|
+
rotation: { x: cameraRotation.x, y: cameraRotation.y, z: cameraRotation.z }
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Background color with validation
|
|
146
262
|
let backgroundColor = 0xffffff;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
263
|
+
try {
|
|
264
|
+
if (charactorConfig.backgroundColor) {
|
|
265
|
+
const parsed = parseInt(charactorConfig.backgroundColor, 16);
|
|
266
|
+
if (!isNaN(parsed)) {
|
|
267
|
+
backgroundColor = parsed;
|
|
268
|
+
} else {
|
|
269
|
+
logger.warn('Invalid backgroundColor in config, using default', {
|
|
270
|
+
value: charactorConfig.backgroundColor
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (options?.backgroundColor) {
|
|
275
|
+
if (renderer.isHexColorStrict(options.backgroundColor)) {
|
|
276
|
+
backgroundColor = parseInt(options.backgroundColor, 16);
|
|
277
|
+
} else {
|
|
278
|
+
logger.warn('Invalid backgroundColor option, using config value', {
|
|
279
|
+
value: options.backgroundColor
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
} catch (error) {
|
|
284
|
+
logger.warn('Error parsing backgroundColor, using default', error);
|
|
152
285
|
}
|
|
153
286
|
|
|
287
|
+
logger.debug('Background color set', { backgroundColor: backgroundColor.toString(16) });
|
|
288
|
+
|
|
154
289
|
// Store callbacks
|
|
155
290
|
renderer.getChatState = options?.getChatState;
|
|
156
291
|
renderer.getExpressionData = options?.getExpressionData;
|
|
157
292
|
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
293
|
+
// Create Viewer with proper error handling
|
|
294
|
+
logger.debug('Creating Viewer instance');
|
|
295
|
+
try {
|
|
296
|
+
renderer.viewer = new Viewer({
|
|
297
|
+
rootElement: container,
|
|
298
|
+
threejsCanvas: renderer._canvas,
|
|
299
|
+
cameraUp: [0, 1, 0],
|
|
300
|
+
initialCameraPosition: [cameraPos.x, cameraPos.y, cameraPos.z],
|
|
301
|
+
initialCameraRotation: [cameraRotation.x, cameraRotation.y, cameraRotation.z],
|
|
302
|
+
sphericalHarmonicsDegree: 0,
|
|
303
|
+
backgroundColor: backgroundColor
|
|
304
|
+
});
|
|
305
|
+
} catch (error) {
|
|
306
|
+
throw new InitializationError(
|
|
307
|
+
`Failed to create Viewer instance: ${error.message}`,
|
|
308
|
+
error
|
|
309
|
+
);
|
|
161
310
|
}
|
|
162
311
|
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
threejsCanvas: GaussianSplatRenderer._canvas,
|
|
167
|
-
cameraUp: [0, 1, 0],
|
|
168
|
-
initialCameraPosition: [cameraPos.x, cameraPos.y, cameraPos.z],
|
|
169
|
-
initialCameraRotation: [cameraRotation.x, cameraRotation.y, cameraRotation.z],
|
|
170
|
-
sphericalHarmonicsDegree: 0,
|
|
171
|
-
backgroundColor: backgroundColor
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
renderer.viewer.useFlame = renderer.useFlame;
|
|
175
|
-
|
|
176
|
-
// Load model based on mode
|
|
177
|
-
if (renderer.viewer.useFlame === true) {
|
|
178
|
-
await renderer.loadFlameModel(fileName, motionConfig);
|
|
179
|
-
} else {
|
|
312
|
+
// Load model (non-FLAME mode only)
|
|
313
|
+
logger.info('Loading model', { fileName });
|
|
314
|
+
try {
|
|
180
315
|
await renderer.loadModel(fileName, animationConfig, motionConfig);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
throw new AssetLoadError(
|
|
318
|
+
`Failed to load model: ${error.message}`,
|
|
319
|
+
fileName,
|
|
320
|
+
error
|
|
321
|
+
);
|
|
181
322
|
}
|
|
182
323
|
|
|
324
|
+
// Progress callback with error isolation
|
|
183
325
|
if (options.loadProgress) {
|
|
184
|
-
|
|
326
|
+
try {
|
|
327
|
+
options.loadProgress(0.2);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
logger.warn('Error in loadProgress callback', error);
|
|
330
|
+
}
|
|
185
331
|
}
|
|
186
332
|
|
|
187
333
|
// Load offset PLY
|
|
188
|
-
|
|
334
|
+
logger.debug('Loading offset PLY file');
|
|
335
|
+
let offsetFileUrl;
|
|
336
|
+
try {
|
|
337
|
+
offsetFileUrl = await renderer.unpackFileAsBlob(fileName + '/offset.ply');
|
|
338
|
+
} catch (error) {
|
|
339
|
+
throw new AssetLoadError(
|
|
340
|
+
`Failed to load offset.ply: ${error.message}`,
|
|
341
|
+
fileName + '/offset.ply',
|
|
342
|
+
error
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Load iris occlusion configuration (optional)
|
|
347
|
+
logger.debug('Checking for iris_occlusion.json');
|
|
348
|
+
let irisOcclusionConfig = null;
|
|
349
|
+
try {
|
|
350
|
+
irisOcclusionConfig = await renderer._loadJsonFromZip(fileName + '/iris_occlusion.json');
|
|
351
|
+
if (irisOcclusionConfig) {
|
|
352
|
+
logger.info('Iris occlusion configuration loaded', {
|
|
353
|
+
rightIrisRanges: irisOcclusionConfig.right_iris?.length ?? 0,
|
|
354
|
+
leftIrisRanges: irisOcclusionConfig.left_iris?.length ?? 0
|
|
355
|
+
});
|
|
356
|
+
renderer.irisOcclusionConfig = irisOcclusionConfig;
|
|
357
|
+
// Pass to viewer for material generation
|
|
358
|
+
renderer.viewer.irisOcclusionConfig = irisOcclusionConfig;
|
|
359
|
+
} else {
|
|
360
|
+
logger.debug('No iris_occlusion.json found, iris occlusion will be disabled');
|
|
361
|
+
}
|
|
362
|
+
} catch (error) {
|
|
363
|
+
// Log but don't fail - iris occlusion is optional
|
|
364
|
+
logger.warn('Failed to load iris_occlusion.json, continuing without it', { error: error.message });
|
|
365
|
+
renderer.irisOcclusionConfig = null;
|
|
366
|
+
}
|
|
189
367
|
|
|
368
|
+
// Progress callback with error isolation
|
|
190
369
|
if (options.loadProgress) {
|
|
191
|
-
|
|
370
|
+
try {
|
|
371
|
+
options.loadProgress(0.3);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
logger.warn('Error in loadProgress callback', error);
|
|
374
|
+
}
|
|
192
375
|
}
|
|
193
376
|
|
|
194
377
|
// Add splat scene
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
378
|
+
logger.debug('Adding splat scene');
|
|
379
|
+
try {
|
|
380
|
+
await renderer.viewer.addSplatScene(offsetFileUrl, {
|
|
381
|
+
progressiveLoad: true,
|
|
382
|
+
sharedMemoryForWorkers: false,
|
|
383
|
+
showLoadingUI: false,
|
|
384
|
+
format: SceneFormat.Ply
|
|
385
|
+
});
|
|
386
|
+
} catch (error) {
|
|
387
|
+
throw new InitializationError(
|
|
388
|
+
`Failed to add splat scene: ${error.message}`,
|
|
389
|
+
error
|
|
390
|
+
);
|
|
391
|
+
}
|
|
202
392
|
|
|
393
|
+
// Initial render
|
|
394
|
+
try {
|
|
395
|
+
renderer.render();
|
|
396
|
+
} catch (error) {
|
|
397
|
+
logger.error('Error in initial render', error);
|
|
398
|
+
// Don't throw - render errors are non-fatal
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Progress callback with error isolation
|
|
203
402
|
if (options.loadProgress) {
|
|
204
|
-
|
|
403
|
+
try {
|
|
404
|
+
options.loadProgress(1);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
logger.warn('Error in loadProgress callback', error);
|
|
407
|
+
}
|
|
205
408
|
}
|
|
206
409
|
|
|
207
|
-
|
|
410
|
+
logger.info('GaussianSplatRenderer initialized successfully');
|
|
208
411
|
return renderer;
|
|
209
412
|
|
|
210
413
|
} catch (error) {
|
|
211
|
-
|
|
212
|
-
|
|
414
|
+
// Re-throw custom errors as-is
|
|
415
|
+
if (error instanceof ValidationError ||
|
|
416
|
+
error instanceof NetworkError ||
|
|
417
|
+
error instanceof AssetLoadError ||
|
|
418
|
+
error instanceof InitializationError) {
|
|
419
|
+
logger.error('Initialization failed', { errorCode: error.code, message: error.message });
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Wrap unexpected errors
|
|
424
|
+
logger.error('Unexpected error during initialization', error);
|
|
425
|
+
throw new InitializationError(
|
|
426
|
+
`Unexpected error initializing GaussianSplatRenderer: ${error.message}`,
|
|
427
|
+
error
|
|
428
|
+
);
|
|
213
429
|
}
|
|
214
430
|
}
|
|
215
431
|
|
|
216
432
|
/**
|
|
217
|
-
*
|
|
218
|
-
* @param {HTMLElement}
|
|
219
|
-
* @param {
|
|
433
|
+
* @deprecated Use create() instead. This method is kept for backwards compatibility.
|
|
434
|
+
* @param {HTMLElement} container - DOM container for canvas
|
|
435
|
+
* @param {string} assetPath - URL to character ZIP file
|
|
436
|
+
* @param {object} [options={}] - Configuration options
|
|
437
|
+
* @returns {Promise<GaussianSplatRenderer>} Renderer instance
|
|
438
|
+
*/
|
|
439
|
+
static async getInstance(container, assetPath, options = {}) {
|
|
440
|
+
logger.warn('getInstance() is deprecated. Use create() instead. Each call creates a new instance.');
|
|
441
|
+
return this.create(container, assetPath, options);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Constructor - Creates a new GaussianSplatRenderer instance
|
|
446
|
+
*
|
|
447
|
+
* @param {HTMLElement} _container - DOM container element for the renderer
|
|
448
|
+
* @param {JSZip} zipData - Loaded ZIP archive containing model data
|
|
449
|
+
* @private - Use create() factory method instead
|
|
220
450
|
*/
|
|
221
451
|
constructor(_container, zipData) {
|
|
452
|
+
logger.debug('GaussianSplatRenderer constructor called');
|
|
453
|
+
|
|
454
|
+
// Disposal tracking
|
|
455
|
+
this._disposed = false;
|
|
456
|
+
|
|
457
|
+
// BlobUrlManager for tracking blob URLs
|
|
458
|
+
this._blobUrlManager = new BlobUrlManager();
|
|
459
|
+
|
|
222
460
|
// ZIP file cache
|
|
223
461
|
this.zipUrls = {
|
|
224
462
|
urls: new Map(),
|
|
@@ -226,19 +464,21 @@ export class GaussianSplatRenderer {
|
|
|
226
464
|
};
|
|
227
465
|
|
|
228
466
|
// State
|
|
229
|
-
this.useFlame = false;
|
|
230
467
|
this.lastTime = 0;
|
|
231
468
|
this.startTime = 0;
|
|
232
469
|
this.expressionData = {};
|
|
233
470
|
this.chatState = TYVoiceChatState.Idle;
|
|
234
471
|
|
|
235
|
-
//
|
|
236
|
-
|
|
472
|
+
// Create instance-specific canvas
|
|
473
|
+
this._canvas = null;
|
|
474
|
+
if (typeof document !== 'undefined' && _container) {
|
|
475
|
+
this._canvas = document.createElement('canvas');
|
|
237
476
|
const { width, height } = _container.getBoundingClientRect();
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
_container.appendChild(
|
|
477
|
+
this._canvas.style.visibility = 'visible';
|
|
478
|
+
this._canvas.width = width;
|
|
479
|
+
this._canvas.height = height;
|
|
480
|
+
_container.appendChild(this._canvas);
|
|
481
|
+
logger.debug('Canvas setup', { width, height });
|
|
242
482
|
}
|
|
243
483
|
|
|
244
484
|
// Animation timing
|
|
@@ -250,42 +490,141 @@ export class GaussianSplatRenderer {
|
|
|
250
490
|
this.mixer = null;
|
|
251
491
|
this.animManager = null;
|
|
252
492
|
this.model = null;
|
|
493
|
+
this.irisOcclusionConfig = null;
|
|
253
494
|
this.motioncfg = null;
|
|
254
495
|
this.getChatState = null;
|
|
255
496
|
this.getExpressionData = null;
|
|
497
|
+
|
|
498
|
+
logger.debug('GaussianSplatRenderer instance created');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Assert renderer is not disposed
|
|
503
|
+
* @private
|
|
504
|
+
* @throws {ResourceDisposedError} If renderer has been disposed
|
|
505
|
+
*/
|
|
506
|
+
_assertNotDisposed() {
|
|
507
|
+
if (this._disposed) {
|
|
508
|
+
throw new ResourceDisposedError('GaussianSplatRenderer has been disposed');
|
|
509
|
+
}
|
|
256
510
|
}
|
|
257
511
|
|
|
258
512
|
/**
|
|
259
|
-
* Dispose renderer and free resources
|
|
513
|
+
* Dispose renderer and free all resources
|
|
514
|
+
*
|
|
515
|
+
* Properly cleans up:
|
|
516
|
+
* - Model resources (mesh, animations, textures)
|
|
517
|
+
* - Blob URLs to prevent memory leaks
|
|
518
|
+
* - Viewer instance
|
|
519
|
+
* - Canvas visibility
|
|
520
|
+
*
|
|
521
|
+
* @returns {void}
|
|
260
522
|
*/
|
|
261
523
|
dispose() {
|
|
262
|
-
if (
|
|
263
|
-
GaussianSplatRenderer.
|
|
524
|
+
if (this._disposed) {
|
|
525
|
+
logger.warn('GaussianSplatRenderer.dispose() called on already disposed instance');
|
|
526
|
+
return;
|
|
264
527
|
}
|
|
265
|
-
|
|
528
|
+
|
|
529
|
+
logger.info('Disposing GaussianSplatRenderer');
|
|
530
|
+
|
|
531
|
+
// Hide and remove canvas
|
|
532
|
+
if (this._canvas) {
|
|
533
|
+
this._canvas.style.visibility = 'hidden';
|
|
534
|
+
if (this._canvas.parentNode) {
|
|
535
|
+
this._canvas.parentNode.removeChild(this._canvas);
|
|
536
|
+
}
|
|
537
|
+
this._canvas = null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Dispose model resources
|
|
266
541
|
this.disposeModel();
|
|
267
|
-
|
|
268
|
-
// Revoke all blob URLs
|
|
269
|
-
this.zipUrls.urls.forEach((value) => {
|
|
270
|
-
URL.revokeObjectURL(value);
|
|
271
|
-
});
|
|
272
542
|
|
|
543
|
+
// Revoke all blob URLs using BlobUrlManager
|
|
544
|
+
try {
|
|
545
|
+
this._blobUrlManager?.dispose();
|
|
546
|
+
} catch (error) {
|
|
547
|
+
logger.error('Error disposing BlobUrlManager', error);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Legacy blob URL cleanup (for URLs created before BlobUrlManager)
|
|
551
|
+
if (this.zipUrls?.urls) {
|
|
552
|
+
this.zipUrls.urls.forEach((value) => {
|
|
553
|
+
try {
|
|
554
|
+
URL.revokeObjectURL(value);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
logger.warn('Error revoking blob URL', { url: value, error });
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
this.zipUrls.urls.clear();
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Nullify references to aid GC
|
|
563
|
+
this.viewer = null;
|
|
564
|
+
this.mixer = null;
|
|
565
|
+
this.animManager = null;
|
|
566
|
+
this.model = null;
|
|
567
|
+
this.motioncfg = null;
|
|
568
|
+
this.getChatState = null;
|
|
569
|
+
this.getExpressionData = null;
|
|
570
|
+
this.zipUrls = null;
|
|
571
|
+
|
|
572
|
+
// Mark as disposed
|
|
573
|
+
this._disposed = true;
|
|
574
|
+
|
|
575
|
+
// Clear singleton instance
|
|
273
576
|
GaussianSplatRenderer.instance = undefined;
|
|
577
|
+
|
|
578
|
+
logger.debug('GaussianSplatRenderer disposed successfully');
|
|
274
579
|
}
|
|
275
580
|
|
|
276
581
|
/**
|
|
277
582
|
* Dispose model-specific resources
|
|
583
|
+
*
|
|
584
|
+
* Cleans up:
|
|
585
|
+
* - Animation mixer and cached actions
|
|
586
|
+
* - Animation manager
|
|
587
|
+
* - Viewer instance
|
|
588
|
+
*
|
|
589
|
+
* @returns {void}
|
|
278
590
|
*/
|
|
279
591
|
disposeModel() {
|
|
592
|
+
logger.debug('Disposing model resources');
|
|
593
|
+
|
|
594
|
+
// Dispose animation mixer
|
|
280
595
|
if (this.mixer) {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
596
|
+
try {
|
|
597
|
+
this.mixer.stopAllAction();
|
|
598
|
+
if (this.viewer?.avatarMesh) {
|
|
599
|
+
this.mixer.uncacheRoot(this.viewer.avatarMesh);
|
|
600
|
+
}
|
|
601
|
+
} catch (error) {
|
|
602
|
+
logger.error('Error disposing animation mixer', error);
|
|
603
|
+
}
|
|
604
|
+
this.mixer = null;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Dispose animation manager
|
|
608
|
+
if (this.animManager) {
|
|
609
|
+
try {
|
|
610
|
+
this.animManager.dispose();
|
|
611
|
+
} catch (error) {
|
|
612
|
+
logger.error('Error disposing animation manager', error);
|
|
613
|
+
}
|
|
614
|
+
this.animManager = null;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Dispose viewer
|
|
618
|
+
if (this.viewer) {
|
|
619
|
+
try {
|
|
620
|
+
this.viewer.dispose();
|
|
621
|
+
} catch (error) {
|
|
622
|
+
logger.error('Error disposing viewer', error);
|
|
284
623
|
}
|
|
285
|
-
this.
|
|
286
|
-
this.animManager?.dispose();
|
|
624
|
+
this.viewer = null;
|
|
287
625
|
}
|
|
288
|
-
|
|
626
|
+
|
|
627
|
+
logger.debug('Model resources disposed');
|
|
289
628
|
}
|
|
290
629
|
|
|
291
630
|
/**
|
|
@@ -386,7 +725,10 @@ export class GaussianSplatRenderer {
|
|
|
386
725
|
this.chatState = this.getChatState();
|
|
387
726
|
// DEBUG: Log state transitions
|
|
388
727
|
if (!this._lastLoggedState || this._lastLoggedState !== this.chatState) {
|
|
389
|
-
|
|
728
|
+
logger.debug('Chat state changed', {
|
|
729
|
+
newState: this.chatState,
|
|
730
|
+
hasAnimManager: !!this.animManager
|
|
731
|
+
});
|
|
390
732
|
this._lastLoggedState = this.chatState;
|
|
391
733
|
}
|
|
392
734
|
this.animManager?.update(this.chatState);
|
|
@@ -397,32 +739,34 @@ export class GaussianSplatRenderer {
|
|
|
397
739
|
this.expressionData = this.updateBS(this.getExpressionData());
|
|
398
740
|
}
|
|
399
741
|
|
|
400
|
-
//
|
|
401
|
-
if (this.
|
|
402
|
-
if (!this.
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
742
|
+
// Animation mixer update
|
|
743
|
+
if (!this.mixer || !this.animManager) {
|
|
744
|
+
if (!this._warnedOnce) {
|
|
745
|
+
logger.warn('Mixer or animManager not initialized, skipping animation update', {
|
|
746
|
+
hasMixer: !!this.mixer,
|
|
747
|
+
hasAnimManager: !!this.animManager
|
|
748
|
+
});
|
|
749
|
+
this._warnedOnce = true;
|
|
750
|
+
}
|
|
751
|
+
// Still update expressions even without mixer/animManager
|
|
752
|
+
this.setExpression();
|
|
753
|
+
} else {
|
|
754
|
+
const mixerUpdateDelta = this.clock.getDelta();
|
|
755
|
+
this.mixer.update(mixerUpdateDelta);
|
|
756
|
+
|
|
757
|
+
// Apply motion config offsets/scales
|
|
758
|
+
if (this.motioncfg) {
|
|
759
|
+
for (const morphTarget in this.expressionData) {
|
|
760
|
+
const offset = this.motioncfg.offset?.[morphTarget];
|
|
761
|
+
const scale = this.motioncfg.scale?.[morphTarget];
|
|
762
|
+
if (offset !== undefined && scale !== undefined) {
|
|
763
|
+
this.expressionData[morphTarget] =
|
|
764
|
+
this.expressionData[morphTarget] * scale + offset;
|
|
421
765
|
}
|
|
422
766
|
}
|
|
423
|
-
|
|
424
|
-
this.setExpression();
|
|
425
767
|
}
|
|
768
|
+
|
|
769
|
+
this.setExpression();
|
|
426
770
|
}
|
|
427
771
|
|
|
428
772
|
// Update viewer
|
|
@@ -431,7 +775,7 @@ export class GaussianSplatRenderer {
|
|
|
431
775
|
// Render if needed
|
|
432
776
|
const shouldRender = this.viewer.shouldRender();
|
|
433
777
|
if (this._renderLogCount <= 3) {
|
|
434
|
-
|
|
778
|
+
logger.debug('shouldRender check', { shouldRender });
|
|
435
779
|
}
|
|
436
780
|
if (shouldRender) {
|
|
437
781
|
this.viewer.render();
|
|
@@ -465,6 +809,19 @@ export class GaussianSplatRenderer {
|
|
|
465
809
|
// Update splat mesh blendshapes
|
|
466
810
|
if (this.viewer?.splatMesh) {
|
|
467
811
|
this.viewer.splatMesh.bsWeight = this.expressionData;
|
|
812
|
+
|
|
813
|
+
// Update eye blink uniforms for smooth iris fade
|
|
814
|
+
const material = this.viewer.splatMesh.material;
|
|
815
|
+
if (material?.uniforms) {
|
|
816
|
+
const eyeBlinkLeft = this.expressionData.eyeBlinkLeft || 0.0;
|
|
817
|
+
const eyeBlinkRight = this.expressionData.eyeBlinkRight || 0.0;
|
|
818
|
+
if (material.uniforms.eyeBlinkLeft) {
|
|
819
|
+
material.uniforms.eyeBlinkLeft.value = eyeBlinkLeft;
|
|
820
|
+
}
|
|
821
|
+
if (material.uniforms.eyeBlinkRight) {
|
|
822
|
+
material.uniforms.eyeBlinkRight.value = eyeBlinkRight;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
468
825
|
}
|
|
469
826
|
|
|
470
827
|
// Update morph targets on avatar model
|
|
@@ -490,70 +847,7 @@ export class GaussianSplatRenderer {
|
|
|
490
847
|
}
|
|
491
848
|
|
|
492
849
|
/**
|
|
493
|
-
* Load
|
|
494
|
-
* @param {string} pathName - Path within ZIP
|
|
495
|
-
* @param {object} motionConfig - Motion configuration
|
|
496
|
-
*/
|
|
497
|
-
async loadFlameModel(pathName, motionConfig) {
|
|
498
|
-
// Load all required files in parallel
|
|
499
|
-
const [skinModel, lbs_weight_80k, flame_params, indexes, bone_tree] = await Promise.all([
|
|
500
|
-
this.unpackAndLoadGlb(pathName + '/skin.glb'),
|
|
501
|
-
this.unpackAndLoadJson(pathName + '/lbs_weight_20k.json'),
|
|
502
|
-
this.unpackAndLoadJson(pathName + '/flame_params.json'),
|
|
503
|
-
this.unpackAndLoadJson(pathName + '/vertex_order.json'),
|
|
504
|
-
this.unpackAndLoadJson(pathName + '/bone_tree.json')
|
|
505
|
-
]);
|
|
506
|
-
|
|
507
|
-
if (!this.viewer) {
|
|
508
|
-
throw new Error('render viewer is not initialized');
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// Find skinned mesh and bone root
|
|
512
|
-
let skinModelSkinnedMesh;
|
|
513
|
-
let boneRoot;
|
|
514
|
-
|
|
515
|
-
skinModel.traverse((object) => {
|
|
516
|
-
if (object.isSkinnedMesh) {
|
|
517
|
-
skinModelSkinnedMesh = object;
|
|
518
|
-
}
|
|
519
|
-
if (object instanceof Bone && object.name === 'hip') {
|
|
520
|
-
boneRoot = object;
|
|
521
|
-
}
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
// Set viewer properties
|
|
525
|
-
this.viewer.sortedIndexes = indexes;
|
|
526
|
-
this.viewer.flame_params = flame_params;
|
|
527
|
-
this.viewer.lbs_weight_80k = lbs_weight_80k;
|
|
528
|
-
this.viewer.bone_tree = bone_tree;
|
|
529
|
-
this.viewer.totalFrames = flame_params['expr']?.length || 1;
|
|
530
|
-
|
|
531
|
-
if (skinModelSkinnedMesh) {
|
|
532
|
-
this.viewer.gaussianSplatCount = skinModelSkinnedMesh.geometry.attributes.position.count;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
this.viewer.avatarMesh = skinModel;
|
|
536
|
-
this.viewer.skinModel = skinModelSkinnedMesh;
|
|
537
|
-
this.viewer.boneRoot = boneRoot;
|
|
538
|
-
this.motioncfg = motionConfig;
|
|
539
|
-
|
|
540
|
-
// Update morph targets
|
|
541
|
-
if (skinModelSkinnedMesh) {
|
|
542
|
-
this.viewer.updateMorphTarget(skinModelSkinnedMesh);
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
// Add to scene (hidden)
|
|
546
|
-
this.viewer.threeScene.add(skinModel);
|
|
547
|
-
skinModel.visible = false;
|
|
548
|
-
|
|
549
|
-
// Compute bone texture
|
|
550
|
-
if (skinModelSkinnedMesh) {
|
|
551
|
-
skinModelSkinnedMesh.skeleton.computeBoneTexture();
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
/**
|
|
556
|
-
* Load non-FLAME model with animation
|
|
850
|
+
* Load model with animation
|
|
557
851
|
* @param {string} pathName - Path within ZIP
|
|
558
852
|
* @param {object} animationConfig - Animation configuration
|
|
559
853
|
* @param {object} motionConfig - Motion configuration
|
|
@@ -602,7 +896,7 @@ export class GaussianSplatRenderer {
|
|
|
602
896
|
} else {
|
|
603
897
|
this.viewer.totalFrames = 1;
|
|
604
898
|
}
|
|
605
|
-
|
|
899
|
+
logger.debug('Total frames calculated', { totalFrames: this.viewer.totalFrames });
|
|
606
900
|
|
|
607
901
|
if (skinModelSkinnedMesh) {
|
|
608
902
|
this.viewer.updateMorphTarget(skinModelSkinnedMesh);
|
|
@@ -617,36 +911,193 @@ export class GaussianSplatRenderer {
|
|
|
617
911
|
}
|
|
618
912
|
|
|
619
913
|
/**
|
|
620
|
-
* Unpack file from ZIP
|
|
621
|
-
*
|
|
622
|
-
*
|
|
914
|
+
* Unpack file from ZIP and create a blob URL
|
|
915
|
+
*
|
|
916
|
+
* Uses BlobUrlManager to track blob URLs for automatic cleanup.
|
|
917
|
+
* Caches URLs for repeated access to the same file.
|
|
918
|
+
*
|
|
919
|
+
* @param {string} path - Path to file within ZIP archive
|
|
920
|
+
* @returns {Promise<string>} Blob URL to the file
|
|
921
|
+
* @throws {AssetLoadError} If file cannot be unpacked
|
|
623
922
|
*/
|
|
624
923
|
async unpackFileAsBlob(path) {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
924
|
+
this._assertNotDisposed();
|
|
925
|
+
|
|
926
|
+
// Return cached URL if available
|
|
927
|
+
if (this.zipUrls.urls.has(path)) {
|
|
928
|
+
logger.debug('Returning cached blob URL', { path });
|
|
929
|
+
return this.zipUrls.urls.get(path);
|
|
631
930
|
}
|
|
632
|
-
|
|
931
|
+
|
|
932
|
+
logger.debug('Unpacking file from ZIP', { path });
|
|
933
|
+
|
|
934
|
+
// Extract file from ZIP
|
|
935
|
+
const fileEntry = this.zipUrls.zip?.file(path);
|
|
936
|
+
if (!fileEntry) {
|
|
937
|
+
throw new AssetLoadError(
|
|
938
|
+
`File not found in ZIP archive: ${path}`,
|
|
939
|
+
path
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
let modelFile;
|
|
944
|
+
try {
|
|
945
|
+
modelFile = await fileEntry.async('blob');
|
|
946
|
+
} catch (error) {
|
|
947
|
+
throw new AssetLoadError(
|
|
948
|
+
`Failed to extract file from ZIP: ${error.message}`,
|
|
949
|
+
path,
|
|
950
|
+
error
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
if (!modelFile) {
|
|
955
|
+
throw new AssetLoadError(
|
|
956
|
+
`File extracted but blob is empty: ${path}`,
|
|
957
|
+
path
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Create blob URL using BlobUrlManager for tracking
|
|
962
|
+
const mimeType = this._getMimeType(path);
|
|
963
|
+
const modelUrl = this._blobUrlManager.createBlobUrl(
|
|
964
|
+
modelFile,
|
|
965
|
+
mimeType,
|
|
966
|
+
`zip:${path}`
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
// Cache for future access
|
|
970
|
+
this.zipUrls.urls.set(path, modelUrl);
|
|
971
|
+
logger.debug('Blob URL created and cached', { path, url: modelUrl.substring(0, 50) });
|
|
972
|
+
|
|
973
|
+
return modelUrl;
|
|
633
974
|
}
|
|
634
975
|
|
|
635
976
|
/**
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
* @
|
|
977
|
+
* Load JSON file from ZIP archive
|
|
978
|
+
*
|
|
979
|
+
* @param {string} path - Path to JSON file within ZIP archive
|
|
980
|
+
* @returns {Promise<Object|null>} Parsed JSON object, or null if file doesn't exist
|
|
981
|
+
* @throws {ParseError} If JSON parsing fails
|
|
982
|
+
* @private
|
|
983
|
+
*/
|
|
984
|
+
async _loadJsonFromZip(path) {
|
|
985
|
+
this._assertNotDisposed();
|
|
986
|
+
|
|
987
|
+
logger.debug('Attempting to load JSON from ZIP', { path });
|
|
988
|
+
|
|
989
|
+
// Check if file exists in ZIP
|
|
990
|
+
const fileEntry = this.zipUrls.zip?.file(path);
|
|
991
|
+
if (!fileEntry) {
|
|
992
|
+
logger.debug('JSON file not found in ZIP, returning null', { path });
|
|
993
|
+
return null;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Extract file as text
|
|
997
|
+
let jsonText;
|
|
998
|
+
try {
|
|
999
|
+
jsonText = await fileEntry.async('text');
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
throw new ParseError(
|
|
1002
|
+
`Failed to extract JSON file from ZIP: ${error.message}`,
|
|
1003
|
+
path,
|
|
1004
|
+
error
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Parse JSON
|
|
1009
|
+
try {
|
|
1010
|
+
const jsonData = JSON.parse(jsonText);
|
|
1011
|
+
logger.debug('JSON file loaded successfully', { path });
|
|
1012
|
+
return jsonData;
|
|
1013
|
+
} catch (error) {
|
|
1014
|
+
throw new ParseError(
|
|
1015
|
+
`Failed to parse JSON file: ${error.message}`,
|
|
1016
|
+
path,
|
|
1017
|
+
error
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Unpack GLB file from ZIP and load it
|
|
1024
|
+
*
|
|
1025
|
+
* @param {string} path - Path to GLB file within ZIP archive
|
|
1026
|
+
* @returns {Promise<THREE.Group|THREE.AnimationClip[]>} Loaded GLTF model
|
|
1027
|
+
* @throws {AssetLoadError} If file cannot be unpacked or loaded
|
|
639
1028
|
*/
|
|
640
1029
|
async unpackAndLoadGlb(path) {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
1030
|
+
this._assertNotDisposed();
|
|
1031
|
+
|
|
1032
|
+
// Return cached URL if available
|
|
1033
|
+
if (this.zipUrls.urls.has(path)) {
|
|
1034
|
+
logger.debug('Using cached GLB URL', { path });
|
|
1035
|
+
return this.LoadGLTF(this.zipUrls.urls.get(path));
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
logger.debug('Unpacking GLB from ZIP', { path });
|
|
1039
|
+
|
|
1040
|
+
// Extract file from ZIP as ArrayBuffer
|
|
1041
|
+
const fileEntry = this.zipUrls.zip?.file(path);
|
|
1042
|
+
if (!fileEntry) {
|
|
1043
|
+
throw new AssetLoadError(
|
|
1044
|
+
`GLB file not found in ZIP archive: ${path}`,
|
|
1045
|
+
path
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
let modelFile;
|
|
1050
|
+
try {
|
|
1051
|
+
modelFile = await fileEntry.async('arraybuffer');
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
throw new AssetLoadError(
|
|
1054
|
+
`Failed to extract GLB from ZIP: ${error.message}`,
|
|
1055
|
+
path,
|
|
1056
|
+
error
|
|
1057
|
+
);
|
|
648
1058
|
}
|
|
649
|
-
|
|
1059
|
+
|
|
1060
|
+
if (!modelFile) {
|
|
1061
|
+
throw new AssetLoadError(
|
|
1062
|
+
`GLB extracted but ArrayBuffer is empty: ${path}`,
|
|
1063
|
+
path
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Create blob URL using BlobUrlManager
|
|
1068
|
+
const blob = new Blob([modelFile], { type: 'model/gltf-binary' });
|
|
1069
|
+
const modelUrl = this._blobUrlManager.createBlobUrl(
|
|
1070
|
+
blob,
|
|
1071
|
+
'model/gltf-binary',
|
|
1072
|
+
`zip:${path}`
|
|
1073
|
+
);
|
|
1074
|
+
|
|
1075
|
+
// Cache for future access
|
|
1076
|
+
this.zipUrls.urls.set(path, modelUrl);
|
|
1077
|
+
logger.debug('GLB blob URL created and cached', { path });
|
|
1078
|
+
|
|
1079
|
+
return this.LoadGLTF(modelUrl);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Get MIME type from file extension
|
|
1084
|
+
* @private
|
|
1085
|
+
* @param {string} path - File path
|
|
1086
|
+
* @returns {string} MIME type
|
|
1087
|
+
*/
|
|
1088
|
+
_getMimeType(path) {
|
|
1089
|
+
const extension = path.split('.').pop()?.toLowerCase();
|
|
1090
|
+
const mimeTypes = {
|
|
1091
|
+
'ply': 'model/ply',
|
|
1092
|
+
'glb': 'model/gltf-binary',
|
|
1093
|
+
'gltf': 'model/gltf+json',
|
|
1094
|
+
'json': 'application/json',
|
|
1095
|
+
'bin': 'application/octet-stream',
|
|
1096
|
+
'png': 'image/png',
|
|
1097
|
+
'jpg': 'image/jpeg',
|
|
1098
|
+
'jpeg': 'image/jpeg'
|
|
1099
|
+
};
|
|
1100
|
+
return mimeTypes[extension] || 'application/octet-stream';
|
|
650
1101
|
}
|
|
651
1102
|
|
|
652
1103
|
/**
|