mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-03 22:59:14 +00:00
[3d] initial version of 3d viewer (#3968)
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
<BypassButton />
|
||||
<PinButton />
|
||||
<EditModelButton />
|
||||
<Load3DViewerButton />
|
||||
<MaskEditorButton />
|
||||
<ConvertToSubgraphButton />
|
||||
<DeleteButton />
|
||||
@@ -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'
|
||||
|
||||
38
src/components/graph/selectionToolbox/Load3DViewerButton.vue
Normal file
38
src/components/graph/selectionToolbox/Load3DViewerButton.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="is3DNode"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_3DViewer_Open3DViewer.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-pencil"
|
||||
@click="open3DViewer"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const is3DNode = computed(() => {
|
||||
const enable3DViewer = useSettingStore().get('Comfy.Load3D.3DViewerEnable')
|
||||
const nodes = canvasStore.selectedItems.filter(isLGraphNode)
|
||||
|
||||
return nodes.length === 1 && nodes.some(isLoad3dNode) && enable3DViewer
|
||||
})
|
||||
|
||||
const open3DViewer = () => {
|
||||
void commandStore.execute('Comfy.3DViewer.Open3DViewer')
|
||||
}
|
||||
</script>
|
||||
@@ -58,8 +58,19 @@
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
<div
|
||||
v-if="showRecordingControls"
|
||||
v-if="enable3DViewer"
|
||||
class="absolute top-12 right-2 z-20 pointer-events-auto"
|
||||
>
|
||||
<ViewerControls :node="node" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showRecordingControls"
|
||||
class="absolute right-2 z-20 pointer-events-auto"
|
||||
:class="{
|
||||
'top-12': !enable3DViewer,
|
||||
'top-24': enable3DViewer
|
||||
}"
|
||||
>
|
||||
<RecordingControls
|
||||
:node="node"
|
||||
@@ -82,6 +93,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import Load3DControls from '@/components/load3d/Load3DControls.vue'
|
||||
import Load3DScene from '@/components/load3d/Load3DScene.vue'
|
||||
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
|
||||
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import {
|
||||
CameraType,
|
||||
@@ -91,6 +103,7 @@ import {
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -121,6 +134,9 @@ const isRecording = ref(false)
|
||||
const hasRecording = ref(false)
|
||||
const recordingDuration = ref(0)
|
||||
const showRecordingControls = ref(!inputSpec.isPreview)
|
||||
const enable3DViewer = computed(() =>
|
||||
useSettingStore().get('Comfy.Load3D.3DViewerEnable')
|
||||
)
|
||||
|
||||
const showPreviewButton = computed(() => {
|
||||
return !type.includes('Preview')
|
||||
|
||||
149
src/components/load3d/Load3dViewerContent.vue
Normal file
149
src/components/load3d/Load3dViewerContent.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div
|
||||
ref="viewerContentRef"
|
||||
class="flex w-full"
|
||||
:class="[maximized ? 'h-full' : 'h-[70vh]']"
|
||||
@mouseenter="viewer.handleMouseEnter"
|
||||
@mouseleave="viewer.handleMouseLeave"
|
||||
>
|
||||
<div ref="mainContentRef" class="flex-1 relative">
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="absolute w-full h-full comfy-load-3d-viewer"
|
||||
@resize="viewer.handleResize"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-72 flex flex-col">
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="space-y-2">
|
||||
<div class="p-2 space-y-4">
|
||||
<SceneControls
|
||||
v-model:background-color="viewer.backgroundColor.value"
|
||||
v-model:show-grid="viewer.showGrid.value"
|
||||
:has-background-image="viewer.hasBackgroundImage.value"
|
||||
@update-background-image="viewer.handleBackgroundImageUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-2 space-y-4">
|
||||
<ModelControls
|
||||
v-model:up-direction="viewer.upDirection.value"
|
||||
v-model:material-mode="viewer.materialMode.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-2 space-y-4">
|
||||
<CameraControls
|
||||
v-model:camera-type="viewer.cameraType.value"
|
||||
v-model:fov="viewer.fov.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-2 space-y-4">
|
||||
<LightControls
|
||||
v-model:light-intensity="viewer.lightIntensity.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-2 space-y-4">
|
||||
<ExportControls @export-model="viewer.exportModel" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
:label="t('g.cancel')"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { onBeforeUnmount, onMounted, ref, toRaw } from 'vue'
|
||||
|
||||
import CameraControls from '@/components/load3d/controls/viewer/CameraControls.vue'
|
||||
import ExportControls from '@/components/load3d/controls/viewer/ExportControls.vue'
|
||||
import LightControls from '@/components/load3d/controls/viewer/LightControls.vue'
|
||||
import ModelControls from '@/components/load3d/controls/viewer/ModelControls.vue'
|
||||
import SceneControls from '@/components/load3d/controls/viewer/SceneControls.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const props = defineProps<{
|
||||
node: LGraphNode
|
||||
}>()
|
||||
|
||||
const viewerContentRef = ref<HTMLDivElement>()
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const mainContentRef = ref<HTMLDivElement>()
|
||||
const maximized = ref(false)
|
||||
const mutationObserver = ref<MutationObserver | null>(null)
|
||||
|
||||
const viewer = useLoad3dService().getOrCreateViewer(toRaw(props.node))
|
||||
|
||||
onMounted(async () => {
|
||||
const source = useLoad3dService().getLoad3d(props.node)
|
||||
if (source && containerRef.value) {
|
||||
await viewer.initializeViewer(containerRef.value, source)
|
||||
}
|
||||
|
||||
if (viewerContentRef.value) {
|
||||
mutationObserver.value = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (
|
||||
mutation.type === 'attributes' &&
|
||||
mutation.attributeName === 'maximized'
|
||||
) {
|
||||
maximized.value =
|
||||
(mutation.target as HTMLElement).getAttribute('maximized') ===
|
||||
'true'
|
||||
|
||||
setTimeout(() => {
|
||||
viewer.refreshViewport()
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
mutationObserver.value.observe(viewerContentRef.value, {
|
||||
attributes: true,
|
||||
attributeFilter: ['maximized']
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('resize', viewer.handleResize)
|
||||
})
|
||||
|
||||
const handleCancel = () => {
|
||||
viewer.restoreInitialState()
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', viewer.handleResize)
|
||||
|
||||
if (mutationObserver.value) {
|
||||
mutationObserver.value.disconnect()
|
||||
mutationObserver.value = null
|
||||
}
|
||||
|
||||
// we will manually cleanup the viewer in dialog close handler
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-panel-content) {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
52
src/components/load3d/controls/ViewerControls.vue
Normal file
52
src/components/load3d/controls/ViewerControls.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="relative bg-gray-700 bg-opacity-30 rounded-lg">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button class="p-button-rounded p-button-text" @click="openIn3DViewer">
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.openIn3DViewer'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-expand text-white text-lg"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from 'primevue'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const vTooltip = Tooltip
|
||||
|
||||
const { node } = defineProps<{
|
||||
node: LGraphNode
|
||||
}>()
|
||||
|
||||
const openIn3DViewer = () => {
|
||||
const props = { node: node }
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
37
src/components/load3d/controls/viewer/CameraControls.vue
Normal file
37
src/components/load3d/controls/viewer/CameraControls.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<label>
|
||||
{{ t('load3d.viewer.cameraType') }}
|
||||
</label>
|
||||
<Select
|
||||
v-model="cameraType"
|
||||
:options="cameras"
|
||||
option-label="title"
|
||||
option-value="value"
|
||||
>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="showFOVButton" class="space-y-4">
|
||||
<label>{{ t('load3d.fov') }}</label>
|
||||
<Slider v-model="fov" :min="10" :max="150" :step="1" aria-label="fov" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const cameras = [
|
||||
{ title: t('load3d.cameraType.perspective'), value: 'perspective' },
|
||||
{ title: t('load3d.cameraType.orthographic'), value: 'orthographic' }
|
||||
]
|
||||
|
||||
const cameraType = defineModel<CameraType>('cameraType')
|
||||
const fov = defineModel<number>('fov')
|
||||
const showFOVButton = computed(() => cameraType.value === 'perspective')
|
||||
</script>
|
||||
37
src/components/load3d/controls/viewer/ExportControls.vue
Normal file
37
src/components/load3d/controls/viewer/ExportControls.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<Select
|
||||
v-model="exportFormat"
|
||||
:options="exportFormats"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
>
|
||||
</Select>
|
||||
|
||||
<Button severity="secondary" text rounded @click="exportModel(exportFormat)">
|
||||
{{ t('load3d.export') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Select from 'primevue/select'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'exportModel', format: string): void
|
||||
}>()
|
||||
|
||||
const exportFormats = [
|
||||
{ label: 'GLB', value: 'glb' },
|
||||
{ label: 'OBJ', value: 'obj' },
|
||||
{ label: 'STL', value: 'stl' }
|
||||
]
|
||||
|
||||
const exportFormat = ref('obj')
|
||||
|
||||
const exportModel = (format: string) => {
|
||||
emit('exportModel', format)
|
||||
}
|
||||
</script>
|
||||
30
src/components/load3d/controls/viewer/LightControls.vue
Normal file
30
src/components/load3d/controls/viewer/LightControls.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<label>{{ t('load3d.lightIntensity') }}</label>
|
||||
|
||||
<Slider
|
||||
v-model="lightIntensity"
|
||||
class="w-full"
|
||||
:min="lightIntensityMinimum"
|
||||
:max="lightIntensityMaximum"
|
||||
:step="lightAdjustmentIncrement"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Slider from 'primevue/slider'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
const lightIntensity = defineModel<number>('lightIntensity')
|
||||
|
||||
const lightIntensityMaximum = useSettingStore().get(
|
||||
'Comfy.Load3D.LightIntensityMaximum'
|
||||
)
|
||||
const lightIntensityMinimum = useSettingStore().get(
|
||||
'Comfy.Load3D.LightIntensityMinimum'
|
||||
)
|
||||
const lightAdjustmentIncrement = useSettingStore().get(
|
||||
'Comfy.Load3D.LightAdjustmentIncrement'
|
||||
)
|
||||
</script>
|
||||
52
src/components/load3d/controls/viewer/ModelControls.vue
Normal file
52
src/components/load3d/controls/viewer/ModelControls.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label>{{ t('load3d.upDirection') }}</label>
|
||||
<Select
|
||||
v-model="upDirection"
|
||||
:options="upDirectionOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{{ t('load3d.materialMode') }}</label>
|
||||
<Select
|
||||
v-model="materialMode"
|
||||
:options="materialModeOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { MaterialMode, UpDirection } from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const upDirection = defineModel<UpDirection>('upDirection')
|
||||
const materialMode = defineModel<MaterialMode>('materialMode')
|
||||
|
||||
const upDirectionOptions = [
|
||||
{ label: t('load3d.upDirections.original'), value: 'original' },
|
||||
{ label: '-X', value: '-x' },
|
||||
{ label: '+X', value: '+x' },
|
||||
{ label: '-Y', value: '-y' },
|
||||
{ label: '+Y', value: '+y' },
|
||||
{ label: '-Z', value: '-z' },
|
||||
{ label: '+Z', value: '+z' }
|
||||
]
|
||||
|
||||
const materialModeOptions = computed(() => {
|
||||
return [
|
||||
{ label: t('load3d.materialModes.original'), value: 'original' },
|
||||
{ label: t('load3d.materialModes.normal'), value: 'normal' },
|
||||
{ label: t('load3d.materialModes.wireframe'), value: 'wireframe' }
|
||||
]
|
||||
})
|
||||
</script>
|
||||
82
src/components/load3d/controls/viewer/SceneControls.vue
Normal file
82
src/components/load3d/controls/viewer/SceneControls.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<label>
|
||||
{{ t('load3d.backgroundColor') }}
|
||||
</label>
|
||||
<input v-model="backgroundColor" type="color" class="w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Checkbox v-model="showGrid" input-id="showGrid" binary name="showGrid" />
|
||||
<label for="showGrid" class="pl-2">
|
||||
{{ t('load3d.showGrid') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<Button
|
||||
severity="secondary"
|
||||
:label="t('load3d.uploadBackgroundImage')"
|
||||
icon="pi pi-image"
|
||||
class="w-full"
|
||||
@click="openImagePicker"
|
||||
/>
|
||||
<input
|
||||
ref="imagePickerRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleImageUpload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="hasBackgroundImage" class="space-y-2">
|
||||
<Button
|
||||
severity="secondary"
|
||||
:label="t('load3d.removeBackgroundImage')"
|
||||
icon="pi pi-times"
|
||||
class="w-full"
|
||||
@click="removeBackgroundImage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const backgroundColor = defineModel<string>('backgroundColor')
|
||||
const showGrid = defineModel<boolean>('showGrid')
|
||||
|
||||
defineProps<{
|
||||
hasBackgroundImage?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateBackgroundImage', file: File | null): void
|
||||
}>()
|
||||
|
||||
const imagePickerRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const openImagePicker = () => {
|
||||
imagePickerRef.value?.click()
|
||||
}
|
||||
|
||||
const handleImageUpload = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files && input.files[0]) {
|
||||
emit('updateBackgroundImage', input.files[0])
|
||||
}
|
||||
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
const removeBackgroundImage = () => {
|
||||
emit('updateBackgroundImage', null)
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user