mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-19 12:59:57 +00:00
Compare commits
2 Commits
update-ing
...
refactor/v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8347c3b469 | ||
|
|
ca57fe0b95 |
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
433
src/extensions/core/load3d/Viewport3d.test.ts
Normal file
433
src/extensions/core/load3d/Viewport3d.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
440
src/extensions/core/load3d/Viewport3d.ts
Normal file
440
src/extensions/core/load3d/Viewport3d.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user