All Downloads are FREE. Search and download functionalities are using the official Maven repository.

info.laht.threekt.controls.OrbitControls.kt Maven / Gradle / Ivy

The newest version!
package info.laht.threekt.controls

import info.laht.threekt.cameras.Camera
import info.laht.threekt.cameras.CameraWithZoom
import info.laht.threekt.cameras.OrthographicCamera
import info.laht.threekt.cameras.PerspectiveCamera
import info.laht.threekt.core.EventDispatcher
import info.laht.threekt.core.EventDispatcherImpl
import info.laht.threekt.input.*
import info.laht.threekt.math.*
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import kotlin.math.*

private const val EPS = 0.000001f

class OrbitControls(
        private val camera: Camera,
        private val eventSource: PeripheralsEventSource
) : EventDispatcher by EventDispatcherImpl() {

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

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

    // How far you can dolly in and out ( PerspectiveCamera only )
    var minDistance = 0f
    var maxDistance = Float.POSITIVE_INFINITY

    // How far you can zoom in and out ( OrthographicCamera only )
    var minZoom = 0f
    var maxZoom = Float.POSITIVE_INFINITY

    // How far you can orbit vertically, upper and lower limits.
    // Range is 0 to Math.PI radians.
    var minPolarAngle = 0f // radians
    var maxPolarAngle = PI.toFloat() // radians

    // How far you can orbit horizontally, upper and lower limits.
    // If set, must be a sub-interval of the interval [ - Math.PI, Math.PI ].
    var minAzimuthAngle = Float.NEGATIVE_INFINITY // radians
    var maxAzimuthAngle = Float.POSITIVE_INFINITY // radians

    // Set to true to enable damping (inertia)
    // If damping is enabled, you must call controls.update() in your animation loop
    var enableDamping = false
    var dampingFactor = 0.05f

    // This option actually enables dollying in and out; left as "zoom" for backwards compatibility.
    // Set to false to disable zooming
    var enableZoom = true
    var zoomSpeed = 1.0f

    // Set to false to disable rotating
    var enableRotate = true
    var rotateSpeed = 1.0f

    // Set to false to disable panning
    var enablePan = true
    var panSpeed = 1.0f
    var screenSpacePanning = false // if true, pan in screen-space
    var keyPanSpeed = 7f    // 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
    var autoRotate = false
    var autoRotateSpeed = 2.0f // 30 seconds per round when fps is 60

    // Set to false to disable use of the keys
    var enableKeys = true

    // for reset
    private var target0 = target.clone()
    private var position0 = camera.position.clone()
    private var zoom0 = (camera as CameraWithZoom).zoom

    // current position in spherical coordinates
    private val spherical = Spherical()
    private val sphericalDelta = Spherical()

    private var scale = 1f
    private val panOffset = Vector3()
    private var zoomChanged = false

    private val rotateStart = Vector2()
    private val rotateEnd = Vector2()
    private val rotateDelta = Vector2()

    private val panStart = Vector2()
    private val panEnd = Vector2()
    private val panDelta = Vector2()

    private val dollyStart = Vector2()
    private val dollyEnd = Vector2()
    private val dollyDelta = Vector2()

    private var state = State.NONE

    private val defaultKeyListener = MyKeyListener()
    private val defaultMouseListener = MyMouseListener()

    init {

        update()

        eventSource.addKeyListener(defaultKeyListener)
        eventSource.addMouseListener(defaultMouseListener)

    }

    fun getPolarAngle(): Float {

        return spherical.phi

    }

    fun getAzimuthalAngle(): Float {

        return spherical.theta

    }

    fun saveState() {

        this.target0.copy(this.target)
        this.position0.copy(this.camera.position)
        this.zoom0 = (this.camera as CameraWithZoom).zoom

    }

    fun reset() {

        this.target.copy(this.target0)
        this.camera.position.copy(this.position0)
        (this.camera as CameraWithZoom).zoom = this.zoom0

        when (camera) {
            is PerspectiveCamera -> camera.updateProjectionMatrix()
            is OrthographicCamera -> camera.updateProjectionMatrix()
            else -> throw UnsupportedOperationException()
        }

        this.dispatchEvent("change", this)

        this.update()

        state = State.NONE

    }

    // this method is exposed, but perhaps it would be better if we can make it private...
    fun update(): Boolean {

        val offset = Vector3()

        // so camera.up is the orbit axis
        val quat = Quaternion().setFromUnitVectors(camera.up, Vector3(0, 1, 0))
        val quatInverse = quat.clone().inverse()

        val lastPosition = Vector3()
        val lastQuaternion = Quaternion()

        val position = this.camera.position

        offset.copy(position).sub(this.target)

        // rotate offset to "y-axis-is-up" space
        offset.applyQuaternion(quat)

        // angle from z-axis around y-axis
        spherical.setFromVector3(offset)

        if (this.autoRotate && state == State.NONE) {

            rotateLeft(getAutoRotationAngle())

        }

        if (this.enableDamping) {

            spherical.theta += sphericalDelta.theta * this.dampingFactor
            spherical.phi += sphericalDelta.phi * this.dampingFactor

        } else {

            spherical.theta += sphericalDelta.theta
            spherical.phi += sphericalDelta.phi

        }

        // restrict theta to be between desired limits
        spherical.theta = max(this.minAzimuthAngle, min(this.maxAzimuthAngle, spherical.theta))

        // restrict phi to be between desired limits
        spherical.phi = max(this.minPolarAngle, min(this.maxPolarAngle, spherical.phi))

        spherical.makeSafe()


        spherical.radius *= scale

        // restrict radius to be between desired limits
        spherical.radius = max(this.minDistance, min(this.maxDistance, spherical.radius))

        // move target to panned location

        if (this.enableDamping) {

            this.target.addScaledVector(panOffset, this.dampingFactor)

        } else {

            this.target.add(panOffset)

        }

        offset.setFromSpherical(spherical)

        // rotate offset back to "camera-up-vector-is-up" space
        offset.applyQuaternion(quatInverse)

        position.copy(this.target).add(offset)

        this.camera.lookAt(this.target)

        if (this.enableDamping) {

            sphericalDelta.theta *= (1 - this.dampingFactor)
            sphericalDelta.phi *= (1 - this.dampingFactor)

            panOffset.multiplyScalar(1 - this.dampingFactor)

        } else {

            sphericalDelta.set(0f, 0f, 0f)

            panOffset.set(0, 0, 0)

        }

        scale = 1f

        // update condition is:
        // min(camera displacement, camera rotation in radians)^2 > EPS
        // using small-angle approximation cos(x/2) = 1 - x^2 / 8

        if (zoomChanged ||
                lastPosition.distanceToSquared(this.camera.position) > EPS ||
                8 * (1 - lastQuaternion.dot(this.camera.quaternion)) > EPS
        ) {

            this.dispatchEvent("change", this)

            lastPosition.copy(this.camera.position)
            lastQuaternion.copy(this.camera.quaternion)
            zoomChanged = false

            return true

        }

        return false

    }

    fun getAutoRotationAngle(): Float {

        return 2 * PI.toFloat() / 60 / 60 * this.autoRotateSpeed

    }

    fun getZoomScale(): Float {

        return 0.95f.pow(this.zoomSpeed)

    }

    fun rotateLeft(angle: Float) {

        sphericalDelta.theta -= angle

    }

    fun rotateUp(angle: Float) {

        sphericalDelta.phi -= angle

    }

    fun panLeft(distance: Float, objectMatrix: Matrix4) {

        val v = Vector3()

        v.setFromMatrixColumn(objectMatrix, 0) // get X column of objectMatrix
        v.multiplyScalar(-distance)

        panOffset.add(v)

    }

    fun panUp(distance: Float, objectMatrix: Matrix4) {

        val v = Vector3()

        if (this.screenSpacePanning) {

            v.setFromMatrixColumn(objectMatrix, 1)

        } else {

            v.setFromMatrixColumn(objectMatrix, 0)
            v.crossVectors(this.camera.up, v)

        }

        v.multiplyScalar(distance)

        panOffset.add(v)

    }

    // deltaX and deltaY are in pixels; right and down are positive
    fun pan(deltaX: Float, deltaY: Float) {

        val offset = Vector3()

        when {
            this.camera is PerspectiveCamera -> {

                // perspective
                val position = this.camera.position
                offset.copy(position).sub(this.target)
                var targetDistance = offset.length()

                // half of the fov is center to top of screen
                targetDistance *= tan((this.camera.fov / 2) * PI.toFloat() / 180f)

                // we use only clientHeight here so aspect ratio does not distort speed
                panLeft(2 * deltaX * targetDistance / eventSource.size.height, this.camera.matrix)
                panUp(2 * deltaY * targetDistance / eventSource.size.height, this.camera.matrix)

            }
            this.camera is OrthographicCamera -> {
                // orthographic
                panLeft(
                        deltaX * (this.camera.right - this.camera.left) / this.camera.zoom / eventSource.size.width,
                        this.camera.matrix
                )
                panUp(
                        deltaY * (this.camera.top - this.camera.bottom) / this.camera.zoom / eventSource.size.height,
                        this.camera.matrix
                )
            }
            else -> {

                // camera neither orthographic nor perspective
                LOG.warn("encountered an unknown camera type - pan disabled.")
                this.enablePan = false

            }
        }

    }

    fun dollyIn(dollyScale: Float) {

        when {
            this.camera is PerspectiveCamera -> scale /= dollyScale
            this.camera is OrthographicCamera -> {

                this.camera.zoom = max(this.minZoom, min(this.maxZoom, this.camera.zoom * dollyScale))
                this.camera.updateProjectionMatrix()
                zoomChanged = true

            }
            else -> {

                LOG.warn("encountered an unknown camera type - dolly/zoom disabled.")
                this.enableZoom = false

            }
        }

    }

    fun dollyOut(dollyScale: Float) {

        when {
            this.camera is PerspectiveCamera -> scale *= dollyScale
            this.camera is OrthographicCamera -> {

                this.camera.zoom = max(this.minZoom, min(this.maxZoom, this.camera.zoom / dollyScale))
                this.camera.updateProjectionMatrix()
                zoomChanged = true

            }
            else -> {

                LOG.warn("encountered an unknown camera type - dolly/zoom disabled.")
                this.enableZoom = false

            }
        }

    }

    private fun handleKeyDown(event: KeyEvent) {

        var needsUpdate = true

        when (event.keyCode) {
            Keys.UP -> {
                pan(0f, keyPanSpeed)
            }
            Keys.BOTTOM -> {
                pan(0f, -keyPanSpeed)
            }
            Keys.LEFT -> {
                pan(keyPanSpeed, 0f)
            }
            Keys.RIGHT -> {
                pan(-keyPanSpeed, 0f)
            }
            else -> needsUpdate = false
        }

        if (needsUpdate) {
            this.update()
        }

    }

    private fun handleMouseDownRotate(event: MouseEvent) {
        rotateStart.set(event.clientX.toFloat(), event.clientY.toFloat())
    }

    private fun handleMouseDownDolly(event: MouseEvent) {
        dollyStart.set(event.clientX.toFloat(), event.clientY.toFloat())
    }

    private fun handleMouseDownPan(event: MouseEvent) {
        panStart.set(event.clientX.toFloat(), event.clientY.toFloat())
    }


    private fun handleMouseMoveRotate(event: MouseEvent) {
        rotateEnd.set(event.clientX.toFloat(), event.clientY.toFloat())

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

        rotateLeft(2 * PI.toFloat() * rotateDelta.x / eventSource.size.width) // yes, height

        rotateUp(2 * PI.toFloat() * rotateDelta.y / eventSource.size.height)

        rotateStart.copy(rotateEnd)

        update()
    }

    private fun handleMouseMoveDolly(event: MouseEvent) {
        dollyEnd.set(event.clientX.toFloat(), event.clientY.toFloat())

        dollyDelta.subVectors(dollyEnd, dollyStart)

        if (dollyDelta.y > 0) {

            dollyIn(getZoomScale())

        } else if (dollyDelta.y < 0) {

            dollyOut(getZoomScale())

        }

        dollyStart.copy(dollyEnd)

        update()
    }

    private fun handleMouseMovePan(event: MouseEvent) {
        panEnd.set(event.clientX.toFloat(), event.clientY.toFloat())

        panDelta.subVectors(panEnd, panStart).multiplyScalar(panSpeed)

        pan(panDelta.x, panDelta.y)

        panStart.copy(panEnd)

        update()
    }

    private fun handleMouseWheel(event: MouseWheelEvent) {

        if (event.deltaY < 0) {

            dollyOut(getZoomScale())

        } else if (event.deltaY > 0) {

            dollyIn(getZoomScale())

        }

        update()

    }

    fun dispose() {
        eventSource.removeKeyListener(defaultKeyListener)
        eventSource.removeMouseListener(defaultMouseListener)
    }

    private inner class MyKeyListener : KeyAdapter() {

        override fun onKeyPressed(event: KeyEvent) {
            if (enabled && enableKeys && enablePan) {
                handleKeyDown(event)
            }
        }

    }

    private inner class MyMouseListener : MouseAdapter() {

        override fun onMouseDown(button: Int, event: MouseEvent) {
            if (enabled) {
                when (button) {
                    MouseButtons.LEFT -> {
                        if (enableRotate) {
                            handleMouseDownRotate(event)
                            state = State.ROTATE
                        }
                    }
                    MouseButtons.MIDDLE -> {
                        if (enableZoom) {
                            handleMouseDownDolly(event)
                            state = State.DOLLY
                        }
                    }
                    MouseButtons.RIGHT -> {
                        if (enablePan) {
                            handleMouseDownRotate(event)
                            handleMouseDownPan(event)
                            state = State.PAN
                        }
                    }
                }

                if (state != State.NONE) {

                    val mouseMoveListener = MyMouseMoveListener()
                    eventSource.addMouseListener(mouseMoveListener)
                    eventSource.addMouseListener(MyMouseUpListener(mouseMoveListener))

                    dispatchEvent("start", this)

                }

            }
        }

        override fun onMouseWheel(event: MouseWheelEvent) {
            if (enabled && enableZoom && !(state != State.NONE && state != State.ROTATE)) {
                handleMouseWheel(event)
            }
        }

    }

    private inner class MyMouseMoveListener : MouseAdapter() {

        override fun onMouseMove(event: MouseEvent) {
            if (enabled) {

                when (state) {
                    State.ROTATE -> {
                        if (enableRotate) {
                            handleMouseMoveRotate(event)
                        }
                    }
                    State.DOLLY -> {
                        if (enableZoom) {
                            handleMouseMoveDolly(event)
                        }
                    }
                    State.PAN -> {
                        if (enablePan)
                            handleMouseMovePan(event)
                    }
                    State.NONE -> TODO()
                }

            }
        }
    }

    private inner class MyMouseUpListener(
            private val moveListener: MyMouseMoveListener
    ) : MouseAdapter() {

        override fun onMouseUp(button: Int, event: MouseEvent) {
            if (enabled) {

                eventSource.removeMouseListener(moveListener)
                eventSource.removeMouseListener(this)

                dispatchEvent("end", this)
                state = State.NONE

            }
        }
    }

    private companion object {

        val LOG: Logger = LoggerFactory.getLogger(OrbitControls::class.java)

    }

}

private object Keys {
    const val LEFT = 263
    const val UP = 265
    const val RIGHT = 262
    const val BOTTOM = 264
}

private object MouseButtons {

    const val LEFT: Int = 0
    const val RIGHT: Int = 1
    const val MIDDLE: Int = 2

}

private enum class State {

    NONE,
    ROTATE,
    DOLLY,
    PAN,

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy