Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion src/extensions/core/load3d/ControlsManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { ControlsManager } from './ControlsManager'
import type { EventManagerInterface } from './interfaces'
Expand Down Expand Up @@ -27,6 +27,12 @@ vi.mock('three/examples/jsm/controls/OrbitControls', () => {
if (!this.listeners.has(event)) this.listeners.set(event, [])
this.listeners.get(event)!.push(cb)
}
removeEventListener(event: string, cb: Listener) {
const list = this.listeners.get(event)
if (!list) return
const idx = list.indexOf(cb)
if (idx >= 0) list.splice(idx, 1)
}
fire(event: string) {
this.listeners.get(event)?.forEach((cb) => cb())
}
Expand Down Expand Up @@ -62,6 +68,10 @@ describe('ControlsManager', () => {
camera = new THREE.PerspectiveCamera()
})

afterEach(() => {
vi.restoreAllMocks()
})

describe('construction', () => {
it('attaches OrbitControls to the canvas parent when one exists', () => {
const renderer = makeRenderer({ withParent: true })
Expand Down Expand Up @@ -136,6 +146,71 @@ describe('ControlsManager', () => {
})
})

describe('setTarget', () => {
it('moves the orbit pivot and translates the camera by the same delta so distance is preserved', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
camera.position.set(0, 0, 5)
camera.zoom = 1
manager.controls.target.set(0, 0, 0)

manager.setTarget(new THREE.Vector3(1, 2, 3))

expect(manager.controls.target.toArray()).toEqual([1, 2, 3])
expect(camera.position.toArray()).toEqual([1, 2, 8])
expect(manager.controls.update).toHaveBeenCalled()
expect(events.emitEvent).toHaveBeenCalledWith('cameraChanged', {
position: expect.objectContaining({ x: 1, y: 2, z: 8 }),
target: expect.objectContaining({ x: 1, y: 2, z: 3 }),
zoom: 1,
cameraType: 'perspective'
})
})

it('moves the camera to the specified distance from the new pivot along the previous direction', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
camera.position.set(0, 0, 100)
manager.controls.target.set(0, 0, 0)

manager.setTarget(new THREE.Vector3(2, 0, 0), 10)

expect(camera.position.toArray()).toEqual([2, 0, 10])
})
})

describe('animateTarget', () => {
it('lerps camera position and target over time and emits cameraChanged on completion', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
camera.position.set(0, 0, 10)
manager.controls.target.set(0, 0, 0)

let now = 1000
vi.spyOn(performance, 'now').mockImplementation(() => now)
const rafQueue: FrameRequestCallback[] = []
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
rafQueue.push(cb)
return rafQueue.length
})
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})

manager.animateTarget(new THREE.Vector3(0, 0, 0), 2, 100)
// Halfway through
now = 1050
rafQueue.shift()?.(now)
expect(camera.position.z).toBeGreaterThan(2)
expect(camera.position.z).toBeLessThan(10)
// Past end
now = 1200
rafQueue.shift()?.(now)
expect(camera.position.toArray()).toEqual([0, 0, 2])
expect(events.emitEvent).toHaveBeenCalledWith(
'cameraChanged',
expect.objectContaining({
position: expect.objectContaining({ x: 0, y: 0, z: 2 })
})
)
})
})

