diff --git a/jest.config.base.ts b/jest.config.base.ts index 7f2ca5b62..f4bf6602a 100644 --- a/jest.config.base.ts +++ b/jest.config.base.ts @@ -14,6 +14,7 @@ const jestConfig: JestConfigWithTsJest = { } ] }, + transformIgnorePatterns: ['/node_modules/(?!(three|@three)/)'], moduleNameMapper: { '^@/(.*)$': '/src/$1', '\\.(css|less|scss|sass)$': 'identity-obj-proxy' diff --git a/package-lock.json b/package-lock.json index a105cf7c3..63a2498fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "pinia": "^2.1.7", "primeicons": "^7.0.0", "primevue": "^4.0.5", + "three": "^0.170.0", "vue": "^3.4.31", "vue-i18n": "^9.13.1", "vue-router": "^4.4.3", @@ -40,6 +41,7 @@ "@types/jest": "^29.5.12", "@types/lodash": "^4.17.6", "@types/node": "^20.14.8", + "@types/three": "^0.169.0", "@vitejs/plugin-vue": "^5.1.4", "@vue/test-utils": "^2.4.6", "@vue/vue3-jest": "^29.2.6", @@ -3849,6 +3851,13 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3984,6 +3993,13 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true }, + "node_modules/@types/stats.js": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.3.tgz", + "integrity": "sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", @@ -3998,6 +4014,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/three": { + "version": "0.169.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.169.0.tgz", + "integrity": "sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -4010,6 +4041,13 @@ "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.20.tgz", + "integrity": "sha512-JGpU6qiIJQKUuVSKx1GtQnHJGxRjtfGIhzO2ilq43VZZS//f1h1Sgexbdk+Lq+7569a6EYhOWrUpIruR/1Enmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.32", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", @@ -4635,6 +4673,13 @@ } } }, + "node_modules/@webgpu/types": { + "version": "0.1.51", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.51.tgz", + "integrity": "sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@xterm/addon-fit": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", @@ -6777,6 +6822,13 @@ "bser": "2.1.1" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -10066,6 +10118,13 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "dev": true, + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", @@ -11951,6 +12010,12 @@ "node": ">=0.8" } }, + "node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index d04567bcd..c28c4e7f9 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@types/jest": "^29.5.12", "@types/lodash": "^4.17.6", "@types/node": "^20.14.8", + "@types/three": "^0.169.0", "@vitejs/plugin-vue": "^5.1.4", "@vue/test-utils": "^2.4.6", "@vue/vue3-jest": "^29.2.6", @@ -86,6 +87,7 @@ "pinia": "^2.1.7", "primeicons": "^7.0.0", "primevue": "^4.0.5", + "three": "^0.170.0", "vue": "^3.4.31", "vue-i18n": "^9.13.1", "vue-router": "^4.4.3", diff --git a/src/extensions/core/index.ts b/src/extensions/core/index.ts index 7e7c355e4..7b12cc2fc 100644 --- a/src/extensions/core/index.ts +++ b/src/extensions/core/index.ts @@ -21,3 +21,4 @@ import './webcamCapture' import './widgetInputs' import './uploadAudio' import './electronAdapter' +import './load3d' diff --git a/src/extensions/core/load3d.ts b/src/extensions/core/load3d.ts new file mode 100644 index 000000000..d756012b6 --- /dev/null +++ b/src/extensions/core/load3d.ts @@ -0,0 +1,960 @@ +import { app } from '@/scripts/app' +import { api } from '@/scripts/api' +import { useToastStore } from '@/stores/toastStore' +import * as THREE from 'three' +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' +import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader' +import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader' +import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader' +import { STLLoader } from 'three/examples/jsm/loaders/STLLoader' +import { IWidget } from '@comfyorg/litegraph' +import { nextTick } from 'vue' + +async function uploadFile( + modelWidget: IWidget, + load3d: Load3d, + file: File, + updateNode: boolean, + fileInput?: HTMLInputElement +) { + try { + const body = new FormData() + body.append('image', file) + body.append('subfolder', '3d') + + const resp = await api.fetchApi('/upload/image', { + method: 'POST', + body + }) + + if (resp.status === 200) { + const data = await resp.json() + let path = data.name + if (data.subfolder) path = data.subfolder + '/' + path + + if (!modelWidget?.options?.values?.includes(path)) { + modelWidget?.options?.values?.push(path) + } + + if (updateNode) { + modelWidget.value = path + const modelUrl = api.apiURL(getResourceURL(...splitFilePath(path))) + await load3d.loadModel(modelUrl, file.name) + } + + const fileExt = file.name.split('.').pop()?.toLowerCase() + if (fileExt === 'obj' && fileInput?.files) { + try { + const mtlFile = Array.from(fileInput.files).find((f) => + f.name.toLowerCase().endsWith('.mtl') + ) + + if (mtlFile) { + const mtlFormData = new FormData() + mtlFormData.append('image', mtlFile) + mtlFormData.append('subfolder', '3d') + + await api.fetchApi('/upload/image', { + method: 'POST', + body: mtlFormData + }) + } + } catch (mtlError) { + console.warn('Failed to upload MTL file:', mtlError) + } + } + } else { + useToastStore().addAlert(resp.status + ' - ' + resp.statusText) + } + } catch (error) { + console.error('Upload error:', error) + useToastStore().addAlert( + error instanceof Error ? error.message : 'Upload failed' + ) + } +} + +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 + currentAnimation: THREE.AnimationMixer | null = null + animationActions: THREE.AnimationAction[] = [] + isAnimationPlaying: boolean = false + node: any + private animationFrameId: number | null = null + gridHelper: THREE.GridHelper + lights: THREE.Light[] = [] + clock: THREE.Clock + normalMaterial: THREE.MeshNormalMaterial + standardMaterial: THREE.MeshStandardMaterial + wireframeMaterial: THREE.MeshBasicMaterial + originalMaterials: WeakMap = + new WeakMap() + + materialMode: 'original' | 'normal' | 'wireframe' = 'original' + + 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({ antialias: true }) + this.renderer.setSize(300, 300) + this.renderer.setClearColor(0x282828) + + 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.standardMaterial = this.createSTLMaterial() + + this.animate() + + this.handleResize() + + this.startAnimation() + } + + setMaterialMode(mode: 'original' | 'normal' | 'wireframe') { + this.materialMode = mode + + if (this.currentModel) { + this.currentModel.traverse((child) => { + if (child instanceof THREE.Mesh) { + switch (mode) { + 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.outputColorSpace = THREE.SRGBColorSpace + 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 position = this.activeCamera.position.clone() + const rotation = this.activeCamera.rotation.clone() + const target = this.controls.target.clone() + + if (!cameraType) { + this.activeCamera = + this.activeCamera === this.perspectiveCamera + ? this.orthographicCamera + : this.perspectiveCamera + } else { + const requestedCamera = + cameraType === 'perspective' + ? this.perspectiveCamera + : this.orthographicCamera + + if (this.activeCamera === requestedCamera) { + return + } + + this.activeCamera = requestedCamera + } + + this.activeCamera.position.copy(position) + this.activeCamera.rotation.copy(rotation) + + this.controls.object = this.activeCamera + this.controls.target.copy(target) + this.controls.update() + + this.handleResize() + } + + 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) + this.controls.update() + this.renderer.render(this.scene, this.activeCamera) + } + animate() + } + + clearModel() { + if (this.currentAnimation) { + this.animationActions.forEach((action) => { + action.stop() + }) + this.currentAnimation = null + } + this.animationActions = [] + this.isAnimationPlaying = false + + 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.currentModel = 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() + } + + toggleAnimation(play?: boolean) { + if (!this.currentAnimation || this.animationActions.length === 0) return + + this.isAnimationPlaying = play ?? !this.isAnimationPlaying + + this.animationActions.forEach((action) => { + action.paused = !this.isAnimationPlaying + }) + } + + remove() { + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId) + } + + this.controls.dispose() + this.renderer.dispose() + this.renderer.domElement.remove() + this.scene.clear() + } + + 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: THREE.Object3D | null = null + + switch (fileExtension) { + case 'stl': + const geometry = await this.stlLoader.loadAsync(url) + 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) + model = fbxModel + + fbxModel.traverse((child) => { + if (child instanceof THREE.Mesh) { + this.originalMaterials.set(child, child.material) + } + }) + + if (fbxModel.animations.length > 0) { + this.currentAnimation = new THREE.AnimationMixer(fbxModel) + this.animationActions = fbxModel.animations.map((clip) => { + const action = this.currentAnimation!.clipAction(clip) + action.clampWhenFinished = true + action.play() + action.paused = true + return action + }) + } + 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) + model = gltf.scene + + gltf.scene.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.geometry.computeVertexNormals() + this.originalMaterials.set(child, child.material) + } + }) + break + + default: + useToastStore().addAlert(`Unsupported file format: ${fileExtension}`) + return + } + + if (model) { + 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) + } + + 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() + } + } catch (error) { + console.error('Error loading model:', error) + } + } + + 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) + + if (this.currentAnimation && this.isAnimationPlaying) { + const delta = this.clock.getDelta() + this.currentAnimation.update(delta) + } + + this.controls.update() + this.renderer.render(this.scene, this.activeCamera) + } + + captureScene(width: number, height: number): Promise { + return new Promise((resolve, reject) => { + try { + const originalWidth = this.renderer.domElement.width + const originalHeight = this.renderer.domElement.height + + 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.render(this.scene, this.activeCamera) + + const imageData = this.renderer.domElement.toDataURL('image/png') + + this.renderer.setSize(originalWidth, originalHeight) + this.handleResize() + + resolve(imageData) + } catch (error) { + reject(error) + } + }) + } + + createSTLMaterial() { + return new THREE.MeshStandardMaterial({ + color: 0x808080, + metalness: 0.1, + roughness: 0.8, + flatShading: false, + side: THREE.DoubleSide + }) + } + + setViewPosition(position: 'front' | 'top' | 'right' | 'isometric') { + const box = new THREE.Box3() + let center = new THREE.Vector3() + let size = new THREE.Vector3() + + if (this.currentModel) { + box.setFromObject(this.currentModel) + box.getCenter(center) + box.getSize(size) + } + + const maxDim = Math.max(size.x, size.y, size.z) + const distance = maxDim * 2 + + switch (position) { + case 'front': + this.activeCamera.position.set(0, 0, distance) + break + case 'top': + this.activeCamera.position.set(0, distance, 0) + break + case 'right': + this.activeCamera.position.set(distance, 0, 0) + break + case 'isometric': + this.activeCamera.position.set(distance, distance, distance) + break + } + + this.activeCamera.lookAt(center) + this.controls.target.copy(center) + this.controls.update() + } + + setBackgroundColor(color: string) { + this.renderer.setClearColor(new THREE.Color(color)) + this.renderer.render(this.scene, this.activeCamera) + } +} + +function splitFilePath(path: string): [string, string] { + const folder_separator = path.lastIndexOf('/') + if (folder_separator === -1) { + return ['', path] + } + return [ + path.substring(0, folder_separator), + path.substring(folder_separator + 1) + ] +} + +function getResourceURL( + subfolder: string, + filename: string, + type: string = 'input' +): string { + const params = [ + 'filename=' + encodeURIComponent(filename), + 'type=' + type, + 'subfolder=' + subfolder, + app.getRandParam().substring(1) + ].join('&') + + return `/view?${params}` +} + +const containerToLoad3D = new Map() + +app.registerExtension({ + name: 'Comfy.Load3D', + + getCustomWidgets(app) { + return { + LOAD_3D(node, inputName) { + let load3dNode = app.graph._nodes.filter((wi) => wi.type == 'Load3D') + + const container = document.createElement('div') + container.id = `comfy-load-3d-${load3dNode.length}` + container.classList.add('comfy-load-3d') + + const load3d = new Load3d(container) + + containerToLoad3D.set(container.id, load3d) + + node.onResize = function () { + if (load3d) { + load3d.handleResize() + } + } + + const origOnRemoved = node.onRemoved + + node.onRemoved = function () { + if (load3d) { + load3d.remove() + } + + containerToLoad3D.delete(container.id) + + origOnRemoved?.apply(this, []) + } + + node.onDrawBackground = function () { + load3d.renderer.domElement.hidden = this.flags.collapsed ?? false + } + + return { + widget: node.addDOMWidget(inputName, 'LOAD_3D', container) + } + } + } + }, + + init() { + const style = document.createElement('style') + + style.innerText = ` + .comfy-load-3d { + display: flex; + flex-direction: column; + background: transparent; + flex: 1; + position: relative; + overflow: hidden; + } + + .comfy-load-3d canvas { + width: 100% !important; + height: 100% !important; + } + ` + document.head.appendChild(style) + }, + + async nodeCreated(node) { + if (node.constructor.comfyClass !== 'Load3D') return + + const [oldWidth, oldHeight] = node.size + + await nextTick() + + const sceneWidget = node.widgets.find((w: IWidget) => w.name === 'image') + + const container = sceneWidget.element + + const load3d = containerToLoad3D.get(container.id) + + const modelWidget = node.widgets.find( + (w: IWidget) => w.name === 'model_file' + ) + + const onModelWidgetUpdate = () => { + if (modelWidget.value) { + const filename = modelWidget.value + const modelUrl = api.apiURL(getResourceURL(...splitFilePath(filename))) + + load3d.loadModel(modelUrl, filename) + + const material = node.widgets.find( + (w: IWidget) => w.name === 'material' + ) + + load3d.setMaterialMode(material.value) + } + } + + if (modelWidget.value) { + onModelWidgetUpdate() + } + + modelWidget.callback = onModelWidgetUpdate + + const showGrid = node.widgets.find((w: IWidget) => w.name === 'show_grid') + + load3d.toggleGrid(showGrid.value) + + showGrid.callback = (value: boolean) => { + load3d.toggleGrid(value) + } + + const cameraType = node.widgets.find( + (w: IWidget) => w.name === 'camera_type' + ) + + load3d.toggleCamera(cameraType.value) + + cameraType.callback = (value: 'perspective' | 'orthographic') => { + load3d.toggleCamera(value) + } + + const view = node.widgets.find((w: IWidget) => w.name === 'view') + + view.callback = (value: 'front' | 'top' | 'right' | 'isometric') => { + load3d.setViewPosition(value) + } + + const material = node.widgets.find((w: IWidget) => w.name === 'material') + + material.callback = (value: 'original' | 'normal' | 'wireframe') => { + load3d.setMaterialMode(value) + } + + load3d.setMaterialMode(material.value) + + const bgColor = node.widgets.find((w: IWidget) => w.name === 'bg_color') + + load3d.setBackgroundColor(bgColor.value) + + bgColor.callback = (value: string) => { + load3d.setBackgroundColor(value) + } + + const lightIntensity = node.widgets.find( + (w: IWidget) => w.name === 'light_intensity' + ) + + load3d.setLightIntensity(lightIntensity.value) + + lightIntensity.callback = (value: number) => { + load3d.setLightIntensity(value) + } + + const w = node.widgets.find((w: IWidget) => w.name === 'width') + const h = node.widgets.find((w: IWidget) => w.name === 'height') + + sceneWidget.serializeValue = async () => { + load3d.toggleAnimation(false) + + const imageData = await load3d.captureScene(w.value, h.value) + + const blob = await fetch(imageData).then((r) => r.blob()) + const name = `scene_${Date.now()}.png` + const file = new File([blob], name) + + const body = new FormData() + body.append('image', file) + body.append('subfolder', 'threed') + body.append('type', 'temp') + + const resp = await api.fetchApi('/upload/image', { + method: 'POST', + body + }) + + if (resp.status !== 200) { + const err = `Error uploading scene capture: ${resp.status} - ${resp.statusText}` + useToastStore().addAlert(err) + throw new Error(err) + } + + const data = await resp.json() + return `threed/${data.name} [temp]` + } + + const fileInput = document.createElement('input') + fileInput.type = 'file' + fileInput.accept = '.gltf,.glb,.obj,.mtl,.fbx,.stl' + fileInput.style.display = 'none' + fileInput.onchange = () => { + if (fileInput.files?.length) { + const modelWidget = node.widgets.find( + (w: IWidget) => w.name === 'model_file' + ) + uploadFile( + modelWidget, + load3d, + fileInput.files[0], + true, + fileInput + ).catch((error) => { + console.error('File upload failed:', error) + useToastStore().addAlert('File upload failed') + }) + } + } + + node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => { + fileInput.click() + }) + + node.addWidget('button', 'clear', 'clear', () => { + load3d.clearModel() + const modelWidget = node.widgets.find( + (w: IWidget) => w.name === 'model_file' + ) + if (modelWidget) { + modelWidget.value = '' + } + }) + + node.addWidget('button', 'Play/Pause Animation', 'toggle_animation', () => { + load3d.toggleAnimation() + }) + + node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 550)]) + } +})