rayzee 8.8.2 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +210 -20
  2. package/dist/{NeuralRetouch-CYOJ21Wn.js → NeuralRetouch-BRquqfmZ.js} +1 -1
  3. package/dist/{NeuralSuperRes-Dc6jRYke.js → NeuralSuperRes-CbNvQjCw.js} +5 -2
  4. package/dist/{ToneMapGPU-BVE852iN.js → ToneMapGPU-DpUZDMbQ.js} +2 -1
  5. package/dist/rayzee.es.js +10795 -5324
  6. package/dist/rayzee.umd.js +279 -268
  7. package/package.json +1 -1
  8. package/src/AssetConfig.js +4 -0
  9. package/src/EngineDefaults.js +60 -76
  10. package/src/EngineEvents.js +7 -0
  11. package/src/EngineIssues.js +6 -0
  12. package/src/Headless.js +3 -1
  13. package/src/Passes/AIUpscaler.js +2 -1
  14. package/src/Passes/OIDNDenoiser.js +3 -6
  15. package/src/Passes/OIDNTemporalHistory.js +25 -7
  16. package/src/PathTracerApp.js +313 -25
  17. package/src/Processor/ArchiveCache.js +120 -0
  18. package/src/Processor/ArchiveReader.js +179 -32
  19. package/src/Processor/AssetLoader.js +523 -76
  20. package/src/Processor/AtmosphereModel.js +465 -0
  21. package/src/Processor/BVHBuilder.js +4 -1
  22. package/src/Processor/BVHLeafFold.js +100 -0
  23. package/src/Processor/BVHRefitter.js +98 -214
  24. package/src/Processor/ChunkedRecords.js +189 -4
  25. package/src/Processor/EmissiveTriangleBuilder.js +8 -0
  26. package/src/Processor/GeometryExtractor.js +238 -40
  27. package/src/Processor/InstanceTable.js +55 -37
  28. package/src/Processor/PBRT/PBRTCurves.js +104 -27
  29. package/src/Processor/PBRT/PBRTParser.js +132 -22
  30. package/src/Processor/PBRT/PBRTSceneBuilder.js +210 -55
  31. package/src/Processor/PBRT/buffers.js +27 -0
  32. package/src/Processor/PBRT/index.js +55 -15
  33. package/src/Processor/PackedRayBuffer.js +10 -13
  34. package/src/Processor/ParallelBVHBuilder.js +36 -15
  35. package/src/Processor/PhysicalSky.js +308 -0
  36. package/src/Processor/QueueManager.js +1 -0
  37. package/src/Processor/SceneProcessor.js +1132 -181
  38. package/src/Processor/SplitBLAS.js +249 -0
  39. package/src/Processor/SunPosition.js +41 -0
  40. package/src/Processor/TextureCreator.js +21 -0
  41. package/src/Processor/Workers/AIUpscalerWorker.js +79 -52
  42. package/src/Processor/Workers/BVHWorker.js +17 -13
  43. package/src/Processor/ZipReader.js +207 -0
  44. package/src/RenderSettings.js +49 -8
  45. package/src/SceneState/SceneState.js +367 -0
  46. package/src/SceneState/portable.js +52 -0
  47. package/src/Stages/MotionVector.js +8 -42
  48. package/src/Stages/NRD.js +30 -21
  49. package/src/Stages/NormalDepth.js +12 -23
  50. package/src/Stages/PathTracer.js +114 -6
  51. package/src/Stages/PathTracerStage.js +251 -104
  52. package/src/Storage/BLASCache.js +190 -0
  53. package/src/Storage/CDFCache.js +77 -0
  54. package/src/Storage/DownloadCache.js +266 -0
  55. package/src/Storage/GeometrySpill.js +217 -0
  56. package/src/Storage/SceneGraphCodec.js +503 -0
  57. package/src/Storage/SpillStore.js +112 -0
  58. package/src/Storage/StorageManager.js +934 -0
  59. package/src/Storage/StorageOps.js +137 -0
  60. package/src/Storage/StorageWorker.js +37 -0
  61. package/src/Storage/events.js +29 -0
  62. package/src/Storage/identity.js +77 -0
  63. package/src/Storage/index.js +9 -0
  64. package/src/Storage/inlineTransport.js +24 -0
  65. package/src/Storage/locks.js +99 -0
  66. package/src/Storage/openStorage.js +87 -0
  67. package/src/Storage/sceneCachePolicy.js +12 -0
  68. package/src/Storage/shared.js +9 -0
  69. package/src/Storage/transport.js +62 -0
  70. package/src/TSL/Atmosphere.js +684 -0
  71. package/src/TSL/BVHTraversal.js +288 -239
  72. package/src/TSL/CameraRay.js +99 -14
  73. package/src/TSL/Common.js +11 -2
  74. package/src/TSL/DebugKernel.js +2 -2
  75. package/src/TSL/Displacement.js +6 -6
  76. package/src/TSL/EmissiveSampling.js +5 -5
  77. package/src/TSL/EnvironmentCDF.js +176 -0
  78. package/src/TSL/GenerateKernel.js +5 -5
  79. package/src/TSL/HitFacet.js +4 -4
  80. package/src/TSL/LightsDirect.js +8 -8
  81. package/src/TSL/LightsSampling.js +55 -37
  82. package/src/TSL/Random.js +1 -0
  83. package/src/TSL/ShadeKernel.js +75 -19
  84. package/src/TSL/Sun.js +40 -0
  85. package/src/TSL/TextureSampling.js +6 -7
  86. package/src/index.js +22 -0
  87. package/src/managers/CameraManager.js +412 -56
  88. package/src/managers/DenoisingManager.js +4 -3
  89. package/src/managers/EnvironmentManager.js +263 -33
  90. package/src/managers/GoboManager.js +19 -8
  91. package/src/managers/IESManager.js +3 -3
  92. package/src/managers/InteractionManager.js +30 -2
  93. package/src/managers/LightManager.js +98 -5
  94. package/src/managers/MaterialDataManager.js +22 -2
  95. package/src/managers/TransformManager.js +26 -0
  96. package/src/managers/UniformManager.js +11 -7
  97. package/src/managers/VideoRenderManager.js +38 -17
  98. package/src/managers/ViewCamera.js +77 -0
  99. package/src/managers/WalkControls.js +262 -0
  100. package/src/managers/helpers/OutlineHelper.js +22 -2
  101. package/src/managers/timeline/CameraTrack.js +235 -0
  102. package/src/managers/timeline/TimelineManager.js +146 -0
  103. package/src/version.js +3 -0
  104. package/src/Processor/ProceduralSky.js +0 -291
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Reads a .zip in place: the central directory at the end names every entry and where it
3
+ * starts, so one entry is read and inflated at a time and the archive is never resident.
4
+ */
5
+
6
+ import { inflateSync } from 'three/addons/libs/fflate.module.js';
7
+
8
+ const EOCD = 0x06054b50;
9
+ const ZIP64_LOCATOR = 0x07064b50;
10
+ const ZIP64_EOCD = 0x06064b50;
11
+ const CENTRAL = 0x02014b50;
12
+ const LOCAL = 0x04034b50;
13
+ const EOCD_MAX_SEARCH = 65535 + 22;
14
+
15
+ const utf8 = new TextDecoder();
16
+
17
+ async function bytes( blob, start, end ) {
18
+
19
+ return new Uint8Array( await blob.slice( start, end ).arrayBuffer() );
20
+
21
+ }
22
+
23
+ function u64( view, offset ) {
24
+
25
+ return view.getUint32( offset + 4, true ) * 0x100000000 + view.getUint32( offset, true );
26
+
27
+ }
28
+
29
+ function latin1( b ) {
30
+
31
+ let out = '';
32
+ for ( let i = 0; i < b.length; i += 8192 ) out += String.fromCharCode( ...b.subarray( i, i + 8192 ) );
33
+ return out;
34
+
35
+ }
36
+
37
+ /**
38
+ * @param {Blob|File} blob
39
+ * @returns {Promise<Array<{name:string, size:number, compSize:number, method:number, local:number, encrypted:boolean}>>}
40
+ */
41
+ export async function readZipDirectory( blob ) {
42
+
43
+ const size = blob.size;
44
+ const tailStart = Math.max( 0, size - EOCD_MAX_SEARCH - 20 );
45
+ const tail = await bytes( blob, tailStart, size );
46
+ const tv = new DataView( tail.buffer );
47
+
48
+ let eocd = - 1;
49
+ for ( let i = tail.length - 22; i >= 0; i -- ) {
50
+
51
+ if ( tv.getUint32( i, true ) === EOCD ) {
52
+
53
+ eocd = i;
54
+ break;
55
+
56
+ }
57
+
58
+ }
59
+
60
+ if ( eocd < 0 ) throw new Error( 'zip: end of central directory not found' );
61
+
62
+ let count = tv.getUint16( eocd + 10, true );
63
+ let cdSize = tv.getUint32( eocd + 12, true );
64
+ let cdOffset = tv.getUint32( eocd + 16, true );
65
+
66
+ if ( count === 0xffff || cdSize === 0xffffffff || cdOffset === 0xffffffff ) {
67
+
68
+ const locator = eocd - 20;
69
+ if ( locator < 0 || tv.getUint32( locator, true ) !== ZIP64_LOCATOR ) throw new Error( 'zip: ZIP64 locator missing' );
70
+ const recordOffset = u64( tv, locator + 8 );
71
+ const record = new DataView( ( await bytes( blob, recordOffset, recordOffset + 56 ) ).buffer );
72
+ if ( record.getUint32( 0, true ) !== ZIP64_EOCD ) throw new Error( 'zip: ZIP64 end record missing' );
73
+ count = u64( record, 32 );
74
+ cdSize = u64( record, 40 );
75
+ cdOffset = u64( record, 48 );
76
+
77
+ }
78
+
79
+ const cd = await bytes( blob, cdOffset, cdOffset + cdSize );
80
+ const v = new DataView( cd.buffer );
81
+ const entries = [];
82
+
83
+ let p = 0;
84
+ for ( let i = 0; i < count && p + 46 <= cd.length; i ++ ) {
85
+
86
+ if ( v.getUint32( p, true ) !== CENTRAL ) throw new Error( 'zip: corrupt central directory' );
87
+
88
+ const flags = v.getUint16( p + 8, true );
89
+ const method = v.getUint16( p + 10, true );
90
+ let compSize = v.getUint32( p + 20, true );
91
+ let entrySize = v.getUint32( p + 24, true );
92
+ const nameLen = v.getUint16( p + 28, true );
93
+ const extraLen = v.getUint16( p + 30, true );
94
+ const commentLen = v.getUint16( p + 32, true );
95
+ let local = v.getUint32( p + 42, true );
96
+
97
+ const rawName = cd.subarray( p + 46, p + 46 + nameLen );
98
+ const name = flags & 0x800 ? utf8.decode( rawName ) : latin1( rawName );
99
+
100
+ // ZIP64 extra field: only the saturated 32-bit fields are present, in this order.
101
+ for ( let e = p + 46 + nameLen, end = e + extraLen; e + 4 <= end; ) {
102
+
103
+ const id = v.getUint16( e, true );
104
+ const len = v.getUint16( e + 2, true );
105
+ if ( id === 0x0001 ) {
106
+
107
+ let q = e + 4;
108
+ if ( entrySize === 0xffffffff ) {
109
+
110
+ entrySize = u64( v, q );
111
+ q += 8;
112
+
113
+ }
114
+
115
+ if ( compSize === 0xffffffff ) {
116
+
117
+ compSize = u64( v, q );
118
+ q += 8;
119
+
120
+ }
121
+
122
+ if ( local === 0xffffffff ) local = u64( v, q );
123
+
124
+ }
125
+
126
+ e += 4 + len;
127
+
128
+ }
129
+
130
+ if ( ! name.endsWith( '/' ) ) entries.push( { name, size: entrySize, compSize, method, local, encrypted: ( flags & 1 ) !== 0 } );
131
+ p += 46 + nameLen + extraLen + commentLen;
132
+
133
+ }
134
+
135
+ return entries;
136
+
137
+ }
138
+
139
+ async function dataStart( blob, entry ) {
140
+
141
+ if ( entry.encrypted ) throw new Error( `zip: "${entry.name}" is encrypted` );
142
+
143
+ const head = new DataView( ( await bytes( blob, entry.local, entry.local + 30 ) ).buffer );
144
+ if ( head.getUint32( 0, true ) !== LOCAL ) throw new Error( `zip: bad local header for "${entry.name}"` );
145
+ return entry.local + 30 + head.getUint16( 26, true ) + head.getUint16( 28, true );
146
+
147
+ }
148
+
149
+ async function readEntry( blob, entry ) {
150
+
151
+ const start = await dataStart( blob, entry );
152
+ const raw = await bytes( blob, start, start + entry.compSize );
153
+
154
+ if ( entry.method === 0 ) return raw;
155
+ if ( entry.method === 8 ) return entry.size === 0 ? new Uint8Array( 0 ) : inflateSync( raw, { out: new Uint8Array( entry.size ) } );
156
+ throw new Error( `zip: "${entry.name}" uses compression method ${entry.method}, which is not supported` );
157
+
158
+ }
159
+
160
+ /**
161
+ * Same shape as `openTar`: `listing` holds every entry, with an `offset` on those `filter` keeps,
162
+ * and `read( path )` inflates one on demand; `slice( path )` gives it as a Blob. Paths are the archive's own, as fflate's unzipSync keys them.
163
+ * @param {Blob|File} blob
164
+ * @param {{filter?: (path:string, size:number) => boolean}} [options]
165
+ */
166
+ export async function openZip( blob, { filter = null } = {} ) {
167
+
168
+ const directory = await readZipDirectory( blob );
169
+ const byPath = new Map();
170
+
171
+ const listing = directory.map( e => {
172
+
173
+ if ( filter && ! filter( e.name, e.size ) ) return { path: e.name, size: e.size };
174
+ byPath.set( e.name, e );
175
+ return { path: e.name, size: e.size, offset: e.local };
176
+
177
+ } );
178
+
179
+ const read = async ( path ) => {
180
+
181
+ const entry = byPath.get( path );
182
+ return entry ? readEntry( blob, entry ) : null;
183
+
184
+ };
185
+
186
+ // A stored entry is a slice of the archive itself, so a large one never passes through memory.
187
+ const slice = async ( path ) => {
188
+
189
+ const entry = byPath.get( path );
190
+ if ( ! entry ) return null;
191
+ if ( entry.method !== 0 ) return new Blob( [ await readEntry( blob, entry ) ] );
192
+ const start = await dataStart( blob, entry );
193
+ return blob.slice( start, start + entry.compSize );
194
+
195
+ };
196
+
197
+ // A stored entry's head is a slice; a deflated one is inflated whole, as a read would.
198
+ const readHead = async ( path, bytes ) => {
199
+
200
+ const part = await slice( path );
201
+ return part ? new Uint8Array( await part.slice( 0, bytes ).arrayBuffer() ) : null;
202
+
203
+ };
204
+
205
+ return { entries: Object.create( null ), listing, read, readHead, slice, retainedBytes: 0, truncated: false, indexed: byPath.size };
206
+
207
+ }
@@ -1,7 +1,8 @@
1
1
  import { EventDispatcher, Color, Vector2, MathUtils } from 'three';
