* Add cameraProjection setting * Add UI to toggle the user-level projection setting. * Make cameraProjection setting respected at startup * Add an E2E test for the perspective toggle * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest) * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest) * Don't force user back into perspective when exiting sketch * Make the projection setting more searchable * Make `current` label apply to the default option if not set * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest) * Re-run CI * Ohh *cargo fmt* * @lf94 feedback, fix found toggling bug, make command bar instantly toggle setting * Roll back the instant toggling behavior, it breaks the tests * Make ortho the default, keep tests using perspective * Move projection below camera controls setting * Fix up gizmo tests, which broke because the gizmo moved * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest) * Look at this (photo)Graph *in the voice of Nickelback* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: 49fl <ircsurfer33@gmail.com>
1316 lines
42 KiB
TypeScript
1316 lines
42 KiB
TypeScript
import { cameraMouseDragGuards, 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 {
|
||
Subscription,
|
||
EngineCommandManager,
|
||
UnreliableSubscription,
|
||
} from 'lang/std/engineConnection'
|
||
import { EngineCommand } from 'lang/std/artifactGraph'
|
||
import { toSync, 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'
|
||
import { reportRejection } from 'lib/trap'
|
||
import { CameraProjectionType } from 'wasm-lib/kcl/bindings/CameraProjectionType'
|
||
|
||
const ORTHOGRAPHIC_CAMERA_SIZE = 20
|
||
const FRAMES_TO_ANIMATE_IN = 30
|
||
const ORTHOGRAPHIC_MAGIC_FOV = 4
|
||
|
||
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]
|
||
target: [number, number, number]
|
||
quaternion: [number, number, number, number]
|
||
}
|
||
| {
|
||
type: 'orthographic'
|
||
zoom?: number
|
||
position: [number, number, number]
|
||
target: [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
|
||
zoomDataFromLastFrame?: number = undefined
|
||
// holds coordinates, and interaction
|
||
moveDataFromLastFrame?: [number, number, string] = undefined
|
||
lastPerspectiveFov: number = 45
|
||
pendingZoom: number | null = null
|
||
pendingRotation: Vector2 | null = null
|
||
pendingPan: Vector2 | null = null
|
||
interactionGuards: MouseGuard = cameraMouseDragGuards.KittyCAD
|
||
isFovAnimationInProgress = false
|
||
perspectiveFovBeforeOrtho = 45
|
||
get isPerspective() {
|
||
return this.camera instanceof PerspectiveCamera
|
||
}
|
||
|
||
setEngineCameraProjection(projection: CameraProjectionType) {
|
||
if (projection === 'orthographic') {
|
||
this.useOrthographicCamera()
|
||
} else {
|
||
this.usePerspectiveCamera(true).catch(reportRejection)
|
||
}
|
||
}
|
||
|
||
handleStart = () => {
|
||
this._isCamMovingCallback(true, false)
|
||
}
|
||
handleEnd = () => {
|
||
this._isCamMovingCallback(false, false)
|
||
}
|
||
|
||
setCam = (camProps: ReactCameraProperties) => {
|
||
if (
|
||
camProps.type === 'perspective' &&
|
||
this.camera instanceof OrthographicCamera
|
||
) {
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
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) => {
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
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),
|
||
},
|
||
}
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
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
|
||
) {
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
this.engineCommandManager.sendSceneCommand(this.lastPerspectiveCmd, true)
|
||
this.lastPerspectiveCmdTime = Date.now()
|
||
}
|
||
}
|
||
|
||
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'
|
||
| 'zoom_to_fit'
|
||
>
|
||
| 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
|
||
)
|
||
const quat = new Quaternion(
|
||
camSettings.orientation.x,
|
||
camSettings.orientation.y,
|
||
camSettings.orientation.z,
|
||
camSettings.orientation.w
|
||
).invert()
|
||
|
||
this.camera.up.copy(new Vector3(0, 1, 0).applyQuaternion(quat))
|
||
if (this.camera instanceof PerspectiveCamera && camSettings.ortho) {
|
||
this.useOrthographicCamera()
|
||
}
|
||
if (this.camera instanceof OrthographicCamera && !camSettings.ortho) {
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
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()
|
||
}
|
||
|
||
// Our stream is never more than 60fps.
|
||
// We can get away with capping our "virtual fps" to 60 then.
|
||
const FPS_VIRTUAL = 60
|
||
|
||
const doZoom = () => {
|
||
if (this.zoomDataFromLastFrame !== undefined) {
|
||
this.handleStart()
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
this.engineCommandManager.sendSceneCommand({
|
||
type: 'modeling_cmd_req',
|
||
cmd: {
|
||
type: 'default_camera_zoom',
|
||
magnitude:
|
||
(-1 * this.zoomDataFromLastFrame) / window.devicePixelRatio,
|
||
},
|
||
cmd_id: uuidv4(),
|
||
})
|
||
this.handleEnd()
|
||
}
|
||
this.zoomDataFromLastFrame = undefined
|
||
}
|
||
setInterval(doZoom, 1000 / FPS_VIRTUAL)
|
||
|
||
const doMove = () => {
|
||
if (this.moveDataFromLastFrame !== undefined) {
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
this.engineCommandManager.sendSceneCommand({
|
||
type: 'modeling_cmd_req',
|
||
cmd: {
|
||
type: 'camera_drag_move',
|
||
interaction: this.moveDataFromLastFrame[2] as any,
|
||
window: {
|
||
x: this.moveDataFromLastFrame[0],
|
||
y: this.moveDataFromLastFrame[1],
|
||
},
|
||
},
|
||
cmd_id: uuidv4(),
|
||
})
|
||
}
|
||
this.moveDataFromLastFrame = undefined
|
||
}
|
||
setInterval(doMove, 1000 / FPS_VIRTUAL)
|
||
|
||
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.subscribeTo({
|
||
event: 'zoom_to_fit',
|
||
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: PointerEvent) => {
|
||
this.domElement.setPointerCapture(event.pointerId)
|
||
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: PointerEvent) => {
|
||
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.moveDataFromLastFrame = [event.clientX, event.clientY, interaction]
|
||
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.perspectiveFovBeforeOrtho
|
||
this.pendingPan.x += -deltaMove.x * panSpeed
|
||
this.pendingPan.y += deltaMove.y * panSpeed
|
||
}
|
||
} else {
|
||
/**
|
||
* If we're not in sketch mode and not dragging, we can highlight entities
|
||
* under the cursor. This recently moved from being handled in App.tsx.
|
||
* This might not be the right spot, but it is more consolidated.
|
||
*/
|
||
if (this.syncDirection === 'engineToClient') {
|
||
const newCmdId = uuidv4()
|
||
|
||
this.throttledEngCmd({
|
||
type: 'modeling_cmd_req',
|
||
cmd: {
|
||
type: 'highlight_set_entity',
|
||
selected_at_window: { x: event.clientX, y: event.clientY },
|
||
},
|
||
cmd_id: newCmdId,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
onMouseUp = (event: PointerEvent) => {
|
||
this.domElement.releasePointerCapture(event.pointerId)
|
||
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) => {
|
||
const interaction = this.getInteractionType(event)
|
||
if (interaction === 'none') return
|
||
event.preventDefault()
|
||
|
||
if (this.syncDirection === 'engineToClient') {
|
||
if (interaction === 'zoom') {
|
||
this.zoomDataFromLastFrame = event.deltaY
|
||
} else {
|
||
// This case will get handled when we add pan and rotate using Apple trackpad.
|
||
console.error(
|
||
`Unexpected interaction type for engineToClient wheel event: ${interaction}`
|
||
)
|
||
}
|
||
return
|
||
}
|
||
|
||
// else "clientToEngine" (Sketch Mode) or forceUpdate
|
||
|
||
// We need to simulate similar behavior as when we send
|
||
// zoom commands to engine. This means dropping some zoom
|
||
// commands too.
|
||
// From onMouseMove zoom handling which seems to be really smooth
|
||
|
||
this.handleStart()
|
||
if (interaction === 'zoom') {
|
||
this.pendingZoom = 1 + (event.deltaY / window.devicePixelRatio) * 0.001
|
||
} else {
|
||
// This case will get handled when we add pan and rotate using Apple trackpad.
|
||
console.error(
|
||
`Unexpected interaction type for wheel event: ${interaction}`
|
||
)
|
||
}
|
||
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()
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
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
|
||
this.camera = this.createPerspectiveCamera()
|
||
|
||
this.camera.position.set(px, py, pz)
|
||
this.camera.quaternion.set(qx, qy, qz, qw)
|
||
const direction = new Vector3().subVectors(
|
||
this.camera.position,
|
||
this.target
|
||
)
|
||
direction.normalize()
|
||
}
|
||
usePerspectiveCamera = async (forceSend = false) => {
|
||
this._usePerspectiveCamera()
|
||
if (forceSend || this.syncDirection === 'clientToEngine') {
|
||
await 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,
|
||
},
|
||
},
|
||
})
|
||
}
|
||
this.onCameraChange()
|
||
this.update()
|
||
return this.camera
|
||
}
|
||
|
||
dollyZoom = async (newFov: number, splitEngineCalls = false) => {
|
||
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)
|
||
|
||
if (splitEngineCalls) {
|
||
await this.engineCommandManager.sendSceneCommand({
|
||
type: 'modeling_cmd_req',
|
||
cmd_id: uuidv4(),
|
||
cmd: {
|
||
type: 'default_camera_look_at',
|
||
...convertThreeCamValuesToEngineCam({
|
||
isPerspective: true,
|
||
position: newPosition,
|
||
quaternion: this.camera.quaternion,
|
||
zoom: this.camera.zoom,
|
||
target: this.target,
|
||
}),
|
||
},
|
||
})
|
||
await this.engineCommandManager.sendSceneCommand({
|
||
type: 'modeling_cmd_req',
|
||
cmd_id: uuidv4(),
|
||
cmd: {
|
||
type: 'default_camera_set_perspective',
|
||
parameters: {
|
||
fov_y: newFov,
|
||
},
|
||
},
|
||
})
|
||
} else {
|
||
await this.engineCommandManager.sendSceneCommand({
|
||
type: 'modeling_cmd_req',
|
||
cmd_id: uuidv4(),
|
||
cmd: {
|
||
type: 'default_camera_perspective_settings',
|
||
...convertThreeCamValuesToEngineCam({
|
||
isPerspective: true,
|
||
position: newPosition,
|
||
quaternion: this.camera.quaternion,
|
||
zoom: this.camera.zoom,
|
||
target: this.target,
|
||
}),
|
||
fov_y: newFov,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
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 updateCameraToAxis(
|
||
axis: 'x' | 'y' | 'z' | '-x' | '-y' | '-z'
|
||
): Promise<void> {
|
||
const distance = this.camera.position.distanceTo(this.target)
|
||
|
||
const vantage = this.target.clone()
|
||
let up = { x: 0, y: 0, z: 1 }
|
||
|
||
if (axis === 'x') {
|
||
vantage.x += distance
|
||
} else if (axis === 'y') {
|
||
vantage.y += distance
|
||
} else if (axis === 'z') {
|
||
vantage.z += distance
|
||
up = { x: -1, y: 0, z: 0 }
|
||
} else if (axis === '-x') {
|
||
vantage.x -= distance
|
||
} else if (axis === '-y') {
|
||
vantage.y -= distance
|
||
} else if (axis === '-z') {
|
||
vantage.z -= distance
|
||
up = { x: -1, y: 0, z: 0 }
|
||
}
|
||
|
||
await this.engineCommandManager.sendSceneCommand({
|
||
type: 'modeling_cmd_req',
|
||
cmd_id: uuidv4(),
|
||
cmd: {
|
||
type: 'default_camera_look_at',
|
||
center: this.target,
|
||
vantage: vantage,
|
||
up: up,
|
||
},
|
||
})
|
||
await this.engineCommandManager.sendSceneCommand({
|
||
type: 'modeling_cmd_req',
|
||
cmd_id: uuidv4(),
|
||
cmd: {
|
||
type: 'default_camera_get_settings',
|
||
},
|
||
})
|
||
}
|
||
|
||
async resetCameraPosition(): Promise<void> {
|
||
await this.engineCommandManager.sendSceneCommand({
|
||
type: 'modeling_cmd_req',
|
||
cmd_id: uuidv4(),
|
||
cmd: {
|
||
type: 'default_camera_look_at',
|
||
center: this.target,
|
||
vantage: {
|
||
x: this.target.x,
|
||
y: this.target.y - 128,
|
||
z: this.target.z + 64,
|
||
},
|
||
up: { x: 0, y: 0, z: 1 },
|
||
},
|
||
})
|
||
await this.engineCommandManager.sendSceneCommand({
|
||
type: 'modeling_cmd_req',
|
||
cmd_id: uuidv4(),
|
||
cmd: {
|
||
type: 'zoom_to_fit',
|
||
object_ids: [], // leave empty to zoom to all objects
|
||
padding: 0.2, // padding around the objects
|
||
},
|
||
})
|
||
}
|
||
|
||
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()) {
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
onComplete()
|
||
return
|
||
}
|
||
|
||
new TWEEN.Tween({ t: 0 })
|
||
.to({ t: tweenEnd }, duration)
|
||
.easing(TWEEN.Easing.Quadratic.InOut)
|
||
.onUpdate(({ t }) => cameraAtTime(t))
|
||
.onComplete(toSync(onComplete, reportRejection))
|
||
.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.perspectiveFovBeforeOrtho = currentFov
|
||
|
||
const targetFov = ORTHOGRAPHIC_MAGIC_FOV
|
||
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()
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
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.perspectiveFovBeforeOrtho // Target FOV for perspective
|
||
this.lastPerspectiveFov = ORTHOGRAPHIC_MAGIC_FOV
|
||
let currentFov = ORTHOGRAPHIC_MAGIC_FOV
|
||
const initialCameraUp = this.camera.up.clone()
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
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)
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
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()
|
||
})
|
||
snapToPerspectiveBeforeHandingBackControlToEngine = async (
|
||
targetCamUp = new Vector3(0, 0, 1)
|
||
) => {
|
||
if (this.syncDirection === 'engineToClient') {
|
||
console.warn(
|
||
'animate To Perspective not design to work with engineToClient syncDirection.'
|
||
)
|
||
}
|
||
this.isFovAnimationInProgress = true
|
||
const targetFov = this.perspectiveFovBeforeOrtho // Target FOV for perspective
|
||
let currentFov = ORTHOGRAPHIC_MAGIC_FOV
|
||
const initialCameraUp = this.camera.up.clone()
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
this.usePerspectiveCamera()
|
||
const tempVec = new Vector3()
|
||
|
||
currentFov = this.lastPerspectiveFov + (targetFov - this.lastPerspectiveFov)
|
||
const currentUp = tempVec.lerpVectors(initialCameraUp, targetCamUp, 1)
|
||
this.camera.up.copy(currentUp)
|
||
await this.dollyZoom(currentFov, true)
|
||
|
||
this.isFovAnimationInProgress = false
|
||
}
|
||
|
||
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),
|
||
],
|
||
target: [
|
||
roundOff(this.target.x, 2),
|
||
roundOff(this.target.y, 2),
|
||
roundOff(this.target.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: MouseEvent) =>
|
||
_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.01, z_far: 1000 }
|
||
}
|
||
|
||
function convertThreeCamValuesToEngineCam({
|
||
target,
|
||
position,
|
||
quaternion,
|
||
zoom,
|
||
isPerspective,
|
||
}: ThreeCamValues): {
|
||
center: Vector3
|
||
up: Vector3
|
||
vantage: Vector3
|
||
} {
|
||
const euler = new Euler().setFromQuaternion(quaternion, 'XYZ')
|
||
|
||
const upVector = new Vector3(0, 1, 0).applyEuler(euler).normalize()
|
||
if (isPerspective) {
|
||
return {
|
||
center: target,
|
||
up: upVector,
|
||
vantage: position,
|
||
}
|
||
}
|
||
|
||
// re-implementing stuff here, though this is a bunch of Mike's code
|
||
// if we need to pull him in again, at least it will be familiar to him
|
||
// and it's all simple functions.
|
||
interface Coord3d {
|
||
x: number
|
||
y: number
|
||
z: number
|
||
}
|
||
|
||
function buildLookAt(distance: number, center: Coord3d, eye: Coord3d) {
|
||
const eyeVector = normalized(sub(eye, center))
|
||
return { center: center, eye: add(center, mult(eyeVector, distance)) }
|
||
}
|
||
|
||
function mult(vecA: Coord3d, sc: number): Coord3d {
|
||
return { x: vecA.x * sc, y: vecA.y * sc, z: vecA.z * sc }
|
||
}
|
||
|
||
function add(vecA: Coord3d, vecB: Coord3d): Coord3d {
|
||
return { x: vecA.x + vecB.x, y: vecA.y + vecB.y, z: vecA.z + vecB.z }
|
||
}
|
||
|
||
function sub(vecA: Coord3d, vecB: Coord3d): Coord3d {
|
||
return { x: vecA.x - vecB.x, y: vecA.y - vecB.y, z: vecA.z - vecB.z }
|
||
}
|
||
|
||
function dot(vecA: Coord3d, vecB: Coord3d) {
|
||
return vecA.x * vecB.x + vecA.y * vecB.y + vecA.z * vecB.z
|
||
}
|
||
|
||
function length(vecA: Coord3d) {
|
||
return Math.sqrt(dot(vecA, vecA))
|
||
}
|
||
|
||
function normalized(vecA: Coord3d) {
|
||
return mult(vecA, 1.0 / length(vecA))
|
||
}
|
||
|
||
const lookAt = buildLookAt(64 / zoom, target, position)
|
||
return {
|
||
center: new Vector3(lookAt.center.x, lookAt.center.y, lookAt.center.z),
|
||
up: new Vector3(upVector.x, upVector.y, upVector.z),
|
||
vantage: new Vector3(lookAt.eye.x, lookAt.eye.y, lookAt.eye.z),
|
||
}
|
||
}
|
||
|
||
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: MouseEvent | WheelEvent,
|
||
enablePan: boolean,
|
||
enableRotate: boolean,
|
||
enableZoom: boolean
|
||
): interactionType | 'none' {
|
||
if (event instanceof WheelEvent) {
|
||
if (enableZoom && interactionGuards.zoom.scrollCallback(event))
|
||
return 'zoom'
|
||
} else {
|
||
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 'none'
|
||
}
|
||
|
||
/**
|
||
* 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: true,
|
||
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',
|
||
},
|
||
})
|
||
}
|