chrmoritz/Troxel

View on GitHub
coffee/Controls.coffee

Summary

Maintainability
Test Coverage
'use strict'
THREE = require('three')

class TroxelControls extends THREE.EventDispatcher
  constructor: (@object, @domElement) ->
    # API
    @enabled = true
    @mode = true # true for Orbital and false for Fly controls
    @target = new THREE.Vector3() # target sets the location of focus, where the control orbits around and where it pans with respect to.
    @noZoom = false
    @zoomSpeed = 0.95 # < 1.0
    @minDistance = 0
    @maxDistance = Infinity
    @noRotate = false
    @rotateSpeed = 1.0
    @noPan = false
    @autoRotate = false
    @autoRotateSpeed = -4.0
    @noKeys = false
    # internals
    @needsRender = false
    @EPS = 0.000001
    @rotateStart = new THREE.Vector2()
    @rotateEnd = new THREE.Vector2()
    @rotateDelta = new THREE.Vector2()
    @panStart = new THREE.Vector2()
    @panEnd = new THREE.Vector2()
    @panDelta = new THREE.Vector2()
    @panOffset = new THREE.Vector3()
    @offset = new THREE.Vector3()
    @dollyStart = new THREE.Vector2()
    @dollyEnd = new THREE.Vector2()
    @dollyDelta = new THREE.Vector2()
    @theta = 0
    @phi = 0
    @phiDelta = 0
    @thetaDelta = 0
    @scale = 1
    @pan = new THREE.Vector3()
    @lastPosition = new THREE.Vector3()
    @lastQuaternion = new THREE.Quaternion()
    @STATE = NONE: -1, ROTATE: 0, DOLLY: 1, PAN: 2, TOUCH_ROTATE: 3, TOUCH_DOLLY: 4, TOUCH_PAN: 5, FLY: 6
    @state = @STATE.NONE
    @target0 = @target.clone()
    @position0 = @object.position.clone()
    @quat = new THREE.Quaternion().setFromUnitVectors @object.up, new THREE.Vector3 0, 1, 0  # so camera.up is the orbit axis
    @quatInverse = @quat.clone().inverse()
    # Fly Controls internals
    @moveVector = new THREE.Vector3()
    @rotationVector = new THREE.Vector3()
    @tmpQuaternion = new THREE.Quaternion()
    @mousefly = false
    # Event handlers
    @domElement.addEventListener 'contextmenu', (e) -> e.preventDefault()
    @domElement.addEventListener 'mousedown', (e) => @onMouseDown(e)
    document.addEventListener 'mouseup', (e) => @onMouseUp(e)
    document.addEventListener 'mousemove', (e) => @onMouseMove(e)
    @domElement.addEventListener 'mousewheel', (e) => @onMouseWheel(e)
    @domElement.addEventListener 'DOMMouseScroll', (e) => @onMouseWheel(e) # firefox
    @domElement.addEventListener 'touchstart', (e) => @touchstart(e)
    @domElement.addEventListener 'touchend', (e) => @touchend(e)
    @domElement.addEventListener 'touchmove', (e) => @touchmove(e)
    window.addEventListener 'keydown', (e) => @onKeyDown(e)
    window.addEventListener 'keyup', (e) => @onKeyUp(e)
    @object.lookAt @target
    @update() # force an update at start

  rotateLeft: (angle) ->
    angle = 2 * Math.PI / 60 / 60 * @autoRotateSpeed unless angle?
    @thetaDelta -= angle

  rotateUp: (angle) ->
    angle = 2 * Math.PI / 60 / 60 * @autoRotateSpeed unless angle?
    @phiDelta -= angle

  panLeft: (distance) -> # pass in distance in world space to move left
    te = @object.matrix.elements
    @panOffset.set te[0], te[1], te[2] # get X column of matrix
    @panOffset.multiplyScalar -distance
    @pan.add @panOffset

  panUp: (distance) -> # pass in distance in world space to move up
    te = @object.matrix.elements
    @panOffset.set te[4], te[5], te[6] # get Y column of matrix
    @panOffset.multiplyScalar distance
    @pan.add @panOffset

  panXY: (deltaX, deltaY) -> # pass in x,y of change desired in pixel space, right and down are positive
    if @object instanceof THREE.PerspectiveCamera
      position = @object.position
      targetDistance = position.clone().sub(@target).length()
      targetDistance *= Math.tan (@object.fov / 2) * Math.PI / 180.0 # half of the fov is center to top of screen
      @panLeft 2 * deltaX * targetDistance / @domElement.clientHeight # we actually don't use screenWidth, since perspective camera is fixed to screen height
      @panUp 2 * deltaY * targetDistance / @domElement.clientHeight
    else
      console.warn 'WARNING: Controls.js only supports perspective camera type.'

  dollyIn: (dollyScale) ->
    dollyScale = @zoomSpeed unless dollyScale?
    @scale /= dollyScale

  dollyOut: (dollyScale) ->
    dollyScale = @zoomSpeed unless dollyScale?
    @scale *= dollyScale

  update: (stats) ->
    if @mode # Orbital Controls
      position = @object.position
      @offset.copy(position).sub @target
      @offset.applyQuaternion @quat # rotate offset to "y-axis-is-up" space
      @theta = Math.atan2 @offset.x, @offset.z # angle from z-axis around y-axis
      @phi = Math.atan2(Math.sqrt(@offset.x * @offset.x + @offset.z * @offset.z ), @offset.y) # angle from y-axis
      @rotateLeft 2 * Math.PI / 60 / 60 * @autoRotateSpeed if @autoRotate and @state == @STATE.NONE
      @theta += @thetaDelta
      @phi += @phiDelta
      @phi = Math.max @EPS, Math.min Math.PI - @EPS, @phi # restrict phi to be betwee EPS and PI-EPS
      radius = @offset.length() * @scale
      radius = Math.max @minDistance, Math.min @maxDistance, radius # restrict radius to be between desired limits
      @target.add @pan # move target to panned location
      @offset.x = radius * Math.sin(@phi) * Math.sin(@theta)
      @offset.y = radius * Math.cos(@phi)
      @offset.z = radius * Math.sin(@phi) * Math.cos(@theta)
      @offset.applyQuaternion @quatInverse # rotate offset back to "camera-up-vector-is-up" space
      position.copy(@target).add @offset
      @object.lookAt @target
      @thetaDelta = 0
      @phiDelta = 0
      @scale = 1
      @pan.set 0, 0, 0
    else # Fly Controls
      @object.translateX @moveVector.x * 20
      @object.translateY @moveVector.y * 20
      @object.translateZ @moveVector.z * 20
      @tmpQuaternion.set(@rotationVector.x * 0.005, @rotationVector.y * 0.005, @rotationVector.z * 0.005, 1).normalize()
      @object.quaternion.multiply @tmpQuaternion
      @object.rotation.setFromQuaternion @object.quaternion, @object.rotation.order # expose the rotation vector for convenience
    # update condition is: min(camera displacement, camera rotation in radians)^2 > EPS using small-angle approximation cos(x/2) = 1 - x^2 / 8
    if @lastPosition.distanceToSquared(@object.position) > @EPS || 8 * (1 - @lastQuaternion.dot(@object.quaternion)) > @EPS
      @dispatchEvent type: 'change'
      @lastPosition.copy @object.position
      @lastQuaternion.copy @object.quaternion
      return @needsRender = false
    if @needsRender
      @dispatchEvent type: 'change'
      return @needsRender = false
    stats.end() if stats?

  reset = ->
    @state = @STATE.NONE
    @target.copy @target0
    @object.position.copy @position0
    @object.updateProjectionMatrix()
    @dispatchEvent type: 'change'
    @update()

  onMouseDown: (e) ->
    return unless @enabled
    e.preventDefault()
    if @mode # Orbital Controls
      if e.button == 0 # left moude button
        return if @noRotate
        @state = @STATE.ROTATE
        @rotateStart.set e.clientX, e.clientY
      else if e.button == 1 # middle mouse button
        return if @noZoom
        @state = @STATE.DOLLY
        @dollyStart.set e.clientX, e.clientY
      else if e.button == 2 # right mouse button
        return if @noPan
        @state = @STATE.PAN
        @panStart.set e.clientX, e.clientY
    else # Fly Controls
      @mousefly = true
      @state = @STATE.FLY
      if e.button == 0 # left mouse button
        @moveVector.z = -1
      else if e.button == 2 # right mouse button
        @moveVector.z = 1
    @dispatchEvent type: 'start' if @state != @STATE.NONE

  onMouseMove: (e) ->
    return unless @enabled
    e.preventDefault()
    if @state == @STATE.ROTATE
      return if @noRotate
      @rotateEnd.set e.clientX, e.clientY
      @rotateDelta.subVectors @rotateEnd, @rotateStart
      @rotateLeft 2 * Math.PI * @rotateDelta.x / @domElement.clientWidth * @rotateSpeed # rotating across whole screen goes 360 degrees around
      @rotateUp 2 * Math.PI * @rotateDelta.y / @domElement.clientHeight * @rotateSpeed # rotating up and down along whole screen attempts to go 360, but limited to 180
      @rotateStart.copy @rotateEnd
    else if @state == @STATE.DOLLY
      return if @noZoom
      @dollyEnd.set e.clientX, e.clientY
      @dollyDelta.subVectors @dollyEnd, @dollyStart
      if @dollyDelta.y > 0
        @dollyIn()
      else if @dollyDelta.y < 0
        @dollyOut()
      @dollyStart.copy @dollyEnd
    else if @state == @STATE.PAN
      return if @noPan
      @panEnd.set e.clientX, e.clientY
      @panDelta.subVectors @panEnd, @panStart
      @panXY @panDelta.x, @panDelta.y
      @panStart.copy @panEnd
    else if @mousefly
      w = @domElement.clientWidth / 2
      h = @domElement.clientHeight / 2
      @rotationVector.y = -(e.clientX - w) / w
      @rotationVector.x = -(e.clientY - h) / h
    @update() if @state != @STATE.NONE and @mode

  onMouseUp: ->
    return unless @enabled
    @moveVector.z = 0
    @dispatchEvent type: 'end'
    @state = @STATE.NONE

  onMouseWheel: (e) ->
    return if !@enabled or @noZoom or @state != @STATE.NONE or !@mode
    e.preventDefault()
    e.stopPropagation()
    delta = 0
    if e.wheelDelta? # WebKit / Opera / Explorer 9
      delta = e.wheelDelta
    else if e.detail? # Firefox
      delta = - e.detail
    if delta > 0
      @dollyOut()
    else if delta < 0
      @dollyIn()
    @update()
    @dispatchEvent type: 'start'
    @dispatchEvent type: 'end'

  onKeyDown: (e) ->
    return if !@enabled or @noKeys
    if @mode # Orbital Controls
      unless @noRotate
        switch e.keyCode
          when 87 then @rotateUp -0.05; @update() # W
          when 65 then @rotateLeft -0.05; @update() # A
          when 83 then @rotateUp 0.05; @update() # S
          when 68 then @rotateLeft 0.05; @update() # D
      unless @noZoom
        switch e.keyCode
          when 81 then @dollyIn(); @update() # Q
          when 69 then @dollyOut(); @update() # E
      unless @noPan
        switch e.keyCode
          when 38 then @panXY 0, 7.0; @update() # up arrow key
          when 40 then @panXY 0, -7.0; @update() # down arrow key
          when 37 then @panXY 7.0, 0; @update() # left arrow key
          when 39 then @panXY -7.0, 0; @update() # right arrow key
    else # Fly Controls
      (@rotationVector.set(0, 0, 0); @mousefly = false) if @mousefly
      switch e.keyCode
        when 87 then @moveVector.z = -1 # W
        when 83 then @moveVector.z = 1 # S
        when 65 then @moveVector.x = -1 # A
        when 68 then @moveVector.x = 1 # D
        when 82 then @moveVector.y = 1 # R
        when 70 then @moveVector.y = -1 # F
        when 38 then @rotationVector.x = -1 # up arrow key
        when 40 then @rotationVector.x = 1 # down arrow key
        when 37 then @rotationVector.y = 1 # left arrow key
        when 39 then @rotationVector.y = -1 # right arrow key
        when 81 then @rotationVector.z = 1 # Q
        when 69 then @rotationVector.z = -1 # E

  onKeyUp: (e) ->
    return if !@enabled or @noKeys or @mode
    switch e.keyCode
      when 87 then @moveVector.z = 0 # W
      when 83 then @moveVector.z = 0 # S
      when 65 then @moveVector.x = 0 # A
      when 68 then @moveVector.x = 0 # D
      when 82 then @moveVector.y = 0 # R
      when 70 then @moveVector.y = 0 # F
      when 38 then @rotationVector.x = 0 # up arrow key
      when 40 then @rotationVector.x = 0 # down arrow key
      when 37 then @rotationVector.y = 0 # left arrow key
      when 39 then @rotationVector.y = 0 # right arrow key
      when 81 then @rotationVector.z = 0 # Q
      when 69 then @rotationVector.z = 0 # E

  touchstart: (e) ->
    return unless @enabled and @mode
    switch e.touches.length
      when 1 # one-fingered touch: rotate
        return if @noRotate
        @state = @STATE.TOUCH_ROTATE
        @rotateStart.set e.touches[0].pageX, e.touches[0].pageY
      when 2 # two-fingered touch: dolly
        return if @noZoom
        @state = @STATE.TOUCH_DOLLY
        dx = e.touches[0].pageX - e.touches[1].pageX
        dy = e.touches[0].pageY - e.touches[1].pageY
        @dollyStart.set 0, Math.sqrt dx * dx + dy * dy
      when 3 # three-fingered touch: pan
        return if @noPan
        @state = @STATE.TOUCH_PAN
        @panStart.set e.touches[0].pageX, e.touches[0].pageY
      else
        @state = @STATE.NONE
    @dispatchEvent type: 'start' if @state != @STATE.NONE

  touchmove: (e) ->
    return unless @enabled and @mode
    e.preventDefault()
    e.stopPropagation()
    switch e.touches.length
      when 1 # one-fingered touch: rotate
        return if @noRotate or @state != @STATE.TOUCH_ROTATE
        @rotateEnd.set e.touches[0].pageX, e.touches[0].pageY
        @rotateDelta.subVectors @rotateEnd, @rotateStart
        @rotateLeft 2 * Math.PI * @rotateDelta.x / @domElement.clientWidth * @rotateSpeed # rotating across whole screen goes 360 degrees around
        @rotateUp 2 * Math.PI * @rotateDelta.y / @domElement.clientHeight * @rotateSpeed # rotating up and down along whole screen attempts to go 360, but limited to 180
        @rotateStart.copy @rotateEnd
        @update()
      when 2 # two-fingered touch: dolly
        return if @noZoom or @state != @STATE.TOUCH_DOLLY
        dx = e.touches[0].pageX - e.touches[1].pageX
        dy = e.touches[0].pageY - e.touches[1].pageY
        @dollyEnd.set 0, Math.sqrt dx * dx + dy * dy
        @dollyDelta.subVectors @dollyEnd, @dollyStart
        if @dollyDelta.y > 0
          @dollyOut()
        else if @dollyDelta.y < 0
          @dollyIn()
        @dollyStart.copy @dollyEnd
        @update()
      when 3 # three-fingered touch: pan
        return if @noPan or @state != @STATE.TOUCH_PAN
        @panEnd.set e.touches[0].pageX, e.touches[0].pageY
        @panDelta.subVectors @panEnd, @panStart
        @panXY @panDelta.x, @panDelta.y
        @panStart.copy @panEnd
        @update()
      else
        @state = @STATE.NONE

  touchend: ->
    return unless @enabled and @mode
    @dispatchEvent type: 'end'
    @state = @STATE.NONE

module.exports = TroxelControls