From b54e270b10ce24f81391cec19fa8f7d51d90312a Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Thu, 23 Jan 2025 11:26:27 -0500 Subject: [PATCH] [3d] refactor code (#2326) --- src/assets/css/style.css | 22 + src/extensions/core/load3d.ts | 1138 +---------------- src/extensions/core/load3d/Load3d.ts | 937 ++++++++++++++ src/extensions/core/load3d/Load3dAnimation.ts | 160 +++ 4 files changed, 1135 insertions(+), 1122 deletions(-) create mode 100644 src/extensions/core/load3d/Load3d.ts create mode 100644 src/extensions/core/load3d/Load3dAnimation.ts diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 24ad5013c..4f4cf667f 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -765,6 +765,28 @@ audio.comfy-audio.empty-audio-widget { padding: var(--comfy-tree-explorer-item-padding) !important; } +/* Load3d styles */ +.comfy-load-3d, +.comfy-load-3d-animation, +.comfy-preview-3d { + display: flex; + flex-direction: column; + background: transparent; + flex: 1; + position: relative; + overflow: hidden; +} + +.comfy-load-3d canvas, +.comfy-load-3d-animation canvas, +.comfy-preview-3d canvas { + display: flex; + width: 100% !important; + height: 100% !important; +} + +/* End of Load3d styles */ + /* [Desktop] Electron window specific styles */ .app-drag { app-region: drag; diff --git a/src/extensions/core/load3d.ts b/src/extensions/core/load3d.ts index 5be50af48..546965a9f 100644 --- a/src/extensions/core/load3d.ts +++ b/src/extensions/core/load3d.ts @@ -1,15 +1,9 @@ // @ts-strict-ignore import { IWidget } 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 { nextTick } from 'vue' +import Load3d from '@/extensions/core/load3d/Load3d' +import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation' import { api } from '@/scripts/api' import { app } from '@/scripts/app' import { useToastStore } from '@/stores/toastStore' @@ -102,1064 +96,6 @@ async function uploadFile( return uploadPath } -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() - - materialMode: 'original' | 'normal' | 'wireframe' | 'depth' = 'original' - currentUpDirection: 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z' = - 'original' - originalRotation: THREE.Euler | null = null - viewHelper: ViewHelper - viewHelperContainer: HTMLDivElement - cameraSwitcherContainer: HTMLDivElement - gridSwitcherContainer: HTMLDivElement - - constructor(container: Element | HTMLElement) { - 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.1, - 1000 - ) - this.orthographicCamera.position.set(5, 5, 5) - - this.activeCamera = this.perspectiveCamera - - this.perspectiveCamera.lookAt(0, 0, 0) - this.orthographicCamera.lookAt(0, 0, 0) - - this.renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }) - this.renderer.setSize(300, 300) - this.renderer.setClearColor(0x282828) - this.renderer.autoClear = false - - const rendererDomElement: HTMLCanvasElement = this.renderer.domElement - - container.appendChild(rendererDomElement) - - this.controls = new OrbitControls( - this.activeCamera, - this.renderer.domElement - ) - this.controls.enableDamping = true - - 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.setupLights() - - this.gridHelper = new THREE.GridHelper(10, 10) - this.gridHelper.position.set(0, 0, 0) - this.scene.add(this.gridHelper) - - 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() - - this.createViewHelper(container) - - this.createGridSwitcher(container) - - this.createCameraSwitcher(container) - - this.handleResize() - - this.startAnimation() - } - - 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 - } - - createGridSwitcher(container: Element | HTMLElement) { - this.gridSwitcherContainer = document.createElement('div') - this.gridSwitcherContainer.style.position = 'absolute' - this.gridSwitcherContainer.style.top = '28px' // 修改这里,让按钮在相机按钮下方 - this.gridSwitcherContainer.style.left = '3px' // 与相机按钮左对齐 - this.gridSwitcherContainer.style.width = '20px' - this.gridSwitcherContainer.style.height = '20px' - this.gridSwitcherContainer.style.cursor = 'pointer' - this.gridSwitcherContainer.style.alignItems = 'center' - this.gridSwitcherContainer.style.justifyContent = 'center' - this.gridSwitcherContainer.style.transition = 'background-color 0.2s' - - const gridIcon = document.createElement('div') - gridIcon.innerHTML = ` - - - - - - - - ` - - const updateButtonState = () => { - if (this.gridHelper.visible) { - this.gridSwitcherContainer.style.backgroundColor = - 'rgba(255, 255, 255, 0.2)' - } else { - this.gridSwitcherContainer.style.backgroundColor = 'transparent' - } - } - - updateButtonState() - - this.gridSwitcherContainer.addEventListener('mouseenter', () => { - if (!this.gridHelper.visible) { - this.gridSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)' - } - }) - - this.gridSwitcherContainer.addEventListener('mouseleave', () => { - if (!this.gridHelper.visible) { - this.gridSwitcherContainer.style.backgroundColor = 'transparent' - } - }) - - this.gridSwitcherContainer.title = 'Toggle Grid' - - this.gridSwitcherContainer.addEventListener('click', (event) => { - event.stopPropagation() - this.toggleGrid(!this.gridHelper.visible) - updateButtonState() - }) - - this.gridSwitcherContainer.appendChild(gridIcon) - container.appendChild(this.gridSwitcherContainer) - } - - createCameraSwitcher(container: Element | HTMLElement) { - this.cameraSwitcherContainer = document.createElement('div') - this.cameraSwitcherContainer.style.position = 'absolute' - this.cameraSwitcherContainer.style.top = '3px' - this.cameraSwitcherContainer.style.left = '3px' - this.cameraSwitcherContainer.style.width = '20px' - this.cameraSwitcherContainer.style.height = '20px' - this.cameraSwitcherContainer.style.cursor = 'pointer' - this.cameraSwitcherContainer.style.alignItems = 'center' - this.cameraSwitcherContainer.style.justifyContent = 'center' - - const cameraIcon = document.createElement('div') - cameraIcon.innerHTML = ` - - - - - - ` - this.cameraSwitcherContainer.addEventListener('mouseenter', () => { - this.cameraSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)' - }) - - this.cameraSwitcherContainer.addEventListener('mouseleave', () => { - this.cameraSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.3)' - }) - - this.cameraSwitcherContainer.title = - 'Switch Camera (Perspective/Orthographic)' - - this.cameraSwitcherContainer.addEventListener('click', (event) => { - event.stopPropagation() - this.toggleCamera() - }) - - this.cameraSwitcherContainer.appendChild(cameraIcon) - - container.appendChild(this.cameraSwitcherContainer) - } - - setFOV(fov: number) { - if (this.activeCamera === this.perspectiveCamera) { - this.perspectiveCamera.fov = fov - this.perspectiveCamera.updateProjectionMatrix() - this.renderer.render(this.scene, this.activeCamera) - } - } - - 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' - }) { - if ( - this.activeCamera !== - (state.cameraType === 'perspective' - ? this.perspectiveCamera - : this.orthographicCamera) - ) { - this.toggleCamera(state.cameraType) - } - - 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) - } - } - - 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 - } - } - - 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() - } - - getCurrentCameraType(): 'perspective' | 'orthographic' { - return this.activeCamera === this.perspectiveCamera - ? 'perspective' - : 'orthographic' - } - - toggleGrid(showGrid: boolean) { - if (this.gridHelper) { - this.gridHelper.visible = showGrid - } - } - - 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 - } - }) - } - - startAnimation() { - const animate = () => { - this.animationFrameId = requestAnimationFrame(animate) - const delta = this.clock.getDelta() - - if (this.viewHelper.animating) { - this.viewHelper.update(delta) - } - - this.renderer.clear() - 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 - ) - 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) - } - - 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) - } - } - - 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()) - - 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) - } - - await this.setupCamera(size) - } - - protected async setupCamera(size: THREE.Vector3) { - 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.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.handleResize() - } - - handleResize() { - const parentElement = this.renderer?.domElement?.parentElement - - if (!parentElement) { - console.warn('Parent element not found') - return - } - - 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.renderer.setSize(width, height) - } - - 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 { - 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() - - 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() - } - - this.renderer.clear() - 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) - } -} - -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 - - constructor(container: Element | HTMLElement) { - super(container) - } - - protected async setupModel(model: THREE.Object3D) { - await super.setupModel(model) - - if (this.currentAnimation) { - this.currentAnimation.stopAllAction() - this.animationActions = [] - } - - 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) - } - } - } - - 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 - - super.clearModel() - } - - getAnimationNames(): string[] { - return this.animationClips.map((clip, index) => { - return clip.name || `Animation ${index + 1}` - }) - } - - 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) - const delta = this.clock.getDelta() - - if (this.currentAnimation && this.isAnimationPlaying) { - this.currentAnimation.update(delta) - } - - this.controls.update() - - this.renderer.clear() - - this.renderer.render(this.scene, this.activeCamera) - - if (this.viewHelper.animating) { - this.viewHelper.update(delta) - } - - this.viewHelper.render(this.renderer) - } - animate() - } -} - function splitFilePath(path: string): [string, string] { const folder_separator = path.lastIndexOf('/') if (folder_separator === -1) { @@ -1186,17 +122,6 @@ function getResourceURL( return `/view?${params}` } -const load3dCSSCLASS = `display: flex; - flex-direction: column; - background: transparent; - flex: 1; - position: relative; - overflow: hidden;` - -const load3dCanvasCSSCLASS = `display: flex; - width: 100% !important; - height: 100% !important;` - const containerToLoad3D = new Map() function configureLoad3D( @@ -1295,6 +220,14 @@ function configureLoad3D( } load3d.setFOV(fov.value as number) + + const cameraType = load3d.loadNodeProperty('Camera Type', 'perspective') + + load3d.toggleCamera(cameraType) + + const showGrid = load3d.loadNodeProperty('Show Grid', true) + + load3d.toggleGrid(showGrid) } app.registerExtension({ @@ -1386,21 +319,6 @@ app.registerExtension({ } }, - init() { - const style = document.createElement('style') - - style.innerText = ` - .comfy-load-3d { - ${load3dCSSCLASS} - } - - .comfy-load-3d canvas { - ${load3dCanvasCSSCLASS} - } - ` - document.head.appendChild(style) - }, - async nodeCreated(node) { if (node.constructor.comfyClass !== 'Load3D') return @@ -1416,6 +334,8 @@ app.registerExtension({ const load3d = containerToLoad3D.get(container.id) + load3d.setNode(node) + const modelWidget = node.widgets.find( (w: IWidget) => w.name === 'model_file' ) @@ -1632,21 +552,6 @@ app.registerExtension({ } }, - init() { - const style = document.createElement('style') - - style.innerText = ` - .comfy-load-3d-animation { - ${load3dCSSCLASS} - } - - .comfy-load-3d-animation canvas { - ${load3dCanvasCSSCLASS} - } - ` - document.head.appendChild(style) - }, - async nodeCreated(node) { if (node.constructor.comfyClass !== 'Load3DAnimation') return @@ -1662,6 +567,8 @@ app.registerExtension({ const load3d = containerToLoad3D.get(container.id) + load3d.setNode(node) + const modelWidget = node.widgets.find( (w: IWidget) => w.name === 'model_file' ) @@ -1812,21 +719,6 @@ app.registerExtension({ } }, - init() { - const style = document.createElement('style') - - style.innerText = ` - .comfy-preview-3d { - ${load3dCSSCLASS} - } - - .comfy-preview-3d canvas { - ${load3dCanvasCSSCLASS} - } - ` - document.head.appendChild(style) - }, - async nodeCreated(node) { if (node.constructor.comfyClass !== 'Preview3D') return @@ -1842,6 +734,8 @@ app.registerExtension({ const load3d = containerToLoad3D.get(container.id) + load3d.setNode(node) + const modelWidget = node.widgets.find( (w: IWidget) => w.name === 'model_file' ) diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts new file mode 100644 index 000000000..dc539c9cd --- /dev/null +++ b/src/extensions/core/load3d/Load3d.ts @@ -0,0 +1,937 @@ +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 { useToastStore } from '@/stores/toastStore' + +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() + + 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 + cameraSwitcherContainer: HTMLDivElement = {} as HTMLDivElement + gridSwitcherContainer: HTMLDivElement = {} as HTMLDivElement + node: LGraphNode = {} as LGraphNode + + constructor(container: Element | HTMLElement) { + 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.1, + 1000 + ) + this.orthographicCamera.position.set(5, 5, 5) + + this.activeCamera = this.perspectiveCamera + + this.perspectiveCamera.lookAt(0, 0, 0) + this.orthographicCamera.lookAt(0, 0, 0) + + this.renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }) + this.renderer.setSize(300, 300) + this.renderer.setClearColor(0x282828) + this.renderer.autoClear = false + + const rendererDomElement: HTMLCanvasElement = this.renderer.domElement + + container.appendChild(rendererDomElement) + + this.controls = new OrbitControls( + this.activeCamera, + this.renderer.domElement + ) + this.controls.enableDamping = true + + 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.setupLights() + + this.gridHelper = new THREE.GridHelper(10, 10) + this.gridHelper.position.set(0, 0, 0) + this.scene.add(this.gridHelper) + + 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() + + this.createViewHelper(container) + + this.createGridSwitcher(container) + + this.createCameraSwitcher(container) + + this.handleResize() + + this.startAnimation() + } + + setNode(node: LGraphNode) { + this.node = node + } + + storeNodeProperty(name: string, value: any) { + if (this.node) { + this.node.properties[name] = value + } + } + + loadNodeProperty(name: string, defaultValue: any) { + if (!this.node || !(name in this.node.properties)) { + return defaultValue + } + return this.node.properties[name] + } + + 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 + } + + createGridSwitcher(container: Element | HTMLElement) { + this.gridSwitcherContainer = document.createElement('div') + this.gridSwitcherContainer.style.position = 'absolute' + this.gridSwitcherContainer.style.top = '28px' // 修改这里,让按钮在相机按钮下方 + this.gridSwitcherContainer.style.left = '3px' // 与相机按钮左对齐 + this.gridSwitcherContainer.style.width = '20px' + this.gridSwitcherContainer.style.height = '20px' + this.gridSwitcherContainer.style.cursor = 'pointer' + this.gridSwitcherContainer.style.alignItems = 'center' + this.gridSwitcherContainer.style.justifyContent = 'center' + this.gridSwitcherContainer.style.transition = 'background-color 0.2s' + + const gridIcon = document.createElement('div') + gridIcon.innerHTML = ` + + + + + + + + ` + + const updateButtonState = () => { + if (this.gridHelper.visible) { + this.gridSwitcherContainer.style.backgroundColor = + 'rgba(255, 255, 255, 0.2)' + } else { + this.gridSwitcherContainer.style.backgroundColor = 'transparent' + } + } + + updateButtonState() + + this.gridSwitcherContainer.addEventListener('mouseenter', () => { + if (!this.gridHelper.visible) { + this.gridSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)' + } + }) + + this.gridSwitcherContainer.addEventListener('mouseleave', () => { + if (!this.gridHelper.visible) { + this.gridSwitcherContainer.style.backgroundColor = 'transparent' + } + }) + + this.gridSwitcherContainer.title = 'Toggle Grid' + + this.gridSwitcherContainer.addEventListener('click', (event) => { + event.stopPropagation() + this.toggleGrid(!this.gridHelper.visible) + updateButtonState() + }) + + this.gridSwitcherContainer.appendChild(gridIcon) + container.appendChild(this.gridSwitcherContainer) + } + + createCameraSwitcher(container: Element | HTMLElement) { + this.cameraSwitcherContainer = document.createElement('div') + this.cameraSwitcherContainer.style.position = 'absolute' + this.cameraSwitcherContainer.style.top = '3px' + this.cameraSwitcherContainer.style.left = '3px' + this.cameraSwitcherContainer.style.width = '20px' + this.cameraSwitcherContainer.style.height = '20px' + this.cameraSwitcherContainer.style.cursor = 'pointer' + this.cameraSwitcherContainer.style.alignItems = 'center' + this.cameraSwitcherContainer.style.justifyContent = 'center' + + const cameraIcon = document.createElement('div') + cameraIcon.innerHTML = ` + + + + + + ` + this.cameraSwitcherContainer.addEventListener('mouseenter', () => { + this.cameraSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)' + }) + + this.cameraSwitcherContainer.addEventListener('mouseleave', () => { + this.cameraSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.3)' + }) + + this.cameraSwitcherContainer.title = + 'Switch Camera (Perspective/Orthographic)' + + this.cameraSwitcherContainer.addEventListener('click', (event) => { + event.stopPropagation() + this.toggleCamera() + }) + + this.cameraSwitcherContainer.appendChild(cameraIcon) + + container.appendChild(this.cameraSwitcherContainer) + } + + setFOV(fov: number) { + if (this.activeCamera === this.perspectiveCamera) { + this.perspectiveCamera.fov = fov + this.perspectiveCamera.updateProjectionMatrix() + this.renderer.render(this.scene, this.activeCamera) + } + } + + 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' + }) { + if ( + this.activeCamera !== + (state.cameraType === 'perspective' + ? this.perspectiveCamera + : this.orthographicCamera) + ) { + //this.toggleCamera(state.cameraType) + } + + 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) + } + } + + 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 + } + } + + 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.storeNodeProperty('Camera Type', this.getCurrentCameraType()) + this.handleResize() + } + + getCurrentCameraType(): 'perspective' | 'orthographic' { + return this.activeCamera === this.perspectiveCamera + ? 'perspective' + : 'orthographic' + } + + toggleGrid(showGrid: boolean) { + if (this.gridHelper) { + this.gridHelper.visible = showGrid + + this.storeNodeProperty('Show Grid', showGrid) + } + } + + 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 + } + }) + } + + startAnimation() { + const animate = () => { + this.animationFrameId = requestAnimationFrame(animate) + const delta = this.clock.getDelta() + + if (this.viewHelper.animating) { + this.viewHelper.update(delta) + } + + this.renderer.clear() + 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 + ) + 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) + } + + 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) + } + } + + 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()) + + 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) + } + + await this.setupCamera(size) + } + + protected async setupCamera(size: THREE.Vector3) { + 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.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.handleResize() + } + + handleResize() { + const parentElement = this.renderer?.domElement?.parentElement + + if (!parentElement) { + console.warn('Parent element not found') + return + } + + 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.renderer.setSize(width, height) + } + + 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 { + 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() + + 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() + } + + this.renderer.clear() + 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) + } +} + +export default Load3d diff --git a/src/extensions/core/load3d/Load3dAnimation.ts b/src/extensions/core/load3d/Load3dAnimation.ts new file mode 100644 index 000000000..065db7402 --- /dev/null +++ b/src/extensions/core/load3d/Load3dAnimation.ts @@ -0,0 +1,160 @@ +import * as THREE from 'three' + +import Load3d from '@/extensions/core/load3d/Load3d' + +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 + + constructor(container: Element | HTMLElement) { + super(container) + } + + protected async setupModel(model: THREE.Object3D) { + await super.setupModel(model) + + if (this.currentAnimation) { + this.currentAnimation.stopAllAction() + this.animationActions = [] + } + + 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) + } + } + } + + 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 + + super.clearModel() + } + + getAnimationNames(): string[] { + return this.animationClips.map((clip, index) => { + return clip.name || `Animation ${index + 1}` + }) + } + + 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) + const delta = this.clock.getDelta() + + if (this.currentAnimation && this.isAnimationPlaying) { + this.currentAnimation.update(delta) + } + + this.controls.update() + + this.renderer.clear() + + this.renderer.render(this.scene, this.activeCamera) + + if (this.viewHelper.animating) { + this.viewHelper.update(delta) + } + + this.viewHelper.render(this.renderer) + } + animate() + } +} + +export default Load3dAnimation