Compare commits

...

2 Commits

6 changed files with 948 additions and 382 deletions

View File

@@ -14,6 +14,7 @@ vi.mock('three/examples/jsm/controls/OrbitControls', () => {
object: THREE.Camera
domElement: HTMLElement
enableDamping = false
enabled = true
target = new THREE.Vector3()
update = vi.fn()
dispose = vi.fn()
@@ -165,4 +166,24 @@ describe('ControlsManager', () => {
expect(manager.controls.dispose).toHaveBeenCalled()
})
})
describe('detach / attach', () => {
it('detach disables OrbitControls interaction', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
expect(manager.controls.enabled).toBe(true)
manager.detach()
expect(manager.controls.enabled).toBe(false)
})
it('attach re-enables OrbitControls interaction', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
manager.detach()
manager.attach()
expect(manager.controls.enabled).toBe(true)
})
})
})

View File

@@ -63,6 +63,15 @@ export class ControlsManager implements ControlsManagerInterface {
camera.position.copy(position)
this.controls.update()
}
detach(): void {
this.controls.enabled = false
}
attach(): void {
this.controls.enabled = true
}
reset(): void {
this.controls.target.set(0, 0, 0)
this.controls.update()

View File

@@ -2,26 +2,19 @@ import * as THREE from 'three'
import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js'
import type { AnimationManager } from './AnimationManager'
import type { CameraManager } from './CameraManager'
import type { ControlsManager } from './ControlsManager'
import type { EventManager } from './EventManager'
import type { GizmoManager } from './GizmoManager'
import type { HDRIManager } from './HDRIManager'
import type { LightingManager } from './LightingManager'
import type { LoaderManager } from './LoaderManager'
import { DIRECT_EXPORT_FORMATS } from './constants'
import { ModelExporter } from './ModelExporter'
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import type { AdapterRef, ModelAdapterCapabilities } from './ModelAdapter'
import type { RecordingManager } from './RecordingManager'
import type { SceneManager } from './SceneManager'
import type { SceneModelManager } from './SceneModelManager'
import type { ViewHelperManager } from './ViewHelperManager'
import { Viewport3d, type Viewport3dDeps } from './Viewport3d'
import { computeCameraFromMatrices } from './cameraFromMatrices'
import { DIRECT_EXPORT_FORMATS } from './constants'
import type {
CameraState,
CaptureResult,
EventCallback,
GizmoMode,
Load3DOptions,
LoadModelOptions,
@@ -29,20 +22,10 @@ import type {
Model3DTransform,
UpDirection
} from './interfaces'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
export type Load3dDeps = {
renderer: THREE.WebGLRenderer
eventManager: EventManager
sceneManager: SceneManager
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
export type Load3dDeps = Viewport3dDeps & {
hdriManager: HDRIManager
viewHelperManager: ViewHelperManager
loaderManager: LoaderManager
modelManager: SceneModelManager
recordingManager: RecordingManager
@@ -70,22 +53,8 @@ function positionThumbnailCamera(
camera.updateProjectionMatrix()
}
class Load3d {
renderer: THREE.WebGLRenderer
protected clock: THREE.Clock
private renderLoop: RenderLoopHandle | null = null
private loadingPromise: Promise<void> | null = null
private _loadGeneration: number = 0
private onContextMenuCallback?: (event: MouseEvent) => void
private getDimensionsCallback?: () => { width: number; height: number } | null
eventManager: EventManager
sceneManager: SceneManager
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
class Load3d extends Viewport3d {
hdriManager: HDRIManager
viewHelperManager: ViewHelperManager
loaderManager: LoaderManager
modelManager: SceneModelManager
recordingManager: RecordingManager
@@ -93,19 +62,8 @@ class Load3d {
gizmoManager: GizmoManager
adapterRef: AdapterRef
STATUS_MOUSE_ON_NODE: boolean
STATUS_MOUSE_ON_SCENE: boolean
STATUS_MOUSE_ON_VIEWER: boolean
INITIAL_RENDER_DONE: boolean = false
targetWidth: number = 0
targetHeight: number = 0
targetAspectRatio: number = 1
isViewerMode: boolean = false
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private getZoomScaleCallback: (() => number) | undefined
private loadingPromise: Promise<void> | null = null
private _loadGeneration: number = 0
private hasLoadedModel: boolean = false
constructor(
@@ -113,26 +71,9 @@ class Load3d {
deps: Load3dDeps,
options: Load3DOptions = {}
) {
this.clock = new THREE.Clock()
this.isViewerMode = options.isViewerMode || false
this.onContextMenuCallback = options.onContextMenu
this.getDimensionsCallback = options.getDimensions
this.getZoomScaleCallback = options.getZoomScale
super(container, deps, options)
if (options.width && options.height) {
this.targetWidth = options.width
this.targetHeight = options.height
this.targetAspectRatio = options.width / options.height
}
this.renderer = deps.renderer
this.eventManager = deps.eventManager
this.sceneManager = deps.sceneManager
this.cameraManager = deps.cameraManager
this.controlsManager = deps.controlsManager
this.lightingManager = deps.lightingManager
this.hdriManager = deps.hdriManager
this.viewHelperManager = deps.viewHelperManager
this.loaderManager = deps.loaderManager
this.modelManager = deps.modelManager
this.recordingManager = deps.recordingManager
@@ -140,35 +81,16 @@ class Load3d {
this.gizmoManager = deps.gizmoManager
this.adapterRef = deps.adapterRef
this.sceneManager.init()
this.cameraManager.init()
this.controlsManager.init()
this.lightingManager.init()
this.loaderManager.init()
this.animationManager.init()
this.gizmoManager.init()
this.viewHelperManager.createViewHelper(container)
this.viewHelperManager.init()
this.STATUS_MOUSE_ON_NODE = false
this.STATUS_MOUSE_ON_SCENE = false
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.initResizeObserver(container)
this.handleResize()
this.startAnimation()
this.eventManager.addEventListener('modelReady', () => {
if (this.adapterRef.current?.kind !== 'splat') return
void this.repaintWhenSparkPaintable()
})
setTimeout(() => {
this.forceRender()
}, 100)
this.start()
}
private async repaintWhenSparkPaintable(): Promise<void> {
@@ -178,49 +100,14 @@ class Load3d {
this.forceRender()
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => {
this.handleResize()
})
this.resizeObserver.observe(container)
}
private initContextMenu(): void {
this.disposeContextMenuGuard = attachContextMenuGuard(
this.renderer.domElement,
(event) => this.onContextMenuCallback?.(event),
{ isDisabled: () => this.isViewerMode }
)
}
getEventManager(): EventManager {
return this.eventManager
}
getSceneManager(): SceneManager {
return this.sceneManager
}
getCameraManager(): CameraManager {
return this.cameraManager
}
getControlsManager(): ControlsManager {
return this.controlsManager
}
getLightingManager(): LightingManager {
return this.lightingManager
}
getViewHelperManager(): ViewHelperManager {
return this.viewHelperManager
}
getLoaderManager(): LoaderManager {
return this.loaderManager
}
getModelManager(): SceneModelManager {
return this.modelManager
}
getRecordingManager(): RecordingManager {
return this.recordingManager
}
@@ -229,119 +116,12 @@ class Load3d {
return this.gizmoManager
}
getTargetSize(): { width: number; height: number } {
return {
width: this.targetWidth,
height: this.targetHeight
}
}
private shouldMaintainAspectRatio(): boolean {
return this.isViewerMode || (this.targetWidth > 0 && this.targetHeight > 0)
}
forceRender(): void {
const delta = this.clock.getDelta()
protected override tickPerFrame(delta: number): void {
this.animationManager.update(delta)
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderMainScene()
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
this.INITIAL_RENDER_DONE = true
super.tickPerFrame(delta)
}
renderMainScene(): void {
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
if (dims) {
this.targetWidth = dims.width
this.targetHeight = dims.height
this.targetAspectRatio = dims.width / dims.height
}
}
if (this.shouldMaintainAspectRatio()) {
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
this.renderer.setClearColor(0x0a0a0a)
this.renderer.clear()
this.renderer.setViewport(offsetX, offsetY, width, height)
this.renderer.setScissor(offsetX, offsetY, width, height)
this.cameraManager.updateAspectRatio(width / height)
} else {
// No aspect ratio constraint: fill the entire container
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
}
this.sceneManager.renderBackground()
this.renderer.render(
this.sceneManager.scene,
this.cameraManager.activeCamera
)
}
resetViewport(): void {
const width = this.renderer.domElement.clientWidth
const height = this.renderer.domElement.clientHeight
this.renderer.setViewport(0, 0, width, height)
this.renderer.setScissor(0, 0, width, height)
this.renderer.setScissorTest(false)
}
private startAnimation(): void {
this.renderLoop = startRenderLoop({
tick: () => {
const delta = this.clock.getDelta()
this.animationManager.update(delta)
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderMainScene()
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
},
isActive: () => this.isActive()
})
}
updateStatusMouseOnNode(onNode: boolean): void {
this.STATUS_MOUSE_ON_NODE = onNode
}
updateStatusMouseOnScene(onScene: boolean): void {
this.STATUS_MOUSE_ON_SCENE = onScene
}
updateStatusMouseOnViewer(onViewer: boolean): void {
this.STATUS_MOUSE_ON_VIEWER = onViewer
}
isActive(): boolean {
override isActive(): boolean {
return isLoad3dActive({
mouseOnNode: this.STATUS_MOUSE_ON_NODE,
mouseOnScene: this.STATUS_MOUSE_ON_SCENE,
@@ -444,9 +224,27 @@ class Load3d {
return ModelExporter.detectFormatFromURL(url)
}
protected override onActiveCameraChanged(): void {
this.gizmoManager.updateCamera(this.cameraManager.activeCamera)
}
setFOV(fov: number): void {
this.cameraManager.setFOV(fov)
this.forceRender()
}
setBackgroundColor(color: string): void {
this.sceneManager.setBackgroundColor(color)
this.forceRender()
}
toggleGrid(showGrid: boolean): void {
this.sceneManager.toggleGrid(showGrid)
this.forceRender()
}
setLightIntensity(intensity: number): void {
this.lightingManager.setLightIntensity(intensity)
this.forceRender()
}
@@ -473,7 +271,6 @@ class Load3d {
height
)
} else {
// No aspect ratio constraints: fill container
this.sceneManager.updateBackgroundSize(
this.sceneManager.backgroundTexture,
this.sceneManager.backgroundMesh,
@@ -488,12 +285,6 @@ class Load3d {
removeBackgroundImage(): void {
this.sceneManager.removeBackgroundImage()
this.forceRender()
}
toggleGrid(showGrid: boolean): void {
this.sceneManager.toggleGrid(showGrid)
this.forceRender()
}
@@ -502,39 +293,6 @@ class Load3d {
this.forceRender()
}
toggleCamera(cameraType?: 'perspective' | 'orthographic'): void {
this.cameraManager.toggleCamera(cameraType)
this.controlsManager.updateCamera(this.cameraManager.activeCamera)
this.gizmoManager.updateCamera(this.cameraManager.activeCamera)
this.viewHelperManager.recreateViewHelper()
this.handleResize()
}
getCurrentCameraType(): 'perspective' | 'orthographic' {
return this.cameraManager.getCurrentCameraType()
}
getCurrentModel(): THREE.Object3D | null {
return this.modelManager.currentModel
}
setCameraState(state: CameraState): void {
this.cameraManager.setCameraState(state)
this.forceRender()
}
getCameraState(): CameraState {
return this.cameraManager.getCameraState()
}
setFOV(fov: number): void {
this.cameraManager.setFOV(fov)
this.forceRender()
}
setCameraFromMatrices(
extrinsics: readonly (readonly number[])[],
intrinsics: readonly (readonly number[])[]
@@ -553,19 +311,15 @@ class Load3d {
this.setFOV(fovYDegrees)
}
getCurrentModel(): THREE.Object3D | null {
return this.modelManager.currentModel
}
setMaterialMode(mode: MaterialMode): void {
this.modelManager.setMaterialMode(mode)
this.forceRender()
}
/**
* Monotonic counter that ticks once per loadModel call, **before** any
* await. Callers can capture this immediately after triggering a load and
* later compare against `currentLoadGeneration` to verify their load is
* still the latest one — useful when chaining post-load work
* (e.g. applying camera matrices) through `whenLoadIdle()`, which would
* otherwise wait for any newer queued load and apply stale state to it.
*/
get currentLoadGeneration(): number {
return this._loadGeneration
}
@@ -606,8 +360,6 @@ class Load3d {
originalFileName?: string,
options?: LoadModelOptions
): Promise<void> {
// First load always uses default framing; subsequent reloads preserve
// the user's framing.
const shouldRetainView = this.hasLoadedModel
const savedCameraState = shouldRetainView
? this.cameraManager.getCameraState()
@@ -623,7 +375,6 @@ class Load3d {
await this.loaderManager.loadModel(url, originalFileName, options)
// Auto-detect and setup animations if present
if (this.modelManager.currentModel) {
this.animationManager.setupModelAnimations(
this.modelManager.currentModel,
@@ -633,7 +384,6 @@ class Load3d {
}
if (savedCameraState) {
// setupForModel runs during loadModel and clobbers the camera; restore on top.
if (
savedCameraState.cameraType !==
this.cameraManager.getCurrentCameraType()
@@ -674,11 +424,6 @@ class Load3d {
this.forceRender()
}
setLightIntensity(intensity: number): void {
this.lightingManager.setLightIntensity(intensity)
this.forceRender()
}
async loadHDRI(url: string): Promise<void> {
await this.hdriManager.loadHDRI(url)
this.forceRender()
@@ -706,73 +451,10 @@ class Load3d {
this.forceRender()
}
setTargetSize(width: number, height: number): void {
this.targetWidth = width
this.targetHeight = height
this.targetAspectRatio = width / height
this.handleResize()
}
addEventListener<T>(event: string, callback: EventCallback<T>): void {
this.eventManager.addEventListener(event, callback)
}
removeEventListener<T>(event: string, callback: EventCallback<T>): void {
this.eventManager.removeEventListener(event, callback)
}
emitModelReady(): void {
this.eventManager.emitEvent('modelReady', null)
}
refreshViewport(): void {
this.handleResize()
}
handleResize(): void {
const parentElement = this.renderer?.domElement?.parentElement
if (!parentElement) {
console.warn('Parent element not found')
return
}
const containerWidth = parentElement.clientWidth
const containerHeight = parentElement.clientHeight
// Scale pixel density to match the graph zoom level so the 3D scene
// renders at the correct resolution when the canvas is zoomed in or out.
const zoomScale = this.getZoomScaleCallback?.() ?? 1
this.renderer.setPixelRatio(Math.min(zoomScale, 3))
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
if (dims) {
this.targetWidth = dims.width
this.targetHeight = dims.height
this.targetAspectRatio = dims.width / dims.height
}
}
if (this.shouldMaintainAspectRatio()) {
const { width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(width, height)
this.sceneManager.handleResize(width, height)
} else {
// No aspect ratio constraint: use container dimensions directly
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(containerWidth, containerHeight)
this.sceneManager.handleResize(containerWidth, containerHeight)
}
this.forceRender()
}
captureScene(width: number, height: number): Promise<CaptureResult> {
this.gizmoManager.removeFromScene()
@@ -818,7 +500,6 @@ class Load3d {
this.recordingManager.clearRecording()
}
// Animation methods
public setAnimationSpeed(speed: number): void {
this.animationManager.setAnimationSpeed(speed)
}
@@ -977,41 +658,15 @@ class Load3d {
this.forceRender()
}
public remove(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
this.disposeContextMenuGuard?.()
this.disposeContextMenuGuard = null
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
const event = new Event('webglcontextlost', {
bubbles: true,
cancelable: true
})
canvas.dispatchEvent(event)
this.renderLoop?.stop()
this.renderLoop = null
this.sceneManager.dispose()
this.cameraManager.dispose()
this.controlsManager.dispose()
this.lightingManager.dispose()
protected override disposeManagers(): void {
super.disposeManagers()
this.hdriManager.dispose()
this.viewHelperManager.dispose()
this.loaderManager.dispose()
this.modelManager.dispose()
this.adapterRef.current = null
this.recordingManager.dispose()
this.animationManager.dispose()
this.gizmoManager.dispose()
this.renderer.dispose()
this.renderer.domElement.remove()
}
}

View File

@@ -0,0 +1,433 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Viewport3d } from '@/extensions/core/load3d/Viewport3d'
type CameraStub = {
toggleCamera: ReturnType<typeof vi.fn>
setupForModel: ReturnType<typeof vi.fn>
reset: ReturnType<typeof vi.fn>
getCameraState: ReturnType<typeof vi.fn>
setCameraState: ReturnType<typeof vi.fn>
setFOV: ReturnType<typeof vi.fn>
getCurrentCameraType: ReturnType<typeof vi.fn>
handleResize: ReturnType<typeof vi.fn>
updateAspectRatio: ReturnType<typeof vi.fn>
activeCamera: THREE.Camera
}
type SceneStub = {
captureScene: ReturnType<typeof vi.fn>
setBackgroundColor: ReturnType<typeof vi.fn>
setBackgroundImage: ReturnType<typeof vi.fn>
removeBackgroundImage: ReturnType<typeof vi.fn>
toggleGrid: ReturnType<typeof vi.fn>
setBackgroundRenderMode: ReturnType<typeof vi.fn>
handleResize: ReturnType<typeof vi.fn>
renderBackground: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
updateBackgroundSize: ReturnType<typeof vi.fn>
backgroundTexture: unknown
backgroundMesh: unknown
scene: THREE.Scene
}
function makeViewportInstance() {
const cameraManager: CameraStub = {
toggleCamera: vi.fn(),
setupForModel: vi.fn(),
reset: vi.fn(),
getCameraState: vi.fn(() => ({
position: new THREE.Vector3(),
target: new THREE.Vector3(),
zoom: 1,
cameraType: 'perspective' as const
})),
setCameraState: vi.fn(),
setFOV: vi.fn(),
getCurrentCameraType: vi.fn(() => 'perspective' as const),
handleResize: vi.fn(),
updateAspectRatio: vi.fn(),
activeCamera: new THREE.PerspectiveCamera()
}
const sceneManager: SceneStub = {
captureScene: vi.fn(),
setBackgroundColor: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
removeBackgroundImage: vi.fn(),
toggleGrid: vi.fn(),
setBackgroundRenderMode: vi.fn(),
handleResize: vi.fn(),
renderBackground: vi.fn(),
dispose: vi.fn(),
updateBackgroundSize: vi.fn(),
backgroundTexture: null,
backgroundMesh: null,
scene: new THREE.Scene()
}
const controlsManager = {
updateCamera: vi.fn(),
update: vi.fn(),
dispose: vi.fn(),
reset: vi.fn(),
detach: vi.fn(),
attach: vi.fn()
}
const lightingManager = {
setLightIntensity: vi.fn(),
dispose: vi.fn()
}
const viewHelperManager = {
recreateViewHelper: vi.fn(),
update: vi.fn(),
visibleViewHelper: vi.fn(),
viewHelper: { render: vi.fn() },
dispose: vi.fn()
}
const eventManager = {
emitEvent: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
const viewport = Object.create(Viewport3d.prototype) as Viewport3d
Object.assign(viewport, {
cameraManager,
sceneManager,
controlsManager,
lightingManager,
viewHelperManager,
eventManager,
forceRender: vi.fn(),
handleResize: vi.fn()
})
return {
viewport,
cameraManager,
sceneManager,
controlsManager,
lightingManager,
viewHelperManager,
eventManager,
forceRender: viewport.forceRender as ReturnType<typeof vi.fn>
}
}
describe('Viewport3d', () => {
let ctx: ReturnType<typeof makeViewportInstance>
beforeEach(() => {
ctx = makeViewportInstance()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('camera delegation (model-independent)', () => {
it('toggleCamera updates controls and recreates view helper without touching model state', () => {
ctx.viewport.toggleCamera('orthographic')
expect(ctx.cameraManager.toggleCamera).toHaveBeenCalledWith(
'orthographic'
)
expect(ctx.controlsManager.updateCamera).toHaveBeenCalledWith(
ctx.cameraManager.activeCamera
)
expect(ctx.viewHelperManager.recreateViewHelper).toHaveBeenCalledOnce()
})
})
describe('isActive (no model concerns)', () => {
it('returns false when no mouse activity is present', () => {
Object.assign(ctx.viewport, {
STATUS_MOUSE_ON_NODE: false,
STATUS_MOUSE_ON_SCENE: false,
STATUS_MOUSE_ON_VIEWER: false,
INITIAL_RENDER_DONE: true
})
expect(ctx.viewport.isActive()).toBe(false)
})
it('does not consult recording or animation state — that is a Load3d concern', () => {
Object.assign(ctx.viewport, {
STATUS_MOUSE_ON_NODE: false,
STATUS_MOUSE_ON_SCENE: false,
STATUS_MOUSE_ON_VIEWER: false,
INITIAL_RENDER_DONE: true
})
expect(() => ctx.viewport.isActive()).not.toThrow()
})
})
describe('manager accessors', () => {
it('exposes managers through both fields and getters', () => {
expect(ctx.viewport.cameraManager).toBe(ctx.cameraManager)
expect(ctx.viewport.getCameraManager()).toBe(ctx.cameraManager)
expect(ctx.viewport.sceneManager).toBe(ctx.sceneManager)
expect(ctx.viewport.getSceneManager()).toBe(ctx.sceneManager)
expect(ctx.viewport.controlsManager).toBe(ctx.controlsManager)
expect(ctx.viewport.getControlsManager()).toBe(ctx.controlsManager)
expect(ctx.viewport.lightingManager).toBe(ctx.lightingManager)
expect(ctx.viewport.getLightingManager()).toBe(ctx.lightingManager)
expect(ctx.viewport.viewHelperManager).toBe(ctx.viewHelperManager)
expect(ctx.viewport.getViewHelperManager()).toBe(ctx.viewHelperManager)
expect(ctx.viewport.eventManager).toBe(ctx.eventManager)
expect(ctx.viewport.getEventManager()).toBe(ctx.eventManager)
})
})
describe('POV swap (setExternalActiveCamera)', () => {
it('getRenderCamera returns the orbit camera when no external camera is set', () => {
expect(ctx.viewport.getRenderCamera()).toBe(
ctx.cameraManager.activeCamera
)
})
it('getRenderCamera returns the external camera once installed', () => {
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setExternalActiveCamera(subjectCamera)
expect(ctx.viewport.getRenderCamera()).toBe(subjectCamera)
})
it('installing an external camera detaches controls and hides the view helper', () => {
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setExternalActiveCamera(subjectCamera)
expect(ctx.controlsManager.detach).toHaveBeenCalledOnce()
expect(ctx.viewHelperManager.visibleViewHelper).toHaveBeenCalledWith(
false
)
expect(ctx.forceRender).toHaveBeenCalled()
})
it('clearing the external camera re-attaches controls and shows the view helper', () => {
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setExternalActiveCamera(subjectCamera)
ctx.controlsManager.detach.mockClear()
ctx.controlsManager.attach.mockClear()
ctx.viewHelperManager.visibleViewHelper.mockClear()
ctx.viewport.setExternalActiveCamera(null)
expect(ctx.controlsManager.attach).toHaveBeenCalledOnce()
expect(ctx.viewHelperManager.visibleViewHelper).toHaveBeenCalledWith(true)
expect(ctx.viewport.getRenderCamera()).toBe(
ctx.cameraManager.activeCamera
)
})
it('setting the same external camera twice is a no-op', () => {
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setExternalActiveCamera(subjectCamera)
ctx.controlsManager.detach.mockClear()
ctx.viewport.setExternalActiveCamera(subjectCamera)
expect(ctx.controlsManager.detach).not.toHaveBeenCalled()
})
})
describe('overlay', () => {
function makeOverlay() {
return {
attach: vi.fn(),
detach: vi.fn(),
update: vi.fn(),
onActiveCameraChange: vi.fn(),
dispose: vi.fn()
}
}
it('setOverlay attaches to the scene and notifies of the current render camera', () => {
const overlay = makeOverlay()
ctx.viewport.setOverlay(overlay)
expect(overlay.attach).toHaveBeenCalledWith(ctx.sceneManager.scene)
expect(overlay.onActiveCameraChange).toHaveBeenCalledWith(
ctx.cameraManager.activeCamera
)
expect(ctx.viewport.getOverlay()).toBe(overlay)
})
it('replacing an overlay detaches and disposes the prior one', () => {
const first = makeOverlay()
const second = makeOverlay()
ctx.viewport.setOverlay(first)
ctx.viewport.setOverlay(second)
expect(first.detach).toHaveBeenCalledOnce()
expect(first.dispose).toHaveBeenCalledOnce()
expect(second.attach).toHaveBeenCalledWith(ctx.sceneManager.scene)
expect(ctx.viewport.getOverlay()).toBe(second)
})
it('removeOverlay detaches and disposes the installed overlay', () => {
const overlay = makeOverlay()
ctx.viewport.setOverlay(overlay)
ctx.viewport.removeOverlay()
expect(overlay.detach).toHaveBeenCalledOnce()
expect(overlay.dispose).toHaveBeenCalledOnce()
expect(ctx.viewport.getOverlay()).toBeNull()
})
it('tickPerFrame forwards delta to the overlay before view-helper/controls update', () => {
const overlay = makeOverlay()
ctx.viewport.setOverlay(overlay)
const tick = (
ctx.viewport as unknown as {
tickPerFrame(delta: number): void
}
).tickPerFrame.bind(ctx.viewport)
tick(0.016)
expect(overlay.update).toHaveBeenCalledWith(0.016)
expect(ctx.viewHelperManager.update).toHaveBeenCalledWith(0.016)
expect(ctx.controlsManager.update).toHaveBeenCalledOnce()
})
it('toggleCamera notifies the overlay with the new orbit camera (when no POV)', () => {
const overlay = makeOverlay()
ctx.viewport.setOverlay(overlay)
overlay.onActiveCameraChange.mockClear()
ctx.viewport.toggleCamera('orthographic')
expect(overlay.onActiveCameraChange).toHaveBeenCalledWith(
ctx.cameraManager.activeCamera
)
})
it('toggleCamera does NOT notify the overlay while a POV camera is active', () => {
const overlay = makeOverlay()
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setOverlay(overlay)
ctx.viewport.setExternalActiveCamera(subjectCamera)
overlay.onActiveCameraChange.mockClear()
ctx.viewport.toggleCamera('orthographic')
expect(overlay.onActiveCameraChange).not.toHaveBeenCalled()
})
it('setExternalActiveCamera notifies the overlay with the new render camera', () => {
const overlay = makeOverlay()
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setOverlay(overlay)
overlay.onActiveCameraChange.mockClear()
ctx.viewport.setExternalActiveCamera(subjectCamera)
expect(overlay.onActiveCameraChange).toHaveBeenCalledWith(subjectCamera)
})
})
describe('applyTargetSize guards', () => {
function applyTargetSize(width: number, height: number): void {
;(
ctx.viewport as unknown as {
applyTargetSize(w: number, h: number): void
}
).applyTargetSize(width, height)
}
beforeEach(() => {
Object.assign(ctx.viewport, {
targetWidth: 0,
targetHeight: 0,
targetAspectRatio: 1
})
})
it('writes width / height / aspect when both inputs are positive finite', () => {
applyTargetSize(800, 400)
expect(ctx.viewport.targetWidth).toBe(800)
expect(ctx.viewport.targetHeight).toBe(400)
expect(ctx.viewport.targetAspectRatio).toBe(2)
})
it.for([
['zero width', 0, 100],
['zero height', 100, 0],
['negative width', -100, 100],
['negative height', 100, -100],
['NaN width', Number.NaN, 100],
['Infinity height', 100, Number.POSITIVE_INFINITY]
] as const)('rejects %s without touching prior state', ([, w, h]) => {
Object.assign(ctx.viewport, {
targetWidth: 800,
targetHeight: 400,
targetAspectRatio: 2
})
applyTargetSize(w, h)
expect(ctx.viewport.targetWidth).toBe(800)
expect(ctx.viewport.targetHeight).toBe(400)
expect(ctx.viewport.targetAspectRatio).toBe(2)
})
it('setTargetSize routes through the guard', () => {
Object.assign(ctx.viewport, {
targetWidth: 800,
targetHeight: 400,
targetAspectRatio: 2
})
ctx.viewport.setTargetSize(0, 0)
expect(ctx.viewport.targetAspectRatio).toBe(2)
})
})
describe('start / remove lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers()
Object.assign(ctx.viewport, {
hasStarted: false,
initialRenderTimer: null,
startAnimation: vi.fn(),
renderLoop: { stop: vi.fn() },
resizeObserver: null,
disposeContextMenuGuard: null,
renderer: {
forceContextLoss: vi.fn(),
dispose: vi.fn(),
domElement: Object.assign(document.createElement('canvas'), {
remove: vi.fn()
})
},
disposeManagers: vi.fn()
})
})
afterEach(() => {
vi.useRealTimers()
})
it('start schedules a deferred forceRender and remove clears it before the timer fires', () => {
ctx.viewport.start()
ctx.forceRender.mockClear()
ctx.viewport.remove()
vi.advanceTimersByTime(500)
expect(ctx.forceRender).not.toHaveBeenCalled()
})
it('the deferred forceRender does fire when remove is not called', () => {
ctx.viewport.start()
ctx.forceRender.mockClear()
vi.advanceTimersByTime(100)
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
})
})

View File

@@ -0,0 +1,440 @@
import * as THREE from 'three'
import type { CameraManager } from './CameraManager'
import type { ControlsManager } from './ControlsManager'
import type { EventManager } from './EventManager'
import type { LightingManager } from './LightingManager'
import type { SceneManager } from './SceneManager'
import type { ViewHelperManager } from './ViewHelperManager'
import type {
CameraState,
EventCallback,
Load3DOptions,
SceneOverlay
} from './interfaces'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
export type Viewport3dDeps = {
renderer: THREE.WebGLRenderer
eventManager: EventManager
sceneManager: SceneManager
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
viewHelperManager: ViewHelperManager
}
export class Viewport3d {
renderer: THREE.WebGLRenderer
protected clock: THREE.Clock
private renderLoop: RenderLoopHandle | null = null
private onContextMenuCallback?: (event: MouseEvent) => void
private getDimensionsCallback?: () => { width: number; height: number } | null
eventManager: EventManager
sceneManager: SceneManager
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
viewHelperManager: ViewHelperManager
STATUS_MOUSE_ON_NODE: boolean
STATUS_MOUSE_ON_SCENE: boolean
STATUS_MOUSE_ON_VIEWER: boolean
INITIAL_RENDER_DONE: boolean = false
targetWidth: number = 0
targetHeight: number = 0
targetAspectRatio: number = 1
isViewerMode: boolean = false
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private getZoomScaleCallback: (() => number) | undefined
private externalActiveCamera: THREE.Camera | null = null
private overlay: SceneOverlay | null = null
private initialRenderTimer: ReturnType<typeof setTimeout> | null = null
constructor(
container: Element | HTMLElement,
deps: Viewport3dDeps,
options: Load3DOptions = {}
) {
this.clock = new THREE.Clock()
this.isViewerMode = options.isViewerMode || false
this.onContextMenuCallback = options.onContextMenu
this.getDimensionsCallback = options.getDimensions
this.getZoomScaleCallback = options.getZoomScale
if (options.width !== undefined && options.height !== undefined) {
this.applyTargetSize(options.width, options.height)
}
this.renderer = deps.renderer
this.eventManager = deps.eventManager
this.sceneManager = deps.sceneManager
this.cameraManager = deps.cameraManager
this.controlsManager = deps.controlsManager
this.lightingManager = deps.lightingManager
this.viewHelperManager = deps.viewHelperManager
this.sceneManager.init()
this.cameraManager.init()
this.controlsManager.init()
this.lightingManager.init()
this.viewHelperManager.createViewHelper(container)
this.viewHelperManager.init()
this.STATUS_MOUSE_ON_NODE = false
this.STATUS_MOUSE_ON_SCENE = false
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.initResizeObserver(container)
}
start(): void {
if (this.hasStarted) return
this.hasStarted = true
this.handleResize()
this.startAnimation()
this.initialRenderTimer = setTimeout(() => {
this.initialRenderTimer = null
this.forceRender()
}, 100)
}
private hasStarted: boolean = false
private applyTargetSize(width: number, height: number): void {
if (!Number.isFinite(width) || !Number.isFinite(height)) return
if (width <= 0 || height <= 0) return
this.targetWidth = width
this.targetHeight = height
this.targetAspectRatio = width / height
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => {
this.handleResize()
})
this.resizeObserver.observe(container)
}
private initContextMenu(): void {
this.disposeContextMenuGuard = attachContextMenuGuard(
this.renderer.domElement,
(event) => this.onContextMenuCallback?.(event),
{ isDisabled: () => this.isViewerMode }
)
}
getEventManager(): EventManager {
return this.eventManager
}
getSceneManager(): SceneManager {
return this.sceneManager
}
getCameraManager(): CameraManager {
return this.cameraManager
}
getControlsManager(): ControlsManager {
return this.controlsManager
}
getLightingManager(): LightingManager {
return this.lightingManager
}
getViewHelperManager(): ViewHelperManager {
return this.viewHelperManager
}
getTargetSize(): { width: number; height: number } {
return {
width: this.targetWidth,
height: this.targetHeight
}
}
protected shouldMaintainAspectRatio(): boolean {
return this.isViewerMode || (this.targetWidth > 0 && this.targetHeight > 0)
}
forceRender(): void {
const delta = this.clock.getDelta()
this.tickPerFrame(delta)
this.renderMainScene()
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
this.INITIAL_RENDER_DONE = true
}
protected tickPerFrame(delta: number): void {
this.overlay?.update?.(delta)
this.viewHelperManager.update(delta)
this.controlsManager.update()
}
getRenderCamera(): THREE.Camera {
return this.externalActiveCamera ?? this.cameraManager.activeCamera
}
setExternalActiveCamera(camera: THREE.Camera | null): void {
if (this.externalActiveCamera === camera) return
this.externalActiveCamera = camera
if (camera) {
this.controlsManager.detach()
this.viewHelperManager.visibleViewHelper(false)
} else {
this.controlsManager.attach()
this.viewHelperManager.visibleViewHelper(true)
}
this.overlay?.onActiveCameraChange?.(this.getRenderCamera())
this.forceRender()
}
setOverlay(overlay: SceneOverlay): void {
if (this.overlay === overlay) return
if (this.overlay) {
this.overlay.detach()
this.overlay.dispose()
}
this.overlay = overlay
overlay.attach(this.sceneManager.scene)
overlay.onActiveCameraChange?.(this.getRenderCamera())
this.forceRender()
}
removeOverlay(): void {
if (!this.overlay) return
this.overlay.detach()
this.overlay.dispose()
this.overlay = null
this.forceRender()
}
getOverlay(): SceneOverlay | null {
return this.overlay
}
renderMainScene(): void {
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
if (dims) {
this.applyTargetSize(dims.width, dims.height)
}
}
if (this.shouldMaintainAspectRatio()) {
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
this.renderer.setClearColor(0x0a0a0a)
this.renderer.clear()
this.renderer.setViewport(offsetX, offsetY, width, height)
this.renderer.setScissor(offsetX, offsetY, width, height)
this.cameraManager.updateAspectRatio(width / height)
} else {
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
}
this.sceneManager.renderBackground()
this.renderer.render(this.sceneManager.scene, this.getRenderCamera())
}
resetViewport(): void {
const width = this.renderer.domElement.clientWidth
const height = this.renderer.domElement.clientHeight
this.renderer.setViewport(0, 0, width, height)
this.renderer.setScissor(0, 0, width, height)
this.renderer.setScissorTest(false)
}
protected startAnimation(): void {
this.renderLoop = startRenderLoop({
tick: () => {
const delta = this.clock.getDelta()
this.tickPerFrame(delta)
this.renderMainScene()
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
},
isActive: () => this.isActive()
})
}
updateStatusMouseOnNode(onNode: boolean): void {
this.STATUS_MOUSE_ON_NODE = onNode
}
updateStatusMouseOnScene(onScene: boolean): void {
this.STATUS_MOUSE_ON_SCENE = onScene
}
updateStatusMouseOnViewer(onViewer: boolean): void {
this.STATUS_MOUSE_ON_VIEWER = onViewer
}
isActive(): boolean {
return isLoad3dActive({
mouseOnNode: this.STATUS_MOUSE_ON_NODE,
mouseOnScene: this.STATUS_MOUSE_ON_SCENE,
mouseOnViewer: this.STATUS_MOUSE_ON_VIEWER,
recording: false,
initialRenderDone: this.INITIAL_RENDER_DONE,
animationPlaying: false
})
}
toggleCamera(cameraType?: 'perspective' | 'orthographic'): void {
this.cameraManager.toggleCamera(cameraType)
this.controlsManager.updateCamera(this.cameraManager.activeCamera)
this.onActiveCameraChanged()
this.viewHelperManager.recreateViewHelper()
if (!this.externalActiveCamera) {
this.overlay?.onActiveCameraChange?.(this.cameraManager.activeCamera)
}
this.handleResize()
}
protected onActiveCameraChanged(): void {}
getCurrentCameraType(): 'perspective' | 'orthographic' {
return this.cameraManager.getCurrentCameraType()
}
setCameraState(state: CameraState): void {
this.cameraManager.setCameraState(state)
this.forceRender()
}
getCameraState(): CameraState {
return this.cameraManager.getCameraState()
}
setTargetSize(width: number, height: number): void {
this.applyTargetSize(width, height)
this.handleResize()
}
addEventListener<T>(event: string, callback: EventCallback<T>): void {
this.eventManager.addEventListener(event, callback)
}
removeEventListener<T>(event: string, callback: EventCallback<T>): void {
this.eventManager.removeEventListener(event, callback)
}
refreshViewport(): void {
this.handleResize()
}
handleResize(): void {
const parentElement = this.renderer?.domElement?.parentElement
if (!parentElement) {
console.warn('Parent element not found')
return
}
const containerWidth = parentElement.clientWidth
const containerHeight = parentElement.clientHeight
const zoomScale = this.getZoomScaleCallback?.() ?? 1
this.renderer.setPixelRatio(Math.min(zoomScale, 3))
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
if (dims) {
this.applyTargetSize(dims.width, dims.height)
}
}
if (this.shouldMaintainAspectRatio()) {
const { width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(width, height)
this.sceneManager.handleResize(width, height)
} else {
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(containerWidth, containerHeight)
this.sceneManager.handleResize(containerWidth, containerHeight)
}
this.forceRender()
}
remove(): void {
if (this.initialRenderTimer) {
clearTimeout(this.initialRenderTimer)
this.initialRenderTimer = null
}
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
this.disposeContextMenuGuard?.()
this.disposeContextMenuGuard = null
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
const event = new Event('webglcontextlost', {
bubbles: true,
cancelable: true
})
canvas.dispatchEvent(event)
this.renderLoop?.stop()
this.renderLoop = null
this.disposeManagers()
this.renderer.dispose()
this.renderer.domElement.remove()
}
protected disposeManagers(): void {
if (this.overlay) {
this.overlay.detach()
this.overlay.dispose()
this.overlay = null
}
this.sceneManager.dispose()
this.cameraManager.dispose()
this.controlsManager.dispose()
this.lightingManager.dispose()
this.viewHelperManager.dispose()
}
}

View File

@@ -244,6 +244,14 @@ export interface LoadModelOptions {
silentOnNotFound?: boolean
}
export interface SceneOverlay {
attach(scene: THREE.Scene): void
detach(): void
update?(deltaSeconds: number): void
onActiveCameraChange?(camera: THREE.Camera): void
dispose(): void
}
export interface LoaderManagerInterface {
init(): void
dispose(): void