describe('update / reset', () => {
it('update delegates to controls.update', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
Expand Down
112 changes: 99 additions & 13 deletions src/extensions/core/load3d/ControlsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export class ControlsManager implements ControlsManagerInterface {
controls: OrbitControls
private eventManager: EventManagerInterface
private camera: THREE.Camera
private requestRender: (() => void) | null = null
private animationRafId: number | null = null
private animationCleanup: (() => void) | null = null

constructor(
renderer: THREE.WebGLRenderer,
Expand All @@ -24,26 +27,109 @@ export class ControlsManager implements ControlsManagerInterface {
this.controls.enableDamping = true
}

setRequestRender(callback: () => void): void {
this.requestRender = callback
}

init(): void {
this.controls.addEventListener('end', () => {
const cameraState = {
position: this.camera.position.clone(),
target: this.controls.target.clone(),
zoom:
this.camera instanceof THREE.OrthographicCamera
? (this.camera as THREE.OrthographicCamera).zoom
: (this.camera as THREE.PerspectiveCamera).zoom,
cameraType:
this.camera instanceof THREE.PerspectiveCamera
? 'perspective'
: 'orthographic'
this.eventManager.emitEvent('cameraChanged', this.buildCameraState())
})
}

setTarget(point: THREE.Vector3, distance?: number): void {
this.animateTarget(point, distance, 0)
}

animateTarget(
point: THREE.Vector3,
distance?: number,
durationMs: number = 450
): void {
this.cancelAnimation()
const { endTarget, endPosition } = this.computeFocusEnd(point, distance)

if (durationMs <= 0) {
this.controls.target.copy(endTarget)
this.camera.position.copy(endPosition)
this.controls.update()
this.eventManager.emitEvent('cameraChanged', this.buildCameraState())
return
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const startTarget = this.controls.target.clone()
const startPosition = this.camera.position.clone()

// If a user grabs the controls mid-animation, abandon the tween so we
// don't fight their input.
const cancelOnInteract = () => this.cancelAnimation()
this.controls.addEventListener('start', cancelOnInteract)
this.animationCleanup = () => {
this.controls.removeEventListener('start', cancelOnInteract)
}

const startTime = performance.now()
const tick = () => {
const t = Math.min((performance.now() - startTime) / durationMs, 1)
const eased = 1 - Math.pow(1 - t, 3) // easeOutCubic
this.controls.target.lerpVectors(startTarget, endTarget, eased)
this.camera.position.lerpVectors(startPosition, endPosition, eased)
this.controls.update()
this.requestRender?.()

if (t >= 1) {
this.cancelAnimation()
this.eventManager.emitEvent('cameraChanged', this.buildCameraState())
return
}
this.animationRafId = requestAnimationFrame(tick)
}
this.animationRafId = requestAnimationFrame(tick)
}

this.eventManager.emitEvent('cameraChanged', cameraState)
})
private computeFocusEnd(
point: THREE.Vector3,
distance?: number
): { endTarget: THREE.Vector3; endPosition: THREE.Vector3 } {
const offset = this.camera.position.clone().sub(this.controls.target)
const currentDistance = offset.length()
const newDistance = distance ?? currentDistance
const direction =
currentDistance > 1e-6
? offset.divideScalar(currentDistance)
: new THREE.Vector3(0, 0, 1)
return {
endTarget: point.clone(),
endPosition: point.clone().addScaledVector(direction, newDistance)
}
}

private cancelAnimation(): void {
if (this.animationRafId !== null) {
cancelAnimationFrame(this.animationRafId)
this.animationRafId = null
}
this.animationCleanup?.()
this.animationCleanup = null
}

private buildCameraState() {
return {
position: this.camera.position.clone(),
target: this.controls.target.clone(),
zoom:
this.camera instanceof THREE.OrthographicCamera
? (this.camera as THREE.OrthographicCamera).zoom
: (this.camera as THREE.PerspectiveCamera).zoom,
cameraType:
this.camera instanceof THREE.PerspectiveCamera
? 'perspective'
: 'orthographic'
}
}

dispose(): void {
this.cancelAnimation()
this.controls.dispose()
}

Expand Down
28 changes: 28 additions & 0 deletions src/extensions/core/load3d/Load3d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
UpDirection
} from './interfaces'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
import { attachFocusUnderCursor } from './load3dFocusUnderCursor'
import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
Expand Down Expand Up @@ -102,6 +103,7 @@ class Load3d {
isViewerMode: boolean = false

private disposeContextMenuGuard: (() => void) | null = null
private disposeFocusUnderCursor: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private getZoomScaleCallback: (() => number) | undefined

Expand Down Expand Up @@ -137,6 +139,8 @@ class Load3d {
this.gizmoManager = deps.gizmoManager
this.adapterRef = deps.adapterRef

this.controlsManager.setRequestRender(() => this.forceRender())

this.sceneManager.init()
this.cameraManager.init()
this.controlsManager.init()
Expand All @@ -153,6 +157,7 @@ class Load3d {
this.STATUS_MOUSE_ON_VIEWER = false

this.initContextMenu()
this.initFocusUnderCursor()
this.initResizeObserver(container)

this.handleResize()
Expand Down Expand Up @@ -181,6 +186,26 @@ class Load3d {
)
}

private initFocusUnderCursor(): void {
this.disposeFocusUnderCursor = attachFocusUnderCursor({
canvas: this.renderer.domElement,
getModel: () => this.modelManager.currentModel,
getCamera: () => this.cameraManager.activeCamera,
getRenderRegion: () => {
const w = this.renderer.domElement.clientWidth
const h = this.renderer.domElement.clientHeight
return this.shouldMaintainAspectRatio()
? computeLetterboxedViewport(
{ width: w, height: h },
this.targetAspectRatio
)
: { offsetX: 0, offsetY: 0, width: w, height: h }
},
focusOn: (point, distance) =>
this.controlsManager.animateTarget(point, distance)
})
}

getEventManager(): EventManager {
return this.eventManager
}
Expand Down Expand Up @@ -901,6 +926,9 @@ class Load3d {
this.disposeContextMenuGuard?.()
this.disposeContextMenuGuard = null

this.disposeFocusUnderCursor?.()
this.disposeFocusUnderCursor = null

this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
const event = new Event('webglcontextlost', {
Expand Down
7 changes: 7 additions & 0 deletions src/extensions/core/load3d/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ export interface CameraManagerInterface extends BaseManager {
export interface ControlsManagerInterface extends BaseManager {
controls: OrbitControls
handleResize(): void
setRequestRender(callback: () => void): void
setTarget(point: THREE.Vector3, distance?: number): void
animateTarget(
point: THREE.Vector3,
distance?: number,
durationMs?: number
): void
}

export interface LightingManagerInterface extends BaseManager {
Expand Down
Loading
Loading