rayzee 9.4.1 → 9.5.0

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 (145) hide show
  1. package/README.md +226 -25
  2. package/dist/ActiveColor-YvuQq44O.js +13 -0
  3. package/dist/ArchiveImporter-xuUJiklP.js +4301 -0
  4. package/dist/AssetConfig-DSEntE65.js +30 -0
  5. package/dist/BidirectionalIntegrator-C5PKjJoT.js +934 -0
  6. package/dist/ColorManagement-CheBCPaW.js +1488 -0
  7. package/dist/EngineDefaults-DH3bI5t-.js +844 -0
  8. package/dist/EngineEvents-Ci8dVWCP.js +73 -0
  9. package/dist/EngineIssues-CIZnXHsF.js +72 -0
  10. package/dist/FileFormats-sPGJeCRc.js +102 -0
  11. package/dist/Logger-BnHghprG.js +97 -0
  12. package/dist/{NeuralRetouch-vkdXXyK2.js → NeuralRetouch-CW11yLZ_.js} +8 -6
  13. package/dist/{NeuralSuperRes-FVqRJKQV.js → NeuralSuperRes-BekaecYg.js} +55 -120
  14. package/dist/PathTracer-Ccl9KOuF.js +29729 -0
  15. package/dist/PhysicalSky-Dv2PMFGW.js +1219 -0
  16. package/dist/RayzeeRenderer-BQxnzCTD.js +3542 -0
  17. package/dist/SolarLimb-ken-JbDK.js +721 -0
  18. package/dist/ToneMapGPU-BHi5JzIq.js +177 -0
  19. package/dist/ViewTransforms-D0rTLBsX.js +255 -0
  20. package/dist/WorkingMatrix-C6y233hT.js +27 -0
  21. package/dist/ZipReader-gDSWr0I5.js +98 -0
  22. package/dist/addons/archives.es.js +3 -0
  23. package/dist/addons/bidirectional.es.js +2 -0
  24. package/dist/addons/color.es.js +3 -0
  25. package/dist/addons/formats.es.js +2 -0
  26. package/dist/addons/physical-sky.es.js +2 -0
  27. package/dist/addons/storage.es.js +3 -0
  28. package/dist/archiveFormats-BZw7TiDp.js +75 -0
  29. package/dist/identity-DrQsGT2o.js +39 -0
  30. package/dist/openStorage-DwcBRUj7.js +820 -0
  31. package/dist/rayzee-core.es.js +12 -0
  32. package/dist/rayzee.es.js +2266 -44125
  33. package/dist/rayzee.umd.js +485 -473
  34. package/dist/sceneCachePolicy-BmwYOYDu.js +183 -0
  35. package/dist/shared-BGBcSxBQ.js +17 -0
  36. package/package.json +23 -2
  37. package/src/Color/ActiveColor.js +34 -0
  38. package/src/Color/BasicColor.js +147 -0
  39. package/src/Color/ColorManagement.js +7 -31
  40. package/src/EngineDefaults.js +23 -14
  41. package/src/EngineEvents.js +2 -0
  42. package/src/EngineIssues.js +1 -0
  43. package/src/PathTracerApp.js +617 -4016
  44. package/src/Pipeline/PipelineContext.js +0 -4
  45. package/src/Platform.js +36 -0
  46. package/src/Processor/ArchiveCache.js +1 -1
  47. package/src/Processor/ArchiveImporter.js +1259 -0
  48. package/src/Processor/AssetLoader.js +322 -1746
  49. package/src/Processor/AtmosphereModel.js +1 -2
  50. package/src/Processor/BVHBuilder.js +4 -95
  51. package/src/Processor/EnvironmentExactTable.js +187 -0
  52. package/src/Processor/EquirectHDRInfo.js +30 -223
  53. package/src/Processor/FileFormats.js +116 -0
  54. package/src/Processor/GLTFDecoders.js +85 -0
  55. package/src/Processor/GLTFDiffuseTransmission.js +63 -0
  56. package/src/Processor/GeometryExtractor.js +12 -1
  57. package/src/Processor/IESParser.js +47 -35
  58. package/src/Processor/KernelManager.js +35 -2
  59. package/src/Processor/MaterialPacking.js +8 -7
  60. package/src/Processor/PBRT/PBRTMaterials.js +129 -41
  61. package/src/Processor/PBRT/PBRTParser.js +30 -17
  62. package/src/Processor/PBRT/PBRTSceneBuilder.js +548 -37
  63. package/src/Processor/PBRT/PBRTTextureBake.js +154 -0
  64. package/src/Processor/PBRT/PFM.js +59 -0
  65. package/src/Processor/PackedRayBuffer.js +116 -9
  66. package/src/Processor/ParallelBVHBuilder.js +3 -6
  67. package/src/Processor/PhysicalSky.js +5 -4
  68. package/src/Processor/PlatformImageLoader.js +50 -6
  69. package/src/Processor/QueueManager.js +12 -3
  70. package/src/Processor/ReinsertionOptimizer.js +2 -2
  71. package/src/Processor/SceneProcessor.js +15 -55
  72. package/src/Processor/ShaderBuilder.js +6 -16
  73. package/src/Processor/SimpleSky.js +1 -62
  74. package/src/Processor/SolarLimb.js +3 -0
  75. package/src/Processor/TextureCreator.js +1 -1
  76. package/src/Processor/Workers/BVHSubtreeWorker.js +1 -34
  77. package/src/Processor/Workers/BVHWorker.js +5 -18
  78. package/src/Processor/Workers/CDFWorker.js +6 -183
  79. package/src/Processor/archiveFormats.js +7 -0
  80. package/src/Processor/utils.js +2 -1
  81. package/src/RayzeeRenderer.js +3902 -0
  82. package/src/RenderSettings.js +49 -41
  83. package/src/Stages/ASVGF.js +5 -7
  84. package/src/Stages/AutoExposure.js +3 -12
  85. package/src/Stages/Compositor.js +8 -23
  86. package/src/Stages/NRD.js +9 -2
  87. package/src/Stages/NormalDepth.js +9 -6
  88. package/src/Stages/PathTracer.js +254 -38
  89. package/src/Stages/PathTracerStage.js +11 -16
  90. package/src/Storage/BLASCache.js +1 -1
  91. package/src/Storage/CDFCache.js +18 -17
  92. package/src/Storage/DownloadCache.js +1 -1
  93. package/src/Storage/SceneGraphCodec.js +46 -3
  94. package/src/Storage/SpillStore.js +1 -1
  95. package/src/Storage/StorageManager.js +2 -4
  96. package/src/Storage/areas.js +4 -0
  97. package/src/TSL/BVHTraversal.js +62 -24
  98. package/src/TSL/Bidirectional.js +204 -0
  99. package/src/TSL/BidirectionalLamps.js +132 -0
  100. package/src/TSL/Clearcoat.js +15 -6
  101. package/src/TSL/Common.js +19 -14
  102. package/src/TSL/ConnectKernel.js +147 -0
  103. package/src/TSL/Debugger.js +1 -1
  104. package/src/TSL/Displacement.js +4 -3
  105. package/src/TSL/EmissiveSampling.js +64 -17
  106. package/src/TSL/Environment.js +77 -106
  107. package/src/TSL/EnvironmentCDF.js +85 -39
  108. package/src/TSL/ExtendKernel.js +8 -3
  109. package/src/TSL/FinalWriteKernel.js +4 -4
  110. package/src/TSL/GenerateKernel.js +18 -4
  111. package/src/TSL/HitFacet.js +35 -5
  112. package/src/TSL/LightBVHSampling.js +40 -9
  113. package/src/TSL/LightGenerateKernel.js +293 -0
  114. package/src/TSL/LightGuide.js +198 -0
  115. package/src/TSL/LightSplatKernel.js +165 -0
  116. package/src/TSL/LightsCore.js +13 -38
  117. package/src/TSL/LightsDirect.js +28 -29
  118. package/src/TSL/LightsIndirect.js +3 -1
  119. package/src/TSL/LightsSampling.js +90 -38
  120. package/src/TSL/MaterialEvaluation.js +113 -31
  121. package/src/TSL/MaterialProperties.js +44 -17
  122. package/src/TSL/MaterialTransmission.js +52 -47
  123. package/src/TSL/MergeKernel.js +218 -0
  124. package/src/TSL/PathTracerCore.js +65 -29
  125. package/src/TSL/Random.js +4 -0
  126. package/src/TSL/SceneResources.js +51 -0
  127. package/src/TSL/ShadeKernel.js +881 -103
  128. package/src/TSL/Struct.js +10 -0
  129. package/src/TSL/Sun.js +1 -1
  130. package/src/TSL/TextureSampling.js +65 -60
  131. package/src/addons/archives.js +14 -0
  132. package/src/addons/bidirectional.js +13 -0
  133. package/src/addons/color.js +32 -0
  134. package/src/addons/formats.js +16 -0
  135. package/src/addons/physicalSky.js +14 -0
  136. package/src/addons/storage.js +19 -0
  137. package/src/core.js +35 -0
  138. package/src/index.js +3 -1
  139. package/src/integrators/BidirectionalIntegrator.js +832 -0
  140. package/src/managers/EnvironmentManager.js +94 -91
  141. package/src/managers/MaterialDataManager.js +42 -0
  142. package/src/managers/UniformManager.js +0 -1
  143. package/dist/ToneMapGPU-DKQM1pMd.js +0 -552
  144. package/dist/rolldown-runtime-CAFD8bLK.js +0 -11
  145. package/src/Processor/TreeletOptimizer.js +0 -574
@@ -1,71 +1,42 @@
1
1
  import { Box3, BufferGeometry, Vector3, RectAreaLight, Color, FloatType, LinearFilter, EquirectangularReflectionMapping,
2
- TextureLoader, Texture, SRGBColorSpace, RepeatWrapping, Mesh, MeshStandardMaterial, MeshPhysicalMaterial,
3
- CircleGeometry, Points, PointsMaterial, LoadingManager, EventDispatcher, LoaderUtils
2
+ TextureLoader, SRGBColorSpace, Mesh, MeshPhysicalMaterial,
3
+ CircleGeometry, LoadingManager, EventDispatcher, LoaderUtils
4
4
  } from 'three';
5
5
  import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
6
6
  import { HDRLoader } from 'three/addons/loaders/HDRLoader.js';
7
- import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
8
- import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
9
- import { EXRLoader } from 'three/addons/loaders/EXRLoader.js';
10
7
  import { createMeshesFromMultiMaterialMesh } from 'three/addons/utils/SceneUtils.js';
11
8
  import { clone as cloneWithSkeletons } from 'three/addons/utils/SkeletonUtils.js';
12
- import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
13
- import { zipSync, strFromU8 } from 'three/addons/libs/fflate.module.js';
14
- import {
15
- detectArchiveKind, readTarGz, elementFilter, listArchiveElements, openTar, indexTarHeaders } from './ArchiveReader.js';
16
- import { openZip, readZipDirectory } from './ZipReader.js';
17
- import { unpackTarGz, loadTarIndex, saveTarIndex } from './ArchiveCache.js';
18
9
  import { DownloadCache, nameFromUrl, cachedObjectURL } from '../Storage/DownloadCache.js';
19
10
  import { setEnvironmentSource } from '../Storage/CDFCache.js';
20
- import { fileIdentity, identityKey, sampleHash } from '../Storage/identity.js';
21
- import { ENGINE_AREAS } from '../Storage/StorageManager.js';
22
- import { encodeSceneGraph, writeSceneGraph, decodeSceneGraph, SceneGraphUnsupported, SCENE_GRAPH_FORMAT, ARCHIVE_PATH, ARCHIVE_LOADER } from '../Storage/SceneGraphCodec.js';
23
- import { worthStoring } from '../Storage/sceneCachePolicy.js';
24
- import { VERSION } from '../version.js';
11
+ import { fileIdentity, identityKey } from '../Storage/identity.js';
25
12
  import { disposeEngineOwnedResources, disposeObjectFromMemory, updateLoading } from './utils';
26
13
  import { BuildTimer } from './BuildTimer.js';
27
- import { getAssetConfig } from '../AssetConfig.js';
28
- import { getPlatform } from '../Platform.js';
29
- import { loadPlatformImage, platformImagesPlugin } from './PlatformImageLoader.js';
30
- import { loadPBRTScene, pickEntryPath, VirtualFS, PBRT_BUILD_REVISION } from './PBRT/index.js';
14
+ import { getPlatform, hasImageDecoder, withHostWorker } from '../Platform.js';
15
+ import { loadPlatformImage, platformImagesPlugin, missingImageDecoderPlugin } from './PlatformImageLoader.js';
16
+ import { diffuseTransmissionPlugin } from './GLTFDiffuseTransmission.js';
17
+ import { decodersOnDemand } from './GLTFDecoders.js';
31
18
  import { extractSceneMetadata } from './SceneMetadata.js';
32
- import { ISSUE_CODES, ISSUE_SEVERITY } from '../EngineIssues.js';
19
+ import { ISSUE_CODES } from '../EngineIssues.js';
33
20
  import { getRenderProfile } from '../EngineDefaults.js';
34
21
 
35
22
  // Define supported file formats
36
- const SUPPORTED_FORMATS = {
23
+ // What the core reads itself; every other format is registered (registerFormat) or comes with an add-on.
24
+ const CORE_FORMATS = {
37
25
  'glb': { type: 'model', name: 'GLB (GLTF Binary)' }, 'gltf': { type: 'model', name: 'GLTF' },
38
- 'fbx': { type: 'model', name: 'FBX' }, 'obj': { type: 'model', name: 'OBJ' },
39
- 'stl': { type: 'model', name: 'STL' }, 'ply': { type: 'model', name: 'PLY (Polygon File Format)' },
40
- 'dae': { type: 'model', name: 'Collada' }, '3mf': { type: 'model', name: '3D Manufacturing Format' },
41
- 'usd': { type: 'model', name: 'USD (Universal Scene Description)' },
42
- 'usda': { type: 'model', name: 'USDA (USD ASCII)' },
43
- 'usdc': { type: 'model', name: 'USDC (USD Crate)' },
44
- 'usdz': { type: 'model', name: 'USDZ (USD Archive)' },
45
- 'hdr': { type: 'environment', name: 'HDR (High Dynamic Range)' }, 'exr': { type: 'environment', name: 'EXR (OpenEXR)' },
26
+ 'hdr': { type: 'environment', name: 'HDR (High Dynamic Range)' },
46
27
  'png': { type: 'image', name: 'PNG' }, 'jpg': { type: 'image', name: 'JPEG' },
47
28
  'jpeg': { type: 'image', name: 'JPEG' }, 'webp': { type: 'image', name: 'WebP' },
48
- 'zip': { type: 'archive', name: 'ZIP Archive' },
49
- 'gz': { type: 'archive', name: 'Gzipped TAR Archive' },
50
- 'tgz': { type: 'archive', name: 'Gzipped TAR Archive' },
51
- 'tar': { type: 'archive', name: 'TAR Archive' }
52
29
  };
53
30
 
54
- // Loose USD layers inside a ZIP compose into one scene; these pick out the
55
- // layers and the image assets they reference.
56
- const USD_LAYER_RE = /\.(usd|usda|usdc)$/i;
57
- const USD_IMAGE_RE = /\.(png|jpg|jpeg|avif)$/i;
58
- const MTL_TEXTURE_TIMEOUT_MS = 30000;
59
- /**
60
- * Unpacked size past which a multi-part scene archive asks which parts to load rather than
61
- * taking all of them. Not a hard limit — picking every part is a valid answer.
62
- *
63
- * Measured on Moana: a 7.5 GB archive of 15 parts parses to 40M triangles and ~7.3 GB of CPU
64
- * memory, which loads on a freshly started browser and fails on one that has been up a while.
65
- * Anything near that is worth a question, because the failure past it kills the tab rather than
66
- * throwing. Override per load with `promptBytes`.
67
- */
68
- export const ARCHIVE_ELEMENT_PROMPT_BYTES = 4_000_000_000;
31
+ // Formats an add-on reads, for the error that names it.
32
+ const ARCHIVES = { addOn: 'rayzee/addons/archives', install: 'assetLoader.setArchiveImporter()' };
33
+ const FORMATS = { addOn: 'rayzee/addons/formats', install: 'assetLoader.registerFormat()' };
34
+ const ADDON_FORMATS = {
35
+ zip: ARCHIVES, gz: ARCHIVES, tgz: ARCHIVES, tar: ARCHIVES,
36
+ fbx: FORMATS, obj: FORMATS, stl: FORMATS, ply: FORMATS, dae: FORMATS, '3mf': FORMATS,
37
+ usd: FORMATS, usda: FORMATS, usdc: FORMATS, usdz: FORMATS, exr: FORMATS,
38
+ };
39
+
69
40
  // A throwaway stand-in for a geometry the engine must not mutate: the split's mergeGroups()
70
41
  // reorders and disposes what it is given, but never writes the attributes.
71
42
  function standInForSplit( source ) {
@@ -130,6 +101,12 @@ export class AssetLoader extends EventDispatcher {
130
101
  this._downloads = null;
131
102
  this._sourceKey = null;
132
103
  this._appended = false;
104
+ /** @type {?import('./ArchiveImporter.js').ArchiveImporter} reads archives; see setArchiveImporter() */
105
+ this.archives = null;
106
+ /** @type {?{load: function(): Promise<Object>, formats: Object, pending: ?Promise}} see setArchiveImporterLoader() */
107
+ this._archiveLoader = null;
108
+ /** @type {Map<string, Object>} extension → a format registerFormat() added */
109
+ this._formats = new Map();
133
110
 
134
111
  this._issues = issues;
135
112
  this._profile = profile ?? getRenderProfile();
@@ -321,7 +298,7 @@ export class AssetLoader extends EventDispatcher {
321
298
  this.sceneMetadata = null;
322
299
  this._sourceKey = null;
323
300
  this._appended = false;
324
- this._pendingGraph = null;
301
+ this.archives?.release();
325
302
 
326
303
  if ( ! this.targetModel ) return;
327
304
 
@@ -340,11 +317,88 @@ export class AssetLoader extends EventDispatcher {
340
317
 
341
318
  }
342
319
 
320
+ /**
321
+ * Installs the importer that reads scene archives and the pbrt scenes in them — `ArchiveImporter` from
322
+ * rayzee/addons/archives. Without one, archives are not a supported format.
323
+ * @param {?import('./ArchiveImporter.js').ArchiveImporter} importer
324
+ */
325
+ setArchiveImporter( importer ) {
326
+
327
+ this.archives = importer;
328
+
329
+ }
330
+
331
+ /**
332
+ * Installs the archive importer on first use: `load()` resolves to one, the first time an archive is read. `formats`
333
+ * are the extensions it reads (its class's `FORMATS`), known before its code is loaded.
334
+ * @param {function(): Promise<import('./ArchiveImporter.js').ArchiveImporter>} load
335
+ * @param {Object<string, {type: string, name: string}>} formats
336
+ */
337
+ setArchiveImporterLoader( load, formats ) {
338
+
339
+ this._archiveLoader = { load, formats, pending: null };
340
+
341
+ }
342
+
343
+ /** The archive importer, loading it first if it was installed with setArchiveImporterLoader(). @private */
344
+ async _archiveImporter() {
345
+
346
+ if ( ! this.archives && this._archiveLoader ) this.archives = await ( this._archiveLoader.pending ??= this._archiveLoader.load() );
347
+ return this.archives;
348
+
349
+ }
350
+
351
+ /** @private */
352
+ get _archiveFormats() {
353
+
354
+ return this.archives?.constructor.FORMATS ?? this._archiveLoader?.formats ?? null;
355
+
356
+ }
357
+
358
+ /**
359
+ * Adds file formats beyond glTF and .hdr — those of rayzee/addons/formats, or a host's own. Each is
360
+ * `{ name, label, type: 'model' | 'environment', extensions, parse | createLoader }` (see Processor/FileFormats.js).
361
+ * @param {...Object} formats
362
+ * @returns {this}
363
+ */
364
+ registerFormat( ...formats ) {
365
+
366
+ for ( const format of formats ) for ( const extension of format.extensions ) this._formats.set( extension, format );
367
+ return this;
368
+
369
+ }
370
+
343
371
  // File utilities
344
372
  getFileFormat( filename ) {
345
373
 
346
374
  const extension = filename.split( '.' ).pop().toLowerCase();
347
- return SUPPORTED_FORMATS[ extension ] || null;
375
+ return CORE_FORMATS[ extension ] || this._formats.get( extension ) || this._archiveFormats?.[ extension ] || null;
376
+
377
+ }
378
+
379
+ /** The error for a file no installed reader takes, naming the add-on that would. */
380
+ formatError( filename ) {
381
+
382
+ const addOn = ADDON_FORMATS[ filename.split( '.' ).pop().toLowerCase() ];
383
+ return new Error( addOn
384
+ ? `${filename}: this format is read by ${addOn.addOn} — install it with ${addOn.install}`
385
+ : `Unsupported file format: ${filename}` );
386
+
387
+ }
388
+
389
+ /** @see ArchiveImporter#inspectArchive */
390
+ async inspectArchive( file ) {
391
+
392
+ const archives = await this._archiveImporter();
393
+ if ( ! archives ) throw this.formatError( file.name ?? 'archive.zip' );
394
+ return await archives.inspectArchive( file );
395
+
396
+ }
397
+
398
+ /** @see ArchiveImporter#flushPendingGraph */
399
+ flushPendingGraph( buildMs ) {
400
+
401
+ this.archives?.flushPendingGraph( buildMs );
348
402
 
349
403
  }
350
404
 
@@ -366,7 +420,7 @@ export class AssetLoader extends EventDispatcher {
366
420
 
367
421
  const filename = file.name;
368
422
  const format = this.getFileFormat( filename );
369
- if ( ! format ) throw new Error( `Unsupported file format: ${filename}` );
423
+ if ( ! format ) throw this.formatError( filename );
370
424
 
371
425
  updateLoading( { isLoading: true, status: `Loading ${format.name}...`, progress: 2 } );
372
426
  try {
@@ -377,7 +431,7 @@ export class AssetLoader extends EventDispatcher {
377
431
  case 'model': result = await this.loadModelFromFile( file, filename ); break;
378
432
  case 'environment':
379
433
  case 'image': result = await this.loadEnvironmentFromFile( file, filename ); break;
380
- case 'archive': result = await this.loadArchiveFromFile( file, filename, options ); break;
434
+ case 'archive': result = await ( await this._archiveImporter() ).loadArchiveFromFile( file, filename, options ); break;
381
435
  default: throw new Error( `Unknown asset type: ${format.type}` );
382
436
 
383
437
  }
@@ -403,7 +457,7 @@ export class AssetLoader extends EventDispatcher {
403
457
  async loadAssetFromUrl( url, options = {} ) {
404
458
 
405
459
  const { filename = nameFromUrl( url ), cacheKey = url, cachePolicy, ...loadOptions } = options;
406
- if ( ! this.getFileFormat( filename ) ) throw new Error( `Unsupported file format: ${filename}` );
460
+ if ( ! this.getFileFormat( filename ) ) throw this.formatError( filename );
407
461
 
408
462
  this._loadCancelled = false;
409
463
  const cancelable = AssetLoader._isNetworkUrl( url );
@@ -463,23 +517,36 @@ export class AssetLoader extends EventDispatcher {
463
517
  async _loadModelFileByExtension( file, filename ) {
464
518
 
465
519
  const extension = filename.split( '.' ).pop().toLowerCase();
466
- const arrayBuffer = await this.readFileAsArrayBuffer( file );
467
-
468
- switch ( extension ) {
469
-
470
- case 'glb':
471
- case 'gltf': return await this.loadGLBFromArrayBuffer( arrayBuffer, filename );
472
- case 'fbx': return await this.loadFBXFromArrayBuffer( arrayBuffer, filename );
473
- case 'obj': return await this.loadOBJFromFile( file, filename );
474
- case 'stl': return await this.loadSTLFromArrayBuffer( arrayBuffer, filename );
475
- case 'ply': return await this.loadPLYFromArrayBuffer( arrayBuffer, filename );
476
- case 'dae': return await this.loadColladaFromFile( file, filename );
477
- case '3mf': return await this.load3MFFromArrayBuffer( arrayBuffer, filename );
478
- case 'usd':
479
- case 'usda':
480
- case 'usdc':
481
- case 'usdz': return await this.loadUSDFromArrayBuffer( arrayBuffer, filename );
482
- default: throw new Error( `Support for ${extension} files is not yet implemented` );
520
+ if ( extension === 'glb' || extension === 'gltf' ) return await this.loadGLBFromArrayBuffer( await this.readFileAsArrayBuffer( file ), filename );
521
+
522
+ const format = this._formats.get( extension );
523
+ if ( format?.type !== 'model' ) throw this.formatError( filename );
524
+ return await this._loadModelWithFormat( format, file, filename );
525
+
526
+ }
527
+
528
+ async _loadModelWithFormat( format, file, filename ) {
529
+
530
+ try {
531
+
532
+ updateLoading( { isLoading: true, status: `Processing ${format.label} Data...`, progress: 5 } );
533
+ await new Promise( r => setTimeout( r, 0 ) );
534
+
535
+ const { model, result = model } = await format.parse( file, { filename, cache: this.loaderCache } );
536
+ this.releaseTargetModel();
537
+ this.targetModel = model;
538
+
539
+ updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
540
+ await this.onModelLoad( this.targetModel );
541
+
542
+ this.dispatchEvent( { type: 'load', model, filename } );
543
+ return result;
544
+
545
+ } catch ( error ) {
546
+
547
+ console.error( `Error loading ${format.label}:`, error );
548
+ this.dispatchEvent( { type: 'error', message: error.message, filename } );
549
+ throw error;
483
550
 
484
551
  }
485
552
 
@@ -599,7 +666,17 @@ export class AssetLoader extends EventDispatcher {
599
666
 
600
667
  }
601
668
 
602
- async loadEnvironmentByExtension( url, extension ) {
669
+ /**
670
+ * @param {string} url
671
+ * @param {string} extension
672
+ * @param {{loader?: Object}} [options] - a three.js loader to read the file with, for a format nothing registered
673
+ */
674
+ async loadEnvironmentByExtension( url, extension, { loader = null } = {} ) {
675
+
676
+ const format = this._formats.get( extension );
677
+ if ( ! loader && format?.type === 'environment' ) loader = this.loaderCache[ extension ] ??= await format.createLoader( this._loadingManager );
678
+ if ( ! loader && extension === 'hdr' ) loader = this.loaderCache.hdr ??= new HDRLoader( this._loadingManager ).setDataType( FloatType );
679
+ if ( ! loader && ADDON_FORMATS[ extension ] ) throw this.formatError( `environment.${extension}` );
603
680
 
604
681
  const cancelable = AssetLoader._isNetworkUrl( url );
605
682
  const status = "Downloading Environment...";
@@ -609,11 +686,8 @@ export class AssetLoader extends EventDispatcher {
609
686
  let texture;
610
687
  try {
611
688
 
612
- if ( extension === 'hdr' || extension === 'exr' ) {
689
+ if ( loader ) {
613
690
 
614
- const loader = extension === 'hdr'
615
- ? ( this.loaderCache.hdr || ( this.loaderCache.hdr = new HDRLoader( this._loadingManager ).setDataType( FloatType ) ) )
616
- : ( this.loaderCache.exr || ( this.loaderCache.exr = new EXRLoader( this._loadingManager ).setDataType( FloatType ) ) );
617
691
  texture = await loader.loadAsync( source.url, onProgress );
618
692
 
619
693
  } else if ( getPlatform().decodeImage ) {
@@ -653,1756 +727,249 @@ export class AssetLoader extends EventDispatcher {
653
727
 
654
728
  }
655
729
 
656
- // Archive handling
657
- /** First bytes of a file, without pulling the whole thing into memory. */
658
- async _readHead( file, bytes = 512 ) {
730
+ // Returns a fresh loader each call — the DRACOLoader/KTX2Loader a file needs hold persistent
731
+ // worker pools. Callers must invoke _disposeGLTFLoader() to terminate them.
732
+ async createGLTFLoader() {
659
733
 
660
- if ( file instanceof Uint8Array ) return file.subarray( 0, bytes );
661
- if ( typeof file.slice === 'function' && typeof file.arrayBuffer === 'function' ) {
734
+ const loader = decodersOnDemand( new GLTFLoader( this._loadingManager ), this.renderer );
735
+ const onImageFailure = ( where, error ) => this._reportImageFailure( where, error );
736
+ if ( getPlatform().decodeImage ) loader.register( ( parser ) => platformImagesPlugin( parser, onImageFailure ) );
737
+ else if ( ! hasImageDecoder() ) loader.register( ( parser ) => missingImageDecoderPlugin( parser, onImageFailure ) );
738
+ loader.register( ( parser ) => diffuseTransmissionPlugin( parser ) );
662
739
 
663
- return new Uint8Array( await file.slice( 0, bytes ).arrayBuffer() );
740
+ return loader;
664
741
 
665
- }
742
+ }
666
743
 
667
- return new Uint8Array( ( await this.readFileAsArrayBuffer( file ) ).slice( 0, bytes ) );
744
+ // GLTFLoader swallows the failure and loads the model untextured, so a strict host's throw is kept
745
+ // for the end of the load (_throwDeferred).
746
+ _reportImageFailure( where, error ) {
668
747
 
669
- }
748
+ try {
670
749
 
671
- /**
672
- * Enumerates a TAR/TAR.GZ archive without keeping any of it, so a caller can offer a
673
- * choice before committing memory. ZIP archives are random-access and report directly.
674
- * @returns {Promise<{kind:string, root:string|null, elements:Array, entryCount:number, totalBytes:number}>}
675
- */
676
- async inspectArchive( file ) {
750
+ this._issues?.record(
751
+ ISSUE_CODES.TEXTURE_BUILD_FAILED,
752
+ `image ${where} could not be decoded (${error?.message ?? error}) — the surfaces using it render untextured`,
753
+ { image: where, cause: String( error?.message ?? error ) }
754
+ );
677
755
 
678
- const kind = detectArchiveKind( await this._readHead( file ) );
679
- const scanning = p => updateLoading( {
680
- isLoading: true, status: `Scanning archive… ${( p.bytes / 1e9 ).toFixed( 1 )} GB`, progress: 4
681
- } );
756
+ } catch ( strictError ) {
682
757
 
683
- let listing;
684
- if ( kind === 'gzip' ) {
758
+ this._deferredError ??= strictError;
685
759
 
686
- const unpacked = await this._unpackGzip( file, file.name );
687
- if ( unpacked ) {
760
+ }
688
761
 
689
- listing = unpacked.index.listing;
690
- unpacked.release();
762
+ }
691
763
 
692
- } else {
764
+ _throwDeferred() {
693
765
 
694
- ( { listing } = await readTarGz( file, { filter: () => false, onProgress: scanning } ) );
766
+ const error = this._deferredError;
767
+ this._deferredError = null;
768
+ if ( error ) throw error;
695
769
 
696
- }
770
+ }
771
+
772
+ _disposeGLTFLoader( loader ) {
697
773
 
698
- } else if ( kind === 'tar' ) {
774
+ if ( ! loader ) return;
775
+ loader.dracoLoader?.dispose();
776
+ loader.ktx2Loader?.dispose();
699
777
 
700
- listing = ( await loadTarIndex( file, this.storage ) )?.listing ?? await indexTarHeaders( file, { onProgress: scanning } );
778
+ }
701
779
 
702
- } else {
780
+ async loadExampleModels( index, modelFiles ) {
781
+
782
+ if ( ! modelFiles || ! modelFiles[ index ] ) {
703
783
 
704
- listing = ( await readZipDirectory( file ) ).map( e => ( { path: e.name, size: e.size } ) );
784
+ throw new Error( `No model file at index ${index}` );
705
785
 
706
786
  }
707
787
 
708
- const { root, elements } = listArchiveElements( listing );
709
- return {
710
- kind: kind ?? 'zip', root, elements,
711
- entryCount: listing.length,
712
- totalBytes: listing.reduce( ( n, e ) => n + e.size, 0 )
713
- };
788
+ const modelUrl = `${modelFiles[ index ].url}`;
789
+ return await this.loadModel( modelUrl );
714
790
 
715
791
  }
716
792
 
717
793
  /**
718
- * A .tar.gz unpacked to storage, or null to read it the old way (no storage, or no room).
719
- * @private
794
+ * @param {string} modelUrl
795
+ * @param {{cacheKey?: string, cachePolicy?: string}} [options] - key a download cache entry by
796
+ * something steadier than a signed, expiring URL
720
797
  */
721
- async _unpackGzip( file, filename ) {
798
+ async loadModel( modelUrl, options = {} ) {
799
+
800
+ this._loadCancelled = false;
801
+ const loader = await this.createGLTFLoader();
802
+ const cancelable = AssetLoader._isNetworkUrl( modelUrl );
803
+ let source = null;
722
804
 
723
805
  try {
724
806
 
725
- return await unpackTarGz( file, {
726
- storage: this.storage,
727
- label: filename,
728
- onProgress: bytes => updateLoading( {
729
- isLoading: true, status: `Unpacking archive… ${( bytes / 1e9 ).toFixed( 1 )} GB`, progress: 4
730
- } ),
731
- } );
807
+ updateLoading( { isLoading: true, status: "Downloading Model...", progress: 2, canCancel: cancelable, loadedBytes: 0, totalBytes: 0 } );
808
+ source = await this._viaCache( modelUrl, "Downloading Model...", cancelable, options );
809
+ if ( source.cached ) loader.setResourcePath( LoaderUtils.extractUrlBase( modelUrl ) );
810
+ const data = await withHostWorker( () => loader.loadAsync( source.url, source.cached ? undefined : this._downloadProgress( "Downloading Model...", cancelable ) ) );
811
+ this._downloadComplete();
812
+ this._throwDeferred();
813
+
814
+ this.releaseTargetModel();
815
+ this._sourceKey = cancelable ? this._keyed( 'url', options.cacheKey ?? modelUrl ) : null;
816
+
817
+ this.targetModel = data.scene;
818
+ this.animations = data.animations || [];
819
+ this.sceneMetadata = extractSceneMetadata( data );
820
+ await this.onModelLoad( this.targetModel );
821
+ this.dispatchEvent( { type: 'load', model: data.scene, filename: modelUrl.split( '/' ).pop() } );
822
+ return data;
732
823
 
733
824
  } catch ( error ) {
734
825
 
735
- // A corrupt stream fails the same way in memory, where the error reads better.
736
- console.warn( `Unpacking "${filename}" to storage failed, reading it in memory: ${error.message}` );
737
- return null;
826
+ if ( this._isCancellation( error ) ) throw this._cancellationError();
827
+ console.error( "Error loading model:", error );
828
+ this.dispatchEvent( { type: 'error', message: error.message, filename: modelUrl } );
829
+ throw error;
738
830
 
739
- }
831
+ } finally {
740
832
 
741
- }
833
+ source?.release();
834
+ this._disposeGLTFLoader( loader );
742
835
 
743
- /**
744
- * @param {File|Blob} file
745
- * @param {string} filename
746
- * @param {object} [options]
747
- * @param {string} [options.pbrtEntry] - which .pbrt to load when the archive holds several
748
- * independent scenes; the issue log lists what else was available.
749
- * @param {string|string[]} [options.element] - path prefix of one subtree to load on its own,
750
- * or several to load together, as reported by `inspectArchive()`. Everything above them
751
- * (scene file, materials, textures) comes along; subtrees left out are skipped without ever
752
- * being held in memory, and the Includes that point at them only warn.
753
- * @param {number} [options.byteBudget] - cap on retained bytes when no element is chosen.
754
- * @param {number} [options.promptBytes] - size past which a multi-part archive asks which
755
- * parts to load instead of taking all of them.
756
- * @param {number} [options.maxTriangles] - stop past this many STORED triangles.
757
- * @param {number} [options.maxPlacements] - stop past this many instance placements.
758
- * @param {number} [options.mergeShapesAbove] - merge small non-instanced shapes past this count.
759
- * @param {number} [options.curveSteps] - samples per spline span when tessellating curves.
760
- * @param {number} [options.curveSides] - 1 ribbon, 2 crossed ribbons, >=3 closed tube.
761
- * @param {number} [options.curveTolerance] - how far a curve strip may stray from the curve, as a
762
- * fraction of its half-width (default 0.05); 0 samples every span `curveSteps` times.
763
- * @param {boolean} [options.instanceIncludes] - place a file included again under the same
764
- * material as another instance of its first reading (default true).
765
- */
766
- async loadArchiveFromFile( file, filename, { pbrtEntry = null, element = null, byteBudget, promptBytes, ...pbrt } = {} ) {
836
+ }
767
837
 
768
- try {
838
+ }
769
839
 
770
- const kind = detectArchiveKind( await this._readHead( file ) );
771
- const archiveId = this.storage ? identityKey( await fileIdentity( file ) ) : null;
772
- const lazy = { element, promptBytes, pbrtEntry, pbrt, archiveId };
840
+ // ─────────────────────────────────────────────────────────────
841
+ // Append / remove primitives (dynamic object add/remove).
842
+ // These deliberately do NOT touch targetModel/animations and do NOT
843
+ // reframe the camera or dispatch load/modelProcessed — the caller
844
+ // (PathTracerApp) drives a reframe-free scene rebuild.
845
+ // ─────────────────────────────────────────────────────────────
773
846
 
774
- // A seekable archive is indexed rather than read: entries are pulled out as the scene
775
- // asks for them and never all held at once. That is the difference between a 7 GB archive
776
- // being refused and loading. A .tar.gz becomes seekable by unpacking it to storage.
777
- if ( kind === 'tar' ) return await this._loadSeekable( file, filename, lazy );
847
+ // Multi-material split + area-light placeholders, then parent into meshScene.
848
+ _processAndParent( model ) {
778
849
 
779
- if ( kind === 'gzip' ) {
850
+ this.processModelObjects( model );
851
+ this.scene.add( model );
852
+ // Refresh world matrices for the whole subtree — the reframe-free rebuild
853
+ // extracts geometry immediately (rAF is gated off), so nothing else would
854
+ // update matrices first and any ancestor-node transform would be dropped.
855
+ model.updateMatrixWorld( true );
780
856
 
781
- const unpacked = await this._unpackGzip( file, filename );
782
- if ( unpacked ) {
857
+ }
783
858
 
784
- try {
859
+ // Append a model from URL without releasing prior models or reframing.
860
+ // Reuses createGLTFLoader() so appended KTX2 textures stay RGBA DataArrayTexture.
861
+ async appendModel( url, options = {} ) {
785
862
 
786
- return await this._loadSeekable( unpacked.file, filename, { ...lazy, index: unpacked.index } );
863
+ this._loadCancelled = false;
864
+ const loader = await this.createGLTFLoader();
865
+ const cancelable = AssetLoader._isNetworkUrl( url );
866
+ let source = null;
787
867
 
788
- } finally {
868
+ try {
789
869
 
790
- unpacked.release();
870
+ updateLoading( { isLoading: true, status: "Downloading Model...", progress: 2, canCancel: cancelable, loadedBytes: 0, totalBytes: 0 } );
871
+ source = await this._viaCache( url, "Downloading Model...", cancelable, options );
872
+ if ( source.cached ) loader.setResourcePath( LoaderUtils.extractUrlBase( url ) );
873
+ const data = await withHostWorker( () => loader.loadAsync( source.url, source.cached ? undefined : this._downloadProgress( "Downloading Model...", cancelable ) ) );
874
+ this._downloadComplete();
875
+ this._throwDeferred();
876
+ this._appended = true;
877
+ this._processAndParent( data.scene );
878
+ return { root: data.scene, animations: data.animations || [] };
791
879
 
792
- }
880
+ } catch ( error ) {
793
881
 
794
- }
882
+ if ( this._isCancellation( error ) ) throw this._cancellationError();
883
+ throw error;
795
884
 
796
- const entries = await this._readStreamedArchive( file, filename, kind, element, byteBudget );
885
+ } finally {
797
886
 
798
- // A pbrt scene archive takes priority — it owns its own geometry/texture refs.
799
- if ( pickEntryPath( entries ) ) return await this.loadPBRTFromZip( entries, filename, pbrtEntry, pbrt );
800
- const result = await this._loadNonPBRTArchive( entries, filename );
801
- this._sourceKey = this._keyed( 'archive', archiveId );
802
- return result;
887
+ source?.release();
888
+ this._disposeGLTFLoader( loader );
803
889
 
804
- }
890
+ }
805
891
 
806
- return await this._loadSeekable( file, filename, { ...lazy, zip: true } );
892
+ }
807
893
 
808
- } catch ( error ) {
894
+ // Append a copy of a caller-owned Object3D without releasing prior models or reframing.
895
+ appendObject3D( object3d, name = 'object3d' ) {
809
896
 
810
- if ( error?.code !== 'ARCHIVE_NEEDS_ELEMENT' ) console.error( 'Error loading archive:', error );
811
- throw error;
897
+ this._appended = true;
812
898
 
813
- }
899
+ const root = this._adoptExternalObject( object3d );
900
+ root.name = object3d.name || name;
901
+ this._processAndParent( root );
902
+ return { root };
814
903
 
815
904
  }
816
905
 
817
- async _loadNonPBRTArchive( entries, filename ) {
906
+ // Detach + dispose a model root. An adopted root's geometry/materials belong to the
907
+ // caller, so only the engine's own allocations go.
908
+ removeModelRoot( root ) {
818
909
 
819
- const result = await this.processObjMtlPairsInZip( entries, filename );
820
- if ( result ) return result;
821
- return await this.findAndLoadModelFromZip( entries, filename );
910
+ if ( ! root ) return;
822
911
 
823
- }
912
+ if ( root.userData.__rayzeeExternal ) {
824
913
 
825
- /**
826
- * Loads a seekable archive — a .tar (or a .tar.gz unpacked to one) or a .zip — reading each
827
- * entry only when the scene asks for it.
828
- * @private
829
- */
830
- async _loadSeekable( file, filename, { element, promptBytes, pbrtEntry, pbrt, index = null, zip = false, archiveId = null } ) {
914
+ disposeEngineOwnedResources( root );
915
+ root.parent?.remove( root );
831
916
 
832
- const source = await this._openSeekableArchive( file, filename, element, promptBytes, { index, zip } );
833
- source.archiveId = archiveId;
834
- source.elements = ( Array.isArray( element ) ? element : [ element ] ).filter( Boolean );
835
- if ( source.listing.some( e => e.path.toLowerCase().endsWith( '.pbrt' ) ) ) {
917
+ } else {
836
918
 
837
- return await this.loadPBRTFromZip( {}, filename, pbrtEntry, pbrt, source );
919
+ disposeObjectFromMemory( root );
838
920
 
839
921
  }
840
922
 
841
- // Not a pbrt scene: fall back to materialising it, which those paths still expect.
842
- for ( const e of source.listing ) if ( e.offset !== undefined ) source.entries[ e.path ] ??= await source.read( e.path );
843
- const result = await this._loadNonPBRTArchive( source.entries, filename );
844
- this._sourceKey = this._keyed( 'archive', archiveId );
845
- return result;
846
-
847
923
  }
848
924
 
849
- /**
850
- * The parsed scene stored for this archive and these options, decoded — its textures read back
851
- * out of the archive — or null.
852
- * @private
853
- */
854
- async _loadStoredGraph( key, source, { imageFromBytes, envFromBytes } ) {
925
+ async loadGLBFromArrayBuffer( arrayBuffer, filename = 'model.glb' ) {
855
926
 
856
- const area = this.storage?.area( ENGINE_AREAS.SCENES );
857
- const entry = area ? await area.open( key ) : null;
858
- if ( ! entry ) return null;
927
+ const loader = await this.createGLTFLoader();
859
928
 
860
- const start = performance.now();
861
- let failure = null;
862
929
  try {
863
930
 
864
- updateLoading( { isLoading: true, status: 'Opening the stored scene...', progress: 6 } );
865
- const manifest = await entry.json( 'graph.json' );
866
- const data = await entry.file( 'data.bin' );
867
- const vfs = new VirtualFS( {}, source );
868
- const decoded = await decodeSceneGraph( manifest, data, {
869
- loadTexture: async ( path, loader ) => {
931
+ updateLoading( { isLoading: true, status: "Processing GLB Data...", progress: 5 } );
932
+ await new Promise( r => setTimeout( r, 0 ) );
933
+
934
+ const data = await withHostWorker( () => loader.parseAsync( arrayBuffer, '' ) );
935
+ this._throwDeferred();
870
936
 
871
- const bytes = await vfs.readPath( path );
872
- if ( ! bytes ) return null;
873
- return loader === 'environment' ? envFromBytes( bytes, path ) : imageFromBytes( bytes, path );
937
+ this.releaseTargetModel();
874
938
 
875
- },
876
- } );
939
+ this.targetModel = data.scene;
940
+ this.animations = data.animations || [];
941
+ this.sceneMetadata = extractSceneMetadata( data );
942
+ updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
943
+ await this.onModelLoad( this.targetModel );
877
944
 
878
- return {
879
- ...decoded.stats,
880
- group: decoded.root,
881
- environment: decoded.environment ? { texture: decoded.environment } : null,
882
- animations: decoded.animations,
883
- report: null,
884
- parseMs: 0,
885
- buildMs: performance.now() - start,
886
- fromStorage: true,
887
- };
945
+ this.dispatchEvent( { type: 'load', model: data.scene, filename } );
946
+ return data;
888
947
 
889
948
  } catch ( error ) {
890
949
 
891
- failure = error;
892
- return null;
950
+ console.error( 'Error loading GLB:', error );
951
+ this.dispatchEvent( { type: 'error', message: error.message, filename } );
952
+ throw error;
893
953
 
894
954
  } finally {
895
955
 
896
- entry.release();
897
- if ( failure ) {
898
-
899
- console.warn( `The stored scene could not be used, parsing the archive instead: ${failure.message}` );
900
- this._issues?.warn( ISSUE_CODES.STORAGE_ENTRY_CORRUPT, `stored scene unusable: ${failure.message}`, { key } );
901
- area.remove( key ).catch( () => {} );
902
-
903
- }
956
+ this._disposeGLTFLoader( loader );
904
957
 
905
958
  }
906
959
 
907
960
  }
908
961
 
909
- /**
910
- * Stores a freshly parsed scene for the next open. Encoded here, before onModelLoad or the
911
- * build touch it; written in the background.
912
- * @private
913
- */
914
- _storeGraph( key, built, label ) {
915
-
916
- const area = this.storage?.area( ENGINE_AREAS.SCENES );
917
- if ( ! area ) return;
962
+ async loadObject3D( object3d, name = 'object3d' ) {
918
963
 
919
- let encoded;
920
- try {
964
+ this.releaseTargetModel();
921
965
 
922
- encoded = encodeSceneGraph( built.group, {
923
- environment: built.environment?.texture ?? null,
924
- animations: built.animations ?? [],
925
- stats: {
926
- meshCount: built.meshCount, entryPath: built.entryPath, candidates: built.candidates, frames: built.frames,
927
- warnings: built.warnings, triangleCount: built.triangleCount, placementCount: built.placementCount,
928
- mergedShapes: built.mergedShapes, skippedForBudget: built.skippedForBudget, droppedNoTemplate: built.droppedNoTemplate,
929
- },
930
- } );
966
+ const model = this._adoptExternalObject( object3d );
967
+ model.name = object3d.name || name;
931
968
 
932
- } catch ( error ) {
969
+ this.targetModel = model;
933
970
 
934
- if ( error instanceof SceneGraphUnsupported ) console.info( `Scene not stored for next time: ${error.message}` );
935
- else console.warn( 'Scene not stored for next time:', error );
936
- return;
937
-
938
- }
939
-
940
- const pending = { key, encoded, label, parseMs: ( built.parseMs ?? 0 ) + ( built.buildMs ?? 0 ), triangles: built.triangleCount ?? null };
941
-
942
- // A parse slow enough on its own is written now, during the build. Held until the build
943
- // ends, it kept every array the build replaces alive with it — the float normals and the
944
- // instance matrices, ~1 GB on the whole Moana subset, at the build's peak.
945
- if ( worthStoring( pending.parseMs, encoded.byteLength ) ) this._writeGraph( area, pending );
946
- else this._pendingGraph = pending;
947
-
948
- }
949
-
950
- /**
951
- * Writes the scene encoded at parse time once the build is done, when the whole cold load —
952
- * parse plus build — was slow enough to be worth it (decision D6).
953
- * @param {number} buildMs - the SceneProcessor build that followed the parse
954
- */
955
- flushPendingGraph( buildMs ) {
956
-
957
- const pending = this._pendingGraph;
958
- this._pendingGraph = null;
959
- const area = this.storage?.area( ENGINE_AREAS.SCENES );
960
- if ( ! pending || ! area || ! worthStoring( pending.parseMs + buildMs, pending.encoded.byteLength ) ) return;
961
- this._writeGraph( area, pending );
962
-
963
- }
964
-
965
- /** Writes an encoded graph in the background, letting each array go once it is on disk. @private */
966
- _writeGraph( area, pending ) {
967
-
968
- ( async () => {
969
-
970
- const writer = await area.create( pending.key, { label: `${pending.label} (scene)`, expectedBytes: pending.encoded.byteLength } );
971
- if ( ! writer ) return;
972
- try {
973
-
974
- await writeSceneGraph( writer, pending.encoded, { release: true } );
975
- await writer.commit( { triangles: pending.triangles } );
976
-
977
- } catch ( error ) {
978
-
979
- await writer.abort();
980
- console.warn( 'Storing the scene failed:', error );
981
-
982
- }
983
-
984
- } )().catch( error => console.warn( 'Storing the scene failed:', error ) );
985
-
986
- }
987
-
988
- /**
989
- * Index a seekable archive without retaining it. Entries are read back from the File on
990
- * demand, so residency is the open include chain rather than the whole archive.
991
- *
992
- * Indexing is cheap and the whole archive is never held, but *parsing* all of it is not:
993
- * the scene that comes out is what runs the tab out of memory. So a large multi-part archive
994
- * stops here and asks which parts to load, the same as the streamed path does. The index is
995
- * already built at that point, so the question costs nothing.
996
- * @private
997
- */
998
- async _openSeekableArchive( file, filename, element, promptBytes, { index = null, zip = false } = {} ) {
999
-
1000
- const chosen = Array.isArray( element ) ? element.filter( Boolean ) : ( element ? [ element ] : [] );
1001
- const filter = chosen.length ? elementFilter( chosen ) : null;
1002
-
1003
- let source;
1004
- if ( zip ) {
1005
-
1006
- source = await openZip( file, { filter } );
1007
-
1008
- } else {
1009
-
1010
- const saved = index ?? await loadTarIndex( file, this.storage );
1011
- source = await openTar( file, {
1012
- index: saved,
1013
- headersOnly: true,
1014
- filter,
1015
- onProgress: p => updateLoading( {
1016
- isLoading: true, status: `Indexing archive… ${( p.bytes / 1e9 ).toFixed( 1 )} GB`, progress: 4
1017
- } ),
1018
- } );
1019
- if ( ! saved ) saveTarIndex( file, this.storage, source.index ).catch( () => {} );
1020
-
1021
- }
1022
-
1023
- const totalBytes = source.listing.reduce( ( n, e ) => n + e.size, 0 );
1024
-
1025
- if ( chosen.length === 0 ) this._requireElementChoice( filename, source.listing, totalBytes, promptBytes );
1026
-
1027
- console.info(
1028
- `Archive "${filename}": indexed ${source.indexed} entries, ` +
1029
- `${( totalBytes / 1e9 ).toFixed( 1 )} GB, none resident` +
1030
- ( chosen.length ? `, ${chosen.length} part${chosen.length > 1 ? 's' : ''} selected.` : '.' )
1031
- );
1032
-
1033
- return source;
1034
-
1035
- }
1036
-
1037
- /**
1038
- * Stop and ask which parts to load, when the archive is big enough that the answer matters
1039
- * and it actually has parts to choose between. Not a capability limit — selecting every part
1040
- * is a valid answer and loads the whole scene.
1041
- * @private
1042
- */
1043
- _requireElementChoice( filename, listing, totalBytes, promptBytes = ARCHIVE_ELEMENT_PROMPT_BYTES ) {
1044
-
1045
- if ( totalBytes < promptBytes ) return;
1046
-
1047
- const { root, elements } = listArchiveElements( listing );
1048
- if ( elements.length < 2 ) return;
1049
-
1050
- const error = new Error(
1051
- `"${filename}" holds ${( totalBytes / 1e9 ).toFixed( 1 )} GB across ${elements.length} parts. ` +
1052
- 'Choose which to load — loading all of them at once may exhaust memory.'
1053
- );
1054
- error.code = 'ARCHIVE_NEEDS_ELEMENT';
1055
- error.root = root;
1056
- error.elements = elements;
1057
- error.totalBytes = totalBytes;
1058
-
1059
- // Logged as a warning on purpose: recording an ERROR makes a strict host throw an
1060
- // EngineIssueError from inside record(), and the caller never learns which parts it
1061
- // could have chosen. The typed throw below is the refusal, and it carries the list.
1062
- this._issues?.record(
1063
- ISSUE_CODES.ASSET_ARCHIVE_TOO_LARGE,
1064
- `archive holds ${( totalBytes / 1e9 ).toFixed( 1 )} GB across ${elements.length} parts; choose which to load`,
1065
- { root, elements: elements.map( e => e.prefix ), totalBytes },
1066
- ISSUE_SEVERITY.WARNING
1067
- );
1068
-
1069
- throw error;
1070
-
1071
- }
1072
-
1073
- async _readStreamedArchive( file, filename, kind, element, byteBudget ) {
1074
-
1075
- const chosen = Array.isArray( element ) ? element.filter( Boolean ) : ( element ? [ element ] : [] );
1076
- const { entries, listing, retainedBytes, truncated } = await readTarGz( file, {
1077
- filter: chosen.length ? elementFilter( chosen ) : null,
1078
- ...( byteBudget === undefined ? {} : { byteBudget } ),
1079
- onProgress: p => updateLoading( {
1080
- isLoading: true,
1081
- status: `Reading archive… ${( p.bytes / 1e9 ).toFixed( 1 )} GB`,
1082
- progress: 4
1083
- } )
1084
- } );
1085
-
1086
- // Choosing every part is a valid answer, so a selection is not refused again for being
1087
- // large: the read budget exists to stop an unasked-for whole-archive load. Past this
1088
- // point the scene's own memory preflight is what refuses, with a figure to act on.
1089
- if ( truncated && chosen.length === 0 ) {
1090
-
1091
- const { root, elements } = listArchiveElements( listing );
1092
- const total = listing.reduce( ( n, e ) => n + e.size, 0 );
1093
- const error = new Error(
1094
- `"${filename}" unpacks to ${( total / 1e9 ).toFixed( 1 )} GB, past the load budget. ` +
1095
- `Load one of its ${elements.length} parts instead.`
1096
- );
1097
- error.code = 'ARCHIVE_NEEDS_ELEMENT';
1098
- error.root = root;
1099
- error.elements = elements;
1100
- error.totalBytes = total;
1101
- // Warning, not error: a strict host's throw from record() would replace the typed
1102
- // error below and take the part list with it.
1103
- this._issues?.record(
1104
- ISSUE_CODES.ASSET_ARCHIVE_TOO_LARGE,
1105
- `archive unpacks to ${( total / 1e9 ).toFixed( 1 )} GB; choose one of ${elements.length} parts`,
1106
- { root, elements: elements.map( e => e.prefix ), totalBytes: total },
1107
- ISSUE_SEVERITY.WARNING
1108
- );
1109
- throw error;
1110
-
1111
- }
1112
-
1113
- if ( truncated ) {
1114
-
1115
- const keep = elementFilter( chosen );
1116
- const missing = listing.filter( e => keep( e.path, e.size ) && ! entries[ e.path ] ).length;
1117
- this._issues?.record(
1118
- ISSUE_CODES.ASSET_ARCHIVE_TOO_LARGE,
1119
- `${missing} files of the chosen parts were left out: they did not fit the ${( ( byteBudget ?? 1.5e9 ) / 1e9 ).toFixed( 1 )} GB in-memory read budget`,
1120
- { elements: chosen, missing, retainedBytes },
1121
- ISSUE_SEVERITY.WARNING
1122
- );
1123
-
1124
- }
1125
-
1126
- if ( chosen.length ) {
1127
-
1128
- console.info(
1129
- `Archive "${filename}": loaded ${chosen.map( c => `"${c}"` ).join( ', ' )} — ` +
1130
- `${Object.keys( entries ).length} of ${listing.length} files, ${( retainedBytes / 1048576 ).toFixed( 1 )} MB.`
1131
- );
1132
-
1133
- }
1134
-
1135
- return entries;
1136
-
1137
- }
1138
-
1139
- /**
1140
- * Loads a pbrt-v4 scene from an unzipped archive. Parses the entry .pbrt
1141
- * (following Include/Import), builds a THREE.Group, sets the infinite light
1142
- * as the scene environment, and runs the standard onModelLoad pipeline.
1143
- * @param {Object<string, Uint8Array>} zip - unzipped entries (path → bytes); consumed, entry by entry
1144
- * @param {string} filename - original archive name (for display/events)
1145
- */
1146
- async loadPBRTFromZip( zip, filename, entryPath = null, options = {}, source = null ) {
1147
-
1148
- updateLoading( { isLoading: true, status: 'Parsing PBRT scene...', progress: 5 } );
1149
-
1150
- // Geometry decoder — reuse the cached PLYLoader (pbrt leans on .ply meshes).
1151
- if ( ! this.loaderCache.ply ) {
1152
-
1153
- const { PLYLoader } = await import( 'three/examples/jsm/loaders/PLYLoader.js' );
1154
- this.loaderCache.ply = new PLYLoader();
1155
-
1156
- }
1157
-
1158
- const plyParser = ( buf ) => this.loaderCache.ply.parse( buf );
1159
-
1160
- // Texture maps — decode by extension (pbrt uses .png/.jpg but also .exr/.hdr/.tga). Tagged
1161
- // with their archive path so a stored scene can read them back instead of keeping pixels.
1162
- const imageFromBytes = async ( bytes, fname ) => {
1163
-
1164
- const texture = await this._pbrtTextureFromBytes( bytes, fname );
1165
- if ( texture ) texture.userData[ ARCHIVE_PATH ] = fname;
1166
- return texture;
1167
-
1168
- };
1169
-
1170
- // Infinite-light maps → HDR/EXR/LDR via the shared environment decoder.
1171
- const envFromBytes = async ( bytes, fname ) => {
1172
-
1173
- const ext = fname.split( '.' ).pop().toLowerCase();
1174
- const blob = new Blob( [ bytes ] );
1175
- const url = URL.createObjectURL( blob );
1176
- try {
1177
-
1178
- const texture = await this.loadEnvironmentByExtension( url, ext );
1179
- setEnvironmentSource( texture, `bytes:${await sampleHash( blob )}` );
1180
- texture.userData[ ARCHIVE_PATH ] = fname;
1181
- texture.userData[ ARCHIVE_LOADER ] = 'environment';
1182
- return texture;
1183
-
1184
- } finally {
1185
-
1186
- URL.revokeObjectURL( url );
1187
-
1188
- }
1189
-
1190
- };
1191
-
1192
- const pbrtStart = performance.now();
1193
- const shape = {
1194
- animation: options.animation, maxTriangles: options.maxTriangles, maxPlacements: options.maxPlacements,
1195
- mergeShapesAbove: options.mergeShapesAbove, curveSteps: options.curveSteps, curveSides: options.curveSides,
1196
- curveTolerance: options.curveTolerance, instanceIncludes: options.instanceIncludes,
1197
- };
1198
- const graphKey = source?.archiveId
1199
- ? this._keyed( `pbrt:${SCENE_GRAPH_FORMAT}.${PBRT_BUILD_REVISION}:${VERSION}`, `${source.archiveId}|${entryPath ?? ''}|${[ ...( source.elements ?? [] ) ].sort().join( ',' )}|${JSON.stringify( shape )}` )
1200
- : null;
1201
-
1202
- const stored = graphKey ? await this._loadStoredGraph( graphKey, source, { imageFromBytes, envFromBytes } ) : null;
1203
- const built = stored ?? await loadPBRTScene( { vfs: zip, source, entryPath, plyParser, imageFromBytes, envFromBytes, ...shape } );
1204
-
1205
- const { group, environment, animations, report, warnings, meshCount, entryPath: loadedEntry, candidates,
1206
- frames, parseMs, buildMs, triangleCount, placementCount, mergedShapes, skippedForBudget,
1207
- droppedNoTemplate } = built;
1208
- if ( stored ) console.info( `PBRT scene "${loadedEntry}" opened from storage in ${( buildMs / 1000 ).toFixed( 1 )} s, skipping the parse` );
1209
-
1210
- // Phase breakdown for scaling work; the engine's own build timings live in
1211
- // SceneProcessor.performanceMetrics.
1212
- this.lastPBRTStats = {
1213
- parseMs, buildMs, loaderMs: performance.now() - pbrtStart,
1214
- triangleCount, placementCount, mergedShapes, skippedForBudget, droppedNoTemplate, meshCount,
1215
- frames: frames?.length ?? 1
1216
- };
1217
-
1218
- // An archive can hold several independent scenes (bistro ships three views). Only one is
1219
- // loaded, so name it and the alternatives rather than leave the user comparing against a
1220
- // reference of a different scene. A frame sequence loads whole, as its animation.
1221
- const loaded = new Set( frames ?? [ loadedEntry ] );
1222
- const others = candidates.filter( p => ! loaded.has( p ) );
1223
- if ( others.length > 0 ) {
1224
-
1225
- console.warn( `PBRT archive holds ${candidates.length} scenes; loaded "${loadedEntry}". Others: ${others.join( ', ' )}` );
1226
- this._issues?.record(
1227
- ISSUE_CODES.ASSET_AMBIGUOUS_ENTRY,
1228
- `archive holds ${candidates.length} pbrt scenes; loaded "${loadedEntry}"`,
1229
- { loaded: loadedEntry, alternatives: others },
1230
- ISSUE_SEVERITY.WARNING
1231
- );
1232
-
1233
- }
1234
-
1235
- // Diagnostics — surface what each mesh resolved to (helps debug black/wrong materials).
1236
- if ( report && report.length && typeof console.table === 'function' ) {
1237
-
1238
- const shown = report.length < meshCount ? ` (first ${report.length} of ${meshCount})` : '';
1239
- console.groupCollapsed( `PBRT loader: ${meshCount} mesh(es) from "${loadedEntry}"${shown}` );
1240
- console.table( report );
1241
- console.groupEnd();
1242
-
1243
- }
1244
-
1245
- if ( warnings && warnings.length ) {
1246
-
1247
- console.warn( `PBRT loader: ${warnings.length} warning(s) parsing "${loadedEntry}"` );
1248
- warnings.forEach( w => console.warn( ' •', w ) );
1249
-
1250
- }
1251
-
1252
- // Infinite light → scene environment (CDF is built later in loadSceneData).
1253
- if ( environment?.texture ) {
1254
-
1255
- environment.texture.generateMipmaps = true;
1256
- this.applyEnvironmentToScene( environment.texture );
1257
-
1258
- }
1259
-
1260
- group.name = loadedEntry || filename;
1261
- this.releaseTargetModel();
1262
- this._sourceKey = graphKey;
1263
- // Before onModelLoad and the build touch the new group.
1264
- if ( graphKey && ! stored ) this._storeGraph( graphKey, built, filename );
1265
- this.targetModel = group;
1266
- this.animations = animations ?? [];
1267
-
1268
- // The light's own orientation and `scale` are already baked into the texture, so the
1269
- // scene is only correct at rotation 0 / intensity 1 — pinned, whatever the host's defaults.
1270
- if ( environment?.texture ) this.sceneMetadata = { environment: { rotation: 0, intensity: 1 } };
1271
-
1272
- updateLoading( { isLoading: true, status: 'Processing PBRT geometry...', progress: 10 } );
1273
- await this.onModelLoad( this.targetModel );
1274
-
1275
- this.dispatchEvent( { type: 'load', model: group, filename: `${loadedEntry} (from ZIP)` } );
1276
- return group;
1277
-
1278
- }
1279
-
1280
- /**
1281
- * Decodes a pbrt texture map from raw bytes, picking a decoder by extension.
1282
- * ImageBitmap can't handle EXR/HDR/TGA, which pbrt scenes use freely.
1283
- * @param {Uint8Array} bytes
1284
- * @param {string} fname
1285
- * @returns {Promise<import('three').Texture>}
1286
- */
1287
- async _pbrtTextureFromBytes( bytes, fname ) {
1288
-
1289
- const ext = fname.split( '.' ).pop().toLowerCase();
1290
- let tex;
1291
-
1292
- if ( ext === 'exr' || ext === 'hdr' ) {
1293
-
1294
- const loader = ext === 'hdr'
1295
- ? ( this.loaderCache.hdr || ( this.loaderCache.hdr = new HDRLoader().setDataType( FloatType ) ) )
1296
- : ( this.loaderCache.exr || ( this.loaderCache.exr = new EXRLoader().setDataType( FloatType ) ) );
1297
- tex = await this._loadViaObjectURL( loader, bytes );
1298
- // HDR/EXR maps are linear — leave colorSpace as the loader set it.
1299
-
1300
- } else if ( ext === 'tga' ) {
1301
-
1302
- if ( ! this.loaderCache.tga ) {
1303
-
1304
- const { TGALoader } = await import( 'three/examples/jsm/loaders/TGALoader.js' );
1305
- this.loaderCache.tga = new TGALoader();
1306
-
1307
- }
1308
-
1309
- tex = await this._loadViaObjectURL( this.loaderCache.tga, bytes );
1310
- tex.colorSpace = SRGBColorSpace;
1311
-
1312
- } else {
1313
-
1314
- // png / jpg / webp / gif / bmp
1315
- const bitmap = await createImageBitmap( new Blob( [ bytes ] ) );
1316
- tex = new Texture( bitmap );
1317
- tex.colorSpace = SRGBColorSpace;
1318
-
1319
- }
1320
-
1321
- tex.wrapS = tex.wrapT = RepeatWrapping;
1322
- tex.needsUpdate = true;
1323
- return tex;
1324
-
1325
- }
1326
-
1327
- /** Decode bytes through a three loader's loadAsync via a transient object URL. */
1328
- async _loadViaObjectURL( loader, bytes ) {
1329
-
1330
- const url = URL.createObjectURL( new Blob( [ bytes ] ) );
1331
- try {
1332
-
1333
- return await loader.loadAsync( url );
1334
-
1335
- } finally {
1336
-
1337
- URL.revokeObjectURL( url );
1338
-
1339
- }
1340
-
1341
- }
1342
-
1343
- async processObjMtlPairsInZip( zip, filename ) {
1344
-
1345
- const objFiles = [];
1346
- const mtlFiles = [];
1347
-
1348
- for ( const path in zip ) {
1349
-
1350
- const lowerPath = path.toLowerCase();
1351
- if ( lowerPath.endsWith( '.obj' ) ) objFiles.push( { path, content: zip[ path ] } );
1352
- else if ( lowerPath.endsWith( '.mtl' ) ) mtlFiles.push( { path, content: zip[ path ] } );
1353
-
1354
- }
1355
-
1356
- if ( objFiles.length > 0 && mtlFiles.length > 0 ) {
1357
-
1358
- console.log( `Found ${objFiles.length} OBJ files and ${mtlFiles.length} MTL files in ZIP` );
1359
- const matches = this.findMatchingObjMtlPairs( objFiles, mtlFiles );
1360
-
1361
- if ( matches.length > 0 ) {
1362
-
1363
- console.log( `Found ${matches.length} matching OBJ+MTL pairs` );
1364
- return await this.loadOBJMTLPairFromZip( matches[ 0 ].obj, matches[ 0 ].mtl, zip, filename );
1365
-
1366
- }
1367
-
1368
- if ( matches.length === 0 ) {
1369
-
1370
- console.log( 'No matching pairs by name, using first OBJ and MTL files' );
1371
- return await this.loadOBJMTLPairFromZip( objFiles[ 0 ], mtlFiles[ 0 ], zip, filename );
1372
-
1373
- }
1374
-
1375
- }
1376
-
1377
- return null;
1378
-
1379
- }
1380
-
1381
- findMatchingObjMtlPairs( objFiles, mtlFiles ) {
1382
-
1383
- const matches = [];
1384
- for ( const objFile of objFiles ) {
1385
-
1386
- const objBaseName = objFile.path.split( '/' ).pop().replace( /\.obj$/i, '' ).toLowerCase();
1387
-
1388
- for ( const mtlFile of mtlFiles ) {
1389
-
1390
- const mtlBaseName = mtlFile.path.split( '/' ).pop().replace( /\.mtl$/i, '' ).toLowerCase();
1391
- if ( objBaseName === mtlBaseName || objBaseName.includes( mtlBaseName ) || mtlBaseName.includes( objBaseName ) ) {
1392
-
1393
- matches.push( { obj: objFile, mtl: mtlFile } );
1394
- break;
1395
-
1396
- }
1397
-
1398
- }
1399
-
1400
- }
1401
-
1402
- return matches;
1403
-
1404
- }
1405
-
1406
- async findAndLoadModelFromZip( zip ) {
1407
-
1408
- const mainModelFiles = [
1409
- 'scene.gltf', 'scene.glb', 'model.gltf', 'model.glb',
1410
- 'main.gltf', 'main.glb', 'asset.gltf', 'asset.glb'
1411
- ];
1412
-
1413
- for ( const mainFile of mainModelFiles ) {
1414
-
1415
- if ( zip[ mainFile ] ) {
1416
-
1417
- console.log( `Found main model file: ${mainFile}` );
1418
- const extension = mainFile.split( '.' ).pop().toLowerCase();
1419
- return await this.loadModelFromZipEntry( zip[ mainFile ], mainFile, extension, zip );
1420
-
1421
- }
1422
-
1423
- }
1424
-
1425
- for ( const path in zip ) {
1426
-
1427
- const extension = path.split( '.' ).pop().toLowerCase();
1428
- if ( SUPPORTED_FORMATS[ extension ] && SUPPORTED_FORMATS[ extension ].type === 'model' ) {
1429
-
1430
- // A loose layer is only one slice of a USD scene — hand the whole
1431
- // archive over so its references and payloads can resolve.
1432
- if ( USD_LAYER_RE.test( path ) ) return await this.loadUSDHierarchyFromZip( zip );
1433
-
1434
- console.log( `Loading model file from ZIP: ${path}` );
1435
- return await this.loadModelFromZipEntry( zip[ path ], path, extension, zip );
1436
-
1437
- }
1438
-
1439
- }
1440
-
1441
- throw new Error( 'No supported model files found in the ZIP archive' );
1442
-
1443
- }
1444
-
1445
- // USDLoader only builds a cross-layer asset map on its USDZ branch, so repack
1446
- // the archive as USDZ — root layer first, per AOUSD core spec 16.4.1.2 — and
1447
- // let the loader resolve the references/payloads itself.
1448
- async loadUSDHierarchyFromZip( zip ) {
1449
-
1450
- const layers = Object.keys( zip ).filter( name => USD_LAYER_RE.test( name ) );
1451
- if ( layers.length === 0 ) throw new Error( 'No USD layers found in the ZIP archive' );
1452
-
1453
- const root = AssetLoader._pickUSDRootLayer( layers );
1454
- console.log( `Loading USD scene from ZIP: ${root} (${layers.length} layers)` );
1455
-
1456
- const packed = { [ root ]: [ zip[ root ], { level: 0 } ] };
1457
- for ( const name of Object.keys( zip ) ) {
1458
-
1459
- if ( name === root ) continue;
1460
- if ( USD_LAYER_RE.test( name ) || USD_IMAGE_RE.test( name ) ) packed[ name ] = [ zip[ name ], { level: 0 } ];
1461
-
1462
- }
1463
-
1464
- return await this.loadUSDFromArrayBuffer( zipSync( packed ), root );
1465
-
1466
- }
1467
-
1468
- // Shallowest layer wins; among ties prefer the <dir>/<dir>.usd convention so a
1469
- // set's entry point beats its sibling variants.
1470
- static _pickUSDRootLayer( layers ) {
1471
-
1472
- const depth = name => name.split( '/' ).length;
1473
- const minDepth = Math.min( ...layers.map( depth ) );
1474
- const candidates = layers.filter( name => depth( name ) === minDepth );
1475
-
1476
- const conventional = candidates.find( name => {
1477
-
1478
- const parts = name.split( '/' );
1479
- return parts.length > 1 && parts[ parts.length - 1 ].replace( USD_LAYER_RE, '' ) === parts[ parts.length - 2 ];
1480
-
1481
- } );
1482
-
1483
- return conventional || candidates[ 0 ];
1484
-
1485
- }
1486
-
1487
- async loadModelFromZipEntry( fileContent, filePath, extension, zipContents ) {
1488
-
1489
- try {
1490
-
1491
- updateLoading( { isLoading: true, status: `Processing ${extension.toUpperCase()} from ZIP...`, progress: 5 } );
1492
- const blob = new Blob( [ fileContent.buffer ], { type: 'application/octet-stream' } );
1493
- const blobUrl = URL.createObjectURL( blob );
1494
- let result;
1495
-
1496
- switch ( extension ) {
1497
-
1498
- case 'glb':
1499
- case 'gltf':
1500
- result = await this.handleGltfFromZip( extension, fileContent, filePath, zipContents );
1501
- break;
1502
- case 'fbx':
1503
- result = await this.loadFBXFromArrayBuffer( fileContent.buffer, filePath );
1504
- break;
1505
- case 'obj':
1506
- result = await this.handleObjFromZip( fileContent, filePath, zipContents );
1507
- break;
1508
- case 'stl':
1509
- result = await this.loadSTLFromArrayBuffer( fileContent.buffer, filePath );
1510
- break;
1511
- case 'ply':
1512
- result = await this.loadPLYFromArrayBuffer( fileContent.buffer, filePath );
1513
- break;
1514
- case 'dae':
1515
- {
1516
-
1517
- const daeContent = strFromU8( fileContent );
1518
- const daeFile = new File( [ new Blob( [ daeContent ] ) ], filePath );
1519
- result = await this.loadColladaFromFile( daeFile, filePath );
1520
-
1521
- }
1522
-
1523
- break;
1524
- case '3mf':
1525
- result = await this.load3MFFromArrayBuffer( fileContent.buffer, filePath );
1526
- break;
1527
- case 'usd':
1528
- case 'usda':
1529
- case 'usdc':
1530
- case 'usdz':
1531
- result = await this.loadUSDFromArrayBuffer( fileContent, filePath );
1532
- break;
1533
- default:
1534
- throw new Error( `Support for ${extension} files is not yet implemented` );
1535
-
1536
- }
1537
-
1538
- URL.revokeObjectURL( blobUrl );
1539
- this.dispatchEvent( {
1540
- type: 'load',
1541
- model: this.targetModel,
1542
- filename: `${filePath} (from ZIP)`
1543
- } );
1544
- return result;
1545
-
1546
- } catch ( error ) {
1547
-
1548
- console.error( `Error loading ${extension} from ZIP:`, error );
1549
- this.dispatchEvent( { type: 'error', message: error.message, filename: filePath } );
1550
- throw error;
1551
-
1552
- }
1553
-
1554
- }
1555
-
1556
- async handleGltfFromZip( extension, fileContent, filePath, zipContents ) {
1557
-
1558
- if ( extension === 'gltf' ) {
1559
-
1560
- const gltfContent = strFromU8( fileContent );
1561
- const manager = new LoadingManager();
1562
- const gltfDir = filePath.split( '/' ).slice( 0, - 1 ).join( '/' );
1563
-
1564
- manager.setURLModifier( url => this.resolveZipResource( url, gltfDir, zipContents ) );
1565
- const loader = await this.createGLTFLoader();
1566
- loader.manager = manager;
1567
-
1568
- try {
1569
-
1570
- return await new Promise( ( resolve, reject ) => {
1571
-
1572
- loader.parse( gltfContent, '',
1573
- gltf => {
1574
-
1575
- try {
1576
-
1577
- this._throwDeferred();
1578
-
1579
- } catch ( error ) {
1580
-
1581
- reject( error );
1582
- return;
1583
-
1584
- }
1585
-
1586
- this.releaseTargetModel();
1587
- this.targetModel = gltf.scene;
1588
- this.sceneMetadata = extractSceneMetadata( gltf );
1589
- this.onModelLoad( this.targetModel ).then( () => resolve( gltf ) );
1590
-
1591
- },
1592
- error => reject( error )
1593
- );
1594
-
1595
- } );
1596
-
1597
- } finally {
1598
-
1599
- this._disposeGLTFLoader( loader );
1600
-
1601
- }
1602
-
1603
- } else {
1604
-
1605
- return await this.loadGLBFromArrayBuffer( fileContent.buffer, filePath );
1606
-
1607
- }
1608
-
1609
- }
1610
-
1611
- async handleObjFromZip( fileContent, filePath, zipContents ) {
1612
-
1613
- const objContent = strFromU8( fileContent );
1614
- const mtlMatch = objContent.match( /mtllib\s+([^\s]+)/ );
1615
- let materials = null;
1616
-
1617
- if ( mtlMatch && mtlMatch[ 1 ] ) {
1618
-
1619
- materials = await this.loadMtlFromZip( mtlMatch[ 1 ], filePath, zipContents );
1620
-
1621
- }
1622
-
1623
- const { OBJLoader } = await import( 'three/examples/jsm/loaders/OBJLoader.js' );
1624
- const objLoader = new OBJLoader();
1625
- if ( materials ) objLoader.setMaterials( materials );
1626
-
1627
- const object = objLoader.parse( objContent );
1628
- object.name = filePath;
1629
-
1630
- this.releaseTargetModel();
1631
- this.targetModel = object;
1632
- await this.onModelLoad( this.targetModel );
1633
- return object;
1634
-
1635
- }
1636
-
1637
- // MTLLoader returns Texture objects whose `.image` only lands on a later tick, and
1638
- // SceneProcessor._bucketTextures drops any texture without one — so handing the model
1639
- // over before the maps decode renders the whole scene untextured. Replaces preload().
1640
- async preloadMtlTextures( materials ) {
1641
-
1642
- const pending = [];
1643
- const loadTexture = materials.loadTexture.bind( materials );
1644
-
1645
- materials.loadTexture = ( url, mapping, onLoad, onProgress, onError ) => {
1646
-
1647
- let settle;
1648
- pending.push( new Promise( resolve => ( settle = resolve ) ) );
1649
- return loadTexture( url, mapping,
1650
- texture => {
1651
-
1652
- settle();
1653
- onLoad?.( texture );
1654
-
1655
- },
1656
- onProgress,
1657
- error => {
1658
-
1659
- settle();
1660
- onError?.( error );
1661
-
1662
- }
1663
- );
1664
-
1665
- };
1666
-
1667
- materials.preload();
1668
- if ( pending.length === 0 ) return materials;
1669
-
1670
- let timer;
1671
- const decoded = await Promise.race( [
1672
- Promise.all( pending ).then( () => true ),
1673
- new Promise( resolve => ( timer = setTimeout( () => resolve( false ), MTL_TEXTURE_TIMEOUT_MS ) ) )
1674
- ] );
1675
- clearTimeout( timer );
1676
-
1677
- if ( ! decoded ) this._issues?.record(
1678
- ISSUE_CODES.TEXTURE_BUILD_FAILED,
1679
- `MTL textures did not decode within ${MTL_TEXTURE_TIMEOUT_MS}ms; affected materials render untextured`,
1680
- { pending: pending.length }
1681
- );
1682
-
1683
- return materials;
1684
-
1685
- }
1686
-
1687
- async loadMtlFromZip( mtlFilename, objPath, zipContents ) {
1688
-
1689
- const objDir = objPath.split( '/' ).slice( 0, - 1 ).join( '/' );
1690
- const possibleMtlPaths = [
1691
- mtlFilename,
1692
- `${objDir}/${mtlFilename}`,
1693
- mtlFilename.split( '/' ).pop()
1694
- ];
1695
-
1696
- for ( const path of possibleMtlPaths ) {
1697
-
1698
- if ( zipContents[ path ] ) {
1699
-
1700
- const { MTLLoader } = await import( 'three/examples/jsm/loaders/MTLLoader.js' );
1701
- const mtlContent = strFromU8( zipContents[ path ] );
1702
- const manager = new LoadingManager();
1703
- manager.setURLModifier( url => this.resolveZipResource( url, objDir, zipContents ) );
1704
- const mtlLoader = new MTLLoader( manager );
1705
- const materials = mtlLoader.parse( mtlContent, objDir );
1706
- await this.preloadMtlTextures( materials );
1707
- return materials;
1708
-
1709
- }
1710
-
1711
- }
1712
-
1713
- return null;
1714
-
1715
- }
1716
-
1717
- resolveZipResource( url, baseDir, zipContents ) {
1718
-
1719
- const normalizedUrl = url.replace( /^\.\/|^\//, '' );
1720
- const possiblePaths = [
1721
- normalizedUrl,
1722
- `${baseDir}/${normalizedUrl}`,
1723
- normalizedUrl.split( '/' ).pop()
1724
- ];
1725
-
1726
- for ( const path of possiblePaths ) {
1727
-
1728
- if ( zipContents[ path ] ) {
1729
-
1730
- const fileBlob = new Blob( [ zipContents[ path ].buffer ], { type: 'application/octet-stream' } );
1731
- return URL.createObjectURL( fileBlob );
1732
-
1733
- }
1734
-
1735
- }
1736
-
1737
- console.warn( `Resource not found in ZIP: ${url}` );
1738
- return url;
1739
-
1740
- }
1741
-
1742
- async loadOBJMTLPairFromZip( objFile, mtlFile, zip, filename ) {
1743
-
1744
- const { MTLLoader } = await import( 'three/examples/jsm/loaders/MTLLoader.js' );
1745
- const { OBJLoader } = await import( 'three/examples/jsm/loaders/OBJLoader.js' );
1746
- const createdUrls = [];
1747
- const manager = new LoadingManager();
1748
- const objDir = objFile.path.split( '/' ).slice( 0, - 1 ).join( '/' );
1749
- const mtlDir = mtlFile.path.split( '/' ).slice( 0, - 1 ).join( '/' );
1750
-
1751
- manager.setURLModifier( url => this.resolveTextureInZip( url, objDir, mtlDir, mtlFile, zip, createdUrls ) );
1752
- const mtlContent = this.prepareFixedMtlContent( mtlFile );
1753
- const materials = new MTLLoader( manager ).parse( mtlContent, mtlDir );
1754
- await this.preloadMtlTextures( materials );
1755
-
1756
- const objLoader = new OBJLoader( manager );
1757
- objLoader.setMaterials( materials );
1758
- const objContent = strFromU8( objFile.content );
1759
- const object = objLoader.parse( objContent );
1760
-
1761
- this.releaseTargetModel();
1762
- this.targetModel = object;
1763
- await this.onModelLoad( this.targetModel );
1764
-
1765
- createdUrls.forEach( url => URL.revokeObjectURL( url ) );
1766
- this.dispatchEvent( {
1767
- type: 'load',
1768
- model: object,
1769
- filename: `${objFile.path} (from ${filename})`
1770
- } );
1771
-
1772
- return object;
1773
-
1774
- }
1775
-
1776
- prepareFixedMtlContent( mtlFile ) {
1777
-
1778
- const mtlContent = strFromU8( mtlFile.content );
1779
- return mtlContent
1780
- .replace( new RegExp( `${mtlFile.path.split( '/' ).pop()}\\s+`, 'g' ), ' ' )
1781
- .replace( /([a-zA-Z_]+)([\\/])/g, '$1 $2' );
1782
-
1783
- }
1784
-
1785
- resolveTextureInZip( url, objDir, mtlDir, mtlFile, zip, createdUrls ) {
1786
-
1787
- const cleanUrl = url.split( '?' )[ 0 ].split( '#' )[ 0 ];
1788
- let normalizedUrl = cleanUrl.replace( /^\.\/|^\//, '' );
1789
-
1790
- const mtlFilename = mtlFile.path.split( '/' ).pop();
1791
- if ( normalizedUrl.startsWith( mtlFilename ) ) {
1792
-
1793
- normalizedUrl = normalizedUrl.substring( mtlFilename.length ).replace( /^\.\/|^\/|^\./, '' );
1794
-
1795
- }
1796
-
1797
- const possibleLocations = [
1798
- normalizedUrl,
1799
- `${objDir}/${normalizedUrl}`,
1800
- `${mtlDir}/${normalizedUrl}`,
1801
- `textures/${normalizedUrl}`,
1802
- `texture/${normalizedUrl}`,
1803
- `materials/${normalizedUrl}`,
1804
- normalizedUrl.split( '/' ).pop()
1805
- ];
1806
-
1807
- for ( const location of possibleLocations ) {
1808
-
1809
- if ( zip[ location ] ) {
1810
-
1811
- const blob = new Blob( [ zip[ location ].buffer ], { type: 'application/octet-stream' } );
1812
- const blobUrl = URL.createObjectURL( blob );
1813
- createdUrls.push( blobUrl );
1814
- return blobUrl;
1815
-
1816
- }
1817
-
1818
- }
1819
-
1820
- return this.findTextureWithFuzzyMatch( normalizedUrl, zip, createdUrls ) || url;
1821
-
1822
- }
1823
-
1824
- findTextureWithFuzzyMatch( normalizedUrl, zip, createdUrls ) {
1825
-
1826
- const textureFilename = normalizedUrl.split( '/' ).pop();
1827
-
1828
- for ( const zipPath in zip ) {
1829
-
1830
- if ( zipPath.endsWith( textureFilename ) ) {
1831
-
1832
- const blob = new Blob( [ zip[ zipPath ].buffer ], { type: 'application/octet-stream' } );
1833
- const blobUrl = URL.createObjectURL( blob );
1834
- createdUrls.push( blobUrl );
1835
- return blobUrl;
1836
-
1837
- }
1838
-
1839
- }
1840
-
1841
- if ( textureFilename && textureFilename.length > 5 ) {
1842
-
1843
- for ( const zipPath in zip ) {
1844
-
1845
- const zipFilename = zipPath.split( '/' ).pop();
1846
- if ( zipFilename.includes( textureFilename ) || textureFilename.includes( zipFilename ) ) {
1847
-
1848
- const blob = new Blob( [ zip[ zipPath ].buffer ], { type: 'application/octet-stream' } );
1849
- const blobUrl = URL.createObjectURL( blob );
1850
- createdUrls.push( blobUrl );
1851
- return blobUrl;
1852
-
1853
- }
1854
-
1855
- }
1856
-
1857
- }
1858
-
1859
- return null;
1860
-
1861
- }
1862
-
1863
- // Returns a fresh loader each call — DRACOLoader/KTX2Loader hold persistent
1864
- // worker pools. Callers must invoke _disposeGLTFLoader() to terminate them.
1865
- async createGLTFLoader() {
1866
-
1867
- const { dracoDecoderPath, ktx2TranscoderPath } = getAssetConfig();
1868
-
1869
- const dracoLoader = new DRACOLoader();
1870
- dracoLoader.setDecoderConfig( { type: 'js' } );
1871
- dracoLoader.setDecoderPath( dracoDecoderPath );
1872
-
1873
- const ktx2Loader = new KTX2Loader();
1874
- ktx2Loader.setTranscoderPath( ktx2TranscoderPath );
1875
-
1876
- if ( this.renderer ) {
1877
-
1878
- ktx2Loader.detectSupport( this.renderer );
1879
-
1880
- // Force RGBA output for Basis Universal textures. GPU-compressed
1881
- // texture arrays (CompressedArrayTexture) are blocked by a Three.js
1882
- // TSL limitation: the node compiler maintains global state that
1883
- // survives dispose(), so swapping texture array formats between
1884
- // DataArrayTexture and CompressedArrayTexture at runtime causes
1885
- // WGSL compilation failures (unresolved uniform bindings).
1886
- ktx2Loader.workerConfig = {
1887
- astcSupported: false, etc1Supported: false, etc2Supported: false,
1888
- dxtSupported: false, bptcSupported: false, pvrtcSupported: false,
1889
- };
1890
-
1891
- }
1892
-
1893
- const loader = new GLTFLoader( this._loadingManager );
1894
- loader.setDRACOLoader( dracoLoader );
1895
- loader.setKTX2Loader( ktx2Loader );
1896
- loader.setMeshoptDecoder( MeshoptDecoder );
1897
- if ( getPlatform().decodeImage ) loader.register( ( parser ) => platformImagesPlugin( parser, ( where, error ) => this._reportImageFailure( where, error ) ) );
1898
-
1899
- return loader;
1900
-
1901
- }
1902
-
1903
- // GLTFLoader swallows the failure and loads the model untextured, so a strict host's throw is kept
1904
- // for the end of the load (_throwDeferred).
1905
- _reportImageFailure( where, error ) {
1906
-
1907
- try {
1908
-
1909
- this._issues?.record(
1910
- ISSUE_CODES.TEXTURE_BUILD_FAILED,
1911
- `image ${where} could not be decoded — the surfaces using it render untextured`,
1912
- { image: where, cause: String( error?.message ?? error ) }
1913
- );
1914
-
1915
- } catch ( strictError ) {
1916
-
1917
- this._deferredError ??= strictError;
1918
-
1919
- }
1920
-
1921
- }
1922
-
1923
- _throwDeferred() {
1924
-
1925
- const error = this._deferredError;
1926
- this._deferredError = null;
1927
- if ( error ) throw error;
1928
-
1929
- }
1930
-
1931
- _disposeGLTFLoader( loader ) {
1932
-
1933
- if ( ! loader ) return;
1934
- loader.dracoLoader?.dispose();
1935
- loader.ktx2Loader?.dispose();
1936
-
1937
- }
1938
-
1939
- async loadExampleModels( index, modelFiles ) {
1940
-
1941
- if ( ! modelFiles || ! modelFiles[ index ] ) {
1942
-
1943
- throw new Error( `No model file at index ${index}` );
1944
-
1945
- }
1946
-
1947
- const modelUrl = `${modelFiles[ index ].url}`;
1948
- return await this.loadModel( modelUrl );
1949
-
1950
- }
1951
-
1952
- /**
1953
- * @param {string} modelUrl
1954
- * @param {{cacheKey?: string, cachePolicy?: string}} [options] - key a download cache entry by
1955
- * something steadier than a signed, expiring URL
1956
- */
1957
- async loadModel( modelUrl, options = {} ) {
1958
-
1959
- this._loadCancelled = false;
1960
- const loader = await this.createGLTFLoader();
1961
- const cancelable = AssetLoader._isNetworkUrl( modelUrl );
1962
- let source = null;
1963
-
1964
- try {
1965
-
1966
- updateLoading( { isLoading: true, status: "Downloading Model...", progress: 2, canCancel: cancelable, loadedBytes: 0, totalBytes: 0 } );
1967
- source = await this._viaCache( modelUrl, "Downloading Model...", cancelable, options );
1968
- if ( source.cached ) loader.setResourcePath( LoaderUtils.extractUrlBase( modelUrl ) );
1969
- const data = await loader.loadAsync( source.url, source.cached ? undefined : this._downloadProgress( "Downloading Model...", cancelable ) );
1970
- this._downloadComplete();
1971
- this._throwDeferred();
1972
-
1973
- this.releaseTargetModel();
1974
- this._sourceKey = cancelable ? this._keyed( 'url', options.cacheKey ?? modelUrl ) : null;
1975
-
1976
- this.targetModel = data.scene;
1977
- this.animations = data.animations || [];
1978
- this.sceneMetadata = extractSceneMetadata( data );
1979
- await this.onModelLoad( this.targetModel );
1980
- this.dispatchEvent( { type: 'load', model: data.scene, filename: modelUrl.split( '/' ).pop() } );
1981
- return data;
1982
-
1983
- } catch ( error ) {
1984
-
1985
- if ( this._isCancellation( error ) ) throw this._cancellationError();
1986
- console.error( "Error loading model:", error );
1987
- this.dispatchEvent( { type: 'error', message: error.message, filename: modelUrl } );
1988
- throw error;
1989
-
1990
- } finally {
1991
-
1992
- source?.release();
1993
- this._disposeGLTFLoader( loader );
1994
-
1995
- }
1996
-
1997
- }
1998
-
1999
- // ─────────────────────────────────────────────────────────────
2000
- // Append / remove primitives (dynamic object add/remove).
2001
- // These deliberately do NOT touch targetModel/animations and do NOT
2002
- // reframe the camera or dispatch load/modelProcessed — the caller
2003
- // (PathTracerApp) drives a reframe-free scene rebuild.
2004
- // ─────────────────────────────────────────────────────────────
2005
-
2006
- // Multi-material split + area-light placeholders, then parent into meshScene.
2007
- _processAndParent( model ) {
2008
-
2009
- this.processModelObjects( model );
2010
- this.scene.add( model );
2011
- // Refresh world matrices for the whole subtree — the reframe-free rebuild
2012
- // extracts geometry immediately (rAF is gated off), so nothing else would
2013
- // update matrices first and any ancestor-node transform would be dropped.
2014
- model.updateMatrixWorld( true );
2015
-
2016
- }
2017
-
2018
- // Append a model from URL without releasing prior models or reframing.
2019
- // Reuses createGLTFLoader() so appended KTX2 textures stay RGBA DataArrayTexture.
2020
- async appendModel( url, options = {} ) {
2021
-
2022
- this._loadCancelled = false;
2023
- const loader = await this.createGLTFLoader();
2024
- const cancelable = AssetLoader._isNetworkUrl( url );
2025
- let source = null;
2026
-
2027
- try {
2028
-
2029
- updateLoading( { isLoading: true, status: "Downloading Model...", progress: 2, canCancel: cancelable, loadedBytes: 0, totalBytes: 0 } );
2030
- source = await this._viaCache( url, "Downloading Model...", cancelable, options );
2031
- if ( source.cached ) loader.setResourcePath( LoaderUtils.extractUrlBase( url ) );
2032
- const data = await loader.loadAsync( source.url, source.cached ? undefined : this._downloadProgress( "Downloading Model...", cancelable ) );
2033
- this._downloadComplete();
2034
- this._throwDeferred();
2035
- this._appended = true;
2036
- this._processAndParent( data.scene );
2037
- return { root: data.scene, animations: data.animations || [] };
2038
-
2039
- } catch ( error ) {
2040
-
2041
- if ( this._isCancellation( error ) ) throw this._cancellationError();
2042
- throw error;
2043
-
2044
- } finally {
2045
-
2046
- source?.release();
2047
- this._disposeGLTFLoader( loader );
2048
-
2049
- }
2050
-
2051
- }
2052
-
2053
- // Append a copy of a caller-owned Object3D without releasing prior models or reframing.
2054
- appendObject3D( object3d, name = 'object3d' ) {
2055
-
2056
- this._appended = true;
2057
-
2058
- const root = this._adoptExternalObject( object3d );
2059
- root.name = object3d.name || name;
2060
- this._processAndParent( root );
2061
- return { root };
2062
-
2063
- }
2064
-
2065
- // Detach + dispose a model root. An adopted root's geometry/materials belong to the
2066
- // caller, so only the engine's own allocations go.
2067
- removeModelRoot( root ) {
2068
-
2069
- if ( ! root ) return;
2070
-
2071
- if ( root.userData.__rayzeeExternal ) {
2072
-
2073
- disposeEngineOwnedResources( root );
2074
- root.parent?.remove( root );
2075
-
2076
- } else {
2077
-
2078
- disposeObjectFromMemory( root );
2079
-
2080
- }
2081
-
2082
- }
2083
-
2084
- async loadGLBFromArrayBuffer( arrayBuffer, filename = 'model.glb' ) {
2085
-
2086
- const loader = await this.createGLTFLoader();
2087
-
2088
- try {
2089
-
2090
- updateLoading( { isLoading: true, status: "Processing GLB Data...", progress: 5 } );
2091
- await new Promise( r => setTimeout( r, 0 ) );
2092
-
2093
- const data = await loader.parseAsync( arrayBuffer, '' );
2094
- this._throwDeferred();
2095
-
2096
- this.releaseTargetModel();
2097
-
2098
- this.targetModel = data.scene;
2099
- this.animations = data.animations || [];
2100
- this.sceneMetadata = extractSceneMetadata( data );
2101
- updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
2102
- await this.onModelLoad( this.targetModel );
2103
-
2104
- this.dispatchEvent( { type: 'load', model: data.scene, filename } );
2105
- return data;
2106
-
2107
- } catch ( error ) {
2108
-
2109
- console.error( 'Error loading GLB:', error );
2110
- this.dispatchEvent( { type: 'error', message: error.message, filename } );
2111
- throw error;
2112
-
2113
- } finally {
2114
-
2115
- this._disposeGLTFLoader( loader );
2116
-
2117
- }
2118
-
2119
- }
2120
-
2121
- async loadFBXFromArrayBuffer( arrayBuffer, filename = 'model.fbx' ) {
2122
-
2123
- try {
2124
-
2125
- updateLoading( { isLoading: true, status: "Processing FBX Data...", progress: 5 } );
2126
- await new Promise( r => setTimeout( r, 0 ) );
2127
-
2128
- if ( ! this.loaderCache.fbx ) {
2129
-
2130
- const { FBXLoader } = await import( 'three/examples/jsm/loaders/FBXLoader.js' );
2131
- this.loaderCache.fbx = new FBXLoader();
2132
-
2133
- }
2134
-
2135
- const object = this.loaderCache.fbx.parse( arrayBuffer );
2136
- this.releaseTargetModel();
2137
- this.targetModel = object;
2138
-
2139
- updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
2140
- await this.onModelLoad( this.targetModel );
2141
-
2142
- this.dispatchEvent( { type: 'load', model: object, filename } );
2143
- return object;
2144
-
2145
- } catch ( error ) {
2146
-
2147
- console.error( 'Error loading FBX:', error );
2148
- this.dispatchEvent( { type: 'error', message: error.message, filename } );
2149
- throw error;
2150
-
2151
- }
2152
-
2153
- }
2154
-
2155
- async loadOBJFromFile( file, filename = 'model.obj' ) {
2156
-
2157
- try {
2158
-
2159
- updateLoading( { isLoading: true, status: "Processing OBJ Data...", progress: 5 } );
2160
- await new Promise( r => setTimeout( r, 0 ) );
2161
-
2162
- if ( ! this.loaderCache.obj ) {
2163
-
2164
- const { OBJLoader } = await import( 'three/examples/jsm/loaders/OBJLoader.js' );
2165
- this.loaderCache.obj = new OBJLoader();
2166
-
2167
- }
2168
-
2169
- const contents = await this.readFileAsText( file );
2170
- const object = this.loaderCache.obj.parse( contents );
2171
- object.name = filename;
2172
-
2173
- this.releaseTargetModel();
2174
- this.targetModel = object;
2175
-
2176
- updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
2177
- await this.onModelLoad( this.targetModel );
2178
-
2179
- this.dispatchEvent( { type: 'load', model: object, filename } );
2180
- return object;
2181
-
2182
- } catch ( error ) {
2183
-
2184
- console.error( 'Error loading OBJ:', error );
2185
- this.dispatchEvent( { type: 'error', message: error.message, filename } );
2186
- throw error;
2187
-
2188
- }
2189
-
2190
- }
2191
-
2192
- async loadSTLFromArrayBuffer( arrayBuffer, filename = 'model.stl' ) {
2193
-
2194
- try {
2195
-
2196
- updateLoading( { isLoading: true, status: "Processing STL Data...", progress: 5 } );
2197
- await new Promise( r => setTimeout( r, 0 ) );
2198
-
2199
- if ( ! this.loaderCache.stl ) {
2200
-
2201
- const { STLLoader } = await import( 'three/examples/jsm/loaders/STLLoader.js' );
2202
- this.loaderCache.stl = new STLLoader();
2203
-
2204
- }
2205
-
2206
- const geometry = this.loaderCache.stl.parse( arrayBuffer );
2207
- const material = new MeshStandardMaterial();
2208
- const mesh = new Mesh( geometry, material );
2209
- mesh.name = filename;
2210
-
2211
- this.releaseTargetModel();
2212
- this.targetModel = mesh;
2213
-
2214
- updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
2215
- await this.onModelLoad( this.targetModel );
2216
-
2217
- this.dispatchEvent( { type: 'load', model: mesh, filename } );
2218
- return mesh;
2219
-
2220
- } catch ( error ) {
2221
-
2222
- console.error( 'Error loading STL:', error );
2223
- this.dispatchEvent( { type: 'error', message: error.message, filename } );
2224
- throw error;
2225
-
2226
- }
2227
-
2228
- }
2229
-
2230
- async loadPLYFromArrayBuffer( arrayBuffer, filename = 'model.ply' ) {
2231
-
2232
- try {
2233
-
2234
- updateLoading( { isLoading: true, status: "Processing PLY Data...", progress: 5 } );
2235
- await new Promise( r => setTimeout( r, 0 ) );
2236
-
2237
- if ( ! this.loaderCache.ply ) {
2238
-
2239
- const { PLYLoader } = await import( 'three/examples/jsm/loaders/PLYLoader.js' );
2240
- this.loaderCache.ply = new PLYLoader();
2241
-
2242
- }
2243
-
2244
- const geometry = this.loaderCache.ply.parse( arrayBuffer );
2245
- let object;
2246
-
2247
- if ( geometry.index !== null ) {
2248
-
2249
- const material = new MeshStandardMaterial();
2250
- object = new Mesh( geometry, material );
2251
-
2252
- } else {
2253
-
2254
- const material = new PointsMaterial( { size: 0.01 } );
2255
- material.vertexColors = geometry.hasAttribute( 'color' );
2256
- object = new Points( geometry, material );
2257
-
2258
- }
2259
-
2260
- object.name = filename;
2261
- this.releaseTargetModel();
2262
- this.targetModel = object;
2263
-
2264
- updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
2265
- await this.onModelLoad( this.targetModel );
2266
-
2267
- this.dispatchEvent( { type: 'load', model: object, filename } );
2268
- return object;
2269
-
2270
- } catch ( error ) {
2271
-
2272
- console.error( 'Error loading PLY:', error );
2273
- this.dispatchEvent( { type: 'error', message: error.message, filename } );
2274
- throw error;
2275
-
2276
- }
2277
-
2278
- }
2279
-
2280
- async loadColladaFromFile( file, filename = 'model.dae' ) {
2281
-
2282
- try {
2283
-
2284
- updateLoading( { isLoading: true, status: "Processing Collada Data...", progress: 5 } );
2285
- await new Promise( r => setTimeout( r, 0 ) );
2286
-
2287
- if ( ! this.loaderCache.collada ) {
2288
-
2289
- const { ColladaLoader } = await import( 'three/examples/jsm/loaders/ColladaLoader.js' );
2290
- this.loaderCache.collada = new ColladaLoader();
2291
-
2292
- }
2293
-
2294
- const contents = await this.readFileAsText( file );
2295
- const collada = this.loaderCache.collada.parse( contents );
2296
- collada.scene.name = filename;
2297
-
2298
- this.releaseTargetModel();
2299
- this.targetModel = collada.scene;
2300
-
2301
- updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
2302
- await this.onModelLoad( this.targetModel );
2303
-
2304
- this.dispatchEvent( { type: 'load', model: collada.scene, filename } );
2305
- return collada;
2306
-
2307
- } catch ( error ) {
2308
-
2309
- console.error( 'Error loading Collada:', error );
2310
- this.dispatchEvent( { type: 'error', message: error.message, filename } );
2311
- throw error;
2312
-
2313
- }
2314
-
2315
- }
2316
-
2317
- async load3MFFromArrayBuffer( arrayBuffer, filename = 'model.3mf' ) {
2318
-
2319
- try {
2320
-
2321
- updateLoading( { isLoading: true, status: "Processing 3MF Data...", progress: 5 } );
2322
- await new Promise( r => setTimeout( r, 0 ) );
2323
-
2324
- if ( ! this.loaderCache.threemf ) {
2325
-
2326
- const { ThreeMFLoader } = await import( 'three/examples/jsm/loaders/3MFLoader.js' );
2327
- this.loaderCache.threemf = new ThreeMFLoader();
2328
-
2329
- }
2330
-
2331
- const object = this.loaderCache.threemf.parse( arrayBuffer );
2332
-
2333
- this.releaseTargetModel();
2334
- this.targetModel = object;
2335
-
2336
- updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
2337
- await this.onModelLoad( this.targetModel );
2338
-
2339
- this.dispatchEvent( { type: 'load', model: object, filename } );
2340
- return object;
2341
-
2342
- } catch ( error ) {
2343
-
2344
- console.error( 'Error loading 3MF:', error );
2345
- this.dispatchEvent( { type: 'error', message: error.message, filename } );
2346
- throw error;
2347
-
2348
- }
2349
-
2350
- }
2351
-
2352
- async loadUSDFromArrayBuffer( data, filename = 'model.usd' ) {
2353
-
2354
- try {
2355
-
2356
- updateLoading( { isLoading: true, status: "Processing USD Data...", progress: 5 } );
2357
- await new Promise( r => setTimeout( r, 0 ) );
2358
-
2359
- if ( ! this.loaderCache.usd ) {
2360
-
2361
- const { USDLoader } = await import( 'three/examples/jsm/loaders/USDLoader.js' );
2362
- this.loaderCache.usd = new USDLoader();
2363
-
2364
- }
2365
-
2366
- // parse() returns the group synchronously but resolves textures async;
2367
- // setupPathTracing snapshots materials, so maps must land before it runs.
2368
- const object = await new Promise( ( resolve, reject ) => {
2369
-
2370
- this.loaderCache.usd.parse( data, '', resolve, reject );
2371
-
2372
- } );
2373
-
2374
- object.name = filename;
2375
-
2376
- this.releaseTargetModel();
2377
- this.targetModel = object;
2378
-
2379
- updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
2380
- await this.onModelLoad( this.targetModel );
2381
-
2382
- this.dispatchEvent( { type: 'load', model: object, filename } );
2383
- return object;
2384
-
2385
- } catch ( error ) {
2386
-
2387
- console.error( 'Error loading USD:', error );
2388
- this.dispatchEvent( { type: 'error', message: error.message, filename } );
2389
- throw error;
2390
-
2391
- }
2392
-
2393
- }
2394
-
2395
- async loadObject3D( object3d, name = 'object3d' ) {
2396
-
2397
- this.releaseTargetModel();
2398
-
2399
- const model = this._adoptExternalObject( object3d );
2400
- model.name = object3d.name || name;
2401
-
2402
- this.targetModel = model;
2403
-
2404
- updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
2405
- await this.onModelLoad( this.targetModel );
971
+ updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
972
+ await this.onModelLoad( this.targetModel );
2406
973
 
2407
974
  this.dispatchEvent( { type: 'load', model, filename: name } );
2408
975
  return model;
@@ -2425,7 +992,7 @@ export class AssetLoader extends EventDispatcher {
2425
992
  const center = box.getCenter( new Vector3() );
2426
993
  const size = box.getSize( new Vector3() );
2427
994
 
2428
- this.controls.target.copy( center );
995
+ this.controls?.target.copy( center );
2429
996
 
2430
997
  const maxDim = Math.max( size.x, size.y, size.z );
2431
998
  const fov = this.camera.fov * ( Math.PI / 180 );
@@ -2445,9 +1012,14 @@ export class AssetLoader extends EventDispatcher {
2445
1012
  this.camera.near = maxDim / 100;
2446
1013
  this.camera.far = maxDim * 100;
2447
1014
  this.camera.updateProjectionMatrix();
2448
- this.controls.maxDistance = cameraDistance * 10;
2449
- this.controls.saveState();
2450
- this.controls.update();
1015
+ if ( this.controls ) {
1016
+
1017
+ this.controls.maxDistance = cameraDistance * 10;
1018
+ this.controls.saveState();
1019
+ this.controls.update();
1020
+
1021
+ }
1022
+
2451
1023
  buildTimer.end( 'Camera setup' );
2452
1024
 
2453
1025
  // Adjust floor plane
@@ -2715,10 +1287,12 @@ export class AssetLoader extends EventDispatcher {
2715
1287
 
2716
1288
  getSupportedFormats( type = null ) {
2717
1289
 
1290
+ const formats = { ...CORE_FORMATS, ...this._archiveFormats };
1291
+ for ( const [ extension, { type, name } ] of this._formats ) formats[ extension ] = { type, name };
2718
1292
  if ( type ) {
2719
1293
 
2720
1294
  const filtered = {};
2721
- for ( const [ ext, info ] of Object.entries( SUPPORTED_FORMATS ) ) {
1295
+ for ( const [ ext, info ] of Object.entries( formats ) ) {
2722
1296
 
2723
1297
  if ( info.type === type ) filtered[ ext ] = info;
2724
1298
 
@@ -2728,7 +1302,7 @@ export class AssetLoader extends EventDispatcher {
2728
1302
 
2729
1303
  }
2730
1304
 
2731
- return SUPPORTED_FORMATS;
1305
+ return formats;
2732
1306
 
2733
1307
  }
2734
1308
 
@@ -2759,7 +1333,9 @@ export class AssetLoader extends EventDispatcher {
2759
1333
  this._urlAbort = null;
2760
1334
  this.storage = null;
2761
1335
  this._downloads = null;
2762
- this._pendingGraph = null;
1336
+ this.archives?.release();
1337
+ this.archives = null;
1338
+ this._archiveLoader = null;
2763
1339
 
2764
1340
  this.releaseTargetModel();
2765
1341