Skip to content

Commit 6472f69

Browse files
feat(OrbitControls): zoomToCursor (#286)
1 parent 68612f9 commit 6472f69

File tree

1 file changed

+122
-15
lines changed

1 file changed

+122
-15
lines changed

src/controls/OrbitControls.ts

Lines changed: 122 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,15 @@ import {
99
TOUCH,
1010
Vector2,
1111
Vector3,
12+
Ray,
13+
Plane,
14+
MathUtils,
1215
} from 'three'
1316

17+
const _ray = /* @__PURE__ */ new Ray()
18+
const _plane = /* @__PURE__ */ new Plane()
19+
const TILT_LIMIT = Math.cos(70 * MathUtils.DEG2RAD)
20+
1421
// This set of controls performs orbiting, dollying (zooming), and panning.
1522
// Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default).
1623
//
@@ -57,6 +64,7 @@ class OrbitControls extends EventDispatcher {
5764
panSpeed = 1.0
5865
screenSpacePanning = true // if false, pan orthogonal to world-space direction camera.up
5966
keyPanSpeed = 7.0 // pixels moved per arrow key push
67+
zoomToCursor = false
6068
// Set to true to automatically rotate around the target
6169
// If auto-rotate is enabled, you must call controls.update() in your animation loop
6270
autoRotate = false
@@ -255,10 +263,6 @@ class OrbitControls extends EventDispatcher {
255263
// restrict phi to be between desired limits
256264
spherical.phi = Math.max(scope.minPolarAngle, Math.min(scope.maxPolarAngle, spherical.phi))
257265
spherical.makeSafe()
258-
spherical.radius *= scale
259-
260-
// restrict radius to be between desired limits
261-
spherical.radius = Math.max(scope.minDistance, Math.min(scope.maxDistance, spherical.radius))
262266

263267
// move target to panned location
264268

@@ -268,6 +272,17 @@ class OrbitControls extends EventDispatcher {
268272
scope.target.add(panOffset)
269273
}
270274

275+
// adjust the camera position based on zoom only if we're not zooming to the cursor or if it's an ortho camera
276+
// we adjust zoom later in these cases
277+
if (
278+
(scope.zoomToCursor && performCursorZoom) ||
279+
(scope.object as THREE.OrthographicCamera).isOrthographicCamera
280+
) {
281+
spherical.radius = clampDistance(spherical.radius)
282+
} else {
283+
spherical.radius = clampDistance(spherical.radius * scale)
284+
}
285+
271286
offset.setFromSpherical(spherical)
272287

273288
// rotate offset back to "camera-up-vector-is-up" space
@@ -288,7 +303,72 @@ class OrbitControls extends EventDispatcher {
288303
panOffset.set(0, 0, 0)
289304
}
290305

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

293373
// update condition is:
294374
// min(camera displacement, camera rotation in radians)^2 > EPS
@@ -374,7 +454,6 @@ class OrbitControls extends EventDispatcher {
374454

375455
let scale = 1
376456
const panOffset = new Vector3()
377-
let zoomChanged = false
378457

379458
const rotateStart = new Vector2()
380459
const rotateEnd = new Vector2()
@@ -388,6 +467,10 @@ class OrbitControls extends EventDispatcher {
388467
const dollyEnd = new Vector2()
389468
const dollyDelta = new Vector2()
390469

470+
const dollyDirection = new Vector3()
471+
const mouse = new Vector2()
472+
let performCursorZoom = false
473+
391474
const pointers: PointerEvent[] = []
392475
const pointerPositions: { [key: string]: Vector2 } = {}
393476

@@ -481,31 +564,52 @@ class OrbitControls extends EventDispatcher {
481564
})()
482565

483566
function dollyOut(dollyScale: number) {
484-
if (scope.object instanceof PerspectiveCamera && scope.object.isPerspectiveCamera) {
567+
if (
568+
(scope.object instanceof PerspectiveCamera && scope.object.isPerspectiveCamera) ||
569+
(scope.object instanceof OrthographicCamera && scope.object.isOrthographicCamera)
570+
) {
485571
scale /= dollyScale
486-
} else if (scope.object instanceof OrthographicCamera && scope.object.isOrthographicCamera) {
487-
scope.object.zoom = Math.max(scope.minZoom, Math.min(scope.maxZoom, scope.object.zoom * dollyScale))
488-
scope.object.updateProjectionMatrix()
489-
zoomChanged = true
490572
} else {
491573
console.warn('WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.')
492574
scope.enableZoom = false
493575
}
494576
}
495577

496578
function dollyIn(dollyScale: number) {
497-
if (scope.object instanceof PerspectiveCamera && scope.object.isPerspectiveCamera) {
579+
if (
580+
(scope.object instanceof PerspectiveCamera && scope.object.isPerspectiveCamera) ||
581+
(scope.object instanceof OrthographicCamera && scope.object.isOrthographicCamera)
582+
) {
498583
scale *= dollyScale
499-
} else if (scope.object instanceof OrthographicCamera && scope.object.isOrthographicCamera) {
500-
scope.object.zoom = Math.max(scope.minZoom, Math.min(scope.maxZoom, scope.object.zoom / dollyScale))
501-
scope.object.updateProjectionMatrix()
502-
zoomChanged = true
503584
} else {
504585
console.warn('WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.')
505586
scope.enableZoom = false
506587
}
507588
}
508589

590+
function updateMouseParameters(event: MouseEvent): void {
591+
if (!scope.zoomToCursor || !scope.domElement) {
592+
return
593+
}
594+
595+
performCursorZoom = true
596+
597+
const rect = scope.domElement.getBoundingClientRect()
598+
const x = event.clientX - rect.left
599+
const y = event.clientY - rect.top
600+
const w = rect.width
601+
const h = rect.height
602+
603+
mouse.x = (x / w) * 2 - 1
604+
mouse.y = -(y / h) * 2 + 1
605+
606+
dollyDirection.set(mouse.x, mouse.y, 1).unproject(scope.object).sub(scope.object.position).normalize()
607+
}
608+
609+
function clampDistance(dist: number): number {
610+
return Math.max(scope.minDistance, Math.min(scope.maxDistance, dist))
611+
}
612+
509613
//
510614
// event callbacks - update the object state
511615
//
@@ -515,6 +619,7 @@ class OrbitControls extends EventDispatcher {
515619
}
516620

517621
function handleMouseDownDolly(event: MouseEvent) {
622+
updateMouseParameters(event)
518623
dollyStart.set(event.clientX, event.clientY)
519624
}
520625

@@ -559,6 +664,8 @@ class OrbitControls extends EventDispatcher {
559664
}
560665

561666
function handleMouseWheel(event: WheelEvent) {
667+
updateMouseParameters(event)
668+
562669
if (event.deltaY < 0) {
563670
dollyIn(getZoomScale())
564671
} else if (event.deltaY > 0) {

0 commit comments

Comments
 (0)