[3d] initial version of 3d viewer (#3968)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Terry Jia
2025-08-11 00:09:19 -04:00
committed by GitHub
parent 8e357c41e3
commit d22d62b670
46 changed files with 2071 additions and 42 deletions

View File

@@ -2,6 +2,7 @@ import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import Load3DAnimation from '@/components/load3d/Load3DAnimation.vue'
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
@@ -9,10 +10,13 @@ import { t } from '@/i18n'
import type { IStringWidget } from '@/lib/litegraph/src/types/widgets'
import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { api } from '@/scripts/api'
import { ComfyApp, app } from '@/scripts/app'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
import { useToastStore } from '@/stores/toastStore'
import { isLoad3dNode } from '@/utils/litegraphUtil'
async function handleModelUpload(files: FileList, node: any) {
if (!files?.length) return
@@ -174,6 +178,51 @@ useExtensionService().registerExtension({
},
defaultValue: 0.5,
experimental: true
},
{
id: 'Comfy.Load3D.3DViewerEnable',
category: ['3D', '3DViewer', 'Enable'],
name: 'Enable 3D Viewer (Beta)',
tooltip:
'Enables the 3D Viewer (Beta) for selected nodes. This feature allows you to visualize and interact with 3D models directly within the full size 3d viewer.',
type: 'boolean',
defaultValue: false,
experimental: true
}
],
commands: [
{
id: 'Comfy.3DViewer.Open3DViewer',
icon: 'pi pi-pencil',
label: 'Open 3D Viewer (Beta) for Selected Node',
function: () => {
const selectedNodes = app.canvas.selected_nodes
if (!selectedNodes || Object.keys(selectedNodes).length !== 1) return
const selectedNode = selectedNodes[Object.keys(selectedNodes)[0]]
if (!isLoad3dNode(selectedNode)) return
ComfyApp.copyToClipspace(selectedNode)
// @ts-expect-error clipspace_return_node is an extension property added at runtime
ComfyApp.clipspace_return_node = selectedNode
const props = { node: selectedNode }
useDialogStore().showDialog({
key: 'global-load3d-viewer',
title: t('load3d.viewer.title'),
component: Load3DViewerContent,
props: props,
dialogComponentProps: {
style: 'width: 80vw; height: 80vh;',
maximizable: true,
onClose: async () => {
await useLoad3dService().handleViewerClose(props.node)
}
}
})
}
}
],
getCustomWidgets() {

View File

@@ -179,12 +179,16 @@ export class CameraManager implements CameraManagerInterface {
}
handleResize(width: number, height: number): void {
const aspect = width / height
this.updateAspectRatio(aspect)
}
updateAspectRatio(aspect: number): void {
if (this.activeCamera === this.perspectiveCamera) {
this.perspectiveCamera.aspect = width / height
this.perspectiveCamera.aspect = aspect
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

View File

@@ -9,11 +9,11 @@ import { EventManager } from './EventManager'
import { LightingManager } from './LightingManager'
import { LoaderManager } from './LoaderManager'
import { ModelExporter } from './ModelExporter'
import { ModelManager } from './ModelManager'
import { NodeStorage } from './NodeStorage'
import { PreviewManager } from './PreviewManager'
import { RecordingManager } from './RecordingManager'
import { SceneManager } from './SceneManager'
import { SceneModelManager } from './SceneModelManager'
import { ViewHelperManager } from './ViewHelperManager'
import {
CameraState,
@@ -29,22 +29,28 @@ class Load3d {
protected animationFrameId: number | null = null
node: LGraphNode
protected eventManager: EventManager
protected nodeStorage: NodeStorage
protected sceneManager: SceneManager
protected cameraManager: CameraManager
protected controlsManager: ControlsManager
protected lightingManager: LightingManager
protected viewHelperManager: ViewHelperManager
protected previewManager: PreviewManager
protected loaderManager: LoaderManager
protected modelManager: ModelManager
protected recordingManager: RecordingManager
eventManager: EventManager
nodeStorage: NodeStorage
sceneManager: SceneManager
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
viewHelperManager: ViewHelperManager
previewManager: PreviewManager
loaderManager: LoaderManager
modelManager: SceneModelManager
recordingManager: RecordingManager
STATUS_MOUSE_ON_NODE: boolean
STATUS_MOUSE_ON_SCENE: boolean
STATUS_MOUSE_ON_VIEWER: boolean
INITIAL_RENDER_DONE: boolean = false
targetWidth: number = 512
targetHeight: number = 512
targetAspectRatio: number = 1
isViewerMode: boolean = false
constructor(
container: Element | HTMLElement,
options: Load3DOptions = {
@@ -54,6 +60,16 @@ class Load3d {
) {
this.node = options.node || ({} as LGraphNode)
this.clock = new THREE.Clock()
this.isViewerMode = options.isViewerMode || false
const widthWidget = this.node.widgets?.find((w) => w.name === 'width')
const heightWidget = this.node.widgets?.find((w) => w.name === 'height')
if (widthWidget && heightWidget) {
this.targetWidth = widthWidget.value as number
this.targetHeight = heightWidget.value as number
this.targetAspectRatio = this.targetWidth / this.targetHeight
}
this.renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
this.renderer.setSize(300, 300)
@@ -109,7 +125,11 @@ class Load3d {
this.sceneManager.backgroundCamera
)
this.modelManager = new ModelManager(
if (options.disablePreview) {
this.previewManager.togglePreview(false)
}
this.modelManager = new SceneModelManager(
this.sceneManager.scene,
this.renderer,
this.eventManager,
@@ -142,6 +162,7 @@ class Load3d {
this.STATUS_MOUSE_ON_NODE = false
this.STATUS_MOUSE_ON_SCENE = false
this.STATUS_MOUSE_ON_VIEWER = false
this.handleResize()
this.startAnimation()
@@ -151,6 +172,41 @@ class Load3d {
}, 100)
}
getEventManager(): EventManager {
return this.eventManager
}
getNodeStorage(): NodeStorage {
return this.nodeStorage
}
getSceneManager(): SceneManager {
return this.sceneManager
}
getCameraManager(): CameraManager {
return this.cameraManager
}
getControlsManager(): ControlsManager {
return this.controlsManager
}
getLightingManager(): LightingManager {
return this.lightingManager
}
getViewHelperManager(): ViewHelperManager {
return this.viewHelperManager
}
getPreviewManager(): PreviewManager {
return this.previewManager
}
getLoaderManager(): LoaderManager {
return this.loaderManager
}
getModelManager(): SceneModelManager {
return this.modelManager
}
getRecordingManager(): RecordingManager {
return this.recordingManager
}
forceRender(): void {
const delta = this.clock.getDelta()
this.viewHelperManager.update(delta)
@@ -172,12 +228,43 @@ class Load3d {
}
renderMainScene(): void {
const width = this.renderer.domElement.clientWidth
const height = this.renderer.domElement.clientHeight
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
this.renderer.setViewport(0, 0, width, height)
this.renderer.setScissor(0, 0, width, height)
this.renderer.setScissorTest(true)
if (this.isViewerMode) {
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
let renderHeight: number
let offsetX: number = 0
let offsetY: number = 0
if (containerAspectRatio > this.targetAspectRatio) {
renderHeight = containerHeight
renderWidth = renderHeight * this.targetAspectRatio
offsetX = (containerWidth - renderWidth) / 2
} else {
renderWidth = containerWidth
renderHeight = renderWidth / this.targetAspectRatio
offsetY = (containerHeight - renderHeight) / 2
}
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
this.renderer.setClearColor(0x0a0a0a)
this.renderer.clear()
this.renderer.setViewport(offsetX, offsetY, renderWidth, renderHeight)
this.renderer.setScissor(offsetX, offsetY, renderWidth, renderHeight)
const renderAspectRatio = renderWidth / renderHeight
this.cameraManager.updateAspectRatio(renderAspectRatio)
} else {
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
}
this.sceneManager.renderBackground()
this.renderer.render(
@@ -243,10 +330,15 @@ class Load3d {
this.STATUS_MOUSE_ON_SCENE = onScene
}
updateStatusMouseOnViewer(onViewer: boolean): void {
this.STATUS_MOUSE_ON_VIEWER = onViewer
}
isActive(): boolean {
return (
this.STATUS_MOUSE_ON_NODE ||
this.STATUS_MOUSE_ON_SCENE ||
this.STATUS_MOUSE_ON_VIEWER ||
this.isRecording() ||
!this.INITIAL_RENDER_DONE
)
@@ -308,6 +400,34 @@ class Load3d {
this.sceneManager.backgroundTexture
)
if (
this.isViewerMode &&
this.sceneManager.backgroundTexture &&
this.sceneManager.backgroundMesh
) {
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
let renderHeight: number
if (containerAspectRatio > this.targetAspectRatio) {
renderHeight = containerHeight
renderWidth = renderHeight * this.targetAspectRatio
} else {
renderWidth = containerWidth
renderHeight = renderWidth / this.targetAspectRatio
}
this.sceneManager.updateBackgroundSize(
this.sceneManager.backgroundTexture,
this.sceneManager.backgroundMesh,
renderWidth,
renderHeight
)
}
this.forceRender()
}
@@ -340,6 +460,10 @@ class Load3d {
return this.cameraManager.getCurrentCameraType()
}
getCurrentModel(): THREE.Object3D | null {
return this.modelManager.currentModel
}
setCameraState(state: CameraState): void {
this.cameraManager.setCameraState(state)
@@ -397,6 +521,9 @@ class Load3d {
}
setTargetSize(width: number, height: number): void {
this.targetWidth = width
this.targetHeight = height
this.targetAspectRatio = width / height
this.previewManager.setTargetSize(width, height)
this.forceRender()
}
@@ -422,13 +549,30 @@ class Load3d {
return
}
const width = parentElement.clientWidth
const height = parentElement.clientHeight
const containerWidth = parentElement.clientWidth
const containerHeight = parentElement.clientHeight
this.cameraManager.handleResize(width, height)
this.sceneManager.handleResize(width, height)
if (this.isViewerMode) {
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
let renderHeight: number
this.renderer.setSize(width, height)
if (containerAspectRatio > this.targetAspectRatio) {
renderHeight = containerHeight
renderWidth = renderHeight * this.targetAspectRatio
} else {
renderWidth = containerWidth
renderHeight = renderWidth / this.targetAspectRatio
}
this.cameraManager.handleResize(renderWidth, renderHeight)
this.sceneManager.handleResize(renderWidth, renderHeight)
} else {
this.cameraManager.handleResize(containerWidth, containerHeight)
this.sceneManager.handleResize(containerWidth, containerHeight)
}
this.renderer.setSize(containerWidth, containerHeight)
this.previewManager.handleResize()
this.forceRender()

View File

@@ -27,10 +27,6 @@ class Load3dAnimation extends Load3d {
this.overrideAnimationLoop()
}
private getCurrentModel(): THREE.Object3D | null {
return this.modelManager.currentModel
}
private overrideAnimationLoop(): void {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)

View File

@@ -18,7 +18,7 @@ import {
UpDirection
} from './interfaces'
export class ModelManager implements ModelManagerInterface {
export class SceneModelManager implements ModelManagerInterface {
currentModel: THREE.Object3D | null = null
originalModel:
| THREE.Object3D
@@ -663,6 +663,12 @@ export class ModelManager implements ModelManagerInterface {
this.originalMaterials = new WeakMap()
}
addModelToScene(model: THREE.Object3D): void {
this.currentModel = model
this.scene.add(this.currentModel)
}
async setupModel(model: THREE.Object3D): Promise<void> {
this.currentModel = model

View File

@@ -37,6 +37,8 @@ export interface EventCallback {
export interface Load3DOptions {
node?: LGraphNode
inputSpec?: CustomInputSpec
disablePreview?: boolean
isViewerMode?: boolean
}
export interface CaptureResult {
@@ -159,6 +161,7 @@ export interface ModelManagerInterface {
clearModel(): void
reset(): void
setupModel(model: THREE.Object3D): Promise<void>
addModelToScene(model: THREE.Object3D): void
setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void
setUpDirection(direction: UpDirection): void
materialMode: MaterialMode