rayzee 8.8.1 → 9.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.
@@ -1,14 +1,20 @@
1
- import { EventDispatcher, PerspectiveCamera, Vector3 } from 'three';
1
+ import { EventDispatcher, MathUtils, OrthographicCamera, PerspectiveCamera, Vector3 } from 'three';
2
2
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
3
3
  import { EngineEvents, } from '../EngineEvents.js';
4
- import { AF_DEFAULTS } from '../EngineDefaults.js';
4
+ import { AF_DEFAULTS, CAMERA_PROJECTION_IDS } from '../EngineDefaults.js';
5
+ import { viewDepth } from './InteractionManager.js';
6
+ import { ViewCamera } from './ViewCamera.js';
7
+ import { WalkControls } from './WalkControls.js';
5
8
 
6
9
  const DEFAULT_CAMERA_SCALE = Object.freeze( new Vector3( 1, 1, 1 ) );
7
10
 
11
+ /** The height of an orthographic camera's view in world units, or null for any other camera. */
12
+ const orthoHeightOf = camera => camera?.isOrthographicCamera ? ( camera.top - camera.bottom ) / camera.zoom : null;
13
+
8
14
  /**
9
15
  * Manages camera creation, switching, auto-focus, and AF point placement.
10
16
  *
11
- * Owns the PerspectiveCamera and OrbitControls instances.
17
+ * Owns the {@link ViewCamera} and OrbitControls instances.
12
18
  * Dispatches events that PathTracerApp relays to external consumers.
13
19
  */
