rayzee 9.4.2 → 9.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +223 -21
  2. package/dist/ActiveColor-YvuQq44O.js +13 -0
  3. package/dist/ArchiveImporter-xuUJiklP.js +4301 -0
  4. package/dist/AssetConfig-DSEntE65.js +30 -0
  5. package/dist/BidirectionalIntegrator-C5PKjJoT.js +934 -0
  6. package/dist/ColorManagement-CheBCPaW.js +1488 -0
  7. package/dist/EngineDefaults-DH3bI5t-.js +844 -0
  8. package/dist/EngineEvents-Ci8dVWCP.js +73 -0
  9. package/dist/EngineIssues-CIZnXHsF.js +72 -0
  10. package/dist/FileFormats-sPGJeCRc.js +102 -0
  11. package/dist/Logger-BnHghprG.js +97 -0
  12. package/dist/{NeuralRetouch-vkdXXyK2.js → NeuralRetouch-CW11yLZ_.js} +8 -6
  13. package/dist/{NeuralSuperRes-FVqRJKQV.js → NeuralSuperRes-BekaecYg.js} +55 -120
  14. package/dist/PathTracer-Ccl9KOuF.js +29729 -0
  15. package/dist/PhysicalSky-Dv2PMFGW.js +1219 -0
  16. package/dist/RayzeeRenderer-BQxnzCTD.js +3542 -0
  17. package/dist/SolarLimb-ken-JbDK.js +721 -0
  18. package/dist/ToneMapGPU-BHi5JzIq.js +177 -0
  19. package/dist/ViewTransforms-D0rTLBsX.js +255 -0
  20. package/dist/WorkingMatrix-C6y233hT.js +27 -0
  21. package/dist/ZipReader-gDSWr0I5.js +98 -0
  22. package/dist/addons/archives.es.js +3 -0
  23. package/dist/addons/bidirectional.es.js +2 -0
  24. package/dist/addons/color.es.js +3 -0
  25. package/dist/addons/formats.es.js +2 -0
  26. package/dist/addons/physical-sky.es.js +2 -0
  27. package/dist/addons/storage.es.js +3 -0
  28. package/dist/archiveFormats-BZw7TiDp.js +75 -0
  29. package/dist/identity-DrQsGT2o.js +39 -0
  30. package/dist/openStorage-DwcBRUj7.js +820 -0
  31. package/dist/rayzee-core.es.js +12 -0
  32. package/dist/rayzee.es.js +2266 -44079
  33. package/dist/rayzee.umd.js +485 -473
  34. package/dist/sceneCachePolicy-BmwYOYDu.js +183 -0
  35. package/dist/shared-BGBcSxBQ.js +17 -0
  36. package/package.json +23 -2
  37. package/src/Color/ActiveColor.js +34 -0
  38. package/src/Color/BasicColor.js +147 -0
  39. package/src/Color/ColorManagement.js +7 -31
  40. package/src/EngineDefaults.js +22 -8
  41. package/src/EngineEvents.js +2 -0
  42. package/src/EngineIssues.js +1 -0
  43. package/src/PathTracerApp.js +617 -4016
  44. package/src/Pipeline/PipelineContext.js +0 -4
  45. package/src/Platform.js +36 -0
  46. package/src/Processor/ArchiveCache.js +1 -1
  47. package/src/Processor/ArchiveImporter.js +1259 -0
  48. package/src/Processor/AssetLoader.js +322 -1746
  49. package/src/Processor/AtmosphereModel.js +1 -2
  50. package/src/Processor/BVHBuilder.js +4 -95
  51. package/src/Processor/EnvironmentExactTable.js +187 -0
  52. package/src/Processor/EquirectHDRInfo.js +30 -223
  53. package/src/Processor/FileFormats.js +116 -0
  54. package/src/Processor/GLTFDecoders.js +85 -0
  55. package/src/Processor/GLTFDiffuseTransmission.js +63 -0
  56. package/src/Processor/GeometryExtractor.js +12 -1
  57. package/src/Processor/IESParser.js +47 -35
  58. package/src/Processor/KernelManager.js +35 -2
  59. package/src/Processor/MaterialPacking.js +8 -7
  60. package/src/Processor/PBRT/PBRTMaterials.js +129 -41
  61. package/src/Processor/PBRT/PBRTParser.js +30 -17
  62. package/src/Processor/PBRT/PBRTSceneBuilder.js +548 -37
  63. package/src/Processor/PBRT/PBRTTextureBake.js +154 -0
  64. package/src/Processor/PBRT/PFM.js +59 -0
  65. package/src/Processor/PackedRayBuffer.js +116 -9
  66. package/src/Processor/ParallelBVHBuilder.js +3 -6
  67. package/src/Processor/PhysicalSky.js +5 -4
  68. package/src/Processor/PlatformImageLoader.js +50 -6
  69. package/src/Processor/QueueManager.js +12 -3
  70. package/src/Processor/ReinsertionOptimizer.js +2 -2
  71. package/src/Processor/SceneProcessor.js +15 -55
  72. package/src/Processor/ShaderBuilder.js +6 -16
  73. package/src/Processor/SolarLimb.js +3 -0
  74. package/src/Processor/TextureCreator.js +1 -1
  75. package/src/Processor/Workers/BVHSubtreeWorker.js +1 -34
  76. package/src/Processor/Workers/BVHWorker.js +5 -18
  77. package/src/Processor/Workers/CDFWorker.js +6 -183
  78. package/src/Processor/archiveFormats.js +7 -0
  79. package/src/Processor/utils.js +2 -1
  80. package/src/RayzeeRenderer.js +3902 -0
  81. package/src/RenderSettings.js +49 -41
  82. package/src/Stages/ASVGF.js +5 -7
  83. package/src/Stages/AutoExposure.js +3 -12
  84. package/src/Stages/Compositor.js +8 -23
  85. package/src/Stages/NRD.js +9 -2
  86. package/src/Stages/NormalDepth.js +9 -6
  87. package/src/Stages/PathTracer.js +254 -38
  88. package/src/Stages/PathTracerStage.js +11 -16
  89. package/src/Storage/BLASCache.js +1 -1
  90. package/src/Storage/CDFCache.js +18 -17
  91. package/src/Storage/DownloadCache.js +1 -1
  92. package/src/Storage/SceneGraphCodec.js +46 -3
  93. package/src/Storage/SpillStore.js +1 -1
  94. package/src/Storage/StorageManager.js +2 -4
  95. package/src/Storage/areas.js +4 -0
  96. package/src/TSL/BVHTraversal.js +62 -24
  97. package/src/TSL/Bidirectional.js +204 -0
  98. package/src/TSL/BidirectionalLamps.js +132 -0
  99. package/src/TSL/Clearcoat.js +15 -6
  100. package/src/TSL/Common.js +19 -14
  101. package/src/TSL/ConnectKernel.js +147 -0
  102. package/src/TSL/Debugger.js +1 -1
  103. package/src/TSL/Displacement.js +4 -3
  104. package/src/TSL/EmissiveSampling.js +64 -17
  105. package/src/TSL/Environment.js +77 -106
  106. package/src/TSL/EnvironmentCDF.js +85 -39
  107. package/src/TSL/ExtendKernel.js +8 -3
  108. package/src/TSL/FinalWriteKernel.js +4 -4
  109. package/src/TSL/GenerateKernel.js +18 -4
  110. package/src/TSL/HitFacet.js +35 -5
  111. package/src/TSL/LightBVHSampling.js +40 -9
  112. package/src/TSL/LightGenerateKernel.js +293 -0
  113. package/src/TSL/LightGuide.js +198 -0
  114. package/src/TSL/LightSplatKernel.js +165 -0
  115. package/src/TSL/LightsCore.js +13 -38
  116. package/src/TSL/LightsDirect.js +28 -29
  117. package/src/TSL/LightsIndirect.js +3 -1
  118. package/src/TSL/LightsSampling.js +90 -38
  119. package/src/TSL/MaterialEvaluation.js +113 -31
  120. package/src/TSL/MaterialProperties.js +44 -17
  121. package/src/TSL/MaterialTransmission.js +52 -47
  122. package/src/TSL/MergeKernel.js +218 -0
  123. package/src/TSL/PathTracerCore.js +65 -29
  124. package/src/TSL/Random.js +4 -0
  125. package/src/TSL/SceneResources.js +51 -0
  126. package/src/TSL/ShadeKernel.js +881 -103
  127. package/src/TSL/Struct.js +10 -0
  128. package/src/TSL/Sun.js +1 -1
  129. package/src/TSL/TextureSampling.js +65 -60
  130. package/src/addons/archives.js +14 -0
  131. package/src/addons/bidirectional.js +13 -0
  132. package/src/addons/color.js +32 -0
  133. package/src/addons/formats.js +16 -0
  134. package/src/addons/physicalSky.js +14 -0
  135. package/src/addons/storage.js +19 -0
  136. package/src/core.js +35 -0
  137. package/src/index.js +3 -1
  138. package/src/integrators/BidirectionalIntegrator.js +832 -0
  139. package/src/managers/EnvironmentManager.js +85 -33
  140. package/src/managers/MaterialDataManager.js +42 -0
  141. package/src/managers/UniformManager.js +0 -1
  142. package/dist/ToneMapGPU-DKQM1pMd.js +0 -552
  143. package/dist/rolldown-runtime-CAFD8bLK.js +0 -11
  144. package/src/Processor/TreeletOptimizer.js +0 -574
