Files
modeling-app/src/clientSideScene/CameraControls.ts
2024-05-09 16:02:15 +10:00

1137 lines
36 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { MouseGuard } from 'lib/cameraControls'
import {
Euler,
MathUtils,
Matrix4,
OrthographicCamera,
PerspectiveCamera,
Quaternion,
Spherical,
Vector2,
Vector3,
} from 'three'
import {
DEBUG_SHOW_INTERSECTION_PLANE,
INTERSECTION_PLANE_LAYER,
SKETCH_LAYER,
ZOOM_MAGIC_NUMBER,
} from './sceneInfra'
import {
EngineCommand,
Subscription,
EngineCommandManager,
UnreliableSubscription,
} from 'lang/std/engineConnection'
import { uuidv4 } from 'lib/utils'
import { deg2Rad } from 'lib/utils2d'
import { isReducedMotion, roundOff, throttle } from 'lib/utils'
import * as TWEEN from '@tweenjs/tween.js'
import { isQuaternionVertical } from './helpers'
const ORTHOGRAPHIC_CAMERA_SIZE = 20
const FRAMES_TO_ANIMATE_IN = 30
const tempQuaternion = new Quaternion() // just used for maths
type interactionType = 'pan' | 'rotate' | 'zoom'
interface ThreeCamValues {
position: Vector3
quaternion: Quaternion
zoom: number
isPerspective: boolean
target: Vector3
}
export type ReactCameraProperties =
| {
type: 'perspective'
fov?: number
position: [number, number, number]
quaternion: [number, number, number, number]
}
| {
type: 'orthographic'
zoom?: number
position: [number, number, number]
quaternion: [number, number, number, number]
}
const lastCmdDelay = 50
export class CameraControls {
engineCommandManager: EngineCommandManager
syncDirection: 'clientToEngine' | 'engineToClient' = 'engineToClient'
camera: PerspectiveCamera | OrthographicCamera
target: Vector3
domElement: HTMLCanvasElement
isDragging: boolean
mouseDownPosition: Vector2
mouseNewPosition: Vector2
rotationSpeed = 0.3
enableRotate = true
enablePan = true
enableZoom = true
lastPerspectiveFov: number = 45
pendingZoom: number | null = null
pendingRotation: Vector2 | null = null
pendingPan: Vector2 | null = null
interactionGuards: MouseGuard = {
pan: {
description: 'Right click + Shift + drag or middle click + drag',
callback: (e) => !!(e.buttons & 4) && !e.ctrlKey,
},
zoom: {
description: 'Scroll wheel or Right click + Ctrl + drag',
dragCallback: (e) => e.button === 2 && e.ctrlKey,
scrollCallback: () => true,
},
rotate: {
description: 'Right click + drag',
callback: (e) => {
console.log('event', e)
return !!(e.buttons & 2)
},
},
}
isFovAnimationInProgress = false
fovBeforeOrtho = 45
get isPerspective() {
return this.camera instanceof PerspectiveCamera
}
private debounceTimer = 0
handleStart = () => {
if (this.debounceTimer) clearTimeout(this.debounceTimer)
this._isCamMovingCallback(true, false)
}
handleEnd = () => {
this.debounceTimer = setTimeout(() => {
this._isCamMovingCallback(false, false)
}, 400) as any as number
}
setCam = (camProps: ReactCameraProperties) => {
if (
camProps.type === 'perspective' &&
this.camera instanceof OrthographicCamera
) {
this.usePerspectiveCamera()
} else if (
camProps.type === 'orthographic' &&
this.camera instanceof PerspectiveCamera
) {
this.useOrthographicCamera()
}
this.camera.position.set(...camProps.position)
this.camera.quaternion.set(...camProps.quaternion)
if (
camProps.type === 'perspective' &&
this.camera instanceof PerspectiveCamera
) {
// not sure what to do here, calling dollyZoom here is buggy because it updates the position
// at the same time
} else if (
camProps.type === 'orthographic' &&
this.camera instanceof OrthographicCamera
) {
this.camera.zoom = camProps.zoom || 1
}
this.camera.updateProjectionMatrix()
console.log('doing this thing', camProps)
this.update(true)
}
throttledEngCmd = throttle((cmd: EngineCommand) => {
this.engineCommandManager.sendSceneCommand(cmd)
}, 1000 / 30)
throttledUpdateEngineCamera = throttle((threeValues: ThreeCamValues) => {
const cmd: EngineCommand = {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_look_at',
...convertThreeCamValuesToEngineCam(threeValues),
},
}
this.engineCommandManager.sendSceneCommand(cmd)
}, 1000 / 15)
lastPerspectiveCmd: EngineCommand | null = null
lastPerspectiveCmdTime: number = Date.now()
lastPerspectiveCmdTimeoutId: number | null = null
sendLastPerspectiveReliableChannel = () => {
if (
this.lastPerspectiveCmd &&
Date.now() - this.lastPerspectiveCmdTime >= lastCmdDelay
) {
this.engineCommandManager.sendSceneCommand(this.lastPerspectiveCmd, true)
this.lastPerspectiveCmdTime = Date.now()
}
}
throttledUpdateEngineFov = throttle(
(vals: {
position: Vector3
quaternion: Quaternion
zoom: number
fov: number
target: Vector3
}) => {
const cmd: EngineCommand = {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_perspective_settings',
...convertThreeCamValuesToEngineCam({
...vals,
isPerspective: true,
}),
fov_y: vals.fov,
...calculateNearFarFromFOV(vals.fov),
},
}
this.engineCommandManager.sendSceneCommand(cmd)
this.lastPerspectiveCmd = cmd
this.lastPerspectiveCmdTime = Date.now()
if (this.lastPerspectiveCmdTimeoutId !== null) {
clearTimeout(this.lastPerspectiveCmdTimeoutId)
}
this.lastPerspectiveCmdTimeoutId = setTimeout(
this.sendLastPerspectiveReliableChannel,
lastCmdDelay
) as any as number
},
1000 / 30
)
constructor(
isOrtho = false,
domElement: HTMLCanvasElement,
engineCommandManager: EngineCommandManager
) {
this.engineCommandManager = engineCommandManager
this.camera = isOrtho ? new OrthographicCamera() : new PerspectiveCamera()
this.camera.up.set(0, 0, 1)
this.camera.far = 20000
this.target = new Vector3()
this.domElement = domElement
this.isDragging = false
this.mouseDownPosition = new Vector2()
this.mouseNewPosition = new Vector2()
this.domElement.addEventListener('pointerdown', this.onMouseDown)
this.domElement.addEventListener('pointermove', this.onMouseMove)
this.domElement.addEventListener('pointerup', this.onMouseUp)
this.domElement.addEventListener('wheel', this.onMouseWheel)
window.addEventListener('resize', this.onWindowResize)
this.onWindowResize()
this.update()
this._usePerspectiveCamera()
type CallBackParam = Parameters<
(
| Subscription<
| 'default_camera_zoom'
| 'camera_drag_end'
| 'default_camera_get_settings'
>
| UnreliableSubscription<'camera_drag_move'>
)['callback']
>[0]
const cb = ({ data, type }: CallBackParam) => {
const camSettings = data.settings
this.camera.position.set(
camSettings.pos.x,
camSettings.pos.y,
camSettings.pos.z
)
this.target.set(
camSettings.center.x,
camSettings.center.y,
camSettings.center.z
)
this.camera.up.set(camSettings.up.x, camSettings.up.y, camSettings.up.z)
if (this.camera instanceof PerspectiveCamera && camSettings.ortho) {
this.useOrthographicCamera()
}
if (this.camera instanceof OrthographicCamera && !camSettings.ortho) {
this.usePerspectiveCamera()
}
if (this.camera instanceof PerspectiveCamera && camSettings.fov_y) {
this.camera.fov = camSettings.fov_y
} else if (
this.camera instanceof OrthographicCamera &&
camSettings.ortho_scale
) {
const distanceToTarget = new Vector3(
camSettings.pos.x,
camSettings.pos.y,
camSettings.pos.z
).distanceTo(
new Vector3(
camSettings.center.x,
camSettings.center.y,
camSettings.center.z
)
)
this.camera.zoom = (camSettings.ortho_scale * 40) / distanceToTarget
}
this.onCameraChange()
}
setTimeout(() => {
this.engineCommandManager.subscribeTo({
event: 'camera_drag_end',
callback: cb,
})
this.engineCommandManager.subscribeTo({
event: 'default_camera_zoom',
callback: cb,
})
this.engineCommandManager.subscribeTo({
event: 'default_camera_get_settings',
callback: cb,
})
this.engineCommandManager.subscribeToUnreliable({
event: 'camera_drag_move',
callback: cb,
})
})
}
private _isCamMovingCallback: (isMoving: boolean, isTween: boolean) => void =
() => {}
setIsCamMovingCallback(cb: (isMoving: boolean, isTween: boolean) => void) {
this._isCamMovingCallback = cb
}
private _camChangeCallbacks: { [key: string]: () => void } = {}
subscribeToCamChange(cb: () => void) {
const cbId = uuidv4()
this._camChangeCallbacks[cbId] = cb
const unsubscribe = () => {
delete this._camChangeCallbacks[cbId]
}
return unsubscribe
}
onWindowResize = () => {
if (this.camera instanceof PerspectiveCamera) {
this.camera.aspect = window.innerWidth / window.innerHeight
} else if (this.camera instanceof OrthographicCamera) {
const aspect = window.innerWidth / window.innerHeight
this.camera.left = -ORTHOGRAPHIC_CAMERA_SIZE * aspect
this.camera.right = ORTHOGRAPHIC_CAMERA_SIZE * aspect
this.camera.top = ORTHOGRAPHIC_CAMERA_SIZE
this.camera.bottom = -ORTHOGRAPHIC_CAMERA_SIZE
}
this.camera.updateProjectionMatrix()
}
onMouseDown = (event: MouseEvent) => {
this.isDragging = true
this.mouseDownPosition.set(event.clientX, event.clientY)
let interaction = this.getInteractionType(event)
if (interaction === 'none') return
this.handleStart()
if (this.syncDirection === 'engineToClient') {
void this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
type: 'camera_drag_start',
interaction,
window: { x: event.clientX, y: event.clientY },
},
cmd_id: uuidv4(),
})
}
}
onMouseMove = (event: MouseEvent) => {
if (this.isDragging) {
this.mouseNewPosition.set(event.clientX, event.clientY)
const deltaMove = this.mouseNewPosition
.clone()
.sub(this.mouseDownPosition)
this.mouseDownPosition.copy(this.mouseNewPosition)
const interaction = this.getInteractionType(event)
if (interaction === 'none') return
if (this.syncDirection === 'engineToClient') {
this.throttledEngCmd({
type: 'modeling_cmd_req',
cmd: {
type: 'camera_drag_move',
interaction,
window: { x: event.clientX, y: event.clientY },
},
cmd_id: uuidv4(),
})
return
}
// Implement camera movement logic here based on deltaMove
// For example, for rotating the camera around the target:
if (interaction === 'rotate') {
this.pendingRotation = this.pendingRotation
? this.pendingRotation
: new Vector2()
this.pendingRotation.x += deltaMove.x
this.pendingRotation.y += deltaMove.y
} else if (interaction === 'zoom') {
this.pendingZoom = this.pendingZoom ? this.pendingZoom : 1
this.pendingZoom *= 1 + deltaMove.y * 0.01
} else if (interaction === 'pan') {
this.pendingPan = this.pendingPan ? this.pendingPan : new Vector2()
let distance = this.camera.position.distanceTo(this.target)
if (this.camera instanceof OrthographicCamera) {
const zoomFudgeFactor = 2280
distance = zoomFudgeFactor / (this.camera.zoom * 45)
}
const panSpeed = (distance / 1000 / 45) * this.fovBeforeOrtho
this.pendingPan.x += -deltaMove.x * panSpeed
this.pendingPan.y += deltaMove.y * panSpeed
}
}
}
onMouseUp = (event: MouseEvent) => {
this.isDragging = false
this.handleEnd()
if (this.syncDirection === 'engineToClient') {
const interaction = this.getInteractionType(event)
if (interaction === 'none') return
void this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
type: 'camera_drag_end',
interaction,
window: { x: event.clientX, y: event.clientY },
},
cmd_id: uuidv4(),
})
}
}
onMouseWheel = (event: WheelEvent) => {
// Assume trackpad if the deltas are small and integers
this.handleStart()
if (this.syncDirection === 'engineToClient') {
const interactions = this.interactionGuards.zoom.scrollCallback(
event as any
)
if (!interactions) {
this.handleEnd()
return
}
this.throttledEngCmd({
type: 'modeling_cmd_req',
cmd: {
type: 'default_camera_zoom',
magnitude: -event.deltaY * 0.4,
},
cmd_id: uuidv4(),
})
this.handleEnd()
return
}
const isTrackpad = Math.abs(event.deltaY) <= 1 || event.deltaY % 1 === 0
const zoomSpeed = isTrackpad ? 0.02 : 0.1 // Reduced zoom speed for trackpad
this.pendingZoom = this.pendingZoom ? this.pendingZoom : 1
this.pendingZoom *= 1 + (event.deltaY > 0 ? zoomSpeed : -zoomSpeed)
this.handleEnd()
}
useOrthographicCamera = () => {
if (this.camera instanceof OrthographicCamera) return
const { x: px, y: py, z: pz } = this.camera.position
const { x: qx, y: qy, z: qz, w: qw } = this.camera.quaternion
const oldCamUp = this.camera.up.clone()
const aspect = window.innerWidth / window.innerHeight
this.lastPerspectiveFov = this.camera.fov
const { z_near, z_far } = calculateNearFarFromFOV(this.lastPerspectiveFov)
this.camera = new OrthographicCamera(
-ORTHOGRAPHIC_CAMERA_SIZE * aspect,
ORTHOGRAPHIC_CAMERA_SIZE * aspect,
ORTHOGRAPHIC_CAMERA_SIZE,
-ORTHOGRAPHIC_CAMERA_SIZE,
z_near,
z_far
)
this.camera.up.copy(oldCamUp)
this.camera.layers.enable(SKETCH_LAYER)
if (DEBUG_SHOW_INTERSECTION_PLANE)
this.camera.layers.enable(INTERSECTION_PLANE_LAYER)
this.camera.position.set(px, py, pz)
const distance = this.camera.position.distanceTo(this.target.clone())
const fovFactor = 45 / this.lastPerspectiveFov
this.camera.zoom = (ZOOM_MAGIC_NUMBER * fovFactor * 0.8) / distance
this.camera.quaternion.set(qx, qy, qz, qw)
this.camera.updateProjectionMatrix()
this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_set_orthographic',
},
})
this.onCameraChange()
}
private createPerspectiveCamera = () => {
const { z_near, z_far } = calculateNearFarFromFOV(this.lastPerspectiveFov)
const previousCamUp = this.camera.up.clone()
this.camera = new PerspectiveCamera(
this.lastPerspectiveFov,
window.innerWidth / window.innerHeight,
z_near,
z_far
)
this.camera.up.copy(previousCamUp)
this.camera.layers.enable(SKETCH_LAYER)
if (DEBUG_SHOW_INTERSECTION_PLANE)
this.camera.layers.enable(INTERSECTION_PLANE_LAYER)
return this.camera
}
_usePerspectiveCamera = () => {
const { x: px, y: py, z: pz } = this.camera.position
const { x: qx, y: qy, z: qz, w: qw } = this.camera.quaternion
const zoom = this.camera.zoom
this.camera = this.createPerspectiveCamera()
this.camera.position.set(px, py, pz)
this.camera.quaternion.set(qx, qy, qz, qw)
const zoomFudgeFactor = 2280
const distance = zoomFudgeFactor / (zoom * this.lastPerspectiveFov)
const direction = new Vector3().subVectors(
this.camera.position,
this.target
)
direction.normalize()
this.camera.position.copy(this.target).addScaledVector(direction, distance)
}
usePerspectiveCamera = () => {
this._usePerspectiveCamera()
this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_set_perspective',
parameters: {
fov_y:
this.camera instanceof PerspectiveCamera ? this.camera.fov : 45,
...calculateNearFarFromFOV(this.lastPerspectiveFov),
},
},
})
this.onCameraChange()
this.update()
return this.camera
}
dollyZoom = (newFov: number) => {
if (!(this.camera instanceof PerspectiveCamera)) {
console.warn('Dolly zoom is only applicable to perspective cameras.')
return
}
this.lastPerspectiveFov = newFov
// Calculate the direction vector from the camera towards the controls target
const direction = new Vector3()
.subVectors(this.target, this.camera.position)
.normalize()
// Calculate the distance to the controls target before changing the FOV
const distanceBefore = this.camera.position.distanceTo(this.target)
// Calculate the scale factor for the new FOV compared to the old one
// This needs to be calculated before updating the camera's FOV
const oldFov = this.camera.fov
const viewHeightFactor = (fov: number) => {
/* *
/|
/ |
/ |
/ |
/ | viewHeight/2
/ |
/ |
/↙fov/2 |
/________|
\ |
\._._._.|
*/
return Math.tan(deg2Rad(fov / 2))
}
const scaleFactor = viewHeightFactor(oldFov) / viewHeightFactor(newFov)
this.camera.fov = newFov
this.camera.updateProjectionMatrix()
const distanceAfter = distanceBefore * scaleFactor
const newPosition = this.target
.clone()
.add(direction.multiplyScalar(-distanceAfter))
this.camera.position.copy(newPosition)
const { z_near, z_far } = calculateNearFarFromFOV(this.lastPerspectiveFov)
this.camera.near = z_near
this.camera.far = z_far
this.throttledUpdateEngineFov({
fov: newFov,
position: newPosition,
quaternion: this.camera.quaternion,
zoom: this.camera.zoom,
target: this.target,
})
}
update = (forceUpdate = false) => {
// If there are any changes that need to be applied to the camera, apply them here.
let didChange = false
if (this.pendingRotation) {
this.rotateCamera(this.pendingRotation.x, this.pendingRotation.y)
this.pendingRotation = null // Clear the pending rotation after applying it
didChange = true
}
if (this.pendingZoom) {
if (this.camera instanceof PerspectiveCamera) {
// move camera towards or away from the target
const distance = this.camera.position.distanceTo(this.target)
const newDistance = distance * this.pendingZoom
const direction = this.camera.position
.clone()
.sub(this.target)
.normalize()
const newPosition = this.target
.clone()
.add(direction.multiplyScalar(newDistance))
this.camera.position.copy(newPosition)
this.camera.updateProjectionMatrix()
this.pendingZoom = null // Clear the pending zoom after applying it
} else {
// TODO change ortho zoom
this.camera.zoom = this.camera.zoom / this.pendingZoom
this.pendingZoom = null
}
didChange = true
}
if (this.pendingPan) {
// move camera left/right and up/down
const offset = this.camera.position.clone().sub(this.target)
const direction = offset.clone().normalize()
const cameraQuaternion = this.camera.quaternion
const up = new Vector3(0, 1, 0).applyQuaternion(cameraQuaternion)
const right = new Vector3().crossVectors(up, direction)
right.multiplyScalar(this.pendingPan.x)
up.multiplyScalar(this.pendingPan.y)
const newPosition = this.camera.position.clone().add(right).add(up)
this.target.add(right)
this.target.add(up)
this.camera.position.copy(newPosition)
this.pendingPan = null
didChange = true
}
this.safeLookAtTarget(this.camera.up)
// Update the camera's matrices
this.camera.updateMatrixWorld()
if (didChange || forceUpdate) {
this.onCameraChange(forceUpdate)
}
// damping would be implemented here in update if we choose to add it.
}
rotateCamera = (deltaX: number, deltaY: number) => {
const quat = new Quaternion().setFromUnitVectors(
new Vector3(0, 0, 1),
new Vector3(0, 1, 0)
)
const quatInverse = quat.clone().invert()
const angleX = deltaX * this.rotationSpeed // rotationSpeed is a constant that defines how fast the camera rotates
const angleY = deltaY * this.rotationSpeed
// Convert angles to radians
const radianX = MathUtils.degToRad(angleX)
const radianY = MathUtils.degToRad(angleY)
// Get the offset from the camera to the target
const offset = new Vector3().subVectors(this.camera.position, this.target)
// spherical is a y-up paradigm, need to conform to that for now
offset.applyQuaternion(quat)
// Convert offset to spherical coordinates
const spherical = new Spherical().setFromVector3(offset)
// Apply the rotations
spherical.theta -= radianX
spherical.phi -= radianY
// Restrict the phi angle to avoid the camera flipping at the poles
spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi))
// Convert back to Cartesian coordinates
offset.setFromSpherical(spherical)
// put the offset back into the z-up paradigm
offset.applyQuaternion(quatInverse)
// Update the camera's position
this.camera.position.copy(this.target).add(offset)
// Look at the target
this.camera.updateMatrixWorld()
}
safeLookAtTarget(up = new Vector3(0, 0, 1)) {
const quaternion = _lookAt(this.camera.position, this.target, up)
this.camera.quaternion.copy(quaternion)
this.camera.updateMatrixWorld()
}
tweenCamToNegYAxis(
// -90 degrees from the x axis puts the camera on the negative y axis
targetAngle = -Math.PI / 2,
duration = 500
): Promise<void> {
return new Promise((resolve) => {
// should tween the camera so that it has an xPosition of 0, and forcing it's yPosition to be negative
// zPosition should stay the same
const xyRadius = Math.sqrt(
(this.target.x - this.camera.position.x) ** 2 +
(this.target.y - this.camera.position.y) ** 2
)
const xyAngle = Math.atan2(
this.camera.position.y - this.target.y,
this.camera.position.x - this.target.x
)
const camAtTime = (obj: { angle: number }) => {
const x = xyRadius * Math.cos(obj.angle)
const y = xyRadius * Math.sin(obj.angle)
this.camera.position.set(
this.target.x + x,
this.target.y + y,
this.camera.position.z
)
this.update()
this.onCameraChange()
}
const onComplete = (obj: { angle: number }) => {
camAtTime(obj)
this._isCamMovingCallback(false, true)
// resolve after a couple of frames
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve())
})
}
this._isCamMovingCallback(true, true)
if (isReducedMotion()) {
onComplete({ angle: targetAngle })
return
}
new TWEEN.Tween({ angle: xyAngle })
.to({ angle: targetAngle }, duration)
.onUpdate(camAtTime)
.onComplete(onComplete)
.start()
})
}
async tweenCameraToQuaternion(
targetQuaternion: Quaternion,
targetPosition = new Vector3(),
duration = 500,
toOrthographic = true
): Promise<void> {
if (this.syncDirection === 'engineToClient')
console.warn(
'tweenCameraToQuaternion not design to work with engineToClient syncDirection.'
)
const isVertical = isQuaternionVertical(targetQuaternion)
let remainingDuration = duration
if (isVertical) {
remainingDuration = duration * 0.5
const orbitRotationDuration = duration * 0.65
let targetAngle = -Math.PI / 2
const v = new Vector3(0, 0, 1).applyQuaternion(targetQuaternion)
if (v.z < 0) targetAngle = Math.PI / 2
await this.tweenCamToNegYAxis(targetAngle, orbitRotationDuration)
}
await this._tweenCameraToQuaternion(
targetQuaternion,
targetPosition,
remainingDuration,
toOrthographic
)
}
_tweenCameraToQuaternion(
targetQuaternion: Quaternion,
targetPosition: Vector3,
duration = 500,
toOrthographic = false
): Promise<void> {
return new Promise((resolve) => {
const camera = this.camera
this._isCamMovingCallback(true, true)
const initialQuaternion = camera.quaternion.clone()
const initialTarget = this.target.clone()
const isVertical = isQuaternionVertical(targetQuaternion)
let tweenEnd = isVertical ? 0.99 : 1
const tempVec = new Vector3()
const initialDistance = initialTarget.distanceTo(camera.position.clone())
const cameraAtTime = (animationProgress: number /* 0 - 1 */) => {
const currentQ = tempQuaternion.slerpQuaternions(
initialQuaternion,
targetQuaternion,
animationProgress
)
const up = new Vector3(0, 0, 1).applyQuaternion(currentQ)
this.camera.up.copy(up)
const currentTarget = tempVec.lerpVectors(
initialTarget,
targetPosition,
animationProgress
)
if (this.camera instanceof PerspectiveCamera)
// changing the camera position back when it's orthographic doesn't do anything
// and it messes up animating back to perspective later
this.camera.position
.set(0, 0, 1)
.applyQuaternion(currentQ)
.multiplyScalar(initialDistance)
.add(currentTarget)
this.camera.up.set(0, 1, 0).applyQuaternion(currentQ).normalize()
this.camera.quaternion.copy(currentQ)
this.target.copy(currentTarget)
this.camera.updateProjectionMatrix()
this.update()
this.onCameraChange()
}
const onComplete = async () => {
if (isReducedMotion() && toOrthographic) {
cameraAtTime(0.9999)
this.useOrthographicCamera()
} else if (toOrthographic) {
await this.animateToOrthographic()
}
this.enableRotate = false
this._isCamMovingCallback(false, true)
resolve()
}
if (isReducedMotion()) {
onComplete()
return
}
new TWEEN.Tween({ t: 0 })
.to({ t: tweenEnd }, duration)
.easing(TWEEN.Easing.Quadratic.InOut)
.onUpdate(({ t }) => cameraAtTime(t))
.onComplete(onComplete)
.start()
})
}
animateToOrthographic = () =>
new Promise((resolve) => {
if (this.syncDirection === 'engineToClient')
console.warn(
'animate To Orthographic not design to work with engineToClient syncDirection.'
)
this.isFovAnimationInProgress = true
let currentFov = this.lastPerspectiveFov
this.fovBeforeOrtho = currentFov
const targetFov = 4
const fovAnimationStep = (currentFov - targetFov) / FRAMES_TO_ANIMATE_IN
let frameWaitOnFinish = 10
const animateFovChange = () => {
if (this.camera instanceof PerspectiveCamera) {
if (this.camera.fov > targetFov) {
// Decrease the FOV
currentFov = Math.max(currentFov - fovAnimationStep, targetFov)
this.camera.updateProjectionMatrix()
this.dollyZoom(currentFov)
requestAnimationFrame(animateFovChange) // Continue the animation
} else if (frameWaitOnFinish > 0) {
frameWaitOnFinish--
requestAnimationFrame(animateFovChange) // Continue the animation
} else {
// Once the target FOV is reached, switch to the orthographic camera
// Needs to wait a couple frames after the FOV animation is complete
this.useOrthographicCamera()
this.isFovAnimationInProgress = false
resolve(true)
}
}
}
animateFovChange() // Start the animation
})
animateToPerspective = (targetCamUp = new Vector3(0, 0, 1)) =>
new Promise((resolve) => {
if (this.syncDirection === 'engineToClient') {
console.warn(
'animate To Perspective not design to work with engineToClient syncDirection.'
)
}
this.isFovAnimationInProgress = true
const targetFov = this.fovBeforeOrtho // Target FOV for perspective
this.lastPerspectiveFov = 4
let currentFov = 4
const initialCameraUp = this.camera.up.clone()
this.usePerspectiveCamera()
const tempVec = new Vector3()
const cameraAtTime = (t: number) => {
currentFov =
this.lastPerspectiveFov + (targetFov - this.lastPerspectiveFov) * t
const currentUp = tempVec.lerpVectors(initialCameraUp, targetCamUp, t)
this.camera.up.copy(currentUp)
this.dollyZoom(currentFov)
}
const onComplete = () => {
this.isFovAnimationInProgress = false
resolve(true)
}
new TWEEN.Tween({ t: 0 })
.to({ t: 1 }, isReducedMotion() ? 50 : FRAMES_TO_ANIMATE_IN * 16) // Assuming 60fps, hence 16ms per frame
.easing(TWEEN.Easing.Quadratic.InOut)
.onUpdate(({ t }) => cameraAtTime(t))
.onComplete(onComplete)
.start()
})
get reactCameraProperties(): ReactCameraProperties {
return {
type: this.isPerspective ? 'perspective' : 'orthographic',
[this.isPerspective ? 'fov' : 'zoom']:
this.camera instanceof PerspectiveCamera
? this.camera.fov
: this.camera.zoom,
position: [
roundOff(this.camera.position.x, 2),
roundOff(this.camera.position.y, 2),
roundOff(this.camera.position.z, 2),
],
quaternion: [
roundOff(this.camera.quaternion.x, 2),
roundOff(this.camera.quaternion.y, 2),
roundOff(this.camera.quaternion.z, 2),
roundOff(this.camera.quaternion.w, 2),
],
}
}
reactCameraPropertiesCallback: (a: ReactCameraProperties) => void = () => {}
setReactCameraPropertiesCallback = (
cb: (a: ReactCameraProperties) => void
) => {
this.reactCameraPropertiesCallback = cb
}
deferReactUpdate = throttle((a: ReactCameraProperties) => {
this.reactCameraPropertiesCallback(a)
}, 200)
onCameraChange = (forceUpdate = false) => {
const distance = this.target.distanceTo(this.camera.position)
if (this.camera.far / 2.1 < distance || this.camera.far / 1.9 > distance) {
this.camera.far = distance * 2
this.camera.near = distance / 10
this.camera.updateProjectionMatrix()
}
if (this.syncDirection === 'clientToEngine' || forceUpdate)
this.throttledUpdateEngineCamera({
quaternion: this.camera.quaternion,
position: this.camera.position,
zoom: this.camera.zoom,
isPerspective: this.isPerspective,
target: this.target,
})
this.deferReactUpdate(this.reactCameraProperties)
Object.values(this._camChangeCallbacks).forEach((cb) => cb())
}
getInteractionType = (event: any) =>
_getInteractionType(
this.interactionGuards,
event,
this.enablePan,
this.enableRotate,
this.enableZoom
)
}
// Pure function helpers
function calculateNearFarFromFOV(fov: number) {
// const nearFarRatio = (fov - 3) / (45 - 3)
// const z_near = 0.1 + nearFarRatio * (5 - 0.1)
// const z_far = 1000 + nearFarRatio * (100000 - 1000)
return { z_near: 0.1, z_far: 1000 }
}
function convertThreeCamValuesToEngineCam({
target,
position,
quaternion,
zoom,
isPerspective,
}: ThreeCamValues): {
center: Vector3
up: Vector3
vantage: Vector3
} {
// Something to consider is that the orbit controls have a target,
// we're kind of deriving the target/lookAtVector here when it might not be needed
// leaving for now since it's working but maybe revisit later
const euler = new Euler().setFromQuaternion(quaternion, 'XYZ')
const lookAtVector = new Vector3(0, 0, -1)
.applyEuler(euler)
.normalize()
.add(position)
const upVector = new Vector3(0, 1, 0).applyEuler(euler).normalize()
if (isPerspective) {
return {
center: target,
up: upVector,
vantage: position,
}
}
const fudgeFactor2 = zoom * 0.9979224466814468 - 0.03473692325839295
const zoomFactor = (-ZOOM_MAGIC_NUMBER + fudgeFactor2) / zoom
const direction = lookAtVector.clone().sub(position).normalize()
const newVantage = position.clone().add(direction.multiplyScalar(zoomFactor))
return {
center: lookAtVector,
up: upVector,
vantage: newVantage,
}
}
function _lookAt(position: Vector3, target: Vector3, up: Vector3): Quaternion {
// Direction from position to target, normalized.
let direction = new Vector3().subVectors(target, position).normalize()
// Calculate a new "effective" up vector that is orthogonal to the direction.
// This step ensures that the up vector does not affect the direction the camera is looking.
let right = new Vector3().crossVectors(direction, up).normalize()
let orthogonalUp = new Vector3().crossVectors(right, direction).normalize()
// Create a lookAt matrix using the position, and the recalculated orthogonal up vector.
let lookAtMatrix = new Matrix4()
lookAtMatrix.lookAt(position, target, orthogonalUp)
// Create a quaternion from the lookAt matrix.
let quaternion = new Quaternion().setFromRotationMatrix(lookAtMatrix)
return quaternion
}
function _getInteractionType(
interactionGuards: MouseGuard,
event: any,
enablePan: boolean,
enableRotate: boolean,
enableZoom: boolean
): interactionType | 'none' {
let state: interactionType | 'none' = 'none'
if (enablePan && interactionGuards.pan.callback(event)) return 'pan'
if (enableRotate && interactionGuards.rotate.callback(event)) return 'rotate'
if (enableZoom && interactionGuards.zoom.dragCallback(event)) return 'zoom'
return state
}
/**
* Tells the engine to fire it's animation waits for it to finish and then requests camera settings
* to ensure the client-side camera is synchronized with the engine's camera state.
*
* @param engineCommandManager Our websocket singleton
* @param entityId - The ID of face or sketchPlane.
*/
export async function letEngineAnimateAndSyncCamAfter(
engineCommandManager: EngineCommandManager,
entityId: string
) {
await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'enable_sketch_mode',
adjust_camera: true,
animated: !isReducedMotion(),
ortho: false,
entity_id: entityId,
},
})
// wait 600ms (animation takes 500, + 100 for safety)
await new Promise((resolve) =>
setTimeout(resolve, isReducedMotion() ? 100 : 600)
)
await engineCommandManager.sendSceneCommand({
// CameraControls subscribes to default_camera_get_settings response events
// firing this at connection ensure the camera's are synced initially
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
})
await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'enable_sketch_mode',
adjust_camera: true,
animated: false,
ortho: true,
entity_id: entityId,
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
await engineCommandManager.sendSceneCommand({
// CameraControls subscribes to default_camera_get_settings response events
// firing this at connection ensure the camera's are synced initially
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
})
}