14
20
  export class CameraManager extends EventDispatcher {
@@ -23,7 +29,7 @@ export class CameraManager extends EventDispatcher {
23
29
  const width = canvas.clientWidth;
24
30
  const height = canvas.clientHeight;
25
31
 
26
- this.camera = new PerspectiveCamera( 60, width / height || 1, 0.01, 1000 );
32
+ this.camera = new ViewCamera( 60, width / height || 1, 0.01, 1000 );
27
33
  this.camera.position.set( 0, 0, 5 );
28
34
 
29
35
  this.controls = new OrbitControls( this.camera, canvas );
@@ -31,9 +37,16 @@ export class CameraManager extends EventDispatcher {
31
37
  this.controls.zoomToCursor = true;
32
38
  this.controls.saveState();
33
39
 
40
+ // The wheel zooms an orthographic view: report its height.
41
+ this._reportedOrthoHeight = null;
42
+ this._reportOrthoHeight = this._reportOrthoHeight.bind( this );
43
+ this.controls.addEventListener( 'change', this._reportOrthoHeight );
44
+
45
+ this.walkControls = new WalkControls( this.camera, canvas, this.controls );
46
+
34
47
  this.interactionManager = null;
35
48
 
36
- /** @type {import('three').PerspectiveCamera[]} */
49
+ /** @type {import('three').Camera[]} */
37
50
  this.cameras = [ this.camera ];
38
51
  this.currentCameraIndex = 0;
39
52
 
@@ -62,19 +75,20 @@ export class CameraManager extends EventDispatcher {
62
75
 
63
76
  /**
64
77
  * Sets the list of available cameras (default + extracted from model).
65
- * @param {import('three').PerspectiveCamera[]} cameras
78
+ * @param {import('three').Camera[]} cameras - perspective or orthographic
66
79
  */
67
80
  setCameras( cameras ) {
68
81
 
69
82
  this.cameras = cameras;
70
83
  this._userCameraCounter = 0;
84
+ this.resetAutoFocus();
71
85
 
72
86
  }
73
87
 
74
88
  /**
75
89
  * Adds a new user camera that snapshots the current render camera's pose,
76
- * FOV, clip planes, and orbit target. The snapshot is a standalone template
77
- * appended to the list; switching to it restores this exact framing.
90
+ * FOV or orthographic size, clip planes, and orbit target. The snapshot is a standalone
91
+ * template appended to the list; switching to it restores this exact framing.
78
92
  *
79
93
  * @param {string} [name] - Optional display name (auto-generated otherwise).
80
94
  * @returns {number} The index of the newly added camera.
@@ -82,7 +96,10 @@ export class CameraManager extends EventDispatcher {
82
96
  addCameraFromView( name ) {
83
97
 
84
98
  const src = this.camera;
85
- const cam = new PerspectiveCamera( src.fov, src.aspect, src.near, src.far );
99
+ const cam = src.orthographic
100
+ ? new OrthographicCamera( src.left, src.right, src.top, src.bottom, src.near, src.far )
101
+ : new PerspectiveCamera( src.fov, src.aspect, src.near, src.far );
102
+ cam.zoom = src.zoom;
86
103
  cam.position.copy( src.position );
87
104
  cam.quaternion.copy( src.quaternion );
88
105
  cam.scale.copy( src.scale );
@@ -193,8 +210,10 @@ export class CameraManager extends EventDispatcher {
193
210
  focalLength: get( 'focalLength' ),
194
211
  apertureScale: get( 'apertureScale' ),
195
212
  anamorphicRatio: get( 'anamorphicRatio' ),
213
+ dofBlur: get( 'dofBlur' ),
196
214
  autoFocusMode: this.autoFocusMode,
197
215
  afScreenPoint: { ...this.afScreenPoint },
216
+ orthoHeight: this.orthoHeight,
198
217
  };
199
218
 
200
219
  }
@@ -214,6 +233,7 @@ export class CameraManager extends EventDispatcher {
214
233
  focalLength: eff.focalLength,
215
234
  apertureScale: eff.apertureScale,
216
235
  anamorphicRatio: eff.anamorphicRatio,
236
+ dofBlur: eff.dofBlur,
217
237
  } );
218
238
 
219
239
  if ( eff.autoFocusMode !== undefined ) this.setAutoFocusMode( eff.autoFocusMode );
@@ -221,13 +241,6 @@ export class CameraManager extends EventDispatcher {
221
241
 
222
242
  }
223
243
 
224
- /** Scene scale used to convert scaled focusDistance to UI-facing units. */
225
- _getSceneScale() {
226
-
227
- return this._afContext?.assetLoader?.getSceneScale?.() || 1;
228
-
229
- }
230
-
231
244
  /**
232
245
  * Switches the active camera by index.
233
246
  * Uses stored callbacks from initCallbacks() for resize/reset.
@@ -305,7 +318,7 @@ export class CameraManager extends EventDispatcher {
305
318
  // quaternion can express — a pbrt scene's `Scale -1 1 1`, for one. Copying
306
319
  // pose alone would silently un-mirror the view.
307
320
  this.camera.scale.copy( sourceCamera.scale );
308
- this.camera.fov = sourceCamera.fov;
321
+ if ( sourceCamera.isPerspectiveCamera ) this.camera.fov = sourceCamera.fov;
309
322
  this.camera.near = sourceCamera.near;
310
323
  this.camera.far = sourceCamera.far;
311
324
  this.camera.updateProjectionMatrix();
@@ -334,18 +347,121 @@ export class CameraManager extends EventDispatcher {
334
347
 
335
348
  }
336
349
 
350
+ this.resetAutoFocus();
351
+
352
+ // A camera left orthographic comes back orthographic; one never visited is what it was made as.
353
+ const incoming = this.cameras[ index ];
354
+ const saved = incoming?.userData?.__rayzeeEffects;
355
+ this._showOrthographic( saved ? saved.orthoHeight : orthoHeightOf( incoming ) );
356
+
337
357
  // Restore the incoming camera's own DOF/focus effects (if it has any saved;
338
358
  // otherwise it inherits the current global config).
339
- this._applyEffects( this.cameras[ index ]?.userData?.__rayzeeEffects );
359
+ this._applyEffects( saved );
340
360
 
341
361
  onResize?.();
342
362
  onReset?.();
343
363
 
344
- // Emit UI-facing effects: focusDistance unscaled (matching AUTO_FOCUS_UPDATED),
345
- // so the adapter is a plain passthrough with no scene-scale awareness.
346
- const effects = this._captureEffects();
347
- if ( effects ) effects.focusDistance /= this._getSceneScale();
348
- this.dispatchEvent( { type: 'CameraSwitched', cameraIndex: index, effects, fov: this.camera.fov } );
364
+ this.dispatchEvent( {
365
+ type: 'CameraSwitched', cameraIndex: index, effects: this._captureEffects(), fov: this.camera.fov,
366
+ cameraProjection: this._getSettings?.( 'cameraProjection' ),
367
+ } );
368
+
369
+ }
370
+
371
+ /**
372
+ * Makes the camera orthographic at `height`, or perspective for null, then hands the `cameraProjection`
373
+ * setting the outcome — after, so its handler finds the camera already there. A 360° panorama stays on
374
+ * across perspective cameras.
375
+ * @param {?number} height - in world units
376
+ */
377
+ _showOrthographic( height ) {
378
+
379
+ if ( height > 0 ) this._setView( true, height / 2 );
380
+ else this._setView( false );
381
+
382
+ const current = this._getSettings?.( 'cameraProjection' );
383
+ const next = height > 0 ? 'orthographic' : current === 'orthographic' ? 'perspective' : current;
384
+ if ( next !== current ) this._applySettings?.( { cameraProjection: next } );
385
+
386
+ }
387
+
388
+ /**
389
+ * The camera's side of the `cameraProjection` setting: set that, not this. Turning orthographic
390
+ * keeps what the view shows at the orbit target, and turning back moves the camera to keep it.
391
+ * @param {'perspective' | 'orthographic' | 'equirectangular'} projection
392
+ */
393
+ applyProjection( projection ) {
394
+
395
+ const camera = this.camera;
396
+ const orthographic = projection === 'orthographic';
397
+ if ( camera.orthographic === orthographic ) return;
398
+
399
+ if ( orthographic ) {
400
+
401
+ this._setView( true, this._fittedHalfHeight() );
402
+
403
+ } else {
404
+
405
+ const target = this.controls.target;
406
+ const distance = this.orthoHeight / 2 / Math.tan( MathUtils.degToRad( camera.fov ) / 2 );
407
+ camera.position.sub( target ).setLength( distance ).add( target );
408
+ this._setView( false );
409
+
410
+ }
411
+
412
+ this.controls.update();
413
+
414
+ }
415
+
416
+ /** The height of the view in world units while orthographic, the wheel's zoom included; null otherwise. */
417
+ get orthoHeight() {
418
+
419
+ return orthoHeightOf( this.camera );
420
+
421
+ }
422
+
423
+ /** @param {number} height - the orthographic view's height, in world units */
424
+ setOrthoHeight( height ) {
425
+
426
+ if ( ! ( height > 0 ) ) return;
427
+ this._setView( this.camera.orthographic, height / 2 );
428
+ this._onReset?.();
429
+
430
+ }
431
+
432
+ /** Sizes an orthographic view to show what a perspective one would at the orbit target. */
433
+ fitOrthographic() {
434
+
435
+ if ( this.camera.orthographic ) this.setOrthoHeight( 2 * this._fittedHalfHeight() );
436
+
437
+ }
438
+
439
+ // The one writer of the camera's projection. The wheel's zoom starts over.
440
+ _setView( orthographic, halfHeight = this.camera.orthoHalfHeight ) {
441
+
442
+ const camera = this.camera;
443
+ camera.orthographic = orthographic;
444
+ camera.orthoHalfHeight = halfHeight;
445
+ camera.zoom = 1;
446
+ camera.updateProjectionMatrix();
447
+ this._reportOrthoHeight();
448
+
449
+ }
450
+
451
+ // Half of what a perspective view shows at the orbit target.
452
+ _fittedHalfHeight() {
453
+
454
+ return this.camera.position.distanceTo( this.controls.target ) * Math.tan( MathUtils.degToRad( this.camera.fov ) / 2 );
455
+
456
+ }
457
+
458
+ _reportOrthoHeight() {
459
+
460
+ if ( ! this.camera.orthographic ) return;
461
+ const height = this.orthoHeight;
462
+ if ( height === this._reportedOrthoHeight ) return;
463
+ this._reportedOrthoHeight = height;
464
+ this.dispatchEvent( { type: EngineEvents.ORTHO_HEIGHT_UPDATED, height } );
349
465
 
350
466
  }
351
467
 
@@ -362,9 +478,88 @@ export class CameraManager extends EventDispatcher {
362
478
 
363
479
  }
364
480
 
481
+ /**
482
+ * Orbit around a target, or walk through the scene first-person (see {@link WalkControls}).
483
+ * Leaving walk mode orbits around the surface at the centre of the view.
484
+ * @param {'orbit' | 'walk'} mode
485
+ */
486
+ setNavigationMode( mode ) {
487
+
488
+ const walk = mode === 'walk';
489
+ if ( this.walkControls.enabled === walk ) return;
490
+
491
+ const controls = this.controls;
492
+ controls.enableRotate = controls.enablePan = controls.enableZoom = ! walk;
493
+ this.walkControls.enabled = walk;
494
+ if ( walk ) return;
495
+
496
+ this.walkControls.release();
497
+ const hit = this.interactionManager?.pickSurface( 0, 0 );
498
+ if ( ! hit ) return;
499
+
500
+ const distance = MathUtils.clamp( hit.distance, controls.minDistance, controls.maxDistance );
501
+ controls.target.copy( this.camera.position ).addScaledVector( this.camera.getWorldDirection( new Vector3() ), distance );
502
+ controls.update();
503
+
504
+ }
505
+
506
+ /** @returns {'orbit' | 'walk'} */
507
+ get navigationMode() {
508
+
509
+ return this.walkControls.enabled ? 'walk' : 'orbit';
510
+
511
+ }
512
+
513
+ /** Per frame, before rendering. */
514
+ updateControls() {
515
+
516
+ this.walkControls.update();
517
+ this.controls.update();
518
+
519
+ }
520
+
521
+ /**
522
+ * The current view as a pose: where the camera is, what it looks at, and how much it shows — the
523
+ * height an orthographic view covers, or what a perspective one covers at its orbit target.
524
+ * @returns {import('./timeline/CameraTrack.js').CameraPose}
525
+ */
526
+ captureView() {
527
+
528
+ const camera = this.camera;
529
+ const orthoHeight = this.orthoHeight ?? 2 * this._fittedHalfHeight();
530
+ return { position: camera.position.clone(), target: this.controls.target.clone(), fov: camera.fov, orthoHeight };
531
+
532
+ }
533
+
534
+ /**
535
+ * Puts the camera at a pose, looking at its target, sized for the projection in use.
536
+ * @param {import('./timeline/CameraTrack.js').CameraPose} pose
537
+ */
538
+ applyPose( pose ) {
539
+
540
+ const camera = this.camera;
541
+ camera.position.copy( pose.position );
542
+ camera.lookAt( pose.target );
543
+ this.controls.target.copy( pose.target );
544
+
545
+ if ( camera.orthographic ) {
546
+
547
+ this._setView( true, pose.orthoHeight / 2 );
548
+
549
+ } else {
550
+
551
+ camera.fov = pose.fov;
552
+ camera.updateProjectionMatrix();
553
+
554
+ }
555
+
556
+ camera.updateMatrixWorld();
557
+
558
+ }
559
+
365
560
  // ── Aliases (match Sub-API surface) ───────────────────────────
366
561
 
367
- /** The active Three.js PerspectiveCamera. */
562
+ /** The active camera, a {@link ViewCamera}. */
368
563
  get active() {
369
564
 
370
565
  return this.camera;
@@ -393,6 +588,15 @@ export class CameraManager extends EventDispatcher {
393
588
 
394
589
  }
395
590
 
591
+ /** Forget what auto-focus last measured: it belonged to another model or viewpoint. */
592
+ resetAutoFocus() {
593
+
594
+ this._lastValidFocusDistance = null;
595
+ this._smoothedFocusDistance = null;
596
+ this._afPointDirty = true;
597
+
598
+ }
599
+
396
600
  setAFScreenPoint( x, y ) {
397
601
 
398
602
  this.afScreenPoint = { x, y };
@@ -420,9 +624,7 @@ export class CameraManager extends EventDispatcher {
420
624
  * Per-frame auto-focus update. Called in animate() before pipeline.render().
421
625
  *
422
626
  * @param {Object} params
423
- * @param {import('three').Scene} params.meshScene
424
627
  * @param {Object} params.assetLoader
425
- * @param {import('three').Mesh} params.floorPlane
426
628
  * @param {number} params.currentFocusDistance
427
629
  * @param {import('../Stages/PathTracer.js').PathTracer} params.pathTracer
428
630
  * @param {Function} params.setFocusDistance - Callback to update uniform + settings
@@ -431,16 +633,18 @@ export class CameraManager extends EventDispatcher {
431
633
  */
432
634
  updateAutoFocus( ctx ) {
433
635
 
434
- const { meshScene, assetLoader, floorPlane, currentFocusDistance, pathTracer, setFocusDistance, softReset, hardReset } = ctx || this._afContext || {};
435
- if ( ! meshScene ) return;
636
+ const context = ctx || this._afContext;
637
+ if ( ! context || ! this.interactionManager ) return;
638
+ const { assetLoader, currentFocusDistance, pathTracer, setFocusDistance, softReset, hardReset } = context;
436
639
 
437
640
  if ( this.autoFocusMode === 'manual' ) return;
438
641
 
439
642
  // Depth-of-field is the only consumer of the auto-focus distance. With DOF
440
643
  // off (the default) the per-frame scene raycast is pure waste, so skip it.
441
- // Re-snap on the frame DOF turns back on so focus is correct immediately
644
+ // A panorama pauses it too: Raycaster.setFromCamera only knows a frustum.
645
+ // Re-snap on the frame it resumes so focus is correct immediately
442
646
  // rather than racking from a stale smoothed value.
443
- if ( ! pathTracer?.enableDOF?.value ) {
647
+ if ( ! pathTracer?.enableDOF?.value || pathTracer.cameraProjection?.value === CAMERA_PROJECTION_IDS.equirectangular ) {
444
648
 
445
649
  this._afSuspended = true;
446
650
  return;
@@ -461,27 +665,13 @@ export class CameraManager extends EventDispatcher {
461
665
  && stage.frameCount > 0
462
666
  && ! stage.isComplete ) return;
463
667
 
464
- // Convert AF screen point (normalized 0-1) to NDC (-1 to 1)
465
- const ndcX = this.afScreenPoint.x * 2 - 1;
466
- const ndcY = - ( this.afScreenPoint.y * 2 - 1 );
467
-
468
- const raycaster = this.interactionManager?.raycaster;
469
- if ( ! raycaster ) return;
470
-
471
- raycaster.setFromCamera( { x: ndcX, y: ndcY }, this.camera );
472
- const intersects = raycaster.intersectObjects( meshScene.children, true );
473
-
474
- const validHit = intersects.find( hit =>
475
- hit.object !== this.interactionManager?.focusPointIndicator &&
476
- hit.object !== floorPlane &&
477
- ! hit.object.name.includes( 'Helper' ) &&
478
- hit.object.type === 'Mesh'
479
- );
668
+ // AF screen point (0 to 1, y down) to NDC.
669
+ const validHit = this.interactionManager.pickSurface( this.afScreenPoint.x * 2 - 1, 1 - this.afScreenPoint.y * 2 );
480
670
 
481
671
  let rawDistance;
482
672
  if ( validHit ) {
483
673
 
484
- rawDistance = validHit.distance;
674
+ rawDistance = viewDepth( validHit.point, this.camera );
485
675
  this._lastValidFocusDistance = rawDistance;
486
676
 
487
677
  } else {
@@ -492,8 +682,9 @@ export class CameraManager extends EventDispatcher {
492
682
 
493
683
  } else {
494
684
 
495
- const scale = assetLoader?.getSceneScale() || 1.0;
496
- rawDistance = AF_DEFAULTS.FALLBACK_DISTANCE * scale;
685
+ // Nothing under the AF point on a fresh view: focus where the camera orbits.
686
+ const depth = this.controls ? viewDepth( this.controls.target, this.camera ) : 0;
687
+ rawDistance = depth > 0 ? depth : AF_DEFAULTS.FALLBACK_DISTANCE * ( assetLoader?.getSceneScale() || 1.0 );
497
688
  this._lastValidFocusDistance = rawDistance;
498
689
 
499
690
  }
@@ -533,9 +724,8 @@ export class CameraManager extends EventDispatcher {
533
724
 
534
725
  setFocusDistance( newFocus );
535
726
 
536
- // Update store for UI display (unscaled value)
537
727
  const scale = assetLoader?.getSceneScale() || 1.0;
538
- this.dispatchEvent( { type: EngineEvents.AUTO_FOCUS_UPDATED, distance: newFocus / scale } );
728
+ this.dispatchEvent( { type: EngineEvents.AUTO_FOCUS_UPDATED, distance: newFocus / scale, worldDistance: newFocus } );
539
729
 
540
730
  const changeRatio = Math.abs( newFocus - prevFocus ) / Math.max( prevFocus, 0.001 );
541
731
  if ( forceReset ) {
@@ -569,20 +759,16 @@ export class CameraManager extends EventDispatcher {
569
759
  * and `updateAutoFocus()` reads from it each frame — no per-frame allocation.
570
760
  *
571
761
  * @param {Object} deps
572
- * @param {import('three').Scene} deps.meshScene
573
762
  * @param {import('../Processor/AssetLoader.js').AssetLoader} deps.assetLoader
574
- * @param {import('three').Mesh} deps.floorPlane
575
763
  * @param {import('../Stages/PathTracer.js').PathTracer} deps.pathTracer
576
764
  * @param {import('../RenderSettings.js').RenderSettings} deps.settings
577
765
  * @param {Function} deps.softReset
578
766
  * @param {Function} deps.hardReset
579
767
  */
580
- initAutoFocus( { meshScene, assetLoader, floorPlane, pathTracer, settings, softReset, hardReset } ) {
768
+ initAutoFocus( { assetLoader, pathTracer, settings, softReset, hardReset } ) {
581
769
 
582
770
  this._afContext = {
583
- meshScene,
584
771
  assetLoader,
585
- floorPlane,
586
772
  pathTracer,
587
773
  setFocusDistance: ( d ) => settings.set( 'focusDistance', d, { silent: true } ),
588
774
  softReset,
@@ -598,6 +784,8 @@ export class CameraManager extends EventDispatcher {
598
784
 
599
785
  dispose() {
600
786
 
787
+ this.walkControls?.dispose();
788
+ this.controls?.removeEventListener( 'change', this._reportOrthoHeight );
601
789
  this.controls?.dispose();
602
790
 
603
791
  }
@@ -8,7 +8,7 @@ import { createLogger } from '../utils/Logger.js';
8
8
  // The neural passes live in `../neural/` but report through the manager that drives them, so they
9
9
  // share one namespace: `rayzee.log.only( 'neural' )` shows the whole chain.
10
10
  const neuralLog = createLogger( 'neural' );
11
- import { ENGINE_DEFAULTS as DEFAULT_STATE, ASVGF_QUALITY_PRESETS, NRD_DEFAULTS, NRD_QUALITY_PRESETS, NRD_PRESET_KEYS } from '../EngineDefaults.js';
11
+ import { ENGINE_DEFAULTS as DEFAULT_STATE, ASVGF_QUALITY_PRESETS, NRD_DEFAULTS, NRD_QUALITY_PRESETS, NRD_PRESET_KEYS, CAMERA_PROJECTION_IDS } from '../EngineDefaults.js';
12
12
 
13
13
  // A refresh slower than this is a slideshow, not a live view, so the cadence swaps to a cheaper
14
14
  // model and puts the chosen one back for the finished image. Resolution-aware by construction:
@@ -164,7 +164,7 @@ export class DenoisingManager extends EventDispatcher {
164
164
  this._knownResetCount = 0;
165
165
  this._seenTracedFrames = 0;
166
166
  this._viewProj = new Matrix4();
167
- this._historyCamera = { world: null, projInv: null, viewProj: null };
167
+ this._historyCamera = { world: null, projInv: null, viewProj: null, orthographic: false };
168
168
  // TLAS leaf → { world, offset, prev }: placements moved since the last traced frame.
169
169
  this._movedPlacements = new Map();
170
170
  this._movedUpload = { count: 0, leaves: new Uint32Array( 0 ), toPrev: new Float32Array( 0 ) };
@@ -870,7 +870,7 @@ export class DenoisingManager extends EventDispatcher {
870
870
 
871
871
  const pt = this._stages.pathTracer;
872
872
  return this.historyWanted && ! this._cadenceSuspended && !! this._stages.normalDepth?.enabled
873
- && ( pt?.uniforms?.get( 'cameraProjection' )?.value ?? 0 ) === 0
873
+ && pt?.uniforms?.get( 'cameraProjection' )?.value !== CAMERA_PROJECTION_IDS.equirectangular
874
874
  && ! ( pt?.visMode?.value > 0 );
875
875
 
876
876
  }
@@ -1043,6 +1043,7 @@ export class DenoisingManager extends EventDispatcher {
1043
1043
  camera.world = pt.cameraWorldMatrix.value.elements;
1044
1044
  camera.projInv = pt.cameraProjectionMatrixInverse.value.elements;
1045
1045
  camera.viewProj = this._viewProj.elements;
1046
+ camera.orthographic = pt.uniforms?.get( 'cameraProjection' )?.value === CAMERA_PROJECTION_IDS.orthographic;
1046
1047
  return camera;
1047
1048
 
1048
1049
  }
@@ -3,9 +3,28 @@ import {
3
3
  Raycaster,
4
4
  SphereGeometry,
5
5
  Mesh,
6
- MeshBasicMaterial
6
+ MeshBasicMaterial,
7
+ Vector3
7
8
  } from 'three';
8
9
  import { EngineEvents } from '../EngineEvents.js';
10
+ import { CAMERA_PROJECTION_IDS } from '../EngineDefaults.js';
11
+
12
+ const _viewAxis = new Vector3();
13
+ const _toPoint = new Vector3();
14
+
15
+ /** Depth of a point along the camera's view axis — the distance the flat focal plane is measured in. */
16
+ export function viewDepth( point, camera ) {
17
+
18
+ return _toPoint.subVectors( point, camera.position ).dot( camera.getWorldDirection( _viewAxis ) );
19
+
20
+ }
21
+
22
+ /** The focus distance that puts a raycast hit in focus: a panorama focuses along each ray, not on a plane. */
23
+ export function focusDistanceOfHit( hit, camera, panorama = false ) {
24
+
25
+ return panorama ? hit.distance : viewDepth( hit.point, camera );
26
+
27
+ }
9
28
 
10
29
  /**
11
30
  * InteractionManager
@@ -207,7 +226,8 @@ export class InteractionManager extends EventDispatcher {
207
226
  if ( intersects.length > 0 ) {
208
227
 
209
228
  const intersection = intersects[ 0 ];
210
- const distance = intersection.distance;
229
+ const panorama = this.pathTracer?.cameraProjection?.value === CAMERA_PROJECTION_IDS.equirectangular;
230
+ const distance = focusDistanceOfHit( intersection, this.camera, panorama );
211
231
 
212
232
  // Show visual indicator at focus point
213
233
  this.showFocusPoint( intersection.point );
@@ -675,6 +695,14 @@ export class InteractionManager extends EventDispatcher {
675
695
 
676
696
  }
677
697
 
698
+ /** The nearest scene surface under an NDC point, skipping the floor and helpers. */
699
+ pickSurface( ndcX, ndcY ) {
700
+
701
+ this.raycaster.setFromCamera( { x: ndcX, y: ndcY }, this.camera );
702
+ return this.filterValidIntersects( this.raycaster.intersectObjects( this.scene.children, true ) )[ 0 ] ?? null;
703
+
704
+ }
705
+
678
706
  /**
679
707
  * Filter intersections to exclude helper objects and floor plane
680
708
  * @private
@@ -8,7 +8,7 @@
8
8
  import { uniform, uniformArray } from 'three/tsl';
9
9
  import { Vector2, Matrix4, Vector3, Color, MathUtils } from 'three';
10
10
  import { samplingTechniqueUniform } from '../TSL/Random.js';
11
- import { ENGINE_DEFAULTS as DEFAULT_STATE } from '../EngineDefaults.js';
11
+ import { ENGINE_DEFAULTS as DEFAULT_STATE, CAMERA_PROJECTION_IDS } from '../EngineDefaults.js';
12
12
 
13
13
  /**
14
14
  * Map of uniform names to their WGSL shader names (where different).
@@ -278,9 +278,9 @@ export class UniformManager {
278
278
  u( 'cameraViewMatrix', new Matrix4(), 'mat4' );
279
279
  u( 'cameraProjectionMatrix', new Matrix4(), 'mat4' );
280
280
 
281
- // Projection: 0 = pinhole, 1 = equirectangular panorama. Ranges are radians (UI carries degrees).
281
+ // Projection: CAMERA_PROJECTION_IDS. Ranges are radians (UI carries degrees).
282
282
  const radRange = ( [ min, max ] ) => new Vector2( min, max ).multiplyScalar( MathUtils.DEG2RAD );
283
- u( 'cameraProjection', DEFAULT_STATE.cameraProjection === 'equirectangular' ? 1 : 0, 'int' );
283
+ u( 'cameraProjection', CAMERA_PROJECTION_IDS[ DEFAULT_STATE.cameraProjection ], 'int' );
284
284
  u( 'panoLonRange', radRange( DEFAULT_STATE.panoramaLonRange ), 'vec2' );
285
285
  u( 'panoLatRange', radRange( DEFAULT_STATE.panoramaLatRange ), 'vec2' );
286
286
  ub( 'panoLevelHorizon', DEFAULT_STATE.panoramaLevelHorizon );
@@ -292,7 +292,9 @@ export class UniformManager {
292
292
  u( 'aperture', DEFAULT_STATE.aperture, 'float' );
293
293
  u( 'apertureScale', 1.0, 'float' );
294
294
  u( 'anamorphicRatio', DEFAULT_STATE.anamorphicRatio ?? 1.0, 'float' );
295
- u( 'sceneScale', 1.0, 'float' );
295
+ u( 'unitsPerMetre', DEFAULT_STATE.unitsPerMetre, 'float' );
296
+ u( 'dofMode', DEFAULT_STATE.dofMode === 'look' ? 1 : 0, 'int' );
297
+ u( 'dofBlur', DEFAULT_STATE.dofBlur, 'float' );
296
298
 
297
299
  // Sampling — use the module-level uniform from Random.js so TSL sees the same node
298
300
  this._uniforms.set( 'samplingTechnique', samplingTechniqueUniform );