rayzee 7.9.2 → 7.10.1

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.9.2",
3
+ "version": "7.10.1",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -689,6 +689,21 @@ export class PathTracerApp extends EventDispatcher {
689
689
 
690
690
  }
691
691
 
692
+ /**
693
+ * Cancel the in-flight model/environment download, if any. The active
694
+ * loadAsync() rejects with a typed LOAD_CANCELLED error, which callers treat
695
+ * as a user cancellation (not a load failure). Only the network-download phase
696
+ * is cancelable — once processing (BVH/textures) has started this is a no-op.
697
+ * The scene is untouched: replace-loads release the old model only after the
698
+ * download succeeds, and appends parent nothing until then.
699
+ */
700
+ cancelLoad() {
701
+
702
+ if ( ! this._loadingInProgress ) return;
703
+ this.assetLoader?.cancelActiveLoad();
704
+
705
+ }
706
+
692
707
  /**
693
708
  * Set the max material-texture dimension (longest edge) used when processing a
694
709
  * scene's textures into GPU arrays. Clamped to the hardware ceiling. Larger =
@@ -744,6 +759,10 @@ export class PathTracerApp extends EventDispatcher {
744
759
  try {
745
760
 
746
761
  await loadFn();
762
+ // Replace-load clears any dynamically-appended models — but only AFTER
763
+ // loadFn() succeeds, so a failed load leaves the current scene intact.
764
+ // (The old primary was already released by releaseTargetModel() in loadFn.)
765
+ this._clearAppendedModels();
747
766
  this._syncControlsAfterLoad();
748
767
  await this.loadSceneData();
749
768
  this.pipeline?.eventBus.emit( 'autoexposure:resetHistory' );
@@ -770,13 +789,18 @@ export class PathTracerApp extends EventDispatcher {
770
789
  */
