import * as THREE from 'three' import { AnimationManager } from './AnimationManager' import { CameraManager } from './CameraManager' import { ControlsManager } from './ControlsManager' import { EventManager } from './EventManager' import { LightingManager } from './LightingManager' import { LoaderManager } from './LoaderManager' import { ModelExporter } from './ModelExporter' import { RecordingManager } from './RecordingManager' import { SceneManager } from './SceneManager' import { SceneModelManager } from './SceneModelManager' import { ViewHelperManager } from './ViewHelperManager' import { type CameraState, type CaptureResult, type EventCallback, type Load3DOptions, type MaterialMode, type UpDirection } from './interfaces' class Load3d { renderer: THREE.WebGLRenderer protected clock: THREE.Clock protected animationFrameId: number | null = null private loadingPromise: Promise | 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 loaderManager: LoaderManager modelManager: SceneModelManager recordingManager: RecordingManager animationManager: AnimationManager 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 // Context menu tracking private rightMouseDownX: number = 0 private rightMouseDownY: number = 0 private rightMouseMoved: boolean = false private readonly dragThreshold: number = 5 private contextMenuAbortController: AbortController | null = null private resizeObserver: ResizeObserver | null = null constructor(container: Element | HTMLElement, options: Load3DOptions = {}) { this.clock = new THREE.Clock() this.isViewerMode = options.isViewerMode || false this.onContextMenuCallback = options.onContextMenu this.getDimensionsCallback = options.getDimensions if (options.width && options.height) { this.targetWidth = options.width this.targetHeight = options.height this.targetAspectRatio = options.width / options.height } this.renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }) this.renderer.setSize(300, 300) this.renderer.setClearColor(0x282828) this.renderer.autoClear = false this.renderer.outputColorSpace = THREE.SRGBColorSpace this.renderer.domElement.classList.add( 'absolute', 'inset-0', 'h-full', 'w-full', 'outline-none' ) container.appendChild(this.renderer.domElement) this.eventManager = new EventManager() this.sceneManager = new SceneManager( this.renderer, this.getActiveCamera.bind(this), this.getControls.bind(this), this.eventManager ) this.cameraManager = new CameraManager(this.renderer, this.eventManager) this.controlsManager = new ControlsManager( this.renderer, this.cameraManager.activeCamera, this.eventManager ) this.cameraManager.setControls(this.controlsManager.controls) this.lightingManager = new LightingManager( this.sceneManager.scene, this.eventManager ) this.viewHelperManager = new ViewHelperManager( this.renderer, this.getActiveCamera.bind(this), this.getControls.bind(this), this.eventManager ) this.modelManager = new SceneModelManager( this.sceneManager.scene, this.renderer, this.eventManager, this.getActiveCamera.bind(this), this.setupCamera.bind(this) ) this.loaderManager = new LoaderManager(this.modelManager, this.eventManager) this.recordingManager = new RecordingManager( this.sceneManager.scene, this.renderer, this.eventManager ) this.animationManager = new AnimationManager(this.eventManager) this.sceneManager.init() this.cameraManager.init() this.controlsManager.init() this.lightingManager.init() this.loaderManager.init() this.animationManager.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() setTimeout(() => { this.forceRender() }, 100) } private initResizeObserver(container: Element | HTMLElement): void { this.resizeObserver = new ResizeObserver(() => { this.handleResize() this.forceRender() }) this.resizeObserver.observe(container) } /** * Initialize context menu on the Three.js canvas * Detects right-click vs right-drag to show menu only on click */ private initContextMenu(): void { const canvas = this.renderer.domElement this.contextMenuAbortController = new AbortController() const { signal } = this.contextMenuAbortController const mousedownHandler = (e: MouseEvent) => { if (e.button === 2) { this.rightMouseDownX = e.clientX this.rightMouseDownY = e.clientY this.rightMouseMoved = false } } const mousemoveHandler = (e: MouseEvent) => { if (e.buttons === 2) { const dx = Math.abs(e.clientX - this.rightMouseDownX) const dy = Math.abs(e.clientY - this.rightMouseDownY) if (dx > this.dragThreshold || dy > this.dragThreshold) { this.rightMouseMoved = true } } } const contextmenuHandler = (e: MouseEvent) => { if (this.isViewerMode) return const dx = Math.abs(e.clientX - this.rightMouseDownX) const dy = Math.abs(e.clientY - this.rightMouseDownY) const wasDragging = this.rightMouseMoved || dx > this.dragThreshold || dy > this.dragThreshold this.rightMouseMoved = false if (wasDragging) { return } e.preventDefault() e.stopPropagation() this.showNodeContextMenu(e) } canvas.addEventListener('mousedown', mousedownHandler, { signal }) canvas.addEventListener('mousemove', mousemoveHandler, { signal }) canvas.addEventListener('contextmenu', contextmenuHandler, { signal }) } private showNodeContextMenu(event: MouseEvent): void { if (this.onContextMenuCallback) { this.onContextMenuCallback(event) } } 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 } 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() 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 } 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 containerAspectRatio = containerWidth / containerHeight let renderWidth: number let renderHeight: number let offsetX: number = 0 let offsetY: number = 0 if (containerAspectRatio > this.targetAspectRatio) { renderHeight = containerHeight renderWidth = renderHeight * this.targetAspectRatio offsetX = (containerWidth - renderWidth) / 2 } else { renderWidth = containerWidth renderHeight = renderWidth / this.targetAspectRatio offsetY = (containerHeight - renderHeight) / 2 } 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, renderWidth, renderHeight) this.renderer.setScissor(offsetX, offsetY, renderWidth, renderHeight) const renderAspectRatio = renderWidth / renderHeight this.cameraManager.updateAspectRatio(renderAspectRatio) } 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 getActiveCamera(): THREE.Camera { return this.cameraManager.activeCamera } private getControls() { return this.controlsManager.controls } private setupCamera(size: THREE.Vector3): void { this.cameraManager.setupForModel(size) } private startAnimation(): void { const animate = () => { this.animationFrameId = requestAnimationFrame(animate) if (!this.isActive()) { return } 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) } } animate() } 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 ( this.STATUS_MOUSE_ON_NODE || this.STATUS_MOUSE_ON_SCENE || this.STATUS_MOUSE_ON_VIEWER || this.isRecording() || !this.INITIAL_RENDER_DONE || this.animationManager.isAnimationPlaying ) } async exportModel(format: string): Promise { if (!this.modelManager.currentModel) { throw new Error('No model to export') } const exportMessage = `Exporting as ${format.toUpperCase()}...` this.eventManager.emitEvent('exportLoadingStart', exportMessage) try { const model = this.modelManager.currentModel.clone() const originalFileName = this.modelManager.originalFileName || 'model' const filename = `${originalFileName}.${format}` const originalURL = this.modelManager.originalURL await new Promise((resolve) => setTimeout(resolve, 10)) switch (format) { case 'glb': await ModelExporter.exportGLB(model, filename, originalURL) break case 'obj': await ModelExporter.exportOBJ(model, filename, originalURL) break case 'stl': ;(await ModelExporter.exportSTL(model, filename), originalURL) break default: throw new Error(`Unsupported export format: ${format}`) } await new Promise((resolve) => setTimeout(resolve, 10)) } catch (error) { console.error(`Error exporting model as ${format}:`, error) throw error } finally { this.eventManager.emitEvent('exportLoadingEnd', null) } } setBackgroundColor(color: string): void { this.sceneManager.setBackgroundColor(color) this.forceRender() } async setBackgroundImage(uploadPath: string): Promise { await this.sceneManager.setBackgroundImage(uploadPath) if ( this.sceneManager.backgroundTexture && this.sceneManager.backgroundMesh ) { const containerWidth = this.renderer.domElement.clientWidth const containerHeight = this.renderer.domElement.clientHeight if (this.shouldMaintainAspectRatio()) { const containerAspectRatio = containerWidth / containerHeight let renderWidth: number let renderHeight: number if (containerAspectRatio > this.targetAspectRatio) { renderHeight = containerHeight renderWidth = renderHeight * this.targetAspectRatio } else { renderWidth = containerWidth renderHeight = renderWidth / this.targetAspectRatio } this.sceneManager.updateBackgroundSize( this.sceneManager.backgroundTexture, this.sceneManager.backgroundMesh, renderWidth, renderHeight ) } else { // No aspect ratio constraints: fill container this.sceneManager.updateBackgroundSize( this.sceneManager.backgroundTexture, this.sceneManager.backgroundMesh, containerWidth, containerHeight ) } } this.forceRender() } removeBackgroundImage(): void { this.sceneManager.removeBackgroundImage() this.forceRender() } toggleGrid(showGrid: boolean): void { this.sceneManager.toggleGrid(showGrid) this.forceRender() } setBackgroundRenderMode(mode: 'tiled' | 'panorama'): void { this.sceneManager.setBackgroundRenderMode(mode) this.forceRender() } toggleCamera(cameraType?: 'perspective' | 'orthographic'): void { this.cameraManager.toggleCamera(cameraType) this.controlsManager.updateCamera(this.cameraManager.activeCamera) this.viewHelperManager.recreateViewHelper() this.handleResize() this.forceRender() } 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() } setMaterialMode(mode: MaterialMode): void { this.modelManager.setMaterialMode(mode) this.forceRender() } async loadModel(url: string, originalFileName?: string): Promise { if (this.loadingPromise) { try { await this.loadingPromise } catch (e) {} } this.loadingPromise = this._loadModelInternal(url, originalFileName) return this.loadingPromise } private async _loadModelInternal( url: string, originalFileName?: string ): Promise { this.cameraManager.reset() this.controlsManager.reset() this.modelManager.clearModel() this.animationManager.dispose() await this.loaderManager.loadModel(url, originalFileName) // Auto-detect and setup animations if present if (this.modelManager.currentModel) { this.animationManager.setupModelAnimations( this.modelManager.currentModel, this.modelManager.originalModel ) } this.handleResize() this.forceRender() this.loadingPromise = null } isSplatModel(): boolean { return this.modelManager.containsSplatMesh() } isPlyModel(): boolean { return this.modelManager.originalModel instanceof THREE.BufferGeometry } clearModel(): void { this.animationManager.dispose() this.modelManager.clearModel() this.forceRender() } setUpDirection(direction: UpDirection): void { this.modelManager.setUpDirection(direction) this.forceRender() } setLightIntensity(intensity: number): void { this.lightingManager.setLightIntensity(intensity) this.forceRender() } setTargetSize(width: number, height: number): void { this.targetWidth = width this.targetHeight = height this.targetAspectRatio = width / height this.handleResize() this.forceRender() } addEventListener(event: string, callback: EventCallback): void { this.eventManager.addEventListener(event, callback) } removeEventListener(event: string, callback: EventCallback): void { this.eventManager.removeEventListener(event, callback) } refreshViewport(): void { this.handleResize() this.forceRender() } 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 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 containerAspectRatio = containerWidth / containerHeight let renderWidth: number let renderHeight: number if (containerAspectRatio > this.targetAspectRatio) { renderHeight = containerHeight renderWidth = renderHeight * this.targetAspectRatio } else { renderWidth = containerWidth renderHeight = renderWidth / this.targetAspectRatio } this.renderer.setSize(containerWidth, containerHeight) this.cameraManager.handleResize(renderWidth, renderHeight) this.sceneManager.handleResize(renderWidth, renderHeight) } 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 { return this.sceneManager.captureScene(width, height) } public async startRecording(): Promise { this.viewHelperManager.visibleViewHelper(false) return this.recordingManager.startRecording( this.targetWidth, this.targetHeight ) } public stopRecording(): void { this.viewHelperManager.visibleViewHelper(true) this.recordingManager.stopRecording() this.eventManager.emitEvent('recordingStatusChange', false) } public isRecording(): boolean { return this.recordingManager.getIsRecording() } public getRecordingDuration(): number { return this.recordingManager.getRecordingDuration() } public getRecordingData(): string | null { return this.recordingManager.getRecordingData() } public exportRecording(filename?: string): void { this.recordingManager.exportRecording(filename) } public clearRecording(): void { this.recordingManager.clearRecording() } // Animation methods public setAnimationSpeed(speed: number): void { this.animationManager.setAnimationSpeed(speed) } public updateSelectedAnimation(index: number): void { this.animationManager.updateSelectedAnimation(index) } public toggleAnimation(play?: boolean): void { this.animationManager.toggleAnimation(play) } public hasAnimations(): boolean { return this.animationManager.animationClips.length > 0 } public hasSkeleton(): boolean { return this.modelManager.hasSkeleton() } public setShowSkeleton(show: boolean): void { this.modelManager.setShowSkeleton(show) this.forceRender() } public getShowSkeleton(): boolean { return this.modelManager.showSkeleton } public getAnimationTime(): number { return this.animationManager.getAnimationTime() } public getAnimationDuration(): number { return this.animationManager.getAnimationDuration() } public setAnimationTime(time: number): void { this.animationManager.setAnimationTime(time) this.forceRender() } public async captureThumbnail( width: number = 256, height: number = 256 ): Promise { if (!this.modelManager.currentModel) { throw new Error('No model loaded for thumbnail capture') } const savedState = this.cameraManager.getCameraState() const savedCameraType = this.cameraManager.getCurrentCameraType() const savedGridVisible = this.sceneManager.gridHelper.visible try { this.sceneManager.gridHelper.visible = false if (savedCameraType !== 'perspective') { this.cameraManager.toggleCamera('perspective') } const box = new THREE.Box3().setFromObject(this.modelManager.currentModel) const size = box.getSize(new THREE.Vector3()) const center = box.getCenter(new THREE.Vector3()) const maxDim = Math.max(size.x, size.y, size.z) const distance = maxDim * 1.5 const cameraPosition = new THREE.Vector3( center.x - distance * 0.8, center.y + distance * 0.4, center.z + distance * 0.3 ) this.cameraManager.perspectiveCamera.position.copy(cameraPosition) this.cameraManager.perspectiveCamera.lookAt(center) this.cameraManager.perspectiveCamera.updateProjectionMatrix() if (this.controlsManager.controls) { this.controlsManager.controls.target.copy(center) this.controlsManager.controls.update() } const result = await this.sceneManager.captureScene(width, height) return result.scene } finally { this.sceneManager.gridHelper.visible = savedGridVisible if (savedCameraType !== 'perspective') { this.cameraManager.toggleCamera(savedCameraType) } this.cameraManager.setCameraState(savedState) this.controlsManager.controls?.update() } } public remove(): void { if (this.resizeObserver) { this.resizeObserver.disconnect() this.resizeObserver = null } if (this.contextMenuAbortController) { this.contextMenuAbortController.abort() this.contextMenuAbortController = null } this.renderer.forceContextLoss() const canvas = this.renderer.domElement const event = new Event('webglcontextlost', { bubbles: true, cancelable: true }) canvas.dispatchEvent(event) if (this.animationFrameId !== null) { cancelAnimationFrame(this.animationFrameId) } this.sceneManager.dispose() this.cameraManager.dispose() this.controlsManager.dispose() this.lightingManager.dispose() this.viewHelperManager.dispose() this.loaderManager.dispose() this.modelManager.dispose() this.recordingManager.dispose() this.animationManager.dispose() this.renderer.dispose() this.renderer.domElement.remove() } } export default Load3d