diff --git a/src/components/load3d/Load3DAnimation.vue b/src/components/load3d/Load3DAnimation.vue index e96ea8508..753226df3 100644 --- a/src/components/load3d/Load3DAnimation.vue +++ b/src/components/load3d/Load3DAnimation.vue @@ -63,8 +63,8 @@ import { computed, ref } from 'vue' import Load3DAnimationControls from '@/components/load3d/Load3DAnimationControls.vue' import Load3DAnimationScene from '@/components/load3d/Load3DAnimationScene.vue' import Load3DControls from '@/components/load3d/Load3DControls.vue' -import type { AnimationItem } from '@/extensions/core/load3d/Load3dAnimation' import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' +import { AnimationItem } from '@/extensions/core/load3d/interfaces' const props = defineProps<{ node: any diff --git a/src/components/load3d/Load3DScene.vue b/src/components/load3d/Load3DScene.vue index c45ad59bf..589494b4e 100644 --- a/src/components/load3d/Load3DScene.vue +++ b/src/components/load3d/Load3DScene.vue @@ -1,11 +1,14 @@ + + diff --git a/src/extensions/core/load3d.ts b/src/extensions/core/load3d.ts index d9720db5f..25895ff6f 100644 --- a/src/extensions/core/load3d.ts +++ b/src/extensions/core/load3d.ts @@ -174,6 +174,8 @@ app.registerExtension({ Load3dUtils.uploadTempImage(maskData, 'scene_mask') ]) + load3d.handleResize() + return { image: `threed/${data.name} [temp]`, mask: `threed/${dataMask.name} [temp]` @@ -345,6 +347,8 @@ app.registerExtension({ Load3dUtils.uploadTempImage(maskData, 'scene_mask') ]) + load3d.handleResize() + return { image: `threed/${data.name} [temp]`, mask: `threed/${dataMask.name} [temp]` diff --git a/src/extensions/core/load3d/AnimationManager.ts b/src/extensions/core/load3d/AnimationManager.ts new file mode 100644 index 000000000..f3efd220e --- /dev/null +++ b/src/extensions/core/load3d/AnimationManager.ts @@ -0,0 +1,161 @@ +import * as THREE from 'three' + +import { + AnimationItem, + AnimationManagerInterface, + EventManagerInterface +} from '@/extensions/core/load3d/interfaces' + +export class AnimationManager implements AnimationManagerInterface { + currentAnimation: THREE.AnimationMixer | null = null + animationActions: THREE.AnimationAction[] = [] + animationClips: THREE.AnimationClip[] = [] + selectedAnimationIndex: number = 0 + isAnimationPlaying: boolean = false + animationSpeed: number = 1.0 + + private eventManager: EventManagerInterface + private getCurrentModel: () => THREE.Object3D | null + + constructor( + eventManager: EventManagerInterface, + getCurrentModel: () => THREE.Object3D | null + ) { + this.eventManager = eventManager + this.getCurrentModel = getCurrentModel + } + + init(): void {} + + dispose(): void { + if (this.currentAnimation) { + this.animationActions.forEach((action) => { + action.stop() + }) + this.currentAnimation = null + } + this.animationActions = [] + this.animationClips = [] + this.selectedAnimationIndex = 0 + this.isAnimationPlaying = false + this.animationSpeed = 1.0 + + this.eventManager.emitEvent('animationListChange', []) + } + + setupModelAnimations(model: THREE.Object3D, originalModel: any): void { + if (this.currentAnimation) { + this.currentAnimation.stopAllAction() + this.animationActions = [] + } + + let animations: THREE.AnimationClip[] = [] + if (model.animations?.length > 0) { + animations = model.animations + } else if (originalModel && 'animations' in originalModel) { + animations = originalModel.animations + } + + if (animations.length > 0) { + this.animationClips = animations + if (model.type === 'Scene') { + this.currentAnimation = new THREE.AnimationMixer(model) + } else { + this.currentAnimation = new THREE.AnimationMixer( + this.getCurrentModel()! + ) + } + + if (this.animationClips.length > 0) { + this.updateSelectedAnimation(0) + } + } + + this.updateAnimationList() + } + + updateAnimationList(): void { + let updatedAnimationList: AnimationItem[] = [] + + if (this.animationClips.length > 0) { + updatedAnimationList = this.animationClips.map((clip, index) => ({ + name: clip.name || `Animation ${index + 1}`, + index + })) + } + + this.eventManager.emitEvent('animationListChange', updatedAnimationList) + } + + setAnimationSpeed(speed: number): void { + this.animationSpeed = speed + this.animationActions.forEach((action) => { + action.setEffectiveTimeScale(speed) + }) + } + + updateSelectedAnimation(index: number): void { + if ( + !this.currentAnimation || + !this.animationClips || + index >= this.animationClips.length + ) { + console.warn('Invalid animation update request') + return + } + + this.animationActions.forEach((action) => { + action.stop() + }) + this.currentAnimation.stopAllAction() + this.animationActions = [] + + this.selectedAnimationIndex = index + const clip = this.animationClips[index] + + const action = this.currentAnimation.clipAction(clip) + + action.setEffectiveTimeScale(this.animationSpeed) + + action.reset() + action.clampWhenFinished = false + action.loop = THREE.LoopRepeat + + if (this.isAnimationPlaying) { + action.play() + } else { + action.play() + action.paused = true + } + + this.animationActions = [action] + } + + toggleAnimation(play?: boolean): void { + if (!this.currentAnimation || this.animationActions.length === 0) { + console.warn('No animation to toggle') + return + } + + this.isAnimationPlaying = play ?? !this.isAnimationPlaying + + this.animationActions.forEach((action) => { + if (this.isAnimationPlaying) { + action.paused = false + if (action.time === 0 || action.time === action.getClip().duration) { + action.reset() + } + } else { + action.paused = true + } + }) + } + + update(delta: number): void { + if (this.currentAnimation && this.isAnimationPlaying) { + this.currentAnimation.update(delta) + } + } + + reset(): void {} +} diff --git a/src/extensions/core/load3d/CameraManager.ts b/src/extensions/core/load3d/CameraManager.ts new file mode 100644 index 000000000..e876102c5 --- /dev/null +++ b/src/extensions/core/load3d/CameraManager.ts @@ -0,0 +1,234 @@ +import * as THREE from 'three' +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' + +import { + CameraManagerInterface, + CameraState, + CameraType, + EventManagerInterface, + NodeStorageInterface +} from './interfaces' + +export class CameraManager implements CameraManagerInterface { + perspectiveCamera: THREE.PerspectiveCamera + orthographicCamera: THREE.OrthographicCamera + activeCamera: THREE.Camera + + private renderer: THREE.WebGLRenderer + private eventManager: EventManagerInterface + private nodeStorage: NodeStorageInterface + + private controls: OrbitControls | null = null + + DEFAULT_DISTANCE = 10 + DEFAULT_LOOK_AT = 0 + + DEFAULT_CAMERA = { + near: 0.01, + far: 10000 + } + + DEFAULT_PERSPECTIVE_CAMERA = { + fov: 75, + aspect: 1 + } + + DEFAULT_FRUSTUM_SIZE = 10 + + DEFAULT_ORTHOGRAPHIC_CAMERA = { + left: -this.DEFAULT_FRUSTUM_SIZE / 2, + right: this.DEFAULT_FRUSTUM_SIZE / 2, + top: this.DEFAULT_FRUSTUM_SIZE / 2, + bottom: -this.DEFAULT_FRUSTUM_SIZE / 2 + } + + constructor( + renderer: THREE.WebGLRenderer, + eventManager: EventManagerInterface, + nodeStorage: NodeStorageInterface + ) { + this.renderer = renderer + this.eventManager = eventManager + this.nodeStorage = nodeStorage + + this.perspectiveCamera = new THREE.PerspectiveCamera( + this.DEFAULT_PERSPECTIVE_CAMERA.fov, + this.DEFAULT_PERSPECTIVE_CAMERA.aspect, + this.DEFAULT_CAMERA.near, + this.DEFAULT_CAMERA.far + ) + + this.orthographicCamera = new THREE.OrthographicCamera( + this.DEFAULT_ORTHOGRAPHIC_CAMERA.left, + this.DEFAULT_ORTHOGRAPHIC_CAMERA.right, + this.DEFAULT_ORTHOGRAPHIC_CAMERA.top, + this.DEFAULT_ORTHOGRAPHIC_CAMERA.bottom, + this.DEFAULT_CAMERA.near, + this.DEFAULT_CAMERA.far + ) + + this.reset() + + this.activeCamera = this.perspectiveCamera + } + + init(): void {} + + dispose(): void {} + + setControls(controls: OrbitControls): void { + this.controls = controls + + if (this.controls) { + this.controls.addEventListener('end', () => { + this.nodeStorage.storeNodeProperty('Camera Info', this.getCameraState()) + }) + } + } + + getCurrentCameraType(): CameraType { + return this.activeCamera === this.perspectiveCamera + ? 'perspective' + : 'orthographic' + } + + toggleCamera(cameraType?: CameraType): void { + const oldCamera = this.activeCamera + + const position = oldCamera.position.clone() + const rotation = oldCamera.rotation.clone() + const target = this.controls?.target.clone() || new THREE.Vector3() + + if (!cameraType) { + this.activeCamera = + oldCamera === this.perspectiveCamera + ? this.orthographicCamera + : this.perspectiveCamera + } else { + this.activeCamera = + cameraType === 'perspective' + ? this.perspectiveCamera + : this.orthographicCamera + + if (oldCamera === this.activeCamera) { + return + } + } + + this.activeCamera.position.copy(position) + this.activeCamera.rotation.copy(rotation) + + if (this.controls) { + this.controls.object = this.activeCamera + this.controls.target.copy(target) + this.controls.update() + } + + this.eventManager.emitEvent('cameraTypeChange', cameraType) + } + + setFOV(fov: number): void { + if (this.activeCamera === this.perspectiveCamera) { + this.perspectiveCamera.fov = fov + this.perspectiveCamera.updateProjectionMatrix() + } + + this.eventManager.emitEvent('fovChange', fov) + } + + getCameraState(): CameraState { + return { + position: this.activeCamera.position.clone(), + target: this.controls?.target.clone() || new THREE.Vector3(), + zoom: + this.activeCamera instanceof THREE.OrthographicCamera + ? this.activeCamera.zoom + : (this.activeCamera as THREE.PerspectiveCamera).zoom, + cameraType: this.getCurrentCameraType() + } + } + + setCameraState(state: CameraState): void { + this.activeCamera.position.copy(state.position) + + this.controls?.target.copy(state.target) + + if (this.activeCamera instanceof THREE.OrthographicCamera) { + this.activeCamera.zoom = state.zoom + this.activeCamera.updateProjectionMatrix() + } else if (this.activeCamera instanceof THREE.PerspectiveCamera) { + this.activeCamera.zoom = state.zoom + this.activeCamera.updateProjectionMatrix() + } + + this.controls?.update() + } + + handleResize(width: number, height: number): void { + if (this.activeCamera === this.perspectiveCamera) { + this.perspectiveCamera.aspect = width / height + this.perspectiveCamera.updateProjectionMatrix() + } else { + const frustumSize = 10 + const aspect = width / height + this.orthographicCamera.left = (-frustumSize * aspect) / 2 + this.orthographicCamera.right = (frustumSize * aspect) / 2 + this.orthographicCamera.top = frustumSize / 2 + this.orthographicCamera.bottom = -frustumSize / 2 + this.orthographicCamera.updateProjectionMatrix() + } + } + + setupForModel(size: THREE.Vector3): void { + const distance = Math.max(size.x, size.z) * 2 + const height = size.y * 2 + + this.perspectiveCamera.position.set(distance, height, distance) + this.orthographicCamera.position.set(distance, height, distance) + + if (this.activeCamera === this.perspectiveCamera) { + this.perspectiveCamera.lookAt(0, size.y / 2, 0) + this.perspectiveCamera.updateProjectionMatrix() + } else { + const frustumSize = Math.max(size.x, size.y, size.z) * 2 + const aspect = this.perspectiveCamera.aspect + this.orthographicCamera.left = (-frustumSize * aspect) / 2 + this.orthographicCamera.right = (frustumSize * aspect) / 2 + this.orthographicCamera.top = frustumSize / 2 + this.orthographicCamera.bottom = -frustumSize / 2 + this.orthographicCamera.lookAt(0, size.y / 2, 0) + this.orthographicCamera.updateProjectionMatrix() + } + + this.controls?.target.set(0, size.y / 2, 0) + this.controls?.update() + } + + reset(): void { + this.perspectiveCamera.position.set( + this.DEFAULT_DISTANCE, + this.DEFAULT_DISTANCE, + this.DEFAULT_DISTANCE + ) + + this.orthographicCamera.position.set( + this.DEFAULT_DISTANCE, + this.DEFAULT_DISTANCE, + this.DEFAULT_DISTANCE + ) + + this.perspectiveCamera.lookAt( + this.DEFAULT_LOOK_AT, + this.DEFAULT_LOOK_AT, + this.DEFAULT_LOOK_AT + ) + this.orthographicCamera.lookAt( + this.DEFAULT_LOOK_AT, + this.DEFAULT_LOOK_AT, + this.DEFAULT_LOOK_AT + ) + + this.perspectiveCamera.updateProjectionMatrix() + this.orthographicCamera.updateProjectionMatrix() + } +} diff --git a/src/extensions/core/load3d/ControlsManager.ts b/src/extensions/core/load3d/ControlsManager.ts new file mode 100644 index 000000000..79c5d1167 --- /dev/null +++ b/src/extensions/core/load3d/ControlsManager.ts @@ -0,0 +1,72 @@ +import * as THREE from 'three' +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' + +import { + ControlsManagerInterface, + EventManagerInterface, + NodeStorageInterface +} from './interfaces' + +export class ControlsManager implements ControlsManagerInterface { + controls: OrbitControls + + private eventManager: EventManagerInterface + private nodeStorage: NodeStorageInterface + private camera: THREE.Camera + + constructor( + renderer: THREE.WebGLRenderer, + camera: THREE.Camera, + eventManager: EventManagerInterface, + nodeStorage: NodeStorageInterface + ) { + this.eventManager = eventManager + this.nodeStorage = nodeStorage + this.camera = camera + + this.controls = new OrbitControls(camera, renderer.domElement) + this.controls.enableDamping = true + } + + init(): void { + this.controls.addEventListener('end', () => { + this.nodeStorage.storeNodeProperty('Camera Info', { + position: this.camera.position.clone(), + target: this.controls.target.clone(), + zoom: + this.camera instanceof THREE.OrthographicCamera + ? (this.camera as THREE.OrthographicCamera).zoom + : (this.camera as THREE.PerspectiveCamera).zoom, + cameraType: + this.camera instanceof THREE.PerspectiveCamera + ? 'perspective' + : 'orthographic' + }) + }) + } + + dispose(): void { + this.controls.dispose() + } + + handleResize(): void {} + + update(): void { + this.controls.update() + } + + updateCamera(camera: THREE.Camera): void { + const position = this.controls.object.position.clone() + const target = this.controls.target.clone() + + this.camera = camera + this.controls.object = camera + this.controls.target = target + camera.position.copy(position) + this.controls.update() + } + reset(): void { + this.controls.target.set(0, 0, 0) + this.controls.update() + } +} diff --git a/src/extensions/core/load3d/EventManager.ts b/src/extensions/core/load3d/EventManager.ts new file mode 100644 index 000000000..e06669ce2 --- /dev/null +++ b/src/extensions/core/load3d/EventManager.ts @@ -0,0 +1,26 @@ +import { EventCallback, EventManagerInterface } from './interfaces' + +export class EventManager implements EventManagerInterface { + private listeners: { [key: string]: EventCallback[] } = {} + + addEventListener(event: string, callback: EventCallback): void { + if (!this.listeners[event]) { + this.listeners[event] = [] + } + this.listeners[event].push(callback) + } + + removeEventListener(event: string, callback: EventCallback): void { + if (this.listeners[event]) { + this.listeners[event] = this.listeners[event].filter( + (cb) => cb !== callback + ) + } + } + + emitEvent(event: string, data?: any): void { + if (this.listeners[event]) { + this.listeners[event].forEach((callback) => callback(data)) + } + } +} diff --git a/src/extensions/core/load3d/LightingManager.ts b/src/extensions/core/load3d/LightingManager.ts new file mode 100644 index 000000000..20212dbdc --- /dev/null +++ b/src/extensions/core/load3d/LightingManager.ts @@ -0,0 +1,78 @@ +import * as THREE from 'three' + +import { EventManagerInterface, LightingManagerInterface } from './interfaces' + +export class LightingManager implements LightingManagerInterface { + lights: THREE.Light[] = [] + private scene: THREE.Scene + private eventManager: EventManagerInterface + + constructor(scene: THREE.Scene, eventManager: EventManagerInterface) { + this.scene = scene + this.eventManager = eventManager + } + + init(): void { + this.setupLights() + } + + dispose(): void { + this.lights.forEach((light) => { + this.scene.remove(light) + }) + this.lights = [] + } + + setupLights(): void { + const ambientLight = new THREE.AmbientLight(0xffffff, 0.5) + this.scene.add(ambientLight) + this.lights.push(ambientLight) + + const mainLight = new THREE.DirectionalLight(0xffffff, 0.8) + mainLight.position.set(0, 10, 10) + this.scene.add(mainLight) + this.lights.push(mainLight) + + const backLight = new THREE.DirectionalLight(0xffffff, 0.5) + backLight.position.set(0, 10, -10) + this.scene.add(backLight) + this.lights.push(backLight) + + const leftFillLight = new THREE.DirectionalLight(0xffffff, 0.3) + leftFillLight.position.set(-10, 0, 0) + this.scene.add(leftFillLight) + this.lights.push(leftFillLight) + + const rightFillLight = new THREE.DirectionalLight(0xffffff, 0.3) + rightFillLight.position.set(10, 0, 0) + this.scene.add(rightFillLight) + this.lights.push(rightFillLight) + + const bottomLight = new THREE.DirectionalLight(0xffffff, 0.2) + bottomLight.position.set(0, -10, 0) + this.scene.add(bottomLight) + this.lights.push(bottomLight) + } + + setLightIntensity(intensity: number): void { + this.lights.forEach((light) => { + if (light instanceof THREE.DirectionalLight) { + if (light === this.lights[1]) { + light.intensity = intensity * 0.8 + } else if (light === this.lights[2]) { + light.intensity = intensity * 0.5 + } else if (light === this.lights[5]) { + light.intensity = intensity * 0.2 + } else { + light.intensity = intensity * 0.3 + } + } else if (light instanceof THREE.AmbientLight) { + light.intensity = intensity * 0.5 + } + }) + + this.eventManager.emitEvent('lightIntensityChange', intensity) + } + + reset(): void {} +} diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index f27e65dbc..112479e7c 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -1,71 +1,40 @@ import { LGraphNode } from '@comfyorg/litegraph' import * as THREE from 'three' -import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' -import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper' -import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader' -import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' -import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader' -import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader' -import { STLLoader } from 'three/examples/jsm/loaders/STLLoader' -import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' -import { useToastStore } from '@/stores/toastStore' - -interface Load3DOptions { - createPreview?: boolean - node?: LGraphNode -} +import { CameraManager } from './CameraManager' +import { ControlsManager } from './ControlsManager' +import { EventManager } from './EventManager' +import { LightingManager } from './LightingManager' +import { LoaderManager } from './LoaderManager' +import { ModelManager } from './ModelManager' +import { NodeStorage } from './NodeStorage' +import { PreviewManager } from './PreviewManager' +import { SceneManager } from './SceneManager' +import { ViewHelperManager } from './ViewHelperManager' +import { + CameraState, + CaptureResult, + Load3DOptions, + MaterialMode, + UpDirection +} from './interfaces' class Load3d { - scene: THREE.Scene - perspectiveCamera: THREE.PerspectiveCamera - orthographicCamera: THREE.OrthographicCamera - activeCamera: THREE.Camera renderer: THREE.WebGLRenderer - controls: OrbitControls - gltfLoader: GLTFLoader - objLoader: OBJLoader - mtlLoader: MTLLoader - fbxLoader: FBXLoader - stlLoader: STLLoader - currentModel: THREE.Object3D | null = null - originalModel: THREE.Object3D | THREE.BufferGeometry | GLTF | null = null - animationFrameId: number | null = null - gridHelper: THREE.GridHelper - lights: THREE.Light[] = [] - clock: THREE.Clock - normalMaterial: THREE.MeshNormalMaterial - standardMaterial: THREE.MeshStandardMaterial - wireframeMaterial: THREE.MeshBasicMaterial - depthMaterial: THREE.MeshDepthMaterial - originalMaterials: WeakMap = - new WeakMap() + protected clock: THREE.Clock + protected animationFrameId: number | null = null + protected node: LGraphNode - materialMode: 'original' | 'normal' | 'wireframe' | 'depth' = 'original' - currentUpDirection: 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z' = - 'original' - originalRotation: THREE.Euler | null = null - viewHelper: ViewHelper = {} as ViewHelper - viewHelperContainer: HTMLDivElement = {} as HTMLDivElement - previewRenderer: THREE.WebGLRenderer | null = null - previewCamera: THREE.Camera | null = null - previewContainer: HTMLDivElement = {} as HTMLDivElement - targetWidth: number = 1024 - targetHeight: number = 1024 - showPreview: boolean = true - previewWidth: number = 120 - node: LGraphNode = {} as LGraphNode - listeners: { [key: string]: Function[] } = {} - - backgroundScene: THREE.Scene - backgroundCamera: THREE.OrthographicCamera - backgroundMesh: THREE.Mesh | null = null - backgroundTexture: THREE.Texture | null = null - - previewBackgroundScene: THREE.Scene - previewBackgroundCamera: THREE.OrthographicCamera - previewBackgroundMesh: THREE.Mesh | null = null - previewBackgroundTexture: THREE.Texture | null = null + protected eventManager: EventManager + protected nodeStorage: NodeStorage + protected sceneManager: SceneManager + protected cameraManager: CameraManager + protected controlsManager: ControlsManager + protected lightingManager: LightingManager + protected viewHelperManager: ViewHelperManager + protected previewManager: PreviewManager + protected loaderManager: LoaderManager + protected modelManager: ModelManager constructor( container: Element | HTMLElement, @@ -74,1115 +43,248 @@ class Load3d { } ) { this.node = options.node || ({} as LGraphNode) - this.scene = new THREE.Scene() - - this.perspectiveCamera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000) - this.perspectiveCamera.position.set(5, 5, 5) - - const frustumSize = 10 - this.orthographicCamera = new THREE.OrthographicCamera( - -frustumSize / 2, - frustumSize / 2, - frustumSize / 2, - -frustumSize / 2, - 0.01, - 10000 - ) - this.orthographicCamera.position.set(5, 5, 5) - - this.activeCamera = this.perspectiveCamera - - this.perspectiveCamera.lookAt(0, 0, 0) - this.orthographicCamera.lookAt(0, 0, 0) + this.clock = new THREE.Clock() 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) - const rendererDomElement: HTMLCanvasElement = this.renderer.domElement + this.eventManager = new EventManager() + this.nodeStorage = new NodeStorage(this.node) - container.appendChild(rendererDomElement) - - this.controls = new OrbitControls( - this.activeCamera, - this.renderer.domElement + this.sceneManager = new SceneManager( + this.renderer, + this.getActiveCamera.bind(this), + this.getControls.bind(this), + this.eventManager ) - this.controls.enableDamping = true - this.controls.addEventListener('end', () => { - this.storeNodeProperty('Camera Info', this.getCameraState()) - }) + this.cameraManager = new CameraManager( + this.renderer, + this.eventManager, + this.nodeStorage + ) - this.gltfLoader = new GLTFLoader() - this.objLoader = new OBJLoader() - this.mtlLoader = new MTLLoader() - this.fbxLoader = new FBXLoader() - this.stlLoader = new STLLoader() - this.clock = new THREE.Clock() + this.controlsManager = new ControlsManager( + this.renderer, + this.cameraManager.activeCamera, + this.eventManager, + this.nodeStorage + ) - this.setupLights() + this.cameraManager.setControls(this.controlsManager.controls) - this.gridHelper = new THREE.GridHelper(10, 10) - this.gridHelper.position.set(0, 0, 0) - this.scene.add(this.gridHelper) + this.lightingManager = new LightingManager( + this.sceneManager.scene, + this.eventManager + ) - this.normalMaterial = new THREE.MeshNormalMaterial({ - flatShading: false, - side: THREE.DoubleSide, - normalScale: new THREE.Vector2(1, 1), - transparent: false, - opacity: 1.0 - }) + this.viewHelperManager = new ViewHelperManager( + this.renderer, + this.getActiveCamera.bind(this), + this.getControls.bind(this), + this.nodeStorage + ) - this.wireframeMaterial = new THREE.MeshBasicMaterial({ - color: 0xffffff, - wireframe: true, - transparent: false, - opacity: 1.0 - }) + 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 + ) - this.depthMaterial = new THREE.MeshDepthMaterial({ - depthPacking: THREE.BasicDepthPacking, - side: THREE.DoubleSide - }) + this.modelManager = new ModelManager( + this.sceneManager.scene, + this.renderer, + this.eventManager, + this.getActiveCamera.bind(this), + this.setupCamera.bind(this) + ) - this.standardMaterial = this.createSTLMaterial() + this.loaderManager = new LoaderManager(this.modelManager, this.eventManager) - this.createViewHelper(container) + 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.createPreview) { - this.createCapturePreview(container) + this.previewManager.createCapturePreview(container) + this.previewManager.init() } - this.backgroundScene = new THREE.Scene() - this.backgroundCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1) - this.previewBackgroundScene = this.backgroundScene.clone() - this.previewBackgroundCamera = this.backgroundCamera.clone() - - const planeGeometry = new THREE.PlaneGeometry(2, 2) - const planeMaterial = new THREE.MeshBasicMaterial({ - transparent: true, - depthWrite: false, - depthTest: false, - side: THREE.DoubleSide - }) - - this.backgroundMesh = new THREE.Mesh(planeGeometry, planeMaterial) - this.backgroundMesh.position.set(0, 0, 0) - - this.previewBackgroundMesh = this.backgroundMesh.clone() - - this.backgroundScene.add(this.backgroundMesh) - this.previewBackgroundScene.add(this.previewBackgroundMesh) - this.handleResize() - this.startAnimation() } - updateBackgroundSize( - backgroundTexture: THREE.Texture | null, - backgroundMesh: THREE.Mesh | null, - targetWidth: number, - targetHeight: number - ) { - if (!backgroundTexture || !backgroundMesh) return - - const material = backgroundMesh.material as THREE.MeshBasicMaterial - - if (!material.map) return - - const imageAspect = - backgroundTexture.image.width / backgroundTexture.image.height - const targetAspect = targetWidth / targetHeight - - if (imageAspect > targetAspect) { - backgroundMesh.scale.set(imageAspect / targetAspect, 1, 1) - } else { - backgroundMesh.scale.set(1, targetAspect / imageAspect, 1) - } - - material.needsUpdate = true + private getActiveCamera(): THREE.Camera { + return this.cameraManager.activeCamera } - async setBackgroundImage(uploadPath: string) { - if (uploadPath === '') { - this.removeBackgroundImage() - return - } - - let imageUrl = Load3dUtils.getResourceURL( - ...Load3dUtils.splitFilePath(uploadPath) - ) - - if (!imageUrl.startsWith('/api')) { - imageUrl = '/api' + imageUrl - } - - try { - const textureLoader = new THREE.TextureLoader() - const texture = await new Promise((resolve, reject) => { - textureLoader.load(imageUrl, resolve, undefined, reject) - }) - - if (this.backgroundTexture) { - this.backgroundTexture.dispose() - } - - if (this.previewBackgroundTexture) { - this.previewBackgroundTexture.dispose() - } - - texture.colorSpace = THREE.SRGBColorSpace - - this.backgroundTexture = texture - this.previewBackgroundTexture = texture - - const material = this.backgroundMesh?.material as THREE.MeshBasicMaterial - material.map = texture - material.needsUpdate = true - - const material2 = this.previewBackgroundMesh - ?.material as THREE.MeshBasicMaterial - material2.map = texture - material2.needsUpdate = true - - this.backgroundMesh?.position.set(0, 0, 0) - this.previewBackgroundMesh?.position.set(0, 0, 0) - - this.updateBackgroundSize( - this.previewBackgroundTexture, - this.previewBackgroundMesh, - this.targetWidth, - this.targetHeight - ) - - this.emitEvent('backgroundImageChange', uploadPath) - } catch (error) { - console.error('Error loading background image:', error) - } + private getControls() { + return this.controlsManager.controls } - removeBackgroundImage() { - if (this.backgroundMesh) { - const material = this.backgroundMesh.material as THREE.MeshBasicMaterial - material.map = null - material.needsUpdate = true - } - - if (this.previewBackgroundMesh) { - const material2 = this.previewBackgroundMesh - .material as THREE.MeshBasicMaterial - material2.map = null - material2.needsUpdate = true - } - - if (this.backgroundTexture) { - this.backgroundTexture.dispose() - this.backgroundTexture = null - } - - if (this.previewBackgroundTexture) { - this.previewBackgroundTexture.dispose() - this.previewBackgroundTexture = null - } - - this.renderer.render(this.scene, this.activeCamera) - if (this.previewRenderer && this.previewCamera) { - this.previewRenderer.render(this.scene, this.previewCamera) - } + private setupCamera(size: THREE.Vector3): void { + this.cameraManager.setupForModel(size) } - addEventListener(event: string, callback: Function) { - if (!this.listeners[event]) { - this.listeners[event] = [] - } - this.listeners[event].push(callback) - } - - removeEventListener(event: string, callback: Function) { - if (this.listeners[event]) { - this.listeners[event] = this.listeners[event].filter( - (cb) => cb !== callback - ) - } - } - - emitEvent(event: string, data?: any) { - if (this.listeners[event]) { - this.listeners[event].forEach((callback) => callback(data)) - } - } - - storeNodeProperty(name: string, value: any) { - if (this.node) { - this.node.properties[name] = value - } - } - - loadNodeProperty(name: string, defaultValue: any) { - if ( - !this.node || - !this.node.properties || - !(name in this.node.properties) - ) { - return defaultValue - } - return this.node.properties[name] - } - - createCapturePreview(container: Element | HTMLElement) { - this.previewRenderer = new THREE.WebGLRenderer({ - alpha: true, - antialias: true, - preserveDrawingBuffer: true - }) - this.previewRenderer.setSize(this.targetWidth, this.targetHeight) - this.previewRenderer.setClearColor(0x282828) - this.previewRenderer.autoClear = false - - this.previewRenderer.outputColorSpace = THREE.SRGBColorSpace - - this.previewContainer = document.createElement('div') - this.previewContainer.style.cssText = ` - position: absolute; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.2); - display: block; - transition: border-color 0.1s ease; - ` - this.previewContainer.appendChild(this.previewRenderer.domElement) - - const MIN_PREVIEW_WIDTH = 120 - const MAX_PREVIEW_WIDTH = 240 - - this.previewContainer.addEventListener('wheel', (event) => { - event.preventDefault() - event.stopPropagation() - - const delta = event.deltaY - const oldWidth = this.previewWidth - - if (delta > 0) { - this.previewWidth = Math.max(MIN_PREVIEW_WIDTH, this.previewWidth - 10) - } else { - this.previewWidth = Math.min(MAX_PREVIEW_WIDTH, this.previewWidth + 10) - } - - if ( - oldWidth !== this.previewWidth && - (this.previewWidth === MIN_PREVIEW_WIDTH || - this.previewWidth === MAX_PREVIEW_WIDTH) - ) { - this.flashPreviewBorder() - } - - this.updatePreviewSize() - this.updatePreviewRender() - }) - - this.previewContainer.style.display = this.showPreview ? 'block' : 'none' - - container.appendChild(this.previewContainer) - } - - flashPreviewBorder() { - const originalBorder = this.previewContainer.style.border - const originalBoxShadow = this.previewContainer.style.boxShadow - - this.previewContainer.style.border = '2px solid rgba(255, 255, 255, 0.8)' - this.previewContainer.style.boxShadow = '0 0 8px rgba(255, 255, 255, 0.5)' - - setTimeout(() => { - this.previewContainer.style.border = originalBorder - this.previewContainer.style.boxShadow = originalBoxShadow - }, 100) - } - - updatePreviewRender() { - if (!this.previewRenderer || !this.previewContainer || !this.showPreview) - return - - if ( - !this.previewCamera || - (this.activeCamera instanceof THREE.PerspectiveCamera && - !(this.previewCamera instanceof THREE.PerspectiveCamera)) || - (this.activeCamera instanceof THREE.OrthographicCamera && - !(this.previewCamera instanceof THREE.OrthographicCamera)) - ) { - this.previewCamera = this.activeCamera.clone() - } - - this.previewCamera.position.copy(this.activeCamera.position) - this.previewCamera.rotation.copy(this.activeCamera.rotation) - - const aspect = this.targetWidth / this.targetHeight - - if (this.activeCamera instanceof THREE.OrthographicCamera) { - const activeOrtho = this.activeCamera as THREE.OrthographicCamera - const previewOrtho = this.previewCamera as THREE.OrthographicCamera - - const frustumHeight = - (activeOrtho.top - activeOrtho.bottom) / activeOrtho.zoom - - const frustumWidth = frustumHeight * aspect - - previewOrtho.top = frustumHeight / 2 - previewOrtho.left = -frustumWidth / 2 - previewOrtho.right = frustumWidth / 2 - previewOrtho.bottom = -frustumHeight / 2 - previewOrtho.zoom = 1 - - previewOrtho.updateProjectionMatrix() - } else { - ;(this.previewCamera as THREE.PerspectiveCamera).aspect = aspect - ;(this.previewCamera as THREE.PerspectiveCamera).fov = ( - this.activeCamera as THREE.PerspectiveCamera - ).fov - ;(this.previewCamera as THREE.PerspectiveCamera).updateProjectionMatrix() - } - - this.previewCamera.lookAt(this.controls.target) - - const previewHeight = - (this.previewWidth * this.targetHeight) / this.targetWidth - this.previewRenderer.setSize(this.previewWidth, previewHeight, false) - - this.previewRenderer.outputColorSpace = THREE.SRGBColorSpace - - this.previewRenderer.clear() - - if (this.previewBackgroundMesh && this.previewBackgroundTexture) { - const material = this.previewBackgroundMesh - .material as THREE.MeshBasicMaterial - if (material.map) { - const currentToneMapping = this.previewRenderer.toneMapping - const currentExposure = this.previewRenderer.toneMappingExposure - - this.previewRenderer.toneMapping = THREE.NoToneMapping - - this.previewRenderer.render( - this.previewBackgroundScene, - this.previewBackgroundCamera - ) - - this.previewRenderer.toneMapping = currentToneMapping - this.previewRenderer.toneMappingExposure = currentExposure - } - } - - this.previewRenderer.render(this.scene, this.previewCamera) - } - - updatePreviewSize() { - if (!this.previewContainer) return - - const previewHeight = - (this.previewWidth * this.targetHeight) / this.targetWidth - - this.previewRenderer?.setSize(this.previewWidth, previewHeight, false) - } - - setTargetSize(width: number, height: number) { - const oldAspect = this.targetWidth / this.targetHeight - - this.targetWidth = width - this.targetHeight = height - - this.updatePreviewSize() - - const newAspect = width / height - if (Math.abs(oldAspect - newAspect) > 0.001) { - this.updateBackgroundSize( - this.previewBackgroundTexture, - this.previewBackgroundMesh, - width, - height - ) - } - - if (this.previewRenderer && this.previewCamera) { - if (this.previewCamera instanceof THREE.PerspectiveCamera) { - this.previewCamera.aspect = width / height - this.previewCamera.updateProjectionMatrix() - } else if (this.previewCamera instanceof THREE.OrthographicCamera) { - const frustumSize = 10 - const aspect = width / height - this.previewCamera.left = (-frustumSize * aspect) / 2 - this.previewCamera.right = (frustumSize * aspect) / 2 - this.previewCamera.updateProjectionMatrix() - } - } - } - - createViewHelper(container: Element | HTMLElement) { - this.viewHelperContainer = document.createElement('div') - - this.viewHelperContainer.style.position = 'absolute' - this.viewHelperContainer.style.bottom = '0' - this.viewHelperContainer.style.left = '0' - this.viewHelperContainer.style.width = '128px' - this.viewHelperContainer.style.height = '128px' - this.viewHelperContainer.addEventListener('pointerup', (event) => { - event.stopPropagation() - - this.viewHelper.handleClick(event) - }) - - this.viewHelperContainer.addEventListener('pointerdown', (event) => { - event.stopPropagation() - }) - - container.appendChild(this.viewHelperContainer) - - this.viewHelper = new ViewHelper( - this.activeCamera, - this.viewHelperContainer - ) - - this.viewHelper.center = this.controls.target - } - - setFOV(fov: number) { - if (this.activeCamera === this.perspectiveCamera) { - this.perspectiveCamera.fov = fov - this.perspectiveCamera.updateProjectionMatrix() - this.renderer.render(this.scene, this.activeCamera) - } - - if ( - this.previewRenderer && - this.previewCamera instanceof THREE.PerspectiveCamera - ) { - this.previewCamera.fov = fov - this.previewCamera.updateProjectionMatrix() - this.previewRenderer.render(this.scene, this.previewCamera) - } - - this.emitEvent('fovChange', fov) - } - - getCameraState() { - const currentType = this.getCurrentCameraType() - return { - position: this.activeCamera.position.clone(), - target: this.controls.target.clone(), - zoom: - this.activeCamera instanceof THREE.OrthographicCamera - ? this.activeCamera.zoom - : (this.activeCamera as THREE.PerspectiveCamera).zoom, - cameraType: currentType - } - } - - setCameraState(state: { - position: THREE.Vector3 - target: THREE.Vector3 - zoom: number - cameraType: 'perspective' | 'orthographic' - }) { - this.activeCamera.position.copy(state.position) - - this.controls.target.copy(state.target) - - if (this.activeCamera instanceof THREE.OrthographicCamera) { - this.activeCamera.zoom = state.zoom - this.activeCamera.updateProjectionMatrix() - } else if (this.activeCamera instanceof THREE.PerspectiveCamera) { - this.activeCamera.zoom = state.zoom - this.activeCamera.updateProjectionMatrix() - } - - this.controls.update() - } - - setUpDirection( - direction: 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z' - ) { - if (!this.currentModel) return - - if (!this.originalRotation && this.currentModel.rotation) { - this.originalRotation = this.currentModel.rotation.clone() - } - - this.currentUpDirection = direction - - if (this.originalRotation) { - this.currentModel.rotation.copy(this.originalRotation) - } - - switch (direction) { - case 'original': - break - case '-x': - this.currentModel.rotation.z = Math.PI / 2 - break - case '+x': - this.currentModel.rotation.z = -Math.PI / 2 - break - case '-y': - this.currentModel.rotation.x = Math.PI - break - case '+y': - break - case '-z': - this.currentModel.rotation.x = Math.PI / 2 - break - case '+z': - this.currentModel.rotation.x = -Math.PI / 2 - break - } - - this.renderer.render(this.scene, this.activeCamera) - } - - setMaterialMode(mode: 'original' | 'normal' | 'wireframe' | 'depth') { - this.materialMode = mode - - if (this.currentModel) { - if (mode === 'depth') { - this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace - } else { - this.renderer.outputColorSpace = THREE.SRGBColorSpace - } - - this.currentModel.traverse((child) => { - if (child instanceof THREE.Mesh) { - switch (mode) { - case 'depth': - if (!this.originalMaterials.has(child)) { - this.originalMaterials.set(child, child.material) - } - const depthMat = new THREE.MeshDepthMaterial({ - depthPacking: THREE.BasicDepthPacking, - side: THREE.DoubleSide - }) - - depthMat.onBeforeCompile = (shader) => { - shader.uniforms.cameraType = { - value: - this.activeCamera instanceof THREE.OrthographicCamera - ? 1.0 - : 0.0 - } - - shader.fragmentShader = ` - uniform float cameraType; - ${shader.fragmentShader} - ` - - shader.fragmentShader = shader.fragmentShader.replace( - /gl_FragColor\s*=\s*vec4\(\s*vec3\(\s*1.0\s*-\s*fragCoordZ\s*\)\s*,\s*opacity\s*\)\s*;/, - ` - float depth = 1.0 - fragCoordZ; - if (cameraType > 0.5) { - depth = pow(depth, 400.0); - } else { - depth = pow(depth, 0.6); - } - gl_FragColor = vec4(vec3(depth), opacity); - ` - ) - } - - depthMat.customProgramCacheKey = () => { - return this.activeCamera instanceof THREE.OrthographicCamera - ? 'ortho' - : 'persp' - } - - child.material = depthMat - break - case 'normal': - if (!this.originalMaterials.has(child)) { - this.originalMaterials.set(child, child.material) - } - child.material = new THREE.MeshNormalMaterial({ - flatShading: false, - side: THREE.DoubleSide, - normalScale: new THREE.Vector2(1, 1), - transparent: false, - opacity: 1.0 - }) - child.geometry.computeVertexNormals() - break - - case 'wireframe': - if (!this.originalMaterials.has(child)) { - this.originalMaterials.set(child, child.material) - } - child.material = new THREE.MeshBasicMaterial({ - color: 0xffffff, - wireframe: true, - transparent: false, - opacity: 1.0 - }) - break - - case 'original': - const originalMaterial = this.originalMaterials.get(child) - if (originalMaterial) { - child.material = originalMaterial - } else { - child.material = this.standardMaterial - } - break - } - } - }) - - this.renderer.render(this.scene, this.activeCamera) - - this.emitEvent('materialModeChange', mode) - } - } - - setupLights() { - const ambientLight = new THREE.AmbientLight(0xffffff, 0.5) - this.scene.add(ambientLight) - this.lights.push(ambientLight) - - const mainLight = new THREE.DirectionalLight(0xffffff, 0.8) - mainLight.position.set(0, 10, 10) - this.scene.add(mainLight) - this.lights.push(mainLight) - - const backLight = new THREE.DirectionalLight(0xffffff, 0.5) - backLight.position.set(0, 10, -10) - this.scene.add(backLight) - this.lights.push(backLight) - - const leftFillLight = new THREE.DirectionalLight(0xffffff, 0.3) - leftFillLight.position.set(-10, 0, 0) - this.scene.add(leftFillLight) - this.lights.push(leftFillLight) - - const rightFillLight = new THREE.DirectionalLight(0xffffff, 0.3) - rightFillLight.position.set(10, 0, 0) - this.scene.add(rightFillLight) - this.lights.push(rightFillLight) - - const bottomLight = new THREE.DirectionalLight(0xffffff, 0.2) - bottomLight.position.set(0, -10, 0) - this.scene.add(bottomLight) - this.lights.push(bottomLight) - } - - toggleCamera(cameraType?: 'perspective' | 'orthographic') { - const oldCamera = this.activeCamera - - const position = oldCamera.position.clone() - const rotation = oldCamera.rotation.clone() - const target = this.controls.target.clone() - - if (!cameraType) { - this.activeCamera = - oldCamera === this.perspectiveCamera - ? this.orthographicCamera - : this.perspectiveCamera - } else { - this.activeCamera = - cameraType === 'perspective' - ? this.perspectiveCamera - : this.orthographicCamera - - if (oldCamera === this.activeCamera) { - return - } - } - - if (this.previewCamera) { - this.previewCamera = null - } - this.previewCamera = this.activeCamera.clone() - - this.activeCamera.position.copy(position) - this.activeCamera.rotation.copy(rotation) - - if (this.materialMode === 'depth' && oldCamera !== this.activeCamera) { - this.setMaterialMode('depth') - } - - this.controls.object = this.activeCamera - this.controls.target.copy(target) - this.controls.update() - - this.viewHelper.dispose() - this.viewHelper = new ViewHelper( - this.activeCamera, - this.viewHelperContainer - ) - this.viewHelper.center = this.controls.target - - this.handleResize() - this.updatePreviewRender() - - this.emitEvent('cameraTypeChange', cameraType) - } - - getCurrentCameraType(): 'perspective' | 'orthographic' { - return this.activeCamera === this.perspectiveCamera - ? 'perspective' - : 'orthographic' - } - - toggleGrid(showGrid: boolean) { - if (this.gridHelper) { - this.gridHelper.visible = showGrid - } - - this.emitEvent('showGridChange', showGrid) - } - - togglePreview(showPreview: boolean) { - if (this.previewRenderer) { - this.showPreview = showPreview - - this.previewContainer.style.display = this.showPreview ? 'block' : 'none' - } - - this.emitEvent('showPreviewChange', showPreview) - } - - setLightIntensity(intensity: number) { - this.lights.forEach((light) => { - if (light instanceof THREE.DirectionalLight) { - if (light === this.lights[1]) { - light.intensity = intensity * 0.8 - } else if (light === this.lights[2]) { - light.intensity = intensity * 0.5 - } else if (light === this.lights[5]) { - light.intensity = intensity * 0.2 - } else { - light.intensity = intensity * 0.3 - } - } else if (light instanceof THREE.AmbientLight) { - light.intensity = intensity * 0.5 - } - }) - - this.emitEvent('lightIntensityChange', intensity) - } - - startAnimation() { + private startAnimation(): void { const animate = () => { this.animationFrameId = requestAnimationFrame(animate) - if (this.showPreview) { - this.updatePreviewRender() + if (this.previewManager.showPreview) { + this.previewManager.updatePreviewRender() } const delta = this.clock.getDelta() - - if (this.viewHelper.animating) { - this.viewHelper.update(delta) - - if (!this.viewHelper.animating) { - this.storeNodeProperty('Camera Info', this.getCameraState()) - } - } + this.viewHelperManager.update(delta) + this.controlsManager.update() this.renderer.clear() + this.sceneManager.renderBackground() + this.renderer.render( + this.sceneManager.scene, + this.cameraManager.activeCamera + ) - if (this.backgroundMesh && this.backgroundTexture) { - const material = this.backgroundMesh.material as THREE.MeshBasicMaterial - if (material.map) { - const currentToneMapping = this.renderer.toneMapping - const currentExposure = this.renderer.toneMappingExposure - - this.renderer.toneMapping = THREE.NoToneMapping - - this.renderer.render(this.backgroundScene, this.backgroundCamera) - - this.renderer.toneMapping = currentToneMapping - this.renderer.toneMappingExposure = currentExposure - } + if (this.viewHelperManager.viewHelper.render) { + this.viewHelperManager.viewHelper.render(this.renderer) } - - this.controls.update() - this.renderer.render(this.scene, this.activeCamera) - this.viewHelper.render(this.renderer) } + animate() } - clearModel() { - const objectsToRemove: THREE.Object3D[] = [] - - this.scene.traverse((object) => { - const isEnvironmentObject = - object === this.gridHelper || - this.lights.includes(object as THREE.Light) || - object === this.perspectiveCamera || - object === this.orthographicCamera - - if (!isEnvironmentObject) { - objectsToRemove.push(object) - } - }) - - objectsToRemove.forEach((obj) => { - if (obj.parent && obj.parent !== this.scene) { - obj.parent.remove(obj) - } else { - this.scene.remove(obj) - } - - if (obj instanceof THREE.Mesh) { - obj.geometry?.dispose() - if (Array.isArray(obj.material)) { - obj.material.forEach((material) => material.dispose()) - } else { - obj.material?.dispose() - } - } - }) - - this.resetScene() - } - - protected resetScene() { - this.currentModel = null - this.originalRotation = null - - const defaultDistance = 10 - this.perspectiveCamera.position.set( - defaultDistance, - defaultDistance, - defaultDistance + setBackgroundColor(color: string): void { + this.sceneManager.setBackgroundColor(color) + this.renderer.render( + this.sceneManager.scene, + this.cameraManager.activeCamera ) - this.orthographicCamera.position.set( - defaultDistance, - defaultDistance, - defaultDistance - ) - - this.perspectiveCamera.lookAt(0, 0, 0) - this.orthographicCamera.lookAt(0, 0, 0) - - const frustumSize = 10 - const aspect = - this.renderer.domElement.width / this.renderer.domElement.height - this.orthographicCamera.left = (-frustumSize * aspect) / 2 - this.orthographicCamera.right = (frustumSize * aspect) / 2 - this.orthographicCamera.top = frustumSize / 2 - this.orthographicCamera.bottom = -frustumSize / 2 - - this.perspectiveCamera.updateProjectionMatrix() - this.orthographicCamera.updateProjectionMatrix() - - this.controls.target.set(0, 0, 0) - this.controls.update() - - this.renderer.render(this.scene, this.activeCamera) - - this.materialMode = 'original' - this.originalMaterials = new WeakMap() - this.renderer.outputColorSpace = THREE.SRGBColorSpace } - remove() { - if (this.animationFrameId !== null) { - cancelAnimationFrame(this.animationFrameId) - } + async setBackgroundImage(uploadPath: string): Promise { + await this.sceneManager.setBackgroundImage(uploadPath) - if (this.backgroundTexture) { - this.backgroundTexture.dispose() - } - - if (this.previewBackgroundTexture) { - this.previewBackgroundTexture.dispose() - } - - if (this.backgroundMesh) { - this.backgroundMesh.geometry.dispose() - ;(this.backgroundMesh.material as THREE.Material).dispose() - } - - if (this.previewBackgroundMesh) { - this.previewBackgroundMesh.geometry.dispose() - ;(this.previewBackgroundMesh.material as THREE.Material).dispose() - } - - this.controls.dispose() - this.viewHelper.dispose() - this.renderer.dispose() - this.renderer.domElement.remove() - this.scene.clear() - } - - protected async loadModelInternal( - url: string, - fileExtension: string - ): Promise { - let model: THREE.Object3D | null = null - - switch (fileExtension) { - case 'stl': - const geometry = await this.stlLoader.loadAsync(url) - - this.originalModel = geometry - - geometry.computeVertexNormals() - const mesh = new THREE.Mesh(geometry, this.standardMaterial) - const group = new THREE.Group() - group.add(mesh) - model = group - break - - case 'fbx': - const fbxModel = await this.fbxLoader.loadAsync(url) - - this.originalModel = fbxModel - - model = fbxModel - - fbxModel.traverse((child) => { - if (child instanceof THREE.Mesh) { - this.originalMaterials.set(child, child.material) - } - }) - - break - - case 'obj': - if (this.materialMode === 'original') { - const mtlUrl = url.replace(/\.obj([^.]*$)/, '.mtl$1') - try { - const materials = await this.mtlLoader.loadAsync(mtlUrl) - materials.preload() - this.objLoader.setMaterials(materials) - } catch (e) { - console.log( - 'No MTL file found or error loading it, continuing without materials' - ) - } - } - model = await this.objLoader.loadAsync(url) - model.traverse((child) => { - if (child instanceof THREE.Mesh) { - this.originalMaterials.set(child, child.material) - } - }) - break - - case 'gltf': - case 'glb': - const gltf = await this.gltfLoader.loadAsync(url) - - this.originalModel = gltf - - model = gltf.scene - gltf.scene.traverse((child) => { - if (child instanceof THREE.Mesh) { - child.geometry.computeVertexNormals() - this.originalMaterials.set(child, child.material) - } - }) - break - } - - return model - } - - async loadModel(url: string, originalFileName?: string) { - try { - this.clearModel() - - let fileExtension: string | undefined - if (originalFileName) { - fileExtension = originalFileName.split('.').pop()?.toLowerCase() - } else { - const filename = new URLSearchParams(url.split('?')[1]).get('filename') - fileExtension = filename?.split('.').pop()?.toLowerCase() - } - - if (!fileExtension) { - useToastStore().addAlert('Could not determine file type') - return - } - - let model = await this.loadModelInternal(url, fileExtension) - - if (model) { - this.currentModel = model - await this.setupModel(model) - } - } catch (error) { - console.error('Error loading model:', error) + if (this.previewManager.previewRenderer) { + this.previewManager.updateBackgroundTexture( + this.sceneManager.backgroundTexture + ) } } - protected async setupModel(model: THREE.Object3D) { - const box = new THREE.Box3().setFromObject(model) - const size = box.getSize(new THREE.Vector3()) - const center = box.getCenter(new THREE.Vector3()) + removeBackgroundImage(): void { + this.sceneManager.removeBackgroundImage() - const maxDim = Math.max(size.x, size.y, size.z) - const targetSize = 5 - const scale = targetSize / maxDim - model.scale.multiplyScalar(scale) - - box.setFromObject(model) - box.getCenter(center) - box.getSize(size) - - model.position.set(-center.x, -box.min.y, -center.z) - - this.scene.add(model) - - if (this.materialMode !== 'original') { - this.setMaterialMode(this.materialMode) + if ( + this.previewManager.previewRenderer && + this.previewManager.previewCamera + ) { + this.previewManager.updateBackgroundTexture(null) } - - if (this.currentUpDirection !== 'original') { - this.setUpDirection(this.currentUpDirection) - } - - await this.setupCamera(size) } - protected async setupCamera(size: THREE.Vector3) { - const distance = Math.max(size.x, size.z) * 2 - const height = size.y * 2 + toggleGrid(showGrid: boolean): void { + this.sceneManager.toggleGrid(showGrid) + } - this.perspectiveCamera.position.set(distance, height, distance) - this.orthographicCamera.position.set(distance, height, distance) + toggleCamera(cameraType?: 'perspective' | 'orthographic'): void { + this.cameraManager.toggleCamera(cameraType) - if (this.activeCamera === this.perspectiveCamera) { - this.perspectiveCamera.lookAt(0, size.y / 2, 0) - this.perspectiveCamera.updateProjectionMatrix() - } else { - const frustumSize = Math.max(size.x, size.y, size.z) * 2 - const aspect = - this.renderer.domElement.width / this.renderer.domElement.height - this.orthographicCamera.left = (-frustumSize * aspect) / 2 - this.orthographicCamera.right = (frustumSize * aspect) / 2 - this.orthographicCamera.top = frustumSize / 2 - this.orthographicCamera.bottom = -frustumSize / 2 - this.orthographicCamera.lookAt(0, size.y / 2, 0) - this.orthographicCamera.updateProjectionMatrix() - } - - this.controls.target.set(0, size.y / 2, 0) - this.controls.update() - - this.renderer.outputColorSpace = THREE.SRGBColorSpace - this.renderer.toneMapping = THREE.ACESFilmicToneMapping - this.renderer.toneMappingExposure = 1 + this.controlsManager.updateCamera(this.cameraManager.activeCamera) + this.viewHelperManager.recreateViewHelper() this.handleResize() } - refreshViewport() { + getCurrentCameraType(): 'perspective' | 'orthographic' { + return this.cameraManager.getCurrentCameraType() + } + + setCameraState(state: CameraState): void { + this.cameraManager.setCameraState(state) + } + + getCameraState(): CameraState { + return this.cameraManager.getCameraState() + } + + setFOV(fov: number): void { + this.cameraManager.setFOV(fov) + this.renderer.render( + this.sceneManager.scene, + this.cameraManager.activeCamera + ) + } + + setMaterialMode(mode: MaterialMode): void { + this.modelManager.setMaterialMode(mode) + + this.renderer.render( + this.sceneManager.scene, + this.cameraManager.activeCamera + ) + } + + async loadModel(url: string, originalFileName?: string): Promise { + this.cameraManager.reset() + this.controlsManager.reset() + this.modelManager.reset() + + await this.loaderManager.loadModel(url, originalFileName) + this.handleResize() } - handleResize() { + clearModel(): void { + this.modelManager.clearModel() + } + + setUpDirection(direction: UpDirection): void { + this.modelManager.setUpDirection(direction) + this.renderer.render( + this.sceneManager.scene, + this.cameraManager.activeCamera + ) + } + + setLightIntensity(intensity: number): void { + this.lightingManager.setLightIntensity(intensity) + } + + togglePreview(showPreview: boolean): void { + this.previewManager.togglePreview(showPreview) + } + + setTargetSize(width: number, height: number): void { + this.previewManager.setTargetSize(width, height) + } + + 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() + } + + handleResize(): void { const parentElement = this.renderer?.domElement?.parentElement if (!parentElement) { @@ -1190,131 +292,41 @@ class Load3d { return } - const width = parentElement?.clientWidth - const height = parentElement?.clientHeight + const width = parentElement.clientWidth + const height = parentElement.clientHeight - if (this.activeCamera === this.perspectiveCamera) { - this.perspectiveCamera.aspect = width / height - this.perspectiveCamera.updateProjectionMatrix() - } else { - const frustumSize = 10 - const aspect = width / height - this.orthographicCamera.left = (-frustumSize * aspect) / 2 - this.orthographicCamera.right = (frustumSize * aspect) / 2 - this.orthographicCamera.top = frustumSize / 2 - this.orthographicCamera.bottom = -frustumSize / 2 - this.orthographicCamera.updateProjectionMatrix() - } + this.cameraManager.handleResize(width, height) + this.sceneManager.handleResize(width, height) this.renderer.setSize(width, height) - if (this.backgroundTexture && this.backgroundMesh) { - this.updateBackgroundSize( - this.backgroundTexture, - this.backgroundMesh, - width, - height - ) + this.previewManager.handleResize() + } + + captureScene(width: number, height: number): Promise { + return this.sceneManager.captureScene(width, height) + } + + loadNodeProperty(name: string, defaultValue: any) { + return this.nodeStorage.loadNodeProperty(name, defaultValue) + } + + remove(): void { + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId) } - this.setTargetSize(this.targetWidth, this.targetHeight) - } + this.sceneManager.dispose() + this.cameraManager.dispose() + this.controlsManager.dispose() + this.lightingManager.dispose() + this.viewHelperManager.dispose() + this.previewManager.dispose() + this.loaderManager.dispose() + this.modelManager.dispose() - animate = () => { - requestAnimationFrame(this.animate) - - this.controls.update() - this.renderer.render(this.scene, this.activeCamera) - } - - captureScene( - width: number, - height: number - ): Promise<{ scene: string; mask: string }> { - return new Promise(async (resolve, reject) => { - try { - this.updatePreviewSize() - const originalWidth = this.renderer.domElement.width - const originalHeight = this.renderer.domElement.height - const originalClearColor = this.renderer.getClearColor( - new THREE.Color() - ) - const originalClearAlpha = this.renderer.getClearAlpha() - const originalToneMapping = this.renderer.toneMapping - const originalExposure = this.renderer.toneMappingExposure - - this.renderer.setSize(width, height) - - if (this.activeCamera === this.perspectiveCamera) { - this.perspectiveCamera.aspect = width / height - this.perspectiveCamera.updateProjectionMatrix() - } else { - const frustumSize = 10 - const aspect = width / height - this.orthographicCamera.left = (-frustumSize * aspect) / 2 - this.orthographicCamera.right = (frustumSize * aspect) / 2 - this.orthographicCamera.top = frustumSize / 2 - this.orthographicCamera.bottom = -frustumSize / 2 - this.orthographicCamera.updateProjectionMatrix() - } - - if (this.backgroundTexture && this.backgroundMesh) { - this.updateBackgroundSize( - this.backgroundTexture, - this.backgroundMesh, - width, - height - ) - } - - this.renderer.clear() - - if (this.backgroundMesh && this.backgroundTexture) { - const material = this.backgroundMesh - .material as THREE.MeshBasicMaterial - - if (material.map) { - this.renderer.toneMapping = THREE.NoToneMapping - this.renderer.render(this.backgroundScene, this.backgroundCamera) - this.renderer.toneMapping = originalToneMapping - this.renderer.toneMappingExposure = originalExposure - } - } - - this.renderer.render(this.scene, this.activeCamera) - const sceneData = this.renderer.domElement.toDataURL('image/png') - - this.renderer.setClearColor(0x000000, 0) - this.renderer.clear() - this.renderer.render(this.scene, this.activeCamera) - const maskData = this.renderer.domElement.toDataURL('image/png') - - this.renderer.setClearColor(originalClearColor, originalClearAlpha) - this.renderer.setSize(originalWidth, originalHeight) - this.handleResize() - - resolve({ scene: sceneData, mask: maskData }) - } catch (error) { - reject(error) - } - }) - } - - createSTLMaterial() { - return new THREE.MeshStandardMaterial({ - color: 0x808080, - metalness: 0.1, - roughness: 0.8, - flatShading: false, - side: THREE.DoubleSide - }) - } - - setBackgroundColor(color: string) { - this.renderer.setClearColor(new THREE.Color(color)) - this.renderer.render(this.scene, this.activeCamera) - - this.emitEvent('backgroundColorChange', color) + this.renderer.dispose() + this.renderer.domElement.remove() } } diff --git a/src/extensions/core/load3d/Load3dAnimation.ts b/src/extensions/core/load3d/Load3dAnimation.ts index b358e2aaf..ecabc96e8 100644 --- a/src/extensions/core/load3d/Load3dAnimation.ts +++ b/src/extensions/core/load3d/Load3dAnimation.ts @@ -1,206 +1,130 @@ -import PrimeVue from 'primevue/config' -import Tooltip from 'primevue/tooltip' +import { LGraphNode } from '@comfyorg/litegraph' import * as THREE from 'three' -import { createApp } from 'vue' -import Load3DAnimationControls from '@/components/load3d/Load3DAnimationControls.vue' -import Load3d from '@/extensions/core/load3d/Load3d' - -interface AnimationItem { - name: string - index: number -} +import { AnimationManager } from './AnimationManager' +import Load3d from './Load3d' +import { Load3DOptions } from './interfaces' class Load3dAnimation extends Load3d { - currentAnimation: THREE.AnimationMixer | null = null - animationActions: THREE.AnimationAction[] = [] - animationClips: THREE.AnimationClip[] = [] - selectedAnimationIndex: number = 0 - isAnimationPlaying: boolean = false - - animationSpeed: number = 1.0 + private animationManager: AnimationManager constructor( container: Element | HTMLElement, - options: { createPreview?: boolean } = {} + options: Load3DOptions = { + node: {} as LGraphNode + } ) { super(container, options) + + this.animationManager = new AnimationManager( + this.eventManager, + this.getCurrentModel.bind(this) + ) + + this.animationManager.init() + + this.overrideAnimationLoop() } - updateAnimationList() { - let updatedAnimationList: AnimationItem[] = [] - - if (this.animationClips.length > 0) { - updatedAnimationList = this.animationClips.map((clip, index) => ({ - name: clip.name || `Animation ${index + 1}`, - index - })) - } - - this.emitEvent('animationListChange', updatedAnimationList) + private getCurrentModel(): THREE.Object3D | null { + return this.modelManager.currentModel } - protected async setupModel(model: THREE.Object3D) { - await super.setupModel(model) - - if (this.currentAnimation) { - this.currentAnimation.stopAllAction() - this.animationActions = [] + private overrideAnimationLoop(): void { + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId) } - let animations: THREE.AnimationClip[] = [] - if (model.animations?.length > 0) { - animations = model.animations - } else if (this.originalModel && 'animations' in this.originalModel) { - animations = ( - this.originalModel as unknown as { animations: THREE.AnimationClip[] } - ).animations - } - - if (animations.length > 0) { - this.animationClips = animations - if (model.type === 'Scene') { - this.currentAnimation = new THREE.AnimationMixer(model) - } else { - this.currentAnimation = new THREE.AnimationMixer(this.currentModel!) - } - - if (this.animationClips.length > 0) { - this.updateSelectedAnimation(0) - } - } - - this.updateAnimationList() - } - - setAnimationSpeed(speed: number) { - this.animationSpeed = speed - this.animationActions.forEach((action) => { - action.setEffectiveTimeScale(speed) - }) - } - - updateSelectedAnimation(index: number) { - if ( - !this.currentAnimation || - !this.animationClips || - index >= this.animationClips.length - ) { - console.warn('Invalid animation update request') - return - } - - this.animationActions.forEach((action) => { - action.stop() - }) - this.currentAnimation.stopAllAction() - this.animationActions = [] - - this.selectedAnimationIndex = index - const clip = this.animationClips[index] - - const action = this.currentAnimation.clipAction(clip) - - action.setEffectiveTimeScale(this.animationSpeed) - - action.reset() - action.clampWhenFinished = false - action.loop = THREE.LoopRepeat - - if (this.isAnimationPlaying) { - action.play() - } else { - action.play() - action.paused = true - } - - this.animationActions = [action] - } - - clearModel() { - if (this.currentAnimation) { - this.animationActions.forEach((action) => { - action.stop() - }) - this.currentAnimation = null - } - this.animationActions = [] - this.animationClips = [] - this.selectedAnimationIndex = 0 - this.isAnimationPlaying = false - this.animationSpeed = 1.0 - - this.emitEvent('animationListChange', []) - - super.clearModel() - } - - toggleAnimation(play?: boolean) { - if (!this.currentAnimation || this.animationActions.length === 0) { - console.warn('No animation to toggle') - return - } - - this.isAnimationPlaying = play ?? !this.isAnimationPlaying - - this.animationActions.forEach((action) => { - if (this.isAnimationPlaying) { - action.paused = false - if (action.time === 0 || action.time === action.getClip().duration) { - action.reset() - } - } else { - action.paused = true - } - }) - } - - startAnimation() { const animate = () => { this.animationFrameId = requestAnimationFrame(animate) - if (this.showPreview) { - this.updatePreviewRender() + if (this.previewManager.showPreview) { + this.previewManager.updatePreviewRender() } const delta = this.clock.getDelta() - if (this.currentAnimation && this.isAnimationPlaying) { - this.currentAnimation.update(delta) - } + this.animationManager.update(delta) - if (this.viewHelper.animating) { - this.viewHelper.update(delta) + this.viewHelperManager.update(delta) - if (!this.viewHelper.animating) { - this.storeNodeProperty('Camera Info', this.getCameraState()) - } - } + this.controlsManager.update() this.renderer.clear() + this.sceneManager.renderBackground() + this.renderer.render( + this.sceneManager.scene, + this.cameraManager.activeCamera + ) - if (this.backgroundMesh && this.backgroundTexture) { - const material = this.backgroundMesh.material as THREE.MeshBasicMaterial - if (material.map) { - const currentToneMapping = this.renderer.toneMapping - const currentExposure = this.renderer.toneMappingExposure - - this.renderer.toneMapping = THREE.NoToneMapping - - this.renderer.render(this.backgroundScene, this.backgroundCamera) - - this.renderer.toneMapping = currentToneMapping - this.renderer.toneMappingExposure = currentExposure - } + if (this.viewHelperManager.viewHelper.render) { + this.viewHelperManager.viewHelper.render(this.renderer) } - - this.controls.update() - this.renderer.render(this.scene, this.activeCamera) - this.viewHelper.render(this.renderer) } + animate() } + + async loadModel(url: string, originalFileName?: string): Promise { + await super.loadModel(url, originalFileName) + + if (this.modelManager.currentModel) { + this.animationManager.setupModelAnimations( + this.modelManager.currentModel, + this.modelManager.originalModel + ) + } + } + + clearModel(): void { + this.animationManager.dispose() + super.clearModel() + } + + updateAnimationList(): void { + this.animationManager.updateAnimationList() + } + + setAnimationSpeed(speed: number): void { + this.animationManager.setAnimationSpeed(speed) + } + + updateSelectedAnimation(index: number): void { + this.animationManager.updateSelectedAnimation(index) + } + + toggleAnimation(play?: boolean): void { + this.animationManager.toggleAnimation(play) + } + + get isAnimationPlaying(): boolean { + return this.animationManager.isAnimationPlaying + } + + get animationSpeed(): number { + return this.animationManager.animationSpeed + } + + get selectedAnimationIndex(): number { + return this.animationManager.selectedAnimationIndex + } + + get animationClips(): THREE.AnimationClip[] { + return this.animationManager.animationClips + } + + get animationActions(): THREE.AnimationAction[] { + return this.animationManager.animationActions + } + + get currentAnimation(): THREE.AnimationMixer | null { + return this.animationManager.currentAnimation + } + + remove(): void { + this.animationManager.dispose() + super.remove() + } } -export { AnimationItem } export default Load3dAnimation diff --git a/src/extensions/core/load3d/Load3dUtils.ts b/src/extensions/core/load3d/Load3dUtils.ts index e7823b4f7..a3e4d12ab 100644 --- a/src/extensions/core/load3d/Load3dUtils.ts +++ b/src/extensions/core/load3d/Load3dUtils.ts @@ -1,4 +1,3 @@ -import Load3d from '@/extensions/core/load3d/Load3d' import { api } from '@/scripts/api' import { app } from '@/scripts/app' import { useToastStore } from '@/stores/toastStore' diff --git a/src/extensions/core/load3d/LoaderManager.ts b/src/extensions/core/load3d/LoaderManager.ts new file mode 100644 index 000000000..e7aabfcd4 --- /dev/null +++ b/src/extensions/core/load3d/LoaderManager.ts @@ -0,0 +1,150 @@ +import * as THREE from 'three' +import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader' +import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' +import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader' +import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader' +import { STLLoader } from 'three/examples/jsm/loaders/STLLoader' + +import { useToastStore } from '@/stores/toastStore' + +import { + EventManagerInterface, + LoaderManagerInterface, + ModelManagerInterface +} from './interfaces' + +export class LoaderManager implements LoaderManagerInterface { + gltfLoader: GLTFLoader + objLoader: OBJLoader + mtlLoader: MTLLoader + fbxLoader: FBXLoader + stlLoader: STLLoader + + private modelManager: ModelManagerInterface + private eventManager: EventManagerInterface + + constructor( + modelManager: ModelManagerInterface, + eventManager: EventManagerInterface + ) { + this.modelManager = modelManager + this.eventManager = eventManager + + this.gltfLoader = new GLTFLoader() + this.objLoader = new OBJLoader() + this.mtlLoader = new MTLLoader() + this.fbxLoader = new FBXLoader() + this.stlLoader = new STLLoader() + } + + init(): void {} + + dispose(): void {} + + async loadModel(url: string, originalFileName?: string): Promise { + try { + this.eventManager.emitEvent('modelLoadingStart', null) + + this.modelManager.clearModel() + + let fileExtension: string | undefined + if (originalFileName) { + fileExtension = originalFileName.split('.').pop()?.toLowerCase() + } else { + const filename = new URLSearchParams(url.split('?')[1]).get('filename') + fileExtension = filename?.split('.').pop()?.toLowerCase() + } + + if (!fileExtension) { + useToastStore().addAlert('Could not determine file type') + return + } + + let model = await this.loadModelInternal(url, fileExtension) + + if (model) { + await this.modelManager.setupModel(model) + } + + this.eventManager.emitEvent('modelLoadingEnd', null) + } catch (error) { + this.eventManager.emitEvent('modelLoadingEnd', null) + console.error('Error loading model:', error) + useToastStore().addAlert('Error loading model') + } + } + + private async loadModelInternal( + url: string, + fileExtension: string + ): Promise { + let model: THREE.Object3D | null = null + + switch (fileExtension) { + case 'stl': + const geometry = await this.stlLoader.loadAsync(url) + this.modelManager.setOriginalModel(geometry) + geometry.computeVertexNormals() + + const mesh = new THREE.Mesh( + geometry, + this.modelManager.standardMaterial + ) + + const group = new THREE.Group() + group.add(mesh) + model = group + break + + case 'fbx': + const fbxModel = await this.fbxLoader.loadAsync(url) + this.modelManager.setOriginalModel(fbxModel) + model = fbxModel + + fbxModel.traverse((child) => { + if (child instanceof THREE.Mesh) { + this.modelManager.originalMaterials.set(child, child.material) + } + }) + break + + case 'obj': + if (this.modelManager.materialMode === 'original') { + const mtlUrl = url.replace(/\.obj([^.]*$)/, '.mtl$1') + try { + const materials = await this.mtlLoader.loadAsync(mtlUrl) + materials.preload() + this.objLoader.setMaterials(materials) + } catch (e) { + console.log( + 'No MTL file found or error loading it, continuing without materials' + ) + } + } + + model = await this.objLoader.loadAsync(url) + model.traverse((child) => { + if (child instanceof THREE.Mesh) { + this.modelManager.originalMaterials.set(child, child.material) + } + }) + break + + case 'gltf': + case 'glb': + const gltf = await this.gltfLoader.loadAsync(url) + this.modelManager.setOriginalModel(gltf) + model = gltf.scene + + gltf.scene.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.geometry.computeVertexNormals() + this.modelManager.originalMaterials.set(child, child.material) + } + }) + break + } + + return model + } +} diff --git a/src/extensions/core/load3d/ModelManager.ts b/src/extensions/core/load3d/ModelManager.ts new file mode 100644 index 000000000..00189f512 --- /dev/null +++ b/src/extensions/core/load3d/ModelManager.ts @@ -0,0 +1,313 @@ +import * as THREE from 'three' +import { GLTF } from 'three/examples/jsm/loaders/GLTFLoader' + +import { + EventManagerInterface, + MaterialMode, + ModelManagerInterface, + UpDirection +} from './interfaces' + +export class ModelManager implements ModelManagerInterface { + currentModel: THREE.Object3D | null = null + originalModel: THREE.Object3D | THREE.BufferGeometry | GLTF | null = null + originalRotation: THREE.Euler | null = null + currentUpDirection: UpDirection = 'original' + materialMode: MaterialMode = 'original' + originalMaterials: WeakMap = + new WeakMap() + normalMaterial: THREE.MeshNormalMaterial + standardMaterial: THREE.MeshStandardMaterial + wireframeMaterial: THREE.MeshBasicMaterial + depthMaterial: THREE.MeshDepthMaterial + + private scene: THREE.Scene + private renderer: THREE.WebGLRenderer + private eventManager: EventManagerInterface + private activeCamera: THREE.Camera + private setupCamera: (size: THREE.Vector3) => void + + constructor( + scene: THREE.Scene, + renderer: THREE.WebGLRenderer, + eventManager: EventManagerInterface, + getActiveCamera: () => THREE.Camera, + setupCamera: (size: THREE.Vector3) => void + ) { + this.scene = scene + this.renderer = renderer + this.eventManager = eventManager + this.activeCamera = getActiveCamera() + this.setupCamera = setupCamera + + this.normalMaterial = new THREE.MeshNormalMaterial({ + flatShading: false, + side: THREE.DoubleSide, + normalScale: new THREE.Vector2(1, 1), + transparent: false, + opacity: 1.0 + }) + + this.wireframeMaterial = new THREE.MeshBasicMaterial({ + color: 0xffffff, + wireframe: true, + transparent: false, + opacity: 1.0 + }) + + this.depthMaterial = new THREE.MeshDepthMaterial({ + depthPacking: THREE.BasicDepthPacking, + side: THREE.DoubleSide + }) + + this.standardMaterial = this.createSTLMaterial() + } + + init(): void {} + + dispose(): void { + this.clearModel() + this.normalMaterial.dispose() + this.standardMaterial.dispose() + this.wireframeMaterial.dispose() + this.depthMaterial.dispose() + } + + createSTLMaterial(): THREE.MeshStandardMaterial { + return new THREE.MeshStandardMaterial({ + color: 0x808080, + metalness: 0.1, + roughness: 0.8, + flatShading: false, + side: THREE.DoubleSide + }) + } + + setMaterialMode(mode: MaterialMode): void { + this.materialMode = mode + + if (!this.currentModel) return + + if (mode === 'depth') { + this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace + } else { + this.renderer.outputColorSpace = THREE.SRGBColorSpace + } + + this.currentModel.traverse((child) => { + if (child instanceof THREE.Mesh) { + switch (mode) { + case 'depth': + if (!this.originalMaterials.has(child)) { + this.originalMaterials.set(child, child.material) + } + const depthMat = new THREE.MeshDepthMaterial({ + depthPacking: THREE.BasicDepthPacking, + side: THREE.DoubleSide + }) + + depthMat.onBeforeCompile = (shader) => { + shader.uniforms.cameraType = { + value: + this.activeCamera instanceof THREE.OrthographicCamera + ? 1.0 + : 0.0 + } + + shader.fragmentShader = ` + uniform float cameraType; + ${shader.fragmentShader} + ` + + shader.fragmentShader = shader.fragmentShader.replace( + /gl_FragColor\s*=\s*vec4\(\s*vec3\(\s*1.0\s*-\s*fragCoordZ\s*\)\s*,\s*opacity\s*\)\s*;/, + ` + float depth = 1.0 - fragCoordZ; + if (cameraType > 0.5) { + depth = pow(depth, 400.0); + } else { + depth = pow(depth, 0.6); + } + gl_FragColor = vec4(vec3(depth), opacity); + ` + ) + } + + depthMat.customProgramCacheKey = () => { + return this.activeCamera instanceof THREE.OrthographicCamera + ? 'ortho' + : 'persp' + } + + child.material = depthMat + break + case 'normal': + if (!this.originalMaterials.has(child)) { + this.originalMaterials.set(child, child.material) + } + child.material = new THREE.MeshNormalMaterial({ + flatShading: false, + side: THREE.DoubleSide, + normalScale: new THREE.Vector2(1, 1), + transparent: false, + opacity: 1.0 + }) + child.geometry.computeVertexNormals() + break + + case 'wireframe': + if (!this.originalMaterials.has(child)) { + this.originalMaterials.set(child, child.material) + } + child.material = new THREE.MeshBasicMaterial({ + color: 0xffffff, + wireframe: true, + transparent: false, + opacity: 1.0 + }) + break + + case 'original': + const originalMaterial = this.originalMaterials.get(child) + if (originalMaterial) { + child.material = originalMaterial + } else { + child.material = this.standardMaterial + } + break + } + } + }) + + this.eventManager.emitEvent('materialModeChange', mode) + } + + setupModelMaterials(model: THREE.Object3D): void { + model.traverse((child) => { + if (child instanceof THREE.Mesh) { + this.originalMaterials.set(child, child.material) + } + }) + + if (this.materialMode !== 'original') { + this.setMaterialMode(this.materialMode) + } + } + + clearModel(): void { + const objectsToRemove: THREE.Object3D[] = [] + + this.scene.traverse((object) => { + const isEnvironmentObject = + object instanceof THREE.GridHelper || + object instanceof THREE.Light || + object instanceof THREE.Camera + + if (!isEnvironmentObject) { + objectsToRemove.push(object) + } + }) + + objectsToRemove.forEach((obj) => { + if (obj.parent && obj.parent !== this.scene) { + obj.parent.remove(obj) + } else { + this.scene.remove(obj) + } + + if (obj instanceof THREE.Mesh) { + obj.geometry?.dispose() + if (Array.isArray(obj.material)) { + obj.material.forEach((material) => material.dispose()) + } else { + obj.material?.dispose() + } + } + }) + + this.reset() + } + + reset(): void { + this.currentModel = null + this.originalRotation = null + this.currentUpDirection = 'original' + this.setMaterialMode('original') + + this.originalMaterials = new WeakMap() + } + + async setupModel(model: THREE.Object3D): Promise { + this.currentModel = model + + const box = new THREE.Box3().setFromObject(model) + 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 targetSize = 5 + const scale = targetSize / maxDim + model.scale.multiplyScalar(scale) + + box.setFromObject(model) + box.getCenter(center) + box.getSize(size) + + model.position.set(-center.x, -box.min.y, -center.z) + + this.scene.add(model) + + if (this.materialMode !== 'original') { + this.setMaterialMode(this.materialMode) + } + + if (this.currentUpDirection !== 'original') { + this.setUpDirection(this.currentUpDirection) + } + this.setupModelMaterials(model) + + this.setupCamera(size) + } + + setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void { + this.originalModel = model + } + + setUpDirection(direction: UpDirection): void { + if (!this.currentModel) return + + if (!this.originalRotation && this.currentModel.rotation) { + this.originalRotation = this.currentModel.rotation.clone() + } + + this.currentUpDirection = direction + + if (this.originalRotation) { + this.currentModel.rotation.copy(this.originalRotation) + } + + switch (direction) { + case 'original': + break + case '-x': + this.currentModel.rotation.z = Math.PI / 2 + break + case '+x': + this.currentModel.rotation.z = -Math.PI / 2 + break + case '-y': + this.currentModel.rotation.x = Math.PI + break + case '+y': + break + case '-z': + this.currentModel.rotation.x = Math.PI / 2 + break + case '+z': + this.currentModel.rotation.x = -Math.PI / 2 + break + } + + this.eventManager.emitEvent('upDirectionChange', direction) + } +} diff --git a/src/extensions/core/load3d/NodeStorage.ts b/src/extensions/core/load3d/NodeStorage.ts new file mode 100644 index 000000000..7791a1fe2 --- /dev/null +++ b/src/extensions/core/load3d/NodeStorage.ts @@ -0,0 +1,32 @@ +import { LGraphNode } from '@comfyorg/litegraph' + +import { NodeStorageInterface } from './interfaces' + +export class NodeStorage implements NodeStorageInterface { + private node: LGraphNode + + constructor(node: LGraphNode = {} as LGraphNode) { + this.node = node + } + + storeNodeProperty(name: string, value: any): void { + if (this.node && this.node.properties) { + this.node.properties[name] = value + } + } + + loadNodeProperty(name: string, defaultValue: any): any { + if ( + !this.node || + !this.node.properties || + !(name in this.node.properties) + ) { + return defaultValue + } + return this.node.properties[name] + } + + setNode(node: LGraphNode): void { + this.node = node + } +} diff --git a/src/extensions/core/load3d/PreviewManager.ts b/src/extensions/core/load3d/PreviewManager.ts new file mode 100644 index 000000000..d63272fa6 --- /dev/null +++ b/src/extensions/core/load3d/PreviewManager.ts @@ -0,0 +1,324 @@ +import * as THREE from 'three' +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' + +import { EventManagerInterface, PreviewManagerInterface } from './interfaces' + +export class PreviewManager implements PreviewManagerInterface { + previewRenderer: THREE.WebGLRenderer | null = null + previewCamera: THREE.Camera + previewContainer: HTMLDivElement = {} as HTMLDivElement + showPreview: boolean = true + previewWidth: number = 120 + + private targetWidth: number = 1024 + private targetHeight: number = 1024 + private scene: THREE.Scene + private getActiveCamera: () => THREE.Camera + private getControls: () => OrbitControls + private eventManager: EventManagerInterface + + private getRenderer: () => THREE.WebGLRenderer + + private previewBackgroundScene: THREE.Scene + private previewBackgroundCamera: THREE.OrthographicCamera + private previewBackgroundMesh: THREE.Mesh | null = null + private previewBackgroundTexture: THREE.Texture | null = null + + constructor( + scene: THREE.Scene, + getActiveCamera: () => THREE.Camera, + getControls: () => OrbitControls, + getRenderer: () => THREE.WebGLRenderer, + eventManager: EventManagerInterface, + backgroundScene: THREE.Scene, + backgroundCamera: THREE.OrthographicCamera + ) { + this.scene = scene + this.getActiveCamera = getActiveCamera + this.getControls = getControls + this.getRenderer = getRenderer + this.eventManager = eventManager + + this.previewCamera = this.getActiveCamera().clone() + + this.previewBackgroundScene = backgroundScene.clone() + this.previewBackgroundCamera = backgroundCamera.clone() + + const planeGeometry = new THREE.PlaneGeometry(2, 2) + const planeMaterial = new THREE.MeshBasicMaterial({ + transparent: true, + depthWrite: false, + depthTest: false, + side: THREE.DoubleSide + }) + + this.previewBackgroundMesh = new THREE.Mesh(planeGeometry, planeMaterial) + this.previewBackgroundMesh.position.set(0, 0, 0) + this.previewBackgroundScene.add(this.previewBackgroundMesh) + } + + init(): void {} + + dispose(): void { + if (this.previewRenderer) { + this.previewRenderer.dispose() + } + + if (this.previewBackgroundTexture) { + this.previewBackgroundTexture.dispose() + } + + if (this.previewBackgroundMesh) { + this.previewBackgroundMesh.geometry.dispose() + ;(this.previewBackgroundMesh.material as THREE.Material).dispose() + } + } + + createCapturePreview(container: Element | HTMLElement): void { + this.previewRenderer = new THREE.WebGLRenderer({ + alpha: true, + antialias: true, + preserveDrawingBuffer: true + }) + + this.previewRenderer.setSize(this.targetWidth, this.targetHeight) + this.previewRenderer.setClearColor(0x282828) + this.previewRenderer.autoClear = false + this.previewRenderer.outputColorSpace = THREE.SRGBColorSpace + + this.previewContainer = document.createElement('div') + this.previewContainer.style.cssText = ` + position: absolute; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.2); + display: block; + transition: border-color 0.1s ease; + ` + this.previewContainer.appendChild(this.previewRenderer.domElement) + + const MIN_PREVIEW_WIDTH = 120 + const MAX_PREVIEW_WIDTH = 240 + + this.previewContainer.addEventListener('wheel', (event) => { + event.preventDefault() + event.stopPropagation() + + const delta = event.deltaY + const oldWidth = this.previewWidth + + if (delta > 0) { + this.previewWidth = Math.max(MIN_PREVIEW_WIDTH, this.previewWidth - 10) + } else { + this.previewWidth = Math.min(MAX_PREVIEW_WIDTH, this.previewWidth + 10) + } + + if ( + oldWidth !== this.previewWidth && + (this.previewWidth === MIN_PREVIEW_WIDTH || + this.previewWidth === MAX_PREVIEW_WIDTH) + ) { + this.flashPreviewBorder() + } + + this.updatePreviewSize() + this.updatePreviewRender() + }) + + this.previewContainer.style.display = this.showPreview ? 'block' : 'none' + + container.appendChild(this.previewContainer) + + this.updatePreviewSize() + } + + flashPreviewBorder(): void { + const originalBorder = this.previewContainer.style.border + const originalBoxShadow = this.previewContainer.style.boxShadow + + this.previewContainer.style.border = '2px solid rgba(255, 255, 255, 0.8)' + this.previewContainer.style.boxShadow = '0 0 8px rgba(255, 255, 255, 0.5)' + + setTimeout(() => { + this.previewContainer.style.border = originalBorder + this.previewContainer.style.boxShadow = originalBoxShadow + }, 100) + } + + updatePreviewSize(): void { + if (!this.previewContainer) return + + const previewHeight = + (this.previewWidth * this.targetHeight) / this.targetWidth + this.previewRenderer?.setSize(this.previewWidth, previewHeight, false) + } + + updatePreviewRender(): void { + if (!this.previewRenderer || !this.previewContainer || !this.showPreview) + return + + if ( + !this.previewCamera || + (this.getActiveCamera() instanceof THREE.PerspectiveCamera && + !(this.previewCamera instanceof THREE.PerspectiveCamera)) || + (this.getActiveCamera() instanceof THREE.OrthographicCamera && + !(this.previewCamera instanceof THREE.OrthographicCamera)) + ) { + this.previewCamera = this.getActiveCamera().clone() + } + + this.previewCamera.position.copy(this.getActiveCamera().position) + this.previewCamera.rotation.copy(this.getActiveCamera().rotation) + + const aspect = this.targetWidth / this.targetHeight + + if (this.getActiveCamera() instanceof THREE.OrthographicCamera) { + const activeOrtho = this.getActiveCamera() as THREE.OrthographicCamera + const previewOrtho = this.previewCamera as THREE.OrthographicCamera + + const frustumHeight = + (activeOrtho.top - activeOrtho.bottom) / activeOrtho.zoom + + const frustumWidth = frustumHeight * aspect + + previewOrtho.top = frustumHeight / 2 + previewOrtho.left = -frustumWidth / 2 + previewOrtho.right = frustumWidth / 2 + previewOrtho.bottom = -frustumHeight / 2 + previewOrtho.zoom = 1 + + previewOrtho.updateProjectionMatrix() + } else { + ;(this.previewCamera as THREE.PerspectiveCamera).aspect = aspect + ;(this.previewCamera as THREE.PerspectiveCamera).fov = ( + this.getActiveCamera() as THREE.PerspectiveCamera + ).fov + ;(this.previewCamera as THREE.PerspectiveCamera).updateProjectionMatrix() + } + + this.previewCamera.lookAt(this.getControls().target) + + const previewHeight = + (this.previewWidth * this.targetHeight) / this.targetWidth + this.previewRenderer.setSize(this.previewWidth, previewHeight, false) + this.previewRenderer.outputColorSpace = THREE.SRGBColorSpace + this.previewRenderer.clear() + + if (this.previewBackgroundMesh && this.previewBackgroundTexture) { + const material = this.previewBackgroundMesh + .material as THREE.MeshBasicMaterial + if (material.map) { + const currentToneMapping = this.previewRenderer.toneMapping + const currentExposure = this.previewRenderer.toneMappingExposure + + this.previewRenderer.toneMapping = THREE.NoToneMapping + this.previewRenderer.render( + this.previewBackgroundScene, + this.previewBackgroundCamera + ) + + this.previewRenderer.toneMapping = currentToneMapping + this.previewRenderer.toneMappingExposure = currentExposure + } + } + + this.previewRenderer.render(this.scene, this.previewCamera) + } + + togglePreview(showPreview: boolean): void { + if (this.previewRenderer) { + this.showPreview = showPreview + this.previewContainer.style.display = this.showPreview ? 'block' : 'none' + } + + this.eventManager.emitEvent('showPreviewChange', showPreview) + } + + setTargetSize(width: number, height: number): void { + const oldAspect = this.targetWidth / this.targetHeight + + this.targetWidth = width + this.targetHeight = height + + this.updatePreviewSize() + + const newAspect = width / height + if (Math.abs(oldAspect - newAspect) > 0.001) { + this.updateBackgroundSize( + this.previewBackgroundTexture, + this.previewBackgroundMesh, + width, + height + ) + } + + if (this.previewRenderer && this.previewCamera) { + if (this.previewCamera instanceof THREE.PerspectiveCamera) { + this.previewCamera.aspect = width / height + this.previewCamera.updateProjectionMatrix() + } else if (this.previewCamera instanceof THREE.OrthographicCamera) { + const frustumSize = 10 + const aspect = width / height + this.previewCamera.left = (-frustumSize * aspect) / 2 + this.previewCamera.right = (frustumSize * aspect) / 2 + this.previewCamera.updateProjectionMatrix() + } + } + } + + handleResize(): void { + this.updatePreviewSize() + this.updatePreviewRender() + } + + updateBackgroundTexture(texture: THREE.Texture | null): void { + if (this.previewBackgroundTexture) { + this.previewBackgroundTexture.dispose() + } + + this.previewBackgroundTexture = texture + + if (texture && this.previewBackgroundMesh) { + const material2 = this.previewBackgroundMesh + .material as THREE.MeshBasicMaterial + material2.map = texture + material2.needsUpdate = true + + this.previewBackgroundMesh.position.set(0, 0, 0) + + this.updateBackgroundSize( + this.previewBackgroundTexture, + this.previewBackgroundMesh, + this.targetWidth, + this.targetHeight + ) + } + } + + private updateBackgroundSize( + backgroundTexture: THREE.Texture | null, + backgroundMesh: THREE.Mesh | null, + targetWidth: number, + targetHeight: number + ): void { + if (!backgroundTexture || !backgroundMesh) return + + const material = backgroundMesh.material as THREE.MeshBasicMaterial + + if (!material.map) return + + const imageAspect = + backgroundTexture.image.width / backgroundTexture.image.height + const targetAspect = targetWidth / targetHeight + + if (imageAspect > targetAspect) { + backgroundMesh.scale.set(imageAspect / targetAspect, 1, 1) + } else { + backgroundMesh.scale.set(1, targetAspect / imageAspect, 1) + } + + material.needsUpdate = true + } + + reset(): void {} +} diff --git a/src/extensions/core/load3d/SceneManager.ts b/src/extensions/core/load3d/SceneManager.ts new file mode 100644 index 000000000..805677626 --- /dev/null +++ b/src/extensions/core/load3d/SceneManager.ts @@ -0,0 +1,277 @@ +import * as THREE from 'three' +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' + +import Load3dUtils from './Load3dUtils' +import { EventManagerInterface, SceneManagerInterface } from './interfaces' + +export class SceneManager implements SceneManagerInterface { + scene: THREE.Scene + gridHelper: THREE.GridHelper + + backgroundScene: THREE.Scene + backgroundCamera: THREE.OrthographicCamera + backgroundMesh: THREE.Mesh | null = null + backgroundTexture: THREE.Texture | null = null + + private eventManager: EventManagerInterface + private renderer: THREE.WebGLRenderer + + private getActiveCamera: () => THREE.Camera + private getControls: () => OrbitControls + + constructor( + renderer: THREE.WebGLRenderer, + getActiveCamera: () => THREE.Camera, + getControls: () => OrbitControls, + eventManager: EventManagerInterface + ) { + this.renderer = renderer + this.eventManager = eventManager + this.scene = new THREE.Scene() + + this.getActiveCamera = getActiveCamera + this.getControls = getControls + + this.gridHelper = new THREE.GridHelper(10, 10) + this.gridHelper.position.set(0, 0, 0) + this.scene.add(this.gridHelper) + + this.backgroundScene = new THREE.Scene() + this.backgroundCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1) + + const planeGeometry = new THREE.PlaneGeometry(2, 2) + const planeMaterial = new THREE.MeshBasicMaterial({ + transparent: true, + depthWrite: false, + depthTest: false, + side: THREE.DoubleSide + }) + + this.backgroundMesh = new THREE.Mesh(planeGeometry, planeMaterial) + this.backgroundMesh.position.set(0, 0, 0) + this.backgroundScene.add(this.backgroundMesh) + } + + init(): void {} + + dispose(): void { + if (this.backgroundTexture) { + this.backgroundTexture.dispose() + } + + if (this.backgroundMesh) { + this.backgroundMesh.geometry.dispose() + ;(this.backgroundMesh.material as THREE.Material).dispose() + } + + this.scene.clear() + } + + toggleGrid(showGrid: boolean): void { + if (this.gridHelper) { + this.gridHelper.visible = showGrid + } + + this.eventManager.emitEvent('showGridChange', showGrid) + } + + setBackgroundColor(color: string): void { + this.renderer.setClearColor(new THREE.Color(color)) + this.eventManager.emitEvent('backgroundColorChange', color) + } + + async setBackgroundImage(uploadPath: string): Promise { + if (uploadPath === '') { + this.removeBackgroundImage() + return + } + + let imageUrl = Load3dUtils.getResourceURL( + ...Load3dUtils.splitFilePath(uploadPath) + ) + + if (!imageUrl.startsWith('/api')) { + imageUrl = '/api' + imageUrl + } + + try { + const textureLoader = new THREE.TextureLoader() + const texture = await new Promise((resolve, reject) => { + textureLoader.load(imageUrl, resolve, undefined, reject) + }) + + if (this.backgroundTexture) { + this.backgroundTexture.dispose() + } + + texture.colorSpace = THREE.SRGBColorSpace + + this.backgroundTexture = texture + + const material = this.backgroundMesh?.material as THREE.MeshBasicMaterial + material.map = texture + material.needsUpdate = true + + this.backgroundMesh?.position.set(0, 0, 0) + + this.updateBackgroundSize( + this.backgroundTexture, + this.backgroundMesh, + this.renderer.domElement.width, + this.renderer.domElement.height + ) + + this.eventManager.emitEvent('backgroundImageChange', uploadPath) + } catch (error) { + console.error('Error loading background image:', error) + } + } + + removeBackgroundImage(): void { + if (this.backgroundMesh) { + const material = this.backgroundMesh.material as THREE.MeshBasicMaterial + material.map = null + material.needsUpdate = true + } + + if (this.backgroundTexture) { + this.backgroundTexture.dispose() + this.backgroundTexture = null + } + } + + updateBackgroundSize( + backgroundTexture: THREE.Texture | null, + backgroundMesh: THREE.Mesh | null, + targetWidth: number, + targetHeight: number + ): void { + if (!backgroundTexture || !backgroundMesh) return + + const material = backgroundMesh.material as THREE.MeshBasicMaterial + + if (!material.map) return + + const imageAspect = + backgroundTexture.image.width / backgroundTexture.image.height + const targetAspect = targetWidth / targetHeight + + if (imageAspect > targetAspect) { + backgroundMesh.scale.set(imageAspect / targetAspect, 1, 1) + } else { + backgroundMesh.scale.set(1, targetAspect / imageAspect, 1) + } + + material.needsUpdate = true + } + + handleResize(width: number, height: number): void { + if (this.backgroundTexture && this.backgroundMesh) { + this.updateBackgroundSize( + this.backgroundTexture, + this.backgroundMesh, + width, + height + ) + } + } + + renderBackground(): void { + if (this.backgroundMesh && this.backgroundTexture) { + const material = this.backgroundMesh.material as THREE.MeshBasicMaterial + if (material.map) { + const currentToneMapping = this.renderer.toneMapping + const currentExposure = this.renderer.toneMappingExposure + + this.renderer.toneMapping = THREE.NoToneMapping + this.renderer.render(this.backgroundScene, this.backgroundCamera) + + this.renderer.toneMapping = currentToneMapping + this.renderer.toneMappingExposure = currentExposure + } + } + } + + captureScene( + width: number, + height: number + ): Promise<{ scene: string; mask: string }> { + return new Promise(async (resolve, reject) => { + try { + const originalWidth = this.renderer.domElement.width + const originalHeight = this.renderer.domElement.height + const originalClearColor = this.renderer.getClearColor( + new THREE.Color() + ) + const originalClearAlpha = this.renderer.getClearAlpha() + const originalToneMapping = this.renderer.toneMapping + const originalExposure = this.renderer.toneMappingExposure + + this.renderer.setSize(width, height) + + if (this.getActiveCamera() instanceof THREE.PerspectiveCamera) { + const perspectiveCamera = + this.getActiveCamera() as THREE.PerspectiveCamera + + perspectiveCamera.aspect = width / height + perspectiveCamera.updateProjectionMatrix() + } else { + const orthographicCamera = + this.getActiveCamera() as THREE.OrthographicCamera + + const frustumSize = 10 + const aspect = width / height + + orthographicCamera.left = (-frustumSize * aspect) / 2 + orthographicCamera.right = (frustumSize * aspect) / 2 + orthographicCamera.top = frustumSize / 2 + orthographicCamera.bottom = -frustumSize / 2 + + orthographicCamera.updateProjectionMatrix() + } + + if (this.backgroundTexture && this.backgroundMesh) { + this.updateBackgroundSize( + this.backgroundTexture, + this.backgroundMesh, + width, + height + ) + } + + this.renderer.clear() + + if (this.backgroundMesh && this.backgroundTexture) { + const material = this.backgroundMesh + .material as THREE.MeshBasicMaterial + + if (material.map) { + this.renderer.toneMapping = THREE.NoToneMapping + this.renderer.render(this.backgroundScene, this.backgroundCamera) + this.renderer.toneMapping = originalToneMapping + this.renderer.toneMappingExposure = originalExposure + } + } + + this.renderer.render(this.scene, this.getActiveCamera()) + const sceneData = this.renderer.domElement.toDataURL('image/png') + + this.renderer.setClearColor(0x000000, 0) + this.renderer.clear() + this.renderer.render(this.scene, this.getActiveCamera()) + const maskData = this.renderer.domElement.toDataURL('image/png') + + this.renderer.setClearColor(originalClearColor, originalClearAlpha) + this.renderer.setSize(originalWidth, originalHeight) + + this.handleResize(width, height) + + resolve({ scene: sceneData, mask: maskData }) + } catch (error) { + reject(error) + } + }) + } + + reset(): void {} +} diff --git a/src/extensions/core/load3d/ViewHelperManager.ts b/src/extensions/core/load3d/ViewHelperManager.ts new file mode 100644 index 000000000..3a95d394a --- /dev/null +++ b/src/extensions/core/load3d/ViewHelperManager.ts @@ -0,0 +1,104 @@ +import * as THREE from 'three' +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' +import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper' + +import { NodeStorageInterface, ViewHelperManagerInterface } from './interfaces' + +export class ViewHelperManager implements ViewHelperManagerInterface { + viewHelper: ViewHelper = {} as ViewHelper + viewHelperContainer: HTMLDivElement = {} as HTMLDivElement + + private getActiveCamera: () => THREE.Camera + private getControls: () => OrbitControls + private nodeStorage: NodeStorageInterface + private renderer: THREE.WebGLRenderer + + constructor( + renderer: THREE.WebGLRenderer, + getActiveCamera: () => THREE.Camera, + getControls: () => OrbitControls, + nodeStorage: NodeStorageInterface + ) { + this.renderer = renderer + this.getActiveCamera = getActiveCamera + this.getControls = getControls + this.nodeStorage = nodeStorage + } + + init(): void {} + + dispose(): void { + if (this.viewHelper) { + this.viewHelper.dispose() + } + + if (this.viewHelperContainer && this.viewHelperContainer.parentNode) { + this.viewHelperContainer.parentNode.removeChild(this.viewHelperContainer) + } + } + + createViewHelper(container: Element | HTMLElement): void { + this.viewHelperContainer = document.createElement('div') + + this.viewHelperContainer.style.position = 'absolute' + this.viewHelperContainer.style.bottom = '0' + this.viewHelperContainer.style.left = '0' + this.viewHelperContainer.style.width = '128px' + this.viewHelperContainer.style.height = '128px' + + this.viewHelperContainer.addEventListener('pointerup', (event) => { + event.stopPropagation() + this.viewHelper.handleClick(event) + }) + + this.viewHelperContainer.addEventListener('pointerdown', (event) => { + event.stopPropagation() + }) + + container.appendChild(this.viewHelperContainer) + + this.viewHelper = new ViewHelper( + this.getActiveCamera(), + this.viewHelperContainer + ) + + this.viewHelper.center = this.getControls().target + } + + update(delta: number): void { + if (this.viewHelper.animating) { + this.viewHelper.update(delta) + + if (!this.viewHelper.animating) { + this.nodeStorage.storeNodeProperty('Camera Info', { + position: this.getActiveCamera().position.clone(), + target: this.getControls().target.clone(), + zoom: + this.getActiveCamera() instanceof THREE.OrthographicCamera + ? (this.getActiveCamera() as THREE.OrthographicCamera).zoom + : (this.getActiveCamera() as THREE.PerspectiveCamera).zoom, + cameraType: + this.getActiveCamera() instanceof THREE.PerspectiveCamera + ? 'perspective' + : 'orthographic' + }) + } + } + } + + handleResize(): void {} + + recreateViewHelper(): void { + if (this.viewHelper) { + this.viewHelper.dispose() + } + + this.viewHelper = new ViewHelper( + this.getActiveCamera(), + this.viewHelperContainer + ) + this.viewHelper.center = this.getControls().target + } + + reset(): void {} +} diff --git a/src/extensions/core/load3d/interfaces.ts b/src/extensions/core/load3d/interfaces.ts new file mode 100644 index 000000000..9ae277d56 --- /dev/null +++ b/src/extensions/core/load3d/interfaces.ts @@ -0,0 +1,164 @@ +import { LGraphNode } from '@comfyorg/litegraph' +import * as THREE from 'three' +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' +import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper' +import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader' +import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' +import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader' +import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader' +import { STLLoader } from 'three/examples/jsm/loaders/STLLoader' + +export type MaterialMode = 'original' | 'normal' | 'wireframe' | 'depth' +export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z' +export type CameraType = 'perspective' | 'orthographic' + +export interface CameraState { + position: THREE.Vector3 + target: THREE.Vector3 + zoom: number + cameraType: CameraType +} + +export interface EventCallback { + (data?: any): void +} + +export interface Load3DOptions { + createPreview?: boolean + node?: LGraphNode +} + +export interface CaptureResult { + scene: string + mask: string +} + +export interface BaseManager { + init(): void + dispose(): void + reset(): void +} + +export interface AnimationItem { + name: string + index: number +} + +export interface SceneManagerInterface extends BaseManager { + scene: THREE.Scene + gridHelper: THREE.GridHelper + toggleGrid(showGrid: boolean): void + setBackgroundColor(color: string): void + setBackgroundImage(uploadPath: string): Promise + removeBackgroundImage(): void + handleResize(width: number, height: number): void + captureScene(width: number, height: number): Promise +} + +export interface CameraManagerInterface extends BaseManager { + activeCamera: THREE.Camera + perspectiveCamera: THREE.PerspectiveCamera + orthographicCamera: THREE.OrthographicCamera + getCurrentCameraType(): CameraType + toggleCamera(cameraType?: CameraType): void + setFOV(fov: number): void + setCameraState(state: CameraState): void + getCameraState(): CameraState + handleResize(width: number, height: number): void + setControls(controls: OrbitControls): void +} + +export interface ControlsManagerInterface extends BaseManager { + controls: OrbitControls + handleResize(): void +} + +export interface LightingManagerInterface extends BaseManager { + lights: THREE.Light[] + setLightIntensity(intensity: number): void +} + +export interface ViewHelperManagerInterface extends BaseManager { + viewHelper: ViewHelper + viewHelperContainer: HTMLDivElement + createViewHelper(container: Element | HTMLElement): void + update(delta: number): void + handleResize(): void +} + +export interface PreviewManagerInterface extends BaseManager { + previewRenderer: THREE.WebGLRenderer | null + previewCamera: THREE.Camera + previewContainer: HTMLDivElement + showPreview: boolean + previewWidth: number + createCapturePreview(container: Element | HTMLElement): void + updatePreviewSize(): void + updatePreviewRender(): void + togglePreview(showPreview: boolean): void + setTargetSize(width: number, height: number): void + handleResize(): void + updateBackgroundTexture(texture: THREE.Texture | null): void +} + +export interface EventManagerInterface { + addEventListener(event: string, callback: EventCallback): void + removeEventListener(event: string, callback: EventCallback): void + emitEvent(event: string, data?: any): void +} + +export interface NodeStorageInterface { + storeNodeProperty(name: string, value: any): void + loadNodeProperty(name: string, defaultValue: any): any +} + +export interface AnimationManagerInterface extends BaseManager { + currentAnimation: THREE.AnimationMixer | null + animationActions: THREE.AnimationAction[] + animationClips: THREE.AnimationClip[] + selectedAnimationIndex: number + isAnimationPlaying: boolean + animationSpeed: number + + setupModelAnimations(model: THREE.Object3D, originalModel: any): void + updateAnimationList(): void + setAnimationSpeed(speed: number): void + updateSelectedAnimation(index: number): void + toggleAnimation(play?: boolean): void + update(delta: number): void +} + +export interface ModelManagerInterface { + currentModel: THREE.Object3D | null + originalModel: THREE.Object3D | THREE.BufferGeometry | GLTF | null + originalRotation: THREE.Euler | null + currentUpDirection: UpDirection + + init(): void + dispose(): void + clearModel(): void + reset(): void + setupModel(model: THREE.Object3D): Promise + setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void + setUpDirection(direction: UpDirection): void + materialMode: MaterialMode + originalMaterials: WeakMap + normalMaterial: THREE.MeshNormalMaterial + standardMaterial: THREE.MeshStandardMaterial + wireframeMaterial: THREE.MeshBasicMaterial + depthMaterial: THREE.MeshDepthMaterial + setMaterialMode(mode: MaterialMode): void + setupModelMaterials(model: THREE.Object3D): void +} + +export interface LoaderManagerInterface { + gltfLoader: GLTFLoader + objLoader: OBJLoader + mtlLoader: MTLLoader + fbxLoader: FBXLoader + stlLoader: STLLoader + + init(): void + dispose(): void + loadModel(url: string, originalFileName?: string): Promise +} diff --git a/src/locales/en/main.json b/src/locales/en/main.json index caed96120..c4695818e 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -847,6 +847,7 @@ "fov": "FOV", "previewOutput": "Preview Output", "uploadBackgroundImage": "Upload Background Image", - "removeBackgroundImage": "Remove Background Image" + "removeBackgroundImage": "Remove Background Image", + "loadingModel": "Loading 3D Model..." } } \ No newline at end of file diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index 86b5f2afa..1cad7f14f 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -321,6 +321,7 @@ "backgroundColor": "Couleur de fond", "fov": "FOV", "lightIntensity": "Intensité de la lumière", + "loadingModel": "Chargement du modèle 3D...", "previewOutput": "Aperçu de la sortie", "removeBackgroundImage": "Supprimer l'image de fond", "showGrid": "Afficher la grille", diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index 1510b87d4..234d46846 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -321,6 +321,7 @@ "backgroundColor": "背景色", "fov": "FOV", "lightIntensity": "光の強度", + "loadingModel": "3Dモデルを読み込んでいます...", "previewOutput": "出力のプレビュー", "removeBackgroundImage": "背景画像を削除", "showGrid": "グリッドを表示", diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index 063d3a47f..0a716434f 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -321,6 +321,7 @@ "backgroundColor": "Цвет фона", "fov": "Угол обзора", "lightIntensity": "Интенсивность света", + "loadingModel": "Загрузка 3D модели...", "previewOutput": "Предварительный просмотр", "removeBackgroundImage": "Удалить фоновое изображение", "showGrid": "Показать сетку", diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index 0f71f874e..2e452a827 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -321,6 +321,7 @@ "backgroundColor": "背景颜色", "fov": "视场", "lightIntensity": "光照强度", + "loadingModel": "正在加载3D模型...", "previewOutput": "预览输出", "removeBackgroundImage": "移除背景图片", "showGrid": "显示网格",