Skip to content

Commit 98e4fc8

Browse files
authored
OrbitControls: Add zoom to cursor (#26165)
* Add initial support for zoom to cursor * simplify implementation, remove ortho special case * Handle mouse dolly * Support planar movement, add gui option * Use relative positioning * Fix ortho camera zooming * auto disable zoom to cursor * Handle incorrect camera case differently * Use cached objects * Fix corner case where camera starts at target position * Use a common clamp function * Fix copy paste error * Add a flag for when to perform the zoom to cursor behavior to avoid using a stale ray with pinch * Limit target movement at steep angles * Add constant for tilt amount * Update comment * Fix relative cursor position
1 parent b08acb8 commit 98e4fc8

File tree

2 files changed

+144
-21
lines changed

2 files changed

+144
-21
lines changed

examples/jsm/controls/OrbitControls.js

Lines changed: 143 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import {
55
Spherical,
66
TOUCH,
77
Vector2,
8-
Vector3
8+
Vector3,
9+
Plane,
10+
Ray,
11+
MathUtils
912
} from 'three';
1013

1114
// OrbitControls performs orbiting, dollying (zooming), and panning.
@@ -18,6 +21,9 @@ import {
1821
const _changeEvent = { type: 'change' };
1922
const _startEvent = { type: 'start' };
2023
const _endEvent = { type: 'end' };
24+
const _ray = new Ray();
25+
const _plane = new Plane();
26+
const TILT_LIMIT = Math.cos( 70 * MathUtils.DEG2RAD );
2127

2228
class OrbitControls extends EventDispatcher {
2329

@@ -72,6 +78,7 @@ class OrbitControls extends EventDispatcher {
7278
this.panSpeed = 1.0;
7379
this.screenSpacePanning = true; // if false, pan orthogonal to world-space direction camera.up
7480
this.keyPanSpeed = 7.0; // pixels moved per arrow key push
81+
this.zoomToCursor = false;
7582

7683
// Set to true to automatically rotate around the target
7784
// If auto-rotate is enabled, you must call controls.update() in your animation loop
@@ -230,11 +237,6 @@ class OrbitControls extends EventDispatcher {
230237
spherical.makeSafe();
231238

232239

233-
spherical.radius *= scale;
234-
235-
// restrict radius to be between desired limits
236-
spherical.radius = Math.max( scope.minDistance, Math.min( scope.maxDistance, spherical.radius ) );
237-
238240
// move target to panned location
239241

240242
if ( scope.enableDamping === true ) {
@@ -247,6 +249,19 @@ class OrbitControls extends EventDispatcher {
247249

248250
}
249251

252+
// adjust the camera position based on zoom only if we're not zooming to the cursor or if it's an ortho camera
253+
// we adjust zoom later in these cases
254+
if ( scope.zoomToCursor && performCursorZoom || scope.object.isOrthographicCamera ) {
255+
256+
spherical.radius = clampDistance( spherical.radius );
257+
258+
} else {
259+
260+
spherical.radius = clampDistance( spherical.radius * scale );
261+
262+
}
263+
264+
250265
offset.setFromSpherical( spherical );
251266

252267
// rotate offset back to "camera-up-vector-is-up" space
@@ -271,7 +286,91 @@ class OrbitControls extends EventDispatcher {
271286

272287
}
273288

289+
// adjust camera position
290+
let zoomChanged = false;
291+
if ( scope.zoomToCursor && performCursorZoom ) {
292+
293+
let newRadius = null;
294+
if ( scope.object.isPerspectiveCamera ) {
295+
296+
// move the camera down the pointer ray
297+
// this method avoids floating point error
298+
const prevRadius = offset.length();
299+
newRadius = clampDistance( prevRadius * scale );
300+
301+
const radiusDelta = prevRadius - newRadius;
302+
scope.object.position.addScaledVector( dollyDirection, radiusDelta );
303+
scope.object.updateMatrixWorld();
304+
305+
} else if ( scope.object.isOrthographicCamera ) {
306+
307+
// adjust the ortho camera position based on zoom changes
308+
const mouseBefore = new Vector3( mouse.x, mouse.y, 0 );
309+
mouseBefore.unproject( scope.object );
310+
311+
scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / scale ) );
312+
scope.object.updateProjectionMatrix();
313+
zoomChanged = true;
314+
315+
const mouseAfter = new Vector3( mouse.x, mouse.y, 0 );
316+
mouseAfter.unproject( scope.object );
317+
318+
scope.object.position.sub( mouseAfter ).add( mouseBefore );
319+
scope.object.updateMatrixWorld();
320+
321+
newRadius = offset.length();
322+
323+
} else {
324+
325+
console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - zoom to cursor disabled.' );
326+
scope.zoomToCursor = false;
327+
328+
}
329+
330+
// handle the placement of the target
331+
if ( newRadius !== null ) {
332+
333+
if ( this.screenSpacePanning ) {
334+
335+
// position the orbit target in front of the new camera position
336+
scope.target.set( 0, 0, - 1 )
337+
.transformDirection( scope.object.matrix )
338+
.multiplyScalar( newRadius )
339+
.add( scope.object.position );
340+
341+
} else {
342+
343+
// get the ray and translation plane to compute target
344+
_ray.origin.copy( scope.object.position );
345+
_ray.direction.set( 0, 0, - 1 ).transformDirection( scope.object.matrix );
346+
347+
// if the camera is 20 degrees above the horizon then don't adjust the focus target to avoid
348+
// extremely large values
349+
if ( Math.abs( scope.object.up.dot( _ray.direction ) ) < TILT_LIMIT ) {
350+
351+
object.lookAt( scope.target );
352+
353+
} else {
354+
355+
_plane.setFromNormalAndCoplanarPoint( scope.object.up, scope.target );
356+
_ray.intersectPlane( _plane, scope.target );
357+
358+
}
359+
360+
}
361+
362+
}
363+
364+
} else if ( scope.object.isOrthographicCamera ) {
365+
366+
scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / scale ) );
367+
scope.object.updateProjectionMatrix();
368+
zoomChanged = true;
369+
370+
}
371+
274372
scale = 1;
373+
performCursorZoom = false;
275374

276375
// update condition is:
277376
// min(camera displacement, camera rotation in radians)^2 > EPS
@@ -350,7 +449,6 @@ class OrbitControls extends EventDispatcher {
350449

351450
let scale = 1;
352451
const panOffset = new Vector3();
353-
let zoomChanged = false;
354452

355453
const rotateStart = new Vector2();
356454
const rotateEnd = new Vector2();
@@ -364,6 +462,10 @@ class OrbitControls extends EventDispatcher {
364462
const dollyEnd = new Vector2();
365463
const dollyDelta = new Vector2();
366464

465+
const dollyDirection = new Vector3();
466+
const mouse = new Vector2();
467+
let performCursorZoom = false;
468+
367469
const pointers = [];
368470
const pointerPositions = {};
369471

@@ -474,16 +576,10 @@ class OrbitControls extends EventDispatcher {
474576

475577
function dollyOut( dollyScale ) {
476578

477-
if ( scope.object.isPerspectiveCamera ) {
579+
if ( scope.object.isPerspectiveCamera || scope.object.isOrthographicCamera ) {
478580

479581
scale /= dollyScale;
480582

481-
} else if ( scope.object.isOrthographicCamera ) {
482-
483-
scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom * dollyScale ) );
484-
scope.object.updateProjectionMatrix();
485-
zoomChanged = true;
486-
487583
} else {
488584

489585
console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
@@ -495,16 +591,10 @@ class OrbitControls extends EventDispatcher {
495591

496592
function dollyIn( dollyScale ) {
497593

498-
if ( scope.object.isPerspectiveCamera ) {
594+
if ( scope.object.isPerspectiveCamera || scope.object.isOrthographicCamera ) {
499595

500596
scale *= dollyScale;
501597

502-
} else if ( scope.object.isOrthographicCamera ) {
503-
504-
scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / dollyScale ) );
505-
scope.object.updateProjectionMatrix();
506-
zoomChanged = true;
507-
508598
} else {
509599

510600
console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
@@ -514,6 +604,35 @@ class OrbitControls extends EventDispatcher {
514604

515605
}
516606

607+
function updateMouseParameters( event ) {
608+
609+
if ( ! scope.zoomToCursor ) {
610+
611+
return;
612+
613+
}
614+
615+
performCursorZoom = true;
616+
617+
const rect = scope.domElement.getBoundingClientRect();
618+
const x = event.clientX - rect.left;
619+
const y = event.clientY - rect.top;
620+
const w = rect.width;
621+
const h = rect.height;
622+
623+
mouse.x = ( x / w ) * 2 - 1;
624+
mouse.y = - ( y / h ) * 2 + 1;
625+
626+
dollyDirection.set( mouse.x, mouse.y, 1 ).unproject( object ).sub( object.position ).normalize();
627+
628+
}
629+
630+
function clampDistance( dist ) {
631+
632+
return Math.max( scope.minDistance, Math.min( scope.maxDistance, dist ) );
633+
634+
}
635+
517636
//
518637
// event callbacks - update the object state
519638
//
@@ -526,6 +645,7 @@ class OrbitControls extends EventDispatcher {
526645

527646
function handleMouseDownDolly( event ) {
528647

648+
updateMouseParameters( event );
529649
dollyStart.set( event.clientX, event.clientY );
530650

531651
}
@@ -592,6 +712,8 @@ class OrbitControls extends EventDispatcher {
592712

593713
function handleMouseWheel( event ) {
594714

715+
updateMouseParameters( event );
716+
595717
if ( event.deltaY < 0 ) {
596718

597719
dollyIn( getZoomScale() );

examples/misc_controls_map.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@
120120

121121

122122
const gui = new GUI();
123+
gui.add( controls, 'zoomToCursor' );
123124
gui.add( controls, 'screenSpacePanning' );
124125

125126
}

0 commit comments

Comments
 (0)