[3d] refactor load3d nodes (#2683)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Terry Jia
2025-02-22 18:36:47 -05:00
committed by GitHub
parent 86b65d481a
commit c502b86c31
24 changed files with 2341 additions and 1399 deletions

View File

@@ -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

View File

@@ -1,11 +1,14 @@
<template>
<div ref="container" class="w-full h-full"></div>
<div ref="container" class="w-full h-full relative">
<LoadingOverlay ref="loadingOverlayRef" />
</div>
</template>
<script setup lang="ts">
import { LGraphNode } from '@comfyorg/litegraph'
import { onMounted, onUnmounted, ref, toRaw, watchEffect } from 'vue'
import LoadingOverlay from '@/components/load3d/LoadingOverlay.vue'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import { useLoad3dService } from '@/services/load3dService'
@@ -26,6 +29,7 @@ const props = defineProps<{
const container = ref<HTMLElement | null>(null)
const node = ref(props.node)
const load3d = ref<Load3d | Load3dAnimation | null>(null)
const loadingOverlayRef = ref<InstanceType<typeof LoadingOverlay> | null>(null)
const eventConfig = {
materialModeChange: (value: string) => emit('materialModeChange', value),
@@ -36,7 +40,10 @@ const eventConfig = {
cameraTypeChange: (value: string) => emit('cameraTypeChange', value),
showGridChange: (value: boolean) => emit('showGridChange', value),
showPreviewChange: (value: boolean) => emit('showPreviewChange', value),
backgroundImageChange: (value: string) => emit('backgroundImageChange', value)
backgroundImageChange: (value: string) =>
emit('backgroundImageChange', value),
modelLoadingStart: () => loadingOverlayRef.value?.startLoading(),
modelLoadingEnd: () => loadingOverlayRef.value?.endLoading()
} as const
watchEffect(() => {

View File

@@ -0,0 +1,56 @@
<template>
<Transition name="fade">
<div
v-if="modelLoading"
class="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div class="flex flex-col items-center">
<div class="spinner"></div>
<div class="text-white mt-4 text-lg">
{{ t('load3d.loadingModel') }}
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { t } from '@/i18n'
const modelLoading = ref(false)
const startLoading = () => {
modelLoading.value = true
}
const endLoading = () => {
modelLoading.value = false
}
defineExpose({
startLoading,
endLoading
})
</script>
<style scoped>
.spinner {
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -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]`

View File

@@ -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 {}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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))
}
}
}

View File

@@ -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 {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<void> {
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

View File

@@ -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'

View File

@@ -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<void> {
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<THREE.Object3D | null> {
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
}
}

View File

@@ -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<THREE.Mesh, THREE.Material | THREE.Material[]> =
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<void> {
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)
}
}

View File

@@ -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
}
}

View File

@@ -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 {}
}

View File

@@ -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<void> {
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<THREE.Texture>((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 {}
}

View File

@@ -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 {}
}

View File

@@ -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<void>
removeBackgroundImage(): void
handleResize(width: number, height: number): void
captureScene(width: number, height: number): Promise<CaptureResult>
}
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<void>
setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void
setUpDirection(direction: UpDirection): void
materialMode: MaterialMode
originalMaterials: WeakMap<THREE.Mesh, THREE.Material | THREE.Material[]>
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<void>
}

View File

@@ -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..."
}
}

View File

@@ -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",

View File

@@ -321,6 +321,7 @@
"backgroundColor": "背景色",
"fov": "FOV",
"lightIntensity": "光の強度",
"loadingModel": "3Dモデルを読み込んでいます...",
"previewOutput": "出力のプレビュー",
"removeBackgroundImage": "背景画像を削除",
"showGrid": "グリッドを表示",

View File

@@ -321,6 +321,7 @@
"backgroundColor": "Цвет фона",
"fov": "Угол обзора",
"lightIntensity": "Интенсивность света",
"loadingModel": "Загрузка 3D модели...",
"previewOutput": "Предварительный просмотр",
"removeBackgroundImage": "Удалить фоновое изображение",
"showGrid": "Показать сетку",

View File

@@ -321,6 +321,7 @@
"backgroundColor": "背景颜色",
"fov": "视场",
"lightIntensity": "光照强度",
"loadingModel": "正在加载3D模型...",
"previewOutput": "预览输出",
"removeBackgroundImage": "移除背景图片",
"showGrid": "显示网格",