three
Version:
JavaScript 3D library
1,999 lines (1,354 loc) • 86.6 kB
JavaScript
import {
Controls,
GridHelper,
EllipseCurve,
BufferGeometry,
Line,
LineBasicMaterial,
Raycaster,
Group,
Box3,
Sphere,
Quaternion,
Vector2,
Vector3,
Matrix4,
MathUtils
} from 'three';
//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()
};
/**
* Fires when the camera has been transformed by the controls.
*
* @event ArcballControls#change
* @type {Object}
*/
const _changeEvent = { type: 'change' };
/**
* Fires when an interaction was initiated.
*
* @event ArcballControls#start
* @type {Object}
*/
const _startEvent = { type: 'start' };
/**
* Fires when an interaction has finished.
*
* @event ArcballControls#end
* @type {Object}
*/
const _endEvent = { type: 'end' };
const _raycaster = new Raycaster();
const _offset = new Vector3();
const _gizmoMatrixStateTemp = new Matrix4();
const _cameraMatrixStateTemp = new Matrix4();
const _scalePointTemp = new Vector3();
const _EPS = 0.000001;
/**
* Arcball controls allow the camera to be controlled by a virtual trackball with full touch support and advanced navigation functionality.
* 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 return to its starting orientation).
*
* In addition to supporting pan, zoom and pinch gestures, Arcball controls provide focus< 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).
*
* Unlike {@link OrbitControls} and {@link TrackballControls}, `ArcballControls` doesn't require `update()` to be called externally in an
* animation loop when animations are on.
*
* @augments Controls
* @three_import import { ArcballControls } from 'three/addons/controls/ArcballControls.js';
*/
class ArcballControls extends Controls {
/**
* Constructs a new controls instance.
*
* @param {Camera} camera - The camera to be controlled. The camera must not be a child of another object, unless that object is the scene itself.
* @param {?HTMLDOMElement} [domElement=null] - The HTML element used for event listeners.
* @param {?Scene} [scene=null] The scene rendered by the camera. If not given, gizmos cannot be shown.
*/
constructor( camera, domElement = null, scene = null ) {
super( camera, domElement );
/**
* The scene rendered by the camera. If not given, gizmos cannot be shown.
*
* @type {?Scene}
* @default null
*/
this.scene = scene;
/**
* The control's focus point.
*
* @type {Vector3}
*/
this.target = new Vector3();
this._currentTarget = new Vector3();
/**
* The size of the gizmo relative to the screen width and height.
*
* @type {number}
* @default 0.67
*/
this.radiusFactor = 0.67;
/**
* Holds the mouse actions of this controls. This property is maintained by the methods
* `setMouseAction()` and `unsetMouseAction()`.
*
* @type {Array<Object>}
*/
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;
/**
* Duration of focus animations in ms.
*
* @type {number}
* @default 500
*/
this.focusAnimationTime = 500;
//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
/**
* If set to `true`, the camera's near and far values will be adjusted every time zoom is
* performed trying to maintain the same visible portion given by initial near and far
* values. Only works with perspective cameras.
*
* @type {boolean}
* @default false
*/
this.adjustNearFar = false;
/**
* The scaling factor used when performing zoom operation.
*
* @type {number}
* @default 1.1
*/
this.scaleFactor = 1.1;
/**
* The damping inertia used if 'enableAnimations` is set to `true`.
*
* @type {number}
* @default 25
*/
this.dampingFactor = 25;
/**
* Maximum angular velocity allowed on rotation animation start.
*
* @type {number}
* @default 20
*/
this.wMax = 20;
/**
* Set to `true` to enable animations for rotation (damping) and focus operation.
*
* @type {boolean}
* @default true
*/
this.enableAnimations = true;
/**
* If set to `true`, a grid will appear when panning operation is being performed
* (desktop interaction only).
*
* @type {boolean}
* @default false
*/
this.enableGrid = false;
/**
* Set to `true` to make zoom become cursor centered.
*
* @type {boolean}
* @default false
*/
this.cursorZoom = false;
/**
* The minimum FOV in degrees.
*
* @type {number}
* @default 5
*/
this.minFov = 5;
/**
* The maximum FOV in degrees.
*
* @type {number}
* @default 90
*/
this.maxFov = 90;
/**
* Speed of rotation.
*
* @type {number}
* @default 1
*/
this.rotateSpeed = 1;
/**
* Enable or disable camera panning.
*
* @type {boolean}
* @default true
*/
this.enablePan = true;
/**
* Enable or disable camera rotation.
*
* @type {boolean}
* @default true
*/
this.enableRotate = true;
/**
* Enable or disable camera zoom.
*
* @type {boolean}
* @default true
*/
this.enableZoom = true;
/**
* Enable or disable gizmos.
*
* @type {boolean}
* @default true
*/
this.enableGizmos = true;
/**
* Enable or disable camera focusing on double-tap (or click) operations.
*
* @type {boolean}
* @default true
*/
this.enableFocus = true;
/**
* How far you can dolly in. For perspective cameras only.
*
* @type {number}
* @default 0
*/
this.minDistance = 0;
/**
* How far you can dolly out. For perspective cameras only.
*
* @type {number}
* @default Infinity
*/
this.maxDistance = Infinity;
/**
* How far you can zoom in. For orthographic cameras only.
*
* @type {number}
* @default 0
*/
this.minZoom = 0;
/**
* How far you can zoom out. For orthographic cameras only.
*
* @type {number}
* @default Infinity
*/
this.maxZoom = Infinity;
//trackball parameters
this._tbRadius = 1;
//FSA
this._state = STATE.IDLE;
this.setCamera( camera );
if ( this.scene != null ) {
this.scene.add( this._gizmos );
}
this.initializeMouseActions();
// event listeners
this._onContextMenu = onContextMenu.bind( this );
this._onWheel = onWheel.bind( this );
this._onPointerUp = onPointerUp.bind( this );
this._onPointerMove = onPointerMove.bind( this );
this._onPointerDown = onPointerDown.bind( this );
this._onPointerCancel = onPointerCancel.bind( this );
this._onWindowResize = onWindowResize.bind( this );
if ( domElement !== null ) {
this.connect( domElement );
}
}
connect( element ) {
super.connect( element );
this.domElement.style.touchAction = 'none';
this._devPxRatio = window.devicePixelRatio;
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( 'resize', this._onWindowResize );
}
disconnect() {
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 );
}
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.object, _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.object, _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.object.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.object, _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.object, _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.object, _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.object, _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 ) * this.rotateSpeed; //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 );
const 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._gizmoMatrixState );
this.applyTransformMatrix( this.scale( size, this._v3_1 ) );
}
}
break;
case STATE.FOV:
if ( this.enableZoom && this.object.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 );
const 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
_offset.copy( this._gizmos.position ).sub( this.object.position ).normalize().multiplyScalar( newDistance / x );
this._m4_1.makeTranslation( _offset.x, _offset.y, _offset.z );
}
}
break;
}
this.dispatchEvent( _changeEvent );
}
}
onSinglePanEnd() {
if ( this._state == STATE.ROTATE ) {
if ( ! this.enableRotate ) {
return;
}
if ( this.enableAnimations ) {
//perform rotation animation
const deltaTime = ( performance.now() - this._timeCurrent );
if ( deltaTime < 120 ) {
const 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.enableFocus && 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.object );
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() {
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.object, _center.x, _center.y, this.domElement, true ) );
this._currentCursorPosition.copy( this._startCursorPosition );
this.activateGizmos( false );
}
}
onDoublePanMove() {
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.object, _center.x, _center.y, this.domElement, true ) );
this.applyTransformMatrix( this.pan( this._startCursorPosition, this._currentCursorPosition, true ) );
this.dispatchEvent( _changeEvent );
}
}
onDoublePanEnd() {
this.updateTbState( STATE.IDLE, false );
this.dispatchEvent( _endEvent );
}
onRotateStart() {
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.object.getWorldDirection( this._rotationAxis ); //rotation axis
if ( ! this.enablePan && ! this.enableZoom ) {
this.activateGizmos( true );
}
}
}
onRotateMove() {
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.object, _center.x, _center.y, this.domElement ).applyQuaternion( this.object.quaternion ).multiplyScalar( 1 / this.object.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() {
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() {
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.object.isOrthographicCamera ) {
scalePoint = this.unprojectOnTbPlane( this.object, _center.x, _center.y, this.domElement )
.applyQuaternion( this.object.quaternion )
.multiplyScalar( 1 / this.object.zoom )
.add( this._gizmos.position );
} else if ( this.object.isPerspectiveCamera ) {
scalePoint = this.unprojectOnTbPlane( this.object, _center.x, _center.y, this.domElement )
.applyQuaternion( this.object.quaternion )
.add( this._gizmos.position );
}
}
this.applyTransformMatrix( this.scale( amount, scalePoint ) );
this.dispatchEvent( _changeEvent );
}
}
onPinchEnd() {
this.updateTbState( STATE.IDLE, false );
this.dispatchEvent( _endEvent );
}
onTriplePanStart() {
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() {
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 );
const 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
_offset.copy( this._gizmos.position ).sub( this.object.position ).normalize().multiplyScalar( newDistance / x );
this._m4_1.makeTranslation( _offset.x, _offset.y, _offset.z );
this.dispatchEvent( _changeEvent );
}
}
onTriplePanEnd() {
this.updateTbState( STATE.IDLE, false );
this.dispatchEvent( _endEvent );
//this.dispatchEvent( _changeEvent );
}
/**
* Set _center's x/y coordinates.
*
* @private
* @param {number} clientX - The x coordinate.
* @param {number} clientY - The y coordinate.
*/
setCenter( clientX, clientY ) {
_center.x = clientX;
_center.y = clientY;
}
/**
* Set default mouse actions.
*
* @private
*/
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.
*
* @private
* @param {Object} action1 - The first mouse action.
* @param {Object} action2 - The second mouse action.
* @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 {'PAN'|'ROTATE'|'ZOOM'|'FOV'} operation - The operation to be performed ('PAN', 'ROTATE', 'ZOOM', 'FOV').
* @param {0|1|2|'WHEEL'} mouse - A mouse button (0, 1, 2) or 'WHEEL' for wheel notches.
* @param {'CTRL'|'SHIFT'|null} [key=null] - 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 {0|1|2|'WHEEL'} mouse - A mouse button (0, 1, 2) or 'WHEEL' for wheel notches.
* @param {'CTRL'|'SHIFT'|null} key - The keyboard modifier ('CTRL', 'SHIFT') or null if key is not needed.
* @returns {boolean} `true` if the operation has been successfully 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.
*
* @private
* @param {0|1|2|'WHEEL'} mouse - Mouse button index (0, 1, 2) or 'WHEEL' for wheel notches.
* @param {'CTRL'|'SHIFT'|null} key - Keyboard modifier.
* @returns {'PAN'|'ROTATE'|'ZOOM'|'FOV'|null} 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.
*
* @private
* @param {0|1|2} mouse - Mouse button index (0, 1, 2)
* @param {'CTRL'|'SHIFT'|null} key - Keyboard modifier
* @returns {?STATE} 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.
*
* @private
* @param {PointerEvent} p1 - The first pointer event.
* @param {PointerEvent} p2 - The second pointer event.
* @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;
}
/**
* Updates a PointerEvent inside current pointerevents array.
*
* @private
* @param {PointerEvent} event - The pointer 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;
}
}
}
/**
* Applies a transformation matrix, to the camera and gizmos.
*
* @private
* @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.object.position, this.object.quaternion, this.object.scale );
this.object.updateMatrix();
//update camera up vector
if ( this._state == STATE.ROTATE || this._state == STATE.ZROTATE || this._state == STATE.ANIMATION_ROTATE ) {
this.object.up.copy( this._upState ).applyQuaternion( this.object.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.object );
if ( this.adjustNearFar ) {
const cameraDistance = this.object.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.object.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.object.far = cameraDistance - minFarPos;
this.object.updateProjectionMatrix();
} else {
let update = false;
if ( this.object.near != this._initialNear ) {
this.object.near = this._initialNear;
update = true;
}
if ( this.object.far != this._initialFar ) {
this.object.far = this._initialFar;
update = true;
}
if ( update ) {
this.object.updateProjectionMatrix();
}
}
}
}
/**
* Calculates the angular speed.
*
* @private
* @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.
* @returns {number} The angular speed.
*/
calculateAngularSpeed( p0, p1, t0, t1 ) {
const s = p1 - p0;
const t = ( t1 - t0 ) / 1000;
if ( t == 0 ) {
return 0;
}
return s / t;
}
/**
* Calculates the distance between two pointers.
*
* @private
* @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 ) );
}
/**
* Calculates the rotation axis as the vector perpendicular between two vectors.
*
* @private
* @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();
}
/**
* Calculates the trackball radius so that gizmo's diameter will be 2/3 of the minimum side of the camera frustum.
*
* @private
* @param {Camera} camera - The camera.
* @returns {number} The trackball radius.
*/
calculateTbRadius( camera ) {
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 * this.radiusFactor;
} else if ( camera.type == 'OrthographicCamera' ) {
return Math.min( camera.top, camera.right ) * this.radiusFactor;
}
}
/**
* Focus operation consist of positioning the point of interest in front of the camera and a slightly zoom in.
*
* @private
* @param {Vector3} point - The point of interest.
* @param {number} size - Scale factor.
* @param {number} [amount=1] - Amount of operation to be completed (used for focus animations, default is complete full operation).
*/
focus( point, size, amount = 1 ) {
//move center of camera (along with gizmos) towards point of interest
_offset.copy( point ).sub( this._gizmos.position ).multiplyScalar( amount );
this._translationMatrix.makeTranslation( _offset.x, _offset.y, _offset.z );
_gizmoMatrixStateTemp.copy( this._gizmoMatrixState );
this._gizmoMatrixState.premultiply( this._translationMatrix );
this._gizmoMatrixState.decompose( this._gizmos.position, this._gizmos.quaternion, this._gizmos.scale );
_cameraMatrixStateTemp.copy( this._cameraMatrixState );
this._cameraMatrixState.premultiply( this._translationMatrix );
this._cameraMatrixState.decompose( this.object.position, this.object.quaternion, this.object.scale );
//apply zoom
if ( this.enableZoom ) {
this.applyTransformMatrix( this.scale( size, this._gizmos.position ) );
}
this._gizmoMatrixState.copy( _gizmoMatrixStateTemp );
this._cameraMatrixState.copy( _cameraMatrixStateTemp );
}
/**
* Creates a grid if necessary and adds it to the scene.
*
* @private
*/
drawGrid() {
if ( this.scene != null ) {
const color = 0x888888;
const multiplier = 3;
let size, divisions, maxLength, tick;
if ( this.object.isOrthographicCamera ) {
const width = this.object.right - this.object.left;
const height = this.object.bottom - this.object.top;
maxLength = Math.max( width, height );
tick = maxLength / 20;
size = maxLength / this.object.zoom * multiplier;
divisions = size / tick * this.object.zoom;
} else if ( this.object.isPerspectiveCamera ) {
const distance = this.object.position.distanceTo( this._gizmos.position );
const halfFovV = MathUtils.DEG2RAD * this.object.fov * 0.5;
const halfFovH = Math.atan( ( this.object.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.object.quaternion );
this._grid.rotateX( Math.PI * 0.5 );
this.scene.add( this._grid );
}
}
}
dispose() {
if ( this._animationId != - 1 ) {
window.cancelAnimationFrame( this._animationId );
}
this.disconnect();
if ( this.scene !== null ) this.scene.remove( this._gizmos );
this.disposeGrid();
}
/**
* Removes the grid from the scene.
*/
disposeGrid() {
if ( this._grid != null && this.scene != null ) {
this.scene.remove( this._grid );
this._grid = null;
}
}
/**
* Computes the easing out cubic function for ease out effect in animation.
*
* @private
* @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 );
}
/**
* Makes rotation gizmos more or less visible.
*
* @param {boolean} isActive - If set to `true`, gizmos are 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 } );
}
}
/**
* Calculates the cursor position in NDC.
*
* @private
* @param {number} cursorX - Cursor horizontal coordinate within the canvas.
* @param {number} cursorY - 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();
}
/**
* Calculates the cursor position inside the canvas x/y coordinates with the origin being in the center of the canvas.
*
* @private
* @param {number} cursorX - Cursor horizontal coordinate within the canvas.
* @param {number} cursorY - 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.object.right - this.object.left ) * 0.5;
this._v2_1.y *= ( this.object.top - this.object.bottom ) * 0.5;
return this._v2_1.clone();
}
/**
* Sets the camera to be controlled. Must be called in order to set a new camera to be controlled.
*
* @param {Camera} camera - The camera to be controlled.
*/
setCamera( camera ) {
camera.lookAt( this.target );
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.target ) - camera.near;
this._nearPos = this._initialNear;
this._initialFar = camera.far;
this._farPos0 = camera.position.distanceTo( this.target ) - camera.far;
this._farPos = this._initialFar;
this._up0.copy( camera.up );
this._upState.copy( camera.up );
this.object = camera;
this.object.updateProjectionMatrix();
//making gizmos
this._tbRadius = this.calculateTbRadius( camera );
this.makeGizmos( this.target, this._tbRadius );
}
/**
* Sets gizmos visibility.
*
* @param {boolean} value - Value of gizmos visibility.
*/
setGizmosVisible( value ) {
this._gizmos.visible = value;
this.dispatchEvent( _changeEvent );
}
/**
* Sets gizmos radius factor and redraws gizmos.
*
* @param {number} value - Value of radius factor.
*/
setTbRadius( value ) {
this.radiusFactor = value;
this._tbRadius = this.calculateTbRadius( this.object );
const curve = new EllipseCurve( 0, 0, this._tbRadius, this._tbRadius );
const points = curve.getPoints( this._curvePts );
const curveGeometry = new BufferGeometry().setFromPoints( points );
for ( const gizmo in this._gizmos.children ) {
this._gizmos.children[ gizmo ].geometry = curveGeometry;
}
this.dispatchEvent( _changeEvent );
}
/**
* Creates the rotation gizmos matching trackball center and radius.
*
* @private
* @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 curveMaterial