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