2
- import { ENGINE_DEFAULTS } from './EngineDefaults.js';
2
+ import { ENGINE_DEFAULTS, CAMERA_PROJECTION_IDS } from './EngineDefaults.js';
3
3
  import { EngineEvents } from './EngineEvents.js';
4
4
  import { ISSUE_CODES } from './EngineIssues.js';
5
+ import { toPortable, fromPortable } from './SceneState/portable.js';
5
6
 
6
7
  /**
7
8
  * Routing table: maps each setting key to its target stage/handler.
@@ -40,6 +41,9 @@ const SETTING_ROUTES = {
40
41
  aperture: { uniform: 'aperture', reset: true },
41
42
  apertureScale: { uniform: 'apertureScale', reset: true },
42
43
  anamorphicRatio: { uniform: 'anamorphicRatio', reset: true },
44
+ unitsPerMetre: { uniform: 'unitsPerMetre', reset: true },
45
+ dofMode: { handler: 'handleDofMode', reset: true },
46
+ dofBlur: { uniform: 'dofBlur', reset: true },
43
47
  samplingTechnique: { uniform: 'samplingTechnique', reset: true },
44
48
  fireflyThreshold: { uniform: 'fireflyThreshold', reset: true },
45
49
  shadowTerminatorOffset: { uniform: 'shadowTerminatorOffset', reset: true },
@@ -149,8 +153,8 @@ export class RenderSettings extends EventDispatcher {
149
153
  * @param {Function} params.resetCallback - Called to reset accumulation
150
154
  * @param {Function} [params.reconcileCompletion] - Called when completion limits change
151
155
  * @param {Object} [params.denoisingManager] - Needed to force ASVGF off under panorama
152
- * @param {Object} [params.cameraManager] - Needed to force auto-focus manual under panorama
153
156
  * @param {Function} [params.onInteractionRenderScale] - Applies a new moving-camera render scale
157
+ * @param {Function} [params.onCameraProjection] - The camera's side of a `cameraProjection` change
154
158
  */
