123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734 |
- /*
- * FlyTool.js
- *
- * @author realor
- */
- import { Tool } from './Tool.js'
- import { Dialog } from '../ui/Dialog.js'
- import { I18N } from '../i18n/I18N.js'
- import * as THREE from '../lib/three.module.js'
- class FlyTool extends Tool {
- static SUBPANELS = [
- {
- name: 'leftWheel',
- xControl: 'lateralControl',
- yControl: 'elevationControl'
- },
- {
- name: 'pitchPanel'
- },
- {
- name: 'rightWheel',
- xControl: 'yawControl',
- yControl: 'forwardControl'
- }
- ]
- static ACTIONS = [
- {
- name: 'ascend',
- subpanel: 'leftWheel',
- keys: [87, 33], // W & page up
- control: 'elevationControl',
- value: 1
- },
- {
- name: 'descend',
- subpanel: 'leftWheel',
- keys: [83, 34], // S & page down
- control: 'elevationControl',
- value: -1
- },
- {
- name: 'moveLeft',
- subpanel: 'leftWheel',
- keys: [65, 45], // A & NP 0
- control: 'lateralControl',
- value: -1
- },
- {
- name: 'moveRight',
- subpanel: 'leftWheel',
- keys: [68, 46], // A & NP 0
- control: 'lateralControl',
- value: 1
- },
- {
- name: 'lookUp',
- subpanel: 'pitchPanel',
- keys: [82, 36], // R & home
- control: 'pitchControl',
- value: 1
- },
- {
- name: 'lookDown',
- subpanel: 'pitchPanel',
- keys: [70, 35], // F & end
- control: 'pitchControl',
- value: -1
- },
- {
- name: 'forward',
- subpanel: 'rightWheel',
- keys: [38], // cursor down
- control: 'forwardControl',
- value: 1,
- text: '' // use background image
- },
- {
- name: 'backward',
- subpanel: 'rightWheel',
- keys: [40], // cursor up
- control: 'forwardControl',
- value: -1,
- text: ''
- },
- {
- name: 'rotateLeft',
- subpanel: 'rightWheel',
- keys: [37], // cursor left
- control: 'yawControl',
- value: -1,
- text: ''
- },
- {
- name: 'rotateRight',
- subpanel: 'rightWheel',
- keys: [39], // cursor right
- control: 'yawControl',
- value: 1,
- text: ''
- },
- {
- name: 'options'
- }
- ]
- constructor(application, options) {
- super(application)
- this.name = 'fly'
- this.label = 'tool.fly.label'
- this.help = 'tool.fly.help'
- this.className = 'fly'
- this.setOptions(options)
- this.linearVelocity = 2 // meters/s
- this.angularVelocity = THREE.MathUtils.degToRad(20) // radians/s
- this.linearAccel = 5 // meters/s2
- this.angularAccel = THREE.MathUtils.degToRad(50) // radians/s2
- this.linearDecel = 2 // meters/s2
- this.angularDecel = THREE.MathUtils.degToRad(20) // radians/s2
- this.yaw = 0 // radians (0: north)
- this.pitch = 0 // radians
- this.stopMovement()
- // fly options
- this.mode = 'buttons'
- this.detectCollision = false
- this.groundDistanceControlEnabled = false
- this.groundDistance = 1.7
- // internals
- this.position = new THREE.Vector3(0, 0, 0)
- this.target = new THREE.Vector3(0, 0, 0)
- this.vector = new THREE.Vector3(0, 0, 0)
- this.raycaster = new THREE.Raycaster()
- this.auxVector = new THREE.Vector3(0, 0, 0)
- this._onKeyUp = this.onKeyUp.bind(this)
- this._onKeyDown = this.onKeyDown.bind(this)
- this._animate = this.animate.bind(this)
- this._onScene = this.onScene.bind(this)
- this.EPS = 0.00001
- this.createPanel()
- this.createKeyMap()
- }
- createPanel() {
- this.panel = this.application.createPanel(this.label, 'left')
- this.panel.preferredHeight = 120
- this.panel.mininumHeight = 120
- this.panel.bodyElem.classList.add('fly_panel')
- const keypad = document.createElement('div')
- keypad.className = 'keypad'
- this.panel.bodyElem.appendChild(keypad)
- this.subpanels = {}
- for (let subpanelDef of FlyTool.SUBPANELS) {
- let subpanelElem = document.createElement('div')
- subpanelElem.className = subpanelDef.name
- keypad.appendChild(subpanelElem)
- const subpanel = {
- name: subpanelDef.name,
- element: subpanelElem
- }
- this.subpanels[subpanel.name] = subpanel
- if (subpanelDef.xControl && subpanelDef.yControl) {
- subpanel.stick = new Stick(this, subpanel)
- subpanel.xControl = subpanelDef.xControl
- subpanel.yControl = subpanelDef.yControl
- }
- }
- this.buttons = {}
- for (let action of FlyTool.ACTIONS) {
- const button = document.createElement('button')
- button.name = action.name
- let text = action.text
- if (text === undefined) {
- if (action.keys) {
- text = String.fromCharCode(action.keys[0])
- } else {
- text = ''
- }
- }
- button.innerHTML = text
- button.className = action.name
- I18N.set(button, 'title', 'tool.fly.' + action.name)
- I18N.set(button, 'alt', 'tool.fly.' + action.name)
- if (action.control) {
- let onPressed = event => {
- this[action.control] = action.value
- button.setPointerCapture(event.pointerId)
- }
- let onReleased = event => {
- this[action.control] = 0
- button.setPointerCapture(event.pointerId)
- }
- button.addEventListener('pointerdown', onPressed)
- button.addEventListener('pointerup', onReleased)
- button.addEventListener('contextmenu', event => event.preventDefault())
- } else {
- button.addEventListener('click', () => this.onButtonClick(action))
- }
- if (action.subpanel) {
- this.subpanels[action.subpanel].element.appendChild(button)
- } else {
- keypad.appendChild(button)
- }
- this.buttons[action.name] = button
- }
- }
- createKeyMap() {
- this.keyMap = new Map()
- for (let action of FlyTool.ACTIONS) {
- if (action.keys) {
- for (let key of action.keys) {
- this.keyMap[key] = action
- }
- }
- }
- }
- activate() {
- this.panel.visible = true
- this.resetParameters()
- const application = this.application
- document.addEventListener('keyup', this._onKeyUp, false)
- document.addEventListener('keydown', this._onKeyDown, false)
- application.addEventListener('animation', this._animate)
- application.addEventListener('scene', this._onScene)
- }
- deactivate() {
- this.panel.visible = false
- this.stopMovement()
- const application = this.application
- document.removeEventListener('keyup', this._onKeyUp, false)
- document.removeEventListener('keydown', this._onKeyDown, false)
- application.removeEventListener('animation', this._animate)
- application.removeEventListener('scene', this._onScene)
- }
- stopMovement() {
- this.forwardControl = 0
- this.forwardAccel = 0
- this.forwardVelocity = 0
- this.lateralControl = 0
- this.lateralAccel = 0
- this.lateralVelocity = 0
- this.elevationControl = 0
- this.elevationAccel = 0
- this.elevationVelocity = 0
- this.yawControl = 0
- this.yawAccel = 0
- this.yawVelocity = 0
- this.pitchControl = 0
- this.pitchAccel = 0
- this.pitchVelocity = 0
- }
- switchMode() {
- if (this.mode === 'buttons') {
- this.mode = 'stick'
- this.panel.bodyElem.classList.add('stick')
- for (let subpanelName in this.subpanels) {
- let subpanel = this.subpanels[subpanelName]
- if (subpanel.stick) {
- subpanel.stick.activate()
- }
- }
- } // mode === "stick"
- else {
- this.mode = 'buttons'
- this.panel.bodyElem.classList.remove('stick')
- for (let subpanelName in this.subpanels) {
- let subpanel = this.subpanels[subpanelName]
- if (subpanel.stick) {
- subpanel.stick.deactivate()
- }
- }
- }
- }
- onKeyDown(event) {
- if (event.srcElement.nodeName.toUpperCase() === 'INPUT' || event.srcElement.classList.contains('cm-content')) return
- event.preventDefault()
- let action = this.keyMap[event.keyCode]
- if (action) {
- this[action.control] = action.value
- let button = this.buttons[action.name]
- button.classList.add('pressed')
- button.focus()
- }
- }
- onKeyUp(event) {
- if (event.srcElement.nodeName.toUpperCase() === 'INPUT') return
- event.preventDefault()
- let action = this.keyMap[event.keyCode]
- if (action) {
- this[action.control] = 0
- let button = this.buttons[action.name]
- button.classList.remove('pressed')
- }
- }
- onButtonClick(action) {
- if (action.name === 'options') {
- const minHeight = 0.5
- const maxHeight = 10
- const dialog = new Dialog('tool.fly.options')
- dialog.setClassName('fly_options')
- dialog.setSize(240, 180)
- dialog.addButton('close', 'button.close', () => {
- dialog.hide()
- })
- const stickControlElem = dialog.addCheckBoxField('stick', 'tool.fly.stick_control', this.mode === 'stick')
- const detectCollisionElem = dialog.addCheckBoxField('collision', 'tool.fly.detect_collision', this.detectCollision)
- const groundDistanceControlElem = dialog.addCheckBoxField('ground_control', 'tool.fly.ground_distance_control', this.groundDistanceControlEnabled)
- const groundDistanceElem = dialog.addNumberField('ground', 'tool.fly.ground_distance', this.groundDistance, 'ground')
- stickControlElem.addEventListener('change', () => this.switchMode())
- detectCollisionElem.addEventListener('change', () => (this.detectCollision = !this.detectCollision))
- groundDistanceControlElem.addEventListener('change', () => {
- this.groundDistanceControlEnabled = !this.groundDistanceControlEnabled
- groundDistanceElem.disabled = !this.groundDistanceControlEnabled
- })
- groundDistanceElem.min = minHeight
- groundDistanceElem.max = maxHeight
- groundDistanceElem.step = '0.01'
- groundDistanceElem.addEventListener('change', () => {
- let distance = groundDistanceElem.value
- distance = Math.max(distance, minHeight)
- distance = Math.min(distance, maxHeight)
- this.groundDistance = distance
- groundDistanceElem.value = distance
- })
- const unitsText = document.createElement('span')
- unitsText.innerHTML = this.application.units
- groundDistanceElem.parentElement.appendChild(unitsText)
- groundDistanceElem.disabled = !this.groundDistanceControlEnabled
- dialog.setI18N(this.application.i18n)
- dialog.show()
- }
- }
- animate(event) {
- const delta = event.delta
- const application = this.application
- const camera = application.camera
- this.forwardAccel = this.forwardControl * this.linearAccel
- if (this.forwardVelocity > 0) {
- this.forwardAccel -= this.linearDecel
- } else if (this.forwardVelocity < 0) {
- this.forwardAccel += this.linearDecel
- }
- this.lateralAccel = this.lateralControl * this.linearAccel
- if (this.lateralVelocity > 0) {
- this.lateralAccel -= this.linearDecel
- } else if (this.lateralVelocity < 0) {
- this.lateralAccel += this.linearDecel
- }
- let groundDistanceControl = 0
- if (this.elevationControl === 0 && this.groundDistanceControlEnabled) {
- groundDistanceControl = this.groundDistanceControl()
- this.elevationAccel = groundDistanceControl * this.linearAccel
- } else {
- this.elevationAccel = this.elevationControl * this.linearAccel
- }
- if (this.elevationVelocity > 0) {
- this.elevationAccel -= this.linearDecel
- } else if (this.elevationVelocity < 0) {
- this.elevationAccel += this.linearDecel
- }
- this.yawAccel = this.yawControl * this.angularAccel
- if (this.yawVelocity > 0) {
- this.yawAccel -= this.angularDecel
- } else if (this.yawVelocity < 0) {
- this.yawAccel += this.angularDecel
- }
- this.pitchAccel = this.pitchControl * this.angularAccel
- if (this.pitchVelocity > 0) {
- this.pitchAccel -= this.angularDecel
- } else if (this.pitchVelocity < 0) {
- this.pitchAccel += this.angularDecel
- }
- this.forwardVelocity += this.forwardAccel * delta
- this.lateralVelocity += this.lateralAccel * delta
- this.elevationVelocity += this.elevationAccel * delta
- this.yawVelocity += this.yawAccel * delta
- this.pitchVelocity += this.pitchAccel * delta
- if (this.forwardControl === 0) {
- if ((this.forwardAccel > 0 && this.forwardVelocity > 0) || (this.forwardAccel < 0 && this.forwardVelocity < 0)) {
- this.forwardAccel = 0
- this.forwardVelocity = 0
- }
- }
- if (this.lateralControl === 0) {
- if ((this.lateralAccel > 0 && this.lateralVelocity > 0) || (this.lateralAccel < 0 && this.lateralVelocity < 0)) {
- this.lateralAccel = 0
- this.lateralVelocity = 0
- }
- }
- if (this.elevationControl === 0 && groundDistanceControl === 0) {
- if ((this.elevationAccel > 0 && this.elevationVelocity > 0) || (this.elevationAccel < 0 && this.elevationVelocity < 0)) {
- this.elevationAccel = 0
- this.elevationVelocity = 0
- }
- }
- if (this.yawControl === 0) {
- if ((this.yawAccel > 0 && this.yawVelocity > 0) || (this.yawAccel < 0 && this.yawVelocity < 0)) {
- this.yawAccel = 0
- this.yawVelocity = 0
- }
- }
- if (this.pitchControl === 0) {
- if ((this.pitchAccel > 0 && this.pitchVelocity > 0) || (this.pitchAccel < 0 && this.pitchVelocity < 0)) {
- this.pitchAccel = 0
- this.pitchVelocity = 0
- }
- }
- const residualMove = 0.00001
- if (
- this.updateCamera ||
- Math.abs(this.yawVelocity) > residualMove ||
- Math.abs(this.pitchVelocity) > residualMove ||
- Math.abs(this.forwardVelocity) > residualMove ||
- Math.abs(this.lateralVelocity) > residualMove ||
- Math.abs(this.elevationVelocity) > residualMove
- ) {
- this.yaw += this.yawVelocity * delta
- this.pitch += this.pitchVelocity * delta
- if (this.pitch <= -0.5 * Math.PI) {
- this.pitch = -0.5 * Math.PI + this.EPS
- this.pitchVelocity = 0
- this.pitchAccel = 0
- } else if (this.pitch >= 0.5 * Math.PI) {
- this.pitch = 0.5 * Math.PI - this.EPS
- this.pitchVelocity = 0
- this.pitchAccel = 0
- }
- const sinYaw = Math.sin(this.yaw)
- const cosYaw = Math.cos(this.yaw)
- const sinPitch = Math.sin(this.pitch)
- const cosPitch = Math.cos(this.pitch)
- const position = camera.position
- this.position.copy(position)
- const me = camera.matrixWorld.elements
- const scale = this.vector.set(me[8], me[9], me[10]).length()
- position.x += (this.forwardVelocity * sinYaw * delta) / scale
- position.y += (this.forwardVelocity * cosYaw * delta) / scale
- position.z += (this.elevationVelocity * delta) / scale
- camera.translateX((this.lateralVelocity * delta) / scale)
- if (this.detectCollision && !this.position.equals(position)) {
- if (this.collide(this.position, position)) {
- this.stopMovement()
- }
- }
- this.target.x = position.x + 100 * cosPitch * sinYaw
- this.target.y = position.y + 100 * cosPitch * cosYaw
- this.target.z = position.z + 100 * sinPitch
- camera.updateMatrix()
- camera.lookAt(this.target)
- camera.updateMatrix()
- application.notifyObjectsChanged(camera, this)
- this.updateCamera = false
- }
- }
- onScene(event) {
- const application = this.application
- if (event.source !== this) {
- const camera = application.camera
- if (event.type === 'nodeChanged' && event.objects.includes(camera)) {
- this.resetParameters()
- } else if (event.type === 'cameraActivated') {
- this.resetParameters()
- }
- }
- }
- resetParameters() {
- const application = this.application
- const container = application.container
- const camera = application.camera
- if (camera instanceof THREE.PerspectiveCamera) {
- camera.aspect = container.clientWidth / container.clientHeight
- camera.updateProjectionMatrix()
- }
- camera.updateMatrix()
- const matrix = camera.matrix
- const me = matrix.elements
- const vz = new THREE.Vector3()
- vz.x = me[8]
- vz.y = me[9]
- vz.z = me[10]
- vz.normalize()
- this.pitch = Math.asin(-vz.z)
- if (Math.abs(vz.x) > 0.01 && Math.abs(vz.y) > 0.01) {
- this.yaw = Math.atan2(-vz.x, -vz.y)
- } else {
- const vx = new THREE.Vector3()
- vx.x = me[0]
- vx.y = me[1]
- vx.z = me[2]
- vx.normalize()
- this.yaw = Math.atan2(-vx.y, vx.x)
- }
- this.updateCamera = true
- }
- collide(oldPosition, newPosition) {
- const vector = this.auxVector
- vector.copy(newPosition)
- vector.sub(oldPosition)
- const margin = 0.3
- const vectorLength = vector.length() + margin
- vector.normalize()
- const distance = this.measureDistance(oldPosition, vector, vectorLength)
- if (distance !== undefined) {
- let stopDistance = distance - margin
- vector.multiplyScalar(stopDistance)
- newPosition.x = oldPosition.x + vector.x
- newPosition.y = oldPosition.y + vector.y
- newPosition.z = oldPosition.z + vector.z
- return true
- }
- return false
- }
- groundDistanceControl() {
- const position = this.position
- const groundVector = this.auxVector
- const groundDistance = this.groundDistance
- const margin = 0.01
- const maxHeight = 100
- groundVector.set(0, 0, -1)
- const currentGroundDistance = this.measureDistance(position, groundVector, maxHeight)
- if (currentGroundDistance !== undefined) {
- const targetDistance = currentGroundDistance - groundDistance
- if (targetDistance > margin) {
- if (this.elevationVelocity < 0) {
- const stopDistance = Math.abs((this.elevationVelocity * this.elevationVelocity) / (2 * this.linearAccel))
- if (stopDistance + margin > targetDistance) return 1
- }
- return -1
- } else if (targetDistance < -margin) {
- if (this.elevationVelocity > 0) {
- const stopDistance = Math.abs((this.elevationVelocity * this.elevationVelocity) / (2 * this.linearAccel))
- if (stopDistance + margin > -targetDistance) return -1
- }
- return 1
- }
- }
- return 0
- }
- measureDistance(origin, unitVector, length) {
- const scene = this.application.scene
- this.raycaster.set(origin, unitVector)
- this.raycaster.far = length
- const intersects = this.raycaster.intersectObjects(scene.children, true)
- let i = 0
- let found = false
- let object = null
- let intersect = null
- while (i < intersects.length && !found) {
- intersect = intersects[i]
- object = intersect.object
- if (this.isCollidable(object)) {
- found = true
- } else i++
- }
- return found ? intersect.distance : undefined
- }
- isCollidable(object) {
- let collidable = true
- while (object && collidable) {
- collidable = object.visible && (object.userData.collision === undefined || object.userData.collision.enabled)
- object = object.parent
- }
- return collidable
- }
- }
- class Stick {
- constructor(tool, subpanel) {
- this.subpanel = subpanel
- const element = document.createElement('div')
- this.element = element
- element.className = 'stick'
- element.style.touchAction = 'none'
- subpanel.element.style.touchAction = 'none'
- subpanel.element.appendChild(element)
- this.onPointerDown = event => {
- subpanel.element.addEventListener('pointermove', this.onPointerMove)
- subpanel.element.setPointerCapture(event.pointerId)
- const control = this.updatePosition(event.clientX, event.clientY)
- tool[subpanel.xControl] = control.x
- tool[subpanel.yControl] = control.y
- }
- this.onPointerUp = event => {
- subpanel.element.removeEventListener('pointermove', this.onPointerMove)
- subpanel.element.releasePointerCapture(event.pointerId)
- element.style.left = ''
- element.style.top = ''
- tool[subpanel.xControl] = 0
- tool[subpanel.yControl] = 0
- }
- this.onPointerMove = event => {
- let control = this.updatePosition(event.clientX, event.clientY)
- tool[subpanel.xControl] = control.x
- tool[subpanel.yControl] = control.y
- }
- subpanel.element.addEventListener('contextmenu', event => event.preventDefault())
- }
- activate() {
- const subpanel = this.subpanel
- subpanel.element.addEventListener('pointerdown', this.onPointerDown)
- subpanel.element.addEventListener('pointerup', this.onPointerUp)
- }
- deactivate() {
- const subpanel = this.subpanel
- subpanel.element.removeEventListener('pointerdown', this.onPointerDown)
- subpanel.element.removeEventListener('pointerup', this.onPointerUp)
- }
- updatePosition(clientX, clientY) {
- const stickElem = this.element
- const size = stickElem.offsetWidth
- const subpanelElem = this.subpanel.element
- const rect = subpanelElem.getBoundingClientRect()
- let layerX = clientX - rect.left
- let layerY = clientY - rect.top
- let x = 2 * (layerX / rect.width) - 1
- let y = -2 * (layerY / rect.height) + 1
- let radius = (rect.width - size) / rect.width
- if (Math.sqrt(x * x + y * y) > radius) {
- let angle = Math.atan2(y, x)
- x = radius * Math.cos(angle)
- y = radius * Math.sin(angle)
- layerX = (x + 1) * 0.5 * rect.width
- layerY = (1 - y) * 0.5 * rect.height
- }
- stickElem.style.left = -1 + (layerX - 0.5 * size) + 'px'
- stickElem.style.top = -1 + (layerY - 0.5 * size) + 'px'
- x = x.toFixed(2)
- y = y.toFixed(2)
- return { x, y }
- }
- }
- export { FlyTool }
|