Visual LabINTERACTIVE LEARNING
OpenGL 与 3D精校教程

从零实现 Orbit Controller

从相机坐标系、球坐标和输入状态机出发,实现旋转、平移、缩放与惯性更新。

OrbitControls相机交互
专题预计 52 分钟查看源文 ↗

参考xeokit实现的控制器

math

import { Matrix4, Vector3 } from 'three';

const lookAtMat = new Matrix4()
const _vec3 = new Vector3()

export function lookAtMat4v(pos, target, up, dest = lookAtMat) {

  const posx = pos.x;
  const posy = pos.y;
  const posz = pos.z;
  const upx = up.x;
  const upy = up.y;
  const upz = up.z;
  const targetx = target.x;
  const targety = target.y;
  const targetz = target.z;

  if (posx === targetx && posy === targety && posz === targetz) {
      return new Matrix4();
  }

  let z0;
  let z1;
  let z2;
  let x0;
  let x1;
  let x2;
  let y0;
  let y1;
  let y2;
  let len;

  //vec3.direction(eye, center, z);
  z0 = posx - targetx;
  z1 = posy - targety;
  z2 = posz - targetz;

  // normalize (no check needed for 0 because of early return)
  len = 1 / Math.sqrt(z0 * z0 + z1 * z1 + z2 * z2);
  z0 *= len;
  z1 *= len;
  z2 *= len;

  //vec3.normalize(vec3.cross(up, z, x));
  x0 = upy * z2 - upz * z1;
  x1 = upz * z0 - upx * z2;
  x2 = upx * z1 - upy * z0;
  len = Math.sqrt(x0 * x0 + x1 * x1 + x2 * x2);
  if (!len) {
      x0 = 0;
      x1 = 0;
      x2 = 0;
  } else {
      len = 1 / len;
      x0 *= len;
      x1 *= len;
      x2 *= len;
  }

  y0 = z1 * x2 - z2 * x1;
  y1 = z2 * x0 - z0 * x2;
  y2 = z0 * x1 - z1 * x0;

  len = Math.sqrt(y0 * y0 + y1 * y1 + y2 * y2);
  if (!len) {
      y0 = 0;
      y1 = 0;
      y2 = 0;
  } else {
      len = 1 / len;
      y0 *= len;
      y1 *= len;
      y2 *= len;
  }

  const e = dest.elements;
  e[0] = x0;
  e[1] = y0;
  e[2] = z0;
  e[3] = 0;
  e[4] = x1;
  e[5] = y1;
  e[6] = z1;
  e[7] = 0;
  e[8] = x2;
  e[9] = y2;
  e[10] = z2;
  e[11] = 0;
  e[12] = -(x0 * posx + x1 * posy + x2 * posz);
  e[13] = -(y0 * posx + y1 * posy + y2 * posz);
  e[14] = -(z0 * posx + z1 * posy + z2 * posz);
  e[15] = 1;

  return dest;
}

export function transformVec3(m, v, dest = _vec3) {
  const v0 = v.x;
  const v1 = v.y;
  const v2 = v.z;
  const e = m.elements;
  dest.x = (e[0] * v0) + (e[4] * v1) + (e[8] * v2);
  dest.y = (e[1] * v0) + (e[5] * v1) + (e[9] * v2);
  dest.z = (e[2] * v0) + (e[6] * v1) + (e[10] * v2);
  return dest;
}

controller

import { EventDispatcher, MOUSE, Spherical, Vector2, Vector3,Matrix3,Matrix4 } from 'three'
import {  lookAtMat4v } from './exokit-math.js'
// OrbitControls performs orbiting, dollying (zooming), and panning.
// Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default).

const _changeEvent = { type: 'change' }
const _startEvent = { type: 'start' }
const _endEvent = { type: 'end' }
const EPSILON = 0.000001
const maxElapsed = 1 / 20
const minElapsed = 1 / 60
const intersectPoint = new Vector3()
const tempVec3 = new Vector3()
const tempVec3b = new Vector3()
const tempMatrix3 = new Matrix3()
const tempDiff = new Vector3()
const tempMatrix4 = new Matrix4()

/**
 * 轨道控制器 封装优化思路
 * 1. 抽象出相机坐标系下的操作,
 * 1.1如pan,dolly,rotate,细分出panLeft,panUp,dollyIn,dollyOut,rotateLeft,rotateUp
 * 1.2 实现触摸、鼠标、快捷键分别控制细分操作
*
 */
class OrbitControls extends EventDispatcher {
  constructor(object, domElement,three) {
    super()

    this.object = object
    this.domElement = domElement
    this.domElement.style.touchAction = 'none' // disable touch scroll

    this.three = three

    // Set to false to disable this control
    this.enabled = true

    // "target" sets the location of focus, where the object orbits around
    this.target = new Vector3()

    // Sets the 3D cursor (similar to Blender), from which the maxTargetRadius takes effect
    this.cursor = new Vector3()

    // How far you can dolly in and out ( PerspectiveCamera only )
    this.minDistance = 0
    this.maxDistance = Infinity

    // This option actually enables dollying in and out; left as "zoom" for backwards compatibility.
    // Set to false to disable zooming
    this.enableZoom = true
    this.zoomSpeed = 1.0
    this.keyDollySpeed = 2.0
    this.shiftSpeed = 4.0
    // Set to false to disable rotating
    this.enableRotate = true
    this.rotateSpeed = 1.0

    // Set to false to disable panning
    this.enablePan = true
    this.panSpeed = 1
    this.keyPanSpeed = 7.0 // pixels moved per arrow key push

    // Set to true to automatically rotate around the target
    // If auto-rotate is enabled, you must call controls.update() in your animation loop
    this.autoRotate = false
    this.autoRotateSpeed = 2.0 // 30 seconds per orbit when fps is 60

    // The four arrow keys 改为数组形式
    this.keys = { LEFT: ['ArrowLeft'], UP: ['ArrowUp'], RIGHT: ['ArrowRight'], BOTTOM: ['ArrowDown'], ZOOM_IN: ['ArrowUp'], ZOOM_OUT: ['ArrowDown'] }

    // Mouse buttons
    this.mouseButtons = { LEFT: MOUSE.ROTATE, MIDDLE: MOUSE.DOLLY, RIGHT: MOUSE.PAN }

    // for reset
    this.target0 = this.target.clone()
    this.position0 = this.object.position.clone()
    this.zoom0 = this.object.zoom

    // the target DOM element for key events
    this._domElementKeyEvents = null

    this.pivot = new Vector3()

    //
    // public methods
    //
    this.getPolarAngle = function () {
      return spherical.phi
    }

    this.getAzimuthalAngle = function () {
      return spherical.theta
    }

    this.getDistance = function () {
      return this.object.position.distanceTo(this.target)
    }

    this.listenToKeyEvents = function (domElement) {
      domElement.addEventListener('keydown', onKeyDown)
      this._domElementKeyEvents = domElement
    }

    this.stopListenToKeyEvents = function () {
      this._domElementKeyEvents.removeEventListener('keydown', onKeyDown)
      this._domElementKeyEvents = null
    }

    this.saveState = function () {
      scope.target0.copy(scope.target)
      scope.position0.copy(scope.object.position)
      scope.zoom0 = scope.object.zoom
    }

    this.reset = function () {
      scope.target.copy(scope.target0)
      scope.object.position.copy(scope.position0)
      scope.object.zoom = scope.zoom0

      scope.object.updateProjectionMatrix()
      scope.dispatchEvent(_changeEvent)

      scope.update()

      state = STATE.NONE
    }

    // this method is exposed, but perhaps it would be better if we can make it private...
    this.update =  function update(deltaTime = null) {

        if (scope.autoRotate && state === STATE.NONE) {
          rotateLeft(getAutoRotationAngle(deltaTime))
        }
        scope.object.lookAt(scope.target)
      }

    this.dispose = function () {
      scope.domElement.removeEventListener('contextmenu', onContextMenu)

      scope.domElement.removeEventListener('pointerdown', onPointerDown)
      scope.domElement.removeEventListener('pointercancel', onPointerUp)
      scope.domElement.removeEventListener('wheel', onMouseWheel)

      scope.domElement.removeEventListener('pointermove', onPointerMove)
      scope.domElement.removeEventListener('pointerup', onPointerUp)

      scope.domElement.removeEventListener('mousemove', handleMouseMove)

      const document = scope.domElement.getRootNode() // offscreen canvas compatibility

      document.removeEventListener('keydown', interceptControlDown, { capture: true })

      if (scope._domElementKeyEvents !== null) {
        scope._domElementKeyEvents.removeEventListener('keydown', onKeyDown)
        scope._domElementKeyEvents = null
      }

      //scope.dispatchEvent( { type: 'dispose' } ); // should this be added here?
    }

    //
    // internals
    //

    const scope = this

    const STATE = {
      NONE: -1,
      ROTATE: 0,
      DOLLY: 1,
      PAN: 2,
      TOUCH_ROTATE: 3,
      TOUCH_PAN: 4,
      TOUCH_DOLLY_PAN: 5,
      TOUCH_DOLLY_ROTATE: 6,
    }

    let state = STATE.NONE

    //panning
    const panOffset = new Vector3()
    const panStart = new Vector2()
    const panEnd = new Vector2()
    const panDelta = new Vector3()
    const panTargrt = new Vector3()

    //dollying
    let scale = 1
    const dollyStart = new Vector2()
    const dollyEnd = new Vector2()
    const dollyDelta = new Vector2()

    const pointers = []
    const pointerPositions = {}

    //rotating
    const cameraOffset = new Vector3() // 相机偏移量
    const rotateStart = new Vector2()
    const rotateEnd = new Vector2()
    const rotateDelta = new Vector2()
    const spherical = new Spherical()

    let secsNowLast = null;
    let dollyDelta1 = 0
    let dollyDistFactor = 1.0
    const mouseWheelDollyRate = 100
    const dollyProximityThreshold = 30.0
    const dollyMinSpeed = 0.04
    const dollyMaxSpeed = 20
    const dollyState = {
      isPass:false,
      changePos:true
    }

    let controlActive = false

camera-actions

    function getAutoRotationAngle(deltaTime) {
      if (deltaTime !== null) {
        return ((2 * Math.PI) / 60) * scope.autoRotateSpeed * deltaTime
      } else {
        return ((2 * Math.PI) / 60 / 60) * scope.autoRotateSpeed
      }
    }

    function rotateLeft(angle) {
      spherical.theta -= angle * 0.6
    }

    function rotateUp(angle) {
      spherical.phi -= angle * 0.6
    }

    //实际dolly的函数
    function dollyToCanvasPos(optionalTargetWorldPos, targetCanvasPos, dollyDelta) {
      const camera = scope.object;
      let dolliedThroughSurface = false
      if (optionalTargetWorldPos) {
        const eyeToWorldPosVec = new Vector3().subVectors(optionalTargetWorldPos, camera.position);
        const eyeWorldPosDist = eyeToWorldPosVec.length();
        dolliedThroughSurface = (eyeWorldPosDist < dollyDelta);
      }
      if (camera.type === "PerspectiveCamera") {

          const unprojectedWorldPos= _unproject(targetCanvasPos)
          const offset = new Vector3().subVectors(unprojectedWorldPos, camera.position);
          const moveVec = offset.clone().normalize().multiplyScalar( -dollyDelta);
          camera.position.sub(moveVec)
          scope.target.sub(moveVec)
          //if (optionalTargetWorldPos) {
           //   const eyeTargetVec = new Vector3().subVectors(optionalTargetWorldPos, camera.position);
             // const lenEyeTargetVec = eyeTargetVec.length();
              //const eyeLookVec = eyeTargetVec.normalize().multiplyScalar(lenEyeTargetVec);
              //scope.target.add(eyeLookVec)
          //}
      }
      return dolliedThroughSurface
    }
    //将画布坐标转换为世界坐标
    function _unproject(canvasPos, worldPos = new Vector3()) {

      const camera = scope.object;

        // 获取投影矩阵
        const projMatrix = camera.projectionMatrix;

        // 获取转置矩阵的第三行和第四行
        // Three.js中矩阵是列主序4x4矩阵,存储在Float32Array中
        const Pt3 = [
            projMatrix.elements[2],  // m20
            projMatrix.elements[6],  // m21
            projMatrix.elements[10], // m22
            projMatrix.elements[14]  // m23
        ];

        const Pt4 = [
            projMatrix.elements[3],  // m30
            projMatrix.elements[7],  // m31
            projMatrix.elements[11], // m32
            projMatrix.elements[15]  // m33
        ];

        const D = [0, 0, -1.0, 1];

        // 计算screenZ
        const screenZ = (
            D[0] * Pt3[0] + D[1] * Pt3[1] + D[2] * Pt3[2] + D[3] * Pt3[3]
        ) / (
            D[0] * Pt4[0] + D[1] * Pt4[1] + D[2] * Pt4[2] + D[3] * Pt4[3]
        );

        // 将画布坐标转换为标准化设备坐标 (NDC)
        const ndcX = (canvasPos.x / scope.domElement.clientWidth) * 2 - 1;
        const ndcY = -(canvasPos.y / scope.domElement.clientHeight) * 2 + 1;
        // 创建NDC坐标
        const ndcPos = new Vector3(ndcX, ndcY, screenZ);

        // 创建投影矩阵和视图矩阵的逆矩阵
        const projInverse = camera.projectionMatrix.clone().invert();
        const viewInverse = camera.matrixWorld; // 相机的世界矩阵就是视图矩阵的逆

        // 应用逆变换
        ndcPos.applyMatrix4(projInverse);
        ndcPos.applyMatrix4(viewInverse);

        // 将结果复制到worldPos
        worldPos.set(ndcPos.x, ndcPos.y, ndcPos.z);

        return worldPos;
    }
    //
    // event callbacks - update the object state
    //
    function handleMouseDownDolly(event) {
      dollyStart.set(event.clientX, event.clientY)
    }

    /**
     * 鼠标点击平移
     * @param {*} event
     */
    function handleMouseDownPan(event) {
      panStart.set(event.clientX, event.clientY)
      const mouseDownPicked = getIntersectPoint(event)
      dollyState.changePos = false
      if(mouseDownPicked){
        panTargrt.copy(intersectPoint)
      } else {
        panTargrt.copy(scope.target)
      }
    }

    /**
     * 获取鼠标点击的射线与场景的交点
     * @param {*} event
     * @returns
     */
    function getIntersectPoint(event) {
      const intersects = scope.three.setMouseRayCaster(event.clientX, event.clientY)
      if (intersects.length) {
        intersectPoint.copy(intersects[0].point)
        return true
      }
      return false
    }
    
     function genViewMat4(pos, target, up,dest=tempMatrix4){
      const zAxis = new Vector3().subVectors(pos, target).normalize()
      const xAxis = new Vector3().crossVectors(up, zAxis).normalize()
      const yAxis = new Vector3().crossVectors(zAxis, xAxis).normalize()

      //得到一个正交矩阵,这个矩阵是相机坐标系下的lookat矩阵
      dest.makeBasis(xAxis, yAxis, zAxis, dest)

      //求逆矩阵,得到view矩阵,但是由于正交矩阵的逆矩阵就是其转置矩阵,所以直接转置即可,避免计算逆矩阵
      dest.transpose()

      dest.elements[12] = -pos.dot(xAxis)
      dest.elements[13] = -pos.dot(yAxis)
      dest.elements[14] = -pos.dot(zAxis)

      return dest

    }

    /**
     * 鼠标点击旋转
     * @param {*} event
     */
    function handleMouseDownRotate(event) {
      rotateStart.set(event.clientX, event.clientY)
      const position = scope.object.position
      const hasIntersectPoint = getIntersectPoint(event)
      if(hasIntersectPoint){
        scope.pivot.copy(intersectPoint)
      }
      // 相机的lookat矩阵,即相机的视图矩阵
      let lookat = scope.object.matrixWorldInverse.clone()

      // 将pivot点转换到相机坐标系下
      cameraOffset.copy(scope.pivot).applyMatrix4(lookat)
      // 计算相机位置到pivot点的距离
      const distance = position.distanceTo(scope.pivot);
      // 将距离加到相机偏移量的z轴上
      cameraOffset.z += distance;
      tempMatrix3.setFromMatrix4(scope.object.matrixWorld)

      // 将pivot点在相机坐标系下的偏移后的坐标转换到世界坐标系下
      const offset = cameraOffset.clone().applyMatrix3(tempMatrix3)

      tempDiff.subVectors(position, scope.pivot).add(offset);
      spherical.setFromVector3(tempDiff)

    }

    // 鼠标移动旋转
    function handleMouseMoveRotate(event) {
      rotateEnd.set(event.clientX, event.clientY)

      rotateDelta.subVectors(rotateEnd, rotateStart).multiplyScalar(scope.rotateSpeed)

      const element = scope.domElement

      const rotateX = (2 * Math.PI * rotateDelta.x) / element.clientHeight // yes, height
      const rotateY = (2 * Math.PI * rotateDelta.y) / element.clientHeight

      rotateLeft(rotateX)
      rotateUp(rotateY)

      // 确保球面坐标系中的角度在安全范围内
      spherical.makeSafe()
      // pos是pivotPos的坐标
      const pos = new Vector3().setFromSpherical(spherical);
      // 计算当前相机视点(eye)到观察点(look)的距离,这个距离在旋转过程中需要保持不变
      const eyeLookLen = scope.object.position.distanceTo(scope.target);
      // 获取当前的pivot点位置(旋转中心)
      const pivotPos = scope.pivot;

      // 将之前计算的球面坐标pos(相对于pivot点的偏移)加上pivot点的位置,得到新的相机位置
      pos.add(pivotPos);
      // 使用新的相机位置(pos)、pivot点位置和世界坐标系的up向量创建视图矩阵
      // 这个矩阵定义了相机的新姿态
      let lookat = lookAtMat4v(pos, pivotPos, new Vector3(0, 1, 0));
      // 将视图矩阵求逆,得到相机的世界变换矩阵
      // 这个矩阵包含了相机的位置和方向信息
      lookat.invert()
      // 将相机偏移量从相机空间转换到世界空间

      const offset = cameraOffset.clone().applyMatrix3(tempMatrix3.setFromMatrix4(lookat))

      // 应用偏移量,调整相机的最终位置

      lookat.setPosition(lookat.elements[12] - offset.x, lookat.elements[13] - offset.y, lookat.elements[14] - offset.z)
      // 从lookat矩阵中提取相机的前向方向(z轴),也就是相机空间的z轴,在世界空间下的表示
      const zAxis = new Vector3().setFromMatrixColumn(lookat, 2).normalize()
      // 设置新的相机位置(position)
      const position = new Vector3().setFromMatrixPosition(lookat)
      scope.object.position.copy(position);

    // 计算新的观察点(look)位置
    // 通过从相机位置沿着视线方向(zAxis)前进eyeLookLen距离
      scope.target.subVectors(scope.object.position, zAxis.multiplyScalar(eyeLookLen));
      // 设置相机的up向量

      scope.object.up.set(lookat.elements[4], lookat.elements[5], lookat.elements[6])

      rotateStart.set(event.clientX, event.clientY)

    }

    function handleMouseMoveDolly(event) {
      dollyEnd.set(event.clientX, event.clientY)
      dollyDelta.subVectors(dollyEnd, dollyStart)
      if (dollyDelta.y > 0) {
        calculatePanDelta(-1,event)
      } else if (dollyDelta.y < 0) {
        calculatePanDelta(1,event)
      }
      dollyStart.copy(dollyEnd)
    }
    /**
     * 新的pan函数,平移
     * @param {*} pan
     */
    function newPan(pan) {
        const eye2 = tempVec3.subVectors(scope.object.position, scope.target);
        const vec = new Vector3()
        if (pan.x !== 0) {
            const left = tempVec3b.crossVectors(eye2.normalize(), scope.object.up.normalize());
            vec.add(left.multiplyScalar(pan.x))
        }
        if (pan.y !== 0) {
            vec.add(tempVec3b.copy(scope.object.up).multiplyScalar(pan.y))
        }
        if (pan.z !== 0) {
            vec.add(tempVec3b.copy(eye2).multiplyScalar(pan.z))
        }
        scope.object.position.add(vec)
        scope.target.add(vec)
    }

    function panLeftUp(panOffset) {
      const  panDepth = scope.object.position.distanceTo(panTargrt)
      const targetDistance = panDepth * Math.tan((scope.object.fov / 2) * Math.PI / 180.0);
      panDelta.x += (panOffset.x * targetDistance / scope.domElement.clientHeight);
      panDelta.y += ( panOffset.y * targetDistance / scope.domElement.clientHeight);
      if (Math.abs(panDelta.x) < EPSILON) {
        panDelta.x = 0;
      }

      if (Math.abs(panDelta.y) < EPSILON) {
        panDelta.y = 0;
      }

      if (Math.abs(panDelta.z) < EPSILON) {
        panDelta.z = 0;
      }
      newPan(panDelta)
      panDelta.set(0,0,0)

    }
    // 鼠标移动平移
    function handleMouseMovePan(event) {
      panEnd.set(event.clientX, event.clientY)
      panOffset.subVectors(panEnd, panStart).multiplyScalar(scope.panSpeed)
      panOffset.z = 0
      panLeftUp(panOffset)
      panStart.set(event.clientX, event.clientY)
    }

    // 响应dolly操作,delta为放大或缩小
    function calculatePanDelta(delta,event){
      const secsNow = performance.now() / 1000.0;
      let secsElapsed = (secsNowLast !== null) ? (secsNow - secsNowLast) : 0;
      secsNowLast = secsNow;
      if (secsElapsed > maxElapsed) {
          secsElapsed = maxElapsed;
      }
      if (secsElapsed < minElapsed) {
          secsElapsed = minElapsed;
      }
      if (Math.abs(dollyDelta1) < EPSILON) {
        dollyDelta1 = 0;
      }

      if (delta === 0) {
          return;
      }
      const normalizedDelta = delta / Math.abs(delta);
      dollyDelta1 += -normalizedDelta * secsElapsed * mouseWheelDollyRate;

      let target = null

      if(dollyState.changePos ){
        const hasIntersectPoint = getIntersectPoint(event)
        if(hasIntersectPoint){
          target = intersectPoint
          dollyState.changePos = false
        } else {
          dollyDistFactor = 1.0
          target = null
        }
      }
      if(target){
        const dist = Math.abs(scope.object.position.distanceTo(target));
        dollyDistFactor = dist / dollyProximityThreshold;

      }

      dollyDistFactor = Math.max(Math.min(dollyDistFactor, dollyMaxSpeed), dollyMinSpeed)

      let dollyDelta = dollyDelta1 * dollyDistFactor

      const isPass = dollyToCanvasPos(target, new Vector2(event.clientX, event.clientY), -dollyDelta)

      if(isPass){
        dollyState.changePos = true
      }
      dollyDelta1 = 0
    }

    function handleMouseWheel(event) {

      //放大delta >0 缩小delta <0
      const delta = Math.max(-1, Math.min(1, -event.deltaY * 40));
      calculatePanDelta(delta,event)

    }

    function handleKeyDown(event) {
      let needsUpdate = false
      let speed = 1
      if(event.shiftKey){
        //加速
        speed *= scope.shiftSpeed
      }

      const filterRes = Object.entries(scope.keys).filter(([_, value]) => value.includes(event.code))
      if (filterRes.length) {
        const [aspect] = filterRes[0]
        switch(aspect){
          case 'UP':
            panLeftUp(new Vector3(0, -speed*scope.keyPanSpeed, 0))
            break
          case 'BOTTOM':
            panLeftUp(new Vector3(0, speed*scope.keyPanSpeed, 0))
            break
          case 'LEFT':
            panLeftUp(new Vector3(-speed*scope.keyPanSpeed,0,  0))
            break
          case 'RIGHT':
            panLeftUp(new Vector3(speed*scope.keyPanSpeed,0,  0))
            break
          case 'ZOOM_IN':
            dollyToCanvasPos(null, new Vector2(scope.domElement.clientWidth/2, scope.domElement.clientHeight/2), speed*scope.keyDollySpeed)
            break
          case 'ZOOM_OUT':
            dollyToCanvasPos(null, new Vector2(scope.domElement.clientWidth/2, scope.domElement.clientHeight/2), -speed*scope.keyDollySpeed)
            break

        }
        needsUpdate = true
      }

      if (needsUpdate) {
        // prevent the browser from scrolling on cursor keys
        event.preventDefault()
      }
    }

    //
    // event handlers - FSM: listen for events and reset state
    //

input-state

    function onPointerDown(event) {
      if (scope.enabled === false) return

      if (pointers.length === 0) {
        scope.domElement.setPointerCapture(event.pointerId)

        scope.domElement.addEventListener('pointermove', onPointerMove)
        scope.domElement.addEventListener('pointerup', onPointerUp)
      }

      //

      if (isTrackingPointer(event)) return

      //

      addPointer(event)

      if (event.pointerType === 'touch') {
      } else {
        onMouseDown(event)
      }
    }

    function onPointerMove(event) {
      dollyState.changePos = true
      if (scope.enabled === false) return

      if (event.pointerType === 'touch') {
      } else {
        onMouseMove(event)
      }
    }

    function onPointerUp(event) {
      removePointer(event)

      switch (pointers.length) {
        case 0:
          scope.domElement.releasePointerCapture(event.pointerId)

          scope.domElement.removeEventListener('pointermove', onPointerMove)
          scope.domElement.removeEventListener('pointerup', onPointerUp)

          scope.dispatchEvent(_endEvent)

          state = STATE.NONE

          break

        case 1:

          break
      }
    }

    function onMouseDown(event) {
      let mouseAction
      dollyState.changePos = true

      switch (event.button) {
        case 0:
          mouseAction = scope.mouseButtons.LEFT
          break

        case 1:
          mouseAction = scope.mouseButtons.MIDDLE
          break

        case 2:
          mouseAction = scope.mouseButtons.RIGHT
          break

        default:
          mouseAction = -1
      }

      switch (mouseAction) {
        case MOUSE.DOLLY:
          if (scope.enableZoom === false) return

          handleMouseDownDolly(event)

          state = STATE.DOLLY

          break

        case MOUSE.ROTATE:
          if (event.ctrlKey || event.metaKey || event.shiftKey) {
            if (scope.enablePan === false) return

            handleMouseDownPan(event)

            state = STATE.PAN
          } else {
            if (scope.enableRotate === false) return

            handleMouseDownRotate(event)

            state = STATE.ROTATE
          }

          break

        case MOUSE.PAN:
          if (event.ctrlKey || event.metaKey || event.shiftKey) {
            if (scope.enableRotate === false) return

            handleMouseDownRotate(event)

            state = STATE.ROTATE
          } else {
            if (scope.enablePan === false) return

            handleMouseDownPan(event)

            state = STATE.PAN
          }

          break

        default:
          state = STATE.NONE
      }

      if (state !== STATE.NONE) {
        scope.dispatchEvent(_startEvent)
      }
    }

    function onMouseMove(event) {
      switch (state) {
        case STATE.ROTATE:
          if (scope.enableRotate === false) return

          handleMouseMoveRotate(event)

          break

        case STATE.DOLLY:
          if (scope.enableZoom === false) return

          handleMouseMoveDolly(event)

          break

        case STATE.PAN:
          if (scope.enablePan === false) return

          handleMouseMovePan(event)

          break
      }
    }

    function onMouseWheel(event) {
      if (scope.enabled === false || scope.enableZoom === false || state !== STATE.NONE) return

      event.preventDefault()

      scope.dispatchEvent(_startEvent)

      handleMouseWheel(customWheelEvent(event))
      // handleMouseWheel(event)

      scope.dispatchEvent(_endEvent)
    }

    function handleMouseMove(event){
      dollyState.changePos = true
    }

    function customWheelEvent(event) {
      const mode = event.deltaMode

      // minimal wheel event altered to meet delta-zoom demand
      const newEvent = {
        clientX: event.clientX,
        clientY: event.clientY,
        deltaY: event.deltaY,
      }

      // switch (mode) {
      //   case 1: // LINE_MODE
      //     newEvent.deltaY *= 16
      //     break

      //   case 2: // PAGE_MODE
      //     newEvent.deltaY *= 100
      //     break
      // }

      // detect if event was triggered by pinching
      if (event.ctrlKey && !controlActive) {
        newEvent.deltaY *= 10
      }

      return newEvent
    }

    function interceptControlDown(event) {
      if (event.key === 'Control') {
        controlActive = true

        const document = scope.domElement.getRootNode() // offscreen canvas compatibility

        document.addEventListener('keyup', interceptControlUp, { passive: true, capture: true })
      }
    }

    function interceptControlUp(event) {
      if (event.key === 'Control') {
        controlActive = false

        const document = scope.domElement.getRootNode() // offscreen canvas compatibility

        document.removeEventListener('keyup', interceptControlUp, { passive: true, capture: true })
      }
    }

    function onKeyDown(event) {
      if (scope.enabled === false || scope.enablePan === false || event.target.nodeName == 'INPUT') return

      handleKeyDown(event)
    }

    function onContextMenu(event) {
      if (scope.enabled === false) return
      event.preventDefault()
    }

    function addPointer(event) {
      pointers.push(event.pointerId)
    }

    function removePointer(event) {
      delete pointerPositions[event.pointerId]

      for (let i = 0; i < pointers.length; i++) {
        if (pointers[i] == event.pointerId) {
          pointers.splice(i, 1)
          return
        }
      }
    }

    function isTrackingPointer(event) {
      for (let i = 0; i < pointers.length; i++) {
        if (pointers[i] == event.pointerId) return true
      }

      return false
    }

    //

    scope.domElement.addEventListener('contextmenu', onContextMenu)
    scope.domElement.addEventListener('mousemove', handleMouseMove)

    scope.domElement.addEventListener('pointerdown', onPointerDown)
    scope.domElement.addEventListener('pointercancel', onPointerUp)
    scope.domElement.addEventListener('wheel', onMouseWheel, { passive: false })

    const document = scope.domElement.getRootNode() // offscreen canvas compatibility

    document.addEventListener('keydown', interceptControlDown, { passive: true, capture: true })

    // force an update at start
    this.update()
  }
}

export default OrbitControls