155
159
  bind( params ) {
156
160
 
@@ -165,7 +169,7 @@ export class RenderSettings extends EventDispatcher {
165
169
  * Builds handler functions for multi-stage settings that can't
166
170
  * be routed with a simple uniform forward.
167
171
  */
168
- _buildHandlers( { stages, renderer, resetCallback, reconcileCompletion, denoisingManager, cameraManager, onInteractionRenderScale } ) {
172
+ _buildHandlers( { stages, renderer, resetCallback, reconcileCompletion, denoisingManager, onInteractionRenderScale, onCameraProjection } ) {
169
173
 
170
174
  // UniformManager copies into the existing node, so one scratch vector serves every write.
171
175
  const panoScratch = new Vector2();
@@ -180,20 +184,20 @@ export class RenderSettings extends EventDispatcher {
180
184
 
181
185
  handleCameraProjection: ( value ) => {
182
186
 
183
- const isPanorama = value === 'equirectangular';
184
- stages.pathTracer?.setUniform( 'cameraProjection', isPanorama ? 1 : 0 );
187
+ stages.pathTracer?.setUniform( 'cameraProjection', CAMERA_PROJECTION_IDS[ value ] ?? CAMERA_PROJECTION_IDS.perspective );
188
+ onCameraProjection?.( value );
185
189
 
186
- if ( ! isPanorama ) return;
190
+ if ( value !== 'equirectangular' ) return;
187
191
 
188
192
  // MotionVector unprojects through projectionMatrixInverse, which is meaningless once
189
193
  // every pixel is its own direction. Fall back to the spatial-only denoiser rather
190
194
  // than leaving no strategy.
191
195
  if ( denoisingManager?.requiresMotionVectors ) denoisingManager.setDenoiserStrategy( 'edgeaware' );
192
- // Auto-focus raycasts via Raycaster.setFromCamera, which only knows the frustum.
193
- cameraManager?.setAutoFocusMode( 'manual' );
194
196
 
195
197
  },
196
198
 
199
+ handleDofMode: ( value ) => stages.pathTracer?.setUniform( 'dofMode', value === 'look' ? 1 : 0 ),
200
+
197
201
  handlePanoramaLonRange: ( value ) => setPanoRange( 'panoLonRange', value ),
198
202
 
199
203
  handlePanoramaLatRange: ( value ) => setPanoRange( 'panoLatRange', value ),
@@ -421,6 +425,43 @@ export class RenderSettings extends EventDispatcher {
421
425
 
422
426
  }
423
427
 
428
+ /** The settings a host set, as portable values (see SceneState/portable.js): what a session restores. */
429
+ serialize() {
430
+
431
+ const out = {};
432
+ for ( const [ key, value ] of this._values ) {
433
+
434
+ if ( this._sources.get( key ) !== SETTING_SOURCE.HOST || ! SETTING_ROUTES[ key ] ) continue;
435
+ const portable = toPortable( value );
436
+ if ( portable !== undefined ) out[ key ] = portable;
437
+
438
+ }
439
+
440
+ return out;
441
+
442
+ }
443
+
444
+ /**
445
+ * Applies {@link serialize}'s values as host settings, once reset at the end. Keys this engine
446
+ * no longer routes are left out rather than recorded as unknown.
447
+ * @returns {string[]} the keys left out
448
+ */
449
+ restore( values ) {
450
+
451
+ const known = {};
452
+ const unknown = [];
453
+ for ( const [ key, value ] of Object.entries( values ?? {} ) ) {
454
+
455
+ if ( SETTING_ROUTES[ key ] ) known[ key ] = fromPortable( value );
456
+ else unknown.push( key );
457
+
458
+ }
459
+
460
+ this.setMany( known );
461
+ return unknown;
462
+
463
+ }
464
+
424
465
  /**
425
466
  * Pushes all stored values to their target stages.
426
467
  * Called after loadSceneData() to ensure GPU uniforms match stored values.
@@ -0,0 +1,367 @@
1
+ /**
2
+ * A scene's state after it loaded: everything a host or user changed, as plain data. Saved
3
+ * sessions and project files carry it; the model itself is the host's to reload first.
4
+ *
5
+ * Objects, materials and cameras are matched by position in this load plus a name check, never by
6
+ * UUID — those change on every load.
7
+ */
8
+
9
+ import { VERSION } from '../version.js';
10
+ import { fromPortable } from './portable.js';
11
+
12
+ export const SCENE_STATE_VERSION = 1;
13
+
14
+ const isUrl = source => typeof source === 'string' && /^https?:\/\//.test( source );
15
+
16
+ function pathOf( object, root ) {
17
+
18
+ const path = [];
19
+ while ( object && object !== root ) {
20
+
21
+ const parent = object.parent;
22
+ if ( ! parent ) return null;
23
+ path.unshift( parent.children.indexOf( object ) );
24
+ object = parent;
25
+
26
+ }
27
+
28
+ return object === root ? path : null;
29
+
30
+ }
31
+
32
+ function objectAt( root, path ) {
33
+
34
+ let object = root;
35
+ for ( const index of path ) {
36
+
37
+ object = object?.children[ index ];
38
+ if ( ! object ) return null;
39
+
40
+ }
41
+
42
+ return object;
43
+
44
+ }
45
+
46
+ function signature( app ) {
47
+
48
+ return {
49
+ key: app.assetLoader?.sceneSourceKey ?? null,
50
+ name: app.sceneModel?.name ?? null,
51
+ meshes: app.sceneMeshes.length,
52
+ materials: app.stages.pathTracer?.materialData.materialCount ?? 0,
53
+ };
54
+
55
+ }
56
+
57
+ function materialsByIndex( app ) {
58
+
59
+ const out = new Map();
60
+ for ( const mesh of app.sceneMeshes ) {
61
+
62
+ const index = mesh.userData?.materialIndex;
63
+ if ( index !== undefined && ! out.has( index ) ) out.set( index, Array.isArray( mesh.material ) ? mesh.material[ 0 ] : mesh.material );
64
+
65
+ }
66
+
67
+ return out;
68
+
69
+ }
70
+
71
+ function colorState( cm ) {
72
+
73
+ if ( ! cm ) return null;
74
+ const status = cm.status();
75
+ const view = status.activeView;
76
+ const configId = status.config?.id ?? view?.configId ?? null;
77
+
78
+ return {
79
+ config: configId ? { id: configId } : null,
80
+ view: view ? { display: view.display, view: view.view, look: view.look ?? null } : null,
81
+ transform: view ? null : status.activeTransform?.id ?? null,
82
+ workingSpace: status.workingSpaceAdopted ? status.workingSpace : null,
83
+ context: status.context ?? null,
84
+ };
85
+
86
+ }
87
+
88
+ /**
89
+ * @param {import('../PathTracerApp.js').PathTracerApp} app
90
+ * @returns {Object} JSON-safe
91
+ */
92
+ export function captureSceneState( app ) {
93
+
94
+ const root = app.meshScene;
95
+ const materials = materialsByIndex( app );
96
+
97
+ const hidden = [];
98
+ root.traverse( object => {
99
+
100
+ if ( object !== root && object.visible === false ) hidden.push( pathOf( object, root ) );
101
+
102
+ } );
103
+
104
+ return {
105
+ v: SCENE_STATE_VERSION,
106
+ engine: VERSION,
107
+ savedAt: Date.now(),
108
+ scene: signature( app ),
109
+ appended: root.children
110
+ .filter( c => c !== app.sceneModel && c.userData?.__rayzeeSceneObject )
111
+ .map( c => ( { name: c.userData.__rayzeeName ?? c.name, url: c.userData.__rayzeeSourceUrl ?? null, cacheKey: c.userData.__rayzeeCacheKey ?? null } ) ),
112
+ settings: app.settings.serialize(),
113
+ environment: app.environmentManager?.serialize() ?? null,
114
+ color: colorState( app.color ),
115
+ lights: app.lightManager.serialize(),
116
+ cameras: app.cameraManager.serialize(),
117
+ timeline: app.timeline?.serialize() ?? null,
118
+ materials: ( app.stages.pathTracer?.materialData.serializeHostEdits() ?? [] ).map( edit => ( { ...edit, name: materials.get( edit.index )?.name ?? '' } ) ),
119
+ hidden,
120
+ moved: ( app.transformManager?.movedObjects ?? [] ).map( object => ( {
121
+ path: pathOf( object, root ),
122
+ name: object.name,
123
+ position: object.position.toArray(),
124
+ quaternion: object.quaternion.toArray(),
125
+ scale: object.scale.toArray(),
126
+ } ) ).filter( m => m.path ),
127
+ };
128
+
129
+ }
130
+
131
+ function applyMaterialValue( material, property, value ) {
132
+
133
+ const current = material[ property ];
134
+ if ( current?.copy && ( value?.isColor || value?.isVector2 || value?.isVector3 ) ) current.copy( value );
135
+ else material[ property ] = value;
136
+ material.needsUpdate = true;
137
+
138
+ }
139
+
140
+ async function restoreEnvironment( app, env, resolve, skip ) {
141
+
142
+ if ( ! env ) return;
143
+ const manager = app.environmentManager;
144
+
145
+ if ( env.hdri && env.hdri !== manager.hdriSource ) {
146
+
147
+ const input = isUrl( env.hdri ) ? env.hdri : await resolve( { kind: 'environment', source: env.hdri } );
148
+ try {
149
+
150
+ if ( typeof input === 'string' ) await app.loadEnvironment( input );
151
+ else if ( input ) await app.loadFile( input );
152
+ else skip( 'environment', 'source unavailable', { source: env.hdri } );
153
+
154
+ } catch ( error ) {
155
+
156
+ skip( 'environment', error.message, { source: env.hdri } );
157
+
158
+ }
159
+
160
+ }
161
+
162
+ await manager.restore( env );
163
+
164
+ }
165
+
166
+ async function restoreColor( app, color, resolve, skip ) {
167
+
168
+ const cm = app.color;
169
+ if ( ! color || ! cm ) return;
170
+
171
+ const status = cm.status();
172
+ const active = status.activeView;
173
+ const sameView = !! color.view && !! active
174
+ && active.display === color.view.display && active.view === color.view.view && ( active.look ?? null ) === color.view.look;
175
+ const loadedId = status.config?.id ?? null;
176
+ const shownId = loadedId ?? active?.configId ?? null;
177
+ const settled = ( color.config?.id ?? null ) === shownId && ! color.workingSpace && ! color.context
178
+ && ( color.view ? sameView : ! active && ( ! color.transform || color.transform === status.activeTransform?.id ) );
179
+ if ( settled ) return;
180
+
181
+ try {
182
+
183
+ if ( color.config && color.config.id !== loadedId ) {
184
+
185
+ if ( ! await resolve( { kind: 'colorConfig', config: color.config } ) ) {
186
+
187
+ const builtins = await cm.constructor.listBuiltinConfigs().catch( () => [] );
188
+ const known = builtins.some( b => ( b.name ?? b ) === color.config.id || `ocio://${b.name ?? b}` === color.config.id );
189
+ if ( ! known ) {
190
+
191
+ skip( 'color', 'config unavailable', { config: color.config.id } );
192
+ return;
193
+
194
+ }
195
+
196
+ await app.loadColorConfig( { builtin: color.config.id, registerViews: false } );
197
+
198
+ }
199
+
200
+ } else if ( ! color.config && loadedId ) {
201
+
202
+ await app.unloadColorConfig();
203
+
204
+ }
205
+
206
+ if ( color.context ) cm.setContext( color.context );
207
+ if ( color.view ) cm.setView( color.view );
208
+ else if ( color.transform ) cm.setActiveView( color.transform );
209
+
210
+ if ( ( color.workingSpace ?? null ) !== ( cm.workingSpaceAdopted ? cm.workingSpace : null ) ) {
211
+
212
+ cm.setWorkingSpace( color.workingSpace );
213
+ await app.applyColorWorkingSpace();
214
+
215
+ }
216
+
217
+ } catch ( error ) {
218
+
219
+ skip( 'color', error.message, { config: color.config?.id ?? null } );
220
+
221
+ }
222
+
223
+ }
224
+
225
+ /**
226
+ * Applies a captured state to the scene now loaded. Call once the model has loaded.
227
+ *
228
+ * @param {import('../PathTracerApp.js').PathTracerApp} app
229
+ * @param {Object} state - from {@link captureSceneState}
230
+ * @param {Object} [options]
231
+ * @param {function(Object): Promise<*>} [options.resolve] - the host's answer for an input the
232
+ * engine cannot reach itself: `{ kind: 'environment', source }` → a File, a URL or null;
233
+ * `{ kind: 'colorConfig', config }` → true once the host has loaded it
234
+ * @returns {Promise<{skipped: Array<{section: string, reason: string}>}>} what could not be put back
235
+ */
236
+ export async function applySceneState( app, state, { resolve = async () => null } = {} ) {
237
+
238
+ if ( state?.v !== SCENE_STATE_VERSION ) throw new Error( `scene state version ${state?.v} is not ${SCENE_STATE_VERSION}` );
239
+
240
+ const skipped = [];
241
+ const skip = ( section, reason, detail = {} ) => skipped.push( { section, reason, ...detail } );
242
+
243
+ for ( const model of state.appended ?? [] ) {
244
+
245
+ if ( ! model.url ) {
246
+
247
+ skip( 'appended', 'added from memory, not a file', { name: model.name } );
248
+ continue;
249
+
250
+ }
251
+
252
+ try {
253
+
254
+ await app.addModel( model.url, { name: model.name, cacheKey: model.cacheKey ?? undefined } );
255
+
256
+ } catch ( error ) {
257
+
258
+ skip( 'appended', error.message, { name: model.name } );
259
+
260
+ }
261
+
262
+ }
263
+
264
+ const now = signature( app );
265
+ const saved = state.scene ?? {};
266
+ const sameScene = saved.meshes === now.meshes && saved.materials === now.materials;
267
+ if ( ! sameScene ) skip( 'scene', 'the loaded scene is not the one saved: objects and materials left as loaded', { saved, now } );
268
+
269
+ await restoreEnvironment( app, state.environment, resolve, skip );
270
+ await restoreColor( app, state.color, resolve, skip );
271
+
272
+ const unknown = app.settings.restore( state.settings );
273
+ if ( unknown.length ) skip( 'settings', 'not settings in this engine', { keys: unknown } );
274
+
275
+ const lights = app.lightManager.restore( state.lights );
276
+ for ( const light of lights ) {
277
+
278
+ const { gobo, ies } = light.userData;
279
+ if ( gobo && app.goboManager?.entries?.length ) app.goboManager.setLightGobo( light.uuid, gobo.name, gobo );
280
+ if ( ies && app.iesManager?.entries?.length ) app.iesManager.setSpotLightProfile( light.uuid, ies.name, ies.intensity, { applyAutoCone: false } );
281
+
282
+ }
283
+
284
+ if ( sameScene ) {
285
+
286
+ const materials = materialsByIndex( app );
287
+ for ( const edit of state.materials ?? [] ) {
288
+
289
+ const material = materials.get( edit.index );
290
+ if ( ! material || ( material.name ?? '' ) !== edit.name ) {
291
+
292
+ skip( 'materials', 'no such material', { index: edit.index, name: edit.name } );
293
+ continue;
294
+
295
+ }
296
+
297
+ for ( const [ property, portable ] of Object.entries( edit.props ) ) {
298
+
299
+ const value = fromPortable( portable );
300
+ applyMaterialValue( material, property, value );
301
+ app.setMaterialProperty( edit.index, property, value );
302
+
303
+ }
304
+
305
+ }
306
+
307
+ const root = app.meshScene;
308
+ const hidden = new Set( ( state.hidden ?? [] ).map( path => objectAt( root, path ) ).filter( Boolean ) );
309
+ root.traverse( object => {
310
+
311
+ if ( object !== root && ! object.isLight ) object.visible = ! hidden.has( object );
312
+
313
+ } );
314
+ app.updateAllMeshVisibility();
315
+
316
+ const moved = [];
317
+ for ( const entry of state.moved ?? [] ) {
318
+
319
+ const object = objectAt( root, entry.path );
320
+ if ( ! object || object.name !== entry.name ) {
321
+
322
+ skip( 'moved', 'no such object', { name: entry.name } );
323
+ continue;
324
+
325
+ }
326
+
327
+ object.position.fromArray( entry.position );
328
+ object.quaternion.fromArray( entry.quaternion );
329
+ object.scale.fromArray( entry.scale );
330
+ object.updateMatrixWorld( true );
331
+ app.transformManager?.noteMoved( object );
332
+ moved.push( object );
333
+
334
+ }
335
+
336
+ if ( moved.length ) {
337
+
338
+ const affected = [];
339
+ app.sceneMeshes.forEach( ( mesh, index ) => {
340
+
341
+ for ( let o = mesh; o; o = o.parent ) {
342
+
343
+ if ( moved.includes( o ) ) {
344
+
345
+ affected.push( index );
346
+ break;
347
+
348
+ }
349
+
350
+ }
351
+
352
+ } );
353
+ if ( affected.length ) app.updateMeshTransforms( affected );
354
+
355
+ }
356
+
357
+ }
358
+
359
+ const { mismatched } = app.cameraManager.restore( state.cameras );
360
+ if ( mismatched.length ) skip( 'cameras', 'model cameras changed', { names: mismatched } );
361
+ app._dispatchCamerasUpdated();
362
+ app.timeline?.restore( state.timeline );
363
+
364
+ app.reset();
365
+ return { skipped };
366
+
367
+ }