rayzee 7.24.0 → 7.26.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "7.24.0",
3
+ "version": "7.26.0",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -18,7 +18,7 @@
18
18
  ],
19
19
  "repository": {
20
20
  "type": "git",
21
- "url": "https://github.com/atul-mourya/RayTracing.git",
21
+ "url": "git+https://github.com/atul-mourya/rayzee-renderer.git",
22
22
  "directory": "rayzee"
23
23
  },
24
24
  "keywords": [
@@ -1,5 +1,5 @@
1
1
  import { EventDispatcher, ACESFilmicToneMapping } from 'three';
2
- import { TONE_MAP_FNS, SRGB_GAMMA, applySaturation } from '../Processor/ToneMapCPU.js';
2
+ import { TONE_MAP_FNS, SRGB_GAMMA, applySaturation, effectiveExposure } from '../Processor/ToneMapCPU.js';
3
3
  import { getAssetConfig } from '../AssetConfig.js';
4
4
  import AIUpscalerWorker from '../Processor/Workers/AIUpscalerWorker.js?worker&inline';
5
5
 
@@ -400,7 +400,7 @@ export class AIUpscaler extends EventDispatcher {
400
400
  if ( sourceImageData.isHDR ) {
401
401
 
402
402
  this._hdrToneMapFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
403
- this._hdrExposure = this.getExposure();
403
+ this._hdrExposure = effectiveExposure( this.getExposure(), this.getToneMapping() );
404
404
  this._hdrSaturation = this.getSaturation();
405
405
  this._tmOut = new Float32Array( 3 );
406
406
 
@@ -793,7 +793,7 @@ export class AIUpscaler extends EventDispatcher {
793
793
  const imageData = ctx.createImageData( width, height );
794
794
  const pixels = imageData.data;
795
795
  const tmFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
796
- const exposure = this.getExposure();
796
+ const exposure = effectiveExposure( this.getExposure(), this.getToneMapping() );
797
797
  const saturation = this.getSaturation();
798
798
  const out = new Float32Array( 3 );
799
799
 
@@ -16,7 +16,7 @@ async function getInitUNetFromURL() {
16
16
 
17
17
  }
18
18
 
19
- import { TONE_MAP_FNS, linearToSRGB, applySaturation } from '../Processor/ToneMapCPU.js';
19
+ import { TONE_MAP_FNS, linearToSRGB, applySaturation, effectiveExposure } from '../Processor/ToneMapCPU.js';
20
20
  import { getAssetConfig } from '../AssetConfig.js';
21
21
 
22
22
  /** Reusable RGB output buffer (avoids per-pixel allocation). */
@@ -764,7 +764,7 @@ export class OIDNDenoiser extends EventDispatcher {
764
764
  /** Display exposure with the input scale divided back out — output arrives pre-multiplied. */
765
765
  _outputExposure() {
766
766
 
767
- return this.getExposure() / this._oidnInputScale;
767
+ return effectiveExposure( this.getExposure(), this.getToneMapping() ) / this._oidnInputScale;
768
768
 
769
769
  }
770
770
 
@@ -109,6 +109,9 @@ export class PathTracerApp extends EventDispatcher {
109
109
  this.canvas = canvas;
110
110
  this._autoResize = options.autoResize !== false;
111
111
  this._container = options.container || null;
112
+ // Apply the environment authored into a model file's metadata on load. See _beginSceneMetadataEnvironment().
113
+ this._applySceneMetadataEnabled = options.applySceneMetadata !== false;
114
+ this._applyingSceneMetadata = false;
112
115
 
113
116
  // ── Settings (single source of truth for all render parameters) ──
114
117
  this.settings = new RenderSettings( DEFAULT_STATE );
@@ -366,8 +369,11 @@ export class PathTracerApp extends EventDispatcher {
366
369
  memoryPeak: tracker?.peak ?? 0,
367
370
  } );
368
371
 
369
- // Check time limit
370
- if ( this.completion.isTimeLimitReached( this.settings.get( 'renderLimitMode' ), this.settings.get( 'renderTimeLimit' ) ) ) {
372
+ // Only the wall-clock stop — PathTracer.render() retires the ceiling and convergence
373
+ // itself, so whichever of the three arrives first wins.
374
+ if ( this.completion.isTimeLimitReached(
375
+ this.stages.pathTracer, this.settings.get( 'renderLimitMode' ), this.settings.get( 'renderTimeLimit' )
376
+ ) ) {
371
377
 
372
378
  this.stages.pathTracer.isComplete = true;
373
379
 
@@ -381,8 +387,14 @@ export class PathTracerApp extends EventDispatcher {
381
387
  context: this.pipeline?.context,
382
388
  } );
383
389
 
384
- this.dispatchEvent( { type: 'RenderComplete' } );
385
- this.dispatchEvent( { type: EngineEvents.RENDER_COMPLETE } );
390
+ const completionInfo = {
391
+ samples: this.stages.pathTracer.frameCount,
392
+ timeElapsed: this.completion.timeElapsed,
393
+ budgetOverrun: this.completion.budgetOverrun,
394
+ };
395
+
396
+ this.dispatchEvent( { type: 'RenderComplete', ...completionInfo } );
397
+ this.dispatchEvent( { type: EngineEvents.RENDER_COMPLETE, ...completionInfo } );
386
398
 
387
399
  }
388
400
 
@@ -665,17 +677,26 @@ export class PathTracerApp extends EventDispatcher {
665
677
 
666
678
  }
667
679
 
680
+ /**
681
+ * A load is already running. Typed so hosts can tell "you clicked too fast" apart from
682
+ * a genuine load failure and say so, instead of dropping the request on the floor.
683
+ * @private
684
+ */
685
+ _busyError( where ) {
686
+
687
+ const error = new Error( `${where}: another load is already in progress` );
688
+ error.code = 'LOAD_IN_PROGRESS';
689
+ return error;
690
+
691
+ }
692
+
668
693
  /**
669
694
  * Loads an environment map and rebuilds CDF.
670
695
  * @param {string} url - Environment URL
671
696
  */
672
697
  async loadEnvironment( url ) {
673
698
 
674
- if ( this._loadingInProgress ) {
675
-
676
- throw new Error( 'PathTracerApp.loadEnvironment: another load is already in progress' );
677
-
678
- }
699
+ if ( this._loadingInProgress ) throw this._busyError( 'PathTracerApp.loadEnvironment' );
679
700
 
680
701
  this._loadingInProgress = true;
681
702
 
@@ -702,6 +723,63 @@ export class PathTracerApp extends EventDispatcher {
702
723
 
703
724
  }
704
725
 
726
+ /**
727
+ * Loads a user-supplied File (drag-drop, file picker) — model, archive, or environment
728
+ * map, dispatched by extension.
729
+ *
730
+ * Prefer this over driving `assetLoader.loadAssetFromFile()` directly. That bypasses the
731
+ * in-progress guard, and AssetLoader disposes the outgoing model before it knows whether
732
+ * the engine will rebuild: a file dropped mid-load would tear down the live scene, add the
733
+ * new one to the graph, and then be discarded by the `load` handler — leaving the path
734
+ * tracer rendering buffers whose geometry has been freed. Here a concurrent call throws
735
+ * LOAD_IN_PROGRESS before anything is touched.
736
+ *
737
+ * @param {File} file
738
+ * @returns {Promise<void>}
739
+ */
740
+ async loadFile( file ) {
741
+
742
+ const format = this.assetLoader?.getFileFormat( file?.name || '' );
743
+ if ( ! format ) throw new Error( `Unsupported file format: ${file?.name}` );
744
+
745
+ if ( format.type !== 'environment' && format.type !== 'image' ) {
746
+
747
+ await this._loadWithSceneRebuild(
748
+ () => this.assetLoader.loadAssetFromFile( file ),
749
+ { type: 'ModelLoaded', filename: file.name }
750
+ );
751
+ return;
752
+
753
+ }
754
+
755
+ // Environment drop: install it, no scene rebuild.
756
+ if ( this._loadingInProgress ) throw this._busyError( 'PathTracerApp.loadFile' );
757
+
758
+ this._loadingInProgress = true;
759
+
760
+ try {
761
+
762
+ await this.assetLoader.loadAssetFromFile( file );
763
+
764
+ const texture = this.meshScene.environment;
765
+ if ( texture && this.stages.pathTracer ) {
766
+
767
+ await this.stages.pathTracer.environment.applyHDRI( texture );
768
+
769
+ }
770
+
771
+ this.pipeline?.eventBus.emit( 'autoexposure:resetHistory' );
772
+ this.reset();
773
+ this.dispatchEvent( { type: 'EnvironmentLoaded', filename: file.name } );
774
+
775
+ } finally {
776
+
777
+ this._loadingInProgress = false;
778
+
779
+ }
780
+
781
+ }
782
+
705
783
  /**
706
784
  * Loads example models by index.
707
785
  * @param {number} index
@@ -775,11 +853,7 @@ export class PathTracerApp extends EventDispatcher {
775
853
  /** Shared pipeline: load asset → sync controls → build BVH → reset → dispatch events */
776
854
  async _loadWithSceneRebuild( loadFn, eventPayload ) {
777
855
 
778
- if ( this._loadingInProgress ) {
779
-
780
- throw new Error( 'PathTracerApp: another load is already in progress' );
781
-
782
- }
856
+ if ( this._loadingInProgress ) throw this._busyError( 'PathTracerApp' );
783
857
 
784
858
  this._loadingInProgress = true;
785
859
 
@@ -794,7 +868,7 @@ export class PathTracerApp extends EventDispatcher {
794
868
  // (The old primary was already released by releaseTargetModel() in loadFn.)
795
869
  this._clearAppendedModels();
796
870
  this._syncControlsAfterLoad();
797
- await this.loadSceneData();
871
+ await this.loadSceneData( { pendingEnvironment: this._beginSceneMetadataEnvironment() } );
798
872
  this.pipeline?.eventBus.emit( 'autoexposure:resetHistory' );
799
873
  this.reset();
800
874
  this.cameraManager.currentCameraIndex = 0;
@@ -809,11 +883,99 @@ export class PathTracerApp extends EventDispatcher {
809
883
 
810
884
  }
811
885
 
886
+ /**
887
+ * Scene-level authoring metadata carried by the current model file (glTF `extras`),
888
+ * or null when the file has none. See {@link module:Processor/SceneMetadata}.
889
+ * @type {{ environment?: { sourceFile: string, rotation?: number, intensity?: number } }|null}
890
+ */
891
+ get sceneMetadata() {
892
+
893
+ return this.assetLoader?.sceneMetadata ?? null;
894
+
895
+ }
896
+
897
+ /**
898
+ * Starts fetching the environment authored into the just-loaded model's metadata and
899
+ * returns a promise for the texture — deliberately NOT awaited here. The fetch is the
900
+ * longest single step of such a load (measured 1448 ms for a 1k HDRI against 325 ms to
901
+ * build and upload a 112k-triangle scene), so the promise is handed to loadSceneData(),
902
+ * which installs it and builds its CDF while the BVH builds.
903
+ *
904
+ * Called from both replace-load seams: the URL/catalog path (_loadWithSceneRebuild) and
905
+ * the drag-drop / File-open path, which drives the AssetLoader directly and rebuilds via
906
+ * the 'load' event (_onAssetLoaded). Appending a model never applies it — an appended
907
+ * model must not hijack the scene's environment.
908
+ *
909
+ * Resolves to null and never rejects: a failed fetch leaves the model on the current
910
+ * environment. Returns null outright when there is nothing to apply.
911
+ * Opt out per app with `new PathTracerApp( canvas, { applySceneMetadata: false } )`.
912
+ *
913
+ * @returns {Promise<import('three').Texture|null>|null}
914
+ */
915
+ _beginSceneMetadataEnvironment() {
916
+
917
+ const env = this.sceneMetadata?.environment;
918
+ if ( ! this._applySceneMetadataEnabled || ! env?.sourceFile || ! this.stages.pathTracer ) return null;
919
+
920
+ // Claim 'hdri' mode up front: loadEnvironment() fires beforeEnvironmentLoad, and a
921
+ // host that answers it with setMode( 'hdri' ) would otherwise restore the HDRI
922
+ // stashed by the current sky mode on top of the download now in flight.
923
+ this.stages.pathTracer.environment.beginHDRI();
924
+ this._applyingSceneMetadata = true;
925
+
926
+ return this.assetLoader.loadEnvironment( env.sourceFile )
927
+ .catch( error => {
928
+
929
+ log.warn( `scene metadata: environment "${env.sourceFile}" failed to load`, error );
930
+ return null;
931
+
932
+ } )
933
+ .finally( () => {
934
+
935
+ this._applyingSceneMetadata = false;
936
+
937
+ } );
938
+
939
+ }
940
+
941
+ /**
942
+ * Mirrors the authored environment onto the settings once its texture is installed.
943
+ * The authored env is both the light and the backdrop, so the backdrop axis
944
+ * (showBackground / transparentBackground) is forced to the image.
945
+ * @private
946
+ */
947
+ _applySceneMetadataSettings( texture ) {
948
+
949
+ const metadata = this.sceneMetadata;
950
+ const env = metadata?.environment;
951
+ if ( ! env ) return;
952
+
953
+ const updates = { enableEnvironment: true, showBackground: true, transparentBackground: false };
954
+ if ( env.intensity !== undefined ) {
955
+
956
+ updates.environmentIntensity = env.intensity;
957
+ updates.backgroundIntensity = env.intensity;
958
+
959
+ }
960
+
961
+ if ( env.rotation !== undefined ) updates.environmentRotation = env.rotation;
962
+
963
+ this.settings.setMany( updates, { reset: false } );
964
+ if ( this.scene ) this.scene.background = texture;
965
+
966
+ this.dispatchEvent( { type: 'SceneMetadataApplied', metadata, environment: { ...env } } );
967
+
968
+ }
969
+
812
970
  /**
813
971
  * Builds BVH from meshScene and uploads all scene data to the path tracer.
972
+ * @param {Object} [options]
973
+ * @param {Promise<import('three').Texture|null>} [options.pendingEnvironment] - An
974
+ * environment still being fetched (see _beginSceneMetadataEnvironment). Installed and
975
+ * CDF'd concurrently with the BVH build rather than before it.
814
976
  * @returns {boolean}
815
977
  */
816
- async loadSceneData() {
978
+ async loadSceneData( { pendingEnvironment = null } = {} ) {
817
979
 
818
980
  // Clear selection before rebuilding — the old object leaves the scene graph.
819
981
  // Skipped on the append path (addModel): the selected object persists, so its
@@ -832,7 +994,23 @@ export class PathTracerApp extends EventDispatcher {
832
994
 
833
995
  // Environment CDF build in parallel with BVH
834
996
  let cdfPromise = null;
835
- if ( environmentTexture?.image?.data ) {
997
+ if ( pendingEnvironment ) {
998
+
999
+ // The authored environment is still downloading. Install + CDF it off to the side so
1000
+ // the fetch overlaps the BVH build below instead of gating it.
1001
+ timer.start( 'Environment fetch + CDF (concurrent)' );
1002
+ cdfPromise = pendingEnvironment
1003
+ .then( async texture => {
1004
+
1005
+ if ( ! texture ) return;
1006
+ await this.stages.pathTracer.environment.applyHDRI( texture );
1007
+ this._applySceneMetadataSettings( texture );
1008
+
1009
+ } )
1010
+ .catch( error => log.warn( 'scene metadata: environment install failed', error ) )
1011
+ .finally( () => timer.end( 'Environment fetch + CDF (concurrent)' ) );
1012
+
1013
+ } else if ( environmentTexture?.image?.data ) {
836
1014
 
837
1015
  timer.start( 'Environment CDF build (worker)' );
838
1016
  this.stages.pathTracer.scene.environment = environmentTexture;
@@ -852,7 +1030,9 @@ export class PathTracerApp extends EventDispatcher {
852
1030
  await new Promise( r => setTimeout( r, 0 ) );
853
1031
  timer.start( 'GPU data transfer' );
854
1032
 
855
- if ( ! this._sdf.uploadToPathTracer( this.stages.pathTracer, this.lightManager, this.meshScene, environmentTexture ) ) return false;
1033
+ // Re-read rather than reusing the snapshot above: a pendingEnvironment may have landed
1034
+ // during the BVH build, and the snapshot is then a disposed texture.
1035
+ if ( ! this._sdf.uploadToPathTracer( this.stages.pathTracer, this.lightManager, this.meshScene, this.meshScene.environment ) ) return false;
856
1036
 
857
1037
  // Patch per-mesh visibility into the TLAS leaves we just uploaded
858
1038
  this.stages.pathTracer._meshRefs = this.stages.pathTracer._collectMeshRefs( this.meshScene );
@@ -2603,11 +2783,18 @@ export class PathTracerApp extends EventDispatcher {
2603
2783
 
2604
2784
  if ( this._loadingInProgress ) return;
2605
2785
 
2786
+ // The authored-environment fetch runs through this same AssetLoader, so its 'load'
2787
+ // event lands right back here. We install that texture ourselves — without the
2788
+ // guard the texture branch below would rebuild the CDF a second time and
2789
+ // resetLoading() would tear down the overlay mid-upload. Model events still pass:
2790
+ // a user can drop another file while the environment is in flight.
2791
+ if ( this._applyingSceneMetadata && event.texture ) return;
2792
+
2606
2793
  if ( event.model ) {
2607
2794
 
2608
2795
  // Drag-drop / file load is a replace: clear any appended models first.
2609
2796
  this._clearAppendedModels();
2610
- await this.loadSceneData();
2797
+ await this.loadSceneData( { pendingEnvironment: this._beginSceneMetadataEnvironment() } );
2611
2798
 
2612
2799
  } else if ( event.texture ) {
2613
2800
 
@@ -1,3 +1,7 @@
1
+ import { createLogger } from '../utils/Logger.js';
2
+
3
+ const log = createLogger( 'engine' );
4
+
1
5
  /**
2
6
  * Tracks render completion state, time limits, and sample limits.
3
7
  *
@@ -12,6 +16,10 @@ export class CompletionTracker {
12
16
  this.lastResetTime = performance.now();
13
17
  this.renderCompleteDispatched = false;
14
18
 
19
+ /** Whether the deadline — rather than the ceiling or convergence — retired the frame. */
20
+ this.budgetOverrun = false;
21
+ this._budgetHeldWarned = false;
22
+
15
23
  }
16
24
 
17
25
  /**
@@ -24,19 +32,48 @@ export class CompletionTracker {
24
32
  }
25
33
 
26
34
  /**
27
- * Checks whether the time-based render limit has been reached.
28
- * @param {string} renderLimitMode - 'time' or 'samples'
35
+ * Checks whether the wall-clock render budget has expired. An additional stop condition,
36
+ * never a replacement — the sample ceiling and convergence still bind when they come first.
37
+ *
38
+ * Floored at one sample: frameCount does not advance while the stage compiles kernels or
39
+ * while the camera is in interaction mode, and retiring in that window denoises an empty
40
+ * buffer and captures a blank canvas.
41
+ *
42
+ * @param {Object} pathTracer - The PathTracer stage
43
+ * @param {string} renderLimitMode - 'time' or 'frames'
29
44
  * @param {number} renderTimeLimit - Time limit in seconds
30
45
  * @returns {boolean}
31
46
  */
32
- isTimeLimitReached( renderLimitMode, renderTimeLimit ) {
47
+ isTimeLimitReached( pathTracer, renderLimitMode, renderTimeLimit ) {
48
+
49
+ if ( renderLimitMode !== 'time' || ! ( renderTimeLimit > 0 ) ) return false;
50
+ if ( this.timeElapsed < renderTimeLimit ) return false;
51
+
52
+ if ( ! ( pathTracer?.frameCount > 0 ) ) {
33
53
 
34
- return renderLimitMode === 'time' && renderTimeLimit > 0 && this.timeElapsed >= renderTimeLimit;
54
+ if ( ! this._budgetHeldWarned ) {
55
+
56
+ this._budgetHeldWarned = true;
57
+ log.warn(
58
+ `render time limit (${renderTimeLimit}s) expired before the first sample landed — ` +
59
+ 'holding the deadline open to avoid capturing an empty frame'
60
+ );
61
+
62
+ }
63
+
64
+ return false;
65
+
66
+ }
67
+
68
+ this.budgetOverrun = true;
69
+ return true;
35
70
 
36
71
  }
37
72
 
38
73
  /**
39
- * Checks whether ANY render limit (time or samples) is reached.
74
+ * Checks whether ANY render limit is reached: sample ceiling, convergence, or time budget,
75
+ * whichever arrives first.
76
+ *
40
77
  * @param {Object} pathTracer - The PathTracer stage
41
78
  * @param {string} renderLimitMode
42
79
  * @param {number} renderTimeLimit
@@ -46,7 +83,7 @@ export class CompletionTracker {
46
83
 
47
84
  if ( ! pathTracer ) return false;
48
85
 
49
- if ( this.isTimeLimitReached( renderLimitMode, renderTimeLimit ) ) return true;
86
+ if ( this.isTimeLimitReached( pathTracer, renderLimitMode, renderTimeLimit ) ) return true;
50
87
 
51
88
  // Tier-1 convergence early-stop must agree with PathTracer.render()'s own check, else the app-level
52
89
  // reconcile would flip isComplete back off and keep dispatching after the frame converged.
@@ -75,6 +112,8 @@ export class CompletionTracker {
75
112
  this.timeElapsed = 0;
76
113
  this.lastResetTime = performance.now();
77
114
  this.renderCompleteDispatched = false;
115
+ this.budgetOverrun = false;
116
+ this._budgetHeldWarned = false;
78
117
 
79
118
  }
80
119
 
@@ -9,11 +9,12 @@ import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
9
9
  import { EXRLoader } from 'three/addons/loaders/EXRLoader.js';
10
10
  import { createMeshesFromMultiMaterialMesh } from 'three/addons/utils/SceneUtils.js';
11
11
  import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
12
- import { unzipSync, strFromU8 } from 'three/addons/libs/fflate.module.js';
12
+ import { unzipSync, zipSync, strFromU8 } from 'three/addons/libs/fflate.module.js';
13
13
  import { disposeObjectFromMemory, updateLoading } from './utils';
14
14
  import { BuildTimer } from './BuildTimer.js';
15
15
  import { getAssetConfig } from '../AssetConfig.js';
16
16
  import { loadPBRTScene, pickEntryPath } from './PBRT/index.js';
17
+ import { extractSceneMetadata } from './SceneMetadata.js';
17
18
 
18
19
  // Define supported file formats
19
20
  const SUPPORTED_FORMATS = {
@@ -21,13 +22,20 @@ const SUPPORTED_FORMATS = {
21
22
  'fbx': { type: 'model', name: 'FBX' }, 'obj': { type: 'model', name: 'OBJ' },
22
23
  'stl': { type: 'model', name: 'STL' }, 'ply': { type: 'model', name: 'PLY (Polygon File Format)' },
23
24
  'dae': { type: 'model', name: 'Collada' }, '3mf': { type: 'model', name: '3D Manufacturing Format' },
24
- 'usdz': { type: 'model', name: 'Universal Scene Description' },
25
+ 'usd': { type: 'model', name: 'USD (Universal Scene Description)' },
26
+ 'usda': { type: 'model', name: 'USDA (USD ASCII)' },
27
+ 'usdc': { type: 'model', name: 'USDC (USD Crate)' },
28
+ 'usdz': { type: 'model', name: 'USDZ (USD Archive)' },
25
29
  'hdr': { type: 'environment', name: 'HDR (High Dynamic Range)' }, 'exr': { type: 'environment', name: 'EXR (OpenEXR)' },
26
30
  'png': { type: 'image', name: 'PNG' }, 'jpg': { type: 'image', name: 'JPEG' },
27
31
  'jpeg': { type: 'image', name: 'JPEG' }, 'webp': { type: 'image', name: 'WebP' },
28
32
  'zip': { type: 'archive', name: 'ZIP Archive' }
29
33
  };
30
34
 
35
+ // Loose USD layers inside a ZIP compose into one scene; these pick out the
36
+ // layers and the image assets they reference.
37
+ const USD_LAYER_RE = /\.(usd|usda|usdc)$/i;
38
+ const USD_IMAGE_RE = /\.(png|jpg|jpeg|avif)$/i;
31
39
  /**
32
40
  * AssetLoader class - handles loading of 3D models, environment maps, and archives
33
41
  */
@@ -48,6 +56,10 @@ export class AssetLoader extends EventDispatcher {
48
56
  this.animations = [];
49
57
  this.renderer = null;
50
58
 
59
+ // Scene-level authoring metadata from the current model (glTF `extras`), or null.
60
+ // See SceneMetadata.js. Cleared by releaseTargetModel() on every replace-load.
61
+ this.sceneMetadata = null;
62
+
51
63
  // Shared across every loader so cancelActiveLoad() can abort whichever
52
64
  // fetch is in flight (three r185 FileLoader wires the manager's abort
53
65
  // signal into its fetch). One load runs at a time (guarded upstream).
@@ -127,6 +139,8 @@ export class AssetLoader extends EventDispatcher {
127
139
  */
128
140
  releaseTargetModel() {
129
141
 
142
+ this.sceneMetadata = null;
143
+
130
144
  if ( ! this.targetModel ) return;
131
145
 
132
146
  if ( this.targetModel === this._externalModel ) {
@@ -234,7 +248,10 @@ export class AssetLoader extends EventDispatcher {
234
248
  case 'ply': return await this.loadPLYFromArrayBuffer( arrayBuffer, filename );
235
249
  case 'dae': return await this.loadColladaFromFile( file, filename );
236
250
  case '3mf': return await this.load3MFFromArrayBuffer( arrayBuffer, filename );
237
- case 'usdz': return await this.loadUSDZFromArrayBuffer( arrayBuffer, filename );
251
+ case 'usd':
252
+ case 'usda':
253
+ case 'usdc':
254
+ case 'usdz': return await this.loadUSDFromArrayBuffer( arrayBuffer, filename );
238
255
  default: throw new Error( `Support for ${extension} files is not yet implemented` );
239
256
 
240
257
  }
@@ -288,7 +305,7 @@ export class AssetLoader extends EventDispatcher {
288
305
  texture.generateMipmaps = true;
289
306
 
290
307
  this.applyEnvironmentToScene( texture );
291
- this.dispatchEvent( { type: 'load', texture } );
308
+ this.dispatchEvent( { type: 'load', texture, url: envUrl, filename: envUrl.split( /[?#]/ )[ 0 ].split( '/' ).pop() } );
292
309
  return texture;
293
310
 
294
311
  } catch ( error ) {
@@ -647,6 +664,10 @@ export class AssetLoader extends EventDispatcher {
647
664
  const extension = path.split( '.' ).pop().toLowerCase();
648
665
  if ( SUPPORTED_FORMATS[ extension ] && SUPPORTED_FORMATS[ extension ].type === 'model' ) {
649
666
 
667
+ // A loose layer is only one slice of a USD scene — hand the whole
668
+ // archive over so its references and payloads can resolve.
669
+ if ( USD_LAYER_RE.test( path ) ) return await this.loadUSDHierarchyFromZip( zip );
670
+
650
671
  console.log( `Loading model file from ZIP: ${path}` );
651
672
  return await this.loadModelFromZipEntry( zip[ path ], path, extension, zip );
652
673
 
@@ -658,6 +679,48 @@ export class AssetLoader extends EventDispatcher {
658
679
 
659
680
  }
660
681
 
682
+ // USDLoader only builds a cross-layer asset map on its USDZ branch, so repack
683
+ // the archive as USDZ — root layer first, per AOUSD core spec 16.4.1.2 — and
684
+ // let the loader resolve the references/payloads itself.
685
+ async loadUSDHierarchyFromZip( zip ) {
686
+
687
+ const layers = Object.keys( zip ).filter( name => USD_LAYER_RE.test( name ) );
688
+ if ( layers.length === 0 ) throw new Error( 'No USD layers found in the ZIP archive' );
689
+
690
+ const root = AssetLoader._pickUSDRootLayer( layers );
691
+ console.log( `Loading USD scene from ZIP: ${root} (${layers.length} layers)` );
692
+
693
+ const packed = { [ root ]: [ zip[ root ], { level: 0 } ] };
694
+ for ( const name of Object.keys( zip ) ) {
695
+
696
+ if ( name === root ) continue;
697
+ if ( USD_LAYER_RE.test( name ) || USD_IMAGE_RE.test( name ) ) packed[ name ] = [ zip[ name ], { level: 0 } ];
698
+
699
+ }
700
+
701
+ return await this.loadUSDFromArrayBuffer( zipSync( packed ), root );
702
+
703
+ }
704
+
705
+ // Shallowest layer wins; among ties prefer the <dir>/<dir>.usd convention so a
706
+ // set's entry point beats its sibling variants.
707
+ static _pickUSDRootLayer( layers ) {
708
+
709
+ const depth = name => name.split( '/' ).length;
710
+ const minDepth = Math.min( ...layers.map( depth ) );
711
+ const candidates = layers.filter( name => depth( name ) === minDepth );
712
+
713
+ const conventional = candidates.find( name => {
714
+
715
+ const parts = name.split( '/' );
716
+ return parts.length > 1 && parts[ parts.length - 1 ].replace( USD_LAYER_RE, '' ) === parts[ parts.length - 2 ];
717
+
718
+ } );
719
+
720
+ return conventional || candidates[ 0 ];
721
+
722
+ }
723
+
661
724
  async loadModelFromZipEntry( fileContent, filePath, extension, zipContents ) {
662
725
 
663
726
  try {
@@ -698,8 +761,11 @@ export class AssetLoader extends EventDispatcher {
698
761
  case '3mf':
699
762
  result = await this.load3MFFromArrayBuffer( fileContent.buffer, filePath );
700
763
  break;
764
+ case 'usd':
765
+ case 'usda':
766
+ case 'usdc':
701
767
  case 'usdz':
702
- result = await this.loadUSDZFromArrayBuffer( fileContent.buffer, filePath );
768
+ result = await this.loadUSDFromArrayBuffer( fileContent, filePath );
703
769
  break;
704
770
  default:
705
771
  throw new Error( `Support for ${extension} files is not yet implemented` );
@@ -745,6 +811,7 @@ export class AssetLoader extends EventDispatcher {
745
811
 
746
812
  this.releaseTargetModel();
747
813
  this.targetModel = gltf.scene;
814
+ this.sceneMetadata = extractSceneMetadata( gltf );
748
815
  this.onModelLoad( this.targetModel ).then( () => resolve( gltf ) );
749
816
 
750
817
  },
@@ -1045,6 +1112,7 @@ export class AssetLoader extends EventDispatcher {
1045
1112
 
1046
1113
  this.targetModel = data.scene;
1047
1114
  this.animations = data.animations || [];
1115
+ this.sceneMetadata = extractSceneMetadata( data );
1048
1116
  await this.onModelLoad( this.targetModel );
1049
1117
  this.dispatchEvent( { type: 'load', model: data.scene, filename: modelUrl.split( '/' ).pop() } );
1050
1118
  return data;
@@ -1145,6 +1213,7 @@ export class AssetLoader extends EventDispatcher {
1145
1213
 
1146
1214
  this.targetModel = data.scene;
1147
1215
  this.animations = data.animations || [];
1216
+ this.sceneMetadata = extractSceneMetadata( data );
1148
1217
  updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
1149
1218
  await this.onModelLoad( this.targetModel );
1150
1219
 
@@ -1396,21 +1465,28 @@ export class AssetLoader extends EventDispatcher {
1396
1465
 
1397
1466
  }
1398
1467
 
1399
- async loadUSDZFromArrayBuffer( arrayBuffer, filename = 'model.usdz' ) {
1468
+ async loadUSDFromArrayBuffer( data, filename = 'model.usd' ) {
1400
1469
 
1401
1470
  try {
1402
1471
 
1403
- updateLoading( { isLoading: true, status: "Processing USDZ Data...", progress: 5 } );
1472
+ updateLoading( { isLoading: true, status: "Processing USD Data...", progress: 5 } );
1404
1473
  await new Promise( r => setTimeout( r, 0 ) );
1405
1474
 
1406
- if ( ! this.loaderCache.usdz ) {
1475
+ if ( ! this.loaderCache.usd ) {
1407
1476
 
1408
- const { USDZLoader } = await import( 'three/examples/jsm/loaders/USDZLoader.js' );
1409
- this.loaderCache.usdz = new USDZLoader();
1477
+ const { USDLoader } = await import( 'three/examples/jsm/loaders/USDLoader.js' );
1478
+ this.loaderCache.usd = new USDLoader();
1410
1479
 
1411
1480
  }
1412
1481
 
1413
- const object = this.loaderCache.usdz.parse( arrayBuffer );
1482
+ // parse() returns the group synchronously but resolves textures async;
1483
+ // setupPathTracing snapshots materials, so maps must land before it runs.
1484
+ const object = await new Promise( ( resolve, reject ) => {
1485
+
1486
+ this.loaderCache.usd.parse( data, '', resolve, reject );
1487
+
1488
+ } );
1489
+
1414
1490
  object.name = filename;
1415
1491
 
1416
1492
  this.releaseTargetModel();
@@ -1424,7 +1500,7 @@ export class AssetLoader extends EventDispatcher {
1424
1500
 
1425
1501
  } catch ( error ) {
1426
1502
 
1427
- console.error( 'Error loading USDZ:', error );
1503
+ console.error( 'Error loading USD:', error );
1428
1504
  this.dispatchEvent( { type: 'error', message: error.message, filename } );
1429
1505
  throw error;
1430
1506