@@ -0,0 +1,1259 @@
1
+ /**
2
+ * Scene archives (.zip, .tar, .tar.gz) and the pbrt scenes in them — `rayzee/addons/archives`. The asset loader reads
3
+ * single files itself; an archive needs this importer installed on it (PathTracerApp installs it):
4
+ *
5
+ * @example
6
+ * import { ArchiveImporter } from 'rayzee/addons/archives';
7
+ * renderer.assetLoader.setArchiveImporter( new ArchiveImporter( renderer.assetLoader ) );
8
+ */
9
+
10
+ import { FloatType, EquirectangularReflectionMapping, Texture, SRGBColorSpace, RepeatWrapping, LoadingManager } from 'three';
11
+ import { HDRLoader } from 'three/addons/loaders/HDRLoader.js';
12
+ import { EXRLoader } from 'three/addons/loaders/EXRLoader.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
+ import { setEnvironmentSource } from '../Storage/CDFCache.js';
19
+ import { fileIdentity, identityKey, sampleHash } from '../Storage/identity.js';
20
+ import { ENGINE_AREAS } from '../Storage/areas.js';
21
+ import { encodeSceneGraph, writeSceneGraph, decodeSceneGraph, SceneGraphUnsupported, SCENE_GRAPH_FORMAT, ARCHIVE_PATH, ARCHIVE_LOADER } from '../Storage/SceneGraphCodec.js';
22
+ import { worthStoring } from '../Storage/sceneCachePolicy.js';
23
+ import { VERSION } from '../version.js';
24
+ import { updateLoading } from './utils';
25
+ import { loadPBRTScene, pickEntryPath, VirtualFS, PBRT_BUILD_REVISION } from './PBRT/index.js';
26
+ import { pfmTexture } from './PBRT/PFM.js';
27
+ import { extractSceneMetadata } from './SceneMetadata.js';
28
+ import { ISSUE_CODES, ISSUE_SEVERITY } from '../EngineIssues.js';
29
+ import { ARCHIVE_FORMATS } from './archiveFormats.js';
30
+
31
+ // Loose USD layers inside a ZIP compose into one scene; these pick out the
32
+ // layers and the image assets they reference.
33
+ const USD_LAYER_RE = /\.(usd|usda|usdc)$/i;
34
+ const USD_IMAGE_RE = /\.(png|jpg|jpeg|avif)$/i;
35
+ const MTL_TEXTURE_TIMEOUT_MS = 30000;
36
+ /**
37
+ * Unpacked size past which a multi-part scene archive asks which parts to load rather than
38
+ * taking all of them. Not a hard limit — picking every part is a valid answer.
39
+ *
40
+ * Measured on Moana: a 7.5 GB archive of 15 parts parses to 40M triangles and ~7.3 GB of CPU
41
+ * memory, which loads on a freshly started browser and fails on one that has been up a while.
42
+ * Anything near that is worth a question, because the failure past it kills the tab rather than
43
+ * throwing. Override per load with `promptBytes`.
44
+ */
45
+ export const ARCHIVE_ELEMENT_PROMPT_BYTES = 4_000_000_000;
46
+
47
+ export class ArchiveImporter {
48
+
49
+ /** The archive formats it adds to the loader's. */
50
+ static FORMATS = ARCHIVE_FORMATS;
51
+
52
+ /** @param {import('./AssetLoader.js').AssetLoader} loader - the loader it reads archives for */
53
+ constructor( loader ) {
54
+
55
+ this.loader = loader;
56
+ this._pendingGraph = null;
57
+ this.lastPBRTStats = null;
58
+
59
+ }
60
+
61
+ /** Drops a parsed scene still waiting to be stored: the model it was for is gone. */
62
+ release() {
63
+
64
+ this._pendingGraph = null;
65
+
66
+ }
67
+
68
+ /** First bytes of a file, without pulling the whole thing into memory. */
69
+ async _readHead( file, bytes = 512 ) {
70
+
71
+ if ( file instanceof Uint8Array ) return file.subarray( 0, bytes );
72
+ if ( typeof file.slice === 'function' && typeof file.arrayBuffer === 'function' ) {
73
+
74
+ return new Uint8Array( await file.slice( 0, bytes ).arrayBuffer() );
75
+
76
+ }
77
+
78
+ return new Uint8Array( ( await this.loader.readFileAsArrayBuffer( file ) ).slice( 0, bytes ) );
79
+
80
+ }
81
+
82
+ /**
83
+ * Enumerates a TAR/TAR.GZ archive without keeping any of it, so a caller can offer a
84
+ * choice before committing memory. ZIP archives are random-access and report directly.
85
+ * @returns {Promise<{kind:string, root:string|null, elements:Array, entryCount:number, totalBytes:number}>}
86
+ */
87
+ async inspectArchive( file ) {
88
+
89
+ const kind = detectArchiveKind( await this._readHead( file ) );
90
+ const scanning = p => updateLoading( {
91
+ isLoading: true, status: `Scanning archive… ${( p.bytes / 1e9 ).toFixed( 1 )} GB`, progress: 4
92
+ } );
93
+
94
+ let listing;
95
+ if ( kind === 'gzip' ) {
96
+
97
+ const unpacked = await this._unpackGzip( file, file.name );
98
+ if ( unpacked ) {
99
+
100
+ listing = unpacked.index.listing;
101
+ unpacked.release();
102
+
103
+ } else {
104
+
105
+ ( { listing } = await readTarGz( file, { filter: () => false, onProgress: scanning } ) );
106
+
107
+ }
108
+
109
+ } else if ( kind === 'tar' ) {
110
+
111
+ listing = ( await loadTarIndex( file, this.loader.storage ) )?.listing ?? await indexTarHeaders( file, { onProgress: scanning } );
112
+
113
+ } else {
114
+
115
+ listing = ( await readZipDirectory( file ) ).map( e => ( { path: e.name, size: e.size } ) );
116
+
117
+ }
118
+
119
+ const { root, elements } = listArchiveElements( listing );
120
+ return {
121
+ kind: kind ?? 'zip', root, elements,
122
+ entryCount: listing.length,
123
+ totalBytes: listing.reduce( ( n, e ) => n + e.size, 0 )
124
+ };
125
+
126
+ }
127
+
128
+ /**
129
+ * A .tar.gz unpacked to storage, or null to read it the old way (no storage, or no room).
130
+ * @private
131
+ */
132
+ async _unpackGzip( file, filename ) {
133
+
134
+ try {
135
+
136
+ return await unpackTarGz( file, {
137
+ storage: this.loader.storage,
138
+ label: filename,
139
+ onProgress: bytes => updateLoading( {
140
+ isLoading: true, status: `Unpacking archive… ${( bytes / 1e9 ).toFixed( 1 )} GB`, progress: 4
141
+ } ),
142
+ } );
143
+
144
+ } catch ( error ) {
145
+
146
+ // A corrupt stream fails the same way in memory, where the error reads better.
147
+ console.warn( `Unpacking "${filename}" to storage failed, reading it in memory: ${error.message}` );
148
+ return null;
149
+
150
+ }
151
+
152
+ }
153
+
154
+ /**
155
+ * @param {File|Blob} file
156
+ * @param {string} filename
157
+ * @param {object} [options]
158
+ * @param {string} [options.pbrtEntry] - which .pbrt to load when the archive holds several
159
+ * independent scenes; the issue log lists what else was available.
160
+ * @param {string|string[]} [options.element] - path prefix of one subtree to load on its own,
161
+ * or several to load together, as reported by `inspectArchive()`. Everything above them
162
+ * (scene file, materials, textures) comes along; subtrees left out are skipped without ever
163
+ * being held in memory, and the Includes that point at them only warn.
164
+ * @param {number} [options.byteBudget] - cap on retained bytes when no element is chosen.
165
+ * @param {number} [options.promptBytes] - size past which a multi-part archive asks which
166
+ * parts to load instead of taking all of them.
167
+ * @param {number} [options.maxTriangles] - stop past this many STORED triangles.
168
+ * @param {number} [options.maxPlacements] - stop past this many instance placements.
169
+ * @param {number} [options.mergeShapesAbove] - merge small non-instanced shapes past this count.
170
+ * @param {number} [options.curveSteps] - samples per spline span when tessellating curves.
171
+ * @param {number} [options.curveSides] - 1 ribbon, 2 crossed ribbons, >=3 closed tube.
172
+ * @param {number} [options.curveTolerance] - how far a curve strip may stray from the curve, as a
173
+ * fraction of its half-width (default 0.05); 0 samples every span `curveSteps` times.
174
+ * @param {boolean} [options.instanceIncludes] - place a file included again under the same
175
+ * material as another instance of its first reading (default true).
176
+ */
177
+ async loadArchiveFromFile( file, filename, { pbrtEntry = null, element = null, byteBudget, promptBytes, ...pbrt } = {} ) {
178
+
179
+ try {
180
+
181
+ const kind = detectArchiveKind( await this._readHead( file ) );
182
+ const archiveId = this.loader.storage ? identityKey( await fileIdentity( file ) ) : null;
183
+ const lazy = { element, promptBytes, pbrtEntry, pbrt, archiveId };
184
+
185
+ // A seekable archive is indexed rather than read: entries are pulled out as the scene
186
+ // asks for them and never all held at once. That is the difference between a 7 GB archive
187
+ // being refused and loading. A .tar.gz becomes seekable by unpacking it to storage.
188
+ if ( kind === 'tar' ) return await this._loadSeekable( file, filename, lazy );
189
+
190
+ if ( kind === 'gzip' ) {
191
+
192
+ const unpacked = await this._unpackGzip( file, filename );
193
+ if ( unpacked ) {
194
+
195
+ try {
196
+
197
+ return await this._loadSeekable( unpacked.file, filename, { ...lazy, index: unpacked.index } );
198
+
199
+ } finally {
200
+
201
+ unpacked.release();
202
+
203
+ }
204
+
205
+ }
206
+
207
+ const entries = await this._readStreamedArchive( file, filename, kind, element, byteBudget );
208
+
209
+ // A pbrt scene archive takes priority — it owns its own geometry/texture refs.
210
+ if ( pickEntryPath( entries ) ) return await this.loadPBRTFromZip( entries, filename, pbrtEntry, pbrt );
211
+ const result = await this._loadNonPBRTArchive( entries, filename );
212
+ this.loader._sourceKey = this.loader._keyed( 'archive', archiveId );
213
+ return result;
214
+
215
+ }
216
+
217
+ return await this._loadSeekable( file, filename, { ...lazy, zip: true } );
218
+
219
+ } catch ( error ) {
220
+
221
+ if ( error?.code !== 'ARCHIVE_NEEDS_ELEMENT' ) console.error( 'Error loading archive:', error );
222
+ throw error;
223
+
224
+ }
225
+
226
+ }
227
+
228
+ async _loadNonPBRTArchive( entries, filename ) {
229
+
230
+ const result = await this.processObjMtlPairsInZip( entries, filename );
231
+ if ( result ) return result;
232
+ return await this.findAndLoadModelFromZip( entries, filename );
233
+
234
+ }
235
+
236
+ /**
237
+ * Loads a seekable archive — a .tar (or a .tar.gz unpacked to one) or a .zip — reading each
238
+ * entry only when the scene asks for it.
239
+ * @private
240
+ */
241
+ async _loadSeekable( file, filename, { element, promptBytes, pbrtEntry, pbrt, index = null, zip = false, archiveId = null } ) {
242
+
243
+ const source = await this._openSeekableArchive( file, filename, element, promptBytes, { index, zip } );
244
+ source.archiveId = archiveId;
245
+ source.elements = ( Array.isArray( element ) ? element : [ element ] ).filter( Boolean );
246
+ if ( source.listing.some( e => e.path.toLowerCase().endsWith( '.pbrt' ) ) ) {
247
+
248
+ return await this.loadPBRTFromZip( {}, filename, pbrtEntry, pbrt, source );
249
+
250
+ }
251
+
252
+ // Not a pbrt scene: fall back to materialising it, which those paths still expect.
253
+ for ( const e of source.listing ) if ( e.offset !== undefined ) source.entries[ e.path ] ??= await source.read( e.path );
254
+ const result = await this._loadNonPBRTArchive( source.entries, filename );
255
+ this.loader._sourceKey = this.loader._keyed( 'archive', archiveId );
256
+ return result;
257
+
258
+ }
259
+
260
+ /**
261
+ * The parsed scene stored for this archive and these options, decoded — its textures read back
262
+ * out of the archive — or null.
263
+ * @private
264
+ */
265
+ async _loadStoredGraph( key, source, { imageFromBytes, envFromBytes } ) {
266
+
267
+ const area = this.loader.storage?.area( ENGINE_AREAS.SCENES );
268
+ const entry = area ? await area.open( key ) : null;
269
+ if ( ! entry ) return null;
270
+
271
+ const start = performance.now();
272
+ let failure = null;
273
+ try {
274
+
275
+ updateLoading( { isLoading: true, status: 'Opening the stored scene...', progress: 6 } );
276
+ const manifest = await entry.json( 'graph.json' );
277
+ const data = await entry.file( 'data.bin' );
278
+ const vfs = new VirtualFS( {}, source );
279
+ const decoded = await decodeSceneGraph( manifest, data, {
280
+ loadTexture: async ( path, loader ) => {
281
+
282
+ const bytes = await vfs.readPath( path );
283
+ if ( ! bytes ) return null;
284
+ return loader === 'environment' ? envFromBytes( bytes, path ) : imageFromBytes( bytes, path );
285
+
286
+ },
287
+ } );
288
+
289
+ return {
290
+ ...decoded.stats,
291
+ group: decoded.root,
292
+ environment: decoded.environment ? { texture: decoded.environment } : null,
293
+ animations: decoded.animations,
294
+ report: null,
295
+ parseMs: 0,
296
+ buildMs: performance.now() - start,
297
+ fromStorage: true,
298
+ };
299
+
300
+ } catch ( error ) {
301
+
302
+ failure = error;
303
+ return null;
304
+
305
+ } finally {
306
+
307
+ entry.release();
308
+ if ( failure ) {
309
+
310
+ console.warn( `The stored scene could not be used, parsing the archive instead: ${failure.message}` );
311
+ this.loader._issues?.warn( ISSUE_CODES.STORAGE_ENTRY_CORRUPT, `stored scene unusable: ${failure.message}`, { key } );
312
+ area.remove( key ).catch( () => {} );
313
+
314
+ }
315
+
316
+ }
317
+
318
+ }
319
+
320
+ /**
321
+ * Stores a freshly parsed scene for the next open. Encoded here, before onModelLoad or the
322
+ * build touch it; written in the background.
323
+ * @private
324
+ */
325
+ _storeGraph( key, built, label ) {
326
+
327
+ const area = this.loader.storage?.area( ENGINE_AREAS.SCENES );
328
+ if ( ! area ) return;
329
+
330
+ let encoded;
331
+ try {
332
+
333
+ encoded = encodeSceneGraph( built.group, {
334
+ environment: built.environment?.texture ?? null,
335
+ animations: built.animations ?? [],
336
+ stats: {
337
+ meshCount: built.meshCount, entryPath: built.entryPath, candidates: built.candidates, frames: built.frames,
338
+ warnings: built.warnings, triangleCount: built.triangleCount, placementCount: built.placementCount,
339
+ mergedShapes: built.mergedShapes, skippedForBudget: built.skippedForBudget, droppedNoTemplate: built.droppedNoTemplate,
340
+ },
341
+ } );
342
+
343
+ } catch ( error ) {
344
+
345
+ if ( error instanceof SceneGraphUnsupported ) console.info( `Scene not stored for next time: ${error.message}` );
346
+ else console.warn( 'Scene not stored for next time:', error );
347
+ return;
348
+
349
+ }
350
+
351
+ const pending = { key, encoded, label, parseMs: ( built.parseMs ?? 0 ) + ( built.buildMs ?? 0 ), triangles: built.triangleCount ?? null };
352
+
353
+ // A parse slow enough on its own is written now, during the build. Held until the build
354
+ // ends, it kept every array the build replaces alive with it — the float normals and the
355
+ // instance matrices, ~1 GB on the whole Moana subset, at the build's peak.
356
+ if ( worthStoring( pending.parseMs, encoded.byteLength ) ) this._writeGraph( area, pending );
357
+ else this._pendingGraph = pending;
358
+
359
+ }
360
+
361
+ /**
362
+ * Writes the scene encoded at parse time once the build is done, when the whole cold load —
363
+ * parse plus build — was slow enough to be worth it (decision D6).
364
+ * @param {number} buildMs - the SceneProcessor build that followed the parse
365
+ */
366
+ flushPendingGraph( buildMs ) {
367
+
368
+ const pending = this._pendingGraph;
369
+ this._pendingGraph = null;
370
+ const area = this.loader.storage?.area( ENGINE_AREAS.SCENES );
371
+ if ( ! pending || ! area || ! worthStoring( pending.parseMs + buildMs, pending.encoded.byteLength ) ) return;
372
+ this._writeGraph( area, pending );
373
+
374
+ }
375
+
376
+ /** Writes an encoded graph in the background, letting each array go once it is on disk. @private */
377
+ _writeGraph( area, pending ) {
378
+
379
+ ( async () => {
380
+
381
+ const writer = await area.create( pending.key, { label: `${pending.label} (scene)`, expectedBytes: pending.encoded.byteLength } );
382
+ if ( ! writer ) return;
383
+ try {
384
+
385
+ await writeSceneGraph( writer, pending.encoded, { release: true } );
386
+ await writer.commit( { triangles: pending.triangles } );
387
+
388
+ } catch ( error ) {
389
+
390
+ await writer.abort();
391
+ console.warn( 'Storing the scene failed:', error );
392
+
393
+ }
394
+
395
+ } )().catch( error => console.warn( 'Storing the scene failed:', error ) );
396
+
397
+ }
398
+
399
+ /**
400
+ * Index a seekable archive without retaining it. Entries are read back from the File on
401
+ * demand, so residency is the open include chain rather than the whole archive.
402
+ *
403
+ * Indexing is cheap and the whole archive is never held, but *parsing* all of it is not:
404
+ * the scene that comes out is what runs the tab out of memory. So a large multi-part archive
405
+ * stops here and asks which parts to load, the same as the streamed path does. The index is
406
+ * already built at that point, so the question costs nothing.
407
+ * @private
408
+ */
409
+ async _openSeekableArchive( file, filename, element, promptBytes, { index = null, zip = false } = {} ) {
410
+
411
+ const chosen = Array.isArray( element ) ? element.filter( Boolean ) : ( element ? [ element ] : [] );
412
+ const filter = chosen.length ? elementFilter( chosen ) : null;
413
+
414
+ let source;
415
+ if ( zip ) {
416
+
417
+ source = await openZip( file, { filter } );
418
+
419
+ } else {
420
+
421
+ const saved = index ?? await loadTarIndex( file, this.loader.storage );
422
+ source = await openTar( file, {
423
+ index: saved,
424
+ headersOnly: true,
425
+ filter,
426
+ onProgress: p => updateLoading( {
427
+ isLoading: true, status: `Indexing archive… ${( p.bytes / 1e9 ).toFixed( 1 )} GB`, progress: 4
428
+ } ),
429
+ } );
430
+ if ( ! saved ) saveTarIndex( file, this.loader.storage, source.index ).catch( () => {} );
431
+
432
+ }
433
+
434
+ const totalBytes = source.listing.reduce( ( n, e ) => n + e.size, 0 );
435
+
436
+ if ( chosen.length === 0 ) this._requireElementChoice( filename, source.listing, totalBytes, promptBytes );
437
+
438
+ console.info(
439
+ `Archive "${filename}": indexed ${source.indexed} entries, ` +
440
+ `${( totalBytes / 1e9 ).toFixed( 1 )} GB, none resident` +
441
+ ( chosen.length ? `, ${chosen.length} part${chosen.length > 1 ? 's' : ''} selected.` : '.' )
442
+ );
443
+
444
+ return source;
445
+
446
+ }
447
+
448
+ /**
449
+ * Stop and ask which parts to load, when the archive is big enough that the answer matters
450
+ * and it actually has parts to choose between. Not a capability limit — selecting every part
451
+ * is a valid answer and loads the whole scene.
452
+ * @private
453
+ */
454
+ _requireElementChoice( filename, listing, totalBytes, promptBytes = ARCHIVE_ELEMENT_PROMPT_BYTES ) {
455
+
456
+ if ( totalBytes < promptBytes ) return;
457
+
458
+ const { root, elements } = listArchiveElements( listing );
459
+ if ( elements.length < 2 ) return;
460
+
461
+ const error = new Error(
462
+ `"${filename}" holds ${( totalBytes / 1e9 ).toFixed( 1 )} GB across ${elements.length} parts. ` +
463
+ 'Choose which to load — loading all of them at once may exhaust memory.'
464
+ );
465
+ error.code = 'ARCHIVE_NEEDS_ELEMENT';
466
+ error.root = root;
467
+ error.elements = elements;
468
+ error.totalBytes = totalBytes;
469
+
470
+ // Logged as a warning on purpose: recording an ERROR makes a strict host throw an
471
+ // EngineIssueError from inside record(), and the caller never learns which parts it
472
+ // could have chosen. The typed throw below is the refusal, and it carries the list.
473
+ this.loader._issues?.record(
474
+ ISSUE_CODES.ASSET_ARCHIVE_TOO_LARGE,
475
+ `archive holds ${( totalBytes / 1e9 ).toFixed( 1 )} GB across ${elements.length} parts; choose which to load`,
476
+ { root, elements: elements.map( e => e.prefix ), totalBytes },
477
+ ISSUE_SEVERITY.WARNING
478
+ );
479
+
480
+ throw error;
481
+
482
+ }
483
+
484
+ async _readStreamedArchive( file, filename, kind, element, byteBudget ) {
485
+
486
+ const chosen = Array.isArray( element ) ? element.filter( Boolean ) : ( element ? [ element ] : [] );
487
+ const { entries, listing, retainedBytes, truncated } = await readTarGz( file, {
488
+ filter: chosen.length ? elementFilter( chosen ) : null,
489
+ ...( byteBudget === undefined ? {} : { byteBudget } ),
490
+ onProgress: p => updateLoading( {
491
+ isLoading: true,
492
+ status: `Reading archive… ${( p.bytes / 1e9 ).toFixed( 1 )} GB`,
493
+ progress: 4
494
+ } )
495
+ } );
496
+
497
+ // Choosing every part is a valid answer, so a selection is not refused again for being
498
+ // large: the read budget exists to stop an unasked-for whole-archive load. Past this
499
+ // point the scene's own memory preflight is what refuses, with a figure to act on.
500
+ if ( truncated && chosen.length === 0 ) {
501
+
502
+ const { root, elements } = listArchiveElements( listing );
503
+ const total = listing.reduce( ( n, e ) => n + e.size, 0 );
504
+ const error = new Error(
505
+ `"${filename}" unpacks to ${( total / 1e9 ).toFixed( 1 )} GB, past the load budget. ` +
506
+ `Load one of its ${elements.length} parts instead.`
507
+ );
508
+ error.code = 'ARCHIVE_NEEDS_ELEMENT';
509
+ error.root = root;
510
+ error.elements = elements;
511
+ error.totalBytes = total;
512
+ // Warning, not error: a strict host's throw from record() would replace the typed
513
+ // error below and take the part list with it.
514
+ this.loader._issues?.record(
515
+ ISSUE_CODES.ASSET_ARCHIVE_TOO_LARGE,
516
+ `archive unpacks to ${( total / 1e9 ).toFixed( 1 )} GB; choose one of ${elements.length} parts`,
517
+ { root, elements: elements.map( e => e.prefix ), totalBytes: total },
518
+ ISSUE_SEVERITY.WARNING
519
+ );
520
+ throw error;
521
+
522
+ }
523
+
524
+ if ( truncated ) {
525
+
526
+ const keep = elementFilter( chosen );
527
+ const missing = listing.filter( e => keep( e.path, e.size ) && ! entries[ e.path ] ).length;
528
+ this.loader._issues?.record(
529
+ ISSUE_CODES.ASSET_ARCHIVE_TOO_LARGE,
530
+ `${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`,
531
+ { elements: chosen, missing, retainedBytes },
532
+ ISSUE_SEVERITY.WARNING
533
+ );
534
+
535
+ }
536
+
537
+ if ( chosen.length ) {
538
+
539
+ console.info(
540
+ `Archive "${filename}": loaded ${chosen.map( c => `"${c}"` ).join( ', ' )} — ` +
541
+ `${Object.keys( entries ).length} of ${listing.length} files, ${( retainedBytes / 1048576 ).toFixed( 1 )} MB.`
542
+ );
543
+
544
+ }
545
+
546
+ return entries;
547
+
548
+ }
549
+
550
+ /**
551
+ * Loads a pbrt-v4 scene from an unzipped archive. Parses the entry .pbrt
552
+ * (following Include/Import), builds a THREE.Group, sets the infinite light
553
+ * as the scene environment, and runs the standard onModelLoad pipeline.
554
+ * @param {Object<string, Uint8Array>} zip - unzipped entries (path → bytes); consumed, entry by entry
555
+ * @param {string} filename - original archive name (for display/events)
556
+ */
557
+ async loadPBRTFromZip( zip, filename, entryPath = null, options = {}, source = null ) {
558
+
559
+ updateLoading( { isLoading: true, status: 'Parsing PBRT scene...', progress: 5 } );
560
+
561
+ // Geometry decoder — reuse the cached PLYLoader (pbrt leans on .ply meshes).
562
+ if ( ! this.loader.loaderCache.ply ) {
563
+
564
+ const { PLYLoader } = await import( 'three/examples/jsm/loaders/PLYLoader.js' );
565
+ this.loader.loaderCache.ply = new PLYLoader();
566
+
567
+ }
568
+
569
+ const plyParser = ( buf ) => this.loader.loaderCache.ply.parse( buf );
570
+
571
+ // Texture maps — decode by extension (pbrt uses .png/.jpg but also .exr/.hdr/.tga). Tagged
572
+ // with their archive path so a stored scene can read them back instead of keeping pixels.
573
+ const imageFromBytes = async ( bytes, fname ) => {
574
+
575
+ const texture = await this._pbrtTextureFromBytes( bytes, fname );
576
+ if ( texture ) texture.userData[ ARCHIVE_PATH ] = fname;
577
+ return texture;
578
+
579
+ };
580
+
581
+ // Infinite-light maps → HDR/EXR/LDR via the shared environment decoder.
582
+ const envFromBytes = async ( bytes, fname ) => {
583
+
584
+ const ext = fname.split( '.' ).pop().toLowerCase();
585
+ const blob = new Blob( [ bytes ] );
586
+ const url = ext === 'pfm' ? null : URL.createObjectURL( blob );
587
+ try {
588
+
589
+ const texture = url ? await this.loader.loadEnvironmentByExtension( url, ext, { loader: ext === 'exr' ? this._exrLoader() : null } ) : pfmTexture( bytes );
590
+ texture.mapping = EquirectangularReflectionMapping;
591
+ setEnvironmentSource( texture, `bytes:${await sampleHash( blob )}` );
592
+ texture.userData[ ARCHIVE_PATH ] = fname;
593
+ texture.userData[ ARCHIVE_LOADER ] = 'environment';
594
+ return texture;
595
+
596
+ } finally {
597
+
598
+ if ( url ) URL.revokeObjectURL( url );
599
+
600
+ }
601
+
602
+ };
603
+
604
+ const pbrtStart = performance.now();
605
+ const shape = {
606
+ animation: options.animation, maxTriangles: options.maxTriangles, maxPlacements: options.maxPlacements,
607
+ mergeShapesAbove: options.mergeShapesAbove, curveSteps: options.curveSteps, curveSides: options.curveSides,
608
+ curveTolerance: options.curveTolerance, instanceIncludes: options.instanceIncludes,
609
+ };
610
+ const graphKey = source?.archiveId
611
+ ? this.loader._keyed( `pbrt:${SCENE_GRAPH_FORMAT}.${PBRT_BUILD_REVISION}:${VERSION}`, `${source.archiveId}|${entryPath ?? ''}|${[ ...( source.elements ?? [] ) ].sort().join( ',' )}|${JSON.stringify( shape )}` )
612
+ : null;
613
+
614
+ const stored = graphKey ? await this._loadStoredGraph( graphKey, source, { imageFromBytes, envFromBytes } ) : null;
615
+ const built = stored ?? await loadPBRTScene( { vfs: zip, source, entryPath, plyParser, imageFromBytes, envFromBytes, ...shape } );
616
+
617
+ const { group, environment, animations, report, warnings, meshCount, entryPath: loadedEntry, candidates,
618
+ frames, parseMs, buildMs, triangleCount, placementCount, mergedShapes, skippedForBudget,
619
+ droppedNoTemplate } = built;
620
+ if ( stored ) console.info( `PBRT scene "${loadedEntry}" opened from storage in ${( buildMs / 1000 ).toFixed( 1 )} s, skipping the parse` );
621
+
622
+ // Phase breakdown for scaling work; the engine's own build timings live in
623
+ // SceneProcessor.performanceMetrics.
624
+ this.lastPBRTStats = {
625
+ parseMs, buildMs, loaderMs: performance.now() - pbrtStart,
626
+ triangleCount, placementCount, mergedShapes, skippedForBudget, droppedNoTemplate, meshCount,
627
+ frames: frames?.length ?? 1
628
+ };
629
+
630
+ // An archive can hold several independent scenes (bistro ships three views). Only one is
631
+ // loaded, so name it and the alternatives rather than leave the user comparing against a
632
+ // reference of a different scene. A frame sequence loads whole, as its animation.
633
+ const loaded = new Set( frames ?? [ loadedEntry ] );
634
+ const others = candidates.filter( p => ! loaded.has( p ) );
635
+ if ( others.length > 0 ) {
636
+
637
+ console.warn( `PBRT archive holds ${candidates.length} scenes; loaded "${loadedEntry}". Others: ${others.join( ', ' )}` );
638
+ this.loader._issues?.record(
639
+ ISSUE_CODES.ASSET_AMBIGUOUS_ENTRY,
640
+ `archive holds ${candidates.length} pbrt scenes; loaded "${loadedEntry}"`,
641
+ { loaded: loadedEntry, alternatives: others },
642
+ ISSUE_SEVERITY.WARNING
643
+ );
644
+
645
+ }
646
+
647
+ // Diagnostics — surface what each mesh resolved to (helps debug black/wrong materials).
648
+ if ( report && report.length && typeof console.table === 'function' ) {
649
+
650
+ const shown = report.length < meshCount ? ` (first ${report.length} of ${meshCount})` : '';
651
+ console.groupCollapsed( `PBRT loader: ${meshCount} mesh(es) from "${loadedEntry}"${shown}` );
652
+ console.table( report );
653
+ console.groupEnd();
654
+
655
+ }
656
+
657
+ if ( warnings && warnings.length ) {
658
+
659
+ console.warn( `PBRT loader: ${warnings.length} warning(s) parsing "${loadedEntry}"` );
660
+ warnings.forEach( w => console.warn( ' •', w ) );
661
+
662
+ }
663
+
664
+ // Infinite light → scene environment (CDF is built later in loadSceneData).
665
+ if ( environment?.texture ) {
666
+
667
+ environment.texture.generateMipmaps = true;
668
+ this.loader.applyEnvironmentToScene( environment.texture );
669
+
670
+ }
671
+
672
+ group.name = loadedEntry || filename;
673
+ this.loader.releaseTargetModel();
674
+ this.loader._sourceKey = graphKey;
675
+ // Before onModelLoad and the build touch the new group.
676
+ if ( graphKey && ! stored ) this._storeGraph( graphKey, built, filename );
677
+ this.loader.targetModel = group;
678
+ this.loader.animations = animations ?? [];
679
+
680
+ // The light's own orientation and `scale` are already baked into the texture, so the
681
+ // scene is only correct at rotation 0 / intensity 1 — pinned, whatever the host's defaults.
682
+ // Without an infinite light pbrt has no environment at all.
683
+ this.loader.sceneMetadata = { environment: environment?.texture ? { rotation: 0, intensity: 1 } : { enabled: false } };
684
+
685
+ updateLoading( { isLoading: true, status: 'Processing PBRT geometry...', progress: 10 } );
686
+ await this.loader.onModelLoad( this.loader.targetModel );
687
+
688
+ this.loader.dispatchEvent( { type: 'load', model: group, filename: `${loadedEntry} (from ZIP)` } );
689
+ return group;
690
+
691
+ }
692
+
693
+ /**
694
+ * Decodes a pbrt texture map from raw bytes, picking a decoder by extension.
695
+ * ImageBitmap can't handle EXR/HDR/TGA, which pbrt scenes use freely.
696
+ * @param {Uint8Array} bytes
697
+ * @param {string} fname
698
+ * @returns {Promise<import('three').Texture>}
699
+ */
700
+ // pbrt scenes carry EXR maps whether or not the host registered the EXR format.
701
+ _exrLoader() {
702
+
703
+ return this.loader.loaderCache.exr ??= new EXRLoader().setDataType( FloatType );
704
+
705
+ }
706
+
707
+ async _pbrtTextureFromBytes( bytes, fname ) {
708
+
709
+ const ext = fname.split( '.' ).pop().toLowerCase();
710
+ let tex;
711
+
712
+ if ( ext === 'exr' || ext === 'hdr' ) {
713
+
714
+ const loader = ext === 'hdr'
715
+ ? ( this.loader.loaderCache.hdr || ( this.loader.loaderCache.hdr = new HDRLoader().setDataType( FloatType ) ) )
716
+ : this._exrLoader();
717
+ tex = await this._loadViaObjectURL( loader, bytes );
718
+ // HDR/EXR maps are linear — leave colorSpace as the loader set it.
719
+
720
+ } else if ( ext === 'pfm' ) {
721
+
722
+ tex = pfmTexture( bytes );
723
+
724
+ } else if ( ext === 'tga' ) {
725
+
726
+ if ( ! this.loader.loaderCache.tga ) {
727
+
728
+ const { TGALoader } = await import( 'three/examples/jsm/loaders/TGALoader.js' );
729
+ this.loader.loaderCache.tga = new TGALoader();
730
+
731
+ }
732
+
733
+ tex = await this._loadViaObjectURL( this.loader.loaderCache.tga, bytes );
734
+ tex.colorSpace = SRGBColorSpace;
735
+
736
+ } else {
737
+
738
+ // png / jpg / webp / gif / bmp
739
+ const bitmap = await createImageBitmap( new Blob( [ bytes ] ) );
740
+ tex = new Texture( bitmap );
741
+ tex.colorSpace = SRGBColorSpace;
742
+
743
+ }
744
+
745
+ tex.wrapS = tex.wrapT = RepeatWrapping;
746
+ tex.needsUpdate = true;
747
+ return tex;
748
+
749
+ }
750
+
751
+ /** Decode bytes through a three loader's loadAsync via a transient object URL. */
752
+ async _loadViaObjectURL( loader, bytes ) {
753
+
754
+ const url = URL.createObjectURL( new Blob( [ bytes ] ) );
755
+ try {
756
+
757
+ return await loader.loadAsync( url );
758
+
759
+ } finally {
760
+
761
+ URL.revokeObjectURL( url );
762
+
763
+ }
764
+
765
+ }
766
+
767
+ async processObjMtlPairsInZip( zip, filename ) {
768
+
769
+ const objFiles = [];
770
+ const mtlFiles = [];
771
+
772
+ for ( const path in zip ) {
773
+
774
+ const lowerPath = path.toLowerCase();
775
+ if ( lowerPath.endsWith( '.obj' ) ) objFiles.push( { path, content: zip[ path ] } );
776
+ else if ( lowerPath.endsWith( '.mtl' ) ) mtlFiles.push( { path, content: zip[ path ] } );
777
+
778
+ }
779
+
780
+ if ( objFiles.length > 0 && mtlFiles.length > 0 ) {
781
+
782
+ console.log( `Found ${objFiles.length} OBJ files and ${mtlFiles.length} MTL files in ZIP` );
783
+ const matches = this.findMatchingObjMtlPairs( objFiles, mtlFiles );
784
+
785
+ if ( matches.length > 0 ) {
786
+
787
+ console.log( `Found ${matches.length} matching OBJ+MTL pairs` );
788
+ return await this.loadOBJMTLPairFromZip( matches[ 0 ].obj, matches[ 0 ].mtl, zip, filename );
789
+
790
+ }
791
+
792
+ if ( matches.length === 0 ) {
793
+
794
+ console.log( 'No matching pairs by name, using first OBJ and MTL files' );
795
+ return await this.loadOBJMTLPairFromZip( objFiles[ 0 ], mtlFiles[ 0 ], zip, filename );
796
+
797
+ }
798
+
799
+ }
800
+
801
+ return null;
802
+
803
+ }
804
+
805
+ findMatchingObjMtlPairs( objFiles, mtlFiles ) {
806
+
807
+ const matches = [];
808
+ for ( const objFile of objFiles ) {
809
+
810
+ const objBaseName = objFile.path.split( '/' ).pop().replace( /\.obj$/i, '' ).toLowerCase();
811
+
812
+ for ( const mtlFile of mtlFiles ) {
813
+
814
+ const mtlBaseName = mtlFile.path.split( '/' ).pop().replace( /\.mtl$/i, '' ).toLowerCase();
815
+ if ( objBaseName === mtlBaseName || objBaseName.includes( mtlBaseName ) || mtlBaseName.includes( objBaseName ) ) {
816
+
817
+ matches.push( { obj: objFile, mtl: mtlFile } );
818
+ break;
819
+
820
+ }
821
+
822
+ }
823
+
824
+ }
825
+
826
+ return matches;
827
+
828
+ }
829
+
830
+ async findAndLoadModelFromZip( zip ) {
831
+
832
+ const mainModelFiles = [
833
+ 'scene.gltf', 'scene.glb', 'model.gltf', 'model.glb',
834
+ 'main.gltf', 'main.glb', 'asset.gltf', 'asset.glb'
835
+ ];
836
+
837
+ for ( const mainFile of mainModelFiles ) {
838
+
839
+ if ( zip[ mainFile ] ) {
840
+
841
+ console.log( `Found main model file: ${mainFile}` );
842
+ const extension = mainFile.split( '.' ).pop().toLowerCase();
843
+ return await this.loadModelFromZipEntry( zip[ mainFile ], mainFile, extension, zip );
844
+
845
+ }
846
+
847
+ }
848
+
849
+ for ( const path in zip ) {
850
+
851
+ const extension = path.split( '.' ).pop().toLowerCase();
852
+ if ( extension === 'obj' || this.loader.getFileFormat( path )?.type === 'model' ) {
853
+
854
+ // A loose layer is only one slice of a USD scene — hand the whole
855
+ // archive over so its references and payloads can resolve.
856
+ if ( USD_LAYER_RE.test( path ) ) return await this.loadUSDHierarchyFromZip( zip );
857
+
858
+ console.log( `Loading model file from ZIP: ${path}` );
859
+ return await this.loadModelFromZipEntry( zip[ path ], path, extension, zip );
860
+
861
+ }
862
+
863
+ }
864
+
865
+ throw new Error( 'No supported model files found in the ZIP archive' );
866
+
867
+ }
868
+
869
+ // USDLoader only builds a cross-layer asset map on its USDZ branch, so repack
870
+ // the archive as USDZ — root layer first, per AOUSD core spec 16.4.1.2 — and
871
+ // let the loader resolve the references/payloads itself.
872
+ async loadUSDHierarchyFromZip( zip ) {
873
+
874
+ const layers = Object.keys( zip ).filter( name => USD_LAYER_RE.test( name ) );
875
+ if ( layers.length === 0 ) throw new Error( 'No USD layers found in the ZIP archive' );
876
+
877
+ const root = ArchiveImporter._pickUSDRootLayer( layers );
878
+ console.log( `Loading USD scene from ZIP: ${root} (${layers.length} layers)` );
879
+
880
+ const packed = { [ root ]: [ zip[ root ], { level: 0 } ] };
881
+ for ( const name of Object.keys( zip ) ) {
882
+
883
+ if ( name === root ) continue;
884
+ if ( USD_LAYER_RE.test( name ) || USD_IMAGE_RE.test( name ) ) packed[ name ] = [ zip[ name ], { level: 0 } ];
885
+
886
+ }
887
+
888
+ return await this.loader._loadModelFileByExtension( new File( [ zipSync( packed ) ], root ), root );
889
+
890
+ }
891
+
892
+ // Shallowest layer wins; among ties prefer the <dir>/<dir>.usd convention so a
893
+ // set's entry point beats its sibling variants.
894
+ static _pickUSDRootLayer( layers ) {
895
+
896
+ const depth = name => name.split( '/' ).length;
897
+ const minDepth = Math.min( ...layers.map( depth ) );
898
+ const candidates = layers.filter( name => depth( name ) === minDepth );
899
+
900
+ const conventional = candidates.find( name => {
901
+
902
+ const parts = name.split( '/' );
903
+ return parts.length > 1 && parts[ parts.length - 1 ].replace( USD_LAYER_RE, '' ) === parts[ parts.length - 2 ];
904
+
905
+ } );
906
+
907
+ return conventional || candidates[ 0 ];
908
+
909
+ }
910
+
911
+ async loadModelFromZipEntry( fileContent, filePath, extension, zipContents ) {
912
+
913
+ try {
914
+
915
+ updateLoading( { isLoading: true, status: `Processing ${extension.toUpperCase()} from ZIP...`, progress: 5 } );
916
+ const blob = new Blob( [ fileContent.buffer ], { type: 'application/octet-stream' } );
917
+ const blobUrl = URL.createObjectURL( blob );
918
+ let result;
919
+
920
+ switch ( extension ) {
921
+
922
+ case 'glb':
923
+ case 'gltf':
924
+ result = await this.handleGltfFromZip( extension, fileContent, filePath, zipContents );
925
+ break;
926
+ case 'obj':
927
+ result = await this.handleObjFromZip( fileContent, filePath, zipContents );
928
+ break;
929
+ default:
930
+ result = await this.loader._loadModelFileByExtension( new File( [ fileContent ], filePath ), filePath );
931
+
932
+ }
933
+
934
+ URL.revokeObjectURL( blobUrl );
935
+ this.loader.dispatchEvent( {
936
+ type: 'load',
937
+ model: this.loader.targetModel,
938
+ filename: `${filePath} (from ZIP)`
939
+ } );
940
+ return result;
941
+
942
+ } catch ( error ) {
943
+
944
+ console.error( `Error loading ${extension} from ZIP:`, error );
945
+ this.loader.dispatchEvent( { type: 'error', message: error.message, filename: filePath } );
946
+ throw error;
947
+
948
+ }
949
+
950
+ }
951
+
952
+ async handleGltfFromZip( extension, fileContent, filePath, zipContents ) {
953
+
954
+ if ( extension === 'gltf' ) {
955
+
956
+ const gltfContent = strFromU8( fileContent );
957
+ const manager = new LoadingManager();
958
+ const gltfDir = filePath.split( '/' ).slice( 0, - 1 ).join( '/' );
959
+
960
+ manager.setURLModifier( url => this.resolveZipResource( url, gltfDir, zipContents ) );
961
+ const loader = await this.loader.createGLTFLoader();
962
+ loader.manager = manager;
963
+
964
+ try {
965
+
966
+ return await new Promise( ( resolve, reject ) => {
967
+
968
+ loader.parse( gltfContent, '',
969
+ gltf => {
970
+
971
+ try {
972
+
973
+ this.loader._throwDeferred();
974
+
975
+ } catch ( error ) {
976
+
977
+ reject( error );
978
+ return;
979
+
980
+ }
981
+
982
+ this.loader.releaseTargetModel();
983
+ this.loader.targetModel = gltf.scene;
984
+ this.loader.sceneMetadata = extractSceneMetadata( gltf );
985
+ this.loader.onModelLoad( this.loader.targetModel ).then( () => resolve( gltf ) );
986
+
987
+ },
988
+ error => reject( error )
989
+ );
990
+
991
+ } );
992
+
993
+ } finally {
994
+
995
+ this.loader._disposeGLTFLoader( loader );
996
+
997
+ }
998
+
999
+ } else {
1000
+
1001
+ return await this.loader.loadGLBFromArrayBuffer( fileContent.buffer, filePath );
1002
+
1003
+ }
1004
+
1005
+ }
1006
+
1007
+ async handleObjFromZip( fileContent, filePath, zipContents ) {
1008
+
1009
+ const objContent = strFromU8( fileContent );
1010
+ const mtlMatch = objContent.match( /mtllib\s+([^\s]+)/ );
1011
+ let materials = null;
1012
+
1013
+ if ( mtlMatch && mtlMatch[ 1 ] ) {
1014
+
1015
+ materials = await this.loadMtlFromZip( mtlMatch[ 1 ], filePath, zipContents );
1016
+
1017
+ }
1018
+
1019
+ const { OBJLoader } = await import( 'three/examples/jsm/loaders/OBJLoader.js' );
1020
+ const objLoader = new OBJLoader();
1021
+ if ( materials ) objLoader.setMaterials( materials );
1022
+
1023
+ const object = objLoader.parse( objContent );
1024
+ object.name = filePath;
1025
+
1026
+ this.loader.releaseTargetModel();
1027
+ this.loader.targetModel = object;
1028
+ await this.loader.onModelLoad( this.loader.targetModel );
1029
+ return object;
1030
+
1031
+ }
1032
+
1033
+ // MTLLoader returns Texture objects whose `.image` only lands on a later tick, and
1034
+ // SceneProcessor._bucketTextures drops any texture without one — so handing the model
1035
+ // over before the maps decode renders the whole scene untextured. Replaces preload().
1036
+ async preloadMtlTextures( materials ) {
1037
+
1038
+ const pending = [];
1039
+ const loadTexture = materials.loadTexture.bind( materials );
1040
+
1041
+ materials.loadTexture = ( url, mapping, onLoad, onProgress, onError ) => {
1042
+
1043
+ let settle;
1044
+ pending.push( new Promise( resolve => ( settle = resolve ) ) );
1045
+ return loadTexture( url, mapping,
1046
+ texture => {
1047
+
1048
+ settle();
1049
+ onLoad?.( texture );
1050
+
1051
+ },
1052
+ onProgress,
1053
+ error => {
1054
+
1055
+ settle();
1056
+ onError?.( error );
1057
+
1058
+ }
1059
+ );
1060
+
1061
+ };
1062
+
1063
+ materials.preload();
1064
+ if ( pending.length === 0 ) return materials;
1065
+
1066
+ let timer;
1067
+ const decoded = await Promise.race( [
1068
+ Promise.all( pending ).then( () => true ),
1069
+ new Promise( resolve => ( timer = setTimeout( () => resolve( false ), MTL_TEXTURE_TIMEOUT_MS ) ) )
1070
+ ] );
1071
+ clearTimeout( timer );
1072
+
1073
+ if ( ! decoded ) this.loader._issues?.record(
1074
+ ISSUE_CODES.TEXTURE_BUILD_FAILED,
1075
+ `MTL textures did not decode within ${MTL_TEXTURE_TIMEOUT_MS}ms; affected materials render untextured`,
1076
+ { pending: pending.length }
1077
+ );
1078
+
1079
+ return materials;
1080
+
1081
+ }
1082
+
1083
+ async loadMtlFromZip( mtlFilename, objPath, zipContents ) {
1084
+
1085
+ const objDir = objPath.split( '/' ).slice( 0, - 1 ).join( '/' );
1086
+ const possibleMtlPaths = [
1087
+ mtlFilename,
1088
+ `${objDir}/${mtlFilename}`,
1089
+ mtlFilename.split( '/' ).pop()
1090
+ ];
1091
+
1092
+ for ( const path of possibleMtlPaths ) {
1093
+
1094
+ if ( zipContents[ path ] ) {
1095
+
1096
+ const { MTLLoader } = await import( 'three/examples/jsm/loaders/MTLLoader.js' );
1097
+ const mtlContent = strFromU8( zipContents[ path ] );
1098
+ const manager = new LoadingManager();
1099
+ manager.setURLModifier( url => this.resolveZipResource( url, objDir, zipContents ) );
1100
+ const mtlLoader = new MTLLoader( manager );
1101
+ const materials = mtlLoader.parse( mtlContent, objDir );
1102
+ await this.preloadMtlTextures( materials );
1103
+ return materials;
1104
+
1105
+ }
1106
+
1107
+ }
1108
+
1109
+ return null;
1110
+
1111
+ }
1112
+
1113
+ resolveZipResource( url, baseDir, zipContents ) {
1114
+
1115
+ const normalizedUrl = url.replace( /^\.\/|^\//, '' );
1116
+ const possiblePaths = [
1117
+ normalizedUrl,
1118
+ `${baseDir}/${normalizedUrl}`,
1119
+ normalizedUrl.split( '/' ).pop()
1120
+ ];
1121
+
1122
+ for ( const path of possiblePaths ) {
1123
+
1124
+ if ( zipContents[ path ] ) {
1125
+
1126
+ const fileBlob = new Blob( [ zipContents[ path ].buffer ], { type: 'application/octet-stream' } );
1127
+ return URL.createObjectURL( fileBlob );
1128
+
1129
+ }
1130
+
1131
+ }
1132
+
1133
+ console.warn( `Resource not found in ZIP: ${url}` );
1134
+ return url;
1135
+
1136
+ }
1137
+
1138
+ async loadOBJMTLPairFromZip( objFile, mtlFile, zip, filename ) {
1139
+
1140
+ const { MTLLoader } = await import( 'three/examples/jsm/loaders/MTLLoader.js' );
1141
+ const { OBJLoader } = await import( 'three/examples/jsm/loaders/OBJLoader.js' );
1142
+ const createdUrls = [];
1143
+ const manager = new LoadingManager();
1144
+ const objDir = objFile.path.split( '/' ).slice( 0, - 1 ).join( '/' );
1145
+ const mtlDir = mtlFile.path.split( '/' ).slice( 0, - 1 ).join( '/' );
1146
+
1147
+ manager.setURLModifier( url => this.resolveTextureInZip( url, objDir, mtlDir, mtlFile, zip, createdUrls ) );
1148
+ const mtlContent = this.prepareFixedMtlContent( mtlFile );
1149
+ const materials = new MTLLoader( manager ).parse( mtlContent, mtlDir );
1150
+ await this.preloadMtlTextures( materials );
1151
+
1152
+ const objLoader = new OBJLoader( manager );
1153
+ objLoader.setMaterials( materials );
1154
+ const objContent = strFromU8( objFile.content );
1155
+ const object = objLoader.parse( objContent );
1156
+
1157
+ this.loader.releaseTargetModel();
1158
+ this.loader.targetModel = object;
1159
+ await this.loader.onModelLoad( this.loader.targetModel );
1160
+
1161
+ createdUrls.forEach( url => URL.revokeObjectURL( url ) );
1162
+ this.loader.dispatchEvent( {
1163
+ type: 'load',
1164
+ model: object,
1165
+ filename: `${objFile.path} (from ${filename})`
1166
+ } );
1167
+
1168
+ return object;
1169
+
1170
+ }
1171
+
1172
+ prepareFixedMtlContent( mtlFile ) {
1173
+
1174
+ const mtlContent = strFromU8( mtlFile.content );
1175
+ return mtlContent
1176
+ .replace( new RegExp( `${mtlFile.path.split( '/' ).pop()}\\s+`, 'g' ), ' ' )
1177
+ .replace( /([a-zA-Z_]+)([\\/])/g, '$1 $2' );
1178
+
1179
+ }
1180
+
1181
+ resolveTextureInZip( url, objDir, mtlDir, mtlFile, zip, createdUrls ) {
1182
+
1183
+ const cleanUrl = url.split( '?' )[ 0 ].split( '#' )[ 0 ];
1184
+ let normalizedUrl = cleanUrl.replace( /^\.\/|^\//, '' );
1185
+
1186
+ const mtlFilename = mtlFile.path.split( '/' ).pop();
1187
+ if ( normalizedUrl.startsWith( mtlFilename ) ) {
1188
+
1189
+ normalizedUrl = normalizedUrl.substring( mtlFilename.length ).replace( /^\.\/|^\/|^\./, '' );
1190
+
1191
+ }
1192
+
1193
+ const possibleLocations = [
1194
+ normalizedUrl,
1195
+ `${objDir}/${normalizedUrl}`,
1196
+ `${mtlDir}/${normalizedUrl}`,
1197
+ `textures/${normalizedUrl}`,
1198
+ `texture/${normalizedUrl}`,
1199
+ `materials/${normalizedUrl}`,
1200
+ normalizedUrl.split( '/' ).pop()
1201
+ ];
1202
+
1203
+ for ( const location of possibleLocations ) {
1204
+
1205
+ if ( zip[ location ] ) {
1206
+
1207
+ const blob = new Blob( [ zip[ location ].buffer ], { type: 'application/octet-stream' } );
1208
+ const blobUrl = URL.createObjectURL( blob );
1209
+ createdUrls.push( blobUrl );
1210
+ return blobUrl;
1211
+
1212
+ }
1213
+
1214
+ }
1215
+
1216
+ return this.findTextureWithFuzzyMatch( normalizedUrl, zip, createdUrls ) || url;
1217
+
1218
+ }
1219
+
1220
+ findTextureWithFuzzyMatch( normalizedUrl, zip, createdUrls ) {
1221
+
1222
+ const textureFilename = normalizedUrl.split( '/' ).pop();
1223
+
1224
+ for ( const zipPath in zip ) {
1225
+
1226
+ if ( zipPath.endsWith( textureFilename ) ) {
1227
+
1228
+ const blob = new Blob( [ zip[ zipPath ].buffer ], { type: 'application/octet-stream' } );
1229
+ const blobUrl = URL.createObjectURL( blob );
1230
+ createdUrls.push( blobUrl );
1231
+ return blobUrl;
1232
+
1233
+ }
1234
+
1235
+ }
1236
+
1237
+ if ( textureFilename && textureFilename.length > 5 ) {
1238
+
1239
+ for ( const zipPath in zip ) {
1240
+
1241
+ const zipFilename = zipPath.split( '/' ).pop();
1242
+ if ( zipFilename.includes( textureFilename ) || textureFilename.includes( zipFilename ) ) {
1243
+
1244
+ const blob = new Blob( [ zip[ zipPath ].buffer ], { type: 'application/octet-stream' } );
1245
+ const blobUrl = URL.createObjectURL( blob );
1246
+ createdUrls.push( blobUrl );
1247
+ return blobUrl;
1248
+
1249
+ }
1250
+
1251
+ }
1252
+
1253
+ }
1254
+
1255
+ return null;
1256
+
1257
+ }
1258
+
1259
+ }