rayzee 7.31.0 → 8.0.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 (53) hide show
  1. package/README.md +164 -0
  2. package/dist/rayzee.es.js +6547 -4334
  3. package/dist/rayzee.umd.js +122 -73
  4. package/package.json +1 -1
  5. package/src/EngineDefaults.js +168 -13
  6. package/src/EngineIssues.js +6 -0
  7. package/src/PathTracerApp.js +128 -20
  8. package/src/Processor/ArchiveReader.js +538 -0
  9. package/src/Processor/AssetLoader.js +357 -22
  10. package/src/Processor/BVHBuilder.js +48 -32
  11. package/src/Processor/BVHRefitter.js +244 -99
  12. package/src/Processor/ChunkedRecords.js +302 -0
  13. package/src/Processor/EmissiveTriangleBuilder.js +48 -21
  14. package/src/Processor/GeometryExtractor.js +577 -92
  15. package/src/Processor/HostMemory.js +222 -0
  16. package/src/Processor/InstanceTable.js +548 -97
  17. package/src/Processor/PBRT/EqualAreaOctahedral.js +173 -0
  18. package/src/Processor/PBRT/LoopSubdivision.js +240 -0
  19. package/src/Processor/PBRT/PBRTCurves.js +249 -0
  20. package/src/Processor/PBRT/PBRTMath.js +28 -0
  21. package/src/Processor/PBRT/PBRTParser.js +187 -41
  22. package/src/Processor/PBRT/PBRTSceneBuilder.js +659 -34
  23. package/src/Processor/PBRT/PBRTTokenizer.js +205 -65
  24. package/src/Processor/PBRT/index.js +306 -45
  25. package/src/Processor/PackedRayBuffer.js +29 -18
  26. package/src/Processor/ParallelBVHBuilder.js +9 -8
  27. package/src/Processor/QueueManager.js +5 -3
  28. package/src/Processor/SceneProcessor.js +1063 -366
  29. package/src/Processor/TLASBuilder.js +546 -201
  30. package/src/Processor/TextureCreator.js +11 -9
  31. package/src/Processor/VRAMTracker.js +12 -3
  32. package/src/Processor/Workers/BVHRefitWorker.js +16 -11
  33. package/src/Processor/Workers/BVHWorker.js +12 -12
  34. package/src/Processor/Workers/TLASWorker.js +37 -0
  35. package/src/Stages/NormalDepth.js +6 -2
  36. package/src/Stages/PathTracer.js +5 -5
  37. package/src/Stages/PathTracerStage.js +163 -41
  38. package/src/TSL/BVHTraversal.js +208 -90
  39. package/src/TSL/Common.js +135 -2
  40. package/src/TSL/Displacement.js +21 -11
  41. package/src/TSL/EmissiveSampling.js +49 -25
  42. package/src/TSL/ExtendKernel.js +3 -2
  43. package/src/TSL/LightBVHSampling.js +7 -4
  44. package/src/TSL/LightsDirect.js +30 -29
  45. package/src/TSL/ShadeKernel.js +8 -6
  46. package/src/TSL/Struct.js +3 -0
  47. package/src/TSL/TextureSampling.js +24 -9
  48. package/src/TSL/patches.js +128 -3
  49. package/src/index.js +3 -0
  50. package/src/managers/AnimationManager.js +86 -76
  51. package/src/managers/CameraManager.js +11 -0
  52. package/src/managers/MaterialDataManager.js +29 -23
  53. package/src/managers/TransformManager.js +9 -192
@@ -11,12 +11,14 @@ import { createMeshesFromMultiMaterialMesh } from 'three/addons/utils/SceneUtils
11
11
  import { clone as cloneWithSkeletons } from 'three/addons/utils/SkeletonUtils.js';
12
12
  import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
13
13
  import { unzipSync, zipSync, strFromU8 } from 'three/addons/libs/fflate.module.js';
14
+ import {
15
+ detectArchiveKind, readTarGz, readTar, elementFilter, listArchiveElements, openTar } from './ArchiveReader.js';
14
16
  import { disposeEngineOwnedResources, disposeObjectFromMemory, updateLoading } from './utils';
15
17
  import { BuildTimer } from './BuildTimer.js';
16
18
  import { getAssetConfig } from '../AssetConfig.js';
17
19
  import { loadPBRTScene, pickEntryPath } from './PBRT/index.js';
