import * as THREE from 'three' import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph' import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' 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 { NodeStorage } from './NodeStorage' import { PreviewManager } from './PreviewManager' import { RecordingManager } from './RecordingManager' import { SceneManager } from './SceneManager' import { SceneModelManager } from './SceneModelManager' import { ViewHelperManager } from './ViewHelperManager' import { type CameraState, type CaptureResult, type Load3DOptions, type MaterialMode, type UpDirection } from './interfaces' import { app } from '@/scripts/app' class Load3d { renderer: THREE.WebGLRenderer protected clock: THREE.Clock protected animationFrameId: number | null = null node: LGraphNode eventManager: EventManager nodeStorage: NodeStorage sceneManager: SceneManager cameraManager: CameraManager controlsManager: ControlsManager lightingManager: LightingManager viewHelperManager: ViewHelperManager previewManager: PreviewManager loaderManager: LoaderManager modelManager: SceneModelManager recordingManager: RecordingManager STATUS_MOUSE_ON_NODE: boolean STATUS_MOUSE_ON_SCENE: boolean STATUS_MOUSE_ON_VIEWER: boolean INITIAL_RENDER_DONE: boolean = false targetWidth: number = 512 targetHeight: number = 512 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 constructor( container: Element | HTMLElement, options: Load3DOptions = { node: {} as LGraphNode, inputSpec: {} as CustomInputSpec } ) { this.node = options.node || ({} as LGraphNode) this.clock = new THREE.Clock() this.isViewerMode = options.isViewerMode || false const widthWidget = this.node.widgets?.find((w) => w.name === 'width') const heightWidget = this.node.widgets?.find((w) => w.name === 'height') if (widthWidget && heightWidget) { this.targetWidth = widthWidget.value as number this.targetHeight = heightWidget.value as number this.targetAspectRatio = this.targetWidth / this.targetHeight } 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 container.appendChild(this.renderer.domElement) this.eventManager = new EventManager() this.nodeStorage = new NodeStorage(this.node) 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.nodeStorage ) this.controlsManager = new ControlsManager( this.renderer, this.cameraManager.activeCamera, this.eventManager, this.nodeStorage ) 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.nodeStorage ) this.previewManager = new PreviewManager( this.sceneManager.scene, this.getActiveCamera.bind(this), this.getControls.bind(this), () => this.renderer, this.eventManager, this.sceneManager.backgroundScene, this.sceneManager.backgroundCamera ) if (options.disablePreview) { this.previewManager.togglePreview(false) } this.modelManager = new SceneModelManager( this.sceneManager.scene, this.renderer, this.eventManager, this.getActiveCamera.bind(this), this.setupCamera.bind(this), options ) this.loaderManager = new LoaderManager(this.modelManager, this.eventManager) this.recordingManager = new RecordingManager( this.sceneManager.scene, this.renderer, this.eventManager ) this.sceneManager.init() this.cameraManager.init() this.controlsManager.init() this.lightingManager.init() this.loaderManager.init() this.loaderManager.init() this.viewHelperManager.createViewHelper(container) this.viewHelperManager.init() if (options && !options.inputSpec?.isPreview) { this.previewManager.createCapturePreview(container) this.previewManager.init() } this.STATUS_MOUSE_ON_NODE = false this.STATUS_MOUSE_ON_SCENE = false this.STATUS_MOUSE_ON_VIEWER = false this.initContextMenu() this.handleResize() this.startAnimation() setTimeout(() => { this.forceRender() }, 100) } /** * 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) => { const wasDragging = this.rightMouseMoved 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 { const menuOptions = app.canvas.getNodeMenuOptions(this.node) new LiteGraph.ContextMenu(menuOptions, { event, title: this.node.type, extra: this.node }) } getEventManager(): EventManager { return this.eventManager } getNodeStorage(): NodeStorage { return this.nodeStorage } 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 } getPreviewManager(): PreviewManager { return this.previewManager } getLoaderManager(): LoaderManager { return this.loaderManager } getModelManager(): SceneModelManager { return this.modelManager } getRecordingManager(): RecordingManager { return this.recordingManager } forceRender(): void { const delta = this.clock.getDelta() this.viewHelperManager.update(delta) this.controlsManager.update() this.renderMainScene() if (this.previewManager.showPreview) { this.previewManager.renderPreview() } 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.isViewerMode) { 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 { 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.viewHelperManager.update(delta) this.controlsManager.update() this.renderMainScene() if (this.previewManager.showPreview) { this.previewManager.renderPreview() } 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 ) } 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.previewManager.setPreviewBackgroundColor(color) this.forceRender() } async setBackgroundImage(uploadPath: string): Promise { await this.sceneManager.setBackgroundImage(uploadPath) this.previewManager.updateBackgroundTexture( this.sceneManager.backgroundTexture ) if ( this.isViewerMode && this.sceneManager.backgroundTexture && this.sceneManager.backgroundMesh ) { const containerWidth = this.renderer.domElement.clientWidth const containerHeight = this.renderer.domElement.clientHeight 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 ) } this.forceRender() } removeBackgroundImage(): void { this.sceneManager.removeBackgroundImage() this.previewManager.setPreviewBackgroundColor( this.sceneManager.currentBackgroundColor ) this.forceRender() } toggleGrid(showGrid: boolean): void { this.sceneManager.toggleGrid(showGrid) 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() } setEdgeThreshold(threshold: number): void { this.modelManager.setEdgeThreshold(threshold) this.forceRender() } setMaterialMode(mode: MaterialMode): void { this.modelManager.setMaterialMode(mode) this.forceRender() } async loadModel(url: string, originalFileName?: string): Promise { this.cameraManager.reset() this.controlsManager.reset() this.modelManager.reset() await this.loaderManager.loadModel(url, originalFileName) this.handleResize() this.forceRender() } clearModel(): void { 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() } togglePreview(showPreview: boolean): void { this.previewManager.togglePreview(showPreview) this.forceRender() } setTargetSize(width: number, height: number): void { this.targetWidth = width this.targetHeight = height this.targetAspectRatio = width / height this.previewManager.setTargetSize(width, height) this.forceRender() } addEventListener(event: string, callback: (data?: any) => void): void { this.eventManager.addEventListener(event, callback) } removeEventListener(event: string, callback: (data?: any) => void): 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.isViewerMode) { 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.cameraManager.handleResize(renderWidth, renderHeight) this.sceneManager.handleResize(renderWidth, renderHeight) } else { this.cameraManager.handleResize(containerWidth, containerHeight) this.sceneManager.handleResize(containerWidth, containerHeight) } this.renderer.setSize(containerWidth, containerHeight) this.previewManager.handleResize() this.forceRender() } captureScene(width: number, height: number): Promise { return this.sceneManager.captureScene(width, height) } loadNodeProperty(name: string, defaultValue: any) { return this.nodeStorage.loadNodeProperty(name, defaultValue) } public async startRecording(): Promise { this.viewHelperManager.visibleViewHelper(false) return this.recordingManager.startRecording() } 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() } public remove(): void { 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.previewManager.dispose() this.loaderManager.dispose() this.modelManager.dispose() this.recordingManager.dispose() this.renderer.dispose() this.renderer.domElement.remove() } } export default Load3d