771
790
  async loadSceneData() {
772
791
 
773
- // Clear selection before rebuilding — the old object leaves the scene graph
774
- this.interactionManager?.deselect();
792
+ // Clear selection before rebuilding — the old object leaves the scene graph.
793
+ // Skipped on the append path (addModel): the selected object persists, so its
794
+ // selection + transform gizmo should survive the rebuild.
795
+ if ( ! this._preserveSelectionOnRebuild ) this.interactionManager?.deselect();
775
796
 
776
797
  // Stop any running animation before rebuilding scene data
777
798
  this.animationManager.dispose();
778
799
  this._animRefitInFlight = false;
779
800
 
801
+ // Tag the primary (replace-loaded) model so it appears in the scene-object list.
802
+ this._tagPrimarySceneObject();
803
+
780
804
  const timer = new BuildTimer( 'loadSceneData' );
781
805
  const environmentTexture = this.meshScene.environment;
782
806
 
@@ -863,6 +887,189 @@ export class PathTracerApp extends EventDispatcher {
863
887
 
864
888
  }
865
889
 
890
+ // ═══════════════════════════════════════════════════════════════
891
+ // Dynamic scene objects (add / remove / list / visibility)
892
+ //
893
+ // Top-level objects are the auto-created "Ground" plane plus each loaded
894
+ // model root parented into meshScene. The scene graph + per-root userData
895
+ // tags are the single source of truth (no separate registry): ids are
896
+ // Object3D uuids (stable across rebuilds, since the same root persists).
897
+ // ═══════════════════════════════════════════════════════════════
898
+
899
+ /** Tag the primary (replace-loaded) model as a removable scene object (read by the Outliner + removeSceneObject). Idempotent. */
900
+ _tagPrimarySceneObject() {
901
+
902
+ const m = this.assetLoader?.targetModel;
903
+ if ( ! m ) return;
904
+ m.userData.__rayzeeSceneObject = true;
905
+ m.userData.__rayzeeExternal = ( m === this.assetLoader._externalModel );
906
+
907
+ }
908
+
909
+ /** Remove + dispose all dynamically-appended models (keeps Ground and the primary). */
910
+ _clearAppendedModels() {
911
+
912
+ const scene = this.meshScene;
913
+ if ( ! scene ) return;
914
+ const floor = this.assetLoader?.floorPlane;
915
+ const primary = this.assetLoader?.targetModel;
916
+ for ( const child of [ ...scene.children ] ) {
917
+
918
+ if ( child === floor || child === primary ) continue;
919
+ if ( ! child.userData?.__rayzeeSceneObject ) continue;
920
+ this.assetLoader.removeModelRoot( child, { external: !! child.userData.__rayzeeExternal } );
921
+
922
+ }
923
+
924
+ }
925
+
926
+ /** Reframe-free rebuild sequence. Assumes the _loadingInProgress guard is already held. */
927
+ async _finishRebuildNoReframe( eventPayload ) {
928
+
929
+ await this.loadSceneData(); // emits 'SceneRebuild'
930
+ this._recalibrateControlLimits(); // scene bounds changed — retune zoom limits + near/far (no camera move)
931
+ this.pipeline?.eventBus.emit( 'autoexposure:resetHistory' );
932
+ this.reset();
933
+ if ( eventPayload ) this.dispatchEvent( eventPayload );
934
+
935
+ }
936
+
937
+ /**
938
+ * Append a model by URL to the current scene (does NOT replace it), then rebuild
939
+ * without reframing the camera.
940
+ * @param {string} url
941
+ * @param {Object} [opts]
942
+ * @param {string} [opts.name] - Display name for the scene-object list.
943
+ * @returns {Promise<string>} the new object's id (Object3D uuid).
944
+ */
945
+ async addModel( url, { name } = {} ) {
946
+
947
+ if ( this._loadingInProgress ) {
948
+
949
+ throw new Error( 'PathTracerApp.addModel: another load is already in progress' );
950
+
951
+ }
952
+
953
+ this._loadingInProgress = true;
954
+ this._preserveSelectionOnRebuild = true;
955
+ try {
956
+
957
+ const { root } = await this.assetLoader.appendModel( url );
958
+ root.userData.__rayzeeSceneObject = true;
959
+ root.userData.__rayzeeExternal = false;
960
+ if ( name ) root.userData.__rayzeeName = name;
961
+ await this._finishRebuildNoReframe( { type: 'ModelAdded', url, id: root.uuid } );
962
+ return root.uuid;
963
+
964
+ } finally {
965
+
966
+ this._preserveSelectionOnRebuild = false;
967
+ this._loadingInProgress = false;
968
+
969
+ }
970
+
971
+ }
972
+
973
+ /**
974
+ * Append a caller-owned Object3D to the current scene, then rebuild (no reframe).
975
+ * The caller retains ownership — removal only detaches it.
976
+ * @param {import('three').Object3D} object3d
977
+ * @param {Object} [opts]
978
+ * @param {string} [opts.name]
979
+ * @returns {Promise<string>} the new object's id (Object3D uuid).
980
+ */
981
+ async addModelFromObject3D( object3d, { name } = {} ) {
982
+
983
+ if ( this._loadingInProgress ) {
984
+
985
+ throw new Error( 'PathTracerApp.addModelFromObject3D: another load is already in progress' );
986
+
987
+ }
988
+
989
+ this._loadingInProgress = true;
990
+ this._preserveSelectionOnRebuild = true;
991
+ try {
992
+
993
+ const { root } = this.assetLoader.appendObject3D( object3d, name || 'object3d' );
994
+ root.userData.__rayzeeSceneObject = true;
995
+ root.userData.__rayzeeExternal = true;
996
+ if ( name ) root.userData.__rayzeeName = name;
997
+ await this._finishRebuildNoReframe( { type: 'ModelAdded', id: root.uuid } );
998
+ return root.uuid;
999
+
1000
+ } finally {
1001
+
1002
+ this._preserveSelectionOnRebuild = false;
1003
+ this._loadingInProgress = false;
1004
+
1005
+ }
1006
+
1007
+ }
1008
+
1009
+ /**
1010
+ * Remove a scene object by id (Object3D uuid). The Ground plane is permanent.
1011
+ * @param {string} id
1012
+ * @returns {Promise<boolean>} true if removed.
1013
+ */
1014
+ async removeSceneObject( id ) {
1015
+
1016
+ const scene = this.meshScene;
1017
+ if ( ! scene ) return false;
1018
+
1019
+ const floor = this.assetLoader?.floorPlane;
1020
+ if ( floor && floor.uuid === id ) return false; // Ground is not deletable
1021
+
1022
+ const root = scene.children.find( c => c.uuid === id && c.userData?.__rayzeeSceneObject );
1023
+ if ( ! root ) return false;
1024
+
1025
+ if ( this._loadingInProgress ) {
1026
+
1027
+ throw new Error( 'PathTracerApp.removeSceneObject: another load is already in progress' );
1028
+
1029
+ }
1030
+
1031
+ this._loadingInProgress = true;
1032
+ try {
1033
+
1034
+ this.interactionManager?.deselect();
1035
+ this.transformManager?.detach?.();
1036
+
1037
+ if ( root === this.assetLoader.targetModel ) {
1038
+
1039
+ this.assetLoader.releaseTargetModel();
1040
+
1041
+ } else {
1042
+
1043
+ this.assetLoader.removeModelRoot( root, { external: !! root.userData.__rayzeeExternal } );
1044
+
1045
+ }
1046
+
1047
+ // Ground is permanent (removal refused above), so the scene always keeps
1048
+ // renderable geometry — a full rebuild is always valid here.
1049
+ await this._finishRebuildNoReframe( { type: 'SceneObjectRemoved', id } );
1050
+
1051
+ return true;
1052
+
1053
+ } finally {
1054
+
1055
+ this._loadingInProgress = false;
1056
+
1057
+ }
1058
+
1059
+ }
1060
+
1061
+ /**
1062
+ * Toggle a scene object's visibility without rebuilding (O(1) TLAS-leaf patch).
1063
+ * @param {string} id - Object3D uuid.
1064
+ * @param {boolean | ((prev:boolean)=>boolean)} visible
1065
+ * @returns {boolean|null} new visibility, or null if not found.
1066
+ */
1067
+ setSceneObjectVisibility( id, visible ) {
1068
+
1069
+ return this.setMeshVisibilityByUuid( id, visible );
1070
+
1071
+ }
1072
+
866
1073
  // ═══════════════════════════════════════════════════════════════
867
1074
  // BVH Refit (Animation)
868
1075
  // ═══════════════════════════════════════════════════════════════
@@ -1611,6 +1818,8 @@ export class PathTracerApp extends EventDispatcher {
1611
1818
 
1612
1819
  if ( event.model ) {
1613
1820
 
1821
+ // Drag-drop / file load is a replace: clear any appended models first.
1822
+ this._clearAppendedModels();
1614
1823
  await this.loadSceneData();
1615
1824
 
1616
1825
  } else if ( event.texture ) {
@@ -1815,6 +2024,63 @@ export class PathTracerApp extends EventDispatcher {
1815
2024
 
1816
2025
  }
1817
2026
 
2027
+ /**
2028
+ * Recompute OrbitControls zoom limits (+ default-camera near/far) from the CURRENT
2029
+ * model bounds without moving the camera or its target. Called after a dynamic
2030
+ * add/remove (the reframe-free path) so an enlarged scene stays reachable and
2031
+ * unclipped, and a shrunken one re-tightens. The replace-load (reframe) path owns
2032
+ * this via onModelLoad(). Bounds cover only the loaded model roots
2033
+ * (__rayzeeSceneObject) so the oversized, usually-hidden Ground plane can't inflate them.
2034
+ */
2035
+ _recalibrateControlLimits() {
2036
+
2037
+ if ( ! this.meshScene || ! this.cameraManager ) return;
2038
+
2039
+ const bounds = new Box3();
2040
+ const tmp = new Box3();
2041
+ for ( const child of this.meshScene.children ) {
2042
+
2043
+ if ( ! child.userData?.__rayzeeSceneObject ) continue;
2044
+ tmp.setFromObject( child );
2045
+ if ( ! tmp.isEmpty() ) bounds.union( tmp );
2046
+
2047
+ }
2048
+
2049
+ if ( bounds.isEmpty() ) return;
2050
+
2051
+ const maxDim = Math.max(
2052
+ bounds.max.x - bounds.min.x,
2053
+ bounds.max.y - bounds.min.y,
2054
+ bounds.max.z - bounds.min.z,
2055
+ );
2056
+ if ( ! Number.isFinite( maxDim ) || maxDim <= 0 ) return;
2057
+
2058
+ const { camera, controls } = this.cameraManager;
2059
+
2060
+ // Same framing distance onModelLoad() uses for the initial reframe.
2061
+ const fov = camera.fov * ( Math.PI / 180 );
2062
+ const cameraDistance = Math.abs( maxDim / Math.sin( fov / 2 ) / 2 );
2063
+
2064
+ // Keep the (grown/shrunken) scene inside the frustum. Only touch near/far when the
2065
+ // default orbit camera is active — don't stomp an authored model camera's frustum.
2066
+ if ( this.cameraManager.currentCameraIndex === 0 ) {
2067
+
2068
+ camera.near = maxDim / 100;
2069
+ camera.far = maxDim * 100;
2070
+ camera.updateProjectionMatrix();
2071
+
2072
+ }
2073
+
2074
+ // Reframe-free: never clamp past where the camera currently sits, so the rebuild
2075
+ // can't yank it (e.g. when the scene shrinks after a removal).
2076
+ const currentDist = camera.position.distanceTo( controls.target );
2077
+ controls.minDistance = Math.min( maxDim / 1000, currentDist );
2078
+ controls.maxDistance = Math.max( cameraDistance * 10, currentDist * 1.1 );
2079
+
2080
+ controls.update();
2081
+
2082
+ }
2083
+
1818
2084
  /**
1819
2085
  * Forwards events from a source EventDispatcher to this app instance.
1820
2086
  */
@@ -48,6 +48,75 @@ export class AssetLoader extends EventDispatcher {
48
48
  this.animations = [];
49
49
  this.renderer = null;
50
50
 
51
+ // Shared across every loader so cancelActiveLoad() can abort whichever
52
+ // fetch is in flight (three r185 FileLoader wires the manager's abort
53
+ // signal into its fetch). One load runs at a time (guarded upstream).
54
+ this._loadingManager = new LoadingManager();
55
+ this._loadCancelled = false;
56
+
57
+ }
58
+
59
+ /**
60
+ * Abort the network download for the in-flight load, if any. The aborted
61
+ * loadAsync() rejects with an AbortError, which each load path re-throws as a
62
+ * typed LOAD_CANCELLED error. Only the download phase is cancelable — once the
63
+ * bytes are in and BVH/texture processing has begun, this is a no-op.
64
+ */
65
+ cancelActiveLoad() {
66
+
67
+ this._loadCancelled = true;
68
+ this._loadingManager.abort();
69
+
70
+ }
71
+
72
+ _isCancellation( error ) {
73
+
74
+ return this._loadCancelled || error?.name === 'AbortError' || error?.code === 'LOAD_CANCELLED';
75
+
76
+ }
77
+
78
+ _cancellationError() {
79
+
80
+ const err = new Error( 'Load cancelled' );
81
+ err.code = 'LOAD_CANCELLED';
82
+ return err;
83
+
84
+ }
85
+
86
+ // Build an onProgress handler that reports download byte counts to the UI.
87
+ // `cancelable` gates the Cancel affordance (true only for network URLs — blob
88
+ // and data URLs resolve locally and have nothing to abort). Download maps onto
89
+ // 2→60% of the bar, leaving headroom for the processing phases that follow.
90
+ _downloadProgress( status, cancelable ) {
91
+
92
+ return ( event ) => {
93
+
94
+ const loaded = event?.loaded || 0;
95
+ const total = event?.lengthComputable ? ( event.total || 0 ) : 0;
96
+ updateLoading( {
97
+ isLoading: true,
98
+ status,
99
+ loadedBytes: loaded,
100
+ totalBytes: total,
101
+ canCancel: !! cancelable,
102
+ progress: total ? Math.min( 60, 2 + Math.round( ( loaded / total ) * 58 ) ) : 2,
103
+ } );
104
+
105
+ };
106
+
107
+ }
108
+
109
+ // Called once bytes are in, before the (non-cancelable) processing phases.
110
+ _downloadComplete( status = 'Processing Data...', progress = 62 ) {
111
+
112
+ updateLoading( { status, progress, canCancel: false, loadedBytes: null, totalBytes: null } );
113
+
114
+ }
115
+
116
+ static _isNetworkUrl( url ) {
117
+
118
+ return typeof url === 'string' && /^https?:/i.test( url );
119
+
51
120
  }
52
121
 
53
122
  /**
@@ -72,6 +141,9 @@ export class AssetLoader extends EventDispatcher {
72
141
 
73
142
  this.targetModel = null;
74
143
  this._externalModel = null;
144
+ // Drop the released model's animation clips so a later rebuild doesn't rebind
145
+ // a mixer to disposed nodes. Every load path re-populates this.animations after.
146
+ this.animations = [];
75
147
 
76
148
  }
77
149
 
@@ -189,6 +261,8 @@ export class AssetLoader extends EventDispatcher {
189
261
 
190
262
  async loadEnvironment( envUrl ) {
191
263
 
264
+ this._loadCancelled = false;
265
+
192
266
  try {
193
267
 
194
268
  // Dispatch event before loading environment to allow UI to prepare
@@ -219,6 +293,7 @@ export class AssetLoader extends EventDispatcher {
219
293
 
220
294
  } catch ( error ) {
221
295
 
296
+ if ( this._isCancellation( error ) ) throw this._cancellationError();
222
297
  console.error( "Error loading environment:", error );
223
298
  this.dispatchEvent( { type: 'error', message: error.message, filename: envUrl } );
224
299
  throw error;
@@ -280,24 +355,29 @@ export class AssetLoader extends EventDispatcher {
280
355
 
281
356
  async loadEnvironmentByExtension( url, extension ) {
282
357
 
358
+ const cancelable = AssetLoader._isNetworkUrl( url );
359
+ const onProgress = this._downloadProgress( "Downloading Environment...", cancelable );
360
+
283
361
  let texture;
284
362
  if ( extension === 'hdr' || extension === 'exr' ) {
285
363
 
286
364
  const loader = extension === 'hdr'
287
- ? ( this.loaderCache.hdr || ( this.loaderCache.hdr = new HDRLoader().setDataType( FloatType ) ) )
288
- : ( this.loaderCache.exr || ( this.loaderCache.exr = new EXRLoader().setDataType( FloatType ) ) );
289
- texture = await loader.loadAsync( url );
365
+ ? ( this.loaderCache.hdr || ( this.loaderCache.hdr = new HDRLoader( this._loadingManager ).setDataType( FloatType ) ) )
366
+ : ( this.loaderCache.exr || ( this.loaderCache.exr = new EXRLoader( this._loadingManager ).setDataType( FloatType ) ) );
367
+ texture = await loader.loadAsync( url, onProgress );
290
368
 
291
369
  } else {
292
370
 
293
- if ( ! this.loaderCache.texture ) this.loaderCache.texture = new TextureLoader();
294
- texture = await this.loaderCache.texture.loadAsync( url );
371
+ if ( ! this.loaderCache.texture ) this.loaderCache.texture = new TextureLoader( this._loadingManager );
372
+ texture = await this.loaderCache.texture.loadAsync( url, onProgress );
295
373
  // LDR env maps (jpg/png/webp) are authored in sRGB; tag them so the backend
296
374
  // decodes to linear. HDR/EXR are already linear and keep the loader's setting.
297
375
  texture.colorSpace = SRGBColorSpace;
298
376
 
299
377
  }
300
378
 
379
+ this._downloadComplete( "Processing Environment...", 62 );
380
+
301
381
  texture.mapping = EquirectangularReflectionMapping;
302
382
  texture.minFilter = LinearFilter;
303
383
  texture.magFilter = LinearFilter;
@@ -919,7 +999,7 @@ export class AssetLoader extends EventDispatcher {
919
999
 
920
1000
  }
921
1001
 
922
- const loader = new GLTFLoader();
1002
+ const loader = new GLTFLoader( this._loadingManager );
923
1003
  loader.setDRACOLoader( dracoLoader );
924
1004
  loader.setKTX2Loader( ktx2Loader );
925
1005
  loader.setMeshoptDecoder( MeshoptDecoder );
@@ -951,13 +1031,15 @@ export class AssetLoader extends EventDispatcher {
951
1031
 
952
1032
  async loadModel( modelUrl ) {
953
1033
 
1034
+ this._loadCancelled = false;
954
1035
  const loader = await this.createGLTFLoader();
1036
+ const cancelable = AssetLoader._isNetworkUrl( modelUrl );
955
1037
 
956
1038
  try {
957
1039
 
958
- updateLoading( { status: "Loading Model...", progress: 2 } );
959
- const data = await loader.loadAsync( modelUrl );
960
- updateLoading( { status: "Processing Data...", progress: 10 } );
1040
+ updateLoading( { isLoading: true, status: "Downloading Model...", progress: 2, canCancel: cancelable, loadedBytes: 0, totalBytes: 0 } );
1041
+ const data = await loader.loadAsync( modelUrl, this._downloadProgress( "Downloading Model...", cancelable ) );
1042
+ this._downloadComplete();
961
1043
 
962
1044
  this.releaseTargetModel();
963
1045
 
@@ -969,6 +1051,7 @@ export class AssetLoader extends EventDispatcher {
969
1051
 
970
1052
  } catch ( error ) {
971
1053
 
1054
+ if ( this._isCancellation( error ) ) throw this._cancellationError();
972
1055
  console.error( "Error loading model:", error );
973
1056
  this.dispatchEvent( { type: 'error', message: error.message, filename: modelUrl } );
974
1057
  throw error;
@@ -981,6 +1064,72 @@ export class AssetLoader extends EventDispatcher {
981
1064
 
982
1065
  }
983
1066
 
1067
+ // ─────────────────────────────────────────────────────────────
1068
+ // Append / remove primitives (dynamic object add/remove).
1069
+ // These deliberately do NOT touch targetModel/animations and do NOT
1070
+ // reframe the camera or dispatch load/modelProcessed — the caller
1071
+ // (PathTracerApp) drives a reframe-free scene rebuild.
1072
+ // ─────────────────────────────────────────────────────────────
1073
+
1074
+ // Multi-material split + area-light placeholders, then parent into meshScene.
1075
+ _processAndParent( model ) {
1076
+
1077
+ this.processModelObjects( model );
1078
+ this.scene.add( model );
1079
+ // Refresh world matrices for the whole subtree — the reframe-free rebuild
1080
+ // extracts geometry immediately (rAF is gated off), so nothing else would
1081
+ // update matrices first and any ancestor-node transform would be dropped.
1082
+ model.updateMatrixWorld( true );
1083
+
1084
+ }
1085
+
1086
+ // Append a model from URL without releasing prior models or reframing.
1087
+ // Reuses createGLTFLoader() so appended KTX2 textures stay RGBA DataArrayTexture.
1088
+ async appendModel( url ) {
1089
+
1090
+ this._loadCancelled = false;
1091
+ const loader = await this.createGLTFLoader();
1092
+ const cancelable = AssetLoader._isNetworkUrl( url );
1093
+
1094
+ try {
1095
+
1096
+ updateLoading( { isLoading: true, status: "Downloading Model...", progress: 2, canCancel: cancelable, loadedBytes: 0, totalBytes: 0 } );
1097
+ const data = await loader.loadAsync( url, this._downloadProgress( "Downloading Model...", cancelable ) );
1098
+ this._downloadComplete();
1099
+ this._processAndParent( data.scene );
1100
+ return { root: data.scene, animations: data.animations || [] };
1101
+
1102
+ } catch ( error ) {
1103
+
1104
+ if ( this._isCancellation( error ) ) throw this._cancellationError();
1105
+ throw error;
1106
+
1107
+ } finally {
1108
+
1109
+ this._disposeGLTFLoader( loader );
1110
+
1111
+ }
1112
+
1113
+ }
1114
+
1115
+ // Append a caller-owned Object3D without releasing prior models or reframing.
1116
+ appendObject3D( object3d, name = 'object3d' ) {
1117
+
1118
+ object3d.name = object3d.name || name;
1119
+ this._processAndParent( object3d );
1120
+ return { root: object3d };
1121
+
1122
+ }
1123
+
1124
+ // Detach + dispose an appended root. External (caller-owned) roots are only detached.
1125
+ removeModelRoot( root, { external = false } = {} ) {
1126
+
1127
+ if ( ! root ) return;
1128
+ if ( external ) root.parent?.remove( root );
1129
+ else disposeObjectFromMemory( root );
1130
+
1131
+ }
1132
+
984
1133
  async loadGLBFromArrayBuffer( arrayBuffer, filename = 'model.glb' ) {
985
1134
 
986
1135
  const loader = await this.createGLTFLoader();
@@ -425,9 +425,13 @@ export class PathTracer extends PathTracerStage {
425
425
  // Async readback of the per-bounce snapshot every N frames; never awaited, so the early-exit uses past-frame data.
426
426
  _maybeReadbackCounters() {
427
427
 
428
- // Never sample the survivor curve mid-motion those counts belong to a pose we're leaving and
429
- // would mis-size the first settled frame. Prime the counter so the settled view re-measures promptly.
430
- if ( this.cameraChanged ) {
428
+ // Never sample the survivor curve mid-motion, nor while the CameraOptimizer is holding maxBounces
429
+ // down to its interaction value (1): mid-motion counts belong to a pose we're leaving, and a curve
430
+ // measured at the interaction budget stores _lastBounceCountsBudget=1 — when the real (high) budget
431
+ // is restored on exit that stale curve forces curveReliableUpto=1, killing the per-bounce early-exit
432
+ // and full-sizing the whole loopBound for a few frames (the dramatic FPS drop right when movement
433
+ // ends, worst at high maxBounces). Prime the counter so the first settled frame re-measures promptly.
434
+ if ( this.cameraChanged || this.cameraOptimizer?.isInInteractionMode() ) {
431
435
 
432
436
  this._readbackFrameCounter = this._readbackEveryNFrames;
433
437
  return;
@@ -75,6 +75,10 @@ export class TransformManager {
75
75
  this._posBuffer = new Float32Array( offset * 9 );
76
76
  this._normalBuffer = new Float32Array( offset * 9 );
77
77
 
78
+ // Mesh indices/buffers were just reallocated (e.g. after a scene rebuild) —
79
+ // force the next drag to recompute the transform baseline.
80
+ this._baselineComputed = false;
81
+
78
82
  }
79
83
 
80
84
  /**