From d22d62b670ac40df806aa2ec79df23474c08eb69 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Mon, 11 Aug 2025 00:09:19 -0400 Subject: [PATCH] [3d] initial version of 3d viewer (#3968) Co-authored-by: github-actions --- src/assets/css/style.css | 3 +- src/components/graph/SelectionToolbox.vue | 2 + .../selectionToolbox/Load3DViewerButton.vue | 38 ++ src/components/load3d/Load3D.vue | 18 +- src/components/load3d/Load3dViewerContent.vue | 149 +++++ .../load3d/controls/ViewerControls.vue | 52 ++ .../load3d/controls/viewer/CameraControls.vue | 37 ++ .../load3d/controls/viewer/ExportControls.vue | 37 ++ .../load3d/controls/viewer/LightControls.vue | 30 + .../load3d/controls/viewer/ModelControls.vue | 52 ++ .../load3d/controls/viewer/SceneControls.vue | 82 +++ src/composables/useLoad3dViewer.ts | 376 +++++++++++ src/extensions/core/load3d.ts | 49 ++ src/extensions/core/load3d/CameraManager.ts | 8 +- src/extensions/core/load3d/Load3d.ts | 190 +++++- src/extensions/core/load3d/Load3dAnimation.ts | 4 - .../{ModelManager.ts => SceneModelManager.ts} | 8 +- src/extensions/core/load3d/interfaces.ts | 3 + src/locales/en/commands.json | 3 + src/locales/en/main.json | 28 +- src/locales/en/settings.json | 4 + src/locales/es/commands.json | 3 + src/locales/es/main.json | 24 +- src/locales/es/settings.json | 4 + src/locales/fr/commands.json | 3 + src/locales/fr/main.json | 24 +- src/locales/fr/settings.json | 4 + src/locales/ja/commands.json | 3 + src/locales/ja/main.json | 24 +- src/locales/ja/settings.json | 4 + src/locales/ko/commands.json | 3 + src/locales/ko/main.json | 24 +- src/locales/ko/settings.json | 4 + src/locales/ru/commands.json | 3 + src/locales/ru/main.json | 24 +- src/locales/ru/settings.json | 4 + src/locales/zh-TW/commands.json | 3 + src/locales/zh-TW/main.json | 24 +- src/locales/zh-TW/settings.json | 4 + src/locales/zh/commands.json | 3 + src/locales/zh/main.json | 24 +- src/locales/zh/settings.json | 4 + src/schemas/apiSchema.ts | 1 + src/services/load3dService.ts | 108 ++++ src/utils/litegraphUtil.ts | 8 + .../tests/composables/useLoad3dViewer.test.ts | 606 ++++++++++++++++++ 46 files changed, 2071 insertions(+), 42 deletions(-) create mode 100644 src/components/graph/selectionToolbox/Load3DViewerButton.vue create mode 100644 src/components/load3d/Load3dViewerContent.vue create mode 100644 src/components/load3d/controls/ViewerControls.vue create mode 100644 src/components/load3d/controls/viewer/CameraControls.vue create mode 100644 src/components/load3d/controls/viewer/ExportControls.vue create mode 100644 src/components/load3d/controls/viewer/LightControls.vue create mode 100644 src/components/load3d/controls/viewer/ModelControls.vue create mode 100644 src/components/load3d/controls/viewer/SceneControls.vue create mode 100644 src/composables/useLoad3dViewer.ts rename src/extensions/core/load3d/{ModelManager.ts => SceneModelManager.ts} (99%) create mode 100644 tests-ui/tests/composables/useLoad3dViewer.test.ts diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 289392447..d5a6285d3 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -616,7 +616,8 @@ audio.comfy-audio.empty-audio-widget { .comfy-load-3d canvas, .comfy-load-3d-animation canvas, .comfy-preview-3d canvas, -.comfy-preview-3d-animation canvas{ +.comfy-preview-3d-animation canvas, +.comfy-load-3d-viewer canvas{ display: flex; width: 100% !important; height: 100% !important; diff --git a/src/components/graph/SelectionToolbox.vue b/src/components/graph/SelectionToolbox.vue index 3c1ac42aa..d4cf408fc 100644 --- a/src/components/graph/SelectionToolbox.vue +++ b/src/components/graph/SelectionToolbox.vue @@ -13,6 +13,7 @@ + @@ -38,6 +39,7 @@ import EditModelButton from '@/components/graph/selectionToolbox/EditModelButton import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue' import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue' import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue' +import Load3DViewerButton from '@/components/graph/selectionToolbox/Load3DViewerButton.vue' import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue' import PinButton from '@/components/graph/selectionToolbox/PinButton.vue' import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue' diff --git a/src/components/graph/selectionToolbox/Load3DViewerButton.vue b/src/components/graph/selectionToolbox/Load3DViewerButton.vue new file mode 100644 index 000000000..b207e5018 --- /dev/null +++ b/src/components/graph/selectionToolbox/Load3DViewerButton.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/components/load3d/Load3D.vue b/src/components/load3d/Load3D.vue index 36d3eec0d..4f42dc1d7 100644 --- a/src/components/load3d/Load3D.vue +++ b/src/components/load3d/Load3D.vue @@ -58,8 +58,19 @@ @export-model="handleExportModel" />
+ +
+ +
+ useSettingStore().get('Comfy.Load3D.3DViewerEnable') +) const showPreviewButton = computed(() => { return !type.includes('Preview') diff --git a/src/components/load3d/Load3dViewerContent.vue b/src/components/load3d/Load3dViewerContent.vue new file mode 100644 index 000000000..86c451dad --- /dev/null +++ b/src/components/load3d/Load3dViewerContent.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/src/components/load3d/controls/ViewerControls.vue b/src/components/load3d/controls/ViewerControls.vue new file mode 100644 index 000000000..e5361d78b --- /dev/null +++ b/src/components/load3d/controls/ViewerControls.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/src/components/load3d/controls/viewer/CameraControls.vue b/src/components/load3d/controls/viewer/CameraControls.vue new file mode 100644 index 000000000..e2edb8cd5 --- /dev/null +++ b/src/components/load3d/controls/viewer/CameraControls.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/components/load3d/controls/viewer/ExportControls.vue b/src/components/load3d/controls/viewer/ExportControls.vue new file mode 100644 index 000000000..6164e5bb0 --- /dev/null +++ b/src/components/load3d/controls/viewer/ExportControls.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/components/load3d/controls/viewer/LightControls.vue b/src/components/load3d/controls/viewer/LightControls.vue new file mode 100644 index 000000000..d38ccfd4d --- /dev/null +++ b/src/components/load3d/controls/viewer/LightControls.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/components/load3d/controls/viewer/ModelControls.vue b/src/components/load3d/controls/viewer/ModelControls.vue new file mode 100644 index 000000000..6f1f38bdb --- /dev/null +++ b/src/components/load3d/controls/viewer/ModelControls.vue @@ -0,0 +1,52 @@ + + + diff --git a/src/components/load3d/controls/viewer/SceneControls.vue b/src/components/load3d/controls/viewer/SceneControls.vue new file mode 100644 index 000000000..3cabcc7be --- /dev/null +++ b/src/components/load3d/controls/viewer/SceneControls.vue @@ -0,0 +1,82 @@ + + + diff --git a/src/composables/useLoad3dViewer.ts b/src/composables/useLoad3dViewer.ts new file mode 100644 index 000000000..4066787fc --- /dev/null +++ b/src/composables/useLoad3dViewer.ts @@ -0,0 +1,376 @@ +import { ref, toRaw, watch } from 'vue' + +import Load3d from '@/extensions/core/load3d/Load3d' +import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' +import { + CameraType, + MaterialMode, + UpDirection +} from '@/extensions/core/load3d/interfaces' +import { t } from '@/i18n' +import { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import { useLoad3dService } from '@/services/load3dService' +import { useToastStore } from '@/stores/toastStore' + +interface Load3dViewerState { + backgroundColor: string + showGrid: boolean + cameraType: CameraType + fov: number + lightIntensity: number + cameraState: any + backgroundImage: string + upDirection: UpDirection + materialMode: MaterialMode + edgeThreshold: number +} + +export const useLoad3dViewer = (node: LGraphNode) => { + const backgroundColor = ref('') + const showGrid = ref(true) + const cameraType = ref('perspective') + const fov = ref(75) + const lightIntensity = ref(1) + const backgroundImage = ref('') + const hasBackgroundImage = ref(false) + const upDirection = ref('original') + const materialMode = ref('original') + const edgeThreshold = ref(85) + const needApplyChanges = ref(true) + + let load3d: Load3d | null = null + let sourceLoad3d: Load3d | null = null + + const initialState = ref({ + backgroundColor: '#282828', + showGrid: true, + cameraType: 'perspective', + fov: 75, + lightIntensity: 1, + cameraState: null, + backgroundImage: '', + upDirection: 'original', + materialMode: 'original', + edgeThreshold: 85 + }) + + watch(backgroundColor, (newColor) => { + if (!load3d) return + try { + load3d.setBackgroundColor(newColor) + } catch (error) { + console.error('Error updating background color:', error) + useToastStore().addAlert( + t('toastMessages.failedToUpdateBackgroundColor', { color: newColor }) + ) + } + }) + + watch(showGrid, (newValue) => { + if (!load3d) return + try { + load3d.toggleGrid(newValue) + } catch (error) { + console.error('Error toggling grid:', error) + useToastStore().addAlert( + t('toastMessages.failedToToggleGrid', { show: newValue ? 'on' : 'off' }) + ) + } + }) + + watch(cameraType, (newCameraType) => { + if (!load3d) return + try { + load3d.toggleCamera(newCameraType) + } catch (error) { + console.error('Error toggling camera:', error) + useToastStore().addAlert( + t('toastMessages.failedToToggleCamera', { camera: newCameraType }) + ) + } + }) + + watch(fov, (newFov) => { + if (!load3d) return + try { + load3d.setFOV(Number(newFov)) + } catch (error) { + console.error('Error updating FOV:', error) + useToastStore().addAlert( + t('toastMessages.failedToUpdateFOV', { fov: newFov }) + ) + } + }) + + watch(lightIntensity, (newValue) => { + if (!load3d) return + try { + load3d.setLightIntensity(Number(newValue)) + } catch (error) { + console.error('Error updating light intensity:', error) + useToastStore().addAlert( + t('toastMessages.failedToUpdateLightIntensity', { intensity: newValue }) + ) + } + }) + + watch(backgroundImage, async (newValue) => { + if (!load3d) return + try { + await load3d.setBackgroundImage(newValue) + hasBackgroundImage.value = !!newValue + } catch (error) { + console.error('Error updating background image:', error) + useToastStore().addAlert(t('toastMessages.failedToUpdateBackgroundImage')) + } + }) + + watch(upDirection, (newValue) => { + if (!load3d) return + try { + load3d.setUpDirection(newValue) + } catch (error) { + console.error('Error updating up direction:', error) + useToastStore().addAlert( + t('toastMessages.failedToUpdateUpDirection', { direction: newValue }) + ) + } + }) + + watch(materialMode, (newValue) => { + if (!load3d) return + try { + load3d.setMaterialMode(newValue) + } catch (error) { + console.error('Error updating material mode:', error) + useToastStore().addAlert( + t('toastMessages.failedToUpdateMaterialMode', { mode: newValue }) + ) + } + }) + + watch(edgeThreshold, (newValue) => { + if (!load3d) return + try { + load3d.setEdgeThreshold(Number(newValue)) + } catch (error) { + console.error('Error updating edge threshold:', error) + useToastStore().addAlert( + t('toastMessages.failedToUpdateEdgeThreshold', { threshold: newValue }) + ) + } + }) + + const initializeViewer = async ( + containerRef: HTMLElement, + source: Load3d + ) => { + if (!containerRef) return + + sourceLoad3d = source + + try { + load3d = new Load3d(containerRef, { + node: node, + disablePreview: true, + isViewerMode: true + }) + + await useLoad3dService().copyLoad3dState(source, load3d) + + const sourceCameraType = source.getCurrentCameraType() + const sourceCameraState = source.getCameraState() + + cameraType.value = sourceCameraType + backgroundColor.value = source.sceneManager.currentBackgroundColor + showGrid.value = source.sceneManager.gridHelper.visible + lightIntensity.value = (node.properties['Light Intensity'] as number) || 1 + + const backgroundInfo = source.sceneManager.getCurrentBackgroundInfo() + if ( + backgroundInfo.type === 'image' && + node.properties['Background Image'] + ) { + backgroundImage.value = node.properties['Background Image'] as string + hasBackgroundImage.value = true + } else { + backgroundImage.value = '' + hasBackgroundImage.value = false + } + + if (sourceCameraType === 'perspective') { + fov.value = source.cameraManager.perspectiveCamera.fov + } + + upDirection.value = source.modelManager.currentUpDirection + materialMode.value = source.modelManager.materialMode + edgeThreshold.value = (node.properties['Edge Threshold'] as number) || 85 + + initialState.value = { + backgroundColor: backgroundColor.value, + showGrid: showGrid.value, + cameraType: cameraType.value, + fov: fov.value, + lightIntensity: lightIntensity.value, + cameraState: sourceCameraState, + backgroundImage: backgroundImage.value, + upDirection: upDirection.value, + materialMode: materialMode.value, + edgeThreshold: edgeThreshold.value + } + + const width = node.widgets?.find((w) => w.name === 'width') + const height = node.widgets?.find((w) => w.name === 'height') + + if (width && height) { + load3d.setTargetSize( + toRaw(width).value as number, + toRaw(height).value as number + ) + } + } catch (error) { + console.error('Error initializing Load3d viewer:', error) + useToastStore().addAlert( + t('toastMessages.failedToInitializeLoad3dViewer') + ) + } + } + + const exportModel = async (format: string) => { + if (!load3d) return + + try { + await load3d.exportModel(format) + } catch (error) { + console.error('Error exporting model:', error) + useToastStore().addAlert( + t('toastMessages.failedToExportModel', { format: format.toUpperCase() }) + ) + } + } + + const handleResize = () => { + load3d?.handleResize() + } + + const handleMouseEnter = () => { + load3d?.updateStatusMouseOnViewer(true) + } + + const handleMouseLeave = () => { + load3d?.updateStatusMouseOnViewer(false) + } + + const restoreInitialState = () => { + const nodeValue = node + + needApplyChanges.value = false + + if (nodeValue.properties) { + nodeValue.properties['Background Color'] = + initialState.value.backgroundColor + nodeValue.properties['Show Grid'] = initialState.value.showGrid + nodeValue.properties['Camera Type'] = initialState.value.cameraType + nodeValue.properties['FOV'] = initialState.value.fov + nodeValue.properties['Light Intensity'] = + initialState.value.lightIntensity + nodeValue.properties['Camera Info'] = initialState.value.cameraState + nodeValue.properties['Background Image'] = + initialState.value.backgroundImage + } + } + + const applyChanges = async () => { + if (!sourceLoad3d || !load3d) return false + + const viewerCameraState = load3d.getCameraState() + const nodeValue = node + + if (nodeValue.properties) { + nodeValue.properties['Background Color'] = backgroundColor.value + nodeValue.properties['Show Grid'] = showGrid.value + nodeValue.properties['Camera Type'] = cameraType.value + nodeValue.properties['FOV'] = fov.value + nodeValue.properties['Light Intensity'] = lightIntensity.value + nodeValue.properties['Camera Info'] = viewerCameraState + nodeValue.properties['Background Image'] = backgroundImage.value + } + + await useLoad3dService().copyLoad3dState(load3d, sourceLoad3d) + + if (backgroundImage.value) { + await sourceLoad3d.setBackgroundImage(backgroundImage.value) + } + + sourceLoad3d.forceRender() + + if (nodeValue.graph) { + nodeValue.graph.setDirtyCanvas(true, true) + } + + return true + } + + const refreshViewport = () => { + useLoad3dService().handleViewportRefresh(load3d) + } + + const handleBackgroundImageUpdate = async (file: File | null) => { + if (!file) { + backgroundImage.value = '' + hasBackgroundImage.value = false + return + } + + try { + const resourceFolder = + (node.properties['Resource Folder'] as string) || '' + const subfolder = resourceFolder.trim() + ? `3d/${resourceFolder.trim()}` + : '3d' + + const uploadPath = await Load3dUtils.uploadFile(file, subfolder) + + if (uploadPath) { + backgroundImage.value = uploadPath + hasBackgroundImage.value = true + } + } catch (error) { + console.error('Error uploading background image:', error) + useToastStore().addAlert(t('toastMessages.failedToUploadBackgroundImage')) + } + } + + const cleanup = () => { + load3d?.remove() + load3d = null + sourceLoad3d = null + } + + return { + // State + backgroundColor, + showGrid, + cameraType, + fov, + lightIntensity, + backgroundImage, + hasBackgroundImage, + upDirection, + materialMode, + edgeThreshold, + needApplyChanges, + + // Methods + initializeViewer, + exportModel, + handleResize, + handleMouseEnter, + handleMouseLeave, + restoreInitialState, + applyChanges, + refreshViewport, + handleBackgroundImageUpdate, + cleanup + } +} diff --git a/src/extensions/core/load3d.ts b/src/extensions/core/load3d.ts index 5dc97ae75..e70f96820 100644 --- a/src/extensions/core/load3d.ts +++ b/src/extensions/core/load3d.ts @@ -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() { diff --git a/src/extensions/core/load3d/CameraManager.ts b/src/extensions/core/load3d/CameraManager.ts index e990e4fe7..2b6b568c9 100644 --- a/src/extensions/core/load3d/CameraManager.ts +++ b/src/extensions/core/load3d/CameraManager.ts @@ -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 diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index cacece808..37121ba6b 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -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() diff --git a/src/extensions/core/load3d/Load3dAnimation.ts b/src/extensions/core/load3d/Load3dAnimation.ts index 849cdcf31..78a9dfae8 100644 --- a/src/extensions/core/load3d/Load3dAnimation.ts +++ b/src/extensions/core/load3d/Load3dAnimation.ts @@ -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) diff --git a/src/extensions/core/load3d/ModelManager.ts b/src/extensions/core/load3d/SceneModelManager.ts similarity index 99% rename from src/extensions/core/load3d/ModelManager.ts rename to src/extensions/core/load3d/SceneModelManager.ts index e5a1c07f3..d4cd6f795 100644 --- a/src/extensions/core/load3d/ModelManager.ts +++ b/src/extensions/core/load3d/SceneModelManager.ts @@ -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 { this.currentModel = model diff --git a/src/extensions/core/load3d/interfaces.ts b/src/extensions/core/load3d/interfaces.ts index 0142fda91..51247a8a3 100644 --- a/src/extensions/core/load3d/interfaces.ts +++ b/src/extensions/core/load3d/interfaces.ts @@ -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 + addModelToScene(model: THREE.Object3D): void setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void setUpDirection(direction: UpDirection): void materialMode: MaterialMode diff --git a/src/locales/en/commands.json b/src/locales/en/commands.json index c7a9f7384..91732005f 100644 --- a/src/locales/en/commands.json +++ b/src/locales/en/commands.json @@ -35,6 +35,9 @@ "Comfy-Desktop_Restart": { "label": "Restart" }, + "Comfy_3DViewer_Open3DViewer": { + "label": "Open 3D Viewer (Beta) for Selected Node" + }, "Comfy_BrowseTemplates": { "label": "Browse Templates" }, diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 891a50402..bd7b28840 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -946,6 +946,7 @@ "Quit": "Quit", "Reinstall": "Reinstall", "Restart": "Restart", + "Open 3D Viewer (Beta) for Selected Node": "Open 3D Viewer (Beta) for Selected Node", "Browse Templates": "Browse Templates", "Add Edit Model Step": "Add Edit Model Step", "Delete Selected Items": "Delete Selected Items", @@ -1079,7 +1080,8 @@ "User": "User", "Credits": "Credits", "API Nodes": "API Nodes", - "Notification Preferences": "Notification Preferences" + "Notification Preferences": "Notification Preferences", + "3DViewer": "3DViewer" }, "serverConfigItems": { "listen": { @@ -1431,12 +1433,31 @@ "depth": "Depth", "lineart": "Lineart" }, + "upDirections": { + "original": "Original" + }, "startRecording": "Start Recording", "stopRecording": "Stop Recording", "exportRecording": "Export Recording", "clearRecording": "Clear Recording", "resizeNodeMatchOutput": "Resize Node to match output", - "loadingBackgroundImage": "Loading Background Image" + "loadingBackgroundImage": "Loading Background Image", + "cameraType": { + "perspective": "Perspective", + "orthographic": "Orthographic" + }, + "viewer": { + "title": "3D Viewer (Beta)", + "apply": "Apply", + "cancel": "Cancel", + "cameraType": "Camera Type", + "sceneSettings": "Scene Settings", + "cameraSettings": "Camera Settings", + "lightSettings": "Light Settings", + "exportSettings": "Export Settings", + "modelSettings": "Model Settings" + }, + "openIn3DViewer": "Open in 3D Viewer" }, "toastMessages": { "nothingToQueue": "Nothing to queue", @@ -1474,7 +1495,8 @@ "useApiKeyTip": "Tip: Can't access normal login? Use the Comfy API Key option.", "nothingSelected": "Nothing selected", "cannotCreateSubgraph": "Cannot create subgraph", - "failedToConvertToSubgraph": "Failed to convert items to subgraph" + "failedToConvertToSubgraph": "Failed to convert items to subgraph", + "failedToInitializeLoad3dViewer": "Failed to initialize 3D Viewer" }, "auth": { "apiKey": { diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index f642f69c8..01fcd79ac 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -119,6 +119,10 @@ "Hidden": "Hidden" } }, + "Comfy_Load3D_3DViewerEnable": { + "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." + }, "Comfy_Load3D_BackgroundColor": { "name": "Initial Background Color", "tooltip": "Controls the default background color of the 3D scene. This setting determines the background appearance when a new 3D widget is created, but can be adjusted individually for each widget after creation." diff --git a/src/locales/es/commands.json b/src/locales/es/commands.json index 25d93f047..1976ba922 100644 --- a/src/locales/es/commands.json +++ b/src/locales/es/commands.json @@ -35,6 +35,9 @@ "Comfy-Desktop_Restart": { "label": "Reiniciar" }, + "Comfy_3DViewer_Open3DViewer": { + "label": "Abrir visor 3D (Beta) para el nodo seleccionado" + }, "Comfy_BrowseTemplates": { "label": "Explorar plantillas" }, diff --git a/src/locales/es/main.json b/src/locales/es/main.json index c168c0e36..f7d451289 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -569,6 +569,10 @@ "applyingTexture": "Aplicando textura...", "backgroundColor": "Color de fondo", "camera": "Cámara", + "cameraType": { + "orthographic": "Ortográfica", + "perspective": "Perspectiva" + }, "clearRecording": "Borrar grabación", "edgeThreshold": "Umbral de borde", "export": "Exportar", @@ -589,6 +593,7 @@ "wireframe": "Malla" }, "model": "Modelo", + "openIn3DViewer": "Abrir en el visor 3D", "previewOutput": "Vista previa de salida", "removeBackgroundImage": "Eliminar imagen de fondo", "resizeNodeMatchOutput": "Redimensionar nodo para coincidir con la salida", @@ -599,8 +604,22 @@ "switchCamera": "Cambiar cámara", "switchingMaterialMode": "Cambiando modo de material...", "upDirection": "Dirección hacia arriba", + "upDirections": { + "original": "Original" + }, "uploadBackgroundImage": "Subir imagen de fondo", - "uploadTexture": "Subir textura" + "uploadTexture": "Subir textura", + "viewer": { + "apply": "Aplicar", + "cameraSettings": "Configuración de la cámara", + "cameraType": "Tipo de cámara", + "cancel": "Cancelar", + "exportSettings": "Configuración de exportación", + "lightSettings": "Configuración de la luz", + "modelSettings": "Configuración del modelo", + "sceneSettings": "Configuración de la escena", + "title": "Visor 3D (Beta)" + } }, "loadWorkflowWarning": { "coreNodesFromVersion": "Requiere ComfyUI {version}:", @@ -784,6 +803,7 @@ "New": "Nuevo", "Next Opened Workflow": "Siguiente flujo de trabajo abierto", "Open": "Abrir", + "Open 3D Viewer (Beta) for Selected Node": "Abrir visor 3D (Beta) para el nodo seleccionado", "Open Custom Nodes Folder": "Abrir carpeta de nodos personalizados", "Open DevTools": "Abrir DevTools", "Open Inputs Folder": "Abrir carpeta de entradas", @@ -1097,6 +1117,7 @@ }, "settingsCategories": { "3D": "3D", + "3DViewer": "Visor 3D", "API Nodes": "Nodos API", "About": "Acerca de", "Appearance": "Apariencia", @@ -1573,6 +1594,7 @@ "failedToExportModel": "Error al exportar modelo como {format}", "failedToFetchBalance": "No se pudo obtener el saldo: {error}", "failedToFetchLogs": "Error al obtener los registros del servidor", + "failedToInitializeLoad3dViewer": "No se pudo inicializar el visor 3D", "failedToInitiateCreditPurchase": "No se pudo iniciar la compra de créditos: {error}", "failedToPurchaseCredits": "No se pudo comprar créditos: {error}", "fileLoadError": "No se puede encontrar el flujo de trabajo en {fileName}", diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index b62c2ed57..0bc0af0a0 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -119,6 +119,10 @@ "Straight": "Recto" } }, + "Comfy_Load3D_3DViewerEnable": { + "name": "Habilitar visor 3D (Beta)", + "tooltip": "Activa el visor 3D (Beta) para los nodos seleccionados. Esta función te permite visualizar e interactuar con modelos 3D directamente dentro del visor 3D a tamaño completo." + }, "Comfy_Load3D_BackgroundColor": { "name": "Color de fondo inicial", "tooltip": "Controla el color de fondo predeterminado de la escena 3D. Esta configuración determina la apariencia del fondo cuando se crea un nuevo widget 3D, pero puede ajustarse individualmente para cada widget después de su creación." diff --git a/src/locales/fr/commands.json b/src/locales/fr/commands.json index 436d2c890..5733457aa 100644 --- a/src/locales/fr/commands.json +++ b/src/locales/fr/commands.json @@ -35,6 +35,9 @@ "Comfy-Desktop_Restart": { "label": "Redémarrer" }, + "Comfy_3DViewer_Open3DViewer": { + "label": "Ouvrir le visualiseur 3D (bêta) pour le nœud sélectionné" + }, "Comfy_BrowseTemplates": { "label": "Parcourir les modèles" }, diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index be8fd5a26..57f36201c 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -569,6 +569,10 @@ "applyingTexture": "Application de la texture...", "backgroundColor": "Couleur de fond", "camera": "Caméra", + "cameraType": { + "orthographic": "Orthographique", + "perspective": "Perspective" + }, "clearRecording": "Effacer l'enregistrement", "edgeThreshold": "Seuil de Bordure", "export": "Exportation", @@ -589,6 +593,7 @@ "wireframe": "Fil de fer" }, "model": "Modèle", + "openIn3DViewer": "Ouvrir dans la visionneuse 3D", "previewOutput": "Aperçu de la sortie", "removeBackgroundImage": "Supprimer l'image de fond", "resizeNodeMatchOutput": "Redimensionner le nœud pour correspondre à la sortie", @@ -599,8 +604,22 @@ "switchCamera": "Changer de caméra", "switchingMaterialMode": "Changement de mode de matériau...", "upDirection": "Direction Haut", + "upDirections": { + "original": "Original" + }, "uploadBackgroundImage": "Télécharger l'image de fond", - "uploadTexture": "Télécharger Texture" + "uploadTexture": "Télécharger Texture", + "viewer": { + "apply": "Appliquer", + "cameraSettings": "Paramètres de la caméra", + "cameraType": "Type de caméra", + "cancel": "Annuler", + "exportSettings": "Paramètres d’exportation", + "lightSettings": "Paramètres de l’éclairage", + "modelSettings": "Paramètres du modèle", + "sceneSettings": "Paramètres de la scène", + "title": "Visionneuse 3D (Bêta)" + } }, "loadWorkflowWarning": { "coreNodesFromVersion": "Nécessite ComfyUI {version} :", @@ -784,6 +803,7 @@ "New": "Nouveau", "Next Opened Workflow": "Prochain flux de travail ouvert", "Open": "Ouvrir", + "Open 3D Viewer (Beta) for Selected Node": "Ouvrir le visualiseur 3D (bêta) pour le nœud sélectionné", "Open Custom Nodes Folder": "Ouvrir le dossier des nœuds personnalisés", "Open DevTools": "Ouvrir DevTools", "Open Inputs Folder": "Ouvrir le dossier des entrées", @@ -1097,6 +1117,7 @@ }, "settingsCategories": { "3D": "3D", + "3DViewer": "Visionneuse 3D", "API Nodes": "Nœuds API", "About": "À Propos", "Appearance": "Apparence", @@ -1573,6 +1594,7 @@ "failedToExportModel": "Échec de l'exportation du modèle en {format}", "failedToFetchBalance": "Échec de la récupération du solde : {error}", "failedToFetchLogs": "Échec de la récupération des journaux du serveur", + "failedToInitializeLoad3dViewer": "Échec de l'initialisation du visualiseur 3D", "failedToInitiateCreditPurchase": "Échec de l'initiation de l'achat de crédits : {error}", "failedToPurchaseCredits": "Échec de l'achat de crédits : {error}", "fileLoadError": "Impossible de trouver le flux de travail dans {fileName}", diff --git a/src/locales/fr/settings.json b/src/locales/fr/settings.json index b27172ec2..fd734e9a1 100644 --- a/src/locales/fr/settings.json +++ b/src/locales/fr/settings.json @@ -119,6 +119,10 @@ "Straight": "Droit" } }, + "Comfy_Load3D_3DViewerEnable": { + "name": "Activer le visualiseur 3D (Bêta)", + "tooltip": "Active le visualiseur 3D (Bêta) pour les nœuds sélectionnés. Cette fonctionnalité vous permet de visualiser et d’interagir avec des modèles 3D directement dans le visualiseur 3D en taille réelle." + }, "Comfy_Load3D_BackgroundColor": { "name": "Couleur de fond initiale", "tooltip": "Contrôle la couleur de fond par défaut de la scène 3D. Ce paramètre détermine l'apparence du fond lors de la création d'un nouveau widget 3D, mais peut être ajusté individuellement pour chaque widget après la création." diff --git a/src/locales/ja/commands.json b/src/locales/ja/commands.json index 8ddd83269..d78088249 100644 --- a/src/locales/ja/commands.json +++ b/src/locales/ja/commands.json @@ -35,6 +35,9 @@ "Comfy-Desktop_Restart": { "label": "再起動" }, + "Comfy_3DViewer_Open3DViewer": { + "label": "選択したノードの3Dビューアー(ベータ)を開く" + }, "Comfy_BrowseTemplates": { "label": "テンプレートを参照" }, diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index f5a6b2b0f..a14e97a50 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -569,6 +569,10 @@ "applyingTexture": "テクスチャを適用中...", "backgroundColor": "背景色", "camera": "カメラ", + "cameraType": { + "orthographic": "オルソグラフィック", + "perspective": "パースペクティブ" + }, "clearRecording": "録画をクリア", "edgeThreshold": "エッジ閾値", "export": "エクスポート", @@ -589,6 +593,7 @@ "wireframe": "ワイヤーフレーム" }, "model": "モデル", + "openIn3DViewer": "3Dビューアで開く", "previewOutput": "出力のプレビュー", "removeBackgroundImage": "背景画像を削除", "resizeNodeMatchOutput": "ノードを出力に合わせてリサイズ", @@ -599,8 +604,22 @@ "switchCamera": "カメラを切り替える", "switchingMaterialMode": "マテリアルモードの切り替え中...", "upDirection": "上方向", + "upDirections": { + "original": "オリジナル" + }, "uploadBackgroundImage": "背景画像をアップロード", - "uploadTexture": "テクスチャをアップロード" + "uploadTexture": "テクスチャをアップロード", + "viewer": { + "apply": "適用", + "cameraSettings": "カメラ設定", + "cameraType": "カメラタイプ", + "cancel": "キャンセル", + "exportSettings": "エクスポート設定", + "lightSettings": "ライト設定", + "modelSettings": "モデル設定", + "sceneSettings": "シーン設定", + "title": "3Dビューア(ベータ)" + } }, "loadWorkflowWarning": { "coreNodesFromVersion": "ComfyUI {version} が必要です:", @@ -784,6 +803,7 @@ "New": "新規", "Next Opened Workflow": "次に開いたワークフロー", "Open": "開く", + "Open 3D Viewer (Beta) for Selected Node": "選択したノードの3Dビューアー(ベータ)を開く", "Open Custom Nodes Folder": "カスタムノードフォルダを開く", "Open DevTools": "DevToolsを開く", "Open Inputs Folder": "入力フォルダを開く", @@ -1097,6 +1117,7 @@ }, "settingsCategories": { "3D": "3D", + "3DViewer": "3Dビューア", "API Nodes": "APIノード", "About": "情報", "Appearance": "外観", @@ -1573,6 +1594,7 @@ "failedToExportModel": "{format}としてモデルのエクスポートに失敗しました", "failedToFetchBalance": "残高の取得に失敗しました: {error}", "failedToFetchLogs": "サーバーログの取得に失敗しました", + "failedToInitializeLoad3dViewer": "3Dビューアの初期化に失敗しました", "failedToInitiateCreditPurchase": "クレジット購入の開始に失敗しました: {error}", "failedToPurchaseCredits": "クレジットの購入に失敗しました: {error}", "fileLoadError": "{fileName}でワークフローが見つかりません", diff --git a/src/locales/ja/settings.json b/src/locales/ja/settings.json index 46b239f15..accae3f8e 100644 --- a/src/locales/ja/settings.json +++ b/src/locales/ja/settings.json @@ -119,6 +119,10 @@ "Straight": "ストレート" } }, + "Comfy_Load3D_3DViewerEnable": { + "name": "3Dビューアーを有効化(ベータ)", + "tooltip": "選択したノードで3Dビューアー(ベータ)を有効にします。この機能により、フルサイズの3Dビューアー内で3Dモデルを直接可視化し、操作できます。" + }, "Comfy_Load3D_BackgroundColor": { "name": "初期背景色", "tooltip": "3Dシーンのデフォルト背景色を設定します。この設定は新しい3Dウィジェット作成時の背景の見た目を決定しますが、作成後に各ウィジェットごとに個別に調整できます。" diff --git a/src/locales/ko/commands.json b/src/locales/ko/commands.json index 4565cd36b..e57b74cba 100644 --- a/src/locales/ko/commands.json +++ b/src/locales/ko/commands.json @@ -35,6 +35,9 @@ "Comfy-Desktop_Restart": { "label": "재시작" }, + "Comfy_3DViewer_Open3DViewer": { + "label": "선택한 노드에 대해 3D 뷰어(베타) 열기" + }, "Comfy_BrowseTemplates": { "label": "템플릿 탐색" }, diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index 1e164bc45..552389357 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -569,6 +569,10 @@ "applyingTexture": "텍스처 적용 중...", "backgroundColor": "배경색", "camera": "카메라", + "cameraType": { + "orthographic": "직교", + "perspective": "원근" + }, "clearRecording": "녹화 지우기", "edgeThreshold": "엣지 임계값", "export": "내보내기", @@ -589,6 +593,7 @@ "wireframe": "와이어프레임" }, "model": "모델", + "openIn3DViewer": "3D 뷰어에서 열기", "previewOutput": "출력 미리보기", "removeBackgroundImage": "배경 이미지 제거", "resizeNodeMatchOutput": "노드 크기를 출력에 맞추기", @@ -599,8 +604,22 @@ "switchCamera": "카메라 전환", "switchingMaterialMode": "재질 모드 전환 중...", "upDirection": "위 방향", + "upDirections": { + "original": "원본" + }, "uploadBackgroundImage": "배경 이미지 업로드", - "uploadTexture": "텍스처 업로드" + "uploadTexture": "텍스처 업로드", + "viewer": { + "apply": "적용", + "cameraSettings": "카메라 설정", + "cameraType": "카메라 유형", + "cancel": "취소", + "exportSettings": "내보내기 설정", + "lightSettings": "조명 설정", + "modelSettings": "모델 설정", + "sceneSettings": "씬 설정", + "title": "3D 뷰어 (베타)" + } }, "loadWorkflowWarning": { "coreNodesFromVersion": "ComfyUI {version} 이상 필요:", @@ -784,6 +803,7 @@ "New": "새로 만들기", "Next Opened Workflow": "다음 열린 워크플로", "Open": "열기", + "Open 3D Viewer (Beta) for Selected Node": "선택한 노드에 대해 3D 뷰어(베타) 열기", "Open Custom Nodes Folder": "사용자 정의 노드 폴더 열기", "Open DevTools": "개발자 도구 열기", "Open Inputs Folder": "입력 폴더 열기", @@ -1097,6 +1117,7 @@ }, "settingsCategories": { "3D": "3D", + "3DViewer": "3D뷰어", "API Nodes": "API 노드", "About": "정보", "Appearance": "모양", @@ -1573,6 +1594,7 @@ "failedToExportModel": "{format} 형식으로 모델 내보내기에 실패했습니다", "failedToFetchBalance": "잔액을 가져오지 못했습니다: {error}", "failedToFetchLogs": "서버 로그를 가져오는 데 실패했습니다", + "failedToInitializeLoad3dViewer": "3D 뷰어 초기화에 실패했습니다", "failedToInitiateCreditPurchase": "크레딧 구매를 시작하지 못했습니다: {error}", "failedToPurchaseCredits": "크레딧 구매에 실패했습니다: {error}", "fileLoadError": "{fileName}에서 워크플로를 찾을 수 없습니다", diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 321704a20..9e46299ea 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -119,6 +119,10 @@ "Straight": "직선" } }, + "Comfy_Load3D_3DViewerEnable": { + "name": "3D 뷰어 활성화 (베타)", + "tooltip": "선택한 노드에 대해 3D 뷰어(베타)를 활성화합니다. 이 기능을 통해 전체 크기의 3D 뷰어에서 3D 모델을 직접 시각화하고 상호작용할 수 있습니다." + }, "Comfy_Load3D_BackgroundColor": { "name": "초기 배경색", "tooltip": "3D 장면의 기본 배경색을 설정합니다. 이 설정은 새 3D 위젯이 생성될 때 배경의 모양을 결정하지만, 생성 후 각 위젯별로 개별적으로 조정할 수 있습니다." diff --git a/src/locales/ru/commands.json b/src/locales/ru/commands.json index c1bbae269..a7e13af15 100644 --- a/src/locales/ru/commands.json +++ b/src/locales/ru/commands.json @@ -35,6 +35,9 @@ "Comfy-Desktop_Restart": { "label": "Перезагрузить" }, + "Comfy_3DViewer_Open3DViewer": { + "label": "Открыть 3D-просмотрщик (бета) для выбранного узла" + }, "Comfy_BrowseTemplates": { "label": "Просмотр шаблонов" }, diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index 9d6f6c9d5..422affc84 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -569,6 +569,10 @@ "applyingTexture": "Применение текстуры...", "backgroundColor": "Цвет фона", "camera": "Камера", + "cameraType": { + "orthographic": "Ортографическая", + "perspective": "Перспективная" + }, "clearRecording": "Очистить запись", "edgeThreshold": "Пороговое значение края", "export": "Экспорт", @@ -589,6 +593,7 @@ "wireframe": "Каркас" }, "model": "Модель", + "openIn3DViewer": "Открыть в 3D просмотрщике", "previewOutput": "Предварительный просмотр", "removeBackgroundImage": "Удалить фоновое изображение", "resizeNodeMatchOutput": "Изменить размер узла под вывод", @@ -599,8 +604,22 @@ "switchCamera": "Переключить камеру", "switchingMaterialMode": "Переключение режима материала...", "upDirection": "Направление Вверх", + "upDirections": { + "original": "Оригинал" + }, "uploadBackgroundImage": "Загрузить фоновое изображение", - "uploadTexture": "Загрузить текстуру" + "uploadTexture": "Загрузить текстуру", + "viewer": { + "apply": "Применить", + "cameraSettings": "Настройки камеры", + "cameraType": "Тип камеры", + "cancel": "Отмена", + "exportSettings": "Настройки экспорта", + "lightSettings": "Настройки освещения", + "modelSettings": "Настройки модели", + "sceneSettings": "Настройки сцены", + "title": "3D Просмотрщик (Бета)" + } }, "loadWorkflowWarning": { "coreNodesFromVersion": "Требуется ComfyUI {version}:", @@ -784,6 +803,7 @@ "New": "Новый", "Next Opened Workflow": "Следующий открытый рабочий процесс", "Open": "Открыть", + "Open 3D Viewer (Beta) for Selected Node": "Открыть 3D-просмотрщик (бета) для выбранного узла", "Open Custom Nodes Folder": "Открыть папку пользовательских нод", "Open DevTools": "Открыть инструменты разработчика", "Open Inputs Folder": "Открыть папку входных данных", @@ -1097,6 +1117,7 @@ }, "settingsCategories": { "3D": "3D", + "3DViewer": "3D-просмотрщик", "API Nodes": "API-узлы", "About": "О программе", "Appearance": "Внешний вид", @@ -1573,6 +1594,7 @@ "failedToExportModel": "Не удалось экспортировать модель как {format}", "failedToFetchBalance": "Не удалось получить баланс: {error}", "failedToFetchLogs": "Не удалось получить серверные логи", + "failedToInitializeLoad3dViewer": "Не удалось инициализировать 3D просмотрщик", "failedToInitiateCreditPurchase": "Не удалось начать покупку кредитов: {error}", "failedToPurchaseCredits": "Не удалось купить кредиты: {error}", "fileLoadError": "Не удалось найти рабочий процесс в {fileName}", diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index 14aad22be..14836ab16 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -119,6 +119,10 @@ "Straight": "Прямой" } }, + "Comfy_Load3D_3DViewerEnable": { + "name": "Включить 3D-просмотрщик (Бета)", + "tooltip": "Включает 3D-просмотрщик (Бета) для выбранных узлов. Эта функция позволяет визуализировать и взаимодействовать с 3D-моделями прямо в полноразмерном 3D-просмотрщике." + }, "Comfy_Load3D_BackgroundColor": { "name": "Начальный цвет фона", "tooltip": "Управляет цветом фона по умолчанию для 3D-сцены. Этот параметр определяет внешний вид фона при создании нового 3D-виджета, но может быть изменён индивидуально для каждого виджета после создания." diff --git a/src/locales/zh-TW/commands.json b/src/locales/zh-TW/commands.json index 3b1bb0f95..e7fb670ad 100644 --- a/src/locales/zh-TW/commands.json +++ b/src/locales/zh-TW/commands.json @@ -35,6 +35,9 @@ "Comfy-Desktop_Restart": { "label": "重新啟動" }, + "Comfy_3DViewer_Open3DViewer": { + "label": "為選取的節點開啟 3D 檢視器(Beta)" + }, "Comfy_BrowseTemplates": { "label": "瀏覽範本" }, diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json index 47edf5bb8..c6ec301d9 100644 --- a/src/locales/zh-TW/main.json +++ b/src/locales/zh-TW/main.json @@ -569,6 +569,10 @@ "applyingTexture": "正在套用材質貼圖...", "backgroundColor": "背景顏色", "camera": "相機", + "cameraType": { + "orthographic": "正交", + "perspective": "透視" + }, "clearRecording": "清除錄影", "edgeThreshold": "邊緣閾值", "export": "匯出", @@ -589,6 +593,7 @@ "wireframe": "線框" }, "model": "模型", + "openIn3DViewer": "在 3D 檢視器中開啟", "previewOutput": "預覽輸出", "removeBackgroundImage": "移除背景圖片", "resizeNodeMatchOutput": "調整節點以符合輸出", @@ -599,8 +604,22 @@ "switchCamera": "切換相機", "switchingMaterialMode": "正在切換材質模式...", "upDirection": "上方方向", + "upDirections": { + "original": "原始" + }, "uploadBackgroundImage": "上傳背景圖片", - "uploadTexture": "上傳材質貼圖" + "uploadTexture": "上傳材質貼圖", + "viewer": { + "apply": "套用", + "cameraSettings": "相機設定", + "cameraType": "相機類型", + "cancel": "取消", + "exportSettings": "匯出設定", + "lightSettings": "燈光設定", + "modelSettings": "模型設定", + "sceneSettings": "場景設定", + "title": "3D 檢視器(測試版)" + } }, "loadWorkflowWarning": { "coreNodesFromVersion": "需要 ComfyUI {version}:", @@ -784,6 +803,7 @@ "New": "新增", "Next Opened Workflow": "下一個已開啟的工作流程", "Open": "開啟", + "Open 3D Viewer (Beta) for Selected Node": "為選取的節點開啟 3D 檢視器(Beta 版)", "Open Custom Nodes Folder": "開啟自訂節點資料夾", "Open DevTools": "開啟開發者工具", "Open Inputs Folder": "開啟輸入資料夾", @@ -1097,6 +1117,7 @@ }, "settingsCategories": { "3D": "3D", + "3DViewer": "3D 檢視器", "API Nodes": "API 節點", "About": "關於", "Appearance": "外觀", @@ -1573,6 +1594,7 @@ "failedToExportModel": "無法將模型匯出為 {format}", "failedToFetchBalance": "取得餘額失敗:{error}", "failedToFetchLogs": "無法取得伺服器日誌", + "failedToInitializeLoad3dViewer": "初始化 3D 檢視器失敗", "failedToInitiateCreditPurchase": "啟動點數購買失敗:{error}", "failedToPurchaseCredits": "購買點數失敗:{error}", "fileLoadError": "無法在 {fileName} 中找到工作流程", diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index 1bd661a1b..360e7ee74 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -119,6 +119,10 @@ "Straight": "直線" } }, + "Comfy_Load3D_3DViewerEnable": { + "name": "啟用 3D 檢視器(測試版)", + "tooltip": "為所選節點啟用 3D 檢視器(測試版)。此功能可讓您在全尺寸 3D 檢視器中直接瀏覽與互動 3D 模型。" + }, "Comfy_Load3D_BackgroundColor": { "name": "初始背景顏色", "tooltip": "控制 3D 場景的預設背景顏色。此設定決定新建立 3D 元件時的背景外觀,但每個元件在建立後都可單獨調整。" diff --git a/src/locales/zh/commands.json b/src/locales/zh/commands.json index 9b12e850a..e26797b83 100644 --- a/src/locales/zh/commands.json +++ b/src/locales/zh/commands.json @@ -35,6 +35,9 @@ "Comfy-Desktop_Restart": { "label": "重启" }, + "Comfy_3DViewer_Open3DViewer": { + "label": "為所選節點開啟 3D 檢視器(Beta 版)" + }, "Comfy_BrowseTemplates": { "label": "浏览模板" }, diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index 7168ebb76..2d88f542b 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -569,6 +569,10 @@ "applyingTexture": "应用纹理中...", "backgroundColor": "背景颜色", "camera": "摄影机", + "cameraType": { + "orthographic": "正交", + "perspective": "透视" + }, "clearRecording": "清除录制", "edgeThreshold": "边缘阈值", "export": "导出", @@ -589,6 +593,7 @@ "wireframe": "线框" }, "model": "模型", + "openIn3DViewer": "在 3D 檢視器中開啟", "previewOutput": "预览输出", "removeBackgroundImage": "移除背景图片", "resizeNodeMatchOutput": "调整节点以匹配输出", @@ -599,8 +604,22 @@ "switchCamera": "切换摄影机类型", "switchingMaterialMode": "切换材质模式中...", "upDirection": "上方向", + "upDirections": { + "original": "原始" + }, "uploadBackgroundImage": "上传背景图片", - "uploadTexture": "上传纹理" + "uploadTexture": "上传纹理", + "viewer": { + "apply": "套用", + "cameraSettings": "相機設定", + "cameraType": "相機類型", + "cancel": "取消", + "exportSettings": "匯出設定", + "lightSettings": "燈光設定", + "modelSettings": "模型設定", + "sceneSettings": "場景設定", + "title": "3D 檢視器(測試版)" + } }, "loadWorkflowWarning": { "coreNodesFromVersion": "需要 ComfyUI {version}:", @@ -784,6 +803,7 @@ "New": "新建", "Next Opened Workflow": "下一个打开的工作流", "Open": "打开", + "Open 3D Viewer (Beta) for Selected Node": "為所選節點開啟 3D 檢視器(Beta 版)", "Open Custom Nodes Folder": "打开自定义节点文件夹", "Open DevTools": "打开开发者工具", "Open Inputs Folder": "打开输入文件夹", @@ -1097,6 +1117,7 @@ }, "settingsCategories": { "3D": "3D", + "3DViewer": "3D 檢視器", "API Nodes": "API 节点", "About": "关于", "Appearance": "外观", @@ -1573,6 +1594,7 @@ "failedToExportModel": "无法将模型导出为 {format}", "failedToFetchBalance": "获取余额失败:{error}", "failedToFetchLogs": "无法获取服务器日志", + "failedToInitializeLoad3dViewer": "初始化 3D 檢視器失敗", "failedToInitiateCreditPurchase": "发起积分购买失败:{error}", "failedToPurchaseCredits": "购买积分失败:{error}", "fileLoadError": "无法在 {fileName} 中找到工作流", diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 12ecbb5ed..32e81246e 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -119,6 +119,10 @@ "Straight": "直角线" } }, + "Comfy_Load3D_3DViewerEnable": { + "name": "啟用 3D 檢視器(測試版)", + "tooltip": "為所選節點啟用 3D 檢視器(測試版)。此功能可讓您直接在全尺寸 3D 檢視器中瀏覽並互動 3D 模型。" + }, "Comfy_Load3D_BackgroundColor": { "name": "初始背景颜色", "tooltip": "控制3D场景的默认背景颜色。此设置决定新建3D组件时的背景外观,但每个组件在创建后都可以单独调整。" diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts index 887d16537..733f0b4ff 100644 --- a/src/schemas/apiSchema.ts +++ b/src/schemas/apiSchema.ts @@ -489,6 +489,7 @@ const zSettings = z.object({ 'Comfy.Load3D.LightIntensityMinimum': z.number(), 'Comfy.Load3D.LightAdjustmentIncrement': z.number(), 'Comfy.Load3D.CameraType': z.enum(['perspective', 'orthographic']), + 'Comfy.Load3D.3DViewerEnable': z.boolean(), 'pysssss.SnapToGrid': z.boolean(), /** VHS setting is used for queue video preview support. */ 'VHS.AdvancedPreviews': z.string(), diff --git a/src/services/load3dService.ts b/src/services/load3dService.ts index f32685fb5..3ea5cf8a7 100644 --- a/src/services/load3dService.ts +++ b/src/services/load3dService.ts @@ -1,12 +1,16 @@ import { toRaw } from 'vue' +import { useLoad3dViewer } from '@/composables/useLoad3dViewer' import Load3d from '@/extensions/core/load3d/Load3d' import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { NodeId } from '@/schemas/comfyWorkflowSchema' import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' type Load3dReadyCallback = (load3d: Load3d | Load3dAnimation) => void +const viewerInstances = new Map() + export class Load3dService { private static instance: Load3dService private nodeToLoad3dMap = new Map() @@ -126,6 +130,110 @@ export class Load3dService { } this.pendingCallbacks.clear() } + + getOrCreateViewer(node: LGraphNode) { + if (!viewerInstances.has(node.id)) { + viewerInstances.set(node.id, useLoad3dViewer(node)) + } + + return viewerInstances.get(node.id) + } + + removeViewer(node: LGraphNode) { + const viewer = viewerInstances.get(node.id) + + if (viewer) { + viewer.cleanup() + } + + viewerInstances.delete(node.id) + } + + async copyLoad3dState(source: Load3d, target: Load3d | Load3dAnimation) { + const sourceModel = source.modelManager.currentModel + + if (sourceModel) { + const modelClone = sourceModel.clone() + + target.getModelManager().currentModel = modelClone + target.getSceneManager().scene.add(modelClone) + + target.getModelManager().materialMode = + source.getModelManager().materialMode + + target.getModelManager().currentUpDirection = + source.getModelManager().currentUpDirection + + target.setMaterialMode(source.getModelManager().materialMode) + target.setUpDirection(source.getModelManager().currentUpDirection) + + if (source.getModelManager().appliedTexture) { + target.getModelManager().appliedTexture = + source.getModelManager().appliedTexture + } + } + + const sourceCameraType = source.getCurrentCameraType() + const sourceCameraState = source.getCameraState() + + target.toggleCamera(sourceCameraType) + target.setCameraState(sourceCameraState) + + target.setBackgroundColor(source.getSceneManager().currentBackgroundColor) + + target.toggleGrid(source.getSceneManager().gridHelper.visible) + + const sourceBackgroundInfo = source + .getSceneManager() + .getCurrentBackgroundInfo() + if (sourceBackgroundInfo.type === 'image') { + const sourceNode = this.getNodeByLoad3d(source) + const backgroundPath = sourceNode?.properties?.[ + 'Background Image' + ] as string + if (backgroundPath) { + await target.setBackgroundImage(backgroundPath) + } + } + + target.setLightIntensity( + source.getLightingManager().lights[1]?.intensity || 1 + ) + + if (sourceCameraType === 'perspective') { + target.setFOV(source.getCameraManager().perspectiveCamera.fov) + } + + const sourceNode = this.getNodeByLoad3d(source) + if (sourceNode?.properties?.['Edge Threshold']) { + target.setEdgeThreshold(sourceNode.properties['Edge Threshold'] as number) + } + } + + handleViewportRefresh(load3d: Load3d | null) { + if (!load3d) return + + load3d.handleResize() + + const currentType = load3d.getCurrentCameraType() + + load3d.toggleCamera( + currentType === 'perspective' ? 'orthographic' : 'perspective' + ) + load3d.toggleCamera(currentType) + + load3d.getControlsManager().controls.update() + } + + async handleViewerClose(node: LGraphNode) { + const viewer = useLoad3dService().getOrCreateViewer(node) + + if (viewer.needApplyChanges.value) { + await viewer.applyChanges() + } + + useLoad3dService().removeViewer(node) + } } export const useLoad3dService = () => { diff --git a/src/utils/litegraphUtil.ts b/src/utils/litegraphUtil.ts index bb905fffd..f9fd5371d 100644 --- a/src/utils/litegraphUtil.ts +++ b/src/utils/litegraphUtil.ts @@ -239,3 +239,11 @@ function compressSubgraphWidgetInputSlots( compressSubgraphWidgetInputSlots(subgraph.definitions?.subgraphs, visited) } } + +export function isLoad3dNode(node: LGraphNode) { + return ( + node && + node.type && + (node.type === 'Load3D' || node.type === 'Load3DAnimation') + ) +} diff --git a/tests-ui/tests/composables/useLoad3dViewer.test.ts b/tests-ui/tests/composables/useLoad3dViewer.test.ts new file mode 100644 index 000000000..baf64055b --- /dev/null +++ b/tests-ui/tests/composables/useLoad3dViewer.test.ts @@ -0,0 +1,606 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' + +import { useLoad3dViewer } from '@/composables/useLoad3dViewer' +import Load3d from '@/extensions/core/load3d/Load3d' +import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' +import { useLoad3dService } from '@/services/load3dService' +import { useToastStore } from '@/stores/toastStore' + +vi.mock('@/services/load3dService', () => ({ + useLoad3dService: vi.fn() +})) + +vi.mock('@/stores/toastStore', () => ({ + useToastStore: vi.fn() +})) + +vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({ + default: { + uploadFile: vi.fn() + } +})) + +vi.mock('@/i18n', () => ({ + t: vi.fn((key) => key) +})) + +vi.mock('@/extensions/core/load3d/Load3d', () => ({ + default: vi.fn() +})) + +describe('useLoad3dViewer', () => { + let mockLoad3d: any + let mockSourceLoad3d: any + let mockLoad3dService: any + let mockToastStore: any + let mockNode: any + + beforeEach(() => { + vi.clearAllMocks() + + mockNode = { + properties: { + 'Background Color': '#282828', + 'Show Grid': true, + 'Camera Type': 'perspective', + FOV: 75, + 'Light Intensity': 1, + 'Camera Info': null, + 'Background Image': '', + 'Up Direction': 'original', + 'Material Mode': 'original', + 'Edge Threshold': 85 + }, + graph: { + setDirtyCanvas: vi.fn() + } + } as any + + mockLoad3d = { + setBackgroundColor: vi.fn(), + toggleGrid: vi.fn(), + toggleCamera: vi.fn(), + setFOV: vi.fn(), + setLightIntensity: vi.fn(), + setBackgroundImage: vi.fn().mockResolvedValue(undefined), + setUpDirection: vi.fn(), + setMaterialMode: vi.fn(), + setEdgeThreshold: vi.fn(), + exportModel: vi.fn().mockResolvedValue(undefined), + handleResize: vi.fn(), + updateStatusMouseOnViewer: vi.fn(), + getCameraState: vi.fn().mockReturnValue({ + position: { x: 0, y: 0, z: 0 }, + target: { x: 0, y: 0, z: 0 }, + zoom: 1, + cameraType: 'perspective' + }), + forceRender: vi.fn(), + remove: vi.fn() + } + + mockSourceLoad3d = { + getCurrentCameraType: vi.fn().mockReturnValue('perspective'), + getCameraState: vi.fn().mockReturnValue({ + position: { x: 1, y: 1, z: 1 }, + target: { x: 0, y: 0, z: 0 }, + zoom: 1, + cameraType: 'perspective' + }), + sceneManager: { + currentBackgroundColor: '#282828', + gridHelper: { visible: true }, + getCurrentBackgroundInfo: vi.fn().mockReturnValue({ + type: 'color', + value: '#282828' + }) + }, + lightingManager: { + lights: [null, { intensity: 1 }] + }, + cameraManager: { + perspectiveCamera: { fov: 75 } + }, + modelManager: { + currentUpDirection: 'original', + materialMode: 'original' + }, + setBackgroundImage: vi.fn().mockResolvedValue(undefined), + forceRender: vi.fn() + } + + vi.mocked(Load3d).mockImplementation(() => mockLoad3d) + + mockLoad3dService = { + copyLoad3dState: vi.fn().mockResolvedValue(undefined), + handleViewportRefresh: vi.fn(), + getLoad3d: vi.fn().mockReturnValue(mockSourceLoad3d) + } + vi.mocked(useLoad3dService).mockReturnValue(mockLoad3dService) + + mockToastStore = { + addAlert: vi.fn() + } + vi.mocked(useToastStore).mockReturnValue(mockToastStore) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('initialization', () => { + it('should initialize with default values', () => { + const viewer = useLoad3dViewer(mockNode) + + expect(viewer.backgroundColor.value).toBe('') + expect(viewer.showGrid.value).toBe(true) + expect(viewer.cameraType.value).toBe('perspective') + expect(viewer.fov.value).toBe(75) + expect(viewer.lightIntensity.value).toBe(1) + expect(viewer.backgroundImage.value).toBe('') + expect(viewer.hasBackgroundImage.value).toBe(false) + expect(viewer.upDirection.value).toBe('original') + expect(viewer.materialMode.value).toBe('original') + expect(viewer.edgeThreshold.value).toBe(85) + }) + + it('should initialize viewer with source Load3d state', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + expect(Load3d).toHaveBeenCalledWith(containerRef, { + disablePreview: true, + isViewerMode: true, + node: mockNode + }) + + expect(mockLoad3dService.copyLoad3dState).toHaveBeenCalledWith( + mockSourceLoad3d, + mockLoad3d + ) + + expect(viewer.cameraType.value).toBe('perspective') + expect(viewer.backgroundColor.value).toBe('#282828') + expect(viewer.showGrid.value).toBe(true) + expect(viewer.lightIntensity.value).toBe(1) + expect(viewer.fov.value).toBe(75) + expect(viewer.upDirection.value).toBe('original') + expect(viewer.materialMode.value).toBe('original') + expect(viewer.edgeThreshold.value).toBe(85) + }) + + it('should handle background image during initialization', async () => { + mockSourceLoad3d.sceneManager.getCurrentBackgroundInfo.mockReturnValue({ + type: 'image', + value: '' + }) + mockNode.properties['Background Image'] = 'test-image.jpg' + + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + expect(viewer.backgroundImage.value).toBe('test-image.jpg') + expect(viewer.hasBackgroundImage.value).toBe(true) + }) + + it('should handle initialization errors', async () => { + vi.mocked(Load3d).mockImplementationOnce(() => { + throw new Error('Load3d creation failed') + }) + + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + expect(mockToastStore.addAlert).toHaveBeenCalledWith( + 'toastMessages.failedToInitializeLoad3dViewer' + ) + }) + }) + + describe('state watchers', () => { + it('should update background color when state changes', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + viewer.backgroundColor.value = '#ff0000' + await nextTick() + + expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#ff0000') + }) + + it('should update grid visibility when state changes', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + viewer.showGrid.value = false + await nextTick() + + expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(false) + }) + + it('should update camera type when state changes', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + viewer.cameraType.value = 'orthographic' + await nextTick() + + expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic') + }) + + it('should update FOV when state changes', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + viewer.fov.value = 90 + await nextTick() + + expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90) + }) + + it('should update light intensity when state changes', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + viewer.lightIntensity.value = 2 + await nextTick() + + expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(2) + }) + + it('should update background image when state changes', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + viewer.backgroundImage.value = 'new-bg.jpg' + await nextTick() + + expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('new-bg.jpg') + expect(viewer.hasBackgroundImage.value).toBe(true) + }) + + it('should update up direction when state changes', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + viewer.upDirection.value = '+y' + await nextTick() + + expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y') + }) + + it('should update material mode when state changes', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + viewer.materialMode.value = 'wireframe' + await nextTick() + + expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe') + }) + + it('should update edge threshold when state changes', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + viewer.edgeThreshold.value = 90 + await nextTick() + + expect(mockLoad3d.setEdgeThreshold).toHaveBeenCalledWith(90) + }) + + it('should handle watcher errors gracefully', async () => { + mockLoad3d.setBackgroundColor.mockImplementationOnce(() => { + throw new Error('Color update failed') + }) + + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + viewer.backgroundColor.value = '#ff0000' + await nextTick() + + expect(mockToastStore.addAlert).toHaveBeenCalledWith( + 'toastMessages.failedToUpdateBackgroundColor' + ) + }) + }) + + describe('exportModel', () => { + it('should export model successfully', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + await viewer.exportModel('glb') + + expect(mockLoad3d.exportModel).toHaveBeenCalledWith('glb') + }) + + it('should handle export errors', async () => { + mockLoad3d.exportModel.mockRejectedValueOnce(new Error('Export failed')) + + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + await viewer.exportModel('glb') + + expect(mockToastStore.addAlert).toHaveBeenCalledWith( + 'toastMessages.failedToExportModel' + ) + }) + + it('should not export when load3d is not initialized', async () => { + const viewer = useLoad3dViewer(mockNode) + + await viewer.exportModel('glb') + + expect(mockLoad3d.exportModel).not.toHaveBeenCalled() + }) + }) + + describe('UI interaction methods', () => { + it('should handle resize', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + viewer.handleResize() + + expect(mockLoad3d.handleResize).toHaveBeenCalled() + }) + + it('should handle mouse enter', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + viewer.handleMouseEnter() + + expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true) + }) + + it('should handle mouse leave', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + viewer.handleMouseLeave() + + expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(false) + }) + }) + + describe('restoreInitialState', () => { + it('should restore all properties to initial values', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + mockNode.properties['Background Color'] = '#ff0000' + mockNode.properties['Show Grid'] = false + + viewer.restoreInitialState() + + expect(mockNode.properties['Background Color']).toBe('#282828') + expect(mockNode.properties['Show Grid']).toBe(true) + expect(mockNode.properties['Camera Type']).toBe('perspective') + expect(mockNode.properties['FOV']).toBe(75) + expect(mockNode.properties['Light Intensity']).toBe(1) + }) + }) + + describe('applyChanges', () => { + it('should apply all changes to source and node', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + viewer.backgroundColor.value = '#ff0000' + viewer.showGrid.value = false + + const result = await viewer.applyChanges() + + expect(result).toBe(true) + expect(mockNode.properties['Background Color']).toBe('#ff0000') + expect(mockNode.properties['Show Grid']).toBe(false) + expect(mockLoad3dService.copyLoad3dState).toHaveBeenCalledWith( + mockLoad3d, + mockSourceLoad3d + ) + expect(mockSourceLoad3d.forceRender).toHaveBeenCalled() + expect(mockNode.graph.setDirtyCanvas).toHaveBeenCalledWith(true, true) + }) + + it('should handle background image during apply', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + viewer.backgroundImage.value = 'new-bg.jpg' + + await viewer.applyChanges() + + expect(mockSourceLoad3d.setBackgroundImage).toHaveBeenCalledWith( + 'new-bg.jpg' + ) + }) + + it('should return false when no load3d instances', async () => { + const viewer = useLoad3dViewer(mockNode) + + const result = await viewer.applyChanges() + + expect(result).toBe(false) + }) + }) + + describe('refreshViewport', () => { + it('should refresh viewport', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + viewer.refreshViewport() + + expect(mockLoad3dService.handleViewportRefresh).toHaveBeenCalledWith( + mockLoad3d + ) + }) + }) + + describe('handleBackgroundImageUpdate', () => { + it('should upload and set background image', async () => { + vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg') + + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + const file = new File([''], 'test.jpg', { type: 'image/jpeg' }) + await viewer.handleBackgroundImageUpdate(file) + + expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d') + expect(viewer.backgroundImage.value).toBe('uploaded-image.jpg') + expect(viewer.hasBackgroundImage.value).toBe(true) + }) + + it('should use resource folder for upload', async () => { + mockNode.properties['Resource Folder'] = 'subfolder' + vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg') + + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + const file = new File([''], 'test.jpg', { type: 'image/jpeg' }) + await viewer.handleBackgroundImageUpdate(file) + + expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d/subfolder') + }) + + it('should clear background image when file is null', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + viewer.backgroundImage.value = 'existing.jpg' + viewer.hasBackgroundImage.value = true + + await viewer.handleBackgroundImageUpdate(null) + + expect(viewer.backgroundImage.value).toBe('') + expect(viewer.hasBackgroundImage.value).toBe(false) + }) + + it('should handle upload errors', async () => { + vi.mocked(Load3dUtils.uploadFile).mockRejectedValueOnce( + new Error('Upload failed') + ) + + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + const file = new File([''], 'test.jpg', { type: 'image/jpeg' }) + await viewer.handleBackgroundImageUpdate(file) + + expect(mockToastStore.addAlert).toHaveBeenCalledWith( + 'toastMessages.failedToUploadBackgroundImage' + ) + }) + }) + + describe('cleanup', () => { + it('should clean up resources', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + viewer.cleanup() + + expect(mockLoad3d.remove).toHaveBeenCalled() + }) + + it('should handle cleanup when not initialized', () => { + const viewer = useLoad3dViewer(mockNode) + + expect(() => viewer.cleanup()).not.toThrow() + }) + }) + + describe('edge cases', () => { + it('should handle missing container ref', async () => { + const viewer = useLoad3dViewer(mockNode) + + await viewer.initializeViewer(null as any, mockSourceLoad3d) + + expect(Load3d).not.toHaveBeenCalled() + }) + + it('should handle orthographic camera', async () => { + mockSourceLoad3d.getCurrentCameraType.mockReturnValue('orthographic') + mockSourceLoad3d.cameraManager = {} // No perspective camera + + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + expect(viewer.cameraType.value).toBe('orthographic') + }) + + it('should handle missing lights', async () => { + mockSourceLoad3d.lightingManager.lights = [] + + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + await viewer.initializeViewer(containerRef, mockSourceLoad3d) + + expect(viewer.lightIntensity.value).toBe(1) // Default value + }) + }) +})