Created
May 23, 2023 14:27
-
-
Save boouee/ae8d2e132de9e25faa72f817f8564b94 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
EventDispatcher, | |
MathUtils, | |
MOUSE, | |
Quaternion, | |
Vector2, | |
Vector3 | |
} from 'three'; | |
const _changeEvent = { type: 'change' }; | |
const _startEvent = { type: 'start' }; | |
const _endEvent = { type: 'end' }; | |
class TrackballControls extends EventDispatcher { | |
constructor( object, domElement ) { | |
super(); | |
const scope = this; | |
const STATE = { NONE: - 1, ROTATE: 0, ZOOM: 1, PAN: 2, TOUCH_ROTATE: 3, TOUCH_ZOOM_PAN: 4 }; | |
this.object = object; | |
this.domElement = domElement; | |
this.domElement.style.touchAction = 'none'; // disable touch scroll | |
// API | |
this.enabled = true; | |
this.screen = { left: 0, top: 0, width: 0, height: 0 }; | |
this.rotateSpeed = 1.0; | |
this.zoomSpeed = 1.2; | |
this.panSpeed = 0.3; | |
this.noRotate = false; | |
this.noZoom = false; | |
this.noPan = false; | |
this.staticMoving = false; | |
this.dynamicDampingFactor = 0.2; | |
this.minDistance = 0; | |
this.maxDistance = Infinity; | |
this.minZoom = 0; | |
this.maxZoom = Infinity; | |
this.keys = [ 'KeyA' /*A*/, 'KeyS' /*S*/, 'KeyD' /*D*/ ]; | |
this.mouseButtons = { LEFT: MOUSE.ROTATE, MIDDLE: MOUSE.DOLLY, RIGHT: MOUSE.PAN }; | |
// internals | |
this.target = new Vector3(); | |
const EPS = 0.000001; | |
const lastPosition = new Vector3(); | |
let lastZoom = 1; | |
let _state = STATE.NONE, | |
_keyState = STATE.NONE, | |
_touchZoomDistanceStart = 0, | |
_touchZoomDistanceEnd = 0, | |
_lastAngle = 0; | |
const _eye = new Vector3(), | |
_movePrev = new Vector2(), | |
_moveCurr = new Vector2(), | |
_lastAxis = new Vector3(), | |
_zoomStart = new Vector2(), | |
_zoomEnd = new Vector2(), | |
_panStart = new Vector2(), | |
_panEnd = new Vector2(), | |
_pointers = [], | |
_pointerPositions = {}; | |
// for reset | |
this.target0 = this.target.clone(); | |
this.position0 = this.object.position.clone(); | |
this.up0 = this.object.up.clone(); | |
this.zoom0 = this.object.zoom; | |
// methods | |
this.handleResize = function () { | |
const box = scope.domElement.getBoundingClientRect(); | |
// adjustments come from similar code in the jquery offset() function | |
const d = scope.domElement.ownerDocument.documentElement; | |
scope.screen.left = box.left + window.pageXOffset - d.clientLeft; | |
scope.screen.top = box.top + window.pageYOffset - d.clientTop; | |
scope.screen.width = box.width; | |
scope.screen.height = box.height; | |
}; | |
const getMouseOnScreen = ( function () { | |
const vector = new Vector2(); | |
return function getMouseOnScreen( pageX, pageY ) { | |
vector.set( | |
( pageX - scope.screen.left ) / scope.screen.width, | |
( pageY - scope.screen.top ) / scope.screen.height | |
); | |
return vector; | |
}; | |
}() ); | |
const getMouseOnCircle = ( function () { | |
const vector = new Vector2(); | |
return function getMouseOnCircle( pageX, pageY ) { | |
vector.set( | |
( ( pageX - scope.screen.width * 0.5 - scope.screen.left ) / ( scope.screen.width * 0.5 ) ), | |
( ( scope.screen.height + 2 * ( scope.screen.top - pageY ) ) / scope.screen.width ) // screen.width intentional | |
); | |
return vector; | |
}; | |
}() ); | |
this.rotateCamera = ( function () { | |
const axis = new Vector3(), | |
quaternion = new Quaternion(), | |
eyeDirection = new Vector3(), | |
objectUpDirection = new Vector3(), | |
objectSidewaysDirection = new Vector3(), | |
moveDirection = new Vector3(); | |
return function rotateCamera() { | |
moveDirection.set( _moveCurr.x - _movePrev.x, _moveCurr.y - _movePrev.y, 0 ); | |
let angle = moveDirection.length(); | |
if ( angle ) { | |
_eye.copy( scope.object.position ).sub( scope.target ); | |
eyeDirection.copy( _eye ).normalize(); | |
objectUpDirection.copy( scope.object.up ).normalize(); | |
objectSidewaysDirection.crossVectors( objectUpDirection, eyeDirection ).normalize(); | |
objectUpDirection.setLength( _moveCurr.y - _movePrev.y ); | |
objectSidewaysDirection.setLength( _moveCurr.x - _movePrev.x ); | |
moveDirection.copy( objectUpDirection.add( objectSidewaysDirection ) ); | |
axis.crossVectors( moveDirection, _eye ).normalize(); | |
angle *= scope.rotateSpeed; | |
quaternion.setFromAxisAngle( axis, angle ); | |
_eye.applyQuaternion( quaternion ); | |
scope.object.up.applyQuaternion( quaternion ); | |
_lastAxis.copy( axis ); | |
_lastAngle = angle; | |
} else if ( ! scope.staticMoving && _lastAngle ) { | |
_lastAngle *= Math.sqrt( 1.0 - scope.dynamicDampingFactor ); | |
_eye.copy( scope.object.position ).sub( scope.target ); | |
quaternion.setFromAxisAngle( _lastAxis, _lastAngle ); | |
_eye.applyQuaternion( quaternion ); | |
scope.object.up.applyQuaternion( quaternion ); | |
} | |
_movePrev.copy( _moveCurr ); | |
}; | |
}() ); | |
this.zoomCamera = function () { | |
let factor; | |
if ( _state === STATE.TOUCH_ZOOM_PAN ) { | |
factor = _touchZoomDistanceStart / _touchZoomDistanceEnd; | |
_touchZoomDistanceStart = _touchZoomDistanceEnd; | |
if ( scope.object.isPerspectiveCamera ) { | |
_eye.multiplyScalar( factor ); | |
} else if ( scope.object.isOrthographicCamera ) { | |
scope.object.zoom = MathUtils.clamp( scope.object.zoom / factor, scope.minZoom, scope.maxZoom ); | |
if ( lastZoom !== scope.object.zoom ) { | |
scope.object.updateProjectionMatrix(); | |
} | |
} else { | |
console.warn( 'THREE.TrackballControls: Unsupported camera type' ); | |
} | |
} else { | |
factor = 1.0 + ( _zoomEnd.y - _zoomStart.y ) * scope.zoomSpeed; | |
if ( factor !== 1.0 && factor > 0.0 ) { | |
if ( scope.object.isPerspectiveCamera ) { | |
_eye.multiplyScalar( factor ); | |
} else if ( scope.object.isOrthographicCamera ) { | |
scope.object.zoom = MathUtils.clamp( scope.object.zoom / factor, scope.minZoom, scope.maxZoom ); | |
if ( lastZoom !== scope.object.zoom ) { | |
scope.object.updateProjectionMatrix(); | |
} | |
} else { | |
console.warn( 'THREE.TrackballControls: Unsupported camera type' ); | |
} | |
} | |
if ( scope.staticMoving ) { | |
_zoomStart.copy( _zoomEnd ); | |
} else { | |
_zoomStart.y += ( _zoomEnd.y - _zoomStart.y ) * this.dynamicDampingFactor; | |
} | |
} | |
}; | |
this.panCamera = ( function () { | |
const mouseChange = new Vector2(), | |
objectUp = new Vector3(), | |
pan = new Vector3(); | |
return function panCamera() { | |
mouseChange.copy( _panEnd ).sub( _panStart ); | |
if ( mouseChange.lengthSq() ) { | |
if ( scope.object.isOrthographicCamera ) { | |
const scale_x = ( scope.object.right - scope.object.left ) / scope.object.zoom / scope.domElement.clientWidth; | |
const scale_y = ( scope.object.top - scope.object.bottom ) / scope.object.zoom / scope.domElement.clientWidth; | |
mouseChange.x *= scale_x; | |
mouseChange.y *= scale_y; | |
} | |
mouseChange.multiplyScalar( _eye.length() * scope.panSpeed ); | |
pan.copy( _eye ).cross( scope.object.up ).setLength( mouseChange.x ); | |
pan.add( objectUp.copy( scope.object.up ).setLength( mouseChange.y ) ); | |
scope.object.position.add( pan ); | |
scope.target.add( pan ); | |
if ( scope.staticMoving ) { | |
_panStart.copy( _panEnd ); | |
} else { | |
_panStart.add( mouseChange.subVectors( _panEnd, _panStart ).multiplyScalar( scope.dynamicDampingFactor ) ); | |
} | |
} | |
}; | |
}() ); | |
this.checkDistances = function () { | |
if ( ! scope.noZoom || ! scope.noPan ) { | |
if ( _eye.lengthSq() > scope.maxDistance * scope.maxDistance ) { | |
scope.object.position.addVectors( scope.target, _eye.setLength( scope.maxDistance ) ); | |
_zoomStart.copy( _zoomEnd ); | |
} | |
if ( _eye.lengthSq() < scope.minDistance * scope.minDistance ) { | |
scope.object.position.addVectors( scope.target, _eye.setLength( scope.minDistance ) ); | |
_zoomStart.copy( _zoomEnd ); | |
} | |
} | |
}; | |
this.update = function () { | |
_eye.subVectors( scope.object.position, scope.target ); | |
if ( ! scope.noRotate ) { | |
scope.rotateCamera(); | |
} | |
if ( ! scope.noZoom ) { | |
scope.zoomCamera(); | |
} | |
if ( ! scope.noPan ) { | |
scope.panCamera(); | |
} | |
scope.object.position.addVectors( scope.target, _eye ); | |
if ( scope.object.isPerspectiveCamera ) { | |
scope.checkDistances(); | |
scope.object.lookAt( scope.target ); | |
if ( lastPosition.distanceToSquared( scope.object.position ) > EPS ) { | |
scope.dispatchEvent( _changeEvent ); | |
lastPosition.copy( scope.object.position ); | |
} | |
} else if ( scope.object.isOrthographicCamera ) { | |
scope.object.lookAt( scope.target ); | |
if ( lastPosition.distanceToSquared( scope.object.position ) > EPS || lastZoom !== scope.object.zoom ) { | |
scope.dispatchEvent( _changeEvent ); | |
lastPosition.copy( scope.object.position ); | |
lastZoom = scope.object.zoom; | |
} | |
} else { | |
console.warn( 'THREE.TrackballControls: Unsupported camera type' ); | |
} | |
}; | |
this.reset = function () { | |
_state = STATE.NONE; | |
_keyState = STATE.NONE; | |
scope.target.copy( scope.target0 ); | |
scope.object.position.copy( scope.position0 ); | |
scope.object.up.copy( scope.up0 ); | |
scope.object.zoom = scope.zoom0; | |
scope.object.updateProjectionMatrix(); | |
_eye.subVectors( scope.object.position, scope.target ); | |
scope.object.lookAt( scope.target ); | |
scope.dispatchEvent( _changeEvent ); | |
lastPosition.copy( scope.object.position ); | |
lastZoom = scope.object.zoom; | |
}; | |
// listeners | |
function onPointerDown( event ) { | |
if ( scope.enabled === false ) return; | |
if ( _pointers.length === 0 ) { | |
scope.domElement.setPointerCapture( event.pointerId ); | |
scope.domElement.addEventListener( 'pointermove', onPointerMove ); | |
scope.domElement.addEventListener( 'pointerup', onPointerUp ); | |
} | |
// | |
addPointer( event ); | |
if ( event.pointerType === 'touch' ) { | |
onTouchStart( event ); | |
} else { | |
onMouseDown( event ); | |
} | |
} | |
function onPointerMove( event ) { | |
if ( scope.enabled === false ) return; | |
if ( event.pointerType === 'touch' ) { | |
onTouchMove( event ); | |
} else { | |
onMouseMove( event ); | |
} | |
} | |
function onPointerUp( event ) { | |
if ( scope.enabled === false ) return; | |
if ( event.pointerType === 'touch' ) { | |
onTouchEnd( event ); | |
} else { | |
onMouseUp(); | |
} | |
// | |
removePointer( event ); | |
if ( _pointers.length === 0 ) { | |
scope.domElement.releasePointerCapture( event.pointerId ); | |
scope.domElement.removeEventListener( 'pointermove', onPointerMove ); | |
scope.domElement.removeEventListener( 'pointerup', onPointerUp ); | |
} | |
} | |
function onPointerCancel( event ) { | |
removePointer( event ); | |
} | |
function keydown( event ) { | |
if ( scope.enabled === false ) return; | |
window.removeEventListener( 'keydown', keydown ); | |
if ( _keyState !== STATE.NONE ) { | |
return; | |
} else if ( event.code === scope.keys[ STATE.ROTATE ] && ! scope.noRotate ) { | |
_keyState = STATE.ROTATE; | |
} else if ( event.code === scope.keys[ STATE.ZOOM ] && ! scope.noZoom ) { | |
_keyState = STATE.ZOOM; | |
} else if ( event.code === scope.keys[ STATE.PAN ] && ! scope.noPan ) { | |
_keyState = STATE.PAN; | |
} | |
} | |
function keyup() { | |
if ( scope.enabled === false ) return; | |
_keyState = STATE.NONE; | |
window.addEventListener( 'keydown', keydown ); | |
} | |
function onMouseDown( event ) { | |
if ( _state === STATE.NONE ) { | |
switch ( event.button ) { | |
case scope.mouseButtons.LEFT: | |
_state = STATE.ROTATE; | |
break; | |
case scope.mouseButtons.MIDDLE: | |
_state = STATE.ZOOM; | |
break; | |
case scope.mouseButtons.RIGHT: | |
_state = STATE.PAN; | |
break; | |
} | |
} | |
const state = ( _keyState !== STATE.NONE ) ? _keyState : _state; | |
if ( state === STATE.ROTATE && ! scope.noRotate ) { | |
_moveCurr.copy( getMouseOnCircle( event.pageX, event.pageY ) ); | |
_movePrev.copy( _moveCurr ); | |
} else if ( state === STATE.ZOOM && ! scope.noZoom ) { | |
_zoomStart.copy( getMouseOnScreen( event.pageX, event.pageY ) ); | |
_zoomEnd.copy( _zoomStart ); | |
} else if ( state === STATE.PAN && ! scope.noPan ) { | |
_panStart.copy( getMouseOnScreen( event.pageX, event.pageY ) ); | |
_panEnd.copy( _panStart ); | |
} | |
scope.dispatchEvent( _startEvent ); | |
} | |
function onMouseMove( event ) { | |
const state = ( _keyState !== STATE.NONE ) ? _keyState : _state; | |
if ( state === STATE.ROTATE && ! scope.noRotate ) { | |
_movePrev.copy( _moveCurr ); | |
_moveCurr.copy( getMouseOnCircle( event.pageX, event.pageY ) ); | |
} else if ( state === STATE.ZOOM && ! scope.noZoom ) { | |
_zoomEnd.copy( getMouseOnScreen( event.pageX, event.pageY ) ); | |
} else if ( state === STATE.PAN && ! scope.noPan ) { | |
_panEnd.copy( getMouseOnScreen( event.pageX, event.pageY ) ); | |
} | |
} | |
function onMouseUp() { | |
_state = STATE.NONE; | |
scope.dispatchEvent( _endEvent ); | |
} | |
function onMouseWheel( event ) { | |
if ( scope.enabled === false ) return; | |
if ( scope.noZoom === true ) return; | |
event.preventDefault(); | |
switch ( event.deltaMode ) { | |
case 2: | |
// Zoom in pages | |
_zoomStart.y -= event.deltaY * 0.025; | |
break; | |
case 1: | |
// Zoom in lines | |
_zoomStart.y -= event.deltaY * 0.01; | |
break; | |
default: | |
// undefined, 0, assume pixels | |
_zoomStart.y -= event.deltaY * 0.00025; | |
break; | |
} | |
scope.dispatchEvent( _startEvent ); | |
scope.dispatchEvent( _endEvent ); | |
} | |
function onTouchStart( event ) { | |
trackPointer( event ); | |
switch ( _pointers.length ) { | |
case 1: | |
_state = STATE.TOUCH_ROTATE; | |
_moveCurr.copy( getMouseOnCircle( _pointers[ 0 ].pageX, _pointers[ 0 ].pageY ) ); | |
_movePrev.copy( _moveCurr ); | |
break; | |
default: // 2 or more | |
_state = STATE.TOUCH_ZOOM_PAN; | |
const dx = _pointers[ 0 ].pageX - _pointers[ 1 ].pageX; | |
const dy = _pointers[ 0 ].pageY - _pointers[ 1 ].pageY; | |
_touchZoomDistanceEnd = _touchZoomDistanceStart = Math.sqrt( dx * dx + dy * dy ); | |
const x = ( _pointers[ 0 ].pageX + _pointers[ 1 ].pageX ) / 2; | |
const y = ( _pointers[ 0 ].pageY + _pointers[ 1 ].pageY ) / 2; | |
_panStart.copy( getMouseOnScreen( x, y ) ); | |
_panEnd.copy( _panStart ); | |
break; | |
} | |
scope.dispatchEvent( _startEvent ); | |
} | |
function onTouchMove( event ) { | |
trackPointer( event ); | |
switch ( _pointers.length ) { | |
case 1: | |
_movePrev.copy( _moveCurr ); | |
_moveCurr.copy( getMouseOnCircle( event.pageX, event.pageY ) ); | |
break; | |
default: // 2 or more | |
const position = getSecondPointerPosition( event ); | |
const dx = event.pageX - position.x; | |
const dy = event.pageY - position.y; | |
_touchZoomDistanceEnd = Math.sqrt( dx * dx + dy * dy ); | |
const x = ( event.pageX + position.x ) / 2; | |
const y = ( event.pageY + position.y ) / 2; | |
_panEnd.copy( getMouseOnScreen( x, y ) ); | |
break; | |
} | |
} | |
function onTouchEnd( event ) { | |
switch ( _pointers.length ) { | |
case 0: | |
_state = STATE.NONE; | |
break; | |
case 1: | |
_state = STATE.TOUCH_ROTATE; | |
_moveCurr.copy( getMouseOnCircle( event.pageX, event.pageY ) ); | |
_movePrev.copy( _moveCurr ); | |
break; | |
case 2: | |
_state = STATE.TOUCH_ZOOM_PAN; | |
for ( let i = 0; i < _pointers.length; i ++ ) { | |
if ( _pointers[ i ].pointerId !== event.pointerId ) { | |
const position = _pointerPositions[ _pointers[ i ].pointerId ]; | |
_moveCurr.copy( getMouseOnCircle( position.x, position.y ) ); | |
_movePrev.copy( _moveCurr ); | |
break; | |
} | |
} | |
break; | |
} | |
scope.dispatchEvent( _endEvent ); | |
} | |
function contextmenu( event ) { | |
if ( scope.enabled === false ) return; | |
event.preventDefault(); | |
} | |
function addPointer( event ) { | |
_pointers.push( event ); | |
} | |
function removePointer( event ) { | |
delete _pointerPositions[ event.pointerId ]; | |
for ( let i = 0; i < _pointers.length; i ++ ) { | |
if ( _pointers[ i ].pointerId == event.pointerId ) { | |
_pointers.splice( i, 1 ); | |
return; | |
} | |
} | |
} | |
function trackPointer( event ) { | |
let position = _pointerPositions[ event.pointerId ]; | |
if ( position === undefined ) { | |
position = new Vector2(); | |
_pointerPositions[ event.pointerId ] = position; | |
} | |
position.set( event.pageX, event.pageY ); | |
} | |
function getSecondPointerPosition( event ) { | |
const pointer = ( event.pointerId === _pointers[ 0 ].pointerId ) ? _pointers[ 1 ] : _pointers[ 0 ]; | |
return _pointerPositions[ pointer.pointerId ]; | |
} | |
this.dispose = function () { | |
scope.domElement.removeEventListener( 'contextmenu', contextmenu ); | |
scope.domElement.removeEventListener( 'pointerdown', onPointerDown ); | |
scope.domElement.removeEventListener( 'pointercancel', onPointerCancel ); | |
scope.domElement.removeEventListener( 'wheel', onMouseWheel ); | |
scope.domElement.removeEventListener( 'pointermove', onPointerMove ); | |
scope.domElement.removeEventListener( 'pointerup', onPointerUp ); | |
window.removeEventListener( 'keydown', keydown ); | |
window.removeEventListener( 'keyup', keyup ); | |
}; | |
this.domElement.addEventListener( 'contextmenu', contextmenu ); | |
this.domElement.addEventListener( 'pointerdown', onPointerDown ); | |
this.domElement.addEventListener( 'pointercancel', onPointerCancel ); | |
this.domElement.addEventListener( 'wheel', onMouseWheel, { passive: false } ); | |
window.addEventListener( 'keydown', keydown ); | |
window.addEventListener( 'keyup', keyup ); | |
this.handleResize(); | |
// force an update at start | |
this.update(); | |
} | |
} | |
export { TrackballControls }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment