mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-19 11:59:37 +00:00
## Summary - Defer thumbnail capture until camera state is restored via new modelReady event so captureThumbnail no longer races with the saved view, fixing the "snap back to default on hover" regression. - Repaint the live scene at the end of captureThumbnail so the canvas is not left with the offscreen mask/normal pass when the render loop is gated. - Persist post-fitToViewer model.scale + model.position into the existing modelConfig.gizmo slot so a refresh reapplies them via the existing applyGizmoConfigToLoad3d path; rotation stays owned by upDirection. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11944-FE-537-fix-load3d-preserve-camera-view-fit-transform-and-first-frame-paint-after-re-3576d73d365081429653ea4740612617) by [Unito](https://www.unito.io)
1000 lines
26 KiB
TypeScript
1000 lines
26 KiB
TypeScript
import type { MaybeRef } from 'vue'
|
|
|
|
import { toRef, useDebounceFn } from '@vueuse/core'
|
|
import { getActivePinia } from 'pinia'
|
|
import { ref, toRaw, watch } from 'vue'
|
|
|
|
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
|
import type Load3d from '@/extensions/core/load3d/Load3d'
|
|
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
|
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
|
import {
|
|
isAssetPreviewSupported,
|
|
persistThumbnail
|
|
} from '@/platform/assets/utils/assetPreviewUtil'
|
|
import type {
|
|
AnimationItem,
|
|
CameraConfig,
|
|
CameraState,
|
|
CameraType,
|
|
EventCallback,
|
|
GizmoConfig,
|
|
GizmoMode,
|
|
LightConfig,
|
|
MaterialMode,
|
|
ModelConfig,
|
|
SceneConfig,
|
|
UpDirection
|
|
} from '@/extensions/core/load3d/interfaces'
|
|
import { t } from '@/i18n'
|
|
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
|
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
|
import { api } from '@/scripts/api'
|
|
import { app } from '@/scripts/app'
|
|
import { useLoad3dService } from '@/services/load3dService'
|
|
|
|
type Load3dReadyCallback = (load3d: Load3d) => void
|
|
export const nodeToLoad3dMap = new Map<LGraphNode, Load3d>()
|
|
const pendingCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
|
|
|
|
export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
|
const nodeRef = toRef(nodeOrRef)
|
|
let load3d: Load3d | null = null
|
|
let isFirstModelLoad = true
|
|
|
|
const debouncedHandleResize = useDebounceFn(() => {
|
|
load3d?.handleResize()
|
|
}, 150)
|
|
|
|
watch(
|
|
() => (getActivePinia() ? useCanvasStore().appScalePercentage : 0),
|
|
debouncedHandleResize
|
|
)
|
|
|
|
const sceneConfig = ref<SceneConfig>({
|
|
showGrid: true,
|
|
backgroundColor: '#000000',
|
|
backgroundImage: '',
|
|
backgroundRenderMode: 'tiled'
|
|
})
|
|
|
|
const modelConfig = ref<ModelConfig>({
|
|
upDirection: 'original',
|
|
materialMode: 'original',
|
|
showSkeleton: false,
|
|
gizmo: {
|
|
enabled: false,
|
|
mode: 'translate',
|
|
position: { x: 0, y: 0, z: 0 },
|
|
rotation: { x: 0, y: 0, z: 0 },
|
|
scale: { x: 1, y: 1, z: 1 }
|
|
}
|
|
})
|
|
|
|
const hasSkeleton = ref(false)
|
|
|
|
const cameraConfig = ref<CameraConfig>({
|
|
cameraType: 'perspective',
|
|
fov: 75
|
|
})
|
|
|
|
const lightConfig = ref<LightConfig>({
|
|
intensity: 5,
|
|
hdri: {
|
|
enabled: false,
|
|
hdriPath: '',
|
|
showAsBackground: false,
|
|
intensity: 1
|
|
}
|
|
})
|
|
const lastNonHdriLightIntensity = ref(lightConfig.value.intensity)
|
|
|
|
const isRecording = ref(false)
|
|
const hasRecording = ref(false)
|
|
const recordingDuration = ref(0)
|
|
|
|
const animations = ref<AnimationItem[]>([])
|
|
const playing = ref(false)
|
|
const selectedSpeed = ref(1)
|
|
const selectedAnimation = ref(0)
|
|
const animationProgress = ref(0)
|
|
const animationDuration = ref(0)
|
|
const loading = ref(false)
|
|
const loadingMessage = ref('')
|
|
const isPreview = ref(false)
|
|
const isSplatModel = ref(false)
|
|
const isPlyModel = ref(false)
|
|
const canFitToViewer = ref(true)
|
|
const canUseGizmo = ref(true)
|
|
const canUseLighting = ref(true)
|
|
const canExport = ref(true)
|
|
const materialModes = ref<readonly MaterialMode[]>([
|
|
'original',
|
|
'normal',
|
|
'wireframe'
|
|
])
|
|
|
|
const initializeLoad3d = async (containerRef: HTMLElement) => {
|
|
const rawNode = toRaw(nodeRef.value)
|
|
if (!containerRef || !rawNode) return
|
|
|
|
const node = rawNode as LGraphNode
|
|
|
|
try {
|
|
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
|
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
|
|
|
if (!(widthWidget && heightWidget)) {
|
|
isPreview.value = true
|
|
}
|
|
|
|
load3d = createLoad3d(containerRef, {
|
|
width: widthWidget?.value as number | undefined,
|
|
height: heightWidget?.value as number | undefined,
|
|
// Provide dynamic dimension getter for reactive updates
|
|
getDimensions:
|
|
widthWidget && heightWidget
|
|
? () => ({
|
|
width: widthWidget.value as number,
|
|
height: heightWidget.value as number
|
|
})
|
|
: undefined,
|
|
getZoomScale: () => app.canvas?.ds?.scale ?? 1,
|
|
onContextMenu: (event) => {
|
|
const menuOptions = app.canvas.getNodeMenuOptions(node)
|
|
new LiteGraph.ContextMenu(menuOptions, {
|
|
event,
|
|
title: node.type,
|
|
extra: node
|
|
})
|
|
}
|
|
})
|
|
|
|
await restoreConfigurationsFromNode(node)
|
|
|
|
node.onMouseEnter = useChainCallback(node.onMouseEnter, () => {
|
|
load3d?.refreshViewport()
|
|
load3d?.updateStatusMouseOnNode(true)
|
|
})
|
|
|
|
node.onMouseLeave = useChainCallback(node.onMouseLeave, () => {
|
|
load3d?.updateStatusMouseOnNode(false)
|
|
})
|
|
|
|
node.onResize = useChainCallback(node.onResize, () => {
|
|
load3d?.handleResize()
|
|
})
|
|
|
|
node.onDrawBackground = useChainCallback(
|
|
node.onDrawBackground,
|
|
function (this: LGraphNode) {
|
|
if (load3d) {
|
|
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
|
|
}
|
|
}
|
|
)
|
|
|
|
node.onRemoved = useChainCallback(node.onRemoved, () => {
|
|
useLoad3dService().removeLoad3d(node)
|
|
pendingCallbacks.delete(node)
|
|
})
|
|
|
|
nodeToLoad3dMap.set(node, load3d)
|
|
|
|
const callbacks = pendingCallbacks.get(node)
|
|
|
|
if (callbacks && load3d) {
|
|
callbacks.forEach((callback) => {
|
|
if (load3d) {
|
|
callback(load3d)
|
|
}
|
|
})
|
|
pendingCallbacks.delete(node)
|
|
}
|
|
|
|
handleEvents('add')
|
|
} catch (error) {
|
|
console.error('Error initializing Load3d:', error)
|
|
useToastStore().addAlert(
|
|
t('toastMessages.failedToInitializeLoad3dViewer')
|
|
)
|
|
}
|
|
}
|
|
|
|
const restoreConfigurationsFromNode = async (node: LGraphNode) => {
|
|
if (!load3d) return
|
|
|
|
// Restore configs - watchers will handle applying them to the Three.js scene
|
|
const savedSceneConfig = node.properties['Scene Config'] as SceneConfig
|
|
if (savedSceneConfig) {
|
|
sceneConfig.value = {
|
|
...sceneConfig.value,
|
|
...savedSceneConfig,
|
|
backgroundRenderMode: savedSceneConfig.backgroundRenderMode || 'tiled'
|
|
}
|
|
}
|
|
|
|
const savedModelConfig = node.properties['Model Config'] as ModelConfig
|
|
if (savedModelConfig) {
|
|
modelConfig.value = {
|
|
...savedModelConfig,
|
|
gizmo: savedModelConfig.gizmo
|
|
? {
|
|
...savedModelConfig.gizmo,
|
|
scale: savedModelConfig.gizmo.scale ?? { x: 1, y: 1, z: 1 }
|
|
}
|
|
: {
|
|
enabled: false,
|
|
mode: 'translate',
|
|
position: { x: 0, y: 0, z: 0 },
|
|
rotation: { x: 0, y: 0, z: 0 },
|
|
scale: { x: 1, y: 1, z: 1 }
|
|
}
|
|
}
|
|
}
|
|
|
|
const savedCameraConfig = node.properties['Camera Config'] as CameraConfig
|
|
|
|
if (savedCameraConfig) {
|
|
cameraConfig.value = savedCameraConfig
|
|
}
|
|
|
|
const savedLightConfig = node.properties['Light Config'] as LightConfig
|
|
const savedHdriEnabled = savedLightConfig?.hdri?.enabled ?? false
|
|
if (savedLightConfig) {
|
|
lightConfig.value = {
|
|
intensity: savedLightConfig.intensity ?? lightConfig.value.intensity,
|
|
hdri: {
|
|
...lightConfig.value.hdri!,
|
|
...savedLightConfig.hdri,
|
|
enabled: false
|
|
}
|
|
}
|
|
lastNonHdriLightIntensity.value = lightConfig.value.intensity
|
|
}
|
|
|
|
const hdri = lightConfig.value.hdri
|
|
let hdriLoaded = false
|
|
if (hdri?.hdriPath) {
|
|
const hdriUrl = api.apiURL(
|
|
Load3dUtils.getResourceURL(
|
|
...Load3dUtils.splitFilePath(hdri.hdriPath),
|
|
'input'
|
|
)
|
|
)
|
|
try {
|
|
await load3d.loadHDRI(hdriUrl)
|
|
hdriLoaded = true
|
|
} catch (error) {
|
|
console.warn('Failed to restore HDRI:', error)
|
|
lightConfig.value = {
|
|
...lightConfig.value,
|
|
hdri: { ...lightConfig.value.hdri!, hdriPath: '', enabled: false }
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hdriLoaded && savedHdriEnabled) {
|
|
lightConfig.value = {
|
|
...lightConfig.value,
|
|
hdri: { ...lightConfig.value.hdri!, enabled: true }
|
|
}
|
|
}
|
|
|
|
applySceneConfigToLoad3d()
|
|
applyLightConfigToLoad3d()
|
|
}
|
|
|
|
const applySceneConfigToLoad3d = () => {
|
|
if (!load3d) return
|
|
const cfg = sceneConfig.value
|
|
load3d.toggleGrid(cfg.showGrid)
|
|
if (!lightConfig.value.hdri?.enabled) {
|
|
load3d.setBackgroundColor(cfg.backgroundColor)
|
|
}
|
|
if (cfg.backgroundRenderMode) {
|
|
load3d.setBackgroundRenderMode(cfg.backgroundRenderMode)
|
|
}
|
|
}
|
|
|
|
const applyGizmoConfigToLoad3d = () => {
|
|
if (!load3d) return
|
|
const gizmo = modelConfig.value.gizmo
|
|
if (!gizmo) return
|
|
const hasTransform =
|
|
gizmo.position.x !== 0 ||
|
|
gizmo.position.y !== 0 ||
|
|
gizmo.position.z !== 0 ||
|
|
gizmo.rotation.x !== 0 ||
|
|
gizmo.rotation.y !== 0 ||
|
|
gizmo.rotation.z !== 0 ||
|
|
gizmo.scale.x !== 1 ||
|
|
gizmo.scale.y !== 1 ||
|
|
gizmo.scale.z !== 1
|
|
if (hasTransform) {
|
|
load3d.applyGizmoTransform(gizmo.position, gizmo.rotation, gizmo.scale)
|
|
}
|
|
if (gizmo.enabled) {
|
|
load3d.setGizmoEnabled(true)
|
|
}
|
|
if (gizmo.mode !== 'translate') {
|
|
load3d.setGizmoMode(gizmo.mode)
|
|
}
|
|
}
|
|
|
|
const applyLightConfigToLoad3d = () => {
|
|
if (!load3d) return
|
|
const cfg = lightConfig.value
|
|
load3d.setLightIntensity(cfg.intensity)
|
|
const hdri = cfg.hdri
|
|
if (!hdri) return
|
|
load3d.setHDRIIntensity(hdri.intensity)
|
|
load3d.setHDRIAsBackground(hdri.showAsBackground)
|
|
load3d.setHDRIEnabled(hdri.enabled)
|
|
}
|
|
|
|
const persistLightConfigToNode = () => {
|
|
const n = nodeRef.value
|
|
if (n) {
|
|
n.properties['Light Config'] = lightConfig.value
|
|
}
|
|
}
|
|
|
|
const waitForLoad3d = (callback: Load3dReadyCallback) => {
|
|
const rawNode = toRaw(nodeRef.value)
|
|
if (!rawNode) return
|
|
|
|
const node = rawNode as LGraphNode
|
|
const existingInstance = nodeToLoad3dMap.get(node)
|
|
|
|
if (existingInstance) {
|
|
callback(existingInstance)
|
|
|
|
return
|
|
}
|
|
|
|
if (!pendingCallbacks.has(node)) {
|
|
pendingCallbacks.set(node, [])
|
|
}
|
|
|
|
pendingCallbacks.get(node)!.push(callback)
|
|
}
|
|
|
|
watch(
|
|
sceneConfig,
|
|
(newValue) => {
|
|
if (nodeRef.value) {
|
|
nodeRef.value.properties['Scene Config'] = newValue
|
|
}
|
|
},
|
|
{ deep: true }
|
|
)
|
|
|
|
watch(
|
|
() => sceneConfig.value.showGrid,
|
|
(showGrid) => {
|
|
load3d?.toggleGrid(showGrid)
|
|
}
|
|
)
|
|
|
|
watch(
|
|
() => sceneConfig.value.backgroundColor,
|
|
(color) => {
|
|
if (!load3d || lightConfig.value.hdri?.enabled) return
|
|
load3d.setBackgroundColor(color)
|
|
}
|
|
)
|
|
|
|
watch(
|
|
() => sceneConfig.value.backgroundImage,
|
|
async (image) => {
|
|
if (!load3d) return
|
|
await load3d.setBackgroundImage(image || '')
|
|
}
|
|
)
|
|
|
|
watch(
|
|
() => sceneConfig.value.backgroundRenderMode,
|
|
(mode) => {
|
|
if (mode) load3d?.setBackgroundRenderMode(mode)
|
|
}
|
|
)
|
|
|
|
watch(
|
|
modelConfig,
|
|
(newValue) => {
|
|
if (nodeRef.value) {
|
|
nodeRef.value.properties['Model Config'] = newValue
|
|
}
|
|
},
|
|
{ deep: true }
|
|
)
|
|
|
|
watch(
|
|
() => modelConfig.value.upDirection,
|
|
(newValue) => {
|
|
if (load3d) load3d.setUpDirection(newValue)
|
|
}
|
|
)
|
|
|
|
watch(
|
|
() => modelConfig.value.materialMode,
|
|
(newValue) => {
|
|
if (load3d) load3d.setMaterialMode(newValue)
|
|
}
|
|
)
|
|
|
|
watch(
|
|
() => modelConfig.value.showSkeleton,
|
|
(newValue) => {
|
|
if (load3d) load3d.setShowSkeleton(newValue)
|
|
}
|
|
)
|
|
|
|
watch(
|
|
cameraConfig,
|
|
(newValue) => {
|
|
if (load3d && nodeRef.value) {
|
|
nodeRef.value.properties['Camera Config'] = newValue
|
|
load3d.toggleCamera(newValue.cameraType)
|
|
load3d.setFOV(newValue.fov)
|
|
}
|
|
},
|
|
{ deep: true }
|
|
)
|
|
|
|
watch(
|
|
() => lightConfig.value.intensity,
|
|
(intensity) => {
|
|
if (!load3d || !nodeRef.value) return
|
|
if (!lightConfig.value.hdri?.enabled) {
|
|
lastNonHdriLightIntensity.value = intensity
|
|
}
|
|
persistLightConfigToNode()
|
|
load3d.setLightIntensity(intensity)
|
|
}
|
|
)
|
|
|
|
watch(
|
|
() => lightConfig.value.hdri?.intensity,
|
|
(intensity) => {
|
|
if (!load3d || !nodeRef.value) return
|
|
if (intensity === undefined) return
|
|
persistLightConfigToNode()
|
|
load3d.setHDRIIntensity(intensity)
|
|
}
|
|
)
|
|
|
|
watch(
|
|
() => lightConfig.value.hdri?.showAsBackground,
|
|
(show) => {
|
|
if (!load3d || !nodeRef.value) return
|
|
if (show === undefined) return
|
|
persistLightConfigToNode()
|
|
load3d.setHDRIAsBackground(show)
|
|
}
|
|
)
|
|
|
|
watch(
|
|
() => lightConfig.value.hdri?.enabled,
|
|
(enabled, prevEnabled) => {
|
|
if (!load3d || !nodeRef.value) return
|
|
if (enabled === undefined) return
|
|
if (enabled && prevEnabled === false) {
|
|
lastNonHdriLightIntensity.value = lightConfig.value.intensity
|
|
}
|
|
if (!enabled && prevEnabled === true) {
|
|
lightConfig.value = {
|
|
...lightConfig.value,
|
|
intensity: lastNonHdriLightIntensity.value
|
|
}
|
|
}
|
|
persistLightConfigToNode()
|
|
load3d.setHDRIEnabled(enabled)
|
|
}
|
|
)
|
|
|
|
watch(playing, (newValue) => {
|
|
if (load3d) {
|
|
load3d.toggleAnimation(newValue)
|
|
}
|
|
})
|
|
|
|
watch(selectedSpeed, (newValue) => {
|
|
if (load3d && newValue) {
|
|
load3d.setAnimationSpeed(newValue)
|
|
}
|
|
})
|
|
|
|
watch(selectedAnimation, (newValue) => {
|
|
if (load3d && newValue !== undefined) {
|
|
load3d.updateSelectedAnimation(newValue)
|
|
}
|
|
})
|
|
|
|
const handleMouseEnter = () => {
|
|
load3d?.updateStatusMouseOnScene(true)
|
|
}
|
|
|
|
const handleMouseLeave = () => {
|
|
load3d?.updateStatusMouseOnScene(false)
|
|
}
|
|
|
|
const handleStartRecording = async () => {
|
|
if (load3d) {
|
|
await load3d.startRecording()
|
|
isRecording.value = true
|
|
}
|
|
}
|
|
|
|
const handleStopRecording = () => {
|
|
if (load3d) {
|
|
load3d.stopRecording()
|
|
isRecording.value = false
|
|
recordingDuration.value = load3d.getRecordingDuration()
|
|
hasRecording.value = recordingDuration.value > 0
|
|
}
|
|
}
|
|
|
|
const handleExportRecording = () => {
|
|
if (load3d) {
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
const filename = `${timestamp}-scene-recording.mp4`
|
|
load3d.exportRecording(filename)
|
|
}
|
|
}
|
|
|
|
const handleClearRecording = () => {
|
|
if (load3d) {
|
|
load3d.clearRecording()
|
|
hasRecording.value = false
|
|
recordingDuration.value = 0
|
|
}
|
|
}
|
|
|
|
const handleSeek = (progress: number) => {
|
|
if (load3d && animationDuration.value > 0) {
|
|
const time = (progress / 100) * animationDuration.value
|
|
load3d.setAnimationTime(time)
|
|
}
|
|
}
|
|
|
|
const handleHDRIFileUpdate = async (file: File | null) => {
|
|
const capturedLoad3d = load3d
|
|
if (!capturedLoad3d) return
|
|
|
|
if (!file) {
|
|
lightConfig.value = {
|
|
...lightConfig.value,
|
|
hdri: {
|
|
...lightConfig.value.hdri!,
|
|
hdriPath: '',
|
|
enabled: false,
|
|
showAsBackground: false
|
|
}
|
|
}
|
|
capturedLoad3d.clearHDRI()
|
|
return
|
|
}
|
|
|
|
const resourceFolder =
|
|
(nodeRef.value?.properties['Resource Folder'] as string) || ''
|
|
|
|
const subfolder = resourceFolder.trim()
|
|
? `3d/${resourceFolder.trim()}`
|
|
: '3d'
|
|
|
|
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
|
|
if (!uploadedPath) {
|
|
return
|
|
}
|
|
|
|
// Re-validate: node may have been removed during upload
|
|
if (load3d !== capturedLoad3d) return
|
|
|
|
const hdriUrl = api.apiURL(
|
|
Load3dUtils.getResourceURL(
|
|
...Load3dUtils.splitFilePath(uploadedPath),
|
|
'input'
|
|
)
|
|
)
|
|
|
|
try {
|
|
loading.value = true
|
|
loadingMessage.value = t('load3d.loadingHDRI')
|
|
await capturedLoad3d.loadHDRI(hdriUrl)
|
|
|
|
if (load3d !== capturedLoad3d) return
|
|
|
|
let sceneMin = 1
|
|
let sceneMax = 10
|
|
if (getActivePinia() != null) {
|
|
const settingStore = useSettingStore()
|
|
sceneMin = settingStore.get(
|
|
'Comfy.Load3D.LightIntensityMinimum'
|
|
) as number
|
|
sceneMax = settingStore.get(
|
|
'Comfy.Load3D.LightIntensityMaximum'
|
|
) as number
|
|
}
|
|
const mappedHdriIntensity = Load3dUtils.mapSceneLightIntensityToHdri(
|
|
lightConfig.value.intensity,
|
|
sceneMin,
|
|
sceneMax
|
|
)
|
|
lightConfig.value = {
|
|
...lightConfig.value,
|
|
hdri: {
|
|
...lightConfig.value.hdri!,
|
|
hdriPath: uploadedPath,
|
|
enabled: true,
|
|
showAsBackground: true,
|
|
intensity: mappedHdriIntensity
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load HDRI:', error)
|
|
capturedLoad3d.clearHDRI()
|
|
lightConfig.value = {
|
|
...lightConfig.value,
|
|
hdri: {
|
|
...lightConfig.value.hdri!,
|
|
hdriPath: '',
|
|
enabled: false,
|
|
showAsBackground: false
|
|
}
|
|
}
|
|
useToastStore().addAlert(t('toastMessages.failedToLoadHDRI'))
|
|
} finally {
|
|
loading.value = false
|
|
loadingMessage.value = ''
|
|
}
|
|
}
|
|
|
|
const handleBackgroundImageUpdate = async (file: File | null) => {
|
|
if (!file) {
|
|
sceneConfig.value.backgroundImage = ''
|
|
await load3d?.setBackgroundImage('')
|
|
return
|
|
}
|
|
|
|
const resourceFolder =
|
|
(nodeRef.value?.properties['Resource Folder'] as string) || ''
|
|
|
|
const subfolder = resourceFolder.trim()
|
|
? `3d/${resourceFolder.trim()}`
|
|
: '3d'
|
|
|
|
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
|
|
sceneConfig.value.backgroundImage = uploadedPath
|
|
await load3d?.setBackgroundImage(uploadedPath)
|
|
}
|
|
|
|
const handleExportModel = async (format: string) => {
|
|
if (!load3d) {
|
|
useToastStore().addAlert(t('toastMessages.no3dSceneToExport'))
|
|
return
|
|
}
|
|
|
|
try {
|
|
await load3d.exportModel(format)
|
|
} catch (error) {
|
|
console.error('Error exporting model:', error)
|
|
useToastStore().addAlert(
|
|
t('toastMessages.failedToExportModel', {
|
|
format: format.toUpperCase()
|
|
})
|
|
)
|
|
}
|
|
}
|
|
|
|
const handleModelDrop = async (file: File) => {
|
|
if (!load3d) {
|
|
useToastStore().addAlert(t('toastMessages.no3dScene'))
|
|
return
|
|
}
|
|
|
|
const node = toRaw(nodeRef.value)
|
|
if (!node) return
|
|
|
|
try {
|
|
const resourceFolder =
|
|
(node.properties['Resource Folder'] as string) || ''
|
|
|
|
const subfolder = resourceFolder.trim()
|
|
? `3d/${resourceFolder.trim()}`
|
|
: '3d'
|
|
|
|
loading.value = true
|
|
loadingMessage.value = t('load3d.uploadingModel')
|
|
|
|
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
|
|
|
|
if (!uploadedPath) {
|
|
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
|
|
return
|
|
}
|
|
|
|
const modelUrl = api.apiURL(
|
|
Load3dUtils.getResourceURL(
|
|
...Load3dUtils.splitFilePath(uploadedPath),
|
|
isPreview.value ? 'output' : 'input'
|
|
)
|
|
)
|
|
|
|
loadingMessage.value = t('load3d.loadingModel')
|
|
await load3d.loadModel(modelUrl)
|
|
|
|
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
|
|
|
if (modelWidget) {
|
|
const options = modelWidget.options as { values?: string[] } | undefined
|
|
if (options?.values && !options.values.includes(uploadedPath)) {
|
|
options.values.push(uploadedPath)
|
|
}
|
|
modelWidget.value = uploadedPath
|
|
}
|
|
} catch (error) {
|
|
console.error('Model drop failed:', error)
|
|
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
|
|
} finally {
|
|
loading.value = false
|
|
loadingMessage.value = ''
|
|
}
|
|
}
|
|
|
|
const eventConfig = {
|
|
materialModeChange: (value: string) => {
|
|
modelConfig.value.materialMode = value as MaterialMode
|
|
},
|
|
backgroundColorChange: (value: string) => {
|
|
sceneConfig.value.backgroundColor = value
|
|
},
|
|
backgroundRenderModeChange: (value: string) => {
|
|
sceneConfig.value.backgroundRenderMode = value as 'tiled' | 'panorama'
|
|
},
|
|
lightIntensityChange: (value: number) => {
|
|
lightConfig.value.intensity = value
|
|
},
|
|
fovChange: (value: number) => {
|
|
cameraConfig.value.fov = value
|
|
},
|
|
cameraTypeChange: (value: string) => {
|
|
cameraConfig.value.cameraType = value as CameraType
|
|
},
|
|
showGridChange: (value: boolean) => {
|
|
sceneConfig.value.showGrid = value
|
|
},
|
|
upDirectionChange: (value: string) => {
|
|
modelConfig.value.upDirection = value as UpDirection
|
|
},
|
|
backgroundImageChange: (value: string) => {
|
|
sceneConfig.value.backgroundImage = value
|
|
},
|
|
backgroundImageLoadingStart: () => {
|
|
loadingMessage.value = t('load3d.loadingBackgroundImage')
|
|
loading.value = true
|
|
},
|
|
backgroundImageLoadingEnd: () => {
|
|
loadingMessage.value = ''
|
|
loading.value = false
|
|
},
|
|
modelLoadingStart: () => {
|
|
loadingMessage.value = t('load3d.loadingModel')
|
|
loading.value = true
|
|
if (!isFirstModelLoad) {
|
|
modelConfig.value = {
|
|
upDirection: 'original',
|
|
materialMode: 'original',
|
|
showSkeleton: false,
|
|
gizmo: {
|
|
enabled: false,
|
|
mode: 'translate',
|
|
position: { x: 0, y: 0, z: 0 },
|
|
rotation: { x: 0, y: 0, z: 0 },
|
|
scale: { x: 1, y: 1, z: 1 }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
modelLoadingEnd: () => {
|
|
loadingMessage.value = ''
|
|
loading.value = false
|
|
isSplatModel.value = load3d?.isSplatModel() ?? false
|
|
isPlyModel.value = load3d?.isPlyModel() ?? false
|
|
const caps = load3d?.getCurrentModelCapabilities()
|
|
canFitToViewer.value = caps?.fitToViewer ?? true
|
|
canUseGizmo.value = caps?.gizmoTransform ?? true
|
|
canUseLighting.value = caps?.lighting ?? true
|
|
canExport.value = caps?.exportable ?? true
|
|
materialModes.value = caps?.materialModes ?? [
|
|
'original',
|
|
'normal',
|
|
'wireframe'
|
|
]
|
|
hasSkeleton.value = load3d?.hasSkeleton() ?? false
|
|
applyGizmoConfigToLoad3d()
|
|
isFirstModelLoad = false
|
|
},
|
|
modelReady: () => {
|
|
if (!load3d || !isAssetPreviewSupported()) return
|
|
|
|
const node = nodeRef.value
|
|
const modelWidget = node?.widgets?.find(
|
|
(w) => w.name === 'model_file' || w.name === 'image'
|
|
)
|
|
const value = modelWidget?.value
|
|
if (typeof value !== 'string' || !value) return
|
|
|
|
const filename = value.trim().replace(/\s*\[output\]$/, '')
|
|
const modelName = Load3dUtils.splitFilePath(filename)[1]
|
|
load3d
|
|
.captureThumbnail(256, 256)
|
|
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
|
|
.then((blob) => persistThumbnail(modelName, blob))
|
|
.catch(() => {})
|
|
},
|
|
skeletonVisibilityChange: (value: boolean) => {
|
|
modelConfig.value.showSkeleton = value
|
|
},
|
|
exportLoadingStart: (message: string) => {
|
|
loadingMessage.value = message || t('load3d.exportingModel')
|
|
loading.value = true
|
|
},
|
|
exportLoadingEnd: () => {
|
|
loadingMessage.value = ''
|
|
loading.value = false
|
|
},
|
|
recordingStatusChange: (value: boolean) => {
|
|
isRecording.value = value
|
|
|
|
if (!value && load3d) {
|
|
recordingDuration.value = load3d.getRecordingDuration()
|
|
hasRecording.value = recordingDuration.value > 0
|
|
}
|
|
},
|
|
animationListChange: (newValue: AnimationItem[]) => {
|
|
animations.value = newValue
|
|
},
|
|
animationProgressChange: (data: {
|
|
progress: number
|
|
currentTime: number
|
|
duration: number
|
|
}) => {
|
|
animationProgress.value = data.progress
|
|
animationDuration.value = data.duration
|
|
},
|
|
cameraChanged: (cameraState: CameraState) => {
|
|
const rawNode = toRaw(nodeRef.value)
|
|
if (rawNode) {
|
|
const node = rawNode as LGraphNode
|
|
if (!node.properties) node.properties = {}
|
|
const cameraConfigProp = node.properties['Camera Config']
|
|
|
|
if (cameraConfigProp) {
|
|
;(cameraConfigProp as CameraConfig).state = cameraState
|
|
} else {
|
|
node.properties['Camera Config'] = {
|
|
cameraType: cameraConfig.value.cameraType,
|
|
fov: cameraConfig.value.fov,
|
|
state: cameraState
|
|
}
|
|
}
|
|
}
|
|
},
|
|
gizmoTransformChange: (data: GizmoConfig) => {
|
|
if (modelConfig.value.gizmo && nodeRef.value) {
|
|
modelConfig.value.gizmo.position = data.position
|
|
modelConfig.value.gizmo.rotation = data.rotation
|
|
modelConfig.value.gizmo.scale = data.scale
|
|
modelConfig.value.gizmo.enabled = data.enabled
|
|
modelConfig.value.gizmo.mode = data.mode
|
|
}
|
|
}
|
|
} as const
|
|
|
|
const handleToggleGizmo = (enabled: boolean) => {
|
|
if (load3d && modelConfig.value.gizmo) {
|
|
modelConfig.value.gizmo.enabled = enabled
|
|
load3d.setGizmoEnabled(enabled)
|
|
}
|
|
}
|
|
|
|
const handleSetGizmoMode = (mode: GizmoMode) => {
|
|
if (load3d && modelConfig.value.gizmo) {
|
|
modelConfig.value.gizmo.mode = mode
|
|
load3d.setGizmoMode(mode)
|
|
}
|
|
}
|
|
|
|
const handleFitToViewer = () => {
|
|
if (!load3d) return
|
|
load3d.fitToViewer()
|
|
|
|
if (!modelConfig.value.gizmo) return
|
|
const transform = load3d.getGizmoTransform()
|
|
modelConfig.value.gizmo.position = transform.position
|
|
modelConfig.value.gizmo.scale = transform.scale
|
|
}
|
|
|
|
const handleResetGizmoTransform = () => {
|
|
if (load3d) {
|
|
load3d.resetGizmoTransform()
|
|
}
|
|
}
|
|
|
|
const handleEvents = (action: 'add' | 'remove') => {
|
|
Object.entries(eventConfig).forEach(([event, handler]) => {
|
|
const method = `${action}EventListener` as const
|
|
load3d?.[method](event, handler as EventCallback)
|
|
})
|
|
}
|
|
|
|
const cleanup = () => {
|
|
handleEvents('remove')
|
|
|
|
const rawNode = toRaw(nodeRef.value)
|
|
if (!rawNode) return
|
|
|
|
const node = rawNode as LGraphNode
|
|
if (nodeToLoad3dMap.get(node) === load3d) {
|
|
nodeToLoad3dMap.delete(node)
|
|
}
|
|
|
|
load3d?.remove()
|
|
load3d = null
|
|
}
|
|
|
|
return {
|
|
// state
|
|
load3d,
|
|
sceneConfig,
|
|
modelConfig,
|
|
cameraConfig,
|
|
lightConfig,
|
|
isRecording,
|
|
isPreview,
|
|
isSplatModel,
|
|
isPlyModel,
|
|
canFitToViewer,
|
|
canUseGizmo,
|
|
canUseLighting,
|
|
canExport,
|
|
materialModes,
|
|
hasSkeleton,
|
|
hasRecording,
|
|
recordingDuration,
|
|
animations,
|
|
playing,
|
|
selectedSpeed,
|
|
selectedAnimation,
|
|
animationProgress,
|
|
animationDuration,
|
|
loading,
|
|
loadingMessage,
|
|
|
|
// Methods
|
|
initializeLoad3d,
|
|
waitForLoad3d,
|
|
handleMouseEnter,
|
|
handleMouseLeave,
|
|
handleStartRecording,
|
|
handleStopRecording,
|
|
handleExportRecording,
|
|
handleClearRecording,
|
|
handleSeek,
|
|
handleBackgroundImageUpdate,
|
|
handleHDRIFileUpdate,
|
|
handleExportModel,
|
|
handleModelDrop,
|
|
handleToggleGizmo,
|
|
handleSetGizmoMode,
|
|
handleResetGizmoTransform,
|
|
handleFitToViewer,
|
|
cleanup
|
|
}
|
|
}
|