18
20
  import { extractSceneMetadata } from './SceneMetadata.js';
19
- import { ISSUE_CODES } from '../EngineIssues.js';
21
+ import { ISSUE_CODES, ISSUE_SEVERITY } from '../EngineIssues.js';
20
22
  import { getRenderProfile } from '../EngineDefaults.js';
21
23
 
22
24
  // Define supported file formats
@@ -32,13 +34,27 @@ const SUPPORTED_FORMATS = {
32
34
  'hdr': { type: 'environment', name: 'HDR (High Dynamic Range)' }, 'exr': { type: 'environment', name: 'EXR (OpenEXR)' },
33
35
  'png': { type: 'image', name: 'PNG' }, 'jpg': { type: 'image', name: 'JPEG' },
34
36
  'jpeg': { type: 'image', name: 'JPEG' }, 'webp': { type: 'image', name: 'WebP' },
35
- 'zip': { type: 'archive', name: 'ZIP Archive' }
37
+ 'zip': { type: 'archive', name: 'ZIP Archive' },
38
+ 'gz': { type: 'archive', name: 'Gzipped TAR Archive' },
39
+ 'tgz': { type: 'archive', name: 'Gzipped TAR Archive' },
40
+ 'tar': { type: 'archive', name: 'TAR Archive' }
36
41
  };
37
42
 
38
43
  // Loose USD layers inside a ZIP compose into one scene; these pick out the
39
44
  // layers and the image assets they reference.
40
45
  const USD_LAYER_RE = /\.(usd|usda|usdc)$/i;
41
46
  const USD_IMAGE_RE = /\.(png|jpg|jpeg|avif)$/i;
47
+ const MTL_TEXTURE_TIMEOUT_MS = 30000;
48
+ /**
49
+ * Unpacked size past which a multi-part scene archive asks which parts to load rather than
50
+ * taking all of them. Not a hard limit — picking every part is a valid answer.
51
+ *
52
+ * Measured on Moana: a 7.5 GB archive of 15 parts parses to 40M triangles and ~7.3 GB of CPU
53
+ * memory, which loads on a freshly started browser and fails on one that has been up a while.
54
+ * Anything near that is worth a question, because the failure past it kills the tab rather than
55
+ * throwing. Override per load with `promptBytes`.
56
+ */
57
+ export const ARCHIVE_ELEMENT_PROMPT_BYTES = 4_000_000_000;
42
58
  // A throwaway stand-in for a geometry the engine must not mutate: the split's mergeGroups()
43
59
  // reorders and disposes what it is given, but never writes the attributes.
44
60
  function standInForSplit( source ) {
@@ -274,7 +290,7 @@ export class AssetLoader extends EventDispatcher {
274
290
  }
275
291
 
276
292
  // Asset loading methods
277
- async loadAssetFromFile( file ) {
293
+ async loadAssetFromFile( file, options = {} ) {
278
294
 
279
295
  const filename = file.name;
280
296
  const format = this.getFileFormat( filename );
@@ -289,7 +305,7 @@ export class AssetLoader extends EventDispatcher {
289
305
  case 'model': result = await this.loadModelFromFile( file, filename ); break;
290
306
  case 'environment':
291
307
  case 'image': result = await this.loadEnvironmentFromFile( file, filename ); break;
292
- case 'archive': result = await this.loadArchiveFromFile( file, filename ); break;
308
+ case 'archive': result = await this.loadArchiveFromFile( file, filename, options ); break;
293
309
  default: throw new Error( `Unknown asset type: ${format.type}` );
294
310
 
295
311
  }
@@ -482,37 +498,258 @@ export class AssetLoader extends EventDispatcher {
482
498
  }
483
499
 
484
500
  // Archive handling
485
- async loadArchiveFromFile( file, filename ) {
501
+ /** First bytes of a file, without pulling the whole thing into memory. */
502
+ async _readHead( file, bytes = 512 ) {
503
+
504
+ if ( file instanceof Uint8Array ) return file.subarray( 0, bytes );
505
+ if ( typeof file.slice === 'function' && typeof file.arrayBuffer === 'function' ) {
506
+
507
+ return new Uint8Array( await file.slice( 0, bytes ).arrayBuffer() );
508
+
509
+ }
510
+
511
+ return new Uint8Array( ( await this.readFileAsArrayBuffer( file ) ).slice( 0, bytes ) );
512
+
513
+ }
514
+
515
+ /**
516
+ * Enumerates a TAR/TAR.GZ archive without keeping any of it, so a caller can offer a
517
+ * choice before committing memory. ZIP archives are random-access and report directly.
518
+ * @returns {Promise<{kind:string, root:string|null, elements:Array, entryCount:number, totalBytes:number}>}
519
+ */
520
+ async inspectArchive( file ) {
521
+
522
+ const kind = detectArchiveKind( await this._readHead( file ) );
523
+
524
+ if ( kind === 'gzip' || kind === 'tar' ) {
525
+
526
+ const read = kind === 'gzip' ? readTarGz : readTar;
527
+ const { listing } = await read( file, {
528
+ filter: () => false,
529
+ onProgress: p => updateLoading( {
530
+ isLoading: true, status: `Scanning archive… ${( p.bytes / 1e9 ).toFixed( 1 )} GB`, progress: 4
531
+ } )
532
+ } );
533
+ const { root, elements } = listArchiveElements( listing );
534
+ return {
535
+ kind, root, elements,
536
+ entryCount: listing.length,
537
+ totalBytes: listing.reduce( ( n, e ) => n + e.size, 0 )
538
+ };
539
+
540
+ }
541
+
542
+ const zip = unzipSync( new Uint8Array( await this.readFileAsArrayBuffer( file ) ) );
543
+ const listing = Object.keys( zip ).map( path => ( { path, size: zip[ path ].length } ) );
544
+ const { root, elements } = listArchiveElements( listing );
545
+ return {
546
+ kind: 'zip', root, elements,
547
+ entryCount: listing.length,
548
+ totalBytes: listing.reduce( ( n, e ) => n + e.size, 0 )
549
+ };
550
+
551
+ }
552
+
553
+ /**
554
+ * @param {File|Blob} file
555
+ * @param {string} filename
556
+ * @param {object} [options]
557
+ * @param {string} [options.pbrtEntry] - which .pbrt to load when the archive holds several
558
+ * independent scenes; the issue log lists what else was available.
559
+ * @param {string|string[]} [options.element] - path prefix of one subtree to load on its own,
560
+ * or several to load together, as reported by `inspectArchive()`. Everything above them
561
+ * (scene file, materials, textures) comes along; subtrees left out are skipped without ever
562
+ * being held in memory, and the Includes that point at them only warn.
563
+ * @param {number} [options.byteBudget] - cap on retained bytes when no element is chosen.
564
+ * @param {number} [options.promptBytes] - size past which a multi-part archive asks which
565
+ * parts to load instead of taking all of them.
566
+ * @param {number} [options.maxTriangles] - stop past this many STORED triangles.
567
+ * @param {number} [options.maxPlacements] - stop past this many instance placements.
568
+ * @param {number} [options.mergeShapesAbove] - merge small non-instanced shapes past this count.
569
+ * @param {number} [options.curveSteps] - samples per spline span when tessellating curves.
570
+ * @param {number} [options.curveSides] - 1 ribbon, 2 crossed ribbons, >=3 closed tube.
571
+ */
572
+ async loadArchiveFromFile( file, filename, { pbrtEntry = null, element = null, byteBudget, promptBytes, ...pbrt } = {} ) {
486
573
 
487
574
  try {
488
575
 
489
- const arrayBuffer = await this.readFileAsArrayBuffer( file );
490
- const zip = unzipSync( new Uint8Array( arrayBuffer ) );
576
+ const kind = detectArchiveKind( await this._readHead( file ) );
577
+
578
+ // An uncompressed tar over a File is seekable, so it is indexed rather than read:
579
+ // entries are pulled out as the scene asks for them and never all held at once.
580
+ // That is the difference between a 7 GB archive being refused and loading.
581
+ if ( kind === 'tar' ) {
582
+
583
+ const source = await this._openSeekableArchive( file, filename, element, promptBytes );
584
+ if ( source.listing.some( e => e.path.toLowerCase().endsWith( '.pbrt' ) ) ) {
585
+
586
+ return await this.loadPBRTFromZip( {}, filename, pbrtEntry, pbrt, source );
587
+
588
+ }
589
+
590
+ // Not a pbrt scene: fall back to materialising it, which those paths still expect.
591
+ for ( const e of source.listing ) source.entries[ e.path ] ??= await source.read( e.path );
592
+ return await this._loadNonPBRTArchive( source.entries, filename );
593
+
594
+ }
595
+
596
+ const entries = kind === 'gzip'
597
+ ? await this._readStreamedArchive( file, filename, kind, element, byteBudget )
598
+ : unzipSync( new Uint8Array( await this.readFileAsArrayBuffer( file ) ) );
491
599
 
492
600
  // A pbrt scene archive takes priority — it owns its own geometry/texture refs.
493
- if ( pickEntryPath( zip ) ) return await this.loadPBRTFromZip( zip, filename );
601
+ if ( pickEntryPath( entries ) ) return await this.loadPBRTFromZip( entries, filename, pbrtEntry, pbrt );
494
602
 
495
- const result = await this.processObjMtlPairsInZip( zip, filename );
496
- if ( result ) return result;
497
- return await this.findAndLoadModelFromZip( zip, filename );
603
+ return await this._loadNonPBRTArchive( entries, filename );
498
604
 
499
605
  } catch ( error ) {
500
606
 
501
- console.error( 'Error loading ZIP archive:', error );
607
+ if ( error?.code !== 'ARCHIVE_NEEDS_ELEMENT' ) console.error( 'Error loading archive:', error );
608
+ throw error;
609
+
610
+ }
611
+
612
+ }
613
+
614
+ async _loadNonPBRTArchive( entries, filename ) {
615
+
616
+ const result = await this.processObjMtlPairsInZip( entries, filename );
617
+ if ( result ) return result;
618
+ return await this.findAndLoadModelFromZip( entries, filename );
619
+
620
+ }
621
+
622
+ /**
623
+ * Index an uncompressed tar without retaining it. Entries are read back from the File on
624
+ * demand, so residency is the open include chain rather than the whole archive.
625
+ *
626
+ * Indexing is cheap and the whole archive is never held, but *parsing* all of it is not:
627
+ * the scene that comes out is what runs the tab out of memory. So a large multi-part archive
628
+ * stops here and asks which parts to load, the same as the streamed path does. The index is
629
+ * already built at that point, so the question costs nothing.
630
+ * @private
631
+ */
632
+ async _openSeekableArchive( file, filename, element, promptBytes ) {
633
+
634
+ const chosen = Array.isArray( element ) ? element.filter( Boolean ) : ( element ? [ element ] : [] );
635
+
636
+ const source = await openTar( file, {
637
+ filter: chosen.length ? elementFilter( chosen ) : null,
638
+ retain: () => false,
639
+ } );
640
+
641
+ const totalBytes = source.listing.reduce( ( n, e ) => n + e.size, 0 );
642
+
643
+ if ( chosen.length === 0 ) this._requireElementChoice( filename, source.listing, totalBytes, promptBytes );
644
+
645
+ console.info(
646
+ `Archive "${filename}": indexed ${source.indexed} entries, ` +
647
+ `${( totalBytes / 1e9 ).toFixed( 1 )} GB, none resident` +
648
+ ( chosen.length ? `, ${chosen.length} part${chosen.length > 1 ? 's' : ''} selected.` : '.' )
649
+ );
650
+
651
+ return source;
652
+
653
+ }
654
+
655
+ /**
656
+ * Stop and ask which parts to load, when the archive is big enough that the answer matters
657
+ * and it actually has parts to choose between. Not a capability limit — selecting every part
658
+ * is a valid answer and loads the whole scene.
659
+ * @private
660
+ */
661
+ _requireElementChoice( filename, listing, totalBytes, promptBytes = ARCHIVE_ELEMENT_PROMPT_BYTES ) {
662
+
663
+ if ( totalBytes < promptBytes ) return;
664
+
665
+ const { root, elements } = listArchiveElements( listing );
666
+ if ( elements.length < 2 ) return;
667
+
668
+ const error = new Error(
669
+ `"${filename}" holds ${( totalBytes / 1e9 ).toFixed( 1 )} GB across ${elements.length} parts. ` +
670
+ 'Choose which to load — loading all of them at once may exhaust memory.'
671
+ );
672
+ error.code = 'ARCHIVE_NEEDS_ELEMENT';
673
+ error.root = root;
674
+ error.elements = elements;
675
+ error.totalBytes = totalBytes;
676
+
677
+ // Logged as a warning on purpose: recording an ERROR makes a strict host throw an
678
+ // EngineIssueError from inside record(), and the caller never learns which parts it
679
+ // could have chosen. The typed throw below is the refusal, and it carries the list.
680
+ this._issues?.record(
681
+ ISSUE_CODES.ASSET_ARCHIVE_TOO_LARGE,
682
+ `archive holds ${( totalBytes / 1e9 ).toFixed( 1 )} GB across ${elements.length} parts; choose which to load`,
683
+ { root, elements: elements.map( e => e.prefix ), totalBytes },
684
+ ISSUE_SEVERITY.WARNING
685
+ );
686
+
687
+ throw error;
688
+
689
+ }
690
+
691
+ async _readStreamedArchive( file, filename, kind, element, byteBudget ) {
692
+
693
+ const chosen = Array.isArray( element ) ? element.filter( Boolean ) : ( element ? [ element ] : [] );
694
+ const read = kind === 'gzip' ? readTarGz : readTar;
695
+ const { entries, listing, retainedBytes, truncated } = await read( file, {
696
+ filter: chosen.length ? elementFilter( chosen ) : null,
697
+ ...( byteBudget === undefined ? {} : { byteBudget } ),
698
+ onProgress: p => updateLoading( {
699
+ isLoading: true,
700
+ status: `Reading archive… ${( p.bytes / 1e9 ).toFixed( 1 )} GB`,
701
+ progress: 4
702
+ } )
703
+ } );
704
+
705
+ // Choosing every part is a valid answer, so a selection is not refused again for being
706
+ // large: the read budget exists to stop an unasked-for whole-archive load. Past this
707
+ // point the scene's own memory preflight is what refuses, with a figure to act on.
708
+ if ( truncated && chosen.length === 0 ) {
709
+
710
+ const { root, elements } = listArchiveElements( listing );
711
+ const total = listing.reduce( ( n, e ) => n + e.size, 0 );
712
+ const error = new Error(
713
+ `"${filename}" unpacks to ${( total / 1e9 ).toFixed( 1 )} GB, past the load budget. ` +
714
+ `Load one of its ${elements.length} parts instead.`
715
+ );
716
+ error.code = 'ARCHIVE_NEEDS_ELEMENT';
717
+ error.root = root;
718
+ error.elements = elements;
719
+ error.totalBytes = total;
720
+ // Warning, not error: a strict host's throw from record() would replace the typed
721
+ // error below and take the part list with it.
722
+ this._issues?.record(
723
+ ISSUE_CODES.ASSET_ARCHIVE_TOO_LARGE,
724
+ `archive unpacks to ${( total / 1e9 ).toFixed( 1 )} GB; choose one of ${elements.length} parts`,
725
+ { root, elements: elements.map( e => e.prefix ), totalBytes: total },
726
+ ISSUE_SEVERITY.WARNING
727
+ );
502
728
  throw error;
503
729
 
504
730
  }
505
731
 
732
+ if ( chosen.length ) {
733
+
734
+ console.info(
735
+ `Archive "${filename}": loaded ${chosen.map( c => `"${c}"` ).join( ', ' )} — ` +
736
+ `${Object.keys( entries ).length} of ${listing.length} files, ${( retainedBytes / 1048576 ).toFixed( 1 )} MB.`
737
+ );
738
+
739
+ }
740
+
741
+ return entries;
742
+
506
743
  }
507
744
 
508
745
  /**
509
746
  * Loads a pbrt-v4 scene from an unzipped archive. Parses the entry .pbrt
510
747
  * (following Include/Import), builds a THREE.Group, sets the infinite light
511
748
  * as the scene environment, and runs the standard onModelLoad pipeline.
512
- * @param {Object<string, Uint8Array>} zip - unzipped entries (path → bytes)
749
+ * @param {Object<string, Uint8Array>} zip - unzipped entries (path → bytes); consumed, entry by entry
513
750
  * @param {string} filename - original archive name (for display/events)
514
751
  */
515
- async loadPBRTFromZip( zip, filename ) {
752
+ async loadPBRTFromZip( zip, filename, entryPath = null, options = {}, source = null ) {
516
753
 
517
754
  updateLoading( { isLoading: true, status: 'Parsing PBRT scene...', progress: 5 } );
518
755
 
@@ -546,14 +783,46 @@ export class AssetLoader extends EventDispatcher {
546
783
 
547
784
  };
548
785
 
549
- const { group, environment, report, warnings, entryPath } = await loadPBRTScene( {
550
- vfs: zip, plyParser, imageFromBytes, envFromBytes
786
+ const pbrtStart = performance.now();
787
+ const { group, environment, report, warnings, meshCount, entryPath: loadedEntry, candidates,
788
+ parseMs, buildMs, triangleCount, placementCount, mergedShapes, skippedForBudget,
789
+ droppedNoTemplate } = await loadPBRTScene( {
790
+ vfs: zip, source, entryPath, plyParser, imageFromBytes, envFromBytes,
791
+ maxTriangles: options.maxTriangles,
792
+ maxPlacements: options.maxPlacements,
793
+ mergeShapesAbove: options.mergeShapesAbove,
794
+ curveSteps: options.curveSteps,
795
+ curveSides: options.curveSides
551
796
  } );
552
797
 
798
+ // Phase breakdown for scaling work; the engine's own build timings live in
799
+ // SceneProcessor.performanceMetrics.
800
+ this.lastPBRTStats = {
801
+ parseMs, buildMs, loaderMs: performance.now() - pbrtStart,
802
+ triangleCount, placementCount, mergedShapes, skippedForBudget, droppedNoTemplate, meshCount
803
+ };
804
+
805
+ // An archive can hold several independent scenes (transparent-machines ships five
806
+ // animation frames). Only one is loaded, so name it and the alternatives rather
807
+ // than leave the user comparing against a reference of a different scene.
808
+ if ( candidates.length > 1 ) {
809
+
810
+ const others = candidates.filter( p => p !== loadedEntry );
811
+ console.warn( `PBRT archive holds ${candidates.length} scenes; loaded "${loadedEntry}". Others: ${others.join( ', ' )}` );
812
+ this._issues?.record(
813
+ ISSUE_CODES.ASSET_AMBIGUOUS_ENTRY,
814
+ `archive holds ${candidates.length} pbrt scenes; loaded "${loadedEntry}"`,
815
+ { loaded: loadedEntry, alternatives: others },
816
+ ISSUE_SEVERITY.WARNING
817
+ );
818
+
819
+ }
820
+
553
821
  // Diagnostics — surface what each mesh resolved to (helps debug black/wrong materials).
554
822
  if ( report && report.length && typeof console.table === 'function' ) {
555
823
 
556
- console.groupCollapsed( `PBRT loader: ${report.length} mesh(es) from "${entryPath}"` );
824
+ const shown = report.length < meshCount ? ` (first ${report.length} of ${meshCount})` : '';
825
+ console.groupCollapsed( `PBRT loader: ${meshCount} mesh(es) from "${loadedEntry}"${shown}` );
557
826
  console.table( report );
558
827
  console.groupEnd();
559
828
 
@@ -561,7 +830,7 @@ export class AssetLoader extends EventDispatcher {
561
830
 
562
831
  if ( warnings && warnings.length ) {
563
832
 
564
- console.warn( `PBRT loader: ${warnings.length} warning(s) parsing "${entryPath}"` );
833
+ console.warn( `PBRT loader: ${warnings.length} warning(s) parsing "${loadedEntry}"` );
565
834
  warnings.forEach( w => console.warn( ' •', w ) );
566
835
 
567
836
  }
@@ -574,14 +843,19 @@ export class AssetLoader extends EventDispatcher {
574
843
 
575
844
  }
576
845
 
577
- group.name = entryPath || filename;
846
+ group.name = loadedEntry || filename;
578
847
  this.releaseTargetModel();
579
848
  this.targetModel = group;
580
849
 
850
+ // The light's own orientation and `scale` are already baked into the texture, so the
851
+ // scene is only correct at rotation 0 / intensity 1. Without this the viewer profile's
852
+ // default 270° rotation lands on top and the sky sits 90° from where pbrt puts it.
853
+ if ( environment?.texture ) this.sceneMetadata = { environment: { rotation: 0, intensity: 1 } };
854
+
581
855
  updateLoading( { isLoading: true, status: 'Processing PBRT geometry...', progress: 10 } );
582
856
  await this.onModelLoad( this.targetModel );
583
857
 
584
- this.dispatchEvent( { type: 'load', model: group, filename: `${entryPath} (from ZIP)` } );
858
+ this.dispatchEvent( { type: 'load', model: group, filename: `${loadedEntry} (from ZIP)` } );
585
859
  return group;
586
860
 
587
861
  }
@@ -932,6 +1206,56 @@ export class AssetLoader extends EventDispatcher {
932
1206
 
933
1207
  }
934
1208
 
1209
+ // MTLLoader returns Texture objects whose `.image` only lands on a later tick, and
1210
+ // SceneProcessor._bucketTextures drops any texture without one — so handing the model
1211
+ // over before the maps decode renders the whole scene untextured. Replaces preload().
1212
+ async preloadMtlTextures( materials ) {
1213
+
1214
+ const pending = [];
1215
+ const loadTexture = materials.loadTexture.bind( materials );
1216
+
1217
+ materials.loadTexture = ( url, mapping, onLoad, onProgress, onError ) => {
1218
+
1219
+ let settle;
1220
+ pending.push( new Promise( resolve => ( settle = resolve ) ) );
1221
+ return loadTexture( url, mapping,
1222
+ texture => {
1223
+
1224
+ settle();
1225
+ onLoad?.( texture );
1226
+
1227
+ },
1228
+ onProgress,
1229
+ error => {
1230
+
1231
+ settle();
1232
+ onError?.( error );
1233
+
1234
+ }
1235
+ );
1236
+
1237
+ };
1238
+
1239
+ materials.preload();
1240
+ if ( pending.length === 0 ) return materials;
1241
+
1242
+ let timer;
1243
+ const decoded = await Promise.race( [
1244
+ Promise.all( pending ).then( () => true ),
1245
+ new Promise( resolve => ( timer = setTimeout( () => resolve( false ), MTL_TEXTURE_TIMEOUT_MS ) ) )
1246
+ ] );
1247
+ clearTimeout( timer );
1248
+
1249
+ if ( ! decoded ) this._issues?.record(
1250
+ ISSUE_CODES.TEXTURE_BUILD_FAILED,
1251
+ `MTL textures did not decode within ${MTL_TEXTURE_TIMEOUT_MS}ms; affected materials render untextured`,
1252
+ { pending: pending.length }
1253
+ );
1254
+
1255
+ return materials;
1256
+
1257
+ }
1258
+
935
1259
  async loadMtlFromZip( mtlFilename, objPath, zipContents ) {
936
1260
 
937
1261
  const objDir = objPath.split( '/' ).slice( 0, - 1 ).join( '/' );
@@ -951,7 +1275,7 @@ export class AssetLoader extends EventDispatcher {
951
1275
  manager.setURLModifier( url => this.resolveZipResource( url, objDir, zipContents ) );
952
1276
  const mtlLoader = new MTLLoader( manager );
953
1277
  const materials = mtlLoader.parse( mtlContent, objDir );
954
- materials.preload();
1278
+ await this.preloadMtlTextures( materials );
955
1279
  return materials;
956
1280
 
957
1281
  }
@@ -999,7 +1323,7 @@ export class AssetLoader extends EventDispatcher {
999
1323
  manager.setURLModifier( url => this.resolveTextureInZip( url, objDir, mtlDir, mtlFile, zip, createdUrls ) );
1000
1324
  const mtlContent = this.prepareFixedMtlContent( mtlFile );
1001
1325
  const materials = new MTLLoader( manager ).parse( mtlContent, mtlDir );
1002
- materials.preload();
1326
+ await this.preloadMtlTextures( materials );
1003
1327
 
1004
1328
  const objLoader = new OBJLoader( manager );
1005
1329
  objLoader.setMaterials( materials );
@@ -1712,6 +2036,17 @@ export class AssetLoader extends EventDispatcher {
1712
2036
  object.getWorldPosition( camera.position );
1713
2037
  object.getWorldQuaternion( camera.quaternion );
1714
2038
 
2039
+ // Keep only the sign of the world scale. A camera has no meaningful
2040
+ // size, but a negative axis is a mirror — a pbrt scene's `Scale -1 1 1`
2041
+ // lives here — and dropping it silently un-flips the view. decompose()
2042
+ // parks any mirror on x, so this lands as (±1, 1, 1).
2043
+ object.getWorldScale( camera.scale );
2044
+ camera.scale.set(
2045
+ Math.sign( camera.scale.x ) || 1,
2046
+ Math.sign( camera.scale.y ) || 1,
2047
+ Math.sign( camera.scale.z ) || 1
2048
+ );
2049
+
1715
2050
  // Set a meaningful name
1716
2051
  if ( ! camera.name || camera.name === '' ) {
1717
2052