Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/assets/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/components/graph/SelectionToolbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<BypassButton />
<PinButton />
<EditModelButton />
<Load3DViewerButton />
<MaskEditorButton />
<ConvertToSubgraphButton />
<DeleteButton />
Expand All @@ -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'
Expand Down
38 changes: 38 additions & 0 deletions src/components/graph/selectionToolbox/Load3DViewerButton.vue
Original file line number Diff line number Diff line change
@@ -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>
18 changes: 17 additions & 1 deletion src/components/load3d/Load3D.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -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')
Expand Down
149 changes: 149 additions & 0 deletions src/components/load3d/Load3dViewerContent.vue
Original file line number Diff line number Diff line change
@@ -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 changes: 52 additions & 0 deletions src/components/load3d/controls/ViewerControls.vue
Original file line number Diff line number Diff line change
@@ -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 changes: 37 additions & 0 deletions src/components/load3d/controls/viewer/CameraControls.vue
Original file line number Diff line number Diff line change
@@ -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 changes: 37 additions & 0 deletions src/components/load3d/controls/viewer/ExportControls.vue
Original file line number Diff line number Diff line change
@@ -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>
Loading