瀏覽代碼

New Arcball controls (#21989)

* Create ArcballControls.html

* Create misc_controls_interactivecontrolscomparison.html

* Updated with interactivecontrolscomparison

* Updated with ArcballControls doc

* Updated with interactivecontrolscomparison

* Create ArcballControls.html

* Create misc_controls_interactivecontrolscomparison.jpg

* Create ArcballControls.js

* Updated documentation

* Added copy/paste state functions

* Added copy/paste functions in arcball menu

* Fixed copy/paste state functions

* Fixed controls switch and updated arcball options gui

* Updated documentation

* Added copy and paste state functions

* Rename misc_controls_interactivecontrolscomparison.html to misc_controls_arcball.html

* Renamed interactivecontrolscomparison to arcball

* Update files.json

* Delete misc_controls_interactivecontrolscomparison.jpg

* Create misc_controls_arcball.jpg

* Fixed Perspective to Orthographic camera transition

* Removed unused variables and minor optimizations

* Fixed issue with pasteState function

* Replaced Hammer.js with PointerEvents

* Fixed double tap issue

* Fixed double tap issue

* Fixed an issue causing a singlepan detection when releasing two fingers from the screen

* Fixed page scrolling issue

* Fixed an issue with rotation animation

* Updated example screenshot

* removed PMREMGenerator component

* Added functions to add/remove operation-mouse/key association and fixed an issue with touch inputs

* Modified default mouse actions

Co-authored-by: Paolo Cignoni <[email protected]>
danielefornari 3 年之前
父節點
當前提交
a403fda266

+ 266 - 0
docs/examples/en/controls/ArcballControls.html

@@ -0,0 +1,266 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<meta charset="utf-8" />
+		<base href="../../../" />
+		<script src="page.js"></script>
+		<link type="text/css" rel="stylesheet" href="page.css" />
+	</head>
+	<body>
+		<h1>[name]</h1>
+
+		<p class="desc">
+		Arcball controls allow the camera to be controlled by a virtual trackball with full touch support and advanced navigation functionality. <br>
+		Cursor/finger positions and movements are mapped over a virtual trackball surface
+		represented by a gizmo and mapped in intuitive and consistent camera movements. 
+		Dragging cursor/fingers will cause camera to orbit around the center of the trackball in a conservative way (returning to the starting point
+		will make the camera to return to its starting orientation).<br><br>
+		
+		In addition to supporting pan, zoom and pinch gestures, Arcball controls provide <i>focus</i> functionality with a double click/tap for
+		intuitively moving the object's point of interest in the center of the virtual trackball.   
+		Focus allows a much better inspection and navigation in complex environment. 
+		Moreover Arcball controls allow FOV manipulation (in a vertigo-style method) and z-rotation.
+		Saving and restoring of Camera State is supported also through clipboard 
+		(use ctrl+c and ctrl+v shortcuts for copy and paste the state).<br><br>
+		
+		Unlike [page:OrbitControls] and [page:TrackballControls], [name] doesn't require [page:.update] to be called externally in an animation loop when animations
+		are on.<br><br>
+
+
+		To use this, as with all files in the /examples directory, you will have to
+		include the file separately in your HTML.
+
+		</p>
+
+		<h2>Code Example</h2>
+
+		<code>
+		const renderer = new THREE.WebGLRenderer();
+		renderer.setSize( window.innerWidth, window.innerHeight );
+		document.body.appendChild( renderer.domElement );
+
+		const scene = new THREE.Scene();
+
+		const camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 1, 10000 );
+
+		const controls = new ArcballControls( camera, renderer.domElement, scene );
+
+		controls.addEventListener( 'change', function () {
+
+			renderer.render( scene, camera );
+
+		} );
+
+		//controls.update() must be called after any manual changes to the camera's transform
+		camera.position.set( 0, 20, 100 );
+		controls.update();
+		</code>
+
+		<h2>Examples</h2>
+
+		<p>[example:misc_controls_arcball misc / controls / arcball ]</p>
+
+		<h2>Constructor</h2>
+
+		<h3>[name]( [param:Camera camera], [param:HTMLDOMElement domElement], [param:Scene scene] )</h3>
+		<p>
+			[page:Camera camera]: (required) The camera to be controlled. The camera must not be a child of another object, unless that object is the scene itself.<br><br>
+
+			[page:HTMLDOMElement domElement]: The HTML element used for event listeners.<br><br>
+
+			[page:Scene scene]: The scene rendered by the camera. If not given, gizmos cannot be shown.
+		</p>
+
+		<h2>Events</h2>
+
+		<h3>change</h3>
+		<p>
+			Fires when the camera has been transformed by the controls.
+		</p>
+
+		<h3>start</h3>
+		<p>
+			Fires when an interaction was initiated.
+		</p>
+
+		<h3>end</h3>
+		<p>
+			Fires when an interaction has finished.
+		</p>
+
+		<h2>Properties</h2>
+
+		<h3>[property:Boolean adjustNearFar]</h3>
+		<p>
+			If true, camera's near and far values will be adjusted every time zoom is performed trying to mantain the same visible portion
+			given by initial near and far values ( [page:PerspectiveCamera] only ).
+			Default is false.
+		</p>
+
+		<h3>[property:Camera camera]</h3>
+		<p>
+			The camera being controlled.
+		</p>
+
+		<h3>[property:Boolean cursorZoom]</h3>
+		<p>
+			Set to true to make zoom become cursor centered.
+		</p>
+
+		<h3>
+			[property:Float dampingFactor]</h3>
+		<p>
+			The damping inertia used if [page:.enableAnimations] is set to true.
+		</p>
+
+		<h3>[property:HTMLDOMElement domElement]</h3>
+		<p>
+			The HTMLDOMElement used to listen for mouse / touch events. This must be passed in the constructor; changing it here will
+			not set up new event listeners.
+		</p>
+
+		<h3>[property:Boolean enabled]</h3>
+		<p>
+			When set to *false*, the controls will not respond to user input. Default is *true*.
+		</p>
+
+		<h3>[property:Boolean enableAnimations]</h3>
+		<p>
+			Set to true to enable animations for rotation (damping) and focus operation. Default is true.
+		</p>
+
+		<h3>[property:Boolean enableGrid]</h3>
+		<p>
+			When set to true, a grid will appear when panning operation is being performed (desktop interaction only). Default is false.
+		</p>
+
+		<h3>[property:Boolean enablePan]</h3>
+		<p>
+			Enable or disable camera panning. Default is true.
+		</p>
+
+		<h3>[property:Boolean enableRotate]</h3>
+		<p>
+			Enable or disable camera rotation. Default is true.
+		</p>
+
+		<h3>[property:Boolean enableZoom]</h3>
+		<p>
+			Enable or disable zooming of the camera.
+		</p>
+
+		<h3>[property:Float focusAnimationTime]</h3>
+		<p>
+			Duration time of focus animation.
+		</p>
+
+		<h3>[property:Float maxDistance]</h3>
+		<p>
+			How far you can dolly out ( [page:PerspectiveCamera] only ). Default is Infinity.
+		</p>
+
+		<h3>[property:Float maxZoom]</h3>
+		<p>
+			How far you can zoom out ( [page:OrthographicCamera] only ). Default is Infinity.
+		</p>
+
+		<h3>[property:Float minDistance]</h3>
+		<p>
+			 How far you can dolly in ( [page:PerspectiveCamera] only ). Default is 0.
+		</p>
+
+		<h3>[property:Float minZoom]</h3>
+		<p>
+			How far you can zoom in ( [page:OrthographicCamera] only ). Default is 0.
+		</p>
+
+		<h3>[property:Float scaleFactor]</h3>
+		<p>
+			The scaling factor used when performing zoom operation.
+		</p>
+
+		<h3>[property:Scene scene]</h3>
+		<p>
+			The scene rendered by the camera.
+		</p>
+
+		<h3>[property:Float wMax]</h3>
+		<p>
+			Maximum angular velocity allowed on rotation animation start.
+		</p>
+
+
+		<h2>Methods</h2>
+
+		<h3>[method:null activateGizmos] ( [param:Boolean isActive] )</h3>
+		<p>
+			Make gizmos more or less visible.
+		</p>
+
+		<h3>[method:null copyState] ()</h3>
+		<p>
+			Copy the current state to clipboard (as a readable JSON text).
+		</p>
+
+		<h3>[method:null dispose] ()</h3>
+		<p>
+			Remove all the event listeners, cancel any pending animation and clean the scene from gizmos and grid.
+		</p>
+
+		<h3>[method:null pasteState] ()</h3>
+		<p>
+			Set the controls state from the clipboard, assumes that the clipboard stores a JSON text as saved from [page:.copyState].
+		</p>
+
+		<h3>[method:null reset] ()</h3>
+		<p>
+			Reset the controls to their state from either the last time the [page:.saveState] was called, or the initial state.
+		</p>
+
+		<h3>[method:null saveState] ()</h3>
+		<p>
+			Save the current state of the controls. This can later be recovered with [page:.reset].
+		</p>
+
+		<h3>[method:null setCamera] ( [param:Camera camera] )</h3>
+		<p>
+			Set the camera to be controlled. Must be called in order to set a new camera to be controlled.
+		</p>
+
+		<h3>[method:null setGizmosVisible] ( [param:Boolean value] )</h3>
+		<p>
+			Set the visible property of gizmos.
+		</p>
+
+		<h3>[method:Boolean setMouseAction] ( [param:String operation], mouse, key )</h3>
+		<p>
+			Set a new mouse action by specifying the operation to be performed and a mouse/key combination. In case of conflict, replaces the existing one.<br><br>
+			Operations can be specified as 'ROTATE', 'PAN', 'FOV' or 'ZOOM'.<br>
+			Mouse inputs can be specified as mouse buttons 0, 1 and 2 or 'WHEEL' for wheel notches.<br>
+			Keyboard modifiers can be specified as 'CTRL', 'SHIFT' or null if not needed.
+		</p>
+
+		<h3>[method:null setTarget] ( [param:Float x], [param:Float y], [param:Float z] )</h3>
+		<p>
+			Set the trackball center point.
+		</p>
+
+		<h3>[method:Boolean unsetMouseAction] ( mouse, key )</h3>
+		<p>
+			Removes a mouse action by specifying its mouse/key combination.<br><br>
+			Mouse inputs can be specified as mouse buttons 0, 1 and 2 or 'WHEEL' for wheel notches.<br>
+			Keyboard modifiers can be specified as 'CTRL', 'SHIFT' or null if not needed.
+		</p>
+		
+		<h3>[method:null update] ()</h3>
+		<p>
+			Update the controls. Must be called after any manual changes to the camera's transform.
+		</p>
+
+		<h2>Source</h2>
+
+		<p>
+			[link:https://github.com/mrdoob/three.js/blob/master/examples/jsm/controls/OrbitControls.js examples/jsm/controls/ArcballControls.js]
+		</p>
+	</body>
+</html>

+ 1 - 0
docs/list.json

@@ -338,6 +338,7 @@
 			},
 			},
 
 
 			"Controls": {
 			"Controls": {
+				"ArcballControls": "examples/en/controls/ArcballControls",
 				"DeviceOrientationControls": "examples/en/controls/DeviceOrientationControls",
 				"DeviceOrientationControls": "examples/en/controls/DeviceOrientationControls",
 				"DragControls": "examples/en/controls/DragControls",
 				"DragControls": "examples/en/controls/DragControls",
 				"FirstPersonControls": "examples/en/controls/FirstPersonControls",
 				"FirstPersonControls": "examples/en/controls/FirstPersonControls",

+ 1 - 0
examples/files.json

@@ -369,6 +369,7 @@
 		"misc_animation_groups",
 		"misc_animation_groups",
 		"misc_animation_keys",
 		"misc_animation_keys",
 		"misc_boxselection",
 		"misc_boxselection",
+		"misc_controls_arcball",
 		"misc_controls_deviceorientation",
 		"misc_controls_deviceorientation",
 		"misc_controls_drag",
 		"misc_controls_drag",
 		"misc_controls_fly",
 		"misc_controls_fly",

+ 3171 - 0
examples/js/controls/ArcballControls.js

@@ -0,0 +1,3171 @@
+( function () {
+	
+	//trackball state
+	const STATE = {
+	
+		IDLE: Symbol(),
+		ROTATE: Symbol(),
+		PAN: Symbol(),
+		SCALE: Symbol(),
+		FOV: Symbol(),
+		FOCUS: Symbol(),
+		ZROTATE: Symbol(),
+		TOUCH_MULTI: Symbol(),
+		ANIMATION_FOCUS: Symbol(),
+		ANIMATION_ROTATE: Symbol()
+	
+	};
+	
+	const INPUT = {
+	
+		NONE: Symbol(),
+		ONE_FINGER: Symbol(),
+		ONE_FINGER_SWITCHED: Symbol(),
+		TWO_FINGER: Symbol(),
+		MULT_FINGER: Symbol(),
+		CURSOR: Symbol()
+	
+	};
+	
+	//cursor center coordinates
+	const _center = {
+	
+		x: 0,
+		y: 0
+	
+	};
+	
+	//transformation matrices for gizmos and camera
+	const _transformation = {
+	
+		camera: new THREE.Matrix4(),
+		gizmos: new THREE.Matrix4()
+	
+	};
+	
+	//events
+	const _changeEvent = { type: 'change' };
+	const _startEvent = { type: 'start' };
+	const _endEvent = { type: 'end' };
+	
+	
+	/**
+	 * 
+	 * @param {Camera} camera Virtual camera used in the scene
+	 * @param {HTMLElement} domElement Renderer's dom element
+	 * @param {Scene} scene The scene to be rendered
+	 */
+	class ArcballControls extends THREE.Object3D {
+	
+		constructor( camera, domElement, scene = null ) {
+	
+			super();
+			this.camera = null;
+			this.domElement = domElement;
+			this.scene = scene;
+
+			this.mouseActions = [];
+			this._mouseOp = null;
+
+			//global vectors and matrices that are used in some operations to avoid creating new objects every time (e.g. every time cursor moves)
+			this._v2_1 = new THREE.Vector2();
+			this._v3_1 = new THREE.Vector3();
+			this._v3_2 = new THREE.Vector3();
+	
+			this._m4_1 = new THREE.Matrix4();
+			this._m4_2 = new THREE.Matrix4();
+	
+			this._quat = new THREE.Quaternion();
+	
+			//transformation matrices
+			this._translationMatrix = new THREE.Matrix4();    //matrix for translation operation
+			this._rotationMatrix = new THREE.Matrix4();   //matrix for rotation operation
+			this._scaleMatrix = new THREE.Matrix4();    //matrix for scaling operation
+	
+			this._rotationAxis = new THREE.Vector3();   //axis for rotate operation
+	
+	
+			//camera state
+			this._cameraMatrixState = new THREE.Matrix4();
+			this._cameraProjectionState = new THREE.Matrix4();
+	
+			this._fovState = 1;
+			this._upState = new THREE.Vector3();
+			this._zoomState = 1;
+			this._nearPos = 0;
+			this._farPos = 0;
+	
+			this._gizmoMatrixState = new THREE.Matrix4();
+		
+			//initial values
+			this._up0 = new THREE.Vector3();
+			this._zoom0 = 1;
+			this._fov0 = 0;
+			this._initialNear = 0;
+			this._nearPos0 = 0;
+			this._initialFar = 0;
+			this._farPos0 = 0;
+			this._cameraMatrixState0 = new THREE.Matrix4();
+			this._gizmoMatrixState0 = new THREE.Matrix4();
+	
+			//pointers array
+			this._button = -1;
+			this._touchStart = [];
+			this._touchCurrent = [];
+			this._input = INPUT.NONE;
+	
+			//two fingers touch interaction
+			this._switchSensibility = 32;	//minimum movement to be performed to fire single pan start after the second finger has been released
+			this._startFingerDistance = 0; //distance between two fingers
+			this._currentFingerDistance = 0;
+			this._startFingerRotation = 0; //amount of rotation performed with two fingers
+			this._currentFingerRotation = 0;
+	
+			//double tap
+			this._devPxRatio = 0;
+			this._downValid = true;
+			this._nclicks = 0;
+			this._downEvents = [];
+			this._downStart = 0;	//pointerDown time
+			this._clickStart = 0;	//first click time
+			this._maxDownTime = 250;
+			this._maxInterval = 300;
+			this._posThreshold = 24;
+			this._movementThreshold = 24;
+	
+			//cursor positions
+			this._currentCursorPosition = new THREE.Vector3();
+			this._startCursorPosition = new THREE.Vector3();
+	
+			//grid
+			this._grid = null; //grid to be visualized during pan operation
+			this._gridPosition = new THREE.Vector3();
+	
+			//gizmos
+			this._gizmos = new THREE.Group();
+			this._curvePts = 128;
+	
+	
+			//animations
+			this._timeStart = -1; //initial time
+			this._animationId = -1;
+	
+			//focus animation
+			this.focusAnimationTime = 500; //duration of focus animation in ms
+	
+			//rotate animation
+			this._timePrev = 0; //time at which previous rotate operation has been detected
+			this._timeCurrent = 0;  //time at which current rotate operation has been detected
+			this._anglePrev = 0; //angle of previous rotation
+			this._angleCurrent = 0;  //angle of current rotation
+			this._cursorPosPrev = new THREE.Vector3();	//cursor position when previous rotate operation has been detected
+			this._cursorPosCurr = new THREE.Vector3();//cursor position when current rotate operation has been detected
+			this._wPrev = 0; //angular velocity of the previous rotate operation
+			this._wCurr = 0; //angular velocity of the current rotate operation
+	
+	
+			//parameters
+			this.adjustNearFar = false;
+			this.scaleFactor = 1.1;	//zoom/distance multiplier
+			this.dampingFactor = 25;
+			this.wMax = 20;	//maximum angular velocity allowed
+			this.enableAnimations = true;    //if animations should be performed
+			this.enableGrid = false;   //if grid should be showed during pan operation
+			this.cursorZoom = false;	//if wheel zoom should be cursor centered
+			this.minFov = 5;
+			this.maxFov = 90;
+	
+			this.enabled = true;
+			this.enablePan = true;
+			this.enableRotate = true;
+			this.enableZoom = true;
+			this.enableGizmos = true;
+	
+			this.minDistance = 0;
+			this.maxDistance = Infinity;
+			this.minZoom = 0;
+			this.maxZoom = Infinity;
+	
+			//trackball parameters
+			this._tbCenter = new THREE.Vector3( 0, 0, 0 );
+			this._tbRadius = 1;
+	
+			//FSA
+			this._state = STATE.IDLE;
+	
+			this.setCamera(camera);
+	
+			if ( this.scene != null ) {
+	
+				this.scene.add( this._gizmos );
+			
+			}
+	
+			this.domElement.style.touchAction = 'none';
+			this._devPxRatio = window.devicePixelRatio;
+	
+			this.initializeMouseActions();
+	
+			this.domElement.addEventListener( 'contextmenu', this.onContextMenu );
+			this.domElement.addEventListener( 'wheel', this.onWheel );
+			this.domElement.addEventListener( 'pointerdown', this.onPointerDown );
+			this.domElement.addEventListener( 'pointercancel', this.onPointerCancel )
+	
+			window.addEventListener( 'keydown', this.onKeyDown );
+			window.addEventListener( 'resize', this.onWindowResize );
+	
+		};
+	
+		//listeners
+	
+		onWindowResize = () => {
+	
+			const scale = ( this._gizmos.scale.x + this._gizmos.scale.y + this._gizmos.scale.z ) / 3; 
+			this._tbRadius = this.calculateTbRadius( this.camera );
+	
+			const newRadius = this._tbRadius / scale;
+			const curve = new THREE.EllipseCurve( 0, 0, newRadius, newRadius );
+			const points = curve.getPoints( this._curvePts );
+			const curveGeometry = new THREE.BufferGeometry().setFromPoints( points );
+	
+	
+			for( let gizmo in this._gizmos.children ) {
+	
+				this._gizmos.children[ gizmo ].geometry = curveGeometry;
+	
+			}
+	
+			this.dispatchEvent( _changeEvent );
+	
+		};
+
+		onContextMenu = ( event ) => {
+
+			if ( !this.enabled ) {
+	
+				return;
+	
+			}
+	
+			for( let i = 0; i < this.mouseActions.length; i++ ) {
+	
+				if ( this.mouseActions[ i ].mouse == 2 ) {
+	
+					//prevent only if button 2 is actually used
+					event.preventDefault();
+					break;
+	
+				}
+	
+			}
+	
+		};
+	
+		onPointerCancel = ( event ) => {
+	
+			this._touchStart.splice( 0, this._touchStart.length );
+			this._touchCurrent.splice( 0, this._touchCurrent.length );
+			this._input = INPUT.NONE;
+	
+		};
+	
+		onPointerDown = ( event ) => {
+
+			if ( event.button == 0 && event.isPrimary ) {
+	
+				this._downValid = true;
+				this._downEvents.push( event );
+				this._downStart = performance.now();
+	
+			} else {
+	
+				this._downValid = false;
+	
+			}
+	
+			if ( event.pointerType == 'touch' && this._input != INPUT.CURSOR ) {
+	
+				this._touchStart.push( event );
+				this._touchCurrent.push( event );
+	
+				switch ( this._input ) {
+	
+					case INPUT.NONE:
+	
+						//singleStart
+						this._input = INPUT.ONE_FINGER;
+						this.onSinglePanStart( event, 'ROTATE' );
+	
+						window.addEventListener( 'pointermove', this.onPointerMove );
+						window.addEventListener( 'pointerup', this.onPointerUp );
+	
+						break;
+	
+					case INPUT.ONE_FINGER:
+					case INPUT.ONE_FINGER_SWITCHED:
+	
+						//doubleStart
+						this._input = INPUT.TWO_FINGER;
+	
+						this.onRotateStart();
+						this.onPinchStart();
+						this.onDoublePanStart();
+	
+						break;
+	
+					case INPUT.TWO_FINGER:
+						
+						//multipleStart
+						this._input = INPUT.MULT_FINGER;
+						this.onTriplePanStart( event );
+						break;
+				}
+	
+			} else if ( event.pointerType != 'touch' && this._input == INPUT.NONE ) {
+	
+				let modifier = null;
+
+				if( event.ctrlKey || event.metaKey ) {
+	
+					modifier = 'CTRL';
+	
+				} else if ( event.shiftKey ) {
+	
+					modifier = 'SHIFT';
+	
+				}
+	
+				this._mouseOp = this.getOpFromAction( event.button, modifier );
+				if ( this._mouseOp != null ) {
+		
+					window.addEventListener( 'pointermove', this.onPointerMove );
+					window.addEventListener( 'pointerup', this.onPointerUp );
+		
+					//singleStart
+					this._input = INPUT.CURSOR;
+					this._button = event.button;
+					this.onSinglePanStart( event, this._mouseOp );
+	
+				}
+	
+			}
+	
+		};
+	
+		onPointerMove = ( event ) => {
+
+			if ( event.pointerType == 'touch' && this._input != INPUT.CURSOR ) {
+	
+				switch ( this._input ) {
+	
+					case INPUT.ONE_FINGER:
+						
+						//singleMove
+						this.updateTouchEvent( event );
+	
+						this.onSinglePanMove( event, STATE.ROTATE );
+						break;
+	
+					case INPUT.ONE_FINGER_SWITCHED:
+	
+						const movement = this.calculatePointersDistance( this._touchCurrent[ 0 ], event ) * this._devPxRatio;
+					
+						if ( movement >= this._switchSensibility ) {
+	
+							//singleMove
+							this._input = INPUT.ONE_FINGER;
+							this.updateTouchEvent( event );
+	
+							this.onSinglePanStart( event, 'ROTATE' );
+							break;
+	
+						}
+	
+						break;
+	
+					case INPUT.TWO_FINGER:
+	
+						//rotate/pan/pinchMove
+						this.updateTouchEvent( event );
+	
+						this.onRotateMove();
+						this.onPinchMove();
+						this.onDoublePanMove();
+	
+						break;
+	
+					case INPUT.MULT_FINGER:
+	
+						//multMove
+						this.updateTouchEvent( event );
+	
+						this.onTriplePanMove( event );
+						break;
+				
+				}
+	
+			} else if ( event.pointerType != 'touch' && this._input == INPUT.CURSOR ) {
+	
+				let modifier = null;
+
+				if( event.ctrlKey || event.metaKey ) {
+	
+					modifier = 'CTRL';
+	
+				} else if ( event.shiftKey ) {
+	
+					modifier = 'SHIFT';
+	
+				}
+	
+				const mouseOpState = this.getOpStateFromAction( this._button, modifier );
+	
+				if ( mouseOpState != null ) {
+	
+					this.onSinglePanMove( event, mouseOpState );
+	
+				}
+	
+			}
+	
+			//checkDistance
+			if( this._downValid ) {
+	
+				const movement = this.calculatePointersDistance( this._downEvents[ this._downEvents.length -1 ], event ) * this._devPxRatio;
+				if ( movement > this._movementThreshold ) {
+		
+					this._downValid = false;
+	
+				}
+	
+			}
+	
+		};
+	
+		onPointerUp = ( event ) => {
+	
+			if ( event.pointerType == 'touch' && this._input != INPUT.CURSOR ) {
+	
+				let nTouch = this._touchCurrent.length;
+	
+				for( let i = 0; i < nTouch; i++ ) {
+	
+					if ( this._touchCurrent[ i ].pointerId == event.pointerId ) {
+	
+						this._touchCurrent.splice( i, 1 );
+						this._touchStart.splice( i, 1 );
+						break;
+	
+					}
+	
+				}
+	
+				switch ( this._input ) {
+	
+					case INPUT.ONE_FINGER:
+					case INPUT.ONE_FINGER_SWITCHED:
+						
+						//singleEnd
+						window.removeEventListener( 'pointermove', this.onPointerMove );
+						window.removeEventListener( 'pointerup', this.onPointerUp );
+	
+						this._input = INPUT.NONE;
+						this.onSinglePanEnd();
+	
+						break;
+	
+					case INPUT.TWO_FINGER:
+	
+						//doubleEnd
+						this.onDoublePanEnd( event );
+						this.onPinchEnd( event );
+						this.onRotateEnd( event );
+	
+						//switching to singleStart
+						this._input = INPUT.ONE_FINGER_SWITCHED;
+	
+						break;					
+						
+					case INPUT.MULT_FINGER:
+						
+						if ( this._touchCurrent.length == 0 ) {
+	
+							window.removeEventListener( 'pointermove', this.onPointerMove );
+							window.removeEventListener( 'pointerup', this.onPointerUp );
+
+							//multCancel
+							this._input = INPUT.NONE;
+							this.onTriplePanEnd();
+	
+						}
+	
+						break;
+	
+				}
+	
+			} else if ( event.pointerType != 'touch' && this._input == INPUT.CURSOR ) {
+	
+				window.removeEventListener( 'pointermove', this.onPointerMove );
+				window.removeEventListener( 'pointerup', this.onPointerUp );
+	
+				this._input = INPUT.NONE;
+				this.onSinglePanEnd();
+				this._button = -1;
+	
+			}
+	
+			if ( event.isPrimary ) {
+	
+				if ( this._downValid ) {
+	
+					const downTime = event.timeStamp - this._downEvents[ this._downEvents.length -1 ].timeStamp;
+	
+					if ( downTime <= this._maxDownTime ) {
+	
+						if ( this._nclicks == 0 ) {
+	
+							//first valid click detected
+							this._nclicks = 1;
+							this._clickStart = performance.now();
+	
+						} else {
+	
+							const clickInterval = event.timeStamp - this._clickStart;
+							const movement = this.calculatePointersDistance( this._downEvents[ 1 ], this._downEvents[ 0 ] ) * this._devPxRatio;
+	
+							if ( clickInterval <= this._maxInterval && movement <= this._posThreshold ) {
+	
+								//second valid click detected
+								//fire double tap and reset values
+								this._nclicks = 0;
+								this._downEvents.splice( 0, this._downEvents.length );
+								this.onDoubleTap( event );
+	
+							} else {
+	
+								//new 'first click'
+								this._nclicks = 1;
+								this._downEvents.shift();
+								this._clickStart = performance.now();
+	
+							}
+	
+						}
+	
+					} else {
+	
+						this._downValid = false;
+						this._nclicks = 0;
+						this._downEvents.splice( 0, this._downEvents.length );
+	
+					}
+	
+				} else {
+	
+					this._nclicks = 0;
+					this._downEvents.splice( 0, this._downEvents.length );
+	
+				}
+			}
+	
+		};
+	
+		onWheel = ( event ) => {
+	
+			if ( this.enabled && this.enableZoom ) {
+
+				let modifier = null;
+	
+				if( event.ctrlKey || event.metaKey ) {
+	
+					modifier = 'CTRL';
+	
+				} else if ( event.shiftKey ) {
+	
+					modifier = 'SHIFT';
+	
+				}
+	
+				const mouseOp = this.getOpFromAction( 'WHEEL', modifier );
+	
+				if ( mouseOp != null ) {
+	
+					event.preventDefault();
+					this.dispatchEvent( _startEvent );
+	
+					const notchDeltaY = 125;    //distance of one notch of mouse wheel
+					let sgn = event.deltaY / notchDeltaY;
+					
+					let size = 1;
+	
+					if ( sgn > 0 ) {
+	
+						size =  1 / this.scaleFactor;
+	
+					} else if ( sgn < 0 ) {
+	
+						size = this.scaleFactor;
+	
+					}
+	
+					switch ( mouseOp ) {
+	
+						case 'ZOOM':
+							
+							this.updateTbState( STATE.SCALE, true );
+	
+							if ( sgn > 0 ) {
+	
+								size =  1 / ( Math.pow( this.scaleFactor, sgn ) );
+				
+							} else if ( sgn < 0 ) {
+				
+								size = Math.pow( this.scaleFactor, -sgn );
+				
+							}
+				
+							if ( this.cursorZoom && this.enablePan ) {
+				
+								let scalePoint;
+			
+								if ( this.camera.isOrthographicCamera ) {
+				
+									scalePoint = this.unprojectOnTbPlane( this.camera, event.clientX, event.clientY, this.domElement ).applyQuaternion( this.camera.quaternion ).multiplyScalar (1 / this.camera.zoom ).add( this._gizmos.position );
+				
+								} else if ( this.camera.isPerspectiveCamera ) {
+				
+									scalePoint = this.unprojectOnTbPlane( this.camera, event.clientX, event.clientY, this.domElement ).applyQuaternion( this.camera.quaternion ).add( this._gizmos.position );
+				
+								}
+				
+								this.applyTransformMatrix( this.scale( size, scalePoint ) );
+				
+							} else {
+				
+								this.applyTransformMatrix( this.scale( size, this._gizmos.position ) );
+				
+							}
+							
+							if ( this._grid != null )  {
+	
+								this.disposeGrid();
+								this.drawGrid();
+				
+							}
+							
+							this.updateTbState( STATE.IDLE, false );
+							
+							this.dispatchEvent( _changeEvent );
+							this.dispatchEvent( _endEvent );
+	
+							break;
+					
+						case 'FOV':
+	
+							if ( this.camera.isPerspectiveCamera ) {
+	
+								this.updateTbState( STATE.FOV, true );
+	
+	
+								//Vertigo effect
+	
+								//	  fov / 2
+								//		|\
+								//		| \
+								//		|  \
+								//	x	|	\
+								//		| 	 \
+								//		| 	  \
+								//		| _ _ _\
+								//			y
+	
+								//check for iOs shift shortcut
+								if ( event.deltaX != 0 ) {
+								
+									sgn = event.deltaX / notchDeltaY;
+								
+									size = 1;
+								
+									if ( sgn > 0 ) {
+									
+										size =  1 / ( Math.pow( this.scaleFactor, sgn ) );
+									
+									} else if ( sgn < 0 ) {
+									
+										size = Math.pow( this.scaleFactor, -sgn );
+									
+									}
+								
+								}
+							
+								this._v3_1.setFromMatrixPosition(this._cameraMatrixState);
+								const x = this._v3_1.distanceTo(this._gizmos.position);
+								let xNew = x / size;	//distance between camera and gizmos if scale(size, scalepoint) would be performed
+							
+								//check min and max distance
+								xNew = THREE.MathUtils.clamp( xNew, this.minDistance, this.maxDistance );
+							
+								const y = x * Math.tan( THREE.MathUtils.DEG2RAD * this.camera.fov * 0.5 );
+							
+								//calculate new fov
+								let newFov = THREE.MathUtils.RAD2DEG * ( Math.atan( y / xNew ) * 2 );
+	
+								//check min and max fov
+								if ( newFov > this.maxFov ) {
+								
+									newFov = this.maxFov;
+								
+								} else if ( newFov < this.minFov ) {
+								
+									newFov = this.minFov;
+								
+								}
+							
+								const newDistance = y / Math.tan( THREE.MathUtils.DEG2RAD * ( newFov / 2 ) );
+								size = x / newDistance;
+							
+								this.setFov( newFov );
+								this.applyTransformMatrix( this.scale( size, this._gizmos.position, false ) );
+	
+							}
+	
+							if ( this._grid != null)  {
+	
+								this.disposeGrid();
+								this.drawGrid();
+				
+							}
+							
+							this.updateTbState( STATE.IDLE, false );
+							
+							this.dispatchEvent( _changeEvent );
+							this.dispatchEvent( _endEvent );
+	
+							break;
+	
+					}
+	
+				}
+	
+			}
+	
+		};
+	
+		onKeyDown = ( event ) => {
+	
+			if ( event.key == 'c' ) {
+	
+				if ( event.ctrlKey || event.metaKey) {
+	
+					this.copyState();
+	
+				}
+	
+			} else if ( event.key == 'v' ) {
+	
+				if ( event.ctrlKey || event.metaKey) {
+	
+					this.pasteState();
+	
+				}
+	
+			}
+	
+		};
+	
+		onSinglePanStart = ( event, operation ) => {
+	
+			if ( this.enabled ) {
+
+				this.dispatchEvent( _startEvent );
+				
+				this.setCenter( event.clientX, event.clientY );
+	
+				switch ( operation ) {
+	
+					case 'PAN':
+					
+						if ( !this.enablePan ) {
+	
+							return;
+	
+						}
+	
+						if (this._animationId != -1) {
+	
+							cancelAnimationFrame( this._animationId );
+							this._animationId = -1;
+							this._timeStart = -1;
+	
+							this.activateGizmos( false );
+							this.dispatchEvent( _changeEvent );
+						
+						}
+	
+						this.updateTbState( STATE.PAN, true );
+						this._startCursorPosition.copy( this.unprojectOnTbPlane( this.camera, _center.x, _center.y, this.domElement ) );
+						if ( this.enableGrid ) {
+	
+							this.drawGrid();
+							this.dispatchEvent( _changeEvent );
+	
+						}
+						
+						break;
+				
+					case 'ROTATE':
+						
+						if ( !this.enableRotate ) {
+	
+							return;
+	
+						}
+	
+						if (this._animationId != -1) {
+	
+							cancelAnimationFrame( this._animationId );
+							this._animationId = -1;
+							this._timeStart = -1;
+						
+						}
+	
+						this.updateTbState( STATE.ROTATE, true );
+						this._startCursorPosition.copy( this.unprojectOnTbSurface( this.camera, _center.x, _center.y, this.domElement, this._tbRadius ) );
+						this.activateGizmos( true );
+						if ( this.enableAnimations ) {
+	
+							this._timePrev = this._timeCurrent = performance.now();
+							this._angleCurrent = this._anglePrev = 0;
+							this._cursorPosPrev.copy( this._startCursorPosition );
+							this._cursorPosCurr.copy( this._cursorPosPrev );
+							this._wCurr = 0;
+							this._wPrev = this._wCurr;
+	
+						}
+	
+						this.dispatchEvent( _changeEvent );
+						break;
+	
+						case 'FOV':
+							
+							if ( !this.camera.isPerspectiveCamera || !this.enableZoom ) {
+	
+								return;
+	
+							}
+	
+							if ( this._animationId != -1 ) {
+	
+								cancelAnimationFrame( this._animationId );
+								this._animationId = -1;
+								this._timeStart = -1;
+		
+								this.activateGizmos( false );
+								this.dispatchEvent( _changeEvent );
+							
+							}
+	
+							this.updateTbState( STATE.FOV, true );
+							this._startCursorPosition.setY( this.getCursorNDC( _center.x, _center.y, this.domElement ).y * 0.5 );
+							this._currentCursorPosition.copy( this._startCursorPosition );
+							break;
+	
+						case 'ZOOM':
+	
+							if ( !this.enableZoom ) {
+	
+								return;
+	
+							}
+	
+							if ( this._animationId != -1 ) {
+	
+								cancelAnimationFrame( this._animationId );
+								this._animationId = -1;
+								this._timeStart = -1;
+		
+								this.activateGizmos( false );
+								this.dispatchEvent( _changeEvent );
+							
+							}
+	
+							this.updateTbState( STATE.SCALE, true );
+							this._startCursorPosition.setY( this.getCursorNDC( _center.x, _center.y, this.domElement ).y * 0.5 );
+							this._currentCursorPosition.copy( this._startCursorPosition );
+							break;
+				}
+	
+			}
+	
+		};
+	
+		onSinglePanMove = ( event ) => {
+	
+			if ( this.enabled ) {
+
+				const restart = opState != this._state;
+				this.setCenter( event.clientX, event.clientY );
+	
+				switch ( opState ) {
+	
+					case STATE.PAN:
+	
+						if ( this.enablePan ) {
+	
+							if ( restart ) {
+	
+								//switch to pan operation
+	
+								this.dispatchEvent( _endEvent );
+								this.dispatchEvent( _startEvent );
+			
+								this.updateTbState( opState, true );
+								this._startCursorPosition.copy( this.unprojectOnTbPlane( this.camera, _center.x, _center.y, this.domElement ) );
+								if ( this.enableGrid ) {
+			
+									this.drawGrid();
+			
+								}
+	
+								this.activateGizmos( false );
+		
+							} else {
+		
+								//continue with pan operation
+								this._currentCursorPosition.copy( this.unprojectOnTbPlane( this.camera, _center.x, _center.y, this.domElement ) );
+								this.applyTransformMatrix( this.pan( this._startCursorPosition, this._currentCursorPosition ) );
+		
+							}
+	
+						}
+				
+						break;
+				
+					case STATE.ROTATE:
+	
+						if ( this.enableRotate ) {
+	
+							if ( restart ) {
+	
+								//switch to rotate operation
+		
+								this.dispatchEvent( _endEvent );
+								this.dispatchEvent( _startEvent );
+	
+								this.updateTbState( opState, true );
+								this._startCursorPosition.copy( this.unprojectOnTbSurface( this.camera, _center.x, _center.y, this.domElement, this._tbRadius ) );
+			
+								if ( this.enableGrid ) {
+			
+									this.disposeGrid();
+			
+								}
+			
+								this.activateGizmos( true );
+		
+							} else {
+		
+								//continue with rotate operation
+								this._currentCursorPosition.copy( this.unprojectOnTbSurface( this.camera, _center.x, _center.y, this.domElement, this._tbRadius ) );
+		
+								const distance = this._startCursorPosition.distanceTo( this._currentCursorPosition );
+								const angle = this._startCursorPosition.angleTo( this._currentCursorPosition );
+								const amount = Math.max( distance / this._tbRadius, angle );  //effective rotation angle
+								
+								this.applyTransformMatrix( this.rotate( this.calculateRotationAxis( this._startCursorPosition, this._currentCursorPosition ), amount ) );
+								
+								if ( this.enableAnimations ) {
+								
+									this._timePrev = this._timeCurrent;
+									this._timeCurrent = performance.now();
+									this._anglePrev = this._angleCurrent;
+									this._angleCurrent = amount;
+									this._cursorPosPrev.copy( this._cursorPosCurr );
+									this._cursorPosCurr.copy( this._currentCursorPosition );
+									this._wPrev = this._wCurr;
+									this._wCurr = this.calculateAngularSpeed( this._anglePrev, this._angleCurrent, this._timePrev, this._timeCurrent );
+								
+								}
+		
+							}
+	
+						}
+	
+						break;
+	
+					case STATE.SCALE:
+					
+						if ( this.enableZoom ) {
+	
+							if ( restart ) {
+	
+								//switch to zoom operation
+	
+								this.dispatchEvent( _endEvent );
+								this.dispatchEvent( _startEvent );
+	
+								this.updateTbState( opState, true );
+								this._startCursorPosition.setY( this.getCursorNDC( _center.x, _center.y, this.domElement ).y * 0.5 );
+								this._currentCursorPosition.copy( this._startCursorPosition );
+	
+								if ( this.enableGrid ) {
+			
+									this.disposeGrid();
+			
+								}
+			
+								this.activateGizmos( false );
+	
+							} else {
+	
+								//continue with zoom operation
+								const screenNotches = 8;	//how many wheel notches corresponds to a full screen pan
+								this._currentCursorPosition.setY( this.getCursorNDC( _center.x, _center.y, this.domElement ).y * 0.5 );
+	
+								let movement = this._currentCursorPosition.y - this._startCursorPosition.y;
+	
+								let size = 1 ;
+	
+								if ( movement < 0 ) {
+	
+									size = 1 / ( Math.pow( this.scaleFactor, -movement * screenNotches ) );
+					
+								} else if ( movement > 0 ) {
+					
+									size = Math.pow( this.scaleFactor, movement * screenNotches );
+					
+								}
+	
+								this.applyTransformMatrix( this.scale( size, this._gizmos.position ) );
+	
+							}
+	
+						}
+	
+						break;
+				
+					case STATE.FOV:
+	
+						if ( this.enableZoom && this.camera.isPerspectiveCamera ) {
+	
+							if ( restart ) {
+	
+								//switch to fov operation
+	
+								this.dispatchEvent( _endEvent );
+								this.dispatchEvent( _startEvent );
+	
+								this.updateTbState( opState, true );
+								this._startCursorPosition.setY( this.getCursorNDC( _center.x, _center.y, this.domElement ).y * 0.5 );
+								this._currentCursorPosition.copy( this._startCursorPosition );
+	
+								if ( this.enableGrid ) {
+			
+									this.disposeGrid();
+			
+								}
+			
+								this.activateGizmos( false );
+	
+							} else {
+	
+								//continue with fov operation
+								const screenNotches = 8;	//how many wheel notches corresponds to a full screen pan
+								this._currentCursorPosition.setY( this.getCursorNDC( _center.x, _center.y, this.domElement ).y * 0.5 );
+	
+								let movement = this._currentCursorPosition.y - this._startCursorPosition.y;
+	
+								let size = 1 ;
+	
+								if ( movement < 0 ) {
+	
+									size = 1 / ( Math.pow( this.scaleFactor, -movement * screenNotches ) );
+					
+								} else if ( movement > 0 ) {
+					
+									size = Math.pow( this.scaleFactor, movement * screenNotches );
+					
+								}
+	
+								this._v3_1.setFromMatrixPosition( this._cameraMatrixState );
+								const x = this._v3_1.distanceTo( this._gizmos.position );
+								let xNew = x / size; //distance between camera and gizmos if scale(size, scalepoint) would be performed
+								
+								//check min and max distance
+								xNew = THREE.MathUtils.clamp( xNew, this.minDistance, this.maxDistance );
+					
+								const y = x * Math.tan( THREE.MathUtils.DEG2RAD * this._fovState * 0.5 );
+					
+								//calculate new fov
+								let newFov = THREE.MathUtils.RAD2DEG * ( Math.atan( y / xNew ) * 2 );
+								
+								//check min and max fov
+								newFov = THREE.MathUtils.clamp( newFov, this.minFov, this.maxFov );
+					
+								const newDistance = y / Math.tan( THREE.MathUtils.DEG2RAD * ( newFov / 2 ) );
+								size = x / newDistance;
+								this._v3_2.setFromMatrixPosition( this._gizmoMatrixState );
+					
+								this.setFov( newFov );
+								this.applyTransformMatrix( this.scale( size, this._v3_2, false ) );
+					
+								//adjusting distance
+								let direction = this._gizmos.position.clone().sub(this.camera.position).normalize().multiplyScalar( newDistance / x );
+								this._m4_1.makeTranslation( direction.x, direction.y, direction.z );
+								
+							}
+	
+						}
+					
+						break;
+	
+				}
+	
+				this.dispatchEvent( _changeEvent );
+	
+			}
+	
+		};
+	
+		onSinglePanEnd = ( event ) => {
+	
+			if ( this._state == STATE.ROTATE ) {
+
+			
+				if ( !this.enableRotate ) {
+	
+					return;
+	
+				}
+	
+				if ( this.enableAnimations ) {
+	
+					//perform rotation animation
+					const deltaTime = ( performance.now() - this._timeCurrent );
+					if ( deltaTime < 120 ) {
+	
+						let w = Math.abs( ( this._wPrev + this._wCurr ) / 2 );
+	
+						const self = this;
+						this._animationId = window.requestAnimationFrame( function( t ) {
+	
+							self.updateTbState( STATE.ANIMATION_ROTATE, true );
+							const rotationAxis = self.calculateRotationAxis( self._cursorPosPrev, self._cursorPosCurr );
+	
+							self.onRotationAnim( t, rotationAxis, Math.min( w, self.wMax ) );
+	
+						} );
+	
+					} else {
+	
+						//cursor has been standing still for over 120 ms since last movement
+						this.updateTbState( STATE.IDLE, false );
+						this.activateGizmos( false );
+						this.dispatchEvent( _changeEvent );
+	
+					}
+	
+				} else {
+	
+					this.updateTbState( STATE.IDLE, false );
+					this.activateGizmos( false );
+					this.dispatchEvent( _changeEvent );
+	
+				}
+	
+			} else if ( this._state == STATE.PAN || this._state == STATE.IDLE ) {
+	
+				this.updateTbState( STATE.IDLE, false );
+	
+				if ( this.enableGrid ) {
+	
+					this.disposeGrid();
+	
+				}
+	
+				this.activateGizmos( false );
+				this.dispatchEvent( _changeEvent );
+	
+	
+			}
+	
+			this.dispatchEvent( _endEvent );
+	
+		};
+	
+		onDoubleTap = ( event ) => {
+	
+			if ( this.enabled && this.enablePan && this.scene != null ) {
+	
+				this.dispatchEvent( _startEvent );
+	
+				this.setCenter( event.clientX, event.clientY );
+				const hitP = this.unprojectOnObj( this.getCursorNDC( _center.x, _center.y, this.domElement ), this.camera );
+	
+				if ( hitP != null && this.enableAnimations ) {
+	
+					const self = this;
+					if ( this._animationId != -1) {
+	
+						window.cancelAnimationFrame( this._animationId );
+	
+					}
+	
+					this._timeStart = -1;
+					this._animationId = window.requestAnimationFrame( function( t ) {
+	
+						self.updateTbState( STATE.ANIMATION_FOCUS, true );
+						self.onFocusAnim( t, hitP, self._cameraMatrixState, self._gizmoMatrixState );
+	
+					} );
+	
+				} else if ( hitP != null && !this.enableAnimations ) {
+	
+					this.updateTbState( STATE.FOCUS, true );
+					this.focus( hitP, this.scaleFactor );
+					this.updateTbState( STATE.IDLE, false );
+					this.dispatchEvent (_changeEvent );
+	
+				}
+	
+			}
+	
+			this.dispatchEvent( _endEvent );
+			
+		};
+	
+		onDoublePanStart = ( event ) => {
+	
+			if ( this.enabled && this.enablePan ) {
+	
+				this.dispatchEvent( _startEvent );
+	
+				this.updateTbState( STATE.PAN, true );
+		
+				this.setCenter( ( this._touchCurrent[ 0 ].clientX + this._touchCurrent[ 1 ].clientX ) / 2, ( this._touchCurrent[ 0 ].clientY + this._touchCurrent[ 1 ].clientY ) / 2 );
+				this._startCursorPosition.copy( this.unprojectOnTbPlane( this.camera, _center.x, _center.y, this.domElement, true ) );
+				this._currentCursorPosition.copy( this._startCursorPosition );
+	
+				this.activateGizmos( false );
+	
+			}
+	
+		};
+	
+		onDoublePanMove = ( event ) => {
+	
+			if( this.enabled && this.enablePan ) {
+	
+				this.setCenter( ( this._touchCurrent[ 0 ].clientX + this._touchCurrent[ 1 ].clientX ) / 2, ( this._touchCurrent[ 0 ].clientY + this._touchCurrent[ 1 ].clientY ) / 2 );
+	
+				if ( this._state != STATE.PAN ) {
+	
+					this.updateTbState( STATE.PAN, true );
+					this._startCursorPosition.copy( this._currentCursorPosition );
+	
+				}
+		
+				this._currentCursorPosition.copy( this.unprojectOnTbPlane( this.camera, _center.x, _center.y, this.domElement, true ) );
+				this.applyTransformMatrix( this.pan( this._startCursorPosition, this._currentCursorPosition, true ) );
+				this.dispatchEvent( _changeEvent );
+			}
+	
+		};
+	
+		onDoublePanEnd = ( event ) => {
+	
+			this.updateTbState( STATE.IDLE, false );
+			this.dispatchEvent( _endEvent );
+	
+		};
+	
+	
+		onRotateStart = ( event ) => {
+	
+			if ( this.enabled && this.enableRotate ) {
+	
+				this.dispatchEvent( _startEvent );
+	
+				this.updateTbState( STATE.ZROTATE, true );
+	
+				//this._startFingerRotation = event.rotation;
+	
+				this._startFingerRotation = this.getAngle( this._touchCurrent[ 1 ], this._touchCurrent[ 0 ] ) + this.getAngle( this._touchStart[ 1 ], this._touchStart[ 0 ] );
+				this._currentFingerRotation = this._startFingerRotation;
+	
+				this.camera.getWorldDirection( this._rotationAxis );  //rotation axis
+	
+				if ( !this.enablePan && !this.enableZoom ) {
+	
+					this.activateGizmos( true );
+	
+				}
+	
+			}
+	
+		};
+	
+		onRotateMove = ( event ) => {
+	
+			if ( this.enabled && this.enableRotate ) {
+	
+				this.setCenter( ( this._touchCurrent[ 0 ].clientX + this._touchCurrent[ 1 ].clientX ) / 2, ( this._touchCurrent[ 0 ].clientY + this._touchCurrent[ 1 ].clientY ) / 2 );
+				let rotationPoint;
+		
+				if ( this._state != STATE.ZROTATE ) {
+	
+					this.updateTbState( STATE.ZROTATE, true );
+					this._startFingerRotation = this._currentFingerRotation;
+	
+				}
+		
+				//this._currentFingerRotation = event.rotation;
+				this._currentFingerRotation = this.getAngle( this._touchCurrent[ 1 ], this._touchCurrent[ 0 ] ) + this.getAngle( this._touchStart[ 1 ], this._touchStart[ 0 ] );
+	
+				if ( !this.enablePan ) {
+	
+					rotationPoint = new THREE.Vector3().setFromMatrixPosition( this._gizmoMatrixState );
+	
+				} else {
+	
+					this._v3_2.setFromMatrixPosition( this._gizmoMatrixState );
+					rotationPoint = this.unprojectOnTbPlane( this.camera, _center.x, _center.y, this.domElement ).applyQuaternion( this.camera.quaternion ).multiplyScalar( 1 / this.camera.zoom ).add( this._v3_2 );
+	
+				}
+	
+				const amount = THREE.MathUtils.DEG2RAD * ( this._startFingerRotation - this._currentFingerRotation );
+	
+				this.applyTransformMatrix( this.zRotate( rotationPoint, amount ) );
+				this.dispatchEvent( _changeEvent );
+	
+			}
+	
+		};
+	
+		onRotateEnd = () => {
+	
+			this.updateTbState( STATE.IDLE, false );
+			this.activateGizmos( false );
+			this.dispatchEvent( _endEvent );
+	
+		};
+	
+		onPinchStart = ( event ) => {
+	
+			if ( this.enabled && this.enableZoom ) {
+	
+				this.dispatchEvent( _startEvent );
+				this.updateTbState( STATE.SCALE, true );
+		
+				this._startFingerDistance = this.calculatePointersDistance( this._touchCurrent[ 0 ], this._touchCurrent[ 1 ] );
+				this._currentFingerDistance =  this._startFingerDistance;
+	
+				this.activateGizmos( false );
+	
+			}
+	
+		};
+	
+		onPinchMove = ( event ) => {
+	
+			if ( this.enabled && this.enableZoom ) {
+	
+				this.setCenter( ( this._touchCurrent[ 0 ].clientX + this._touchCurrent[ 1 ].clientX ) / 2, ( this._touchCurrent[ 0 ].clientY + this._touchCurrent[ 1 ].clientY ) / 2 );
+				const minDistance = 12; //minimum distance between fingers (in css pixels)
+		
+				if ( this._state != STATE.SCALE ) {
+	
+					this._startFingerDistance = this._currentFingerDistance;
+					this.updateTbState( STATE.SCALE, true );
+	
+				}
+		
+				this._currentFingerDistance = Math.max( this.calculatePointersDistance( this._touchCurrent[ 0 ], this._touchCurrent[ 1 ] ), minDistance * this._devPxRatio );
+				const amount = this._currentFingerDistance / this._startFingerDistance;
+		
+				let scalePoint;
+	
+				if ( !this.enablePan ) {
+	
+					scalePoint = this._gizmos.position;
+	
+				} else {
+	
+					if ( this.camera.isOrthographicCamera ) {
+	
+						scalePoint = this.unprojectOnTbPlane( this.camera, _center.x, _center.y, this.domElement )
+							.applyQuaternion( this.camera.quaternion )
+							.multiplyScalar( 1 / this.camera.zoom )
+							.add( this._gizmos.position );
+	
+					}
+					else if ( this.camera.isPerspectiveCamera ) {
+	
+						scalePoint = this.unprojectOnTbPlane( this.camera, _center.x, _center.y, this.domElement )
+							.applyQuaternion( this.camera.quaternion )
+							.add( this._gizmos.position );
+						
+					}
+	
+				}
+		
+				this.applyTransformMatrix( this.scale( amount, scalePoint ) );
+				this.dispatchEvent( _changeEvent );
+			}
+		};
+	
+		onPinchEnd = () => {
+	
+			this.updateTbState( STATE.IDLE, false );
+			this.dispatchEvent( _endEvent );
+	
+		};
+	
+		onTriplePanStart = ( event ) => {
+	
+			if ( this.enabled && this.enableZoom ) {
+	
+				this.dispatchEvent( _startEvent );
+	
+				this.updateTbState( STATE.SCALE, true );
+		
+				//const center = event.center;
+				let clientX = 0;
+				let clientY = 0;
+				const nFingers = this._touchCurrent.length;
+	
+				for ( let i = 0; i < nFingers; i++ ) {
+	
+					clientX += this._touchCurrent[ i ].clientX;
+					clientY += this._touchCurrent[ i ].clientY;
+	
+				}
+	
+				this.setCenter( clientX / nFingers, clientY / nFingers );
+	
+				this._startCursorPosition.setY( this.getCursorNDC( _center.x, _center.y, this.domElement ).y * 0.5 );
+				this._currentCursorPosition.copy( this._startCursorPosition );
+	
+			}
+	
+		};
+	
+		onTriplePanMove = ( event ) => {
+	
+			if ( this.enabled && this.enableZoom ) {
+	
+					//	  fov / 2
+					//		|\
+					//		| \
+					//		|  \
+					//	x	|	\
+					//		| 	 \
+					//		| 	  \
+					//		| _ _ _\
+					//			y
+	
+				//const center = event.center;
+				let clientX = 0;
+				let clientY = 0;
+				const nFingers = this._touchCurrent.length;
+	
+				for ( let i = 0; i < nFingers; i++ ) {
+	
+					clientX += this._touchCurrent[ i ].clientX;
+					clientY += this._touchCurrent[ i ].clientY;
+	
+				}
+				
+				this.setCenter( clientX / nFingers, clientY / nFingers );
+	
+				const screenNotches = 8;	//how many wheel notches corresponds to a full screen pan
+				this._currentCursorPosition.setY( this.getCursorNDC( _center.x, _center.y, this.domElement ).y * 0.5 );
+	
+				let movement = this._currentCursorPosition.y - this._startCursorPosition.y;
+	
+				let size = 1 ;
+	
+				if ( movement < 0 ) {
+	
+					size = 1 / ( Math.pow( this.scaleFactor, -movement * screenNotches ) );
+	
+				} else if ( movement > 0 ) {
+	
+					size = Math.pow( this.scaleFactor, movement * screenNotches );
+	
+				}
+	
+				this._v3_1.setFromMatrixPosition( this._cameraMatrixState );
+				const x = this._v3_1.distanceTo( this._gizmos.position );
+				let xNew = x / size; //distance between camera and gizmos if scale(size, scalepoint) would be performed
+				
+				//check min and max distance
+				xNew = THREE.MathUtils.clamp( xNew, this.minDistance, this.maxDistance );
+	
+				const y = x * Math.tan( THREE.MathUtils.DEG2RAD * this._fovState * 0.5 );
+	
+				//calculate new fov
+				let newFov = THREE.MathUtils.RAD2DEG * ( Math.atan( y / xNew ) * 2 );
+				
+				//check min and max fov
+				newFov = THREE.MathUtils.clamp( newFov, this.minFov, this.maxFov );
+	
+				const newDistance = y / Math.tan( THREE.MathUtils.DEG2RAD * ( newFov / 2 ) );
+				size = x / newDistance;
+				this._v3_2.setFromMatrixPosition( this._gizmoMatrixState );
+	
+				this.setFov( newFov );
+				this.applyTransformMatrix( this.scale( size, this._v3_2, false ) );
+	
+				//adjusting distance
+				let direction = this._gizmos.position.clone().sub(this.camera.position).normalize().multiplyScalar( newDistance / x );
+				this._m4_1.makeTranslation( direction.x, direction.y, direction.z );
+				
+				this.dispatchEvent( _changeEvent );
+	
+			}
+		
+		};
+	
+		onTriplePanEnd = ( event ) => {
+	
+			this.updateTbState( STATE.IDLE, false );
+			this.dispatchEvent( _endEvent );
+			//this.dispatchEvent( _changeEvent );
+	
+		};
+	
+		/**
+		 * Set _center's x/y coordinates
+		 * @param {Number} clientX 
+		 * @param {Number} clientY 
+		 */
+		setCenter = ( clientX, clientY ) => {
+
+			_center.x = clientX;
+			_center.y = clientY;
+
+		};
+
+			/**
+		 * Set default mouse actions
+		 */
+		initializeMouseActions = () => {
+
+			this.setMouseAction( 'PAN', 0, 'CTRL' );
+			this.setMouseAction( 'PAN', 2 );
+	
+			this.setMouseAction( 'ROTATE', 0 );
+	
+			this.setMouseAction( 'ZOOM', 'WHEEL' );
+			this.setMouseAction( 'ZOOM', 1 );
+	
+			this.setMouseAction( 'FOV', 'WHEEL', 'SHIFT' );
+			this.setMouseAction( 'FOV', 1, 'SHIFT' );
+
+		};
+
+		/**
+		 * Compare two mouse actions
+		 * @param {Object} action1 
+		 * @param {Object} action2 
+		 * @returns {Boolean} True if action1 and action 2 are the same mouse action, false otherwise
+		 */
+		compareMouseAction = ( action1, action2 ) => {
+
+			if ( action1.operation == action2.operation ) {
+
+				if ( action1.mouse == action2.mouse && action1.key == action2.key ) {
+
+					return true;
+
+				} else {
+
+					return false;
+
+				}
+
+			} else {
+
+				return false;
+
+			}
+
+		};
+
+		/**
+		 * Set a new mouse action by specifying the operation to be performed and a mouse/key combination. In case of conflict, replaces the existing one
+		 * @param {String} operation The operation to be performed ('PAN', 'ROTATE', 'ZOOM', 'FOV)
+		 * @param {*} mouse A mouse button (0, 1, 2) or 'WHEEL' for wheel notches
+		 * @param {*} key The keyboard modifier ('CTRL', 'SHIFT') or null if key is not needed
+		 * @returns {Boolean} True if the mouse action has been successfully added, false otherwise
+		 */
+		setMouseAction = ( operation, mouse, key = null ) => {
+
+			const operationInput = [ 'PAN', 'ROTATE', 'ZOOM', 'FOV' ];
+			const mouseInput = [ 0, 1, 2, 'WHEEL' ];
+			const keyInput = [ 'CTRL', 'SHIFT', null ];
+			let state;
+
+			if ( !operationInput.includes( operation ) || !mouseInput.includes( mouse ) || !keyInput.includes( key ) ) {
+
+				//invalid parameters
+				return false;
+
+			}
+
+			if ( mouse == 'WHEEL' ) {
+
+				if ( operation != 'ZOOM' && operation != 'FOV' ) {
+
+					//cannot associate 2D operation to 1D input
+					return false
+
+				}
+
+			}
+
+			switch ( operation ) {
+
+				case 'PAN':
+
+					state = STATE.PAN;
+					break;
+			
+				case 'ROTATE':
+
+					state = STATE.ROTATE;
+					break;
+
+				case 'ZOOM':
+
+					state = STATE.SCALE;
+					break;
+			
+				case 'FOV':
+
+					state = STATE.FOV;
+					break;
+
+			}
+
+			const action = {
+
+				operation: operation,
+				mouse: mouse,
+				key: key,
+				state: state
+
+			};
+
+			for( let i = 0; i < this.mouseActions.length; i++ ) {
+
+				if ( this.mouseActions[ i ].mouse == action.mouse && this.mouseActions[ i ].key == action.key ) {
+
+					this.mouseActions.splice( i, 1, action );
+					return true;
+
+				}
+
+			}
+
+			this.mouseActions.push( action );
+			return true;
+
+		};
+
+		/**
+		 * Remove a mouse action by specifying its mouse/key combination
+		 * @param {*} mouse A mouse button (0, 1, 2) or 'WHEEL' for wheel notches
+		 * @param {*} key The keyboard modifier ('CTRL', 'SHIFT') or null if key is not needed
+		 * @returns {Boolean} True if the operation has been succesfully removed, false otherwise
+		 */
+		unsetMouseAction = ( mouse, key = null ) => {
+
+			for( let i = 0; i < this.mouseActions.length; i++ ) {
+
+				if ( this.mouseActions[ i ].mouse == mouse && this.mouseActions[ i ].key == key ) {
+
+					this.mouseActions.splice( i, 1 );
+					return true;
+
+				}
+
+			}
+
+			return false;
+
+		};
+
+		/**
+		 * Return the operation associated to a mouse/keyboard combination
+		 * @param {*} mouse A mouse button (0, 1, 2) or 'WHEEL' for wheel notches
+		 * @param {*} key The keyboard modifier ('CTRL', 'SHIFT') or null if key is not needed
+		 * @returns The operation if it has been found, null otherwise
+		 */
+		getOpFromAction = ( mouse, key ) => {
+
+			let action;
+
+			for( let i = 0; i < this.mouseActions.length; i++ ) {
+
+				action = this.mouseActions[ i ];
+				if( action.mouse == mouse && action.key == key ) {
+
+					return action.operation;
+
+				}
+
+			}
+
+			if ( key != null ) {
+
+				for( let i = 0; i < this.mouseActions.length; i++ ) {
+
+					action = this.mouseActions[ i ];
+					if ( action.mouse == mouse && action.key == null ) {
+					
+						return action.operation;
+					
+					}
+				
+				}
+
+			}
+
+			return null;
+
+		};
+
+		/**
+		 * Get the operation associated to mouse and key combination and returns the corresponding FSA state
+		 * @param {Number} mouse Mouse button 
+		 * @param {String} key Keyboard modifier
+		 * @returns The FSA state obtained from the operation associated to mouse/keyboard combination
+		 */
+		getOpStateFromAction = ( mouse, key ) => {
+
+			let action;
+
+			for( let i = 0; i < this.mouseActions.length; i++ ) {
+
+				action = this.mouseActions[ i ];
+				if( action.mouse == mouse && action.key == key ) {
+
+					return action.state;
+
+				}
+
+			}
+
+			if ( key != null ) {
+
+				for( let i = 0; i < this.mouseActions.length; i++ ) {
+
+					action = this.mouseActions[ i ];
+					if ( action.mouse == mouse && action.key == null ) {
+					
+						return action.state;
+					
+					}
+				
+				}
+
+			}
+
+			return null;
+
+		};
+
+		/**
+		 * Calculate the angle between two pointers
+		 * @param {PointerEvent} p1 
+		 * @param {PointerEvent} p2
+		 * @returns {Number} The angle between two pointers in degrees
+		 */
+		getAngle = ( p1, p2  ) => {
+
+			return Math.atan2( p2.clientY - p1.clientY, p2.clientX - p1.clientX ) * 180 / Math.PI;
+
+		};
+
+		/**
+	 	* Update a PointerEvent inside current pointerevents array
+	 	* @param {PointerEvent} event
+	 	*/
+		updateTouchEvent = ( event ) => {
+
+			for ( let i = 0; i < this._touchCurrent.length; i++ ) {
+	
+				if ( this._touchCurrent[ i ].pointerId == event.pointerId ) {
+	
+					this._touchCurrent.splice( i, 1, event )
+					break;
+	
+				}
+	
+			}
+	
+		};
+	
+		/**
+		 * Apply a transformation matrix, to the camera and gizmos
+		 * @param {Object} transformation Object containing matrices to apply to camera and gizmos
+		 */
+		applyTransformMatrix( transformation ) {
+	
+			if ( transformation.camera != null ) {
+	
+				this._m4_1.copy( this._cameraMatrixState ).premultiply( transformation.camera );
+				this._m4_1.decompose( this.camera.position, this.camera.quaternion, this.camera.scale );
+				this.camera.updateMatrix();
+	
+				//update camera up vector
+				if ( this._state == STATE.ROTATE || this._state == STATE.ZROTATE || this._state == STATE.ANIMATION_ROTATE ) {
+	
+					this.camera.up.copy( this._upState ).applyQuaternion( this.camera.quaternion );
+	
+				}
+	
+			}
+	
+			if ( transformation.gizmos != null ) {
+	
+				this._m4_1.copy( this._gizmoMatrixState ).premultiply( transformation.gizmos );
+				this._m4_1.decompose( this._gizmos.position, this._gizmos.quaternion, this._gizmos.scale );
+				this._gizmos.updateMatrix();
+	
+			}
+	
+			if ( this._state == STATE.SCALE || this._state == STATE.FOCUS || this._state == STATE.ANIMATION_FOCUS) {
+	
+				this._tbRadius = this.calculateTbRadius(this.camera);
+	
+				if ( this.adjustNearFar ) {
+	
+					const cameraDistance = this.camera.position.distanceTo( this._gizmos.position );
+					
+					const bb = new THREE.Box3();
+					bb.setFromObject( this._gizmos );
+					const sphere = new THREE.Sphere();
+					bb.getBoundingSphere( sphere );
+	
+					const adjustedNearPosition = Math.max( this._nearPos0, sphere.radius + sphere.center.length() );
+					const regularNearPosition = cameraDistance - this._initialNear;
+	
+					const minNearPos = Math.min( adjustedNearPosition, regularNearPosition );
+					this.camera.near = cameraDistance - minNearPos;
+	
+	
+					const adjustedFarPosition = Math.min( this._farPos0, -sphere.radius + sphere.center.length() );
+					const regularFarPosition = cameraDistance - this._initialFar;
+	
+					const minFarPos = Math.min( adjustedFarPosition, regularFarPosition );
+					this.camera.far = cameraDistance - minFarPos;
+	
+					this.camera.updateProjectionMatrix();
+	
+				} else {
+	
+					let update = false;
+	
+					if (this.camera.near != this._initialNear ) {
+	
+						this.camera.near = this._initialNear;
+						update = true;
+	
+					}
+	
+					if ( this.camera.far != this._initialFar ) {
+	
+						this.camera.far = this._initialFar;
+						update = true;
+	
+					}
+	
+					if ( update ) {
+	
+						this.camera.updateProjectionMatrix();
+	
+					}
+	
+				}
+	
+			}
+	
+		};
+	
+		/**
+		 * Calculate the angular speed
+		 * @param {Number} p0 Position at t0 
+		 * @param {Number} p1 Position at t1
+		 * @param {Number} t0 Initial time in milliseconds
+		 * @param {Number} t1 Ending time in milliseconds
+		 */
+		calculateAngularSpeed = ( p0, p1, t0, t1 ) => {
+	
+			const s = p1 - p0;
+			const t = ( t1 - t0 ) / 1000;
+			if ( t == 0 ) {
+	
+				return 0;
+	
+			}
+	
+			return s / t;
+	
+		};
+	
+		/**
+		 * Calculate the distance between two pointers
+		 * @param {PointerEvent} p0 The first pointer
+		 * @param {PointerEvent} p1 The second pointer
+		 * @returns {number} The distance between the two pointers 
+		 */
+		calculatePointersDistance = ( p0, p1 ) => {
+	
+			return Math.sqrt( Math.pow( p1.clientX - p0.clientX, 2 ) + Math.pow( p1.clientY - p0.clientY, 2 ) );
+	
+		};
+	
+		/**
+		 * Calculate the rotation axis as the vector perpendicular between two vectors
+		 * @param {THREE.Vector3} vec1 The first vector
+		 * @param {THREE.Vector3} vec2 The second vector
+		 * @returns {THREE.Vector3} The normalized rotation axis
+		 */
+		calculateRotationAxis = ( vec1, vec2 ) => {
+	
+			this._rotationMatrix.extractRotation( this._cameraMatrixState );
+			this._quat.setFromRotationMatrix( this._rotationMatrix );
+	
+			this._rotationAxis.crossVectors( vec1, vec2 ).applyQuaternion( this._quat );
+			return this._rotationAxis.normalize().clone();
+	
+		};
+	
+		/**
+		 * Calculate the trackball radius so that gizmo's diamater will be 2/3 of the minimum side of the camera frustum
+		 * @param {Camera} camera 
+		 * @returns {Number} The trackball radius
+		 */
+		calculateTbRadius = ( camera ) => {
+	
+			const factor = 0.67;
+			const distance = camera.position.distanceTo( this._gizmos.position );
+	
+			if ( camera.type == 'PerspectiveCamera' ) {
+	
+				const halfFovV = THREE.MathUtils.DEG2RAD * camera.fov * 0.5;  //vertical fov/2 in radians
+				const halfFovH = Math.atan( ( camera.aspect ) * Math.tan( halfFovV ) ); //horizontal fov/2 in radians
+				return Math.tan( Math.min( halfFovV, halfFovH ) ) * distance * factor;
+	
+			} else if ( camera.type == 'OrthographicCamera' ) {
+	
+				return Math.min( camera.top, camera.right ) * factor;
+	
+			}
+	
+		};
+	
+		/**
+		 * Focus operation consist of positioning the point of interest in front of the camera and a slightly zoom in
+		 * @param {THREE.Vector3} point The point of interest 
+		 * @param {Number} size Scale factor
+		 * @param {Number} amount Amount of operation to be completed (used for focus animations, default is complete full operation)
+		 */
+		focus = ( point, size, amount = 1 ) => {
+	
+			const focusPoint = point.clone();
+	
+			//move center of camera (along with gizmos) towards point of interest
+			focusPoint.sub( this._gizmos.position ).multiplyScalar( amount );
+			this._translationMatrix.makeTranslation( focusPoint.x, focusPoint.y, focusPoint.z );
+	
+			const gizmoStateTemp = this._gizmoMatrixState.clone();
+			this._gizmoMatrixState.premultiply( this._translationMatrix );
+			this._gizmoMatrixState.decompose( this._gizmos.position, this._gizmos.quaternion, this._gizmos.scale )
+	
+			const cameraStateTemp = this._cameraMatrixState.clone();
+			this._cameraMatrixState.premultiply( this._translationMatrix );
+			this._cameraMatrixState.decompose( this.camera.position, this.camera.quaternion, this.camera.scale );
+	
+			//apply zoom
+			if ( this.enableZoom ) {
+	
+				this.applyTransformMatrix( this.scale( size, this._gizmos.position ) );
+	
+			}
+	
+			this._gizmoMatrixState.copy( gizmoStateTemp );
+			this._cameraMatrixState.copy( cameraStateTemp );
+	
+		};
+	
+		/**
+		 * Draw a grid and add it to the scene
+		 */
+		drawGrid = () => {
+	
+			if ( this.scene != null ) {
+	
+				const color = 0x888888;
+				const multiplier = 3;
+				let size, divisions, maxLength, tick;
+	
+				if ( this.camera.isOrthographicCamera ) {
+	
+					const width = this.camera.right - this.camera.left;
+					const height = this.camera.bottom - this.camera.top;
+	
+					maxLength = Math.max( width, height );
+					tick = maxLength / 20;
+	
+					size = maxLength / this.camera.zoom * multiplier;
+					divisions = size / tick * this.camera.zoom;
+	
+				} else if ( this.camera.isPerspectiveCamera ) {
+	
+					const distance = this.camera.position.distanceTo( this._gizmos.position );
+					const halfFovV = THREE.MathUtils.DEG2RAD * this.camera.fov * 0.5;
+					const halfFovH = Math.atan( ( this.camera.aspect ) * Math.tan( halfFovV ) );
+	
+					maxLength = Math.tan( Math.max( halfFovV, halfFovH ) ) * distance * 2;
+					tick = maxLength / 20;
+	
+					size = maxLength * multiplier;
+					divisions = size / tick;
+	
+				}
+				
+				if ( this._grid == null ) {
+	
+					this._grid = new THREE.GridHelper( size, divisions, color, color );
+					this._grid.position.copy( this._gizmos.position );
+					this._gridPosition.copy( this._grid.position );
+					this._grid.quaternion.copy( this.camera.quaternion  );
+					this._grid.rotateX( Math.PI * 0.5 );
+			
+					this.scene.add( this._grid );
+	
+				}
+	
+			}
+	
+		};
+	
+		/**
+		 * Remove all listeners, stop animations and clean scene
+		 */
+		dispose = () => {
+	
+			if ( this._animationId != -1 ) {
+
+				window.cancelAnimationFrame( this._animationId );
+
+			}
+
+			this.domElement.removeEventListener( 'pointerdown', this.onPointerDown );
+			this.domElement.removeEventListener( 'pointercancel', this.onPointerCancel )
+			this.domElement.removeEventListener( 'wheel', this.onWheel );
+			this.domElement.removeEventListener('contextmenu', this.onContextMenu);
+
+			window.removeEventListener( 'pointermove', this.onPointerMove );
+			window.removeEventListener( 'pointerup', this.onPointerUp );
+
+			window.removeEventListener( 'resize', this.onWindowResize );
+			window.addEventListener( 'keydown', this.onKeyDown );
+
+			this.scene.remove( this._gizmos );
+			this.disposeGrid();
+	
+		};
+	
+		/**
+		 * remove the grid from the scene
+		 */
+		disposeGrid = () => {
+	
+			if ( this._grid != null && this.scene != null ) {
+	
+				this.scene.remove( this._grid );
+				this._grid = null;
+	
+			}
+	
+		};
+	
+		/**
+		 * Compute the easing out cubic function for ease out effect in animation
+		 * @param {Number} t The absolute progress of the animation in the bound of 0 (beginning of the) and 1 (ending of animation)
+		 * @returns {Number} Result of easing out cubic at time t
+		 */
+		easeOutCubic = ( t ) => {
+	
+			return 1 - Math.pow( 1 - t, 3 );
+	
+		};
+	
+		/**
+		 * Make rotation gizmos more or less visible
+		 * @param {Boolean} isActive If true, make gizmos more visible
+		 */
+		activateGizmos = ( isActive ) => {
+	
+			const gizmoX = this._gizmos.children[ 0 ];
+			const gizmoY = this._gizmos.children[ 1 ];
+			const gizmoZ = this._gizmos.children[ 2 ];
+	
+			if ( isActive ) {
+	 
+				gizmoX.material.setValues( { opacity: 1 } );
+				gizmoY.material.setValues( { opacity: 1 } );
+				gizmoZ.material.setValues( { opacity: 1 } );
+	
+			} else {  
+	
+				gizmoX.material.setValues( { opacity: 0.6 } );
+				gizmoY.material.setValues( { opacity: 0.6 } );
+				gizmoZ.material.setValues( { opacity: 0.6 } );
+	
+			}
+	
+		};
+	
+		/**
+		 * Calculate the cursor position in NDC
+		 * @param {number} x Cursor horizontal coordinate within the canvas 
+		 * @param {number} y Cursor vertical coordinate within the canvas
+		 * @param {HTMLElement} canvas The canvas where the renderer draws its output
+		 * @returns {THREE.Vector2} Cursor normalized position inside the canvas
+		 */
+		getCursorNDC = ( cursorX, cursorY, canvas ) => {
+	
+			const canvasRect = canvas.getBoundingClientRect();
+			this._v2_1.setX ( ( ( cursorX - canvasRect.left ) / canvasRect.width ) * 2 - 1 );
+			this._v2_1.setY( ( ( canvasRect.bottom - cursorY ) / canvasRect.height ) * 2 - 1 );
+			return this._v2_1.clone();
+	
+		};
+	
+		/**
+		 * Calculate the cursor position inside the canvas x/y coordinates with the origin being in the center of the canvas
+		 * @param {Number} x Cursor horizontal coordinate within the canvas 
+		 * @param {Number} y Cursor vertical coordinate within the canvas
+		 * @param {HTMLElement} canvas The canvas where the renderer draws its output
+		 * @returns {THREE.Vector2} Cursor position inside the canvas
+		 */
+		getCursorPosition = ( cursorX, cursorY, canvas ) => {
+	
+			this._v2_1.copy( this.getCursorNDC( cursorX, cursorY, canvas) );
+			this._v2_1.x *= ( this.camera.right - this.camera.left ) * 0.5;
+			this._v2_1.y *= ( this.camera.top - this.camera.bottom ) * 0.5;
+			return this._v2_1.clone();
+	
+		};
+	
+		/**
+		 * Set the camera to be controlled
+		 * @param {Camera} camera The virtual camera to be controlled
+		 */
+		setCamera = ( camera ) => {
+	
+			camera.lookAt( this._tbCenter );
+			camera.updateMatrix();
+	
+			//setting state
+			if ( camera.type == 'PerspectiveCamera' ) {
+	
+				this._fov0 = camera.fov;
+				this._fovState = camera.fov;
+	
+			}
+			this._cameraMatrixState0.copy( camera.matrix );
+			this._cameraMatrixState.copy( this._cameraMatrixState0 );
+			this._cameraProjectionState.copy( camera.projectionMatrix );
+			this._zoom0 = camera.zoom;
+			this._zoomState = this._zoom0;
+	
+			this._initialNear = camera.near;
+			this._nearPos0 = camera.position.distanceTo( this._tbCenter ) - camera.near;
+			this._nearPos = this._initialNear;
+	
+			this._initialFar = camera.far;
+			this._farPos0 = camera.position.distanceTo( this._tbCenter ) - camera.far;
+			this._farPos = this._initialFar;
+	
+			this._up0.copy( camera.up );
+			this._upState.copy( camera.up );
+	
+			this.camera = camera;
+			this.camera.updateProjectionMatrix();
+	
+			//making gizmos
+			this._tbRadius = this.calculateTbRadius( camera );
+			this.makeGizmos( this._tbCenter, this._tbRadius );
+	
+		};
+	
+		/**
+		 * Set gizmos visibility
+		 * @param {Boolean} value Value of gizmos visibility
+		 */
+		setGizmosVisible( value ) {
+	
+			this._gizmos.visible = value;
+			this.dispatchEvent( _changeEvent );
+			
+		};
+	
+		/**
+		 * Creates the rotation gizmos matching trackball center and radius
+		 * @param {THREE.Vector3} tbCenter The trackball center
+		 * @param {number} tbRadius The trackball radius
+		 */
+		makeGizmos = ( tbCenter, tbRadius ) => {
+	
+			const curve = new THREE.EllipseCurve( 0, 0, tbRadius, tbRadius );
+			const points = curve.getPoints( this._curvePts );
+	
+			//geometry
+			const curveGeometry = new THREE.BufferGeometry().setFromPoints( points );
+	
+			//material 
+			const curveMaterialX = new THREE.LineBasicMaterial( { color: 0xff8080, fog: false, transparent: true, opacity: 0.6 } );
+			const curveMaterialY = new THREE.LineBasicMaterial( { color: 0x80ff80, fog: false, transparent: true, opacity: 0.6 } );
+			const curveMaterialZ = new THREE.LineBasicMaterial( { color: 0x8080ff, fog: false, transparent: true, opacity: 0.6 } );
+	
+			//line
+			const gizmoX = new THREE.Line( curveGeometry, curveMaterialX );
+			const gizmoY = new THREE.Line( curveGeometry, curveMaterialY );
+			const gizmoZ = new THREE.Line( curveGeometry, curveMaterialZ );
+	
+			const rotation = Math.PI * 0.5;
+			gizmoX.rotation.x = rotation;
+			gizmoY.rotation.y = rotation;
+	
+	
+			//setting state
+			this._gizmoMatrixState0.identity().setPosition( tbCenter ) ;
+			this._gizmoMatrixState.copy( this._gizmoMatrixState0 );
+	
+			if ( this.camera.zoom != 1 ) {
+	
+				//adapt gizmos size to camera zoom
+				const size = 1 / this.camera.zoom;
+				this._scaleMatrix.makeScale( size, size, size );
+				this._translationMatrix.makeTranslation( -tbCenter.x, -tbCenter.y, -tbCenter.z );
+	
+				this._gizmoMatrixState.premultiply( this._translationMatrix ).premultiply( this._scaleMatrix );
+				this._translationMatrix.makeTranslation( tbCenter.x, tbCenter.y, tbCenter.z );
+				this._gizmoMatrixState.premultiply( this._translationMatrix );
+				
+			}
+	
+			this._gizmoMatrixState.decompose( this._gizmos.position, this._gizmos.quaternion, this._gizmos.scale );
+	
+			this._gizmos.clear();
+	
+			this._gizmos.add( gizmoX );
+			this._gizmos.add( gizmoY );
+			this._gizmos.add( gizmoZ );
+	
+		};
+	
+		/**
+		 * Perform animation for focus operation
+		 * @param {Number} time Instant in which this function is called as performance.now()
+		 * @param {THREE.Vector3} point Point of interest for focus operation
+		 * @param {THREE.Matrix4} cameraMatrix Camera matrix
+		 * @param {THREE.Matrix4} gizmoMatrix Gizmos matrix
+		 */
+		onFocusAnim = ( time, point, cameraMatrix, gizmoMatrix ) => {
+	
+			if ( this._timeStart == -1 ) {
+	
+				//animation start
+				this._timeStart = time;
+	
+			}
+	
+			if ( this._state == STATE.ANIMATION_FOCUS ) {
+	
+				const deltaTime = time - this._timeStart;
+				const animTime = deltaTime / this.focusAnimationTime;
+	
+				this._gizmoMatrixState.copy( gizmoMatrix );
+	
+				if ( animTime >= 1)  {
+	
+					//animation end
+					this._gizmoMatrixState.decompose( this._gizmos.position, this._gizmos.quaternion, this._gizmos.scale );
+	
+					this.focus( point, this.scaleFactor );
+	
+					this._timeStart = -1;
+					this.updateTbState( STATE.IDLE, false );
+					this.activateGizmos( false );
+		
+					this.dispatchEvent( _changeEvent ); 
+	
+				} else {
+	
+					const amount = this.easeOutCubic( animTime );
+					const size = ( ( 1 - amount ) + ( this.scaleFactor * amount ) );
+	  
+					this._gizmoMatrixState.decompose( this._gizmos.position, this._gizmos.quaternion, this._gizmos.scale );
+					this.focus( point, size, amount );
+	
+					this.dispatchEvent( _changeEvent );
+					const self = this;
+					this._animationId = window.requestAnimationFrame( function( t ) {
+	
+						self.onFocusAnim( t, point, cameraMatrix, gizmoMatrix.clone() );
+	
+					} );
+	
+				}
+	
+			} else {
+	
+				//interrupt animation
+				this._timeStart = -1;
+				this._animationId = -1;
+		
+			}
+	
+		};
+		
+		/**
+		 * Perform animation for rotation operation
+		 * @param {Number} time Instant in which this function is called as performance.now()
+		 * @param {THREE.Vector3} rotationAxis Rotation axis
+		 * @param {number} w0 Initial angular velocity
+		 */
+		onRotationAnim = ( time, rotationAxis, w0 ) => {
+	
+			if ( this._timeStart == -1 ) {
+	
+				//animation start
+				this._anglePrev = 0
+				this._angleCurrent = 0;
+				this._timeStart = time;
+	
+			}
+	
+			if ( this._state == STATE.ANIMATION_ROTATE ) {
+	
+				//w = w0 + alpha * t
+				const deltaTime = ( time - this._timeStart ) / 1000;
+				const w = w0 + ( ( -this.dampingFactor ) * deltaTime );
+				
+				if ( w > 0 ) {
+	
+					//tetha = 0.5 * alpha * t^2 + w0 * t + tetha0
+					this._angleCurrent = 0.5 * ( -this.dampingFactor ) * Math.pow( deltaTime, 2 ) + w0 * deltaTime + 0;
+					this.applyTransformMatrix( this.rotate( rotationAxis, this._angleCurrent ) );
+					this.dispatchEvent( _changeEvent );
+					const self = this;
+					this._animationId = window.requestAnimationFrame( function( t ) {
+	
+						self.onRotationAnim( t, rotationAxis, w0 );
+	
+					} );
+	
+				} else {
+	
+					this._animationId = -1;
+					this._timeStart = -1;
+
+					this.updateTbState( STATE.IDLE, false );
+					this.activateGizmos( false );
+	
+					this.dispatchEvent( _changeEvent );
+	
+				}
+	
+			} else {
+	
+				//interrupt animation
+
+				this._animationId = -1;
+				this._timeStart = -1;
+
+				if ( this._state != STATE.ROTATE ) {
+	
+					this.activateGizmos( false );
+					this.dispatchEvent ( _changeEvent );
+
+				}
+			
+			}
+	
+		};
+	
+		
+		/**
+		 * Perform pan operation moving camera between two points
+		 * @param {THREE.Vector3} p0 Initial point
+		 * @param {THREE.Vector3} p1 Ending point
+		 * @param {Boolean} adjust If movement should be adjusted considering camera distance (Perspective only)
+		 */
+		pan = ( p0, p1, adjust = false ) => {
+	
+			const movement = p0.clone().sub( p1 );
+	
+			if ( this.camera.isOrthographicCamera ) {
+	
+				//adjust movement amount
+				movement.multiplyScalar( 1 / this.camera.zoom );
+	
+			} else if ( this.camera.isPerspectiveCamera && adjust ) {
+	
+				//adjust movement amount
+				this._v3_1.setFromMatrixPosition( this._cameraMatrixState0 );	//camera's initial position
+				this._v3_2.setFromMatrixPosition( this._gizmoMatrixState0 );	//gizmo's initial position
+				const distanceFactor = this._v3_1.distanceTo( this._v3_2 ) / this.camera.position.distanceTo( this._gizmos.position );
+				movement.multiplyScalar( 1 / distanceFactor );
+	
+			}
+	
+			this._v3_1.set( movement.x, movement.y, 0 ).applyQuaternion( this.camera.quaternion );
+	
+			this._m4_1.makeTranslation( this._v3_1.x, this._v3_1.y,this. _v3_1.z );
+		   
+			this.setTransformationMatrices( this._m4_1, this._m4_1 );
+			return _transformation;
+	
+		};
+	
+		/**
+		 * Reset trackball
+		 */
+		reset = () => {
+	
+			this.camera.zoom = this._zoom0;
+		
+			if( this.camera.isPerspectiveCamera ) {
+	
+				this.camera.fov = this._fov0;
+	
+			}
+	
+			this.camera.near = this._nearPos;
+			this.camera.far = this._farPos;
+			this._cameraMatrixState.copy( this._cameraMatrixState0 );
+			this._cameraMatrixState.decompose( this.camera.position, this.camera.quaternion, this.camera.scale );
+			this.camera.up.copy( this._up0 );
+	
+			this.camera.updateMatrix();
+			this.camera.updateProjectionMatrix();
+	
+			this._gizmoMatrixState.copy (this._gizmoMatrixState0 );
+			this._gizmoMatrixState0.decompose( this._gizmos.position, this._gizmos.quaternion, this._gizmos.scale );
+			this._gizmos.updateMatrix();
+	
+			this._tbRadius = this.calculateTbRadius( this.camera );
+			this.makeGizmos(this._gizmos.position, this._tbRadius);
+	
+			this.camera.lookAt( this._gizmos.position );
+	
+			this.updateTbState( STATE.IDLE, false );
+			
+			this.dispatchEvent( _changeEvent );
+	
+		};
+	
+		/**
+		 * Rotate the camera around an axis passing by trackball's center
+		 * @param {THREE.Vector3} axis Rotation axis
+		 * @param {number} angle Angle in radians
+		 * @returns {Object} Object with 'camera' field containing transformation matrix resulting from the operation to be applied to the camera
+		 */
+		rotate = ( axis, angle ) => {
+	
+			const point = this._gizmos.position;    //rotation center
+			this._translationMatrix.makeTranslation( -point.x, -point.y, -point.z );
+			this._rotationMatrix.makeRotationAxis( axis, -angle );
+	
+			//rotate camera
+			this._m4_1.makeTranslation( point.x, point.y, point.z );
+			this._m4_1.multiply( this._rotationMatrix );
+			this._m4_1.multiply( this._translationMatrix );
+	
+			this.setTransformationMatrices( this._m4_1 );
+	
+			return _transformation;
+	
+		};
+	
+		copyState = () => {
+	
+			let state;
+			if ( this.camera.isOrthographicCamera ) {
+	
+				state = JSON.stringify( { arcballState: { 
+					
+					cameraFar: this.camera.far, 
+					cameraMatrix: this.camera.matrix, 
+					cameraNear: this.camera.near, 
+					cameraUp: this.camera.up, 
+					cameraZoom: this.camera.zoom, 
+					gizmoMatrix: this._gizmos.matrix 
+	
+				} } );
+	
+			} else if ( this.camera.isPerspectiveCamera ) {
+	
+				state = JSON.stringify( { arcballState: { 
+					cameraFar: this.camera.far,
+					cameraFov: this.camera.fov,  
+					cameraMatrix: this.camera.matrix, 
+					cameraNear: this.camera.near, 
+					cameraUp: this.camera.up, 
+					cameraZoom: this.camera.zoom, 
+					gizmoMatrix: this._gizmos.matrix 
+					
+				} } );
+	
+			}
+			
+			navigator.clipboard.writeText( state );
+	
+		};
+	
+		pasteState = () => {
+	
+			const self = this;
+			navigator.clipboard.readText().then( function resolved( value ) {
+	
+				self.setStateFromJSON( value );
+				
+			} );
+	
+		};
+	
+		/**
+		 * Save the current state of the control. This can later be recover with .reset
+		 */
+		saveState = () => {
+	 
+			this._cameraMatrixState0.copy( this.camera.matrix );
+			this._gizmoMatrixState0.copy( this._gizmos.matrix );
+			this._nearPos = this.camera.near;
+			this._farPos = this.camera.far;
+			this._zoom0 = this.camera.zoom;
+			this._up0.copy( this.camera.up );
+	
+			if ( this.camera.isPerspectiveCamera ) {
+	
+				this._fov0 = this.camera.fov;
+	
+			}
+	
+		};
+	
+		/**
+		 * Perform uniform scale operation around a given point
+		 * @param {Number} size Scale factor
+		 * @param {THREE.Vector3} point Point around which scale 
+		 * @param {Boolean} scaleGizmos If gizmos should be scaled (Perspective only)
+		 * @returns {Object} Object with 'camera' and 'gizmo' fields containing transformation matrices resulting from the operation to be applied to the camera and gizmos
+		 */
+		scale = ( size, point, scaleGizmos = true ) => {
+	
+			const scalePoint = point.clone();
+			let sizeInverse = 1 / size;
+			
+			if ( this.camera.isOrthographicCamera ) {
+	
+				//camera zoom
+				this.camera.zoom = this._zoomState;
+				this.camera.zoom *= size;
+	
+				//check min and max zoom
+				if ( this.camera.zoom > this.maxZoom ) {
+	
+					this.camera.zoom = this.maxZoom;
+					sizeInverse = this._zoomState / this.maxZoom;
+	
+				}
+				else if ( this.camera.zoom < this.minZoom ) {
+	
+					this.camera.zoom = this.minZoom;
+					sizeInverse = this._zoomState / this.minZoom;
+	
+				}
+	
+				this.camera.updateProjectionMatrix();
+	
+				this._v3_1.setFromMatrixPosition( this._gizmoMatrixState );	//gizmos position
+	
+				//scale gizmos so they appear in the same spot having the same dimension
+				this._scaleMatrix.makeScale(sizeInverse, sizeInverse, sizeInverse);
+				this._translationMatrix.makeTranslation( -this._v3_1.x, -this._v3_1.y, -this._v3_1.z );
+	
+				this._m4_2.makeTranslation( this._v3_1.x, this._v3_1.y, this._v3_1.z ).multiply( this._scaleMatrix );
+				this._m4_2.multiply( this._translationMatrix );
+	
+	
+				//move camera and gizmos to obtain pinch effect
+				scalePoint.sub( this._v3_1 );
+	
+				const amount = scalePoint.clone().multiplyScalar( sizeInverse );
+				scalePoint.sub( amount );
+	
+				this._m4_1.makeTranslation( scalePoint.x, scalePoint.y, scalePoint.z );
+				this._m4_2.premultiply( this._m4_1 );   
+	
+				this.setTransformationMatrices( this._m4_1, this._m4_2 );
+				return _transformation;
+	
+			} else if ( this.camera.isPerspectiveCamera ) {
+			   
+				this._v3_1.setFromMatrixPosition( this._cameraMatrixState );
+				this._v3_2.setFromMatrixPosition( this._gizmoMatrixState );
+	
+				//move camera
+				let distance = this._v3_1.distanceTo( scalePoint );
+				let amount = distance - ( distance * sizeInverse );
+	
+				//check min and max distance
+				const newDistance = distance - amount
+				if ( newDistance < this.minDistance ) {
+	
+					sizeInverse = this.minDistance / distance;
+					amount = distance - ( distance * sizeInverse );
+	
+				} else if ( newDistance > this.maxDistance ) {
+	
+					sizeInverse = this.maxDistance / distance;
+					amount = distance - ( distance * sizeInverse );    
+	
+				}
+	
+				let direction = scalePoint.clone().sub( this._v3_1 ).normalize().multiplyScalar( amount );
+	
+				this._m4_1.makeTranslation( direction.x, direction.y, direction.z );
+	
+				
+				if ( scaleGizmos ) {
+	
+					//scale gizmos so they appear in the same spot having the same dimension
+					const pos = this._v3_2;
+	
+					distance = pos.distanceTo( scalePoint );
+					amount = distance - ( distance * sizeInverse );
+					direction = scalePoint.clone().sub( this._v3_2 ).normalize().multiplyScalar( amount );
+	
+					this._translationMatrix.makeTranslation( pos.x, pos.y, pos.z );
+					this._scaleMatrix.makeScale( sizeInverse, sizeInverse, sizeInverse );
+	
+					this._m4_2.makeTranslation( direction.x, direction.y, direction.z ).multiply( this._translationMatrix );
+					this._m4_2.multiply( this._scaleMatrix );
+	
+					this._translationMatrix.makeTranslation( -pos.x, -pos.y, -pos.z );
+	
+					this._m4_2.multiply( this._translationMatrix );
+					this.setTransformationMatrices( this._m4_1, this._m4_2 );
+	
+	
+				} else {
+	
+					this.setTransformationMatrices( this._m4_1 );
+	
+				}
+	
+				return _transformation;
+	
+			}
+	
+		};
+	
+		/**
+		 * Set camera fov
+		 * @param {Number} value fov to be setted 
+		 */
+		setFov = ( value ) => {
+	
+			if ( this.camera.isPerspectiveCamera ) {
+	
+				this.camera.fov = THREE.MathUtils.clamp( value, this.minFov, this.maxFov );
+				this.camera.updateProjectionMatrix();
+	
+			}
+	
+		};
+	
+		/**
+		 * Set the trackball's center point
+		 * @param {Number} x X coordinate
+		 * @param {Number} y Y coordinate
+		 * @param {Number} z Z coordinate
+		 */
+		setTarget = ( x, y, z ) => {
+	
+			this._tbCenter.set( x, y, z );
+			this._gizmos.position.set( x, y, z );	//for correct radius calculation
+			this._tbRadius = this.calculateTbRadius( this.camera );
+	
+			this.makeGizmos( this._tbCenter, this._tbRadius );
+			this.camera.lookAt( this._tbCenter );
+	
+		};
+	
+		/**
+		 * Set values in transformation object
+		 * @param {THREE.Matrix4} camera Transformation to be applied to the camera 
+		 * @param {THREE.Matrix4} gizmos Transformation to be applied to gizmos
+		 */
+		 setTransformationMatrices( camera = null, gizmos = null) {
+			 
+			if ( camera != null ) {
+	
+				if ( _transformation.camera != null ) {
+	
+					_transformation.camera.copy( camera );
+	
+				} else {
+	
+					_transformation.camera = camera.clone();
+	
+				}
+	
+			} else {
+	
+				_transformation.camera = null;
+	
+			}
+	
+			if ( gizmos != null ) {
+	
+				if ( _transformation.gizmos != null ) {
+	
+					_transformation.gizmos.copy( gizmos );
+	
+				} else {
+	
+					_transformation.gizmos = gizmos.clone();
+	
+				}
+	
+			} else {
+	
+				_transformation.gizmos = null;
+	
+			}
+	
+		};
+	
+		/**
+		 * Rotate camera around its direction axis passing by a given point by a given angle
+		 * @param {THREE.Vector3} point The point where the rotation axis is passing trough
+		 * @param {Number} angle Angle in radians
+		 * @returns The computed transormation matix
+		 */
+		zRotate = ( point, angle ) => {
+	
+			this._rotationMatrix.makeRotationAxis( this._rotationAxis, angle );
+			this._translationMatrix.makeTranslation( -point.x, -point.y, -point.z );
+	
+			this._m4_1.makeTranslation( point.x, point.y, point.z );
+			this._m4_1.multiply( this._rotationMatrix );
+			this._m4_1.multiply( this._translationMatrix );
+	
+			this._v3_1.setFromMatrixPosition( this._gizmoMatrixState ).sub( point );	//vector from rotation center to gizmos position
+			this._v3_2.copy( this._v3_1 ).applyAxisAngle( this._rotationAxis, angle );	//apply rotation
+			this._v3_2.sub( this._v3_1 );
+	
+			this._m4_2.makeTranslation( this._v3_2.x, this._v3_2.y, this._v3_2.z );
+	
+			this.setTransformationMatrices( this._m4_1, this._m4_2 );
+			return _transformation;
+	
+		};
+		
+	
+		/**
+		 * Unproject the cursor on the 3D object surface
+		 * @param {THREE.Vector2} cursor Cursor coordinates in NDC
+		 * @param {Camera} camera Virtual camera
+		 * @returns {THREE.Vector3} The point of intersection with the model, if exist, null otherwise
+		 */
+		unprojectOnObj = ( cursor, camera ) => {
+	
+			const raycaster = new THREE.Raycaster();
+			raycaster.near = camera.near;
+			raycaster.far = camera.far;
+			raycaster.setFromCamera( cursor, camera );
+	
+			const intersect = raycaster.intersectObjects( this.scene.children, true );
+	
+			for ( let i = 0; i < intersect.length; i++ ) {
+	
+				if ( intersect[ i ].object.uuid != this._gizmos.uuid && intersect[ i ].face != null ) {
+	
+					return intersect[ i ].point.clone();
+	
+				}
+	
+			}
+			
+			return  null;
+		};
+	
+		/**
+		 * Unproject the cursor on the trackball surface
+		 * @param {Camera} camera The virtual camera
+		 * @param {Number} cursorX Cursor horizontal coordinate on screen
+		 * @param {Number} cursorY Cursor vertical coordinate on screen
+		 * @param {HTMLElement} canvas The canvas where the renderer draws its output
+		 * @param {number} tbRadius The trackball radius
+		 * @returns {THREE.Vector3} The unprojected point on the trackball surface
+		 */
+		unprojectOnTbSurface = ( camera, cursorX, cursorY, canvas, tbRadius ) => {
+	
+			if ( camera.type == 'OrthographicCamera' ) {
+	
+				this._v2_1.copy( this.getCursorPosition ( cursorX, cursorY, canvas ) );
+				this._v3_1.set( this._v2_1.x, this._v2_1.y, 0 );
+	
+				const x2 = Math.pow( this._v2_1.x, 2 );
+				const y2 = Math.pow( this._v2_1.y, 2 );
+				const r2 = Math.pow( this._tbRadius, 2 );
+	
+				if ( x2 + y2 <= r2 * 0.5 ) {
+	
+					//intersection with sphere
+					this._v3_1.setZ( Math.sqrt( r2 - ( x2 + y2 ) ) );
+	
+				} else {
+	
+					//intersection with hyperboloid
+					this._v3_1.setZ( ( r2 * 0.5 ) / ( Math.sqrt( x2 + y2 ) ) );
+				}
+	
+				return this._v3_1;
+	
+			} else if ( camera.type == 'PerspectiveCamera' ) {
+	
+				//unproject cursor on the near plane
+				this._v2_1.copy( this.getCursorNDC( cursorX, cursorY, canvas ) );
+	
+				this._v3_1.set( this._v2_1.x, this._v2_1.y, -1 );
+				this._v3_1.applyMatrix4( camera.projectionMatrixInverse );
+	
+				const rayDir = this._v3_1.clone().normalize(); //unprojected ray direction
+				const cameraGizmoDistance = camera.position.distanceTo( this._gizmos.position );
+				const radius2 = Math.pow( tbRadius, 2 );
+			
+				//	  camera
+				//		|\
+				//		| \
+				//		|  \
+				//	h	|	\
+				//		| 	 \
+				//		| 	  \
+				//	_ _ | _ _ _\ _ _  near plane
+				//			l
+				
+				const h = this._v3_1.z;
+				const l = Math.sqrt( Math.pow( this._v3_1.x, 2 ) + Math.pow( this._v3_1.y, 2 ) );
+	
+				if ( l == 0 ) {
+	
+					//ray aligned with camera
+					rayDir.set( this._v3_1.x, this._v3_1.y, tbRadius );
+					return rayDir;
+	
+				}
+	
+				const m = h / l;
+				const q = cameraGizmoDistance;
+			
+				/*
+				 * calculate intersection point between unprojected ray and trackball surface
+				 *|y = m * x + q
+				 *|x^2 + y^2 = r^2
+				 *
+				 * (m^2 + 1) * x^2 + (2 * m * q) * x + q^2 - r^2 = 0
+				 */
+				let a = Math.pow( m, 2 ) + 1;
+				let b = 2 * m * q;
+				let c = Math.pow( q, 2 ) - radius2;
+				let delta = Math.pow( b, 2 ) - ( 4 * a * c );
+			
+				if ( delta >= 0 ) {
+	
+					//intersection with sphere
+					this._v2_1.setX( ( -b - Math.sqrt( delta ) ) / ( 2 * a ) );
+					this._v2_1.setY( m * this._v2_1.x + q );
+	
+					let angle = THREE.MathUtils.RAD2DEG * this._v2_1.angle();
+	
+					if ( angle >= 45 ) {
+	
+						//if angle between intersection point and X' axis is >= 45°, return that point
+						//otherwise, calculate intersection point with hyperboloid
+				
+						let rayLength = Math.sqrt( Math.pow( this._v2_1.x, 2 ) + Math.pow( ( cameraGizmoDistance - this._v2_1.y ), 2 ) );
+						rayDir.multiplyScalar( rayLength );
+						rayDir.z += cameraGizmoDistance;
+						return rayDir;
+	
+					}
+	
+				} 
+	
+				//intersection with hyperboloid
+				/*
+				 *|y = m * x + q
+				 *|y = (1 / x) * (r^2 / 2)
+				 *
+				 * m * x^2 + q * x - r^2 / 2 = 0
+				 */
+	
+				a = m;
+				b = q;
+				c = -radius2 * 0.5;
+				delta = Math.pow( b, 2 ) - ( 4 * a * c );
+				this._v2_1.setX( ( -b - Math.sqrt( delta ) ) / ( 2 * a ) );
+				this._v2_1.setY( m * this._v2_1.x + q );
+	
+				let rayLength = Math.sqrt( Math.pow( this._v2_1.x, 2 ) + Math.pow( ( cameraGizmoDistance - this._v2_1.y ), 2 ) );
+	
+				rayDir.multiplyScalar( rayLength );
+				rayDir.z += cameraGizmoDistance;
+				return rayDir;
+	
+			}
+	
+		};
+	
+	
+		/**
+		 * Unproject the cursor on the plane passing through the center of the trackball orthogonal to the camera
+		 * @param {Camera} camera The virtual camera
+		 * @param {Number} cursorX Cursor horizontal coordinate on screen
+		 * @param {Number} cursorY Cursor vertical coordinate on screen
+		 * @param {HTMLElement} canvas The canvas where the renderer draws its output
+		 * @param {Boolean} initialDistance If initial distance between camera and gizmos should be used for calculations instead of current (Perspective only)
+		 * @returns {THREE.Vector3} The unprojected point on the trackball plane
+		 */
+		unprojectOnTbPlane = ( camera, cursorX, cursorY, canvas, initialDistance = false ) => {
+	
+			if ( camera.type == 'OrthographicCamera' ) {
+	
+				this._v2_1.copy( this.getCursorPosition( cursorX, cursorY, canvas ) );
+				this._v3_1.set( this._v2_1.x, this._v2_1.y, 0 );
+	
+				return this._v3_1.clone();
+	
+			} else if ( camera.type == 'PerspectiveCamera' ) {
+				
+				this._v2_1.copy( this.getCursorNDC( cursorX, cursorY, canvas ) );
+	
+				//unproject cursor on the near plane
+				this._v3_1.set( this._v2_1.x, this._v2_1.y, -1 );
+				this._v3_1.applyMatrix4( camera.projectionMatrixInverse );
+	
+				const rayDir = this._v3_1.clone().normalize();    //unprojected ray direction
+	
+				//	  camera
+				//		|\
+				//		| \
+				//		|  \
+				//	h	|	\
+				//		| 	 \
+				//		| 	  \
+				//	_ _ | _ _ _\ _ _  near plane
+				//			l
+	
+				const h = this._v3_1.z;
+				const l = Math.sqrt( Math.pow( this._v3_1.x, 2 ) + Math.pow( this._v3_1.y, 2 ) );
+				let cameraGizmoDistance;
+	
+				if ( initialDistance ) {
+	
+					cameraGizmoDistance = this._v3_1.setFromMatrixPosition( this._cameraMatrixState0 ).distanceTo( this._v3_2.setFromMatrixPosition( this._gizmoMatrixState0 ) );
+				
+				} else {
+	
+					cameraGizmoDistance = camera.position.distanceTo( this._gizmos.position );
+	
+				}
+	
+				/*
+				 * calculate intersection point between unprojected ray and the plane
+				 *|y = mx + q
+				 *|y = 0
+				 *
+				 * x = -q/m
+				*/
+				if ( l == 0 ) {
+	
+					//ray aligned with camera
+					rayDir.set( 0, 0, 0 );
+					return rayDir;
+	
+				}
+				
+				const m = h / l;
+				const q = cameraGizmoDistance;
+				const x = -q / m;
+	
+				const rayLength = Math.sqrt( Math.pow( q, 2 ) + Math.pow( x, 2 ) );
+				rayDir.multiplyScalar( rayLength );
+				rayDir.z = 0;
+				return rayDir;
+	
+			}
+			
+		};
+	
+		/**
+		 * Update camera and gizmos state
+		 */
+		updateMatrixState = () => {
+	
+			//update camera and gizmos state
+			this._cameraMatrixState.copy( this.camera.matrix );
+			this._gizmoMatrixState.copy( this._gizmos.matrix );
+	
+			if ( this.camera.isOrthographicCamera ) {
+	
+				this._cameraProjectionState.copy( this.camera.projectionMatrix );
+				this.camera.updateProjectionMatrix();
+				this._zoomState = this.camera.zoom;
+	
+			} else if ( this.camera.isPerspectiveCamera) {
+	
+				this._fovState =  this.camera.fov;
+	
+			}
+	
+		};
+	
+		/**
+		 * Update the trackball FSA
+		 * @param {STATE} newState New state of the FSA
+		 * @param {Boolean} updateMatrices If matriices state should be updated
+		 */
+		updateTbState = ( newState, updateMatrices ) => {
+	
+			this._state = newState;
+			if ( updateMatrices ) {
+	
+				this.updateMatrixState();
+	
+			}
+	
+		};
+	
+		update = () => {
+	
+			const EPS = 0.000001;
+	
+			//check min/max parameters
+			if ( this.camera.isOrthographicCamera ) {
+	
+				//check zoom
+				if ( this.camera.zoom > this.maxZoom || this.camera.zoom < this.minZoom ) {
+	
+					const newZoom = THREE.MathUtils.clamp( this.camera.zoom, this.minZoom, this.maxZoom );
+					this.applyTransformMatrix( this.scale( newZoom / this.camera.zoom, this._gizmos.position, true ) );
+							
+				}
+	
+			} else if ( this.camera.isPerspectiveCamera ) {
+	 
+				//check distance
+				const distance = this.camera.position.distanceTo( this._gizmos.position );
+	
+				if ( distance > this.maxDistance + EPS || distance < this.minDistance - EPS ) {
+	
+					const newDistance = THREE.MathUtils.clamp( distance, this.minDistance, this.maxDistance );
+					this.applyTransformMatrix( this.scale( newDistance / distance, this._gizmos.position ) );				
+					this.updateMatrixState();
+	
+				 }
+	
+				//check fov
+				if ( this.camera.fov < this.minFov || this.camera.fov > this.maxFov ) {
+	
+					this.camera.fov = THREE.MathUtils.clamp( this.camera.fov, this.minFov, this.maxFov );
+					this.camera.updateProjectionMatrix();
+	
+				}
+	
+				const oldRadius = this._tbRadius;
+				this._tbRadius = this.calculateTbRadius( this.camera );
+	
+				if ( oldRadius < this._tbRadius - EPS || oldRadius > this._tbRadius + EPS ) {
+	
+					const scale = ( this._gizmos.scale.x + this._gizmos.scale.y + this._gizmos.scale.z ) / 3; 
+					const newRadius = this._tbRadius / scale;
+					const curve = new THREE.EllipseCurve( 0, 0, newRadius, newRadius );
+					const points = curve.getPoints( this._curvePts );
+					const curveGeometry = new THREE.BufferGeometry().setFromPoints( points );
+				
+					for( let gizmo in this._gizmos.children ) {
+				
+						this._gizmos.children[ gizmo ].geometry = curveGeometry;
+				
+					}
+						
+				}
+	
+			}
+	
+			this.camera.lookAt( this._gizmos.position ); 
+	
+		};
+	
+		setStateFromJSON = ( json ) => {
+	
+			const state = JSON.parse( json );
+	
+			if ( state.arcballState != undefined ) {
+	
+				this._cameraMatrixState.fromArray( state.arcballState.cameraMatrix.elements );
+				this._cameraMatrixState.decompose( this.camera.position, this.camera.quaternion, this.camera.scale );
+				
+				this.camera.up.copy( state.arcballState.cameraUp );
+				this.camera.near = state.arcballState.cameraNear;
+				this.camera.far = state.arcballState.cameraFar;
+	
+				this.camera.zoom = state.arcballState.cameraZoom;
+	
+				if ( this.camera.isPerspectiveCamera ) {
+	
+					this.camera.fov = state.arcballState.cameraFov;
+	
+				}
+	
+				this._gizmoMatrixState.fromArray( state.arcballState.gizmoMatrix.elements );
+				this._gizmoMatrixState.decompose( this._gizmos.position, this._gizmos.quaternion, this._gizmos.scale );
+				
+				this.camera.updateMatrix();
+				this.camera.updateProjectionMatrix();
+	
+				this._gizmos.updateMatrix();
+	
+				this._tbRadius = this.calculateTbRadius( this.camera );
+				let gizmoTmp = new THREE.Matrix4().copy( this._gizmoMatrixState0 );
+				this.makeGizmos( this._gizmos.position, this._tbRadius );
+				this._gizmoMatrixState0.copy( gizmoTmp );
+	
+				this.camera.lookAt( this._gizmos.position );
+				this.updateTbState( STATE.IDLE, false );
+	
+				this.dispatchEvent( _changeEvent );
+	
+			}
+	
+		};
+	
+	};
+	
+	THREE.ArcballControls = ArcballControls;
+
+} )();

+ 3189 - 0
examples/jsm/controls/ArcballControls.js

@@ -0,0 +1,3189 @@
+import {
+	GridHelper,
+	EllipseCurve,
+	BufferGeometry,
+	Line,
+	LineBasicMaterial,
+	Raycaster,
+	Group,
+	Object3D,
+	Box3,
+	Sphere,
+	Quaternion,
+	Vector2,
+	Vector3,
+	Matrix4,
+	MathUtils
+} from '../../../build/three.module.js';
+
+//trackball state
+const STATE = {
+
+	IDLE: Symbol(),
+	ROTATE: Symbol(),
+	PAN: Symbol(),
+	SCALE: Symbol(),
+	FOV: Symbol(),
+	FOCUS: Symbol(),
+	ZROTATE: Symbol(),
+	TOUCH_MULTI: Symbol(),
+	ANIMATION_FOCUS: Symbol(),
+	ANIMATION_ROTATE: Symbol()
+
+};
+
+const INPUT = {
+
+	NONE: Symbol(),
+	ONE_FINGER: Symbol(),
+	ONE_FINGER_SWITCHED: Symbol(),
+	TWO_FINGER: Symbol(),
+	MULT_FINGER: Symbol(),
+	CURSOR: Symbol()
+
+};
+
+//cursor center coordinates
+const _center = {
+
+	x: 0,
+	y: 0
+
+};
+
+//transformation matrices for gizmos and camera
+const _transformation = {
+
+	camera: new Matrix4(),
+	gizmos: new Matrix4()
+
+};
+
+//events
+const _changeEvent = { type: 'change' };
+const _startEvent = { type: 'start' };
+const _endEvent = { type: 'end' };
+
+
+/**
+ * 
+ * @param {Camera} camera Virtual camera used in the scene
+ * @param {HTMLElement} domElement Renderer's dom element
+ * @param {Scene} scene The scene to be rendered
+ */
+class ArcballControls extends Object3D {
+
+	constructor( camera, domElement, scene = null ) {
+
+		super();
+		this.camera = null;
+		this.domElement = domElement;
+		this.scene = scene;
+
+		this.mouseActions = [];
+		this._mouseOp = null;
+
+
+		//global vectors and matrices that are used in some operations to avoid creating new objects every time (e.g. every time cursor moves)
+		this._v2_1 = new Vector2();
+		this._v3_1 = new Vector3();
+		this._v3_2 = new Vector3();
+
+		this._m4_1 = new Matrix4();
+		this._m4_2 = new Matrix4();
+
+		this._quat = new Quaternion();
+
+		//transformation matrices
+		this._translationMatrix = new Matrix4();    //matrix for translation operation
+		this._rotationMatrix = new Matrix4();   //matrix for rotation operation
+		this._scaleMatrix = new Matrix4();    //matrix for scaling operation
+
+		this._rotationAxis = new Vector3();   //axis for rotate operation
+
+
+		//camera state
+		this._cameraMatrixState = new Matrix4();
+		this._cameraProjectionState = new Matrix4();
+
+		this._fovState = 1;
+		this._upState = new Vector3();
+		this._zoomState = 1;
+		this._nearPos = 0;
+		this._farPos = 0;
+
+		this._gizmoMatrixState = new Matrix4();
+	
+		//initial values
+		this._up0 = new Vector3();
+		this._zoom0 = 1;
+		this._fov0 = 0;
+		this._initialNear = 0;
+		this._nearPos0 = 0;
+		this._initialFar = 0;
+		this._farPos0 = 0;
+		this._cameraMatrixState0 = new Matrix4();
+		this._gizmoMatrixState0 = new Matrix4();
+
+		//pointers array
+		this._button = -1;
+		this._touchStart = [];
+		this._touchCurrent = [];
+		this._input = INPUT.NONE;
+
+		//two fingers touch interaction
+		this._switchSensibility = 32;	//minimum movement to be performed to fire single pan start after the second finger has been released
+		this._startFingerDistance = 0; //distance between two fingers
+		this._currentFingerDistance = 0;
+		this._startFingerRotation = 0; //amount of rotation performed with two fingers
+		this._currentFingerRotation = 0;
+
+		//double tap
+		this._devPxRatio = 0;
+		this._downValid = true;
+		this._nclicks = 0;
+		this._downEvents = [];
+		this._downStart = 0;	//pointerDown time
+		this._clickStart = 0;	//first click time
+		this._maxDownTime = 250;
+		this._maxInterval = 300;
+		this._posThreshold = 24;
+		this._movementThreshold = 24;
+
+		//cursor positions
+		this._currentCursorPosition = new Vector3();
+		this._startCursorPosition = new Vector3();
+
+		//grid
+		this._grid = null; //grid to be visualized during pan operation
+		this._gridPosition = new Vector3();
+
+		//gizmos
+		this._gizmos = new Group();
+		this._curvePts = 128;
+
+
+		//animations
+		this._timeStart = -1; //initial time
+		this._animationId = -1;
+
+		//focus animation
+		this.focusAnimationTime = 500; //duration of focus animation in ms
+
+		//rotate animation
+		this._timePrev = 0; //time at which previous rotate operation has been detected
+		this._timeCurrent = 0;  //time at which current rotate operation has been detected
+		this._anglePrev = 0; //angle of previous rotation
+		this._angleCurrent = 0;  //angle of current rotation
+		this._cursorPosPrev = new Vector3();	//cursor position when previous rotate operation has been detected
+		this._cursorPosCurr = new Vector3();//cursor position when current rotate operation has been detected
+		this._wPrev = 0; //angular velocity of the previous rotate operation
+		this._wCurr = 0; //angular velocity of the current rotate operation
+
+
+		//parameters
+		this.adjustNearFar = false;
+		this.scaleFactor = 1.1;	//zoom/distance multiplier
+		this.dampingFactor = 25;
+		this.wMax = 20;	//maximum angular velocity allowed
+		this.enableAnimations = true;    //if animations should be performed
+		this.enableGrid = false;   //if grid should be showed during pan operation
+		this.cursorZoom = false;	//if wheel zoom should be cursor centered
+		this.minFov = 5;
+		this.maxFov = 90;
+
+		this.enabled = true;
+		this.enablePan = true;
+		this.enableRotate = true;
+		this.enableZoom = true;
+		this.enableGizmos = true;
+
+		this.minDistance = 0;
+		this.maxDistance = Infinity;
+		this.minZoom = 0;
+		this.maxZoom = Infinity;
+
+		//trackball parameters
+		this._tbCenter = new Vector3( 0, 0, 0 );
+		this._tbRadius = 1;
+
+		//FSA
+		this._state = STATE.IDLE;
+
+		this.setCamera(camera);
+
+		if ( this.scene != null ) {
+
+			this.scene.add( this._gizmos );
+		
+		}
+
+		this.domElement.style.touchAction = 'none';
+		this._devPxRatio = window.devicePixelRatio;
+
+		this.initializeMouseActions();
+
+		this.domElement.addEventListener( 'contextmenu', this.onContextMenu );
+		this.domElement.addEventListener( 'wheel', this.onWheel );
+		this.domElement.addEventListener( 'pointerdown', this.onPointerDown );
+		this.domElement.addEventListener( 'pointercancel', this.onPointerCancel )
+
+		window.addEventListener( 'keydown', this.onKeyDown );
+		window.addEventListener( 'resize', this.onWindowResize );
+
+	};
+
+	//listeners
+
+	onWindowResize = () => {
+
+		const scale = ( this._gizmos.scale.x + this._gizmos.scale.y + this._gizmos.scale.z ) / 3; 
+		this._tbRadius = this.calculateTbRadius( this.camera );
+
+		const newRadius = this._tbRadius / scale;
+		const curve = new EllipseCurve( 0, 0, newRadius, newRadius );
+		const points = curve.getPoints( this._curvePts );
+		const curveGeometry = new BufferGeometry().setFromPoints( points );
+
+
+		for( let gizmo in this._gizmos.children ) {
+
+			this._gizmos.children[ gizmo ].geometry = curveGeometry;
+
+		}
+
+		this.dispatchEvent( _changeEvent );
+
+	};
+
+	onContextMenu = ( event ) => {
+
+		if ( !this.enabled ) {
+
+			return;
+
+		}
+
+		for( let i = 0; i < this.mouseActions.length; i++ ) {
+
+			if ( this.mouseActions[ i ].mouse == 2 ) {
+
+				//prevent only if button 2 is actually used
+				event.preventDefault();
+				break;
+
+			}
+
+		}
+
+	};
+
+	onPointerCancel = ( event ) => {
+
+		this._touchStart.splice( 0, this._touchStart.length );
+		this._touchCurrent.splice( 0, this._touchCurrent.length );
+		this._input = INPUT.NONE;
+
+	};
+
+	onPointerDown = ( event ) => {
+
+		if ( event.button == 0 && event.isPrimary ) {
+
+			this._downValid = true;
+			this._downEvents.push( event );
+			this._downStart = performance.now();
+
+		} else {
+
+			this._downValid = false;
+
+		}
+
+		if ( event.pointerType == 'touch' && this._input != INPUT.CURSOR ) {
+
+			this._touchStart.push( event );
+			this._touchCurrent.push( event );
+
+			switch ( this._input ) {
+
+				case INPUT.NONE:
+
+					//singleStart
+					this._input = INPUT.ONE_FINGER;
+					this.onSinglePanStart( event, 'ROTATE' );
+
+					window.addEventListener( 'pointermove', this.onPointerMove );
+					window.addEventListener( 'pointerup', this.onPointerUp );
+
+					break;
+
+				case INPUT.ONE_FINGER:
+				case INPUT.ONE_FINGER_SWITCHED:
+
+					//doubleStart
+					this._input = INPUT.TWO_FINGER;
+
+					this.onRotateStart();
+					this.onPinchStart();
+					this.onDoublePanStart();
+
+					break;
+
+				case INPUT.TWO_FINGER:
+					
+					//multipleStart
+					this._input = INPUT.MULT_FINGER;
+					this.onTriplePanStart( event );
+					break;
+			}
+
+		} else if ( event.pointerType != 'touch' && this._input == INPUT.NONE ) {
+
+			let modifier = null;
+
+			if( event.ctrlKey || event.metaKey ) {
+
+				modifier = 'CTRL';
+
+			} else if ( event.shiftKey ) {
+
+				modifier = 'SHIFT';
+
+			}
+
+			this._mouseOp = this.getOpFromAction( event.button, modifier );
+			if ( this._mouseOp != null ) {
+	
+				window.addEventListener( 'pointermove', this.onPointerMove );
+				window.addEventListener( 'pointerup', this.onPointerUp );
+	
+				//singleStart
+				this._input = INPUT.CURSOR;
+				this._button = event.button;
+				this.onSinglePanStart( event, this._mouseOp );
+
+			}
+
+		}
+
+	};
+
+	onPointerMove = ( event ) => {
+
+		if ( event.pointerType == 'touch' && this._input != INPUT.CURSOR ) {
+
+			switch ( this._input ) {
+
+				case INPUT.ONE_FINGER:
+					
+					//singleMove
+					this.updateTouchEvent( event );
+
+					this.onSinglePanMove( event, STATE.ROTATE );
+					break;
+
+				case INPUT.ONE_FINGER_SWITCHED:
+
+					const movement = this.calculatePointersDistance( this._touchCurrent[ 0 ], event ) * this._devPxRatio;
+				
+					if ( movement >= this._switchSensibility ) {
+
+						//singleMove
+						this._input = INPUT.ONE_FINGER;
+						this.updateTouchEvent( event );
+
+						this.onSinglePanStart( event, 'ROTATE' );
+						break;
+
+					}
+
+					break;
+
+				case INPUT.TWO_FINGER:
+
+					//rotate/pan/pinchMove
+					this.updateTouchEvent( event );
+
+					this.onRotateMove();
+					this.onPinchMove();
+					this.onDoublePanMove();
+
+					break;
+
+				case INPUT.MULT_FINGER:
+
+					//multMove
+					this.updateTouchEvent( event );
+
+					this.onTriplePanMove( event );
+					break;
+			
+			}
+
+		} else if ( event.pointerType != 'touch' && this._input == INPUT.CURSOR ) {
+
+			let modifier = null;
+
+			if( event.ctrlKey || event.metaKey ) {
+
+				modifier = 'CTRL';
+
+			} else if ( event.shiftKey ) {
+
+				modifier = 'SHIFT';
+
+			}
+
+			const mouseOpState = this.getOpStateFromAction( this._button, modifier );
+
+			if ( mouseOpState != null ) {
+
+				this.onSinglePanMove( event, mouseOpState );
+
+			}
+
+		}
+
+		//checkDistance
+		if ( this._downValid ) {
+
+			const movement = this.calculatePointersDistance( this._downEvents[ this._downEvents.length -1 ], event ) * this._devPxRatio;
+			if ( movement > this._movementThreshold ) {
+	
+				this._downValid = false;
+
+			}
+
+		}
+
+	};
+
+	onPointerUp = ( event ) => {
+
+		if ( event.pointerType == 'touch' && this._input != INPUT.CURSOR ) {
+
+			let nTouch = this._touchCurrent.length;
+
+			for( let i = 0; i < nTouch; i++ ) {
+
+				if ( this._touchCurrent[ i ].pointerId == event.pointerId ) {
+
+					this._touchCurrent.splice( i, 1 );
+					this._touchStart.splice( i, 1 );
+					break;
+
+				}
+
+			}
+
+			switch ( this._input ) {
+
+				case INPUT.ONE_FINGER:
+				case INPUT.ONE_FINGER_SWITCHED:
+					
+					//singleEnd
+					window.removeEventListener( 'pointermove', this.onPointerMove );
+					window.removeEventListener( 'pointerup', this.onPointerUp );
+
+					this._input = INPUT.NONE;
+					this.onSinglePanEnd();
+
+					break;
+
+				case INPUT.TWO_FINGER:
+
+					//doubleEnd
+					this.onDoublePanEnd( event );
+					this.onPinchEnd( event );
+					this.onRotateEnd( event );
+
+					//switching to singleStart
+					this._input = INPUT.ONE_FINGER_SWITCHED;
+
+					break;					
+					
+				case INPUT.MULT_FINGER:
+					
+					if ( this._touchCurrent.length == 0 ) {
+
+						window.removeEventListener( 'pointermove', this.onPointerMove );
+						window.removeEventListener( 'pointerup', this.onPointerUp );
+
+						//multCancel
+						this._input = INPUT.NONE;
+						this.onTriplePanEnd();
+
+					}
+
+					break;
+
+			}
+
+		} else if ( event.pointerType != 'touch' && this._input == INPUT.CURSOR ) {
+
+			window.removeEventListener( 'pointermove', this.onPointerMove );
+			window.removeEventListener( 'pointerup', this.onPointerUp );
+
+			this._input = INPUT.NONE;
+			this.onSinglePanEnd();
+			this._button = -1;
+
+		}
+
+		if ( event.isPrimary ) {
+
+			if ( this._downValid ) {
+
+				const downTime = event.timeStamp - this._downEvents[ this._downEvents.length -1 ].timeStamp;
+
+				if ( downTime <= this._maxDownTime ) {
+
+					if ( this._nclicks == 0 ) {
+
+						//first valid click detected
+						this._nclicks = 1;
+						this._clickStart = performance.now();
+
+					} else {
+
+						const clickInterval = event.timeStamp - this._clickStart;
+						const movement = this.calculatePointersDistance( this._downEvents[ 1 ], this._downEvents[ 0 ] ) * this._devPxRatio;
+
+						if ( clickInterval <= this._maxInterval && movement <= this._posThreshold ) {
+
+							//second valid click detected
+							//fire double tap and reset values
+							this._nclicks = 0;
+							this._downEvents.splice( 0, this._downEvents.length );
+							this.onDoubleTap( event );
+
+						} else {
+
+							//new 'first click'
+							this._nclicks = 1;
+							this._downEvents.shift();
+							this._clickStart = performance.now();
+
+						}
+
+					}
+
+				} else {
+
+					this._downValid = false;
+					this._nclicks = 0;
+					this._downEvents.splice( 0, this._downEvents.length );
+
+				}
+
+			} else {
+
+				this._nclicks = 0;
+				this._downEvents.splice( 0, this._downEvents.length );
+
+			}
+		}
+
+	};
+
+	onWheel = ( event ) => {
+
+		if ( this.enabled && this.enableZoom ) {
+
+			let modifier = null;
+
+			if( event.ctrlKey || event.metaKey ) {
+
+				modifier = 'CTRL';
+
+			} else if ( event.shiftKey ) {
+
+				modifier = 'SHIFT';
+
+			}
+
+			const mouseOp = this.getOpFromAction( 'WHEEL', modifier );
+
+			if ( mouseOp != null ) {
+
+				event.preventDefault();
+				this.dispatchEvent( _startEvent );
+
+				const notchDeltaY = 125;    //distance of one notch of mouse wheel
+				let sgn = event.deltaY / notchDeltaY;
+				
+				let size = 1;
+
+				if ( sgn > 0 ) {
+
+					size =  1 / this.scaleFactor;
+
+				} else if ( sgn < 0 ) {
+
+					size = this.scaleFactor;
+
+				}
+
+				switch ( mouseOp ) {
+
+					case 'ZOOM':
+						
+						this.updateTbState( STATE.SCALE, true );
+
+						if ( sgn > 0 ) {
+
+							size =  1 / ( Math.pow( this.scaleFactor, sgn ) );
+			
+						} else if ( sgn < 0 ) {
+			
+							size = Math.pow( this.scaleFactor, -sgn );
+			
+						}
+			
+						if ( this.cursorZoom && this.enablePan ) {
+			
+							let scalePoint;
+		
+							if ( this.camera.isOrthographicCamera ) {
+			
+								scalePoint = this.unprojectOnTbPlane( this.camera, event.clientX, event.clientY, this.domElement ).applyQuaternion( this.camera.quaternion ).multiplyScalar (1 / this.camera.zoom ).add( this._gizmos.position );
+			
+							} else if ( this.camera.isPerspectiveCamera ) {
+			
+								scalePoint = this.unprojectOnTbPlane( this.camera, event.clientX, event.clientY, this.domElement ).applyQuaternion( this.camera.quaternion ).add( this._gizmos.position );
+			
+							}
+			
+							this.applyTransformMatrix( this.scale( size, scalePoint ) );
+			
+						} else {
+			
+							this.applyTransformMatrix( this.scale( size, this._gizmos.position ) );
+			
+						}
+						
+						if ( this._grid != null )  {
+
+							this.disposeGrid();
+							this.drawGrid();
+			
+						}
+						
+						this.updateTbState( STATE.IDLE, false );
+						
+						this.dispatchEvent( _changeEvent );
+						this.dispatchEvent( _endEvent );
+
+						break;
+				
+					case 'FOV':
+
+						if ( this.camera.isPerspectiveCamera ) {
+
+							this.updateTbState( STATE.FOV, true );
+
+
+							//Vertigo effect
+
+							//	  fov / 2
+							//		|\
+							//		| \
+							//		|  \
+							//	x	|	\
+							//		| 	 \
+							//		| 	  \
+							//		| _ _ _\
+							//			y
+
+							//check for iOs shift shortcut
+							if ( event.deltaX != 0 ) {
+							
+								sgn = event.deltaX / notchDeltaY;
+							
+								size = 1;
+							
+								if ( sgn > 0 ) {
+								
+									size =  1 / ( Math.pow( this.scaleFactor, sgn ) );
+								
+								} else if ( sgn < 0 ) {
+								
+									size = Math.pow( this.scaleFactor, -sgn );
+								
+								}
+							
+							}
+						
+							this._v3_1.setFromMatrixPosition(this._cameraMatrixState);
+							const x = this._v3_1.distanceTo(this._gizmos.position);
+							let xNew = x / size;	//distance between camera and gizmos if scale(size, scalepoint) would be performed
+						
+							//check min and max distance
+							xNew = MathUtils.clamp( xNew, this.minDistance, this.maxDistance );
+						
+							const y = x * Math.tan( MathUtils.DEG2RAD * this.camera.fov * 0.5 );
+						
+							//calculate new fov
+							let newFov = MathUtils.RAD2DEG * ( Math.atan( y / xNew ) * 2 );
+
+							//check min and max fov
+							if ( newFov > this.maxFov ) {
+							
+								newFov = this.maxFov;
+							
+							} else if ( newFov < this.minFov ) {
+							
+								newFov = this.minFov;
+							
+							}
+						
+							const newDistance = y / Math.tan( MathUtils.DEG2RAD * ( newFov / 2 ) );
+							size = x / newDistance;
+						
+							this.setFov( newFov );
+							this.applyTransformMatrix( this.scale( size, this._gizmos.position, false ) );
+
+						}
+
+						if ( this._grid != null)  {
+
+							this.disposeGrid();
+							this.drawGrid();
+			
+						}
+						
+						this.updateTbState( STATE.IDLE, false );
+						
+						this.dispatchEvent( _changeEvent );
+						this.dispatchEvent( _endEvent );
+
+						break;
+
+				}
+
+			}
+
+		}
+
+	};
+
+	onKeyDown = ( event ) => {
+
+		if ( event.key == 'c' ) {
+
+			if ( event.ctrlKey || event.metaKey) {
+
+				this.copyState();
+
+			}
+
+		} else if ( event.key == 'v' ) {
+
+			if ( event.ctrlKey || event.metaKey) {
+
+				this.pasteState();
+
+			}
+
+		}
+
+	};
+
+	onSinglePanStart = ( event, operation ) => {
+
+		if ( this.enabled ) {
+
+			this.dispatchEvent( _startEvent );
+			
+			this.setCenter( event.clientX, event.clientY );
+
+			switch ( operation ) {
+
+				case 'PAN':
+				
+					if ( !this.enablePan ) {
+
+						return;
+
+					}
+
+					if (this._animationId != -1) {
+
+						cancelAnimationFrame( this._animationId );
+						this._animationId = -1;
+						this._timeStart = -1;
+
+						this.activateGizmos( false );
+						this.dispatchEvent( _changeEvent );
+					
+					}
+
+					this.updateTbState( STATE.PAN, true );
+					this._startCursorPosition.copy( this.unprojectOnTbPlane( this.camera, _center.x, _center.y, this.domElement ) );
+					if ( this.enableGrid ) {
+
+						this.drawGrid();
+						this.dispatchEvent( _changeEvent );
+
+					}
+					
+					break;
+			
+				case 'ROTATE':
+					
+					if ( !this.enableRotate ) {
+
+						return;
+
+					}
+
+					if (this._animationId != -1) {
+
+						cancelAnimationFrame( this._animationId );
+						this._animationId = -1;
+						this._timeStart = -1;
+					
+					}
+
+					this.updateTbState( STATE.ROTATE, true );
+					this._startCursorPosition.copy( this.unprojectOnTbSurface( this.camera, _center.x, _center.y, this.domElement, this._tbRadius ) );
+					this.activateGizmos( true );
+					if ( this.enableAnimations ) {
+
+						this._timePrev = this._timeCurrent = performance.now();
+						this._angleCurrent = this._anglePrev = 0;
+						this._cursorPosPrev.copy( this._startCursorPosition );
+						this._cursorPosCurr.copy( this._cursorPosPrev );
+						this._wCurr = 0;
+						this._wPrev = this._wCurr;
+
+					}
+
+					this.dispatchEvent( _changeEvent );
+					break;
+
+					case 'FOV':
+						
+						if ( !this.camera.isPerspectiveCamera || !this.enableZoom ) {
+
+							return;
+
+						}
+
+						if ( this._animationId != -1 ) {
+
+							cancelAnimationFrame( this._animationId );
+							this._animationId = -1;
+							this._timeStart = -1;
+	
+							this.activateGizmos( false );
+							this.dispatchEvent( _changeEvent );
+						
+						}
+
+						this.updateTbState( STATE.FOV, true );
+						this._startCursorPosition.setY( this.getCursorNDC( _center.x, _center.y, this.domElement ).y * 0.5 );
+						this._currentCursorPosition.copy( this._startCursorPosition );
+						break;
+
+					case 'ZOOM':
+
+						if ( !this.enableZoom ) {
+
+							return;
+
+						}
+
+						if ( this._animationId != -1 ) {
+
+							cancelAnimationFrame( this._animationId );
+							this._animationId = -1;
+							this._timeStart = -1;
+	
+							this.activateGizmos( false );
+							this.dispatchEvent( _changeEvent );
+						
+						}
+
+						this.updateTbState( STATE.SCALE, true );
+						this._startCursorPosition.setY( this.getCursorNDC( _center.x, _center.y, this.domElement ).y * 0.5 );
+						this._currentCursorPosition.copy( this._startCursorPosition );
+						break;
+			}
+
+		}
+
+	};
+
+	onSinglePanMove = ( event, opState ) => {
+
+		if ( this.enabled ) {
+
+			const restart = opState != this._state;
+			this.setCenter( event.clientX, event.clientY );
+
+			switch ( opState ) {
+
+				case STATE.PAN:
+
+					if ( this.enablePan ) {
+
+						if ( restart ) {
+
+							//switch to pan operation
+
+							this.dispatchEvent( _endEvent );
+							this.dispatchEvent( _startEvent );
+		
+							this.updateTbState( opState, true );
+							this._startCursorPosition.copy( this.unprojectOnTbPlane( this.camera, _center.x, _center.y, this.domElement ) );
+							if ( this.enableGrid ) {
+		
+								this.drawGrid();
+		
+							}
+
+							this.activateGizmos( false );
+	
+						} else {
+	
+							//continue with pan operation
+							this._currentCursorPosition.copy( this.unprojectOnTbPlane( this.camera, _center.x, _center.y, this.domElement ) );
+							this.applyTransformMatrix( this.pan( this._startCursorPosition, this._currentCursorPosition ) );
+	
+						}
+
+					}
+			
+					break;
+			
+				case STATE.ROTATE:
+
+					if ( this.enableRotate ) {
+
+						if ( restart ) {
+
+							//switch to rotate operation
+	
+							this.dispatchEvent( _endEvent );
+							this.dispatchEvent( _startEvent );
+
+							this.updateTbState( opState, true );
+							this._startCursorPosition.copy( this.unprojectOnTbSurface( this.camera, _center.x, _center.y, this.domElement, this._tbRadius ) );
+		
+							if ( this.enableGrid ) {
+		
+								this.disposeGrid();
+		
+							}
+		
+							this.activateGizmos( true );
+	
+						} else {
+	
+							//continue with rotate operation
+							this._currentCursorPosition.copy( this.unprojectOnTbSurface( this.camera, _center.x, _center.y, this.domElement, this._tbRadius ) );
+	
+							const distance = this._startCursorPosition.distanceTo( this._currentCursorPosition );
+							const angle = this._startCursorPosition.angleTo( this._currentCursorPosition );
+							const amount = Math.max( distance / this._tbRadius, angle );  //effective rotation angle
+							
+							this.applyTransformMatrix( this.rotate( this.calculateRotationAxis( this._startCursorPosition, this._currentCursorPosition ), amount ) );
+							
+							if ( this.enableAnimations ) {
+							
+								this._timePrev = this._timeCurrent;
+								this._timeCurrent = performance.now();
+								this._anglePrev = this._angleCurrent;
+								this._angleCurrent = amount;
+								this._cursorPosPrev.copy( this._cursorPosCurr );
+								this._cursorPosCurr.copy( this._currentCursorPosition );
+								this._wPrev = this._wCurr;
+								this._wCurr = this.calculateAngularSpeed( this._anglePrev, this._angleCurrent, this._timePrev, this._timeCurrent );
+							
+							}
+	
+						}
+
+					}
+
+					break;
+
+				case STATE.SCALE:
+				
+					if ( this.enableZoom ) {
+
+						if ( restart ) {
+
+							//switch to zoom operation
+
+							this.dispatchEvent( _endEvent );
+							this.dispatchEvent( _startEvent );
+
+							this.updateTbState( opState, true );
+							this._startCursorPosition.setY( this.getCursorNDC( _center.x, _center.y, this.domElement ).y * 0.5 );
+							this._currentCursorPosition.copy( this._startCursorPosition );
+
+							if ( this.enableGrid ) {
+		
+								this.disposeGrid();
+		
+							}
+		
+							this.activateGizmos( false );
+
+						} else {
+
+							//continue with zoom operation
+							const screenNotches = 8;	//how many wheel notches corresponds to a full screen pan
+							this._currentCursorPosition.setY( this.getCursorNDC( _center.x, _center.y, this.domElement ).y * 0.5 );
+
+							let movement = this._currentCursorPosition.y - this._startCursorPosition.y;
+
+							let size = 1 ;
+
+							if ( movement < 0 ) {
+
+								size = 1 / ( Math.pow( this.scaleFactor, -movement * screenNotches ) );
+				
+							} else if ( movement > 0 ) {
+				
+								size = Math.pow( this.scaleFactor, movement * screenNotches );
+				
+							}
+
+							this.applyTransformMatrix( this.scale( size, this._gizmos.position ) );
+
+						}
+
+					}
+
+					break;
+			
+				case STATE.FOV:
+
+					if ( this.enableZoom && this.camera.isPerspectiveCamera ) {
+
+						if ( restart ) {
+
+							//switch to fov operation
+
+							this.dispatchEvent( _endEvent );
+							this.dispatchEvent( _startEvent );
+
+							this.updateTbState( opState, true );
+							this._startCursorPosition.setY( this.getCursorNDC( _center.x, _center.y, this.domElement ).y * 0.5 );
+							this._currentCursorPosition.copy( this._startCursorPosition );
+
+							if ( this.enableGrid ) {
+		
+								this.disposeGrid();
+		
+							}
+		
+							this.activateGizmos( false );
+
+						} else {
+
+							//continue with fov operation
+							const screenNotches = 8;	//how many wheel notches corresponds to a full screen pan
+							this._currentCursorPosition.setY( this.getCursorNDC( _center.x, _center.y, this.domElement ).y * 0.5 );
+
+							let movement = this._currentCursorPosition.y - this._startCursorPosition.y;
+
+							let size = 1 ;
+
+							if ( movement < 0 ) {
+
+								size = 1 / ( Math.pow( this.scaleFactor, -movement * screenNotches ) );
+				
+							} else if ( movement > 0 ) {
+				
+								size = Math.pow( this.scaleFactor, movement * screenNotches );
+				
+							}
+
+							this._v3_1.setFromMatrixPosition( this._cameraMatrixState );
+							const x = this._v3_1.distanceTo( this._gizmos.position );
+							let xNew = x / size; //distance between camera and gizmos if scale(size, scalepoint) would be performed
+							
+							//check min and max distance
+							xNew = MathUtils.clamp( xNew, this.minDistance, this.maxDistance );
+				
+							const y = x * Math.tan( MathUtils.DEG2RAD * this._fovState * 0.5 );
+				
+							//calculate new fov
+							let newFov = MathUtils.RAD2DEG * ( Math.atan( y / xNew ) * 2 );
+							
+							//check min and max fov
+							newFov = MathUtils.clamp( newFov, this.minFov, this.maxFov );
+				
+							const newDistance = y / Math.tan( MathUtils.DEG2RAD * ( newFov / 2 ) );
+							size = x / newDistance;
+							this._v3_2.setFromMatrixPosition( this._gizmoMatrixState );
+				
+							this.setFov( newFov );
+							this.applyTransformMatrix( this.scale( size, this._v3_2, false ) );
+				
+							//adjusting distance
+							let direction = this._gizmos.position.clone().sub(this.camera.position).normalize().multiplyScalar( newDistance / x );
+							this._m4_1.makeTranslation( direction.x, direction.y, direction.z );
+							
+						}
+
+					}
+				
+					break;
+
+			}
+
+			this.dispatchEvent( _changeEvent );
+
+		}
+
+	};
+
+	onSinglePanEnd = ( event ) => {
+
+		if ( this._state == STATE.ROTATE ) {
+
+			
+			if ( !this.enableRotate ) {
+
+				return;
+
+			}
+
+			if ( this.enableAnimations ) {
+
+				//perform rotation animation
+				const deltaTime = ( performance.now() - this._timeCurrent );
+				if ( deltaTime < 120 ) {
+
+					let w = Math.abs( ( this._wPrev + this._wCurr ) / 2 );
+
+					const self = this;
+					this._animationId = window.requestAnimationFrame( function( t ) {
+
+						self.updateTbState( STATE.ANIMATION_ROTATE, true );
+						const rotationAxis = self.calculateRotationAxis( self._cursorPosPrev, self._cursorPosCurr );
+
+						self.onRotationAnim( t, rotationAxis, Math.min( w, self.wMax ) );
+
+					} );
+
+				} else {
+
+					//cursor has been standing still for over 120 ms since last movement
+					this.updateTbState( STATE.IDLE, false );
+					this.activateGizmos( false );
+					this.dispatchEvent( _changeEvent );
+
+				}
+
+			} else {
+
+				this.updateTbState( STATE.IDLE, false );
+				this.activateGizmos( false );
+				this.dispatchEvent( _changeEvent );
+
+			}
+
+		} else if ( this._state == STATE.PAN || this._state == STATE.IDLE ) {
+
+			this.updateTbState( STATE.IDLE, false );
+
+			if ( this.enableGrid ) {
+
+				this.disposeGrid();
+
+			}
+
+			this.activateGizmos( false );
+			this.dispatchEvent( _changeEvent );
+
+
+		}
+
+		this.dispatchEvent( _endEvent );
+
+	};
+
+	onDoubleTap = ( event ) => {
+
+		if ( this.enabled && this.enablePan && this.scene != null ) {
+
+			this.dispatchEvent( _startEvent );
+
+			this.setCenter( event.clientX, event.clientY );
+			const hitP = this.unprojectOnObj( this.getCursorNDC( _center.x, _center.y, this.domElement ), this.camera );
+
+			if ( hitP != null && this.enableAnimations ) {
+
+				const self = this;
+				if ( this._animationId != -1) {
+
+					window.cancelAnimationFrame( this._animationId );
+
+				}
+
+				this._timeStart = -1;
+				this._animationId = window.requestAnimationFrame( function( t ) {
+
+					self.updateTbState( STATE.ANIMATION_FOCUS, true );
+					self.onFocusAnim( t, hitP, self._cameraMatrixState, self._gizmoMatrixState );
+
+				} );
+
+			} else if ( hitP != null && !this.enableAnimations ) {
+
+				this.updateTbState( STATE.FOCUS, true );
+				this.focus( hitP, this.scaleFactor );
+				this.updateTbState( STATE.IDLE, false );
+				this.dispatchEvent (_changeEvent );
+
+			}
+
+		}
+
+		this.dispatchEvent( _endEvent );
+		
+	};
+
+	onDoublePanStart = ( event ) => {
+
+		if ( this.enabled && this.enablePan ) {
+
+			this.dispatchEvent( _startEvent );
+
+			this.updateTbState( STATE.PAN, true );
+	
+			this.setCenter( ( this._touchCurrent[ 0 ].clientX + this._touchCurrent[ 1 ].clientX ) / 2, ( this._touchCurrent[ 0 ].clientY + this._touchCurrent[ 1 ].clientY ) / 2 );
+			this._startCursorPosition.copy( this.unprojectOnTbPlane( this.camera, _center.x, _center.y, this.domElement, true ) );
+			this._currentCursorPosition.copy( this._startCursorPosition );
+
+			this.activateGizmos( false );
+
+		}
+
+	};
+
+	onDoublePanMove = ( event ) => {
+
+		if( this.enabled && this.enablePan ) {
+
+			this.setCenter( ( this._touchCurrent[ 0 ].clientX + this._touchCurrent[ 1 ].clientX ) / 2, ( this._touchCurrent[ 0 ].clientY + this._touchCurrent[ 1 ].clientY ) / 2 );
+
+			if ( this._state != STATE.PAN ) {
+
+				this.updateTbState( STATE.PAN, true );
+				this._startCursorPosition.copy( this._currentCursorPosition );
+
+			}
+	
+			this._currentCursorPosition.copy( this.unprojectOnTbPlane( this.camera, _center.x, _center.y, this.domElement, true ) );
+			this.applyTransformMatrix( this.pan( this._startCursorPosition, this._currentCursorPosition, true ) );
+			this.dispatchEvent( _changeEvent );
+		}
+
+	};
+
+	onDoublePanEnd = ( event ) => {
+
+		this.updateTbState( STATE.IDLE, false );
+		this.dispatchEvent( _endEvent );
+
+	};
+
+
+	onRotateStart = ( event ) => {
+
+		if ( this.enabled && this.enableRotate ) {
+
+			this.dispatchEvent( _startEvent );
+
+			this.updateTbState( STATE.ZROTATE, true );
+
+			//this._startFingerRotation = event.rotation;
+
+			this._startFingerRotation = this.getAngle( this._touchCurrent[ 1 ], this._touchCurrent[ 0 ] ) + this.getAngle( this._touchStart[ 1 ], this._touchStart[ 0 ] );
+			this._currentFingerRotation = this._startFingerRotation;
+
+			this.camera.getWorldDirection( this._rotationAxis );  //rotation axis
+
+			if ( !this.enablePan && !this.enableZoom ) {
+
+				this.activateGizmos( true );
+
+			}
+
+		}
+
+	};
+
+	onRotateMove = ( event ) => {
+
+		if ( this.enabled && this.enableRotate ) {
+
+			this.setCenter( ( this._touchCurrent[ 0 ].clientX + this._touchCurrent[ 1 ].clientX ) / 2, ( this._touchCurrent[ 0 ].clientY + this._touchCurrent[ 1 ].clientY ) / 2 );
+			let rotationPoint;
+	
+			if ( this._state != STATE.ZROTATE ) {
+
+				this.updateTbState( STATE.ZROTATE, true );
+				this._startFingerRotation = this._currentFingerRotation;
+
+			}
+	
+			//this._currentFingerRotation = event.rotation;
+			this._currentFingerRotation = this.getAngle( this._touchCurrent[ 1 ], this._touchCurrent[ 0 ] ) + this.getAngle( this._touchStart[ 1 ], this._touchStart[ 0 ] );
+
+			if ( !this.enablePan ) {
+
+				rotationPoint = new Vector3().setFromMatrixPosition( this._gizmoMatrixState );
+
+			} else {
+
+				this._v3_2.setFromMatrixPosition( this._gizmoMatrixState );
+				rotationPoint = this.unprojectOnTbPlane( this.camera, _center.x, _center.y, this.domElement ).applyQuaternion( this.camera.quaternion ).multiplyScalar( 1 / this.camera.zoom ).add( this._v3_2 );
+
+			}
+
+			const amount = MathUtils.DEG2RAD * ( this._startFingerRotation - this._currentFingerRotation );
+
+			this.applyTransformMatrix( this.zRotate( rotationPoint, amount ) );
+			this.dispatchEvent( _changeEvent );
+
+		}
+
+	};
+
+	onRotateEnd = () => {
+
+		this.updateTbState( STATE.IDLE, false );
+		this.activateGizmos( false );
+		this.dispatchEvent( _endEvent );
+
+	};
+
+	onPinchStart = ( event ) => {
+
+		if ( this.enabled && this.enableZoom ) {
+
+			this.dispatchEvent( _startEvent );
+			this.updateTbState( STATE.SCALE, true );
+	
+			this._startFingerDistance = this.calculatePointersDistance( this._touchCurrent[ 0 ], this._touchCurrent[ 1 ] );
+			this._currentFingerDistance =  this._startFingerDistance;
+
+			this.activateGizmos( false );
+
+		}
+
+	};
+
+	onPinchMove = ( event ) => {
+
+		if ( this.enabled && this.enableZoom ) {
+
+			this.setCenter( ( this._touchCurrent[ 0 ].clientX + this._touchCurrent[ 1 ].clientX ) / 2, ( this._touchCurrent[ 0 ].clientY + this._touchCurrent[ 1 ].clientY ) / 2 );
+			const minDistance = 12; //minimum distance between fingers (in css pixels)
+	
+			if ( this._state != STATE.SCALE ) {
+
+				this._startFingerDistance = this._currentFingerDistance;
+				this.updateTbState( STATE.SCALE, true );
+
+			}
+	
+			this._currentFingerDistance = Math.max( this.calculatePointersDistance( this._touchCurrent[ 0 ], this._touchCurrent[ 1 ] ), minDistance * this._devPxRatio );
+			const amount = this._currentFingerDistance / this._startFingerDistance;
+	
+			let scalePoint;
+
+			if ( !this.enablePan ) {
+
+				scalePoint = this._gizmos.position;
+
+			} else {
+
+				if ( this.camera.isOrthographicCamera ) {
+
+					scalePoint = this.unprojectOnTbPlane( this.camera, _center.x, _center.y, this.domElement )
+						.applyQuaternion( this.camera.quaternion )
+						.multiplyScalar( 1 / this.camera.zoom )
+						.add( this._gizmos.position );
+
+				}
+				else if ( this.camera.isPerspectiveCamera ) {
+
+					scalePoint = this.unprojectOnTbPlane( this.camera, _center.x, _center.y, this.domElement )
+						.applyQuaternion( this.camera.quaternion )
+						.add( this._gizmos.position );
+					
+				}
+
+			}
+	
+			this.applyTransformMatrix( this.scale( amount, scalePoint ) );
+			this.dispatchEvent( _changeEvent );
+		}
+	};
+
+	onPinchEnd = () => {
+
+		this.updateTbState( STATE.IDLE, false );
+		this.dispatchEvent( _endEvent );
+
+	};
+
+	onTriplePanStart = ( event ) => {
+
+		if ( this.enabled && this.enableZoom ) {
+
+			this.dispatchEvent( _startEvent );
+
+			this.updateTbState( STATE.SCALE, true );
+	
+			//const center = event.center;
+			let clientX = 0;
+			let clientY = 0;
+			const nFingers = this._touchCurrent.length;
+
+			for ( let i = 0; i < nFingers; i++ ) {
+
+				clientX += this._touchCurrent[ i ].clientX;
+				clientY += this._touchCurrent[ i ].clientY;
+
+			}
+
+			this.setCenter( clientX / nFingers, clientY / nFingers );
+
+			this._startCursorPosition.setY( this.getCursorNDC( _center.x, _center.y, this.domElement ).y * 0.5 );
+			this._currentCursorPosition.copy( this._startCursorPosition );
+
+		}
+
+	};
+
+	onTriplePanMove = ( event ) => {
+
+		if ( this.enabled && this.enableZoom ) {
+
+				//	  fov / 2
+				//		|\
+				//		| \
+				//		|  \
+				//	x	|	\
+				//		| 	 \
+				//		| 	  \
+				//		| _ _ _\
+				//			y
+
+			//const center = event.center;
+			let clientX = 0;
+			let clientY = 0;
+			const nFingers = this._touchCurrent.length;
+
+			for ( let i = 0; i < nFingers; i++ ) {
+
+				clientX += this._touchCurrent[ i ].clientX;
+				clientY += this._touchCurrent[ i ].clientY;
+
+			}
+			
+			this.setCenter( clientX / nFingers, clientY / nFingers );
+
+			const screenNotches = 8;	//how many wheel notches corresponds to a full screen pan
+			this._currentCursorPosition.setY( this.getCursorNDC( _center.x, _center.y, this.domElement ).y * 0.5 );
+
+			let movement = this._currentCursorPosition.y - this._startCursorPosition.y;
+
+			let size = 1 ;
+
+			if ( movement < 0 ) {
+
+				size = 1 / ( Math.pow( this.scaleFactor, -movement * screenNotches ) );
+
+			} else if ( movement > 0 ) {
+
+				size = Math.pow( this.scaleFactor, movement * screenNotches );
+
+			}
+
+			this._v3_1.setFromMatrixPosition( this._cameraMatrixState );
+			const x = this._v3_1.distanceTo( this._gizmos.position );
+			let xNew = x / size; //distance between camera and gizmos if scale(size, scalepoint) would be performed
+			
+			//check min and max distance
+			xNew = MathUtils.clamp( xNew, this.minDistance, this.maxDistance );
+
+			const y = x * Math.tan( MathUtils.DEG2RAD * this._fovState * 0.5 );
+
+			//calculate new fov
+			let newFov = MathUtils.RAD2DEG * ( Math.atan( y / xNew ) * 2 );
+			
+			//check min and max fov
+			newFov = MathUtils.clamp( newFov, this.minFov, this.maxFov );
+
+			const newDistance = y / Math.tan( MathUtils.DEG2RAD * ( newFov / 2 ) );
+			size = x / newDistance;
+			this._v3_2.setFromMatrixPosition( this._gizmoMatrixState );
+
+			this.setFov( newFov );
+			this.applyTransformMatrix( this.scale( size, this._v3_2, false ) );
+
+			//adjusting distance
+			let direction = this._gizmos.position.clone().sub(this.camera.position).normalize().multiplyScalar( newDistance / x );
+			this._m4_1.makeTranslation( direction.x, direction.y, direction.z );
+			
+			this.dispatchEvent( _changeEvent );
+
+		}
+
+	};
+
+	onTriplePanEnd = ( event ) => {
+
+		this.updateTbState( STATE.IDLE, false );
+		this.dispatchEvent( _endEvent );
+		//this.dispatchEvent( _changeEvent );
+
+	};
+
+	/**
+	 * Set _center's x/y coordinates
+	 * @param {Number} clientX 
+	 * @param {Number} clientY 
+	 */
+	setCenter = ( clientX, clientY ) => {
+
+		_center.x = clientX;
+		_center.y = clientY;
+
+	};
+
+	/**
+	 * Set default mouse actions
+	 */
+	initializeMouseActions = () => {
+
+		this.setMouseAction( 'PAN', 0, 'CTRL' );
+		this.setMouseAction( 'PAN', 2 );
+
+		this.setMouseAction( 'ROTATE', 0 );
+
+		this.setMouseAction( 'ZOOM', 'WHEEL' );
+		this.setMouseAction( 'ZOOM', 1 );
+
+		this.setMouseAction( 'FOV', 'WHEEL', 'SHIFT' );
+		this.setMouseAction( 'FOV', 1, 'SHIFT' );
+
+		
+	};
+
+	/**
+	 * Compare two mouse actions
+	 * @param {Object} action1 
+	 * @param {Object} action2 
+	 * @returns {Boolean} True if action1 and action 2 are the same mouse action, false otherwise
+	 */
+	compareMouseAction = ( action1, action2 ) => {
+
+		if ( action1.operation == action2.operation ) {
+
+			if ( action1.mouse == action2.mouse && action1.key == action2.key ) {
+
+				return true;
+
+			} else {
+
+				return false;
+
+			}
+
+		} else {
+
+			return false;
+
+		}
+
+	};
+
+	/**
+	 * Set a new mouse action by specifying the operation to be performed and a mouse/key combination. In case of conflict, replaces the existing one
+	 * @param {String} operation The operation to be performed ('PAN', 'ROTATE', 'ZOOM', 'FOV)
+	 * @param {*} mouse A mouse button (0, 1, 2) or 'WHEEL' for wheel notches
+	 * @param {*} key The keyboard modifier ('CTRL', 'SHIFT') or null if key is not needed
+	 * @returns {Boolean} True if the mouse action has been successfully added, false otherwise
+	 */
+	setMouseAction = ( operation, mouse, key = null ) => {
+
+		const operationInput = [ 'PAN', 'ROTATE', 'ZOOM', 'FOV' ];
+		const mouseInput = [ 0, 1, 2, 'WHEEL' ];
+		const keyInput = [ 'CTRL', 'SHIFT', null ];
+		let state;
+
+		if ( !operationInput.includes( operation ) || !mouseInput.includes( mouse ) || !keyInput.includes( key ) ) {
+
+			//invalid parameters
+			return false;
+
+		}
+
+		if ( mouse == 'WHEEL' ) {
+
+			if ( operation != 'ZOOM' && operation != 'FOV' ) {
+
+				//cannot associate 2D operation to 1D input
+				return false
+
+			}
+
+		}
+
+		switch ( operation ) {
+
+			case 'PAN':
+				
+				state = STATE.PAN;
+				break;
+		
+			case 'ROTATE':
+
+				state = STATE.ROTATE;
+				break;
+
+			case 'ZOOM':
+			
+				state = STATE.SCALE;
+				break;
+		
+			case 'FOV':
+				
+				state = STATE.FOV;
+				break;
+
+		}
+		
+		const action = {
+
+			operation: operation,
+			mouse: mouse,
+			key: key,
+			state: state
+
+		};
+
+		for( let i = 0; i < this.mouseActions.length; i++ ) {
+
+			if ( this.mouseActions[ i ].mouse == action.mouse && this.mouseActions[ i ].key == action.key ) {
+
+				this.mouseActions.splice( i, 1, action );
+				return true;
+
+			}
+
+		}
+
+		this.mouseActions.push( action );
+		return true;
+
+	};
+
+	/**
+	 * Remove a mouse action by specifying its mouse/key combination
+	 * @param {*} mouse A mouse button (0, 1, 2) or 'WHEEL' for wheel notches
+	 * @param {*} key The keyboard modifier ('CTRL', 'SHIFT') or null if key is not needed
+	 * @returns {Boolean} True if the operation has been succesfully removed, false otherwise
+	 */
+	unsetMouseAction = ( mouse, key = null ) => {
+
+		for( let i = 0; i < this.mouseActions.length; i++ ) {
+
+			if ( this.mouseActions[ i ].mouse == mouse && this.mouseActions[ i ].key == key ) {
+
+				this.mouseActions.splice( i, 1 );
+				return true;
+
+			}
+
+		}
+
+		return false;
+
+	};
+
+	/**
+	 * Return the operation associated to a mouse/keyboard combination
+	 * @param {*} mouse A mouse button (0, 1, 2) or 'WHEEL' for wheel notches
+	 * @param {*} key The keyboard modifier ('CTRL', 'SHIFT') or null if key is not needed
+	 * @returns The operation if it has been found, null otherwise
+	 */
+	getOpFromAction = ( mouse, key ) => {
+
+		let action;
+
+		for( let i = 0; i < this.mouseActions.length; i++ ) {
+
+			action = this.mouseActions[ i ];
+			if( action.mouse == mouse && action.key == key ) {
+
+				return action.operation;
+
+			}
+
+		}
+
+		if ( key != null ) {
+
+			for( let i = 0; i < this.mouseActions.length; i++ ) {
+
+				action = this.mouseActions[ i ];
+				if ( action.mouse == mouse && action.key == null ) {
+	
+					return action.operation;
+	
+				}
+	
+			}
+
+		}
+
+		return null;
+
+	};
+
+	/**
+	 * Get the operation associated to mouse and key combination and returns the corresponding FSA state
+	 * @param {Number} mouse Mouse button 
+	 * @param {String} key Keyboard modifier
+	 * @returns The FSA state obtained from the operation associated to mouse/keyboard combination
+	 */
+	getOpStateFromAction = ( mouse, key ) => {
+
+		let action;
+
+		for( let i = 0; i < this.mouseActions.length; i++ ) {
+
+			action = this.mouseActions[ i ];
+			if( action.mouse == mouse && action.key == key ) {
+
+				return action.state;
+
+			}
+
+		}
+
+		if ( key != null ) {
+
+			for( let i = 0; i < this.mouseActions.length; i++ ) {
+
+				action = this.mouseActions[ i ];
+				if ( action.mouse == mouse && action.key == null ) {
+	
+					return action.state;
+	
+				}
+	
+			}
+
+		}
+
+		return null;
+
+	};
+
+	/**
+	 * Calculate the angle between two pointers
+	 * @param {PointerEvent} p1 
+	 * @param {PointerEvent} p2
+	 * @returns {Number} The angle between two pointers in degrees
+	 */
+	getAngle = ( p1, p2  ) => {
+
+		return Math.atan2( p2.clientY - p1.clientY, p2.clientX - p1.clientX ) * 180 / Math.PI;
+
+	};
+
+	/**
+	 * Update a PointerEvent inside current pointerevents array
+	 * @param {PointerEvent} event
+	 */
+	updateTouchEvent = ( event ) => {
+
+		for ( let i = 0; i < this._touchCurrent.length; i++ ) {
+
+			if ( this._touchCurrent[ i ].pointerId == event.pointerId ) {
+
+				this._touchCurrent.splice( i, 1, event )
+				break;
+
+			}
+
+		}
+
+	};
+
+	/**
+	 * Apply a transformation matrix, to the camera and gizmos
+	 * @param {Object} transformation Object containing matrices to apply to camera and gizmos
+	 */
+	applyTransformMatrix( transformation ) {
+
+		if ( transformation.camera != null ) {
+
+			this._m4_1.copy( this._cameraMatrixState ).premultiply( transformation.camera );
+			this._m4_1.decompose( this.camera.position, this.camera.quaternion, this.camera.scale );
+			this.camera.updateMatrix();
+
+			//update camera up vector
+			if ( this._state == STATE.ROTATE || this._state == STATE.ZROTATE || this._state == STATE.ANIMATION_ROTATE ) {
+
+				this.camera.up.copy( this._upState ).applyQuaternion( this.camera.quaternion );
+
+			}
+
+		}
+
+		if ( transformation.gizmos != null ) {
+
+			this._m4_1.copy( this._gizmoMatrixState ).premultiply( transformation.gizmos );
+			this._m4_1.decompose( this._gizmos.position, this._gizmos.quaternion, this._gizmos.scale );
+			this._gizmos.updateMatrix();
+
+		}
+
+		if ( this._state == STATE.SCALE || this._state == STATE.FOCUS || this._state == STATE.ANIMATION_FOCUS) {
+
+			this._tbRadius = this.calculateTbRadius(this.camera);
+
+			if ( this.adjustNearFar ) {
+
+				const cameraDistance = this.camera.position.distanceTo( this._gizmos.position );
+				
+				const bb = new Box3();
+				bb.setFromObject( this._gizmos );
+				const sphere = new Sphere();
+				bb.getBoundingSphere( sphere );
+
+				const adjustedNearPosition = Math.max( this._nearPos0, sphere.radius + sphere.center.length() );
+				const regularNearPosition = cameraDistance - this._initialNear;
+
+				const minNearPos = Math.min( adjustedNearPosition, regularNearPosition );
+				this.camera.near = cameraDistance - minNearPos;
+
+
+				const adjustedFarPosition = Math.min( this._farPos0, -sphere.radius + sphere.center.length() );
+				const regularFarPosition = cameraDistance - this._initialFar;
+
+				const minFarPos = Math.min( adjustedFarPosition, regularFarPosition );
+				this.camera.far = cameraDistance - minFarPos;
+
+				this.camera.updateProjectionMatrix();
+
+			} else {
+
+				let update = false;
+
+				if (this.camera.near != this._initialNear ) {
+
+					this.camera.near = this._initialNear;
+					update = true;
+
+				}
+
+				if ( this.camera.far != this._initialFar ) {
+
+					this.camera.far = this._initialFar;
+					update = true;
+
+				}
+
+				if ( update ) {
+
+					this.camera.updateProjectionMatrix();
+
+				}
+
+			}
+
+		}
+
+	};
+
+	/**
+	 * Calculate the angular speed
+	 * @param {Number} p0 Position at t0 
+	 * @param {Number} p1 Position at t1
+	 * @param {Number} t0 Initial time in milliseconds
+	 * @param {Number} t1 Ending time in milliseconds
+	 */
+	calculateAngularSpeed = ( p0, p1, t0, t1 ) => {
+
+		const s = p1 - p0;
+		const t = ( t1 - t0 ) / 1000;
+		if ( t == 0 ) {
+
+			return 0;
+
+		}
+
+		return s / t;
+
+	};
+
+	/**
+	 * Calculate the distance between two pointers
+	 * @param {PointerEvent} p0 The first pointer
+	 * @param {PointerEvent} p1 The second pointer
+	 * @returns {number} The distance between the two pointers 
+	 */
+	calculatePointersDistance = ( p0, p1 ) => {
+
+		return Math.sqrt( Math.pow( p1.clientX - p0.clientX, 2 ) + Math.pow( p1.clientY - p0.clientY, 2 ) );
+
+	};
+
+	/**
+	 * Calculate the rotation axis as the vector perpendicular between two vectors
+	 * @param {Vector3} vec1 The first vector
+	 * @param {Vector3} vec2 The second vector
+	 * @returns {Vector3} The normalized rotation axis
+	 */
+	calculateRotationAxis = ( vec1, vec2 ) => {
+
+		this._rotationMatrix.extractRotation( this._cameraMatrixState );
+		this._quat.setFromRotationMatrix( this._rotationMatrix );
+
+		this._rotationAxis.crossVectors( vec1, vec2 ).applyQuaternion( this._quat );
+		return this._rotationAxis.normalize().clone();
+
+	};
+
+	/**
+	 * Calculate the trackball radius so that gizmo's diamater will be 2/3 of the minimum side of the camera frustum
+	 * @param {Camera} camera 
+	 * @returns {Number} The trackball radius
+	 */
+	calculateTbRadius = ( camera ) => {
+
+		const factor = 0.67;
+		const distance = camera.position.distanceTo( this._gizmos.position );
+
+		if ( camera.type == 'PerspectiveCamera' ) {
+
+			const halfFovV = MathUtils.DEG2RAD * camera.fov * 0.5;  //vertical fov/2 in radians
+			const halfFovH = Math.atan( ( camera.aspect ) * Math.tan( halfFovV ) ); //horizontal fov/2 in radians
+			return Math.tan( Math.min( halfFovV, halfFovH ) ) * distance * factor;
+
+		} else if ( camera.type == 'OrthographicCamera' ) {
+
+			return Math.min( camera.top, camera.right ) * factor;
+
+		}
+
+	};
+
+	/**
+	 * Focus operation consist of positioning the point of interest in front of the camera and a slightly zoom in
+	 * @param {Vector3} point The point of interest 
+	 * @param {Number} size Scale factor
+	 * @param {Number} amount Amount of operation to be completed (used for focus animations, default is complete full operation)
+	 */
+	focus = ( point, size, amount = 1 ) => {
+
+		const focusPoint = point.clone();
+
+		//move center of camera (along with gizmos) towards point of interest
+		focusPoint.sub( this._gizmos.position ).multiplyScalar( amount );
+		this._translationMatrix.makeTranslation( focusPoint.x, focusPoint.y, focusPoint.z );
+
+		const gizmoStateTemp = this._gizmoMatrixState.clone();
+		this._gizmoMatrixState.premultiply( this._translationMatrix );
+		this._gizmoMatrixState.decompose( this._gizmos.position, this._gizmos.quaternion, this._gizmos.scale )
+
+		const cameraStateTemp = this._cameraMatrixState.clone();
+		this._cameraMatrixState.premultiply( this._translationMatrix );
+		this._cameraMatrixState.decompose( this.camera.position, this.camera.quaternion, this.camera.scale );
+
+		//apply zoom
+		if ( this.enableZoom ) {
+
+			this.applyTransformMatrix( this.scale( size, this._gizmos.position ) );
+
+		}
+
+		this._gizmoMatrixState.copy( gizmoStateTemp );
+		this._cameraMatrixState.copy( cameraStateTemp );
+
+	};
+
+	/**
+	 * Draw a grid and add it to the scene
+	 */
+	drawGrid = () => {
+
+		if ( this.scene != null ) {
+
+			const color = 0x888888;
+			const multiplier = 3;
+			let size, divisions, maxLength, tick;
+
+			if ( this.camera.isOrthographicCamera ) {
+
+				const width = this.camera.right - this.camera.left;
+				const height = this.camera.bottom - this.camera.top;
+
+				maxLength = Math.max( width, height );
+				tick = maxLength / 20;
+
+				size = maxLength / this.camera.zoom * multiplier;
+				divisions = size / tick * this.camera.zoom;
+
+			} else if ( this.camera.isPerspectiveCamera ) {
+
+				const distance = this.camera.position.distanceTo( this._gizmos.position );
+				const halfFovV = MathUtils.DEG2RAD * this.camera.fov * 0.5;
+				const halfFovH = Math.atan( ( this.camera.aspect ) * Math.tan( halfFovV ) );
+
+				maxLength = Math.tan( Math.max( halfFovV, halfFovH ) ) * distance * 2;
+				tick = maxLength / 20;
+
+				size = maxLength * multiplier;
+				divisions = size / tick;
+
+			}
+			
+			if ( this._grid == null ) {
+
+				this._grid = new GridHelper( size, divisions, color, color );
+				this._grid.position.copy( this._gizmos.position );
+				this._gridPosition.copy( this._grid.position );
+				this._grid.quaternion.copy( this.camera.quaternion  );
+				this._grid.rotateX( Math.PI * 0.5 );
+		
+				this.scene.add( this._grid );
+
+			}
+
+		}
+
+	};
+
+	/**
+	 * Remove all listeners, stop animations and clean scene
+	 */
+	dispose = () => {
+
+		if ( this._animationId != -1 ) {
+
+			window.cancelAnimationFrame( this._animationId );
+
+		}
+
+		this.domElement.removeEventListener( 'pointerdown', this.onPointerDown );
+		this.domElement.removeEventListener( 'pointercancel', this.onPointerCancel )
+		this.domElement.removeEventListener( 'wheel', this.onWheel );
+		this.domElement.removeEventListener('contextmenu', this.onContextMenu);
+
+		window.removeEventListener( 'pointermove', this.onPointerMove );
+		window.removeEventListener( 'pointerup', this.onPointerUp );
+
+		window.removeEventListener( 'resize', this.onWindowResize );
+		window.addEventListener( 'keydown', this.onKeyDown );
+
+		this.scene.remove( this._gizmos );
+		this.disposeGrid();
+
+	};
+
+	/**
+	 * remove the grid from the scene
+	 */
+	disposeGrid = () => {
+
+		if ( this._grid != null && this.scene != null ) {
+
+			this.scene.remove( this._grid );
+			this._grid = null;
+
+		}
+
+	};
+
+	/**
+	 * Compute the easing out cubic function for ease out effect in animation
+	 * @param {Number} t The absolute progress of the animation in the bound of 0 (beginning of the) and 1 (ending of animation)
+	 * @returns {Number} Result of easing out cubic at time t
+	 */
+	easeOutCubic = ( t ) => {
+
+		return 1 - Math.pow( 1 - t, 3 );
+
+	};
+
+	/**
+	 * Make rotation gizmos more or less visible
+	 * @param {Boolean} isActive If true, make gizmos more visible
+	 */
+	activateGizmos = ( isActive ) => {
+
+		const gizmoX = this._gizmos.children[ 0 ];
+		const gizmoY = this._gizmos.children[ 1 ];
+		const gizmoZ = this._gizmos.children[ 2 ];
+
+		if ( isActive ) {
+ 
+			gizmoX.material.setValues( { opacity: 1 } );
+			gizmoY.material.setValues( { opacity: 1 } );
+			gizmoZ.material.setValues( { opacity: 1 } );
+
+		} else {  
+
+			gizmoX.material.setValues( { opacity: 0.6 } );
+			gizmoY.material.setValues( { opacity: 0.6 } );
+			gizmoZ.material.setValues( { opacity: 0.6 } );
+
+		}
+
+	};
+
+	/**
+	 * Calculate the cursor position in NDC
+	 * @param {number} x Cursor horizontal coordinate within the canvas 
+	 * @param {number} y Cursor vertical coordinate within the canvas
+	 * @param {HTMLElement} canvas The canvas where the renderer draws its output
+	 * @returns {Vector2} Cursor normalized position inside the canvas
+	 */
+	getCursorNDC = ( cursorX, cursorY, canvas ) => {
+
+		const canvasRect = canvas.getBoundingClientRect();
+		this._v2_1.setX ( ( ( cursorX - canvasRect.left ) / canvasRect.width ) * 2 - 1 );
+		this._v2_1.setY( ( ( canvasRect.bottom - cursorY ) / canvasRect.height ) * 2 - 1 );
+		return this._v2_1.clone();
+
+	};
+
+	/**
+	 * Calculate the cursor position inside the canvas x/y coordinates with the origin being in the center of the canvas
+	 * @param {Number} x Cursor horizontal coordinate within the canvas 
+	 * @param {Number} y Cursor vertical coordinate within the canvas
+	 * @param {HTMLElement} canvas The canvas where the renderer draws its output
+	 * @returns {Vector2} Cursor position inside the canvas
+	 */
+	getCursorPosition = ( cursorX, cursorY, canvas ) => {
+
+		this._v2_1.copy( this.getCursorNDC( cursorX, cursorY, canvas) );
+		this._v2_1.x *= ( this.camera.right - this.camera.left ) * 0.5;
+		this._v2_1.y *= ( this.camera.top - this.camera.bottom ) * 0.5;
+		return this._v2_1.clone();
+
+	};
+
+	/**
+	 * Set the camera to be controlled
+	 * @param {Camera} camera The virtual camera to be controlled
+	 */
+	setCamera = ( camera ) => {
+
+		camera.lookAt( this._tbCenter );
+		camera.updateMatrix();
+
+		//setting state
+		if ( camera.type == 'PerspectiveCamera' ) {
+
+			this._fov0 = camera.fov;
+			this._fovState = camera.fov;
+
+		}
+		this._cameraMatrixState0.copy( camera.matrix );
+		this._cameraMatrixState.copy( this._cameraMatrixState0 );
+		this._cameraProjectionState.copy( camera.projectionMatrix );
+		this._zoom0 = camera.zoom;
+		this._zoomState = this._zoom0;
+
+		this._initialNear = camera.near;
+		this._nearPos0 = camera.position.distanceTo( this._tbCenter ) - camera.near;
+		this._nearPos = this._initialNear;
+
+		this._initialFar = camera.far;
+		this._farPos0 = camera.position.distanceTo( this._tbCenter ) - camera.far;
+		this._farPos = this._initialFar;
+
+		this._up0.copy( camera.up );
+		this._upState.copy( camera.up );
+
+		this.camera = camera;
+		this.camera.updateProjectionMatrix();
+
+		//making gizmos
+		this._tbRadius = this.calculateTbRadius( camera );
+		this.makeGizmos( this._tbCenter, this._tbRadius );
+
+	};
+
+	/**
+	 * Set gizmos visibility
+	 * @param {Boolean} value Value of gizmos visibility
+	 */
+	setGizmosVisible( value ) {
+
+		this._gizmos.visible = value;
+		this.dispatchEvent( _changeEvent );
+		
+	};
+
+	/**
+	 * Creates the rotation gizmos matching trackball center and radius
+	 * @param {Vector3} tbCenter The trackball center
+	 * @param {number} tbRadius The trackball radius
+	 */
+	makeGizmos = ( tbCenter, tbRadius ) => {
+
+		const curve = new EllipseCurve( 0, 0, tbRadius, tbRadius );
+		const points = curve.getPoints( this._curvePts );
+
+		//geometry
+		const curveGeometry = new BufferGeometry().setFromPoints( points );
+
+		//material 
+		const curveMaterialX = new LineBasicMaterial( { color: 0xff8080, fog: false, transparent: true, opacity: 0.6 } );
+		const curveMaterialY = new LineBasicMaterial( { color: 0x80ff80, fog: false, transparent: true, opacity: 0.6 } );
+		const curveMaterialZ = new LineBasicMaterial( { color: 0x8080ff, fog: false, transparent: true, opacity: 0.6 } );
+
+		//line
+		const gizmoX = new Line( curveGeometry, curveMaterialX );
+		const gizmoY = new Line( curveGeometry, curveMaterialY );
+		const gizmoZ = new Line( curveGeometry, curveMaterialZ );
+
+		const rotation = Math.PI * 0.5;
+		gizmoX.rotation.x = rotation;
+		gizmoY.rotation.y = rotation;
+
+
+		//setting state
+		this._gizmoMatrixState0.identity().setPosition( tbCenter ) ;
+		this._gizmoMatrixState.copy( this._gizmoMatrixState0 );
+
+		if ( this.camera.zoom != 1 ) {
+
+			//adapt gizmos size to camera zoom
+			const size = 1 / this.camera.zoom;
+			this._scaleMatrix.makeScale( size, size, size );
+			this._translationMatrix.makeTranslation( -tbCenter.x, -tbCenter.y, -tbCenter.z );
+
+			this._gizmoMatrixState.premultiply( this._translationMatrix ).premultiply( this._scaleMatrix );
+			this._translationMatrix.makeTranslation( tbCenter.x, tbCenter.y, tbCenter.z );
+			this._gizmoMatrixState.premultiply( this._translationMatrix );
+			
+		}
+
+		this._gizmoMatrixState.decompose( this._gizmos.position, this._gizmos.quaternion, this._gizmos.scale );
+
+		this._gizmos.clear();
+
+		this._gizmos.add( gizmoX );
+		this._gizmos.add( gizmoY );
+		this._gizmos.add( gizmoZ );
+
+	};
+
+	/**
+	 * Perform animation for focus operation
+	 * @param {Number} time Instant in which this function is called as performance.now()
+	 * @param {Vector3} point Point of interest for focus operation
+	 * @param {Matrix4} cameraMatrix Camera matrix
+	 * @param {Matrix4} gizmoMatrix Gizmos matrix
+	 */
+	onFocusAnim = ( time, point, cameraMatrix, gizmoMatrix ) => {
+
+		if ( this._timeStart == -1 ) {
+
+			//animation start
+			this._timeStart = time;
+
+		}
+
+		if ( this._state == STATE.ANIMATION_FOCUS ) {
+
+			const deltaTime = time - this._timeStart;
+			const animTime = deltaTime / this.focusAnimationTime;
+
+			this._gizmoMatrixState.copy( gizmoMatrix );
+
+			if ( animTime >= 1)  {
+
+				//animation end
+
+				this._gizmoMatrixState.decompose( this._gizmos.position, this._gizmos.quaternion, this._gizmos.scale );
+
+				this.focus( point, this.scaleFactor );
+
+				this._timeStart = -1;
+				this.updateTbState( STATE.IDLE, false );
+				this.activateGizmos( false );
+
+				this.dispatchEvent( _changeEvent ); 
+
+			} else {
+
+				const amount = this.easeOutCubic( animTime );
+				const size = ( ( 1 - amount ) + ( this.scaleFactor * amount ) );
+  
+				this._gizmoMatrixState.decompose( this._gizmos.position, this._gizmos.quaternion, this._gizmos.scale );
+				this.focus( point, size, amount );
+
+				this.dispatchEvent( _changeEvent );
+				const self = this;
+				this._animationId = window.requestAnimationFrame( function( t ) {
+
+					self.onFocusAnim( t, point, cameraMatrix, gizmoMatrix.clone() );
+
+				} );
+
+			}
+
+		} else {
+
+			//interrupt animation
+
+			this._animationId = -1;
+			this._timeStart = -1;
+
+		}
+
+	};
+	
+	/**
+	 * Perform animation for rotation operation
+	 * @param {Number} time Instant in which this function is called as performance.now()
+	 * @param {Vector3} rotationAxis Rotation axis
+	 * @param {number} w0 Initial angular velocity
+	 */
+	onRotationAnim = ( time, rotationAxis, w0 ) => {
+
+		if ( this._timeStart == -1 ) {
+
+			//animation start
+			this._anglePrev = 0
+			this._angleCurrent = 0;
+			this._timeStart = time;
+
+		}
+
+		if ( this._state == STATE.ANIMATION_ROTATE ) {
+
+			//w = w0 + alpha * t
+			const deltaTime = ( time - this._timeStart ) / 1000;
+			const w = w0 + ( ( -this.dampingFactor ) * deltaTime );
+			
+			if ( w > 0 ) {
+
+				//tetha = 0.5 * alpha * t^2 + w0 * t + tetha0
+				this._angleCurrent = 0.5 * ( -this.dampingFactor ) * Math.pow( deltaTime, 2 ) + w0 * deltaTime + 0;
+				this.applyTransformMatrix( this.rotate( rotationAxis, this._angleCurrent ) );
+				this.dispatchEvent( _changeEvent );
+				const self = this;
+				this._animationId = window.requestAnimationFrame( function( t ) {
+
+					self.onRotationAnim( t, rotationAxis, w0 );
+
+				} );
+
+			} else {
+
+				this._animationId = -1;
+				this._timeStart = -1;
+
+				this.updateTbState( STATE.IDLE, false );
+				this.activateGizmos( false );
+
+				this.dispatchEvent( _changeEvent );
+
+			}
+
+		} else {
+
+			//interrupt animation
+
+			this._animationId = -1;
+			this._timeStart = -1;
+
+			if ( this._state != STATE.ROTATE ) {
+
+				this.activateGizmos( false );
+				this.dispatchEvent ( _changeEvent );
+
+			}
+
+		}
+
+	};
+
+	
+	/**
+	 * Perform pan operation moving camera between two points
+	 * @param {Vector3} p0 Initial point
+	 * @param {Vector3} p1 Ending point
+	 * @param {Boolean} adjust If movement should be adjusted considering camera distance (Perspective only)
+	 */
+	pan = ( p0, p1, adjust = false ) => {
+
+		const movement = p0.clone().sub( p1 );
+
+		if ( this.camera.isOrthographicCamera ) {
+
+			//adjust movement amount
+			movement.multiplyScalar( 1 / this.camera.zoom );
+
+		} else if ( this.camera.isPerspectiveCamera && adjust ) {
+
+			//adjust movement amount
+			this._v3_1.setFromMatrixPosition( this._cameraMatrixState0 );	//camera's initial position
+			this._v3_2.setFromMatrixPosition( this._gizmoMatrixState0 );	//gizmo's initial position
+			const distanceFactor = this._v3_1.distanceTo( this._v3_2 ) / this.camera.position.distanceTo( this._gizmos.position );
+			movement.multiplyScalar( 1 / distanceFactor );
+
+		}
+
+		this._v3_1.set( movement.x, movement.y, 0 ).applyQuaternion( this.camera.quaternion );
+
+		this._m4_1.makeTranslation( this._v3_1.x, this._v3_1.y,this. _v3_1.z );
+	   
+		this.setTransformationMatrices( this._m4_1, this._m4_1 );
+		return _transformation;
+
+	};
+
+	/**
+	 * Reset trackball
+	 */
+	reset = () => {
+
+		this.camera.zoom = this._zoom0;
+	
+		if( this.camera.isPerspectiveCamera ) {
+
+			this.camera.fov = this._fov0;
+
+		}
+
+		this.camera.near = this._nearPos;
+		this.camera.far = this._farPos;
+		this._cameraMatrixState.copy( this._cameraMatrixState0 );
+		this._cameraMatrixState.decompose( this.camera.position, this.camera.quaternion, this.camera.scale );
+		this.camera.up.copy( this._up0 );
+
+		this.camera.updateMatrix();
+		this.camera.updateProjectionMatrix();
+
+		this._gizmoMatrixState.copy (this._gizmoMatrixState0 );
+		this._gizmoMatrixState0.decompose( this._gizmos.position, this._gizmos.quaternion, this._gizmos.scale );
+		this._gizmos.updateMatrix();
+
+		this._tbRadius = this.calculateTbRadius( this.camera );
+		this.makeGizmos(this._gizmos.position, this._tbRadius);
+
+		this.camera.lookAt( this._gizmos.position );
+
+		this.updateTbState( STATE.IDLE, false );
+		
+		this.dispatchEvent( _changeEvent );
+
+	};
+
+	/**
+	 * Rotate the camera around an axis passing by trackball's center
+	 * @param {Vector3} axis Rotation axis
+	 * @param {number} angle Angle in radians
+	 * @returns {Object} Object with 'camera' field containing transformation matrix resulting from the operation to be applied to the camera
+	 */
+	rotate = ( axis, angle ) => {
+
+		const point = this._gizmos.position;    //rotation center
+		this._translationMatrix.makeTranslation( -point.x, -point.y, -point.z );
+		this._rotationMatrix.makeRotationAxis( axis, -angle );
+
+		//rotate camera
+		this._m4_1.makeTranslation( point.x, point.y, point.z );
+		this._m4_1.multiply( this._rotationMatrix );
+		this._m4_1.multiply( this._translationMatrix );
+
+		this.setTransformationMatrices( this._m4_1 );
+
+		return _transformation;
+
+	};
+
+	copyState = () => {
+
+		let state;
+		if ( this.camera.isOrthographicCamera ) {
+
+			state = JSON.stringify( { arcballState: { 
+				
+				cameraFar: this.camera.far, 
+				cameraMatrix: this.camera.matrix, 
+				cameraNear: this.camera.near, 
+				cameraUp: this.camera.up, 
+				cameraZoom: this.camera.zoom, 
+				gizmoMatrix: this._gizmos.matrix 
+
+			} } );
+
+		} else if ( this.camera.isPerspectiveCamera ) {
+
+			state = JSON.stringify( { arcballState: { 
+				cameraFar: this.camera.far,
+				cameraFov: this.camera.fov,  
+				cameraMatrix: this.camera.matrix, 
+				cameraNear: this.camera.near, 
+				cameraUp: this.camera.up, 
+				cameraZoom: this.camera.zoom, 
+				gizmoMatrix: this._gizmos.matrix 
+				
+			} } );
+
+		}
+		
+		navigator.clipboard.writeText( state );
+
+	};
+
+	pasteState = () => {
+
+		const self = this;
+		navigator.clipboard.readText().then( function resolved( value ) {
+
+			self.setStateFromJSON( value );
+			
+		} );
+
+	};
+
+	/**
+	 * Save the current state of the control. This can later be recover with .reset
+	 */
+	saveState = () => {
+ 
+		this._cameraMatrixState0.copy( this.camera.matrix );
+		this._gizmoMatrixState0.copy( this._gizmos.matrix );
+		this._nearPos = this.camera.near;
+		this._farPos = this.camera.far;
+		this._zoom0 = this.camera.zoom;
+		this._up0.copy( this.camera.up );
+
+		if ( this.camera.isPerspectiveCamera ) {
+
+			this._fov0 = this.camera.fov;
+
+		}
+
+	};
+
+	/**
+	 * Perform uniform scale operation around a given point
+	 * @param {Number} size Scale factor
+	 * @param {Vector3} point Point around which scale 
+	 * @param {Boolean} scaleGizmos If gizmos should be scaled (Perspective only)
+	 * @returns {Object} Object with 'camera' and 'gizmo' fields containing transformation matrices resulting from the operation to be applied to the camera and gizmos
+	 */
+	scale = ( size, point, scaleGizmos = true ) => {
+
+		const scalePoint = point.clone();
+		let sizeInverse = 1 / size;
+		
+		if ( this.camera.isOrthographicCamera ) {
+
+			//camera zoom
+			this.camera.zoom = this._zoomState;
+			this.camera.zoom *= size;
+
+			//check min and max zoom
+			if ( this.camera.zoom > this.maxZoom ) {
+
+				this.camera.zoom = this.maxZoom;
+				sizeInverse = this._zoomState / this.maxZoom;
+
+			}
+			else if ( this.camera.zoom < this.minZoom ) {
+
+				this.camera.zoom = this.minZoom;
+				sizeInverse = this._zoomState / this.minZoom;
+
+			}
+
+			this.camera.updateProjectionMatrix();
+
+			this._v3_1.setFromMatrixPosition( this._gizmoMatrixState );	//gizmos position
+
+			//scale gizmos so they appear in the same spot having the same dimension
+			this._scaleMatrix.makeScale(sizeInverse, sizeInverse, sizeInverse);
+			this._translationMatrix.makeTranslation( -this._v3_1.x, -this._v3_1.y, -this._v3_1.z );
+
+			this._m4_2.makeTranslation( this._v3_1.x, this._v3_1.y, this._v3_1.z ).multiply( this._scaleMatrix );
+			this._m4_2.multiply( this._translationMatrix );
+
+
+			//move camera and gizmos to obtain pinch effect
+			scalePoint.sub( this._v3_1 );
+
+			const amount = scalePoint.clone().multiplyScalar( sizeInverse );
+			scalePoint.sub( amount );
+
+			this._m4_1.makeTranslation( scalePoint.x, scalePoint.y, scalePoint.z );
+			this._m4_2.premultiply( this._m4_1 );   
+
+			this.setTransformationMatrices( this._m4_1, this._m4_2 );
+			return _transformation;
+
+		} else if ( this.camera.isPerspectiveCamera ) {
+		   
+			this._v3_1.setFromMatrixPosition( this._cameraMatrixState );
+			this._v3_2.setFromMatrixPosition( this._gizmoMatrixState );
+
+			//move camera
+			let distance = this._v3_1.distanceTo( scalePoint );
+			let amount = distance - ( distance * sizeInverse );
+
+			//check min and max distance
+			const newDistance = distance - amount
+			if ( newDistance < this.minDistance ) {
+
+				sizeInverse = this.minDistance / distance;
+				amount = distance - ( distance * sizeInverse );
+
+			} else if ( newDistance > this.maxDistance ) {
+
+				sizeInverse = this.maxDistance / distance;
+				amount = distance - ( distance * sizeInverse );    
+
+			}
+
+			let direction = scalePoint.clone().sub( this._v3_1 ).normalize().multiplyScalar( amount );
+
+			this._m4_1.makeTranslation( direction.x, direction.y, direction.z );
+
+			
+			if ( scaleGizmos ) {
+
+				//scale gizmos so they appear in the same spot having the same dimension
+				const pos = this._v3_2;
+
+				distance = pos.distanceTo( scalePoint );
+				amount = distance - ( distance * sizeInverse );
+				direction = scalePoint.clone().sub( this._v3_2 ).normalize().multiplyScalar( amount );
+
+				this._translationMatrix.makeTranslation( pos.x, pos.y, pos.z );
+				this._scaleMatrix.makeScale( sizeInverse, sizeInverse, sizeInverse );
+
+				this._m4_2.makeTranslation( direction.x, direction.y, direction.z ).multiply( this._translationMatrix );
+				this._m4_2.multiply( this._scaleMatrix );
+
+				this._translationMatrix.makeTranslation( -pos.x, -pos.y, -pos.z );
+
+				this._m4_2.multiply( this._translationMatrix );
+				this.setTransformationMatrices( this._m4_1, this._m4_2 );
+
+
+			} else {
+
+				this.setTransformationMatrices( this._m4_1 );
+
+			}
+
+			return _transformation;
+
+		}
+
+	};
+
+	/**
+	 * Set camera fov
+	 * @param {Number} value fov to be setted 
+	 */
+	setFov = ( value ) => {
+
+		if ( this.camera.isPerspectiveCamera ) {
+
+			this.camera.fov = MathUtils.clamp( value, this.minFov, this.maxFov );
+			this.camera.updateProjectionMatrix();
+
+		}
+
+	};
+
+	/**
+	 * Set the trackball's center point
+	 * @param {Number} x X coordinate
+	 * @param {Number} y Y coordinate
+	 * @param {Number} z Z coordinate
+	 */
+	setTarget = ( x, y, z ) => {
+
+		this._tbCenter.set( x, y, z );
+		this._gizmos.position.set( x, y, z );	//for correct radius calculation
+		this._tbRadius = this.calculateTbRadius( this.camera );
+
+		this.makeGizmos( this._tbCenter, this._tbRadius );
+		this.camera.lookAt( this._tbCenter );
+
+	};
+
+	/**
+	 * Set values in transformation object
+	 * @param {Matrix4} camera Transformation to be applied to the camera 
+	 * @param {Matrix4} gizmos Transformation to be applied to gizmos
+	 */
+	 setTransformationMatrices( camera = null, gizmos = null) {
+		 
+		if ( camera != null ) {
+
+			if ( _transformation.camera != null ) {
+
+				_transformation.camera.copy( camera );
+
+			} else {
+
+				_transformation.camera = camera.clone();
+
+			}
+
+		} else {
+
+			_transformation.camera = null;
+
+		}
+
+		if ( gizmos != null ) {
+
+			if ( _transformation.gizmos != null ) {
+
+				_transformation.gizmos.copy( gizmos );
+
+			} else {
+
+				_transformation.gizmos = gizmos.clone();
+
+			}
+
+		} else {
+
+			_transformation.gizmos = null;
+
+		}
+
+	};
+
+	/**
+	 * Rotate camera around its direction axis passing by a given point by a given angle
+	 * @param {Vector3} point The point where the rotation axis is passing trough
+	 * @param {Number} angle Angle in radians
+	 * @returns The computed transormation matix
+	 */
+	zRotate = ( point, angle ) => {
+
+		this._rotationMatrix.makeRotationAxis( this._rotationAxis, angle );
+		this._translationMatrix.makeTranslation( -point.x, -point.y, -point.z );
+
+		this._m4_1.makeTranslation( point.x, point.y, point.z );
+		this._m4_1.multiply( this._rotationMatrix );
+		this._m4_1.multiply( this._translationMatrix );
+
+		this._v3_1.setFromMatrixPosition( this._gizmoMatrixState ).sub( point );	//vector from rotation center to gizmos position
+		this._v3_2.copy( this._v3_1 ).applyAxisAngle( this._rotationAxis, angle );	//apply rotation
+		this._v3_2.sub( this._v3_1 );
+
+		this._m4_2.makeTranslation( this._v3_2.x, this._v3_2.y, this._v3_2.z );
+
+		this.setTransformationMatrices( this._m4_1, this._m4_2 );
+		return _transformation;
+
+	};
+	
+
+	/**
+	 * Unproject the cursor on the 3D object surface
+	 * @param {Vector2} cursor Cursor coordinates in NDC
+	 * @param {Camera} camera Virtual camera
+	 * @returns {Vector3} The point of intersection with the model, if exist, null otherwise
+	 */
+	unprojectOnObj = ( cursor, camera ) => {
+
+		const raycaster = new Raycaster();
+		raycaster.near = camera.near;
+		raycaster.far = camera.far;
+		raycaster.setFromCamera( cursor, camera );
+
+		const intersect = raycaster.intersectObjects( this.scene.children, true );
+
+		for ( let i = 0; i < intersect.length; i++ ) {
+
+			if ( intersect[ i ].object.uuid != this._gizmos.uuid && intersect[ i ].face != null ) {
+
+				return intersect[ i ].point.clone();
+
+			}
+
+		}
+		
+		return  null;
+	};
+
+	/**
+	 * Unproject the cursor on the trackball surface
+	 * @param {Camera} camera The virtual camera
+	 * @param {Number} cursorX Cursor horizontal coordinate on screen
+	 * @param {Number} cursorY Cursor vertical coordinate on screen
+	 * @param {HTMLElement} canvas The canvas where the renderer draws its output
+	 * @param {number} tbRadius The trackball radius
+	 * @returns {Vector3} The unprojected point on the trackball surface
+	 */
+	unprojectOnTbSurface = ( camera, cursorX, cursorY, canvas, tbRadius ) => {
+
+		if ( camera.type == 'OrthographicCamera' ) {
+
+			this._v2_1.copy( this.getCursorPosition ( cursorX, cursorY, canvas ) );
+			this._v3_1.set( this._v2_1.x, this._v2_1.y, 0 );
+
+			const x2 = Math.pow( this._v2_1.x, 2 );
+			const y2 = Math.pow( this._v2_1.y, 2 );
+			const r2 = Math.pow( this._tbRadius, 2 );
+
+			if ( x2 + y2 <= r2 * 0.5 ) {
+
+				//intersection with sphere
+				this._v3_1.setZ( Math.sqrt( r2 - ( x2 + y2 ) ) );
+
+			} else {
+
+				//intersection with hyperboloid
+				this._v3_1.setZ( ( r2 * 0.5 ) / ( Math.sqrt( x2 + y2 ) ) );
+			}
+
+			return this._v3_1;
+
+		} else if ( camera.type == 'PerspectiveCamera' ) {
+
+			//unproject cursor on the near plane
+			this._v2_1.copy( this.getCursorNDC( cursorX, cursorY, canvas ) );
+
+			this._v3_1.set( this._v2_1.x, this._v2_1.y, -1 );
+			this._v3_1.applyMatrix4( camera.projectionMatrixInverse );
+
+			const rayDir = this._v3_1.clone().normalize(); //unprojected ray direction
+			const cameraGizmoDistance = camera.position.distanceTo( this._gizmos.position );
+			const radius2 = Math.pow( tbRadius, 2 );
+		
+			//	  camera
+			//		|\
+			//		| \
+			//		|  \
+			//	h	|	\
+			//		| 	 \
+			//		| 	  \
+			//	_ _ | _ _ _\ _ _  near plane
+			//			l
+			
+			const h = this._v3_1.z;
+			const l = Math.sqrt( Math.pow( this._v3_1.x, 2 ) + Math.pow( this._v3_1.y, 2 ) );
+
+			if ( l == 0 ) {
+
+				//ray aligned with camera
+				rayDir.set( this._v3_1.x, this._v3_1.y, tbRadius );
+				return rayDir;
+
+			}
+
+			const m = h / l;
+			const q = cameraGizmoDistance;
+		
+			/*
+			 * calculate intersection point between unprojected ray and trackball surface
+			 *|y = m * x + q
+			 *|x^2 + y^2 = r^2
+			 *
+			 * (m^2 + 1) * x^2 + (2 * m * q) * x + q^2 - r^2 = 0
+			 */
+			let a = Math.pow( m, 2 ) + 1;
+			let b = 2 * m * q;
+			let c = Math.pow( q, 2 ) - radius2;
+			let delta = Math.pow( b, 2 ) - ( 4 * a * c );
+		
+			if ( delta >= 0 ) {
+
+				//intersection with sphere
+				this._v2_1.setX( ( -b - Math.sqrt( delta ) ) / ( 2 * a ) );
+				this._v2_1.setY( m * this._v2_1.x + q );
+
+				let angle = MathUtils.RAD2DEG * this._v2_1.angle();
+
+				if ( angle >= 45 ) {
+
+					//if angle between intersection point and X' axis is >= 45°, return that point
+					//otherwise, calculate intersection point with hyperboloid
+			
+					let rayLength = Math.sqrt( Math.pow( this._v2_1.x, 2 ) + Math.pow( ( cameraGizmoDistance - this._v2_1.y ), 2 ) );
+					rayDir.multiplyScalar( rayLength );
+					rayDir.z += cameraGizmoDistance;
+					return rayDir;
+
+				}
+
+			} 
+
+			//intersection with hyperboloid
+			/*
+			 *|y = m * x + q
+			 *|y = (1 / x) * (r^2 / 2)
+			 *
+			 * m * x^2 + q * x - r^2 / 2 = 0
+			 */
+
+			a = m;
+			b = q;
+			c = -radius2 * 0.5;
+			delta = Math.pow( b, 2 ) - ( 4 * a * c );
+			this._v2_1.setX( ( -b - Math.sqrt( delta ) ) / ( 2 * a ) );
+			this._v2_1.setY( m * this._v2_1.x + q );
+
+			let rayLength = Math.sqrt( Math.pow( this._v2_1.x, 2 ) + Math.pow( ( cameraGizmoDistance - this._v2_1.y ), 2 ) );
+
+			rayDir.multiplyScalar( rayLength );
+			rayDir.z += cameraGizmoDistance;
+			return rayDir;
+
+		}
+
+	};
+
+
+	/**
+	 * Unproject the cursor on the plane passing through the center of the trackball orthogonal to the camera
+	 * @param {Camera} camera The virtual camera
+	 * @param {Number} cursorX Cursor horizontal coordinate on screen
+	 * @param {Number} cursorY Cursor vertical coordinate on screen
+	 * @param {HTMLElement} canvas The canvas where the renderer draws its output
+	 * @param {Boolean} initialDistance If initial distance between camera and gizmos should be used for calculations instead of current (Perspective only)
+	 * @returns {Vector3} The unprojected point on the trackball plane
+	 */
+	unprojectOnTbPlane = ( camera, cursorX, cursorY, canvas, initialDistance = false ) => {
+
+		if ( camera.type == 'OrthographicCamera' ) {
+
+			this._v2_1.copy( this.getCursorPosition( cursorX, cursorY, canvas ) );
+			this._v3_1.set( this._v2_1.x, this._v2_1.y, 0 );
+
+			return this._v3_1.clone();
+
+		} else if ( camera.type == 'PerspectiveCamera' ) {
+			
+			this._v2_1.copy( this.getCursorNDC( cursorX, cursorY, canvas ) );
+
+			//unproject cursor on the near plane
+			this._v3_1.set( this._v2_1.x, this._v2_1.y, -1 );
+			this._v3_1.applyMatrix4( camera.projectionMatrixInverse );
+
+			const rayDir = this._v3_1.clone().normalize();    //unprojected ray direction
+
+			//	  camera
+			//		|\
+			//		| \
+			//		|  \
+			//	h	|	\
+			//		| 	 \
+			//		| 	  \
+			//	_ _ | _ _ _\ _ _  near plane
+			//			l
+
+			const h = this._v3_1.z;
+			const l = Math.sqrt( Math.pow( this._v3_1.x, 2 ) + Math.pow( this._v3_1.y, 2 ) );
+			let cameraGizmoDistance;
+
+			if ( initialDistance ) {
+
+				cameraGizmoDistance = this._v3_1.setFromMatrixPosition( this._cameraMatrixState0 ).distanceTo( this._v3_2.setFromMatrixPosition( this._gizmoMatrixState0 ) );
+			
+			} else {
+
+				cameraGizmoDistance = camera.position.distanceTo( this._gizmos.position );
+
+			}
+
+			/*
+			 * calculate intersection point between unprojected ray and the plane
+			 *|y = mx + q
+			 *|y = 0
+			 *
+			 * x = -q/m
+			*/
+			if ( l == 0 ) {
+
+				//ray aligned with camera
+				rayDir.set( 0, 0, 0 );
+				return rayDir;
+
+			}
+			
+			const m = h / l;
+			const q = cameraGizmoDistance;
+			const x = -q / m;
+
+			const rayLength = Math.sqrt( Math.pow( q, 2 ) + Math.pow( x, 2 ) );
+			rayDir.multiplyScalar( rayLength );
+			rayDir.z = 0;
+			return rayDir;
+
+		}
+		
+	};
+
+	/**
+	 * Update camera and gizmos state
+	 */
+	updateMatrixState = () => {
+
+		//update camera and gizmos state
+		this._cameraMatrixState.copy( this.camera.matrix );
+		this._gizmoMatrixState.copy( this._gizmos.matrix );
+
+		if ( this.camera.isOrthographicCamera ) {
+
+			this._cameraProjectionState.copy( this.camera.projectionMatrix );
+			this.camera.updateProjectionMatrix();
+			this._zoomState = this.camera.zoom;
+
+		} else if ( this.camera.isPerspectiveCamera) {
+
+			this._fovState =  this.camera.fov;
+
+		}
+
+	};
+
+	/**
+	 * Update the trackball FSA
+	 * @param {STATE} newState New state of the FSA
+	 * @param {Boolean} updateMatrices If matriices state should be updated
+	 */
+	updateTbState = ( newState, updateMatrices ) => {
+
+		this._state = newState;
+		if ( updateMatrices ) {
+
+			this.updateMatrixState();
+
+		}
+
+	};
+
+	update = () => {
+
+		const EPS = 0.000001;
+
+		//check min/max parameters
+		if ( this.camera.isOrthographicCamera ) {
+
+			//check zoom
+			if ( this.camera.zoom > this.maxZoom || this.camera.zoom < this.minZoom ) {
+
+				const newZoom = MathUtils.clamp( this.camera.zoom, this.minZoom, this.maxZoom );
+				this.applyTransformMatrix( this.scale( newZoom / this.camera.zoom, this._gizmos.position, true ) );
+						
+			}
+
+		} else if ( this.camera.isPerspectiveCamera ) {
+ 
+			//check distance
+			const distance = this.camera.position.distanceTo( this._gizmos.position );
+
+			if ( distance > this.maxDistance + EPS || distance < this.minDistance - EPS ) {
+
+				const newDistance = MathUtils.clamp( distance, this.minDistance, this.maxDistance );
+				this.applyTransformMatrix( this.scale( newDistance / distance, this._gizmos.position ) );				
+				this.updateMatrixState();
+
+			 }
+
+			//check fov
+			if ( this.camera.fov < this.minFov || this.camera.fov > this.maxFov ) {
+
+				this.camera.fov = MathUtils.clamp( this.camera.fov, this.minFov, this.maxFov );
+				this.camera.updateProjectionMatrix();
+
+			}
+
+			const oldRadius = this._tbRadius;
+			this._tbRadius = this.calculateTbRadius( this.camera );
+
+			if ( oldRadius < this._tbRadius - EPS || oldRadius > this._tbRadius + EPS ) {
+
+				const scale = ( this._gizmos.scale.x + this._gizmos.scale.y + this._gizmos.scale.z ) / 3; 
+				const newRadius = this._tbRadius / scale;
+				const curve = new EllipseCurve( 0, 0, newRadius, newRadius );
+				const points = curve.getPoints( this._curvePts );
+				const curveGeometry = new BufferGeometry().setFromPoints( points );
+			
+				for( let gizmo in this._gizmos.children ) {
+			
+					this._gizmos.children[ gizmo ].geometry = curveGeometry;
+			
+				}
+					
+			}
+
+		}
+
+		this.camera.lookAt( this._gizmos.position ); 
+
+	};
+
+	setStateFromJSON = ( json ) => {
+
+		const state = JSON.parse( json );
+
+		if ( state.arcballState != undefined ) {
+
+			this._cameraMatrixState.fromArray( state.arcballState.cameraMatrix.elements );
+			this._cameraMatrixState.decompose( this.camera.position, this.camera.quaternion, this.camera.scale );
+			
+			this.camera.up.copy( state.arcballState.cameraUp );
+			this.camera.near = state.arcballState.cameraNear;
+			this.camera.far = state.arcballState.cameraFar;
+
+			this.camera.zoom = state.arcballState.cameraZoom;
+
+			if ( this.camera.isPerspectiveCamera ) {
+
+				this.camera.fov = state.arcballState.cameraFov;
+
+			}
+
+			this._gizmoMatrixState.fromArray( state.arcballState.gizmoMatrix.elements );
+			this._gizmoMatrixState.decompose( this._gizmos.position, this._gizmos.quaternion, this._gizmos.scale );
+			
+			this.camera.updateMatrix();
+			this.camera.updateProjectionMatrix();
+
+			this._gizmos.updateMatrix();
+
+			this._tbRadius = this.calculateTbRadius( this.camera );
+			let gizmoTmp = new Matrix4().copy( this._gizmoMatrixState0 );
+			this.makeGizmos( this._gizmos.position, this._tbRadius );
+			this._gizmoMatrixState0.copy( gizmoTmp );
+
+			this.camera.lookAt( this._gizmos.position );
+			this.updateTbState( STATE.IDLE, false );
+
+			this.dispatchEvent( _changeEvent );
+
+		}
+
+	};
+
+};
+
+export { ArcballControls };

+ 263 - 0
examples/misc_controls_arcball.html

@@ -0,0 +1,263 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<title>three.js webgl - arcball controls</title>
+		<meta charset="utf-8">
+		<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+		<link type="text/css" rel="stylesheet" href="main.css">
+
+	</head>
+
+	<body>
+		<div id="info">
+			<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> - arcball controls<br/>
+			<a href="http://www.polycount.com/forum/showthread.php?t=130641" target="_blank" rel="noopener">Cerberus(FFVII Gun) model</a> by Andrew Maximov.
+		</div> 
+
+		<script type="module">
+			import * as THREE from '../build/three.module.js';
+
+			import { GUI } from './jsm/libs/dat.gui.module.js';
+
+			import { ArcballControls } from './jsm/controls/ArcballControls.js';
+
+			import { OBJLoader } from './jsm/loaders/OBJLoader.js';
+			import { RGBELoader } from './jsm/loaders/RGBELoader.js';
+
+			const cameras = [ 'Orthographic', 'Perspective' ];
+			const cameraType = { type: 'Perspective' };
+
+			const perspectiveDistance = 2.5;
+			const orthographicDistance = 120;
+			let camera, controls, scene, renderer, gui;
+			let folderOptions, folderAnimations;
+
+			const arcballGui = {
+				
+				gizmoVisible: true,
+
+				setArcballControls: function() {
+					
+					controls = new ArcballControls( camera, renderer.domElement, scene );
+					controls.addEventListener( 'change', render );
+
+					this.gizmoVisible = true;
+
+					this.populateGui();
+
+				},
+
+				populateGui: function() {
+
+					folderOptions.add( controls, 'enabled' ).name( 'Enable controls' );
+					folderOptions.add( controls, 'enableGrid' ).name( 'Enable Grid' );
+					folderOptions.add( controls, 'enableRotate' ).name( 'Enable rotate' );
+					folderOptions.add( controls, 'enablePan' ).name( 'Enable pan' );
+					folderOptions.add( controls, 'enableZoom' ).name( 'Enable zoom' );
+					folderOptions.add( controls, 'cursorZoom' ).name( 'Cursor zoom' );
+					folderOptions.add( controls, 'adjustNearFar' ).name( 'adjust near/far' );
+					folderOptions.add( controls, 'scaleFactor', 1.1, 10, 0.1 ).name( 'Scale factor' );
+					folderOptions.add( controls, 'minDistance', 0, 50, 0.5 ).name( 'Min distance' );
+					folderOptions.add( controls, 'maxDistance', 0, 50, 0.5 ).name( 'Max distance' );
+					folderOptions.add( controls, 'minZoom', 0, 50, 0.5 ).name( 'Min zoom' );
+					folderOptions.add( controls, 'maxZoom', 0, 50, 0.5 ).name( 'Max zoom' );   
+					folderOptions.add( arcballGui, 'gizmoVisible' ).name( 'Show gizmos' ).onChange( function() {
+							
+						controls.setGizmosVisible( arcballGui.gizmoVisible );
+
+					} );
+					folderOptions.add( controls, 'copyState' ).name( 'Copy state(ctrl+c)' );
+					folderOptions.add( controls, 'pasteState' ).name( 'Paste state(ctrl+v)' );
+					folderOptions.add( controls, 'reset' ).name( 'Reset' );
+					folderAnimations.add( controls, 'enableAnimations' ).name( 'Enable anim.' );
+					folderAnimations.add( controls, 'dampingFactor', 0, 100, 1 ).name( 'Damping' );
+					folderAnimations.add( controls, 'wMax', 0, 100, 1 ).name( 'Angular spd' );
+
+				}
+
+			};
+
+
+			init();
+
+			function init() {
+
+				const container = document.createElement( 'div' );
+				document.body.appendChild( container );
+
+				renderer = new THREE.WebGLRenderer( { antialias: true, alpha: true } );
+				renderer.setPixelRatio( window.devicePixelRatio );
+				renderer.setSize( window.innerWidth, window.innerHeight );
+				
+				renderer.outputEncoding = THREE.sRGBEncoding;
+				renderer.toneMapping = THREE.ReinhardToneMapping;
+				renderer.toneMappingExposure = 3;
+				renderer.domElement.style.background = "linear-gradient( 180deg, rgba( 0,0,0,1 ) 0%, rgba( 128,128,255,1 ) 100% )";
+				container.appendChild( renderer.domElement );
+
+				//
+
+				scene = new THREE.Scene();
+				scene.add( new THREE.HemisphereLight( 0x443333, 0x222233, 4 ) );
+
+				camera = makePerspectiveCamera();
+				camera.position.set( 0, 0, perspectiveDistance );
+
+				const material = new THREE.MeshStandardMaterial();
+
+				new OBJLoader()
+					.setPath( 'models/obj/cerberus/' )
+					.load( 'Cerberus.obj', function ( group ) {
+
+						const textureLoader = new THREE.TextureLoader().setPath( 'models/obj/cerberus/' );
+						
+						material.roughness = 1;
+						material.metalness = 1;
+
+						const diffuseMap = textureLoader.load( 'Cerberus_A.jpg', render );
+						diffuseMap.encoding = THREE.sRGBEncoding;
+						material.map = diffuseMap;
+
+						material.metalnessMap = material.roughnessMap = textureLoader.load( 'Cerberus_RM.jpg', render);
+						material.normalMap = textureLoader.load( 'Cerberus_N.jpg', render );
+
+						material.map.wrapS = THREE.RepeatWrapping;
+						material.roughnessMap.wrapS = THREE.RepeatWrapping;
+						material.metalnessMap.wrapS = THREE.RepeatWrapping;
+						material.normalMap.wrapS = THREE.RepeatWrapping;
+
+
+						group.traverse( function ( child ) {
+
+							if ( child.isMesh ) {
+							
+								child.material = material;
+							
+							}
+
+						} );
+
+						group.rotation.y = Math.PI / 2;
+						group.position.x += 0.25;
+						scene.add( group );
+						render();
+
+						new RGBELoader()
+							.setDataType( THREE.UnsignedByteType )
+							.setPath( 'textures/equirectangular/' )
+							.load( 'venice_sunset_1k.hdr', function ( hdrEquirect ) {
+
+								hdrEquirect.mapping = THREE.EquirectangularReflectionMapping;
+
+								scene.environment = hdrEquirect;
+
+								render();
+
+							} );
+
+
+						window.addEventListener( 'resize', onWindowResize );
+
+						//
+
+						gui = new GUI();
+						gui.add( cameraType, 'type', cameras ).name( 'Choose Camera' ).onChange( function () {
+							
+							setCamera( cameraType.type )
+
+						} );
+
+						folderOptions = gui.addFolder( 'Arcball parameters' );
+						folderAnimations = folderOptions.addFolder( 'Animations' );
+
+						arcballGui.setArcballControls();
+
+						render();
+
+				} );
+
+			};
+
+			function makeOrthographicCamera( domElement ) {
+
+				const halfFovV = THREE.MathUtils.DEG2RAD * 45 * 0.5;
+				const halfFovH = Math.atan( ( window.innerWidth / window.innerHeight ) * Math.tan( halfFovV ) );
+
+				const halfW = perspectiveDistance * Math.tan( halfFovH );
+				const halfH = perspectiveDistance * Math.tan( halfFovV );
+				const near = 0.01;
+				const far = 2000;
+				const newCamera = new THREE.OrthographicCamera( -halfW, halfW, halfH, -halfH, near, far );
+				return newCamera
+
+			};
+
+			function makePerspectiveCamera() {
+
+				const fov = 45;
+				const aspect = window.innerWidth / window.innerHeight;
+				const near = 0.01;
+				const far = 2000;
+				const newCamera = new THREE.PerspectiveCamera( fov, aspect, near, far );
+				return newCamera;
+
+			};
+
+		
+			function onWindowResize() {
+
+				if ( camera.type == 'OrthographicCamera') {
+
+					const halfFovV = THREE.MathUtils.DEG2RAD * 45 * 0.5;
+					const halfFovH = Math.atan( ( window.innerWidth / window.innerHeight ) * Math.tan( halfFovV ) );
+
+					const halfW = perspectiveDistance * Math.tan( halfFovH );
+					const halfH = perspectiveDistance * Math.tan( halfFovV );
+					camera.left = -halfW;
+					camera.right = halfW
+					camera.top = halfH;
+					camera.bottom = -halfH;
+
+				} else if ( camera.type == 'PerspectiveCamera' ) {
+
+					camera.aspect = window.innerWidth / window.innerHeight;
+
+				}
+
+				camera.updateProjectionMatrix();
+
+				renderer.setSize( window.innerWidth, window.innerHeight );
+
+				render();
+
+			};
+			
+			function render() {
+
+				renderer.render( scene, camera );
+
+			};
+
+			function setCamera( type ) {
+
+				if ( type == 'Orthographic' ) {
+
+					camera = makeOrthographicCamera();
+					camera.position.set( 0, 0, orthographicDistance );
+
+
+				} else if ( type == 'Perspective' ) {
+
+					camera = makePerspectiveCamera();
+					camera.position.set( 0, 0, perspectiveDistance );
+
+				}
+
+				controls.setCamera( camera );
+
+				render();
+
+			};
+
+		</script>
+	</body>

二進制
examples/screenshots/misc_controls_arcball.jpg


+ 1 - 0
examples/tags.json

@@ -94,6 +94,7 @@
 	"webgl2_multiple_rendertargets": [ "mrt" ],
 	"webgl2_multiple_rendertargets": [ "mrt" ],
 	"webgl2_multisampled_renderbuffers": [ "msaa" ],
 	"webgl2_multisampled_renderbuffers": [ "msaa" ],
 	"physics_ammo_cloth": [ "integration" ],
 	"physics_ammo_cloth": [ "integration" ],
+	"misc_controls_arcball": [ "rotation" ],
 	"misc_controls_deviceorientation": [ "accelerometer", "sensors" ],
 	"misc_controls_deviceorientation": [ "accelerometer", "sensors" ],
 	"misc_controls_drag": [ "translate" ],
 	"misc_controls_drag": [ "translate" ],
 	"misc_controls_map": [ "drag" ],
 	"misc_controls_map": [ "drag" ],