mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
[refactor] refactor load3d (#5765)
Summary Fully Refactored the Load3D module to improve architecture and maintainability by consolidating functionality into a centralized composable pattern and simplifying component structure. and support VueNodes system Changes - Architecture: Introduced new useLoad3d composable to centralize 3D loading logic and state management - Component Simplification: Removed redundant components (Load3DAnimation.vue, Load3DAnimationScene.vue, PreviewManager.ts) - Support VueNodes - improve config store - remove lineart output due Animation doesnot support it, may add it back later - remove Preview screen and keep scene in fixed ratio in load3d (not affect preview3d) - improve record video feature which will already record video by same ratio as scene Need BE change https://github.com/comfyanonymous/ComfyUI/pull/10025 https://github.com/user-attachments/assets/9e038729-84a0-45ad-b0f2-11c57d7e0c9a ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5765-refactor-refactor-load3d-2796d73d365081728297cc486e2e9052) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -1,71 +1,48 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative h-full w-full"
|
||||
class="widget-expands relative h-full w-full"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
>
|
||||
<Load3DScene
|
||||
v-if="node"
|
||||
ref="load3DSceneRef"
|
||||
:node="node"
|
||||
:input-spec="inputSpec"
|
||||
:background-color="backgroundColor"
|
||||
:show-grid="showGrid"
|
||||
:light-intensity="lightIntensity"
|
||||
:fov="fov"
|
||||
:camera-type="cameraType"
|
||||
:show-preview="showPreview"
|
||||
:background-image="backgroundImage"
|
||||
:up-direction="upDirection"
|
||||
:material-mode="materialMode"
|
||||
:edge-threshold="edgeThreshold"
|
||||
@material-mode-change="listenMaterialModeChange"
|
||||
@background-color-change="listenBackgroundColorChange"
|
||||
@light-intensity-change="listenLightIntensityChange"
|
||||
@fov-change="listenFOVChange"
|
||||
@camera-type-change="listenCameraTypeChange"
|
||||
@show-grid-change="listenShowGridChange"
|
||||
@show-preview-change="listenShowPreviewChange"
|
||||
@background-image-change="listenBackgroundImageChange"
|
||||
@up-direction-change="listenUpDirectionChange"
|
||||
@edge-threshold-change="listenEdgeThresholdChange"
|
||||
@recording-status-change="listenRecordingStatusChange"
|
||||
/>
|
||||
<Load3DControls
|
||||
:input-spec="inputSpec"
|
||||
:background-color="backgroundColor"
|
||||
:show-grid="showGrid"
|
||||
:show-preview="showPreview"
|
||||
:light-intensity="lightIntensity"
|
||||
:show-light-intensity-button="showLightIntensityButton"
|
||||
:fov="fov"
|
||||
:show-f-o-v-button="showFOVButton"
|
||||
:show-preview-button="showPreviewButton"
|
||||
:camera-type="cameraType"
|
||||
:has-background-image="hasBackgroundImage"
|
||||
:up-direction="upDirection"
|
||||
:material-mode="materialMode"
|
||||
:edge-threshold="edgeThreshold"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@switch-camera="switchCamera"
|
||||
@toggle-grid="toggleGrid"
|
||||
@update-background-color="handleBackgroundColorChange"
|
||||
@update-light-intensity="handleUpdateLightIntensity"
|
||||
@toggle-preview="togglePreview"
|
||||
@update-f-o-v="handleUpdateFOV"
|
||||
@update-up-direction="handleUpdateUpDirection"
|
||||
@update-material-mode="handleUpdateMaterialMode"
|
||||
@update-edge-threshold="handleUpdateEdgeThreshold"
|
||||
@export-model="handleExportModel"
|
||||
:initialize-load3d="initializeLoad3d"
|
||||
:cleanup="cleanup"
|
||||
:loading="loading"
|
||||
:loading-message="loadingMessage"
|
||||
:on-model-drop="isPreview ? undefined : handleModelDrop"
|
||||
:is-preview="isPreview"
|
||||
/>
|
||||
<div class="pointer-events-none absolute top-0 left-0 h-full w-full">
|
||||
<Load3DControls
|
||||
v-model:scene-config="sceneConfig"
|
||||
v-model:model-config="modelConfig"
|
||||
v-model:camera-config="cameraConfig"
|
||||
v-model:light-config="lightConfig"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
<AnimationControls
|
||||
v-if="animations && animations.length > 0"
|
||||
v-model:animations="animations"
|
||||
v-model:playing="playing"
|
||||
v-model:selected-speed="selectedSpeed"
|
||||
v-model:selected-animation="selectedAnimation"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="enable3DViewer"
|
||||
v-if="enable3DViewer && node"
|
||||
class="pointer-events-auto absolute top-12 right-2 z-20"
|
||||
>
|
||||
<ViewerControls :node="node" />
|
||||
<ViewerControls :node="node as LGraphNode" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showRecordingControls"
|
||||
v-if="!isPreview"
|
||||
class="pointer-events-auto absolute right-2 z-20"
|
||||
:class="{
|
||||
'top-12': !enable3DViewer,
|
||||
@@ -73,10 +50,9 @@
|
||||
}"
|
||||
>
|
||||
<RecordingControls
|
||||
:node="node"
|
||||
:is-recording="isRecording"
|
||||
:has-recording="hasRecording"
|
||||
:recording-duration="recordingDuration"
|
||||
v-model:is-recording="isRecording"
|
||||
v-model:has-recording="hasRecording"
|
||||
v-model:recording-duration="recordingDuration"
|
||||
@start-recording="handleStartRecording"
|
||||
@stop-recording="handleStopRecording"
|
||||
@export-recording="handleExportRecording"
|
||||
@@ -87,250 +63,79 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import Load3DControls from '@/components/load3d/Load3DControls.vue'
|
||||
import Load3DScene from '@/components/load3d/Load3DScene.vue'
|
||||
import AnimationControls from '@/components/load3d/controls/AnimationControls.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 type {
|
||||
CameraType,
|
||||
Load3DNodeType,
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { useLoad3d } from '@/composables/useLoad3d'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { widget } = defineProps<{
|
||||
widget: ComponentWidget<string[]>
|
||||
const props = defineProps<{
|
||||
widget: ComponentWidget<string[]> | SimplifiedWidget
|
||||
nodeId?: NodeId
|
||||
}>()
|
||||
|
||||
const inputSpec = widget.inputSpec as CustomInputSpec
|
||||
function isComponentWidget(
|
||||
widget: ComponentWidget<string[]> | SimplifiedWidget
|
||||
): widget is ComponentWidget<string[]> {
|
||||
return 'node' in widget && widget.node !== undefined
|
||||
}
|
||||
|
||||
const node = widget.node
|
||||
const type = inputSpec.type as Load3DNodeType
|
||||
const node = ref<LGraphNode | null>(null)
|
||||
|
||||
if (isComponentWidget(props.widget)) {
|
||||
node.value = props.widget.node
|
||||
} else if (props.nodeId) {
|
||||
onMounted(() => {
|
||||
node.value = app.rootGraph?.getNodeById(props.nodeId!) || null
|
||||
})
|
||||
}
|
||||
|
||||
const backgroundColor = ref('#000000')
|
||||
const showGrid = ref(true)
|
||||
const showPreview = ref(false)
|
||||
const lightIntensity = ref(5)
|
||||
const showLightIntensityButton = ref(true)
|
||||
const fov = ref(75)
|
||||
const showFOVButton = ref(true)
|
||||
const cameraType = ref<CameraType>('perspective')
|
||||
const hasBackgroundImage = ref(false)
|
||||
const backgroundImage = ref('')
|
||||
const upDirection = ref<UpDirection>('original')
|
||||
const materialMode = ref<MaterialMode>('original')
|
||||
const edgeThreshold = ref(85)
|
||||
const load3DSceneRef = ref<InstanceType<typeof Load3DScene> | null>(null)
|
||||
const isRecording = ref(false)
|
||||
const hasRecording = ref(false)
|
||||
const recordingDuration = ref(0)
|
||||
const showRecordingControls = ref(!inputSpec.isPreview)
|
||||
|
||||
const {
|
||||
// configs
|
||||
sceneConfig,
|
||||
modelConfig,
|
||||
cameraConfig,
|
||||
lightConfig,
|
||||
|
||||
// other state
|
||||
isRecording,
|
||||
isPreview,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
animations,
|
||||
playing,
|
||||
selectedSpeed,
|
||||
selectedAnimation,
|
||||
loading,
|
||||
loadingMessage,
|
||||
|
||||
// Methods
|
||||
initializeLoad3d,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
handleStartRecording,
|
||||
handleStopRecording,
|
||||
handleExportRecording,
|
||||
handleClearRecording,
|
||||
handleBackgroundImageUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
cleanup
|
||||
} = useLoad3d(node as Ref<LGraphNode | null>)
|
||||
|
||||
const enable3DViewer = computed(() =>
|
||||
useSettingStore().get('Comfy.Load3D.3DViewerEnable')
|
||||
)
|
||||
|
||||
const showPreviewButton = computed(() => {
|
||||
return !type.includes('Preview')
|
||||
})
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (load3DSceneRef.value?.load3d) {
|
||||
load3DSceneRef.value.load3d.updateStatusMouseOnScene(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (load3DSceneRef.value?.load3d) {
|
||||
load3DSceneRef.value.load3d.updateStatusMouseOnScene(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartRecording = async () => {
|
||||
if (load3DSceneRef.value?.load3d) {
|
||||
await load3DSceneRef.value.load3d.startRecording()
|
||||
isRecording.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopRecording = () => {
|
||||
if (load3DSceneRef.value?.load3d) {
|
||||
load3DSceneRef.value.load3d.stopRecording()
|
||||
isRecording.value = false
|
||||
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportRecording = () => {
|
||||
if (load3DSceneRef.value?.load3d) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `${timestamp}-scene-recording.mp4`
|
||||
load3DSceneRef.value.load3d.exportRecording(filename)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearRecording = () => {
|
||||
if (load3DSceneRef.value?.load3d) {
|
||||
load3DSceneRef.value.load3d.clearRecording()
|
||||
hasRecording.value = false
|
||||
recordingDuration.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const switchCamera = () => {
|
||||
cameraType.value =
|
||||
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
|
||||
|
||||
showFOVButton.value = cameraType.value === 'perspective'
|
||||
|
||||
node.properties['Camera Type'] = cameraType.value
|
||||
}
|
||||
|
||||
const togglePreview = (value: boolean) => {
|
||||
showPreview.value = value
|
||||
|
||||
node.properties['Show Preview'] = showPreview.value
|
||||
}
|
||||
|
||||
const toggleGrid = (value: boolean) => {
|
||||
showGrid.value = value
|
||||
|
||||
node.properties['Show Grid'] = showGrid.value
|
||||
}
|
||||
|
||||
const handleUpdateLightIntensity = (value: number) => {
|
||||
lightIntensity.value = value
|
||||
|
||||
node.properties['Light Intensity'] = lightIntensity.value
|
||||
}
|
||||
|
||||
const handleBackgroundImageUpdate = async (file: File | null) => {
|
||||
if (!file) {
|
||||
hasBackgroundImage.value = false
|
||||
backgroundImage.value = ''
|
||||
node.properties['Background Image'] = ''
|
||||
return
|
||||
}
|
||||
|
||||
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
|
||||
|
||||
const subfolder = resourceFolder.trim() ? `3d/${resourceFolder.trim()}` : '3d'
|
||||
|
||||
backgroundImage.value = await Load3dUtils.uploadFile(file, subfolder)
|
||||
|
||||
node.properties['Background Image'] = backgroundImage.value
|
||||
}
|
||||
|
||||
const handleUpdateFOV = (value: number) => {
|
||||
fov.value = value
|
||||
|
||||
node.properties['FOV'] = fov.value
|
||||
}
|
||||
|
||||
const handleUpdateEdgeThreshold = (value: number) => {
|
||||
edgeThreshold.value = value
|
||||
|
||||
node.properties['Edge Threshold'] = edgeThreshold.value
|
||||
}
|
||||
|
||||
const handleBackgroundColorChange = (value: string) => {
|
||||
backgroundColor.value = value
|
||||
|
||||
node.properties['Background Color'] = value
|
||||
}
|
||||
|
||||
const handleUpdateUpDirection = (value: UpDirection) => {
|
||||
upDirection.value = value
|
||||
|
||||
node.properties['Up Direction'] = value
|
||||
}
|
||||
|
||||
const handleUpdateMaterialMode = (value: MaterialMode) => {
|
||||
materialMode.value = value
|
||||
|
||||
node.properties['Material Mode'] = value
|
||||
}
|
||||
|
||||
const handleExportModel = async (format: string) => {
|
||||
if (!load3DSceneRef.value?.load3d) {
|
||||
useToastStore().addAlert(t('toastMessages.no3dSceneToExport'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await load3DSceneRef.value.load3d.exportModel(format)
|
||||
} catch (error) {
|
||||
console.error('Error exporting model:', error)
|
||||
useToastStore().addAlert(
|
||||
t('toastMessages.failedToExportModel', {
|
||||
format: format.toUpperCase()
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const listenMaterialModeChange = (mode: MaterialMode) => {
|
||||
materialMode.value = mode
|
||||
|
||||
showLightIntensityButton.value = mode === 'original'
|
||||
}
|
||||
|
||||
const listenUpDirectionChange = (value: UpDirection) => {
|
||||
upDirection.value = value
|
||||
}
|
||||
|
||||
const listenEdgeThresholdChange = (value: number) => {
|
||||
edgeThreshold.value = value
|
||||
}
|
||||
|
||||
const listenRecordingStatusChange = (value: boolean) => {
|
||||
isRecording.value = value
|
||||
|
||||
if (!value && load3DSceneRef.value?.load3d) {
|
||||
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
}
|
||||
}
|
||||
|
||||
const listenBackgroundColorChange = (value: string) => {
|
||||
backgroundColor.value = value
|
||||
}
|
||||
|
||||
const listenLightIntensityChange = (value: number) => {
|
||||
lightIntensity.value = value
|
||||
}
|
||||
|
||||
const listenFOVChange = (value: number) => {
|
||||
fov.value = value
|
||||
}
|
||||
|
||||
const listenCameraTypeChange = (value: CameraType) => {
|
||||
cameraType.value = value
|
||||
showFOVButton.value = cameraType.value === 'perspective'
|
||||
}
|
||||
|
||||
const listenShowGridChange = (value: boolean) => {
|
||||
showGrid.value = value
|
||||
}
|
||||
|
||||
const listenShowPreviewChange = (value: boolean) => {
|
||||
showPreview.value = value
|
||||
}
|
||||
|
||||
const listenBackgroundImageChange = (value: string) => {
|
||||
backgroundImage.value = value
|
||||
|
||||
if (backgroundImage.value && backgroundImage.value !== '') {
|
||||
hasBackgroundImage.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative h-full w-full"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<Load3DAnimationScene
|
||||
ref="load3DAnimationSceneRef"
|
||||
:node="node"
|
||||
:input-spec="inputSpec"
|
||||
:background-color="backgroundColor"
|
||||
:show-grid="showGrid"
|
||||
:light-intensity="lightIntensity"
|
||||
:fov="fov"
|
||||
:camera-type="cameraType"
|
||||
:show-preview="showPreview"
|
||||
:show-f-o-v-button="showFOVButton"
|
||||
:show-light-intensity-button="showLightIntensityButton"
|
||||
:playing="playing"
|
||||
:selected-speed="selectedSpeed"
|
||||
:selected-animation="selectedAnimation"
|
||||
:background-image="backgroundImage"
|
||||
:up-direction="upDirection"
|
||||
:material-mode="materialMode"
|
||||
@material-mode-change="listenMaterialModeChange"
|
||||
@background-color-change="listenBackgroundColorChange"
|
||||
@light-intensity-change="listenLightIntensityChange"
|
||||
@fov-change="listenFOVChange"
|
||||
@camera-type-change="listenCameraTypeChange"
|
||||
@show-grid-change="listenShowGridChange"
|
||||
@show-preview-change="listenShowPreviewChange"
|
||||
@background-image-change="listenBackgroundImageChange"
|
||||
@animation-list-change="animationListChange"
|
||||
@up-direction-change="listenUpDirectionChange"
|
||||
@recording-status-change="listenRecordingStatusChange"
|
||||
/>
|
||||
<div class="pointer-events-none absolute top-0 left-0 h-full w-full">
|
||||
<Load3DControls
|
||||
:input-spec="inputSpec"
|
||||
:background-color="backgroundColor"
|
||||
:show-grid="showGrid"
|
||||
:show-preview="showPreview"
|
||||
:light-intensity="lightIntensity"
|
||||
:show-light-intensity-button="showLightIntensityButton"
|
||||
:fov="fov"
|
||||
:show-f-o-v-button="showFOVButton"
|
||||
:show-preview-button="showPreviewButton"
|
||||
:camera-type="cameraType"
|
||||
:has-background-image="hasBackgroundImage"
|
||||
:up-direction="upDirection"
|
||||
:material-mode="materialMode"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@switch-camera="switchCamera"
|
||||
@toggle-grid="toggleGrid"
|
||||
@update-background-color="handleBackgroundColorChange"
|
||||
@update-light-intensity="handleUpdateLightIntensity"
|
||||
@toggle-preview="togglePreview"
|
||||
@update-f-o-v="handleUpdateFOV"
|
||||
@update-up-direction="handleUpdateUpDirection"
|
||||
@update-material-mode="handleUpdateMaterialMode"
|
||||
/>
|
||||
<Load3DAnimationControls
|
||||
:animations="animations"
|
||||
:playing="playing"
|
||||
@toggle-play="togglePlay"
|
||||
@speed-change="speedChange"
|
||||
@animation-change="animationChange"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showRecordingControls"
|
||||
class="pointer-events-auto absolute top-12 right-2 z-20"
|
||||
>
|
||||
<RecordingControls
|
||||
:node="node"
|
||||
:is-recording="isRecording"
|
||||
:has-recording="hasRecording"
|
||||
:recording-duration="recordingDuration"
|
||||
@start-recording="handleStartRecording"
|
||||
@stop-recording="handleStopRecording"
|
||||
@export-recording="handleExportRecording"
|
||||
@clear-recording="handleClearRecording"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Load3DAnimationControls from '@/components/load3d/Load3DAnimationControls.vue'
|
||||
import Load3DAnimationScene from '@/components/load3d/Load3DAnimationScene.vue'
|
||||
import Load3DControls from '@/components/load3d/Load3DControls.vue'
|
||||
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type {
|
||||
AnimationItem,
|
||||
CameraType,
|
||||
Load3DAnimationNodeType,
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: ComponentWidget<string[]>
|
||||
}>()
|
||||
|
||||
const inputSpec = widget.inputSpec as CustomInputSpec
|
||||
|
||||
const node = widget.node
|
||||
const type = inputSpec.type as Load3DAnimationNodeType
|
||||
|
||||
const backgroundColor = ref('#000000')
|
||||
const showGrid = ref(true)
|
||||
const showPreview = ref(false)
|
||||
const lightIntensity = ref(5)
|
||||
const showLightIntensityButton = ref(true)
|
||||
const fov = ref(75)
|
||||
const showFOVButton = ref(true)
|
||||
const cameraType = ref<'perspective' | 'orthographic'>('perspective')
|
||||
const hasBackgroundImage = ref(false)
|
||||
|
||||
const animations = ref<AnimationItem[]>([])
|
||||
const playing = ref(false)
|
||||
const selectedSpeed = ref(1)
|
||||
const selectedAnimation = ref(0)
|
||||
const backgroundImage = ref('')
|
||||
|
||||
const isRecording = ref(false)
|
||||
const hasRecording = ref(false)
|
||||
const recordingDuration = ref(0)
|
||||
const showRecordingControls = ref(!inputSpec.isPreview)
|
||||
|
||||
const showPreviewButton = computed(() => {
|
||||
return !type.includes('Preview')
|
||||
})
|
||||
|
||||
const load3DAnimationSceneRef = ref<InstanceType<
|
||||
typeof Load3DAnimationScene
|
||||
> | null>(null)
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
|
||||
if (sceneRef?.load3d) {
|
||||
sceneRef.load3d.updateStatusMouseOnScene(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
|
||||
if (sceneRef?.load3d) {
|
||||
sceneRef.load3d.updateStatusMouseOnScene(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartRecording = async () => {
|
||||
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
|
||||
if (sceneRef?.load3d) {
|
||||
await sceneRef.load3d.startRecording()
|
||||
isRecording.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopRecording = () => {
|
||||
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
|
||||
if (sceneRef?.load3d) {
|
||||
sceneRef.load3d.stopRecording()
|
||||
isRecording.value = false
|
||||
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportRecording = () => {
|
||||
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
|
||||
if (sceneRef?.load3d) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `${timestamp}-animation-recording.mp4`
|
||||
sceneRef.load3d.exportRecording(filename)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearRecording = () => {
|
||||
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
|
||||
if (sceneRef?.load3d) {
|
||||
sceneRef.load3d.clearRecording()
|
||||
hasRecording.value = false
|
||||
recordingDuration.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const listenRecordingStatusChange = (value: boolean) => {
|
||||
isRecording.value = value
|
||||
|
||||
if (!value) {
|
||||
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
|
||||
if (sceneRef?.load3d) {
|
||||
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const switchCamera = () => {
|
||||
cameraType.value =
|
||||
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
|
||||
|
||||
showFOVButton.value = cameraType.value === 'perspective'
|
||||
|
||||
node.properties['Camera Type'] = cameraType.value
|
||||
}
|
||||
|
||||
const togglePreview = (value: boolean) => {
|
||||
showPreview.value = value
|
||||
|
||||
node.properties['Show Preview'] = showPreview.value
|
||||
}
|
||||
|
||||
const toggleGrid = (value: boolean) => {
|
||||
showGrid.value = value
|
||||
|
||||
node.properties['Show Grid'] = showGrid.value
|
||||
}
|
||||
|
||||
const handleUpdateLightIntensity = (value: number) => {
|
||||
lightIntensity.value = value
|
||||
|
||||
node.properties['Light Intensity'] = lightIntensity.value
|
||||
}
|
||||
|
||||
const handleBackgroundImageUpdate = async (file: File | null) => {
|
||||
if (!file) {
|
||||
hasBackgroundImage.value = false
|
||||
backgroundImage.value = ''
|
||||
node.properties['Background Image'] = ''
|
||||
return
|
||||
}
|
||||
|
||||
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
|
||||
|
||||
const subfolder = resourceFolder.trim() ? `3d/${resourceFolder.trim()}` : '3d'
|
||||
|
||||
backgroundImage.value = await Load3dUtils.uploadFile(file, subfolder)
|
||||
|
||||
node.properties['Background Image'] = backgroundImage.value
|
||||
}
|
||||
|
||||
const handleUpdateFOV = (value: number) => {
|
||||
fov.value = value
|
||||
|
||||
node.properties['FOV'] = fov.value
|
||||
}
|
||||
|
||||
const materialMode = ref<MaterialMode>('original')
|
||||
const upDirection = ref<UpDirection>('original')
|
||||
|
||||
const handleUpdateUpDirection = (value: UpDirection) => {
|
||||
upDirection.value = value
|
||||
|
||||
node.properties['Up Direction'] = value
|
||||
}
|
||||
|
||||
const handleUpdateMaterialMode = (value: MaterialMode) => {
|
||||
materialMode.value = value
|
||||
|
||||
node.properties['Material Mode'] = value
|
||||
}
|
||||
|
||||
const handleBackgroundColorChange = (value: string) => {
|
||||
backgroundColor.value = value
|
||||
|
||||
node.properties['Background Color'] = value
|
||||
}
|
||||
|
||||
const togglePlay = (value: boolean) => {
|
||||
playing.value = value
|
||||
}
|
||||
|
||||
const speedChange = (value: number) => {
|
||||
selectedSpeed.value = value
|
||||
}
|
||||
|
||||
const animationChange = (value: number) => {
|
||||
selectedAnimation.value = value
|
||||
}
|
||||
|
||||
const animationListChange = (value: any) => {
|
||||
animations.value = value
|
||||
}
|
||||
|
||||
const listenMaterialModeChange = (mode: MaterialMode) => {
|
||||
materialMode.value = mode
|
||||
|
||||
showLightIntensityButton.value = mode === 'original'
|
||||
}
|
||||
|
||||
const listenUpDirectionChange = (value: UpDirection) => {
|
||||
upDirection.value = value
|
||||
}
|
||||
|
||||
const listenBackgroundColorChange = (value: string) => {
|
||||
backgroundColor.value = value
|
||||
}
|
||||
|
||||
const listenLightIntensityChange = (value: number) => {
|
||||
lightIntensity.value = value
|
||||
}
|
||||
|
||||
const listenFOVChange = (value: number) => {
|
||||
fov.value = value
|
||||
}
|
||||
|
||||
const listenCameraTypeChange = (value: CameraType) => {
|
||||
cameraType.value = value
|
||||
|
||||
showFOVButton.value = cameraType.value === 'perspective'
|
||||
}
|
||||
|
||||
const listenShowGridChange = (value: boolean) => {
|
||||
showGrid.value = value
|
||||
}
|
||||
|
||||
const listenShowPreviewChange = (value: boolean) => {
|
||||
showPreview.value = value
|
||||
}
|
||||
|
||||
const listenBackgroundImageChange = (value: string) => {
|
||||
backgroundImage.value = value
|
||||
|
||||
if (backgroundImage.value && backgroundImage.value !== '') {
|
||||
hasBackgroundImage.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,208 +0,0 @@
|
||||
<template>
|
||||
<Load3DScene
|
||||
ref="load3DSceneRef"
|
||||
:node="node"
|
||||
:input-spec="inputSpec"
|
||||
:background-color="backgroundColor"
|
||||
:show-grid="showGrid"
|
||||
:light-intensity="lightIntensity"
|
||||
:fov="fov"
|
||||
:camera-type="cameraType"
|
||||
:show-preview="showPreview"
|
||||
:extra-listeners="animationListeners"
|
||||
:background-image="backgroundImage"
|
||||
:up-direction="upDirection"
|
||||
:material-mode="materialMode"
|
||||
@material-mode-change="listenMaterialModeChange"
|
||||
@background-color-change="listenBackgroundColorChange"
|
||||
@light-intensity-change="listenLightIntensityChange"
|
||||
@fov-change="listenFOVChange"
|
||||
@camera-type-change="listenCameraTypeChange"
|
||||
@show-grid-change="listenShowGridChange"
|
||||
@show-preview-change="listenShowPreviewChange"
|
||||
@recording-status-change="listenRecordingStatusChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import Load3DScene from '@/components/load3d/Load3DScene.vue'
|
||||
import type Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
|
||||
import type {
|
||||
CameraType,
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
const props = defineProps<{
|
||||
node: any
|
||||
inputSpec: CustomInputSpec
|
||||
backgroundColor: string
|
||||
showGrid: boolean
|
||||
lightIntensity: number
|
||||
fov: number
|
||||
cameraType: CameraType
|
||||
showPreview: boolean
|
||||
materialMode: MaterialMode
|
||||
upDirection: UpDirection
|
||||
showFOVButton: boolean
|
||||
showLightIntensityButton: boolean
|
||||
playing: boolean
|
||||
selectedSpeed: number
|
||||
selectedAnimation: number
|
||||
backgroundImage: string
|
||||
}>()
|
||||
|
||||
const node = ref(props.node)
|
||||
const backgroundColor = ref(props.backgroundColor)
|
||||
const showPreview = ref(props.showPreview)
|
||||
const fov = ref(props.fov)
|
||||
const lightIntensity = ref(props.lightIntensity)
|
||||
const cameraType = ref(props.cameraType)
|
||||
const showGrid = ref(props.showGrid)
|
||||
const upDirection = ref(props.upDirection)
|
||||
const materialMode = ref(props.materialMode)
|
||||
const showFOVButton = ref(props.showFOVButton)
|
||||
const showLightIntensityButton = ref(props.showLightIntensityButton)
|
||||
const load3DSceneRef = ref<InstanceType<typeof Load3DScene> | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.cameraType,
|
||||
(newValue) => {
|
||||
cameraType.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showGrid,
|
||||
(newValue) => {
|
||||
showGrid.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.backgroundColor,
|
||||
(newValue) => {
|
||||
backgroundColor.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.lightIntensity,
|
||||
(newValue) => {
|
||||
lightIntensity.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.fov,
|
||||
(newValue) => {
|
||||
fov.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.upDirection,
|
||||
(newValue) => {
|
||||
upDirection.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.materialMode,
|
||||
(newValue) => {
|
||||
materialMode.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showPreview,
|
||||
(newValue) => {
|
||||
showPreview.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.playing,
|
||||
(newValue) => {
|
||||
const load3d = load3DSceneRef.value?.load3d as Load3dAnimation | null
|
||||
load3d?.toggleAnimation(newValue)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.selectedSpeed,
|
||||
(newValue) => {
|
||||
const load3d = load3DSceneRef.value?.load3d as Load3dAnimation | null
|
||||
load3d?.setAnimationSpeed(newValue)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.selectedAnimation,
|
||||
(newValue) => {
|
||||
const load3d = load3DSceneRef.value?.load3d as Load3dAnimation | null
|
||||
load3d?.updateSelectedAnimation(newValue)
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'animationListChange', animationList: string): void
|
||||
(e: 'materialModeChange', materialMode: MaterialMode): void
|
||||
(e: 'backgroundColorChange', color: string): void
|
||||
(e: 'lightIntensityChange', lightIntensity: number): void
|
||||
(e: 'fovChange', fov: number): void
|
||||
(e: 'cameraTypeChange', cameraType: CameraType): void
|
||||
(e: 'showGridChange', showGrid: boolean): void
|
||||
(e: 'showPreviewChange', showPreview: boolean): void
|
||||
(e: 'upDirectionChange', direction: UpDirection): void
|
||||
(e: 'recording-status-change', status: boolean): void
|
||||
}>()
|
||||
|
||||
const listenMaterialModeChange = (mode: MaterialMode) => {
|
||||
materialMode.value = mode
|
||||
|
||||
showLightIntensityButton.value = mode === 'original'
|
||||
}
|
||||
|
||||
const listenBackgroundColorChange = (value: string) => {
|
||||
backgroundColor.value = value
|
||||
}
|
||||
|
||||
const listenLightIntensityChange = (value: number) => {
|
||||
lightIntensity.value = value
|
||||
}
|
||||
|
||||
const listenFOVChange = (value: number) => {
|
||||
fov.value = value
|
||||
}
|
||||
|
||||
const listenCameraTypeChange = (value: CameraType) => {
|
||||
cameraType.value = value
|
||||
|
||||
showFOVButton.value = cameraType.value === 'perspective'
|
||||
}
|
||||
|
||||
const listenShowGridChange = (value: boolean) => {
|
||||
showGrid.value = value
|
||||
}
|
||||
|
||||
const listenShowPreviewChange = (value: boolean) => {
|
||||
showPreview.value = value
|
||||
}
|
||||
|
||||
const listenRecordingStatusChange = (value: boolean) => {
|
||||
emit('recording-status-change', value)
|
||||
}
|
||||
|
||||
const animationListeners = {
|
||||
animationListChange: (newValue: any) => {
|
||||
emit('animationListChange', newValue)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
load3DSceneRef
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<div
|
||||
class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-smoke-700/30"
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
@wheel.stop
|
||||
>
|
||||
<div class="show-menu relative">
|
||||
<Button class="p-button-rounded p-button-text" @click="toggleMenu">
|
||||
@@ -20,7 +24,9 @@
|
||||
@click="selectCategory(category)"
|
||||
>
|
||||
<i :class="getCategoryIcon(category)" />
|
||||
<span class="text-white">{{ t(categoryLabels[category]) }}</span>
|
||||
<span class="whitespace-nowrap text-white">{{
|
||||
$t(categoryLabels[category])
|
||||
}}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,71 +34,47 @@
|
||||
|
||||
<div v-show="activeCategory" class="rounded-lg bg-smoke-700/30">
|
||||
<SceneControls
|
||||
v-if="activeCategory === 'scene'"
|
||||
v-if="showSceneControls"
|
||||
ref="sceneControlsRef"
|
||||
:background-color="backgroundColor"
|
||||
:show-grid="showGrid"
|
||||
:has-background-image="hasBackgroundImage"
|
||||
@toggle-grid="handleToggleGrid"
|
||||
@update-background-color="handleBackgroundColorChange"
|
||||
v-model:show-grid="sceneConfig!.showGrid"
|
||||
v-model:background-color="sceneConfig!.backgroundColor"
|
||||
v-model:background-image="sceneConfig!.backgroundImage"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
/>
|
||||
|
||||
<ModelControls
|
||||
v-if="activeCategory === 'model'"
|
||||
v-if="showModelControls"
|
||||
ref="modelControlsRef"
|
||||
:input-spec="inputSpec"
|
||||
:up-direction="upDirection"
|
||||
:material-mode="materialMode"
|
||||
:edge-threshold="edgeThreshold"
|
||||
@update-up-direction="handleUpdateUpDirection"
|
||||
@update-material-mode="handleUpdateMaterialMode"
|
||||
@update-edge-threshold="handleUpdateEdgeThreshold"
|
||||
v-model:material-mode="modelConfig!.materialMode"
|
||||
v-model:up-direction="modelConfig!.upDirection"
|
||||
/>
|
||||
|
||||
<CameraControls
|
||||
v-if="activeCategory === 'camera'"
|
||||
v-if="showCameraControls"
|
||||
ref="cameraControlsRef"
|
||||
:camera-type="cameraType"
|
||||
:fov="fov"
|
||||
:show-f-o-v-button="showFOVButton"
|
||||
@switch-camera="switchCamera"
|
||||
@update-f-o-v="handleUpdateFOV"
|
||||
v-model:camera-type="cameraConfig!.cameraType"
|
||||
v-model:fov="cameraConfig!.fov"
|
||||
/>
|
||||
|
||||
<LightControls
|
||||
v-if="activeCategory === 'light'"
|
||||
v-if="showLightControls"
|
||||
ref="lightControlsRef"
|
||||
:light-intensity="lightIntensity"
|
||||
:show-light-intensity-button="showLightIntensityButton"
|
||||
@update-light-intensity="handleUpdateLightIntensity"
|
||||
v-model:light-intensity="lightConfig!.intensity"
|
||||
v-model:material-mode="modelConfig!.materialMode"
|
||||
/>
|
||||
|
||||
<ExportControls
|
||||
v-if="activeCategory === 'export'"
|
||||
v-if="showExportControls"
|
||||
ref="exportControlsRef"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showPreviewButton">
|
||||
<Button class="p-button-rounded p-button-text" @click="togglePreview">
|
||||
<i
|
||||
v-tooltip.right="{ value: t('load3d.previewOutput'), showDelay: 300 }"
|
||||
:class="[
|
||||
'pi',
|
||||
showPreview ? 'pi-eye' : 'pi-eye-slash',
|
||||
'text-lg text-white'
|
||||
]"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from 'primevue'
|
||||
import Button from 'primevue/button'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
|
||||
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
|
||||
@@ -100,31 +82,16 @@ import LightControls from '@/components/load3d/controls/LightControls.vue'
|
||||
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
|
||||
import SceneControls from '@/components/load3d/controls/SceneControls.vue'
|
||||
import type {
|
||||
CameraType,
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
CameraConfig,
|
||||
LightConfig,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
const vTooltip = Tooltip
|
||||
|
||||
const props = defineProps<{
|
||||
inputSpec: CustomInputSpec
|
||||
backgroundColor: string
|
||||
showGrid: boolean
|
||||
showPreview: boolean
|
||||
lightIntensity: number
|
||||
showLightIntensityButton: boolean
|
||||
fov: number
|
||||
showFOVButton: boolean
|
||||
showPreviewButton: boolean
|
||||
cameraType: CameraType
|
||||
hasBackgroundImage?: boolean
|
||||
upDirection: UpDirection
|
||||
materialMode: MaterialMode
|
||||
edgeThreshold?: number
|
||||
}>()
|
||||
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
|
||||
const modelConfig = defineModel<ModelConfig>('modelConfig')
|
||||
const cameraConfig = defineModel<CameraConfig>('cameraConfig')
|
||||
const lightConfig = defineModel<LightConfig>('lightConfig')
|
||||
|
||||
const isMenuOpen = ref(false)
|
||||
const activeCategory = ref<string>('scene')
|
||||
@@ -137,15 +104,26 @@ const categoryLabels: Record<string, string> = {
|
||||
}
|
||||
|
||||
const availableCategories = computed(() => {
|
||||
const baseCategories = ['scene', 'model', 'camera', 'light']
|
||||
|
||||
if (!props.inputSpec.isAnimation) {
|
||||
return [...baseCategories, 'export']
|
||||
}
|
||||
|
||||
return baseCategories
|
||||
return ['scene', 'model', 'camera', 'light', 'export']
|
||||
})
|
||||
|
||||
const showSceneControls = computed(
|
||||
() => activeCategory.value === 'scene' && !!sceneConfig.value
|
||||
)
|
||||
const showModelControls = computed(
|
||||
() => activeCategory.value === 'model' && !!modelConfig.value
|
||||
)
|
||||
const showCameraControls = computed(
|
||||
() => activeCategory.value === 'camera' && !!cameraConfig.value
|
||||
)
|
||||
const showLightControls = computed(
|
||||
() =>
|
||||
activeCategory.value === 'light' &&
|
||||
!!lightConfig.value &&
|
||||
!!modelConfig.value
|
||||
)
|
||||
const showExportControls = computed(() => activeCategory.value === 'export')
|
||||
|
||||
const toggleMenu = () => {
|
||||
isMenuOpen.value = !isMenuOpen.value
|
||||
}
|
||||
@@ -168,73 +146,14 @@ const getCategoryIcon = (category: string) => {
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'switchCamera'): void
|
||||
(e: 'toggleGrid', value: boolean): void
|
||||
(e: 'updateBackgroundColor', color: string): void
|
||||
(e: 'updateLightIntensity', value: number): void
|
||||
(e: 'updateFOV', value: number): void
|
||||
(e: 'togglePreview', value: boolean): void
|
||||
(e: 'updateBackgroundImage', file: File | null): void
|
||||
(e: 'updateUpDirection', direction: UpDirection): void
|
||||
(e: 'updateMaterialMode', mode: MaterialMode): void
|
||||
(e: 'updateEdgeThreshold', value: number): void
|
||||
(e: 'exportModel', format: string): void
|
||||
}>()
|
||||
|
||||
const backgroundColor = ref(props.backgroundColor)
|
||||
const showGrid = ref(props.showGrid)
|
||||
const showPreview = ref(props.showPreview)
|
||||
const lightIntensity = ref(props.lightIntensity)
|
||||
const upDirection = ref(props.upDirection || 'original')
|
||||
const materialMode = ref(props.materialMode || 'original')
|
||||
const showLightIntensityButton = ref(props.showLightIntensityButton)
|
||||
const fov = ref(props.fov)
|
||||
const showFOVButton = ref(props.showFOVButton)
|
||||
const showPreviewButton = ref(props.showPreviewButton)
|
||||
const hasBackgroundImage = ref(props.hasBackgroundImage)
|
||||
const edgeThreshold = ref(props.edgeThreshold)
|
||||
|
||||
const switchCamera = () => {
|
||||
emit('switchCamera')
|
||||
}
|
||||
|
||||
const togglePreview = () => {
|
||||
showPreview.value = !showPreview.value
|
||||
emit('togglePreview', showPreview.value)
|
||||
}
|
||||
|
||||
const handleToggleGrid = (value: boolean) => {
|
||||
emit('toggleGrid', value)
|
||||
}
|
||||
|
||||
const handleBackgroundColorChange = (value: string) => {
|
||||
emit('updateBackgroundColor', value)
|
||||
}
|
||||
|
||||
const handleBackgroundImageUpdate = (file: File | null) => {
|
||||
emit('updateBackgroundImage', file)
|
||||
}
|
||||
|
||||
const handleUpdateUpDirection = (direction: UpDirection) => {
|
||||
emit('updateUpDirection', direction)
|
||||
}
|
||||
|
||||
const handleUpdateMaterialMode = (mode: MaterialMode) => {
|
||||
emit('updateMaterialMode', mode)
|
||||
}
|
||||
|
||||
const handleUpdateEdgeThreshold = (value: number) => {
|
||||
emit('updateEdgeThreshold', value)
|
||||
}
|
||||
|
||||
const handleUpdateLightIntensity = (value: number) => {
|
||||
emit('updateLightIntensity', value)
|
||||
}
|
||||
|
||||
const handleUpdateFOV = (value: number) => {
|
||||
emit('updateFOV', value)
|
||||
}
|
||||
|
||||
const handleExportModel = (format: string) => {
|
||||
emit('exportModel', format)
|
||||
}
|
||||
@@ -247,101 +166,6 @@ const closeSlider = (e: MouseEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.upDirection,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
upDirection.value = newValue
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.backgroundColor,
|
||||
(newValue) => {
|
||||
backgroundColor.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.fov,
|
||||
(newValue) => {
|
||||
fov.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.lightIntensity,
|
||||
(newValue) => {
|
||||
lightIntensity.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showFOVButton,
|
||||
(newValue) => {
|
||||
showFOVButton.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showLightIntensityButton,
|
||||
(newValue) => {
|
||||
showLightIntensityButton.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.upDirection,
|
||||
(newValue) => {
|
||||
upDirection.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.materialMode,
|
||||
(newValue) => {
|
||||
materialMode.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showPreviewButton,
|
||||
(newValue) => {
|
||||
showPreviewButton.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showPreview,
|
||||
(newValue) => {
|
||||
showPreview.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.hasBackgroundImage,
|
||||
(newValue) => {
|
||||
hasBackgroundImage.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.materialMode,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
materialMode.value = newValue
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.edgeThreshold,
|
||||
(newValue) => {
|
||||
edgeThreshold.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeSlider)
|
||||
})
|
||||
|
||||
@@ -1,238 +1,72 @@
|
||||
<template>
|
||||
<div ref="container" class="relative h-full w-full">
|
||||
<LoadingOverlay ref="loadingOverlayRef" />
|
||||
<div
|
||||
ref="container"
|
||||
class="relative h-full w-full"
|
||||
data-capture-wheel="true"
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
@mousedown.stop
|
||||
@mousemove.stop
|
||||
@mouseup.stop
|
||||
@contextmenu.stop.prevent
|
||||
@dragover.prevent.stop="handleDragOver"
|
||||
@dragleave.stop="handleDragLeave"
|
||||
@drop.prevent.stop="handleDrop"
|
||||
>
|
||||
<LoadingOverlay
|
||||
ref="loadingOverlayRef"
|
||||
:loading="loading"
|
||||
:loading-message="loadingMessage"
|
||||
/>
|
||||
<div
|
||||
v-if="!isPreview && isDragging"
|
||||
class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="rounded-lg border-2 border-dashed border-blue-400 bg-blue-500/20 px-6 py-4 text-lg font-medium text-blue-100"
|
||||
>
|
||||
{{ dragMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, toRaw, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import LoadingOverlay from '@/components/load3d/LoadingOverlay.vue'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
|
||||
import type {
|
||||
CameraType,
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
|
||||
|
||||
const props = defineProps<{
|
||||
node: LGraphNode
|
||||
inputSpec: CustomInputSpec
|
||||
backgroundColor: string
|
||||
showGrid: boolean
|
||||
lightIntensity: number
|
||||
fov: number
|
||||
cameraType: CameraType
|
||||
showPreview: boolean
|
||||
backgroundImage: string
|
||||
upDirection: UpDirection
|
||||
materialMode: MaterialMode
|
||||
edgeThreshold?: number
|
||||
extraListeners?: Record<string, (value: any) => void>
|
||||
initializeLoad3d: (containerRef: HTMLElement) => Promise<void>
|
||||
cleanup: () => void
|
||||
loading: boolean
|
||||
loadingMessage: string
|
||||
onModelDrop?: (file: File) => void | Promise<void>
|
||||
isPreview: boolean
|
||||
}>()
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const node = ref(props.node)
|
||||
const load3d = ref<Load3d | Load3dAnimation | null>(null)
|
||||
const loadingOverlayRef = ref<InstanceType<typeof LoadingOverlay> | null>(null)
|
||||
|
||||
const eventConfig = {
|
||||
materialModeChange: (value: string) =>
|
||||
emit('materialModeChange', value as MaterialMode),
|
||||
backgroundColorChange: (value: string) =>
|
||||
emit('backgroundColorChange', value),
|
||||
lightIntensityChange: (value: number) => emit('lightIntensityChange', value),
|
||||
fovChange: (value: number) => emit('fovChange', value),
|
||||
cameraTypeChange: (value: string) =>
|
||||
emit('cameraTypeChange', value as CameraType),
|
||||
showGridChange: (value: boolean) => emit('showGridChange', value),
|
||||
showPreviewChange: (value: boolean) => emit('showPreviewChange', value),
|
||||
backgroundImageChange: (value: string) =>
|
||||
emit('backgroundImageChange', value),
|
||||
backgroundImageLoadingStart: () =>
|
||||
loadingOverlayRef.value?.startLoading(t('load3d.loadingBackgroundImage')),
|
||||
backgroundImageLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
|
||||
upDirectionChange: (value: string) =>
|
||||
emit('upDirectionChange', value as UpDirection),
|
||||
edgeThresholdChange: (value: number) => emit('edgeThresholdChange', value),
|
||||
modelLoadingStart: () =>
|
||||
loadingOverlayRef.value?.startLoading(t('load3d.loadingModel')),
|
||||
modelLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
|
||||
materialLoadingStart: () =>
|
||||
loadingOverlayRef.value?.startLoading(t('load3d.switchingMaterialMode')),
|
||||
materialLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
|
||||
exportLoadingStart: (message: string) => {
|
||||
loadingOverlayRef.value?.startLoading(message || t('load3d.exportingModel'))
|
||||
},
|
||||
exportLoadingEnd: () => {
|
||||
loadingOverlayRef.value?.endLoading()
|
||||
},
|
||||
recordingStatusChange: (value: boolean) =>
|
||||
emit('recordingStatusChange', value)
|
||||
} as const
|
||||
|
||||
watch(
|
||||
() => props.showPreview,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.togglePreview(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.cameraType,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.toggleCamera(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.fov,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.setFOV(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.lightIntensity,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.setLightIntensity(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showGrid,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.toggleGrid(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.backgroundColor,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.setBackgroundColor(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.backgroundImage,
|
||||
async (newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
await rawLoad3d.setBackgroundImage(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.upDirection,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.setUpDirection(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.materialMode,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.setMaterialMode(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.edgeThreshold,
|
||||
(newValue) => {
|
||||
if (load3d.value && newValue) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.setEdgeThreshold(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'materialModeChange', materialMode: MaterialMode): void
|
||||
(e: 'backgroundColorChange', color: string): void
|
||||
(e: 'lightIntensityChange', lightIntensity: number): void
|
||||
(e: 'fovChange', fov: number): void
|
||||
(e: 'cameraTypeChange', cameraType: CameraType): void
|
||||
(e: 'showGridChange', showGrid: boolean): void
|
||||
(e: 'showPreviewChange', showPreview: boolean): void
|
||||
(e: 'backgroundImageChange', backgroundImage: string): void
|
||||
(e: 'upDirectionChange', upDirection: UpDirection): void
|
||||
(e: 'edgeThresholdChange', threshold: number): void
|
||||
(e: 'recordingStatusChange', status: boolean): void
|
||||
}>()
|
||||
|
||||
const handleEvents = (action: 'add' | 'remove') => {
|
||||
if (!load3d.value) return
|
||||
|
||||
Object.entries(eventConfig).forEach(([event, handler]) => {
|
||||
const method = `${action}EventListener` as const
|
||||
load3d.value?.[method](event, handler)
|
||||
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
|
||||
useLoad3dDrag({
|
||||
onModelDrop: async (file) => {
|
||||
if (props.onModelDrop) {
|
||||
await props.onModelDrop(file)
|
||||
}
|
||||
},
|
||||
disabled: computed(() => props.isPreview)
|
||||
})
|
||||
|
||||
if (props.extraListeners) {
|
||||
Object.entries(props.extraListeners).forEach(([event, handler]) => {
|
||||
const method = `${action}EventListener` as const
|
||||
load3d.value?.[method](event, handler)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (container.value) {
|
||||
load3d.value = useLoad3dService().registerLoad3d(
|
||||
node.value as LGraphNode,
|
||||
container.value,
|
||||
props.inputSpec
|
||||
)
|
||||
void props.initializeLoad3d(container.value)
|
||||
}
|
||||
handleEvents('add')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
handleEvents('remove')
|
||||
useLoad3dService().removeLoad3d(node.value as LGraphNode)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
load3d
|
||||
props.cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,20 @@
|
||||
ref="containerRef"
|
||||
class="absolute h-full w-full"
|
||||
@resize="viewer.handleResize"
|
||||
@dragover.prevent.stop="handleDragOver"
|
||||
@dragleave.stop="handleDragLeave"
|
||||
@drop.prevent.stop="handleDrop"
|
||||
/>
|
||||
<div
|
||||
v-if="isDragging"
|
||||
class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="rounded-lg border-2 border-dashed border-blue-400 bg-blue-500/20 px-6 py-4 text-lg font-medium text-blue-100"
|
||||
>
|
||||
{{ dragMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-72 flex-col">
|
||||
@@ -75,6 +88,7 @@ import ExportControls from '@/components/load3d/controls/viewer/ViewerExportCont
|
||||
import LightControls from '@/components/load3d/controls/viewer/ViewerLightControls.vue'
|
||||
import ModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue'
|
||||
import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue'
|
||||
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
@@ -92,6 +106,14 @@ const mutationObserver = ref<MutationObserver | null>(null)
|
||||
|
||||
const viewer = useLoad3dService().getOrCreateViewer(toRaw(props.node))
|
||||
|
||||
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
|
||||
useLoad3dDrag({
|
||||
onModelDrop: async (file) => {
|
||||
await viewer.handleModelDrop(file)
|
||||
},
|
||||
disabled: viewer.isPreview
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const source = useLoad3dService().getLoad3d(props.node)
|
||||
if (source && containerRef.value) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="modelLoading"
|
||||
class="absolute inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
v-if="loading"
|
||||
class="bg-opacity-50 absolute inset-0 z-50 flex items-center justify-center bg-black"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="spinner" />
|
||||
@@ -15,29 +15,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const modelLoading = ref(false)
|
||||
const loadingMessage = ref('')
|
||||
|
||||
const startLoading = async (message?: string) => {
|
||||
loadingMessage.value = message || t('load3d.loadingModel')
|
||||
modelLoading.value = true
|
||||
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
const endLoading = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
modelLoading.value = false
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
startLoading,
|
||||
endLoading
|
||||
})
|
||||
defineProps<{
|
||||
loading: boolean
|
||||
loadingMessage: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
class="w-24"
|
||||
@change="speedChange"
|
||||
/>
|
||||
|
||||
<Select
|
||||
@@ -24,7 +23,6 @@
|
||||
option-label="name"
|
||||
option-value="index"
|
||||
class="w-32"
|
||||
@change="animationChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -32,23 +30,13 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Select from 'primevue/select'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
animations: Array<{ name: string; index: number }>
|
||||
playing: boolean
|
||||
}>()
|
||||
type Animation = { name: string; index: number }
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'togglePlay', value: boolean): void
|
||||
(e: 'speedChange', value: number): void
|
||||
(e: 'animationChange', value: number): void
|
||||
}>()
|
||||
|
||||
const animations = ref(props.animations)
|
||||
const playing = ref(props.playing)
|
||||
const selectedSpeed = ref(1)
|
||||
const selectedAnimation = ref(0)
|
||||
const animations = defineModel<Animation[]>('animations')
|
||||
const playing = defineModel<boolean>('playing')
|
||||
const selectedSpeed = defineModel<number>('selectedSpeed')
|
||||
const selectedAnimation = defineModel<number>('selectedAnimation')
|
||||
|
||||
const speedOptions = [
|
||||
{ name: '0.1x', value: 0.1 },
|
||||
@@ -58,24 +46,7 @@ const speedOptions = [
|
||||
{ name: '2x', value: 2 }
|
||||
]
|
||||
|
||||
watch(
|
||||
() => props.animations,
|
||||
(newVal) => {
|
||||
animations.value = newVal
|
||||
}
|
||||
)
|
||||
|
||||
const togglePlay = () => {
|
||||
playing.value = !playing.value
|
||||
|
||||
emit('togglePlay', playing.value)
|
||||
}
|
||||
|
||||
const speedChange = () => {
|
||||
emit('speedChange', selectedSpeed.value)
|
||||
}
|
||||
|
||||
const animationChange = () => {
|
||||
emit('animationChange', selectedAnimation.value)
|
||||
}
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@
|
||||
<Button class="p-button-rounded p-button-text" @click="switchCamera">
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.switchCamera'),
|
||||
value: $t('load3d.switchCamera'),
|
||||
showDelay: 300
|
||||
}"
|
||||
:class="['pi', getCameraIcon, 'text-lg text-white']"
|
||||
@@ -12,7 +12,7 @@
|
||||
<div v-if="showFOVButton" class="show-fov relative">
|
||||
<Button class="p-button-rounded p-button-text" @click="toggleFOV">
|
||||
<i
|
||||
v-tooltip.right="{ value: t('load3d.fov'), showDelay: 300 }"
|
||||
v-tooltip.right="{ value: $t('load3d.fov'), showDelay: 300 }"
|
||||
class="pi pi-expand text-lg text-white"
|
||||
/>
|
||||
</Button>
|
||||
@@ -21,83 +21,37 @@
|
||||
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg"
|
||||
style="width: 150px"
|
||||
>
|
||||
<Slider
|
||||
v-model="fov"
|
||||
class="w-full"
|
||||
:min="10"
|
||||
:max="150"
|
||||
:step="1"
|
||||
@change="updateFOV"
|
||||
/>
|
||||
<Slider v-model="fov" class="w-full" :min="10" :max="150" :step="1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from 'primevue'
|
||||
import Button from 'primevue/button'
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const vTooltip = Tooltip
|
||||
|
||||
const props = defineProps<{
|
||||
cameraType: CameraType
|
||||
fov: number
|
||||
showFOVButton: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'switchCamera'): void
|
||||
(e: 'updateFOV', value: number): void
|
||||
}>()
|
||||
|
||||
const cameraType = ref(props.cameraType)
|
||||
const fov = ref(props.fov)
|
||||
const showFOVButton = ref(props.showFOVButton)
|
||||
const showFOV = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.fov,
|
||||
(newValue) => {
|
||||
fov.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showFOVButton,
|
||||
(newValue) => {
|
||||
showFOVButton.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.cameraType,
|
||||
(newValue) => {
|
||||
cameraType.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
const switchCamera = () => {
|
||||
emit('switchCamera')
|
||||
}
|
||||
const cameraType = defineModel<CameraType>('cameraType')
|
||||
const fov = defineModel<number>('fov')
|
||||
const showFOVButton = computed(() => cameraType.value === 'perspective')
|
||||
const getCameraIcon = computed(() => {
|
||||
return cameraType.value === 'perspective' ? 'pi-camera' : 'pi-camera'
|
||||
})
|
||||
|
||||
const toggleFOV = () => {
|
||||
showFOV.value = !showFOV.value
|
||||
}
|
||||
|
||||
const updateFOV = () => {
|
||||
emit('updateFOV', fov.value)
|
||||
const switchCamera = () => {
|
||||
cameraType.value =
|
||||
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
|
||||
}
|
||||
|
||||
const getCameraIcon = computed(() => {
|
||||
return props.cameraType === 'perspective' ? 'pi-camera' : 'pi-camera'
|
||||
})
|
||||
|
||||
const closeCameraSlider = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
>
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.exportModel'),
|
||||
value: $t('load3d.exportModel'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-download text-lg text-white"
|
||||
@@ -33,14 +33,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from 'primevue'
|
||||
import Button from 'primevue/button'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const vTooltip = Tooltip
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'exportModel', format: string): void
|
||||
}>()
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
>
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.lightIntensity'),
|
||||
value: $t('load3d.lightIntensity'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-sun text-lg text-white"
|
||||
@@ -24,7 +24,6 @@
|
||||
:min="lightIntensityMinimum"
|
||||
:max="lightIntensityMaximum"
|
||||
:step="lightAdjustmentIncrement"
|
||||
@change="updateLightIntensity"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,27 +31,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from 'primevue'
|
||||
import Button from 'primevue/button'
|
||||
import Slider from 'primevue/slider'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { MaterialMode } from '@/extensions/core/load3d/interfaces'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const vTooltip = Tooltip
|
||||
const lightIntensity = defineModel<number>('lightIntensity')
|
||||
const materialMode = defineModel<MaterialMode>('materialMode')
|
||||
|
||||
const props = defineProps<{
|
||||
lightIntensity: number
|
||||
showLightIntensityButton: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateLightIntensity', value: number): void
|
||||
}>()
|
||||
|
||||
const lightIntensity = ref(props.lightIntensity)
|
||||
const showLightIntensityButton = ref(props.showLightIntensityButton)
|
||||
const showLightIntensityButton = computed(
|
||||
() => materialMode.value === 'original'
|
||||
)
|
||||
const showLightIntensity = ref(false)
|
||||
|
||||
const lightIntensityMaximum = useSettingStore().get(
|
||||
@@ -65,28 +56,10 @@ const lightAdjustmentIncrement = useSettingStore().get(
|
||||
'Comfy.Load3D.LightAdjustmentIncrement'
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.lightIntensity,
|
||||
(newValue) => {
|
||||
lightIntensity.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showLightIntensityButton,
|
||||
(newValue) => {
|
||||
showLightIntensityButton.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
const toggleLightIntensity = () => {
|
||||
showLightIntensity.value = !showLightIntensity.value
|
||||
}
|
||||
|
||||
const updateLightIntensity = () => {
|
||||
emit('updateLightIntensity', lightIntensity.value)
|
||||
}
|
||||
|
||||
const closeLightSlider = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
:class="{ 'bg-blue-500': upDirection === direction }"
|
||||
@click="selectUpDirection(direction)"
|
||||
>
|
||||
{{ formatOption(direction) }}
|
||||
{{ direction.toUpperCase() }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,7 +49,7 @@
|
||||
<Button
|
||||
v-for="mode in materialModes"
|
||||
:key="mode"
|
||||
class="p-button-text text-white"
|
||||
class="p-button-text whitespace-nowrap text-white"
|
||||
:class="{ 'bg-blue-500': materialMode === mode }"
|
||||
@click="selectMaterialMode(mode)"
|
||||
>
|
||||
@@ -58,75 +58,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="materialMode === 'lineart'" class="show-edge-threshold relative">
|
||||
<Button
|
||||
class="p-button-rounded p-button-text"
|
||||
@click="toggleEdgeThreshold"
|
||||
>
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.edgeThreshold'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-sliders-h text-lg text-white"
|
||||
/>
|
||||
</Button>
|
||||
<div
|
||||
v-show="showEdgeThreshold"
|
||||
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg"
|
||||
style="width: 150px"
|
||||
>
|
||||
<label class="mb-1 block text-xs text-white"
|
||||
>{{ t('load3d.edgeThreshold') }}: {{ edgeThreshold }}°</label
|
||||
>
|
||||
<Slider
|
||||
v-model="edgeThreshold"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="120"
|
||||
:step="1"
|
||||
@change="updateEdgeThreshold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from 'primevue'
|
||||
import Button from 'primevue/button'
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
const vTooltip = Tooltip
|
||||
const materialMode = defineModel<MaterialMode>('materialMode')
|
||||
const upDirection = defineModel<UpDirection>('upDirection')
|
||||
|
||||
const props = defineProps<{
|
||||
inputSpec: CustomInputSpec
|
||||
upDirection: UpDirection
|
||||
materialMode: MaterialMode
|
||||
edgeThreshold?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateUpDirection', direction: UpDirection): void
|
||||
(e: 'updateMaterialMode', mode: MaterialMode): void
|
||||
(e: 'updateEdgeThreshold', value: number): void
|
||||
}>()
|
||||
|
||||
const upDirection = ref(props.upDirection || 'original')
|
||||
const materialMode = ref(props.materialMode || 'original')
|
||||
const edgeThreshold = ref(props.edgeThreshold || 85)
|
||||
const showUpDirection = ref(false)
|
||||
const showMaterialMode = ref(false)
|
||||
const showEdgeThreshold = ref(false)
|
||||
|
||||
const upDirections: UpDirection[] = [
|
||||
'original',
|
||||
@@ -146,65 +95,26 @@ const materialModes = computed(() => {
|
||||
//'depth' disable for now
|
||||
]
|
||||
|
||||
if (!props.inputSpec.isAnimation && !props.inputSpec.isPreview) {
|
||||
modes.push('lineart')
|
||||
}
|
||||
|
||||
return modes
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.upDirection,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
upDirection.value = newValue
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.materialMode,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
materialMode.value = newValue
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.edgeThreshold,
|
||||
(newValue) => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
edgeThreshold.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
const toggleUpDirection = () => {
|
||||
showUpDirection.value = !showUpDirection.value
|
||||
showMaterialMode.value = false
|
||||
showEdgeThreshold.value = false
|
||||
}
|
||||
|
||||
const selectUpDirection = (direction: UpDirection) => {
|
||||
upDirection.value = direction
|
||||
emit('updateUpDirection', direction)
|
||||
showUpDirection.value = false
|
||||
}
|
||||
|
||||
const formatOption = (option: string) => {
|
||||
if (option === 'original') return 'Original'
|
||||
return option.toUpperCase()
|
||||
}
|
||||
|
||||
const toggleMaterialMode = () => {
|
||||
showMaterialMode.value = !showMaterialMode.value
|
||||
showUpDirection.value = false
|
||||
showEdgeThreshold.value = false
|
||||
}
|
||||
|
||||
const selectMaterialMode = (mode: MaterialMode) => {
|
||||
materialMode.value = mode
|
||||
emit('updateMaterialMode', mode)
|
||||
showMaterialMode.value = false
|
||||
}
|
||||
|
||||
@@ -212,16 +122,6 @@ const formatMaterialMode = (mode: MaterialMode) => {
|
||||
return t(`load3d.materialModes.${mode}`)
|
||||
}
|
||||
|
||||
const toggleEdgeThreshold = () => {
|
||||
showEdgeThreshold.value = !showEdgeThreshold.value
|
||||
showUpDirection.value = false
|
||||
showMaterialMode.value = false
|
||||
}
|
||||
|
||||
const updateEdgeThreshold = () => {
|
||||
emit('updateEdgeThreshold', edgeThreshold.value)
|
||||
}
|
||||
|
||||
const closeSceneSlider = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
@@ -232,10 +132,6 @@ const closeSceneSlider = (e: MouseEvent) => {
|
||||
if (!target.closest('.show-material-mode')) {
|
||||
showMaterialMode.value = false
|
||||
}
|
||||
|
||||
if (!target.closest('.show-edge-threshold')) {
|
||||
showEdgeThreshold.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
<template>
|
||||
<div class="relative rounded-lg bg-smoke-700/30">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
class="p-button-rounded p-button-text"
|
||||
@click="resizeNodeMatchOutput"
|
||||
>
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.resizeNodeMatchOutput'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-window-maximize text-lg text-white"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
class="p-button-rounded p-button-text"
|
||||
:class="{
|
||||
@@ -24,8 +12,8 @@
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: isRecording
|
||||
? t('load3d.stopRecording')
|
||||
: t('load3d.startRecording'),
|
||||
? $t('load3d.stopRecording')
|
||||
: $t('load3d.startRecording'),
|
||||
showDelay: 300
|
||||
}"
|
||||
:class="[
|
||||
@@ -39,11 +27,11 @@
|
||||
<Button
|
||||
v-if="hasRecording && !isRecording"
|
||||
class="p-button-rounded p-button-text"
|
||||
@click="exportRecording"
|
||||
@click="handleExportRecording"
|
||||
>
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.exportRecording'),
|
||||
value: $t('load3d.exportRecording'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-download text-lg text-white"
|
||||
@@ -53,11 +41,11 @@
|
||||
<Button
|
||||
v-if="hasRecording && !isRecording"
|
||||
class="p-button-rounded p-button-text"
|
||||
@click="clearRecording"
|
||||
@click="handleClearRecording"
|
||||
>
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.clearRecording'),
|
||||
value: $t('load3d.clearRecording'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-trash text-lg text-white"
|
||||
@@ -65,7 +53,7 @@
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-if="recordingDuration > 0 && !isRecording"
|
||||
v-if="recordingDuration && recordingDuration > 0 && !isRecording"
|
||||
class="mt-1 text-center text-xs text-white"
|
||||
>
|
||||
{{ formatDuration(recordingDuration) }}
|
||||
@@ -75,21 +63,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from 'primevue'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
|
||||
const vTooltip = Tooltip
|
||||
|
||||
const { hasRecording, isRecording, node, recordingDuration } = defineProps<{
|
||||
hasRecording: boolean
|
||||
isRecording: boolean
|
||||
node: LGraphNode
|
||||
recordingDuration: number
|
||||
}>()
|
||||
const hasRecording = defineModel<boolean>('hasRecording')
|
||||
const isRecording = defineModel<boolean>('isRecording')
|
||||
const recordingDuration = defineModel<number>('recordingDuration')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'startRecording'): void
|
||||
@@ -98,49 +76,19 @@ const emit = defineEmits<{
|
||||
(e: 'clearRecording'): void
|
||||
}>()
|
||||
|
||||
const resizeNodeMatchOutput = () => {
|
||||
const outputWidth = node.widgets?.find((w) => w.name === 'width')
|
||||
const outputHeight = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
if (outputWidth && outputHeight && outputHeight.value && outputWidth.value) {
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
const scene = node.widgets?.find((w) => w.name === 'image')
|
||||
|
||||
const sceneHeight = scene?.computedHeight
|
||||
|
||||
if (sceneHeight) {
|
||||
const sceneWidth = oldWidth - 20
|
||||
|
||||
const outputRatio = Number(outputHeight.value) / Number(outputWidth.value)
|
||||
const expectSceneHeight = sceneWidth * outputRatio
|
||||
|
||||
node.setSize([oldWidth, oldHeight + (expectSceneHeight - sceneHeight)])
|
||||
|
||||
node.graph?.setDirtyCanvas(true, true)
|
||||
|
||||
const load3d = useLoad3dService().getLoad3d(node as LGraphNode)
|
||||
|
||||
if (load3d) {
|
||||
load3d.refreshViewport()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRecording = () => {
|
||||
if (isRecording) {
|
||||
if (isRecording.value) {
|
||||
emit('stopRecording')
|
||||
} else {
|
||||
emit('startRecording')
|
||||
}
|
||||
}
|
||||
|
||||
const exportRecording = () => {
|
||||
const handleExportRecording = () => {
|
||||
emit('exportRecording')
|
||||
}
|
||||
|
||||
const clearRecording = () => {
|
||||
const handleClearRecording = () => {
|
||||
emit('clearRecording')
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
@click="toggleGrid"
|
||||
>
|
||||
<i
|
||||
v-tooltip.right="{ value: t('load3d.showGrid'), showDelay: 300 }"
|
||||
v-tooltip.right="{ value: $t('load3d.showGrid'), showDelay: 300 }"
|
||||
class="pi pi-table text-lg text-white"
|
||||
/>
|
||||
</Button>
|
||||
@@ -15,7 +15,7 @@
|
||||
<Button class="p-button-rounded p-button-text" @click="openColorPicker">
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.backgroundColor'),
|
||||
value: $t('load3d.backgroundColor'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-palette text-lg text-white"
|
||||
@@ -36,7 +36,7 @@
|
||||
<Button class="p-button-rounded p-button-text" @click="openImagePicker">
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.uploadBackgroundImage'),
|
||||
value: $t('load3d.uploadBackgroundImage'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-image text-lg text-white"
|
||||
@@ -58,7 +58,7 @@
|
||||
>
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.removeBackgroundImage'),
|
||||
value: $t('load3d.removeBackgroundImage'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-times text-lg text-white"
|
||||
@@ -69,60 +69,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from 'primevue'
|
||||
import Button from 'primevue/button'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const vTooltip = Tooltip
|
||||
|
||||
const props = defineProps<{
|
||||
backgroundColor: string
|
||||
showGrid: boolean
|
||||
hasBackgroundImage?: boolean
|
||||
}>()
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggleGrid', value: boolean): void
|
||||
(e: 'updateBackgroundColor', color: string): void
|
||||
(e: 'updateBackgroundImage', file: File | null): void
|
||||
}>()
|
||||
|
||||
const backgroundColor = ref(props.backgroundColor)
|
||||
const showGrid = ref(props.showGrid)
|
||||
const hasBackgroundImage = ref(props.hasBackgroundImage)
|
||||
const showGrid = defineModel<boolean>('showGrid')
|
||||
const backgroundColor = defineModel<string>('backgroundColor')
|
||||
const backgroundImage = defineModel<string>('backgroundImage')
|
||||
const hasBackgroundImage = computed(
|
||||
() => backgroundImage.value && backgroundImage.value !== ''
|
||||
)
|
||||
|
||||
const colorPickerRef = ref<HTMLInputElement | null>(null)
|
||||
const imagePickerRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.backgroundColor,
|
||||
(newValue) => {
|
||||
backgroundColor.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showGrid,
|
||||
(newValue) => {
|
||||
showGrid.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.hasBackgroundImage,
|
||||
(newValue) => {
|
||||
hasBackgroundImage.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
const toggleGrid = () => {
|
||||
showGrid.value = !showGrid.value
|
||||
emit('toggleGrid', showGrid.value)
|
||||
}
|
||||
|
||||
const updateBackgroundColor = (color: string) => {
|
||||
emit('updateBackgroundColor', color)
|
||||
backgroundColor.value = color
|
||||
}
|
||||
|
||||
const openColorPicker = () => {
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from 'primevue'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
@@ -24,8 +23,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const vTooltip = Tooltip
|
||||
|
||||
const { node } = defineProps<{
|
||||
node: LGraphNode
|
||||
}>()
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</Select>
|
||||
|
||||
<Button severity="secondary" text rounded @click="exportModel(exportFormat)">
|
||||
{{ t('load3d.export') }}
|
||||
{{ $t('load3d.export') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -17,8 +17,6 @@ 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
|
||||
}>()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<label>{{ t('load3d.lightIntensity') }}</label>
|
||||
<label>{{ $t('load3d.lightIntensity') }}</label>
|
||||
|
||||
<Slider
|
||||
v-model="lightIntensity"
|
||||
@@ -13,7 +13,6 @@
|
||||
<script setup lang="ts">
|
||||
import Slider from 'primevue/slider'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const lightIntensity = defineModel<number>('lightIntensity')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label>{{ t('load3d.upDirection') }}</label>
|
||||
<label>{{ $t('load3d.upDirection') }}</label>
|
||||
<Select
|
||||
v-model="upDirection"
|
||||
:options="upDirectionOptions"
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{{ t('load3d.materialMode') }}</label>
|
||||
<label>{{ $t('load3d.materialMode') }}</label>
|
||||
<Select
|
||||
v-model="materialMode"
|
||||
:options="materialModeOptions"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="space-y-4">
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<label>
|
||||
{{ t('load3d.backgroundColor') }}
|
||||
{{ $t('load3d.backgroundColor') }}
|
||||
</label>
|
||||
<input v-model="backgroundColor" type="color" class="w-full" />
|
||||
</div>
|
||||
@@ -10,14 +10,14 @@
|
||||
<div>
|
||||
<Checkbox v-model="showGrid" input-id="showGrid" binary name="showGrid" />
|
||||
<label for="showGrid" class="pl-2">
|
||||
{{ t('load3d.showGrid') }}
|
||||
{{ $t('load3d.showGrid') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<Button
|
||||
severity="secondary"
|
||||
:label="t('load3d.uploadBackgroundImage')"
|
||||
:label="$t('load3d.uploadBackgroundImage')"
|
||||
icon="pi pi-image"
|
||||
class="w-full"
|
||||
@click="openImagePicker"
|
||||
@@ -34,7 +34,7 @@
|
||||
<div v-if="hasBackgroundImage" class="space-y-2">
|
||||
<Button
|
||||
severity="secondary"
|
||||
:label="t('load3d.removeBackgroundImage')"
|
||||
:label="$t('load3d.removeBackgroundImage')"
|
||||
icon="pi pi-times"
|
||||
class="w-full"
|
||||
@click="removeBackgroundImage"
|
||||
@@ -48,8 +48,6 @@ 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')
|
||||
|
||||
|
||||
536
src/composables/useLoad3d.ts
Normal file
536
src/composables/useLoad3d.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
import { toRef } from '@vueuse/core'
|
||||
import type { MaybeRef } from '@vueuse/core'
|
||||
import { nextTick, ref, toRaw, watch } from 'vue'
|
||||
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type {
|
||||
AnimationItem,
|
||||
CameraConfig,
|
||||
CameraType,
|
||||
LightConfig,
|
||||
MaterialMode,
|
||||
ModelConfig,
|
||||
SceneConfig,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
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
|
||||
|
||||
const sceneConfig = ref<SceneConfig>({
|
||||
showGrid: true,
|
||||
backgroundColor: '#000000',
|
||||
backgroundImage: ''
|
||||
})
|
||||
|
||||
const modelConfig = ref<ModelConfig>({
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
})
|
||||
|
||||
const cameraConfig = ref<CameraConfig>({
|
||||
cameraType: 'perspective',
|
||||
fov: 75
|
||||
})
|
||||
|
||||
const lightConfig = ref<LightConfig>({
|
||||
intensity: 5
|
||||
})
|
||||
|
||||
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 loading = ref(false)
|
||||
const loadingMessage = ref('')
|
||||
const isPreview = ref(false)
|
||||
|
||||
const initializeLoad3d = async (containerRef: HTMLElement) => {
|
||||
const rawNode = toRaw(nodeRef.value)
|
||||
if (!containerRef || !rawNode) return
|
||||
|
||||
const node = rawNode as LGraphNode
|
||||
|
||||
try {
|
||||
load3d = new Load3d(containerRef, {
|
||||
node
|
||||
})
|
||||
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
if (!(widthWidget && heightWidget)) {
|
||||
isPreview.value = true
|
||||
}
|
||||
|
||||
await restoreConfigurationsFromNode(node)
|
||||
|
||||
node.onMouseEnter = function () {
|
||||
load3d?.refreshViewport()
|
||||
|
||||
load3d?.updateStatusMouseOnNode(true)
|
||||
}
|
||||
|
||||
node.onMouseLeave = function () {
|
||||
load3d?.updateStatusMouseOnNode(false)
|
||||
}
|
||||
|
||||
node.onResize = function () {
|
||||
load3d?.handleResize()
|
||||
}
|
||||
|
||||
node.onDrawBackground = function () {
|
||||
if (load3d) {
|
||||
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
|
||||
}
|
||||
}
|
||||
|
||||
node.onRemoved = function () {
|
||||
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.failedToInitializeLoad3d'))
|
||||
}
|
||||
}
|
||||
|
||||
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 = savedSceneConfig
|
||||
}
|
||||
|
||||
const savedModelConfig = node.properties['Model Config'] as ModelConfig
|
||||
if (savedModelConfig) {
|
||||
modelConfig.value = savedModelConfig
|
||||
}
|
||||
|
||||
const savedCameraConfig = node.properties['Camera Config'] as CameraConfig
|
||||
const cameraStateToRestore = savedCameraConfig?.state
|
||||
|
||||
if (savedCameraConfig) {
|
||||
cameraConfig.value = savedCameraConfig
|
||||
}
|
||||
|
||||
const savedLightConfig = node.properties['Light Config'] as LightConfig
|
||||
if (savedLightConfig) {
|
||||
lightConfig.value = savedLightConfig
|
||||
}
|
||||
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
if (modelWidget?.value) {
|
||||
const modelUrl = getModelUrl(modelWidget.value as string)
|
||||
if (modelUrl) {
|
||||
loading.value = true
|
||||
loadingMessage.value = t('load3d.reloadingModel')
|
||||
try {
|
||||
await load3d.loadModel(modelUrl)
|
||||
|
||||
if (cameraStateToRestore) {
|
||||
await nextTick()
|
||||
load3d.setCameraState(cameraStateToRestore)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reload model:', error)
|
||||
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMessage.value = ''
|
||||
}
|
||||
}
|
||||
} else if (cameraStateToRestore) {
|
||||
load3d.setCameraState(cameraStateToRestore)
|
||||
}
|
||||
}
|
||||
|
||||
const getModelUrl = (modelPath: string): string | null => {
|
||||
if (!modelPath) return null
|
||||
|
||||
try {
|
||||
if (modelPath.startsWith('http')) {
|
||||
return modelPath
|
||||
}
|
||||
|
||||
const [subfolder, filename] = Load3dUtils.splitFilePath(modelPath)
|
||||
return api.apiURL(
|
||||
Load3dUtils.getResourceURL(
|
||||
subfolder,
|
||||
filename,
|
||||
isPreview.value ? 'output' : 'input'
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to construct model URL:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
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 (load3d && nodeRef.value) {
|
||||
nodeRef.value.properties['Scene Config'] = newValue
|
||||
load3d.toggleGrid(newValue.showGrid)
|
||||
load3d.setBackgroundColor(newValue.backgroundColor)
|
||||
void load3d.setBackgroundImage(newValue.backgroundImage || '')
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
modelConfig,
|
||||
(newValue) => {
|
||||
if (load3d && nodeRef.value) {
|
||||
nodeRef.value.properties['Model Config'] = newValue
|
||||
load3d.setUpDirection(newValue.upDirection)
|
||||
load3d.setMaterialMode(newValue.materialMode)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
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,
|
||||
(newValue) => {
|
||||
if (load3d && nodeRef.value) {
|
||||
nodeRef.value.properties['Light Config'] = newValue
|
||||
load3d.setLightIntensity(newValue.intensity)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
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 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
|
||||
},
|
||||
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
|
||||
},
|
||||
modelLoadingEnd: () => {
|
||||
loadingMessage.value = ''
|
||||
loading.value = false
|
||||
},
|
||||
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: any) => {
|
||||
animations.value = newValue
|
||||
}
|
||||
} as const
|
||||
|
||||
const handleEvents = (action: 'add' | 'remove') => {
|
||||
Object.entries(eventConfig).forEach(([event, handler]) => {
|
||||
const method = `${action}EventListener` as const
|
||||
load3d?.[method](event, handler)
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
animations,
|
||||
playing,
|
||||
selectedSpeed,
|
||||
selectedAnimation,
|
||||
loading,
|
||||
loadingMessage,
|
||||
|
||||
// Methods
|
||||
initializeLoad3d,
|
||||
waitForLoad3d,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
handleStartRecording,
|
||||
handleStopRecording,
|
||||
handleExportRecording,
|
||||
handleClearRecording,
|
||||
handleBackgroundImageUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
70
src/composables/useLoad3dDrag.ts
Normal file
70
src/composables/useLoad3dDrag.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { computed, ref, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { SUPPORTED_EXTENSIONS } from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
interface UseLoad3dDragOptions {
|
||||
onModelDrop: (file: File) => void | Promise<void>
|
||||
disabled?: MaybeRefOrGetter<boolean>
|
||||
}
|
||||
|
||||
export function useLoad3dDrag(options: UseLoad3dDragOptions) {
|
||||
const isDragging = ref(false)
|
||||
const dragMessage = ref('')
|
||||
|
||||
const isDisabled = computed(() => toValue(options.disabled) ?? false)
|
||||
|
||||
function isValidModelFile(file: File): boolean {
|
||||
const fileName = file.name.toLowerCase()
|
||||
const extension = fileName.substring(fileName.lastIndexOf('.'))
|
||||
return SUPPORTED_EXTENSIONS.has(extension)
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
if (isDisabled.value) return
|
||||
|
||||
if (!event.dataTransfer) return
|
||||
|
||||
const hasFiles = event.dataTransfer.types.includes('Files')
|
||||
|
||||
if (!hasFiles) return
|
||||
|
||||
isDragging.value = true
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
dragMessage.value = t('load3d.dropToLoad')
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
async function handleDrop(event: DragEvent) {
|
||||
isDragging.value = false
|
||||
|
||||
if (isDisabled.value) return
|
||||
|
||||
if (!event.dataTransfer) return
|
||||
|
||||
const files = Array.from(event.dataTransfer.files)
|
||||
|
||||
if (files.length === 0) return
|
||||
|
||||
const modelFile = files.find(isValidModelFile)
|
||||
|
||||
if (modelFile) {
|
||||
await options.onModelDrop(modelFile)
|
||||
} else {
|
||||
useToastStore().addAlert(t('load3d.unsupportedFileType'))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
dragMessage,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
|
||||
interface Load3dViewerState {
|
||||
@@ -22,7 +23,6 @@ interface Load3dViewerState {
|
||||
backgroundImage: string
|
||||
upDirection: UpDirection
|
||||
materialMode: MaterialMode
|
||||
edgeThreshold: number
|
||||
}
|
||||
|
||||
export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
@@ -35,8 +35,8 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
const hasBackgroundImage = ref(false)
|
||||
const upDirection = ref<UpDirection>('original')
|
||||
const materialMode = ref<MaterialMode>('original')
|
||||
const edgeThreshold = ref(85)
|
||||
const needApplyChanges = ref(true)
|
||||
const isPreview = ref(false)
|
||||
|
||||
let load3d: Load3d | null = null
|
||||
let sourceLoad3d: Load3d | null = null
|
||||
@@ -50,8 +50,7 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
cameraState: null,
|
||||
backgroundImage: '',
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
edgeThreshold: 85
|
||||
materialMode: 'original'
|
||||
})
|
||||
|
||||
watch(backgroundColor, (newColor) => {
|
||||
@@ -149,18 +148,6 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
@@ -178,34 +165,52 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
|
||||
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 sceneConfig = node.properties['Scene Config'] as any
|
||||
const modelConfig = node.properties['Model Config'] as any
|
||||
const cameraConfig = node.properties['Camera Config'] as any
|
||||
const lightConfig = node.properties['Light Config'] as any
|
||||
|
||||
const backgroundInfo = source.sceneManager.getCurrentBackgroundInfo()
|
||||
if (
|
||||
backgroundInfo.type === 'image' &&
|
||||
node.properties['Background Image']
|
||||
) {
|
||||
backgroundImage.value = node.properties['Background Image'] as string
|
||||
hasBackgroundImage.value = true
|
||||
isPreview.value = node.type === 'Preview3D'
|
||||
|
||||
if (sceneConfig) {
|
||||
backgroundColor.value =
|
||||
sceneConfig.backgroundColor ||
|
||||
source.sceneManager.currentBackgroundColor
|
||||
showGrid.value =
|
||||
sceneConfig.showGrid ?? source.sceneManager.gridHelper.visible
|
||||
|
||||
const backgroundInfo = source.sceneManager.getCurrentBackgroundInfo()
|
||||
if (backgroundInfo.type === 'image' && sceneConfig.backgroundImage) {
|
||||
backgroundImage.value = sceneConfig.backgroundImage
|
||||
hasBackgroundImage.value = true
|
||||
} else {
|
||||
backgroundImage.value = ''
|
||||
hasBackgroundImage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
if (cameraConfig) {
|
||||
cameraType.value =
|
||||
cameraConfig.cameraType || source.getCurrentCameraType()
|
||||
fov.value =
|
||||
cameraConfig.fov || source.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
|
||||
if (lightConfig) {
|
||||
lightIntensity.value = lightConfig.intensity || 1
|
||||
} else {
|
||||
backgroundImage.value = ''
|
||||
hasBackgroundImage.value = false
|
||||
lightIntensity.value = 1
|
||||
}
|
||||
|
||||
if (sourceCameraType === 'perspective') {
|
||||
fov.value = source.cameraManager.perspectiveCamera.fov
|
||||
if (modelConfig) {
|
||||
upDirection.value =
|
||||
modelConfig.upDirection || source.modelManager.currentUpDirection
|
||||
materialMode.value =
|
||||
modelConfig.materialMode || source.modelManager.materialMode
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -215,8 +220,7 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
cameraState: sourceCameraState,
|
||||
backgroundImage: backgroundImage.value,
|
||||
upDirection: upDirection.value,
|
||||
materialMode: materialMode.value,
|
||||
edgeThreshold: edgeThreshold.value
|
||||
materialMode: materialMode.value
|
||||
}
|
||||
|
||||
const width = node.widgets?.find((w) => w.name === 'width')
|
||||
@@ -267,16 +271,31 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
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
|
||||
nodeValue.properties['Scene Config'] = {
|
||||
showGrid: initialState.value.showGrid,
|
||||
backgroundColor: initialState.value.backgroundColor,
|
||||
backgroundImage: initialState.value.backgroundImage
|
||||
}
|
||||
|
||||
nodeValue.properties['Camera Config'] = {
|
||||
cameraType: initialState.value.cameraType,
|
||||
fov: initialState.value.fov
|
||||
}
|
||||
|
||||
nodeValue.properties['Light Config'] = {
|
||||
intensity: initialState.value.lightIntensity
|
||||
}
|
||||
|
||||
nodeValue.properties['Model Config'] = {
|
||||
upDirection: initialState.value.upDirection,
|
||||
materialMode: initialState.value.materialMode
|
||||
}
|
||||
|
||||
const currentCameraConfig = nodeValue.properties['Camera Config'] as any
|
||||
nodeValue.properties['Camera Config'] = {
|
||||
...currentCameraConfig,
|
||||
state: initialState.value.cameraState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,20 +306,31 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
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
|
||||
nodeValue.properties['Scene Config'] = {
|
||||
showGrid: showGrid.value,
|
||||
backgroundColor: backgroundColor.value,
|
||||
backgroundImage: backgroundImage.value
|
||||
}
|
||||
|
||||
nodeValue.properties['Camera Config'] = {
|
||||
cameraType: cameraType.value,
|
||||
fov: fov.value,
|
||||
state: viewerCameraState
|
||||
}
|
||||
|
||||
nodeValue.properties['Light Config'] = {
|
||||
intensity: lightIntensity.value
|
||||
}
|
||||
|
||||
nodeValue.properties['Model Config'] = {
|
||||
upDirection: upDirection.value,
|
||||
materialMode: materialMode.value
|
||||
}
|
||||
}
|
||||
|
||||
await useLoad3dService().copyLoad3dState(load3d, sourceLoad3d)
|
||||
|
||||
if (backgroundImage.value) {
|
||||
await sourceLoad3d.setBackgroundImage(backgroundImage.value)
|
||||
}
|
||||
await sourceLoad3d.setBackgroundImage(backgroundImage.value)
|
||||
|
||||
sourceLoad3d.forceRender()
|
||||
|
||||
@@ -341,6 +371,49 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleModelDrop = async (file: File) => {
|
||||
if (!load3d) {
|
||||
useToastStore().addAlert(t('toastMessages.no3dScene'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const resourceFolder =
|
||||
(node.properties['Resource Folder'] as string) || ''
|
||||
const subfolder = resourceFolder.trim()
|
||||
? `3d/${resourceFolder.trim()}`
|
||||
: '3d'
|
||||
|
||||
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
|
||||
|
||||
if (!uploadedPath) {
|
||||
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
const modelUrl = api.apiURL(
|
||||
Load3dUtils.getResourceURL(
|
||||
...Load3dUtils.splitFilePath(uploadedPath),
|
||||
'input'
|
||||
)
|
||||
)
|
||||
|
||||
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'))
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
load3d?.remove()
|
||||
load3d = null
|
||||
@@ -358,8 +431,8 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
hasBackgroundImage,
|
||||
upDirection,
|
||||
materialMode,
|
||||
edgeThreshold,
|
||||
needApplyChanges,
|
||||
isPreview,
|
||||
|
||||
// Methods
|
||||
initializeViewer,
|
||||
@@ -371,6 +444,7 @@ export const useLoad3dViewer = (node: LGraphNode) => {
|
||||
applyChanges,
|
||||
refreshViewport,
|
||||
handleBackgroundImageUpdate,
|
||||
handleModelDrop,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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 { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
|
||||
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
@@ -21,6 +20,18 @@ import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { isLoad3dNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const inputSpecLoad3D: CustomInputSpec = {
|
||||
name: 'image',
|
||||
type: 'Load3D',
|
||||
isPreview: false
|
||||
}
|
||||
|
||||
const inputSpecPreview3D: CustomInputSpec = {
|
||||
name: 'image',
|
||||
type: 'Preview3D',
|
||||
isPreview: true
|
||||
}
|
||||
|
||||
async function handleModelUpload(files: FileList, node: any) {
|
||||
if (!files?.length) return
|
||||
|
||||
@@ -49,7 +60,13 @@ async function handleModelUpload(files: FileList, node: any) {
|
||||
)
|
||||
)
|
||||
|
||||
await useLoad3dService().getLoad3d(node)?.loadModel(modelUrl)
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
try {
|
||||
load3d.loadModel(modelUrl)
|
||||
} catch (error) {
|
||||
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
|
||||
}
|
||||
})
|
||||
|
||||
if (uploadPath && modelWidget) {
|
||||
if (!modelWidget.options?.values?.includes(uploadPath)) {
|
||||
@@ -106,16 +123,6 @@ useExtensionService().registerExtension({
|
||||
defaultValue: true,
|
||||
experimental: true
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Load3D.ShowPreview',
|
||||
category: ['3D', 'Scene', 'Initial Preview Visibility'],
|
||||
name: 'Initial Preview Visibility',
|
||||
tooltip:
|
||||
'Controls whether the preview screen is visible by default when a new 3D widget is created. This default can still be toggled individually for each widget after creation.',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
experimental: true
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Load3D.BackgroundColor',
|
||||
category: ['3D', 'Scene', 'Initial Background Color'],
|
||||
@@ -260,7 +267,9 @@ useExtensionService().registerExtension({
|
||||
)
|
||||
|
||||
node.addWidget('button', 'clear', 'clear', () => {
|
||||
useLoad3dService().getLoad3d(node)?.clearModel()
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
load3d.clearModel()
|
||||
})
|
||||
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
if (modelWidget) {
|
||||
@@ -268,21 +277,16 @@ useExtensionService().registerExtension({
|
||||
}
|
||||
})
|
||||
|
||||
const inputSpec: CustomInputSpec = {
|
||||
name: 'image',
|
||||
type: 'Load3D',
|
||||
isAnimation: false,
|
||||
isPreview: false
|
||||
}
|
||||
|
||||
const widget = new ComponentWidgetImpl({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
node: node,
|
||||
name: 'image',
|
||||
component: Load3D,
|
||||
inputSpec,
|
||||
inputSpec: inputSpecLoad3D,
|
||||
options: {}
|
||||
})
|
||||
|
||||
widget.type = 'load3D'
|
||||
|
||||
addWidget(node, widget)
|
||||
|
||||
return { widget }
|
||||
@@ -309,8 +313,9 @@ useExtensionService().registerExtension({
|
||||
|
||||
await nextTick()
|
||||
|
||||
useLoad3dService().waitForLoad3d(node, (load3d) => {
|
||||
let cameraState = node.properties['Camera Info']
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
const cameraConfig = node.properties['Camera Config'] as any
|
||||
const cameraState = cameraConfig?.state
|
||||
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
|
||||
@@ -320,159 +325,36 @@ useExtensionService().registerExtension({
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
|
||||
if (modelWidget && width && height && sceneWidget) {
|
||||
config.configure('input', modelWidget, cameraState, width, height)
|
||||
const settings = {
|
||||
loadFolder: 'input',
|
||||
modelWidget: modelWidget,
|
||||
cameraState: cameraState,
|
||||
width: width,
|
||||
height: height
|
||||
}
|
||||
config.configure(settings)
|
||||
|
||||
sceneWidget.serializeValue = async () => {
|
||||
node.properties['Camera Info'] = load3d.getCameraState()
|
||||
|
||||
load3d.stopRecording()
|
||||
|
||||
const {
|
||||
scene: imageData,
|
||||
mask: maskData,
|
||||
normal: normalData,
|
||||
lineart: lineartData
|
||||
} = await load3d.captureScene(
|
||||
width.value as number,
|
||||
height.value as number
|
||||
)
|
||||
|
||||
const [data, dataMask, dataNormal, dataLineart] = await Promise.all([
|
||||
Load3dUtils.uploadTempImage(imageData, 'scene'),
|
||||
Load3dUtils.uploadTempImage(maskData, 'scene_mask'),
|
||||
Load3dUtils.uploadTempImage(normalData, 'scene_normal'),
|
||||
Load3dUtils.uploadTempImage(lineartData, 'scene_lineart')
|
||||
])
|
||||
|
||||
load3d.handleResize()
|
||||
|
||||
const returnVal = {
|
||||
image: `threed/${data.name} [temp]`,
|
||||
mask: `threed/${dataMask.name} [temp]`,
|
||||
normal: `threed/${dataNormal.name} [temp]`,
|
||||
lineart: `threed/${dataLineart.name} [temp]`,
|
||||
camera_info: node.properties['Camera Info'],
|
||||
recording: ''
|
||||
const currentLoad3d = nodeToLoad3dMap.get(node)
|
||||
if (!currentLoad3d) {
|
||||
console.error('No load3d instance found for node')
|
||||
return null
|
||||
}
|
||||
|
||||
const recordingData = load3d.getRecordingData()
|
||||
|
||||
if (recordingData) {
|
||||
const [recording] = await Promise.all([
|
||||
Load3dUtils.uploadTempImage(recordingData, 'recording', 'mp4')
|
||||
])
|
||||
|
||||
returnVal['recording'] = `threed/${recording.name} [temp]`
|
||||
const cameraConfig = (node.properties['Camera Config'] as any) || {
|
||||
cameraType: currentLoad3d.getCurrentCameraType(),
|
||||
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
cameraConfig.state = currentLoad3d.getCameraState()
|
||||
node.properties['Camera Config'] = cameraConfig
|
||||
|
||||
return returnVal
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Load3DAnimation',
|
||||
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
LOAD_3D_ANIMATION(node) {
|
||||
const fileInput = createFileInput('.gltf,.glb,.fbx', false)
|
||||
|
||||
node.properties['Resource Folder'] = ''
|
||||
|
||||
fileInput.onchange = async () => {
|
||||
await handleModelUpload(fileInput.files!, node)
|
||||
}
|
||||
|
||||
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
|
||||
fileInput.click()
|
||||
})
|
||||
|
||||
const resourcesInput = createFileInput('*', true)
|
||||
|
||||
resourcesInput.onchange = async () => {
|
||||
await handleResourcesUpload(resourcesInput.files!, node)
|
||||
resourcesInput.value = ''
|
||||
}
|
||||
|
||||
node.addWidget(
|
||||
'button',
|
||||
'upload extra resources',
|
||||
'uploadExtraResources',
|
||||
() => {
|
||||
resourcesInput.click()
|
||||
}
|
||||
)
|
||||
|
||||
node.addWidget('button', 'clear', 'clear', () => {
|
||||
useLoad3dService().getLoad3d(node)?.clearModel()
|
||||
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
if (modelWidget) {
|
||||
modelWidget.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
const inputSpec: CustomInputSpec = {
|
||||
name: 'image',
|
||||
type: 'Load3DAnimation',
|
||||
isAnimation: true,
|
||||
isPreview: false
|
||||
}
|
||||
|
||||
const widget = new ComponentWidgetImpl({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: Load3DAnimation,
|
||||
inputSpec,
|
||||
options: {}
|
||||
})
|
||||
|
||||
addWidget(node, widget)
|
||||
|
||||
return { widget }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async nodeCreated(node) {
|
||||
if (node.constructor.comfyClass !== 'Load3DAnimation') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 700)])
|
||||
|
||||
await nextTick()
|
||||
|
||||
useLoad3dService().waitForLoad3d(node, (load3d) => {
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
let cameraState = node.properties['Camera Info']
|
||||
const width = node.widgets?.find((w) => w.name === 'width')
|
||||
const height = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
if (modelWidget && width && height && sceneWidget && load3d) {
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
|
||||
config.configure('input', modelWidget, cameraState, width, height)
|
||||
|
||||
sceneWidget.serializeValue = async () => {
|
||||
node.properties['Camera Info'] = load3d.getCameraState()
|
||||
|
||||
const load3dAnimation = load3d as Load3dAnimation
|
||||
load3dAnimation.toggleAnimation(false)
|
||||
|
||||
if (load3dAnimation.isRecording()) {
|
||||
load3dAnimation.stopRecording()
|
||||
}
|
||||
currentLoad3d.stopRecording()
|
||||
|
||||
const {
|
||||
scene: imageData,
|
||||
mask: maskData,
|
||||
normal: normalData
|
||||
} = await load3dAnimation.captureScene(
|
||||
} = await currentLoad3d.captureScene(
|
||||
width.value as number,
|
||||
height.value as number
|
||||
)
|
||||
@@ -483,17 +365,19 @@ useExtensionService().registerExtension({
|
||||
Load3dUtils.uploadTempImage(normalData, 'scene_normal')
|
||||
])
|
||||
|
||||
load3dAnimation.handleResize()
|
||||
currentLoad3d.handleResize()
|
||||
|
||||
const returnVal = {
|
||||
image: `threed/${data.name} [temp]`,
|
||||
mask: `threed/${dataMask.name} [temp]`,
|
||||
normal: `threed/${dataNormal.name} [temp]`,
|
||||
camera_info: node.properties['Camera Info'],
|
||||
camera_info:
|
||||
(node.properties['Camera Config'] as any)?.state || null,
|
||||
recording: ''
|
||||
}
|
||||
|
||||
const recordingData = load3dAnimation.getRecordingData()
|
||||
const recordingData = currentLoad3d.getRecordingData()
|
||||
|
||||
if (recordingData) {
|
||||
const [recording] = await Promise.all([
|
||||
Load3dUtils.uploadTempImage(recordingData, 'recording', 'mp4')
|
||||
@@ -531,21 +415,16 @@ useExtensionService().registerExtension({
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
PREVIEW_3D(node) {
|
||||
const inputSpec: CustomInputSpec = {
|
||||
name: 'image',
|
||||
type: 'Preview3D',
|
||||
isAnimation: false,
|
||||
isPreview: true
|
||||
}
|
||||
|
||||
const widget = new ComponentWidgetImpl({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
name: inputSpecPreview3D.name,
|
||||
component: Load3D,
|
||||
inputSpec,
|
||||
inputSpec: inputSpecPreview3D,
|
||||
options: {}
|
||||
})
|
||||
|
||||
widget.type = 'load3D'
|
||||
|
||||
addWidget(node, widget)
|
||||
|
||||
return { widget }
|
||||
@@ -564,7 +443,7 @@ useExtensionService().registerExtension({
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
useLoad3dService().waitForLoad3d(node, (load3d) => {
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
@@ -575,9 +454,16 @@ useExtensionService().registerExtension({
|
||||
if (lastTimeModelFile) {
|
||||
modelWidget.value = lastTimeModelFile
|
||||
|
||||
const cameraState = node.properties['Camera Info']
|
||||
const cameraConfig = node.properties['Camera Config'] as any
|
||||
const cameraState = cameraConfig?.state
|
||||
|
||||
config.configure('output', modelWidget, cameraState)
|
||||
const settings = {
|
||||
loadFolder: 'output',
|
||||
modelWidget: modelWidget,
|
||||
cameraState: cameraState
|
||||
}
|
||||
|
||||
config.configure(settings)
|
||||
}
|
||||
|
||||
node.onExecuted = function (message: any) {
|
||||
@@ -592,98 +478,24 @@ useExtensionService().registerExtension({
|
||||
}
|
||||
|
||||
let cameraState = message.result[1]
|
||||
let bgImagePath = message.result[2]
|
||||
|
||||
modelWidget.value = filePath.replaceAll('\\', '/')
|
||||
|
||||
node.properties['Last Time Model File'] = modelWidget.value
|
||||
|
||||
config.configure('output', modelWidget, cameraState)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Preview3DAnimation',
|
||||
|
||||
async beforeRegisterNodeDef(_nodeType, nodeData) {
|
||||
if ('Preview3DAnimation' === nodeData.name) {
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
nodeData.input.required.image = ['PREVIEW_3D_ANIMATION']
|
||||
}
|
||||
},
|
||||
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
PREVIEW_3D_ANIMATION(node) {
|
||||
const inputSpec: CustomInputSpec = {
|
||||
name: 'image',
|
||||
type: 'Preview3DAnimation',
|
||||
isAnimation: true,
|
||||
isPreview: true
|
||||
}
|
||||
|
||||
const widget = new ComponentWidgetImpl({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: Load3DAnimation,
|
||||
inputSpec,
|
||||
options: {}
|
||||
})
|
||||
|
||||
addWidget(node, widget)
|
||||
|
||||
return { widget }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async nodeCreated(node) {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAnimation') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 550)])
|
||||
|
||||
await nextTick()
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
useLoad3dService().waitForLoad3d(node, (load3d) => {
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
|
||||
if (modelWidget) {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
|
||||
if (lastTimeModelFile) {
|
||||
modelWidget.value = lastTimeModelFile
|
||||
|
||||
const cameraState = node.properties['Camera Info']
|
||||
|
||||
config.configure('output', modelWidget, cameraState)
|
||||
}
|
||||
|
||||
node.onExecuted = function (message: any) {
|
||||
onExecuted?.apply(this, arguments as any)
|
||||
|
||||
let filePath = message.result[0]
|
||||
|
||||
if (!filePath) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
const settings = {
|
||||
loadFolder: 'output',
|
||||
modelWidget: modelWidget,
|
||||
cameraState: cameraState,
|
||||
bgImagePath: bgImagePath
|
||||
}
|
||||
|
||||
let cameraState = message.result[1]
|
||||
config.configure(settings)
|
||||
|
||||
modelWidget.value = filePath.replaceAll('\\', '/')
|
||||
|
||||
node.properties['Last Time Model File'] = modelWidget.value
|
||||
|
||||
config.configure('output', modelWidget, cameraState)
|
||||
if (bgImagePath) {
|
||||
load3d.setBackgroundImage(bgImagePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,14 +15,9 @@ export class AnimationManager implements AnimationManagerInterface {
|
||||
animationSpeed: number = 1.0
|
||||
|
||||
private eventManager: EventManagerInterface
|
||||
private getCurrentModel: () => THREE.Object3D | null
|
||||
|
||||
constructor(
|
||||
eventManager: EventManagerInterface,
|
||||
getCurrentModel: () => THREE.Object3D | null
|
||||
) {
|
||||
constructor(eventManager: EventManagerInterface) {
|
||||
this.eventManager = eventManager
|
||||
this.getCurrentModel = getCurrentModel
|
||||
}
|
||||
|
||||
init(): void {}
|
||||
@@ -52,23 +47,24 @@ export class AnimationManager implements AnimationManagerInterface {
|
||||
let animations: THREE.AnimationClip[] = []
|
||||
if (model.animations?.length > 0) {
|
||||
animations = model.animations
|
||||
} else if (originalModel && 'animations' in originalModel) {
|
||||
} else if (
|
||||
originalModel &&
|
||||
'animations' in originalModel &&
|
||||
Array.isArray(originalModel.animations)
|
||||
) {
|
||||
animations = originalModel.animations
|
||||
}
|
||||
|
||||
if (animations.length > 0) {
|
||||
this.animationClips = animations
|
||||
if (model.type === 'Scene') {
|
||||
this.currentAnimation = new THREE.AnimationMixer(model)
|
||||
} else {
|
||||
this.currentAnimation = new THREE.AnimationMixer(
|
||||
this.getCurrentModel()!
|
||||
)
|
||||
}
|
||||
|
||||
this.currentAnimation = new THREE.AnimationMixer(model)
|
||||
|
||||
if (this.animationClips.length > 0) {
|
||||
this.updateSelectedAnimation(0)
|
||||
}
|
||||
} else {
|
||||
this.animationClips = []
|
||||
}
|
||||
|
||||
this.updateAnimationList()
|
||||
|
||||
@@ -82,7 +82,17 @@ export class CameraManager implements CameraManagerInterface {
|
||||
|
||||
if (this.controls) {
|
||||
this.controls.addEventListener('end', () => {
|
||||
this.nodeStorage.storeNodeProperty('Camera Info', this.getCameraState())
|
||||
const cameraState = this.getCameraState()
|
||||
|
||||
const cameraConfig = this.nodeStorage.loadNodeProperty(
|
||||
'Camera Config',
|
||||
{
|
||||
cameraType: this.getCurrentCameraType(),
|
||||
fov: this.perspectiveCamera.fov
|
||||
}
|
||||
)
|
||||
cameraConfig.state = cameraState
|
||||
this.nodeStorage.storeNodeProperty('Camera Config', cameraConfig)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,14 @@ export class ControlsManager implements ControlsManagerInterface {
|
||||
this.nodeStorage = nodeStorage
|
||||
this.camera = camera
|
||||
|
||||
this.controls = new OrbitControls(camera, renderer.domElement)
|
||||
const container = renderer.domElement.parentElement || renderer.domElement
|
||||
this.controls = new OrbitControls(camera, container)
|
||||
this.controls.enableDamping = true
|
||||
}
|
||||
|
||||
init(): void {
|
||||
this.controls.addEventListener('end', () => {
|
||||
this.nodeStorage.storeNodeProperty('Camera Info', {
|
||||
const cameraState = {
|
||||
position: this.camera.position.clone(),
|
||||
target: this.controls.target.clone(),
|
||||
zoom:
|
||||
@@ -41,7 +42,17 @@ export class ControlsManager implements ControlsManagerInterface {
|
||||
this.camera instanceof THREE.PerspectiveCamera
|
||||
? 'perspective'
|
||||
: 'orthographic'
|
||||
}
|
||||
|
||||
const cameraConfig = this.nodeStorage.loadNodeProperty('Camera Config', {
|
||||
cameraType: cameraState.cameraType,
|
||||
fov:
|
||||
this.camera instanceof THREE.PerspectiveCamera
|
||||
? (this.camera as THREE.PerspectiveCamera).fov
|
||||
: 75
|
||||
})
|
||||
cameraConfig.state = cameraState
|
||||
this.nodeStorage.storeNodeProperty('Camera Config', cameraConfig)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
|
||||
export class LightingManager implements LightingManagerInterface {
|
||||
lights: THREE.Light[] = []
|
||||
currentIntensity: number = 3
|
||||
private scene: THREE.Scene
|
||||
private eventManager: EventManagerInterface
|
||||
|
||||
@@ -58,6 +59,7 @@ export class LightingManager implements LightingManagerInterface {
|
||||
}
|
||||
|
||||
setLightIntensity(intensity: number): void {
|
||||
this.currentIntensity = intensity
|
||||
this.lights.forEach((light) => {
|
||||
if (light instanceof THREE.DirectionalLight) {
|
||||
if (light === this.lights[1]) {
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type {
|
||||
CameraConfig,
|
||||
LightConfig,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
type Load3DConfigurationSettings = {
|
||||
loadFolder: string
|
||||
modelWidget: IBaseWidget
|
||||
cameraState?: any
|
||||
width?: IBaseWidget
|
||||
height?: IBaseWidget
|
||||
bgImagePath?: string
|
||||
}
|
||||
|
||||
class Load3DConfiguration {
|
||||
constructor(private load3d: Load3d) {}
|
||||
|
||||
@@ -12,22 +27,17 @@ class Load3DConfiguration {
|
||||
this.setupDefaultProperties()
|
||||
}
|
||||
|
||||
configure(
|
||||
loadFolder: 'input' | 'output',
|
||||
modelWidget: IBaseWidget,
|
||||
cameraState?: any,
|
||||
width: IBaseWidget | null = null,
|
||||
height: IBaseWidget | null = null
|
||||
) {
|
||||
this.setupModelHandling(modelWidget, loadFolder, cameraState)
|
||||
this.setupTargetSize(width, height)
|
||||
this.setupDefaultProperties()
|
||||
configure(setting: Load3DConfigurationSettings) {
|
||||
this.setupModelHandling(
|
||||
setting.modelWidget,
|
||||
setting.loadFolder,
|
||||
setting.cameraState
|
||||
)
|
||||
this.setupTargetSize(setting.width, setting.height)
|
||||
this.setupDefaultProperties(setting.bgImagePath)
|
||||
}
|
||||
|
||||
private setupTargetSize(
|
||||
width: IBaseWidget | null,
|
||||
height: IBaseWidget | null
|
||||
) {
|
||||
private setupTargetSize(width?: IBaseWidget, height?: IBaseWidget) {
|
||||
if (width && height) {
|
||||
this.load3d.setTargetSize(width.value as number, height.value as number)
|
||||
|
||||
@@ -41,10 +51,7 @@ class Load3DConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
private setupModelHandlingForSaveMesh(
|
||||
filePath: string,
|
||||
loadFolder: 'input' | 'output'
|
||||
) {
|
||||
private setupModelHandlingForSaveMesh(filePath: string, loadFolder: string) {
|
||||
const onModelWidgetUpdate = this.createModelUpdateHandler(loadFolder)
|
||||
|
||||
if (filePath) {
|
||||
@@ -54,7 +61,7 @@ class Load3DConfiguration {
|
||||
|
||||
private setupModelHandling(
|
||||
modelWidget: IBaseWidget,
|
||||
loadFolder: 'input' | 'output',
|
||||
loadFolder: string,
|
||||
cameraState?: any
|
||||
) {
|
||||
const onModelWidgetUpdate = this.createModelUpdateHandler(
|
||||
@@ -65,63 +72,119 @@ class Load3DConfiguration {
|
||||
onModelWidgetUpdate(modelWidget.value)
|
||||
}
|
||||
|
||||
modelWidget.callback = (value: string | number | boolean | object) => {
|
||||
this.load3d.node.properties['Texture'] = undefined
|
||||
const originalCallback = modelWidget.callback
|
||||
|
||||
let currentValue = modelWidget.value
|
||||
Object.defineProperty(modelWidget, 'value', {
|
||||
get() {
|
||||
return currentValue
|
||||
},
|
||||
set(newValue) {
|
||||
currentValue = newValue
|
||||
if (modelWidget.callback && newValue !== undefined && newValue !== '') {
|
||||
modelWidget.callback(newValue)
|
||||
}
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
})
|
||||
|
||||
modelWidget.callback = (value: string | number | boolean | object) => {
|
||||
onModelWidgetUpdate(value)
|
||||
|
||||
if (originalCallback) {
|
||||
originalCallback(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setupDefaultProperties() {
|
||||
const cameraType = this.load3d.loadNodeProperty(
|
||||
'Camera Type',
|
||||
useSettingStore().get('Comfy.Load3D.CameraType')
|
||||
)
|
||||
this.load3d.toggleCamera(cameraType)
|
||||
private setupDefaultProperties(bgImagePath?: string) {
|
||||
const sceneConfig = this.loadSceneConfig()
|
||||
this.applySceneConfig(sceneConfig, bgImagePath)
|
||||
|
||||
const showGrid = this.load3d.loadNodeProperty(
|
||||
'Show Grid',
|
||||
useSettingStore().get('Comfy.Load3D.ShowGrid')
|
||||
)
|
||||
const cameraConfig = this.loadCameraConfig()
|
||||
this.applyCameraConfig(cameraConfig)
|
||||
|
||||
this.load3d.toggleGrid(showGrid)
|
||||
|
||||
const showPreview = this.load3d.loadNodeProperty(
|
||||
'Show Preview',
|
||||
useSettingStore().get('Comfy.Load3D.ShowPreview')
|
||||
)
|
||||
|
||||
this.load3d.togglePreview(showPreview)
|
||||
|
||||
const bgColor = this.load3d.loadNodeProperty(
|
||||
'Background Color',
|
||||
'#' + useSettingStore().get('Comfy.Load3D.BackgroundColor')
|
||||
)
|
||||
|
||||
this.load3d.setBackgroundColor(bgColor)
|
||||
|
||||
const lightIntensity: number = Number(
|
||||
this.load3d.loadNodeProperty(
|
||||
'Light Intensity',
|
||||
useSettingStore().get('Comfy.Load3D.LightIntensity')
|
||||
)
|
||||
)
|
||||
|
||||
this.load3d.setLightIntensity(lightIntensity)
|
||||
|
||||
const fov: number = Number(this.load3d.loadNodeProperty('FOV', 35))
|
||||
|
||||
this.load3d.setFOV(fov)
|
||||
|
||||
const backgroundImage = this.load3d.loadNodeProperty('Background Image', '')
|
||||
|
||||
this.load3d.setBackgroundImage(backgroundImage)
|
||||
const lightConfig = this.loadLightConfig()
|
||||
this.applyLightConfig(lightConfig)
|
||||
}
|
||||
|
||||
private createModelUpdateHandler(
|
||||
loadFolder: 'input' | 'output',
|
||||
cameraState?: any
|
||||
) {
|
||||
private loadSceneConfig(): SceneConfig {
|
||||
const defaultConfig: SceneConfig = {
|
||||
showGrid: useSettingStore().get('Comfy.Load3D.ShowGrid'),
|
||||
backgroundColor:
|
||||
'#' + useSettingStore().get('Comfy.Load3D.BackgroundColor'),
|
||||
backgroundImage: ''
|
||||
}
|
||||
|
||||
const config = this.load3d.loadNodeProperty('Scene Config', defaultConfig)
|
||||
this.load3d.node.properties['Scene Config'] = config
|
||||
return config
|
||||
}
|
||||
|
||||
private loadCameraConfig(): CameraConfig {
|
||||
const defaultConfig: CameraConfig = {
|
||||
cameraType: useSettingStore().get('Comfy.Load3D.CameraType'),
|
||||
fov: 35
|
||||
}
|
||||
|
||||
const config = this.load3d.loadNodeProperty('Camera Config', defaultConfig)
|
||||
this.load3d.node.properties['Camera Config'] = config
|
||||
return config
|
||||
}
|
||||
|
||||
private loadLightConfig(): LightConfig {
|
||||
const defaultConfig: LightConfig = {
|
||||
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity')
|
||||
}
|
||||
|
||||
const config = this.load3d.loadNodeProperty('Light Config', defaultConfig)
|
||||
this.load3d.node.properties['Light Config'] = config
|
||||
return config
|
||||
}
|
||||
|
||||
private loadModelConfig(): ModelConfig {
|
||||
const defaultConfig: ModelConfig = {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
}
|
||||
|
||||
const config = this.load3d.loadNodeProperty('Model Config', defaultConfig)
|
||||
this.load3d.node.properties['Model Config'] = config
|
||||
return config
|
||||
}
|
||||
|
||||
private applySceneConfig(config: SceneConfig, bgImagePath?: string) {
|
||||
this.load3d.toggleGrid(config.showGrid)
|
||||
this.load3d.setBackgroundColor(config.backgroundColor)
|
||||
if (config.backgroundImage) {
|
||||
if (bgImagePath && bgImagePath != config.backgroundImage) {
|
||||
return
|
||||
}
|
||||
|
||||
this.load3d.setBackgroundImage(config.backgroundImage)
|
||||
}
|
||||
}
|
||||
|
||||
private applyCameraConfig(config: CameraConfig) {
|
||||
this.load3d.toggleCamera(config.cameraType)
|
||||
this.load3d.setFOV(config.fov)
|
||||
|
||||
if (config.state) {
|
||||
this.load3d.setCameraState(config.state)
|
||||
}
|
||||
}
|
||||
|
||||
private applyLightConfig(config: LightConfig) {
|
||||
this.load3d.setLightIntensity(config.intensity)
|
||||
}
|
||||
|
||||
private applyModelConfig(config: ModelConfig) {
|
||||
this.load3d.setUpDirection(config.upDirection)
|
||||
this.load3d.setMaterialMode(config.materialMode)
|
||||
}
|
||||
|
||||
private createModelUpdateHandler(loadFolder: string, cameraState?: any) {
|
||||
let isFirstLoad = true
|
||||
return async (value: string | number | boolean | object) => {
|
||||
if (!value) return
|
||||
@@ -139,25 +202,8 @@ class Load3DConfiguration {
|
||||
|
||||
await this.load3d.loadModel(modelUrl, filename)
|
||||
|
||||
const upDirection = this.load3d.loadNodeProperty(
|
||||
'Up Direction',
|
||||
'original'
|
||||
)
|
||||
|
||||
this.load3d.setUpDirection(upDirection)
|
||||
|
||||
const materialMode = this.load3d.loadNodeProperty(
|
||||
'Material Mode',
|
||||
'original'
|
||||
)
|
||||
|
||||
this.load3d.setMaterialMode(materialMode)
|
||||
|
||||
const edgeThreshold: number = Number(
|
||||
this.load3d.loadNodeProperty('Edge Threshold', 85)
|
||||
)
|
||||
|
||||
this.load3d.setEdgeThreshold(edgeThreshold)
|
||||
const modelConfig = this.loadModelConfig()
|
||||
this.applyModelConfig(modelConfig)
|
||||
|
||||
if (isFirstLoad && cameraState && typeof cameraState === 'object') {
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
import { AnimationManager } from './AnimationManager'
|
||||
import { CameraManager } from './CameraManager'
|
||||
import { ControlsManager } from './ControlsManager'
|
||||
import { EventManager } from './EventManager'
|
||||
@@ -10,7 +10,6 @@ import { LightingManager } from './LightingManager'
|
||||
import { LoaderManager } from './LoaderManager'
|
||||
import { ModelExporter } from './ModelExporter'
|
||||
import { NodeStorage } from './NodeStorage'
|
||||
import { PreviewManager } from './PreviewManager'
|
||||
import { RecordingManager } from './RecordingManager'
|
||||
import { SceneManager } from './SceneManager'
|
||||
import { SceneModelManager } from './SceneModelManager'
|
||||
@@ -29,6 +28,7 @@ class Load3d {
|
||||
protected clock: THREE.Clock
|
||||
protected animationFrameId: number | null = null
|
||||
node: LGraphNode
|
||||
private loadingPromise: Promise<void> | null = null
|
||||
|
||||
eventManager: EventManager
|
||||
nodeStorage: NodeStorage
|
||||
@@ -37,10 +37,10 @@ class Load3d {
|
||||
controlsManager: ControlsManager
|
||||
lightingManager: LightingManager
|
||||
viewHelperManager: ViewHelperManager
|
||||
previewManager: PreviewManager
|
||||
loaderManager: LoaderManager
|
||||
modelManager: SceneModelManager
|
||||
recordingManager: RecordingManager
|
||||
animationManager: AnimationManager
|
||||
|
||||
STATUS_MOUSE_ON_NODE: boolean
|
||||
STATUS_MOUSE_ON_SCENE: boolean
|
||||
@@ -62,8 +62,7 @@ class Load3d {
|
||||
constructor(
|
||||
container: Element | HTMLElement,
|
||||
options: Load3DOptions = {
|
||||
node: {} as LGraphNode,
|
||||
inputSpec: {} as CustomInputSpec
|
||||
node: {} as LGraphNode
|
||||
}
|
||||
) {
|
||||
this.node = options.node || ({} as LGraphNode)
|
||||
@@ -124,27 +123,12 @@ class Load3d {
|
||||
this.nodeStorage
|
||||
)
|
||||
|
||||
this.previewManager = new PreviewManager(
|
||||
this.sceneManager.scene,
|
||||
this.getActiveCamera.bind(this),
|
||||
this.getControls.bind(this),
|
||||
() => this.renderer,
|
||||
this.eventManager,
|
||||
this.sceneManager.backgroundScene,
|
||||
this.sceneManager.backgroundCamera
|
||||
)
|
||||
|
||||
if (options.disablePreview) {
|
||||
this.previewManager.togglePreview(false)
|
||||
}
|
||||
|
||||
this.modelManager = new SceneModelManager(
|
||||
this.sceneManager.scene,
|
||||
this.renderer,
|
||||
this.eventManager,
|
||||
this.getActiveCamera.bind(this),
|
||||
this.setupCamera.bind(this),
|
||||
options
|
||||
this.setupCamera.bind(this)
|
||||
)
|
||||
|
||||
this.loaderManager = new LoaderManager(this.modelManager, this.eventManager)
|
||||
@@ -154,21 +138,18 @@ class Load3d {
|
||||
this.renderer,
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.animationManager = new AnimationManager(this.eventManager)
|
||||
this.sceneManager.init()
|
||||
this.cameraManager.init()
|
||||
this.controlsManager.init()
|
||||
this.lightingManager.init()
|
||||
this.loaderManager.init()
|
||||
this.loaderManager.init()
|
||||
this.animationManager.init()
|
||||
|
||||
this.viewHelperManager.createViewHelper(container)
|
||||
this.viewHelperManager.init()
|
||||
|
||||
if (options && !options.inputSpec?.isPreview) {
|
||||
this.previewManager.createCapturePreview(container)
|
||||
this.previewManager.init()
|
||||
}
|
||||
|
||||
this.STATUS_MOUSE_ON_NODE = false
|
||||
this.STATUS_MOUSE_ON_SCENE = false
|
||||
this.STATUS_MOUSE_ON_VIEWER = false
|
||||
@@ -253,9 +234,6 @@ class Load3d {
|
||||
return this.eventManager
|
||||
}
|
||||
|
||||
getNodeStorage(): NodeStorage {
|
||||
return this.nodeStorage
|
||||
}
|
||||
getSceneManager(): SceneManager {
|
||||
return this.sceneManager
|
||||
}
|
||||
@@ -271,9 +249,6 @@ class Load3d {
|
||||
getViewHelperManager(): ViewHelperManager {
|
||||
return this.viewHelperManager
|
||||
}
|
||||
getPreviewManager(): PreviewManager {
|
||||
return this.previewManager
|
||||
}
|
||||
getLoaderManager(): LoaderManager {
|
||||
return this.loaderManager
|
||||
}
|
||||
@@ -286,15 +261,12 @@ class Load3d {
|
||||
|
||||
forceRender(): void {
|
||||
const delta = this.clock.getDelta()
|
||||
this.animationManager.update(delta)
|
||||
this.viewHelperManager.update(delta)
|
||||
this.controlsManager.update()
|
||||
|
||||
this.renderMainScene()
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.renderPreview()
|
||||
}
|
||||
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
@@ -308,7 +280,18 @@ class Load3d {
|
||||
const containerWidth = this.renderer.domElement.clientWidth
|
||||
const containerHeight = this.renderer.domElement.clientHeight
|
||||
|
||||
if (this.isViewerMode) {
|
||||
const widthWidget = this.node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = this.node.widgets?.find((w) => w.name === 'height')
|
||||
const shouldMaintainAspectRatio =
|
||||
(widthWidget && heightWidget) || this.isViewerMode
|
||||
|
||||
if (shouldMaintainAspectRatio) {
|
||||
if (widthWidget && heightWidget) {
|
||||
this.targetWidth = widthWidget.value as number
|
||||
this.targetHeight = heightWidget.value as number
|
||||
this.targetAspectRatio = this.targetWidth / this.targetHeight
|
||||
}
|
||||
|
||||
const containerAspectRatio = containerWidth / containerHeight
|
||||
|
||||
let renderWidth: number
|
||||
@@ -338,6 +321,7 @@ class Load3d {
|
||||
const renderAspectRatio = renderWidth / renderHeight
|
||||
this.cameraManager.updateAspectRatio(renderAspectRatio)
|
||||
} else {
|
||||
// Preview3D: fill the entire container
|
||||
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissorTest(true)
|
||||
@@ -380,15 +364,12 @@ class Load3d {
|
||||
}
|
||||
|
||||
const delta = this.clock.getDelta()
|
||||
this.animationManager.update(delta)
|
||||
this.viewHelperManager.update(delta)
|
||||
this.controlsManager.update()
|
||||
|
||||
this.renderMainScene()
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.renderPreview()
|
||||
}
|
||||
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
@@ -465,44 +446,54 @@ class Load3d {
|
||||
setBackgroundColor(color: string): void {
|
||||
this.sceneManager.setBackgroundColor(color)
|
||||
|
||||
this.previewManager.setPreviewBackgroundColor(color)
|
||||
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
async setBackgroundImage(uploadPath: string): Promise<void> {
|
||||
await this.sceneManager.setBackgroundImage(uploadPath)
|
||||
|
||||
this.previewManager.updateBackgroundTexture(
|
||||
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
|
||||
// Calculate the actual render area based on target aspect ratio
|
||||
const widthWidget = this.node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = this.node.widgets?.find((w) => w.name === 'height')
|
||||
const shouldMaintainAspectRatio =
|
||||
(widthWidget && heightWidget) || this.isViewerMode
|
||||
|
||||
if (containerAspectRatio > this.targetAspectRatio) {
|
||||
renderHeight = containerHeight
|
||||
renderWidth = renderHeight * this.targetAspectRatio
|
||||
if (shouldMaintainAspectRatio) {
|
||||
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
|
||||
)
|
||||
} else {
|
||||
renderWidth = containerWidth
|
||||
renderHeight = renderWidth / this.targetAspectRatio
|
||||
// For Preview3D mode without aspect ratio constraints
|
||||
this.sceneManager.updateBackgroundSize(
|
||||
this.sceneManager.backgroundTexture,
|
||||
this.sceneManager.backgroundMesh,
|
||||
containerWidth,
|
||||
containerHeight
|
||||
)
|
||||
}
|
||||
|
||||
this.sceneManager.updateBackgroundSize(
|
||||
this.sceneManager.backgroundTexture,
|
||||
this.sceneManager.backgroundMesh,
|
||||
renderWidth,
|
||||
renderHeight
|
||||
)
|
||||
}
|
||||
|
||||
this.forceRender()
|
||||
@@ -511,10 +502,6 @@ class Load3d {
|
||||
removeBackgroundImage(): void {
|
||||
this.sceneManager.removeBackgroundImage()
|
||||
|
||||
this.previewManager.setPreviewBackgroundColor(
|
||||
this.sceneManager.currentBackgroundColor
|
||||
)
|
||||
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
@@ -556,28 +543,49 @@ class Load3d {
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setEdgeThreshold(threshold: number): void {
|
||||
this.modelManager.setEdgeThreshold(threshold)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setMaterialMode(mode: MaterialMode): void {
|
||||
this.modelManager.setMaterialMode(mode)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
async loadModel(url: string, originalFileName?: string): Promise<void> {
|
||||
if (this.loadingPromise) {
|
||||
try {
|
||||
await this.loadingPromise
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
this.loadingPromise = this._loadModelInternal(url, originalFileName)
|
||||
return this.loadingPromise
|
||||
}
|
||||
|
||||
private async _loadModelInternal(
|
||||
url: string,
|
||||
originalFileName?: string
|
||||
): Promise<void> {
|
||||
this.cameraManager.reset()
|
||||
this.controlsManager.reset()
|
||||
this.modelManager.reset()
|
||||
this.modelManager.clearModel()
|
||||
this.animationManager.dispose()
|
||||
|
||||
await this.loaderManager.loadModel(url, originalFileName)
|
||||
|
||||
// Auto-detect and setup animations if present
|
||||
if (this.modelManager.currentModel) {
|
||||
this.animationManager.setupModelAnimations(
|
||||
this.modelManager.currentModel,
|
||||
this.modelManager.originalModel
|
||||
)
|
||||
}
|
||||
|
||||
this.handleResize()
|
||||
this.forceRender()
|
||||
|
||||
this.loadingPromise = null
|
||||
}
|
||||
|
||||
clearModel(): void {
|
||||
this.animationManager.dispose()
|
||||
this.modelManager.clearModel()
|
||||
this.forceRender()
|
||||
}
|
||||
@@ -592,16 +600,10 @@ class Load3d {
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
togglePreview(showPreview: boolean): void {
|
||||
this.previewManager.togglePreview(showPreview)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setTargetSize(width: number, height: number): void {
|
||||
this.targetWidth = width
|
||||
this.targetHeight = height
|
||||
this.targetAspectRatio = width / height
|
||||
this.previewManager.setTargetSize(width, height)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
@@ -619,7 +621,7 @@ class Load3d {
|
||||
}
|
||||
|
||||
handleResize(): void {
|
||||
const parentElement = this.renderer?.domElement?.parentElement
|
||||
const parentElement = this.renderer?.domElement
|
||||
|
||||
if (!parentElement) {
|
||||
console.warn('Parent element not found')
|
||||
@@ -629,7 +631,20 @@ class Load3d {
|
||||
const containerWidth = parentElement.clientWidth
|
||||
const containerHeight = parentElement.clientHeight
|
||||
|
||||
if (this.isViewerMode) {
|
||||
// Check if we have width/height widgets (Load3D nodes) or if it's viewer mode
|
||||
const widthWidget = this.node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = this.node.widgets?.find((w) => w.name === 'height')
|
||||
const shouldMaintainAspectRatio =
|
||||
(widthWidget && heightWidget) || this.isViewerMode
|
||||
|
||||
if (shouldMaintainAspectRatio) {
|
||||
// Load3D or viewer mode: maintain aspect ratio
|
||||
if (widthWidget && heightWidget) {
|
||||
this.targetWidth = widthWidget.value as number
|
||||
this.targetHeight = heightWidget.value as number
|
||||
this.targetAspectRatio = this.targetWidth / this.targetHeight
|
||||
}
|
||||
|
||||
const containerAspectRatio = containerWidth / containerHeight
|
||||
let renderWidth: number
|
||||
let renderHeight: number
|
||||
@@ -642,16 +657,16 @@ class Load3d {
|
||||
renderHeight = renderWidth / this.targetAspectRatio
|
||||
}
|
||||
|
||||
this.renderer.setSize(containerWidth, containerHeight)
|
||||
this.cameraManager.handleResize(renderWidth, renderHeight)
|
||||
this.sceneManager.handleResize(renderWidth, renderHeight)
|
||||
} else {
|
||||
// Preview3D: use container dimensions directly
|
||||
this.renderer.setSize(containerWidth, containerHeight)
|
||||
this.cameraManager.handleResize(containerWidth, containerHeight)
|
||||
this.sceneManager.handleResize(containerWidth, containerHeight)
|
||||
}
|
||||
|
||||
this.renderer.setSize(containerWidth, containerHeight)
|
||||
|
||||
this.previewManager.handleResize()
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
@@ -666,7 +681,10 @@ class Load3d {
|
||||
public async startRecording(): Promise<void> {
|
||||
this.viewHelperManager.visibleViewHelper(false)
|
||||
|
||||
return this.recordingManager.startRecording()
|
||||
return this.recordingManager.startRecording(
|
||||
this.targetWidth,
|
||||
this.targetHeight
|
||||
)
|
||||
}
|
||||
|
||||
public stopRecording(): void {
|
||||
@@ -697,6 +715,23 @@ class Load3d {
|
||||
this.recordingManager.clearRecording()
|
||||
}
|
||||
|
||||
// Animation methods
|
||||
public setAnimationSpeed(speed: number): void {
|
||||
this.animationManager.setAnimationSpeed(speed)
|
||||
}
|
||||
|
||||
public updateSelectedAnimation(index: number): void {
|
||||
this.animationManager.updateSelectedAnimation(index)
|
||||
}
|
||||
|
||||
public toggleAnimation(play?: boolean): void {
|
||||
this.animationManager.toggleAnimation(play)
|
||||
}
|
||||
|
||||
public hasAnimations(): boolean {
|
||||
return this.animationManager.animationClips.length > 0
|
||||
}
|
||||
|
||||
public remove(): void {
|
||||
if (this.contextMenuAbortController) {
|
||||
this.contextMenuAbortController.abort()
|
||||
@@ -720,10 +755,10 @@ class Load3d {
|
||||
this.controlsManager.dispose()
|
||||
this.lightingManager.dispose()
|
||||
this.viewHelperManager.dispose()
|
||||
this.previewManager.dispose()
|
||||
this.loaderManager.dispose()
|
||||
this.modelManager.dispose()
|
||||
this.recordingManager.dispose()
|
||||
this.animationManager.dispose()
|
||||
|
||||
this.renderer.dispose()
|
||||
this.renderer.domElement.remove()
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { AnimationManager } from './AnimationManager'
|
||||
import Load3d from './Load3d'
|
||||
import { type Load3DOptions } from './interfaces'
|
||||
|
||||
class Load3dAnimation extends Load3d {
|
||||
private animationManager: AnimationManager
|
||||
|
||||
constructor(
|
||||
container: Element | HTMLElement,
|
||||
options: Load3DOptions = {
|
||||
node: {} as LGraphNode
|
||||
}
|
||||
) {
|
||||
super(container, options)
|
||||
|
||||
this.animationManager = new AnimationManager(
|
||||
this.eventManager,
|
||||
this.getCurrentModel.bind(this)
|
||||
)
|
||||
|
||||
this.animationManager.init()
|
||||
|
||||
this.overrideAnimationLoop()
|
||||
}
|
||||
|
||||
private overrideAnimationLoop(): void {
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId)
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
this.animationFrameId = requestAnimationFrame(animate)
|
||||
|
||||
if (!this.isActive()) {
|
||||
return
|
||||
}
|
||||
|
||||
const delta = this.clock.getDelta()
|
||||
|
||||
this.animationManager.update(delta)
|
||||
|
||||
this.viewHelperManager.update(delta)
|
||||
|
||||
this.controlsManager.update()
|
||||
|
||||
this.renderMainScene()
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.renderPreview()
|
||||
}
|
||||
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
}
|
||||
}
|
||||
|
||||
animate()
|
||||
}
|
||||
|
||||
override async loadModel(
|
||||
url: string,
|
||||
originalFileName?: string
|
||||
): Promise<void> {
|
||||
await super.loadModel(url, originalFileName)
|
||||
|
||||
if (this.modelManager.currentModel) {
|
||||
this.animationManager.setupModelAnimations(
|
||||
this.modelManager.currentModel,
|
||||
this.modelManager.originalModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override clearModel(): void {
|
||||
this.animationManager.dispose()
|
||||
super.clearModel()
|
||||
}
|
||||
|
||||
updateAnimationList(): void {
|
||||
this.animationManager.updateAnimationList()
|
||||
}
|
||||
|
||||
setAnimationSpeed(speed: number): void {
|
||||
this.animationManager.setAnimationSpeed(speed)
|
||||
}
|
||||
|
||||
updateSelectedAnimation(index: number): void {
|
||||
this.animationManager.updateSelectedAnimation(index)
|
||||
}
|
||||
|
||||
toggleAnimation(play?: boolean): void {
|
||||
this.animationManager.toggleAnimation(play)
|
||||
}
|
||||
|
||||
get isAnimationPlaying(): boolean {
|
||||
return this.animationManager.isAnimationPlaying
|
||||
}
|
||||
|
||||
get animationSpeed(): number {
|
||||
return this.animationManager.animationSpeed
|
||||
}
|
||||
|
||||
get selectedAnimationIndex(): number {
|
||||
return this.animationManager.selectedAnimationIndex
|
||||
}
|
||||
|
||||
get animationClips(): THREE.AnimationClip[] {
|
||||
return this.animationManager.animationClips
|
||||
}
|
||||
|
||||
get animationActions(): THREE.AnimationAction[] {
|
||||
return this.animationManager.animationActions
|
||||
}
|
||||
|
||||
get currentAnimation(): THREE.AnimationMixer | null {
|
||||
return this.animationManager.currentAnimation
|
||||
}
|
||||
|
||||
override remove(): void {
|
||||
this.animationManager.dispose()
|
||||
super.remove()
|
||||
}
|
||||
}
|
||||
|
||||
export default Load3dAnimation
|
||||
@@ -1,416 +0,0 @@
|
||||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
|
||||
import {
|
||||
type EventManagerInterface,
|
||||
type PreviewManagerInterface
|
||||
} from './interfaces'
|
||||
|
||||
export class PreviewManager implements PreviewManagerInterface {
|
||||
previewCamera: THREE.Camera
|
||||
previewContainer: HTMLDivElement = null!
|
||||
showPreview: boolean = true
|
||||
previewWidth: number = 120
|
||||
|
||||
private targetWidth: number = 1024
|
||||
private targetHeight: number = 1024
|
||||
private scene: THREE.Scene
|
||||
private getActiveCamera: () => THREE.Camera
|
||||
private getControls: () => OrbitControls
|
||||
private eventManager: EventManagerInterface
|
||||
|
||||
private getRenderer: () => THREE.WebGLRenderer
|
||||
|
||||
private previewBackgroundScene: THREE.Scene
|
||||
private previewBackgroundCamera: THREE.OrthographicCamera
|
||||
private previewBackgroundMesh: THREE.Mesh | null = null
|
||||
private previewBackgroundTexture: THREE.Texture | null = null
|
||||
|
||||
private previewBackgroundColorMaterial: THREE.MeshBasicMaterial | null = null
|
||||
private currentBackgroundColor: THREE.Color = new THREE.Color(0x282828)
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
getControls: () => OrbitControls,
|
||||
getRenderer: () => THREE.WebGLRenderer,
|
||||
eventManager: EventManagerInterface,
|
||||
backgroundScene: THREE.Scene,
|
||||
backgroundCamera: THREE.OrthographicCamera
|
||||
) {
|
||||
this.scene = scene
|
||||
this.getActiveCamera = getActiveCamera
|
||||
this.getControls = getControls
|
||||
this.getRenderer = getRenderer
|
||||
this.eventManager = eventManager
|
||||
|
||||
this.previewCamera = this.getActiveCamera().clone()
|
||||
|
||||
this.previewBackgroundScene = backgroundScene.clone()
|
||||
this.previewBackgroundCamera = backgroundCamera.clone()
|
||||
|
||||
this.initPreviewBackgroundScene()
|
||||
}
|
||||
|
||||
private initPreviewBackgroundScene(): void {
|
||||
const planeGeometry = new THREE.PlaneGeometry(2, 2)
|
||||
|
||||
this.previewBackgroundColorMaterial = new THREE.MeshBasicMaterial({
|
||||
color: this.currentBackgroundColor.clone(),
|
||||
transparent: false,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
|
||||
this.previewBackgroundMesh = new THREE.Mesh(
|
||||
planeGeometry,
|
||||
this.previewBackgroundColorMaterial
|
||||
)
|
||||
this.previewBackgroundMesh.position.set(0, 0, 0)
|
||||
this.previewBackgroundScene.add(this.previewBackgroundMesh)
|
||||
}
|
||||
|
||||
init(): void {}
|
||||
|
||||
dispose(): void {
|
||||
if (this.previewBackgroundTexture) {
|
||||
this.previewBackgroundTexture.dispose()
|
||||
}
|
||||
|
||||
if (this.previewBackgroundColorMaterial) {
|
||||
this.previewBackgroundColorMaterial.dispose()
|
||||
}
|
||||
|
||||
if (this.previewBackgroundMesh) {
|
||||
this.previewBackgroundMesh.geometry.dispose()
|
||||
if (this.previewBackgroundMesh.material instanceof THREE.Material) {
|
||||
this.previewBackgroundMesh.material.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createCapturePreview(container: Element | HTMLElement): void {
|
||||
this.previewContainer = document.createElement('div')
|
||||
this.previewContainer.style.cssText = `
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
display: block;
|
||||
transition: border-color 0.1s ease;
|
||||
`
|
||||
|
||||
const MIN_PREVIEW_WIDTH = 120
|
||||
const MAX_PREVIEW_WIDTH = 240
|
||||
|
||||
this.previewContainer.addEventListener('wheel', (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const delta = event.deltaY
|
||||
const oldWidth = this.previewWidth
|
||||
|
||||
if (delta > 0) {
|
||||
this.previewWidth = Math.max(MIN_PREVIEW_WIDTH, this.previewWidth - 10)
|
||||
} else {
|
||||
this.previewWidth = Math.min(MAX_PREVIEW_WIDTH, this.previewWidth + 10)
|
||||
}
|
||||
|
||||
if (
|
||||
oldWidth !== this.previewWidth &&
|
||||
(this.previewWidth === MIN_PREVIEW_WIDTH ||
|
||||
this.previewWidth === MAX_PREVIEW_WIDTH)
|
||||
) {
|
||||
this.flashPreviewBorder()
|
||||
}
|
||||
|
||||
this.updatePreviewSize()
|
||||
})
|
||||
|
||||
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
|
||||
|
||||
container.appendChild(this.previewContainer)
|
||||
|
||||
this.updatePreviewSize()
|
||||
}
|
||||
|
||||
flashPreviewBorder(): void {
|
||||
const originalBorder = this.previewContainer.style.border
|
||||
const originalBoxShadow = this.previewContainer.style.boxShadow
|
||||
|
||||
this.previewContainer.style.border = '2px solid rgba(255, 255, 255, 0.8)'
|
||||
this.previewContainer.style.boxShadow = '0 0 8px rgba(255, 255, 255, 0.5)'
|
||||
|
||||
setTimeout(() => {
|
||||
this.previewContainer.style.border = originalBorder
|
||||
this.previewContainer.style.boxShadow = originalBoxShadow
|
||||
}, 100)
|
||||
}
|
||||
|
||||
updatePreviewSize(): void {
|
||||
if (!this.previewContainer) return
|
||||
|
||||
const previewHeight =
|
||||
(this.previewWidth * this.targetHeight) / this.targetWidth
|
||||
|
||||
this.previewContainer.style.width = `${this.previewWidth}px`
|
||||
this.previewContainer.style.height = `${previewHeight}px`
|
||||
}
|
||||
|
||||
getPreviewViewport(): {
|
||||
left: number
|
||||
bottom: number
|
||||
width: number
|
||||
height: number
|
||||
} | null {
|
||||
if (!this.showPreview || !this.previewContainer) {
|
||||
return null
|
||||
}
|
||||
|
||||
const renderer = this.getRenderer()
|
||||
const canvas = renderer.domElement
|
||||
|
||||
const containerRect = this.previewContainer.getBoundingClientRect()
|
||||
const canvasRect = canvas.getBoundingClientRect()
|
||||
|
||||
if (
|
||||
containerRect.bottom < canvasRect.top ||
|
||||
containerRect.top > canvasRect.bottom ||
|
||||
containerRect.right < canvasRect.left ||
|
||||
containerRect.left > canvasRect.right
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const width = parseFloat(this.previewContainer.style.width)
|
||||
const height = parseFloat(this.previewContainer.style.height)
|
||||
|
||||
const left = this.getRenderer().domElement.clientWidth - width
|
||||
|
||||
const bottom = 0
|
||||
|
||||
return { left, bottom, width, height }
|
||||
}
|
||||
|
||||
renderPreview(): void {
|
||||
const viewport = this.getPreviewViewport()
|
||||
if (!viewport) return
|
||||
|
||||
const renderer = this.getRenderer()
|
||||
|
||||
const originalClearColor = renderer.getClearColor(new THREE.Color())
|
||||
const originalClearAlpha = renderer.getClearAlpha()
|
||||
|
||||
if (
|
||||
!this.previewCamera ||
|
||||
(this.getActiveCamera() instanceof THREE.PerspectiveCamera &&
|
||||
!(this.previewCamera instanceof THREE.PerspectiveCamera)) ||
|
||||
(this.getActiveCamera() instanceof THREE.OrthographicCamera &&
|
||||
!(this.previewCamera instanceof THREE.OrthographicCamera))
|
||||
) {
|
||||
this.previewCamera = this.getActiveCamera().clone()
|
||||
}
|
||||
|
||||
this.previewCamera.position.copy(this.getActiveCamera().position)
|
||||
this.previewCamera.rotation.copy(this.getActiveCamera().rotation)
|
||||
|
||||
const aspect = this.targetWidth / this.targetHeight
|
||||
|
||||
if (this.getActiveCamera() instanceof THREE.OrthographicCamera) {
|
||||
const activeOrtho = this.getActiveCamera() as THREE.OrthographicCamera
|
||||
const previewOrtho = this.previewCamera as THREE.OrthographicCamera
|
||||
|
||||
const frustumHeight =
|
||||
(activeOrtho.top - activeOrtho.bottom) / activeOrtho.zoom
|
||||
|
||||
const frustumWidth = frustumHeight * aspect
|
||||
|
||||
previewOrtho.top = frustumHeight / 2
|
||||
previewOrtho.left = -frustumWidth / 2
|
||||
previewOrtho.right = frustumWidth / 2
|
||||
previewOrtho.bottom = -frustumHeight / 2
|
||||
previewOrtho.zoom = 1
|
||||
|
||||
previewOrtho.updateProjectionMatrix()
|
||||
} else {
|
||||
const activePerspective =
|
||||
this.getActiveCamera() as THREE.PerspectiveCamera
|
||||
const previewPerspective = this.previewCamera as THREE.PerspectiveCamera
|
||||
|
||||
previewPerspective.fov = activePerspective.fov
|
||||
previewPerspective.zoom = activePerspective.zoom
|
||||
previewPerspective.aspect = aspect
|
||||
|
||||
previewPerspective.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
this.previewCamera.lookAt(this.getControls().target)
|
||||
|
||||
renderer.setViewport(
|
||||
viewport.left,
|
||||
viewport.bottom,
|
||||
viewport.width,
|
||||
viewport.height
|
||||
)
|
||||
renderer.setScissor(
|
||||
viewport.left,
|
||||
viewport.bottom,
|
||||
viewport.width,
|
||||
viewport.height
|
||||
)
|
||||
|
||||
renderer.setClearColor(0x000000, 0)
|
||||
renderer.clear()
|
||||
|
||||
this.renderPreviewBackground(renderer)
|
||||
|
||||
renderer.render(this.scene, this.previewCamera)
|
||||
|
||||
renderer.setClearColor(originalClearColor, originalClearAlpha)
|
||||
}
|
||||
|
||||
private renderPreviewBackground(renderer: THREE.WebGLRenderer): void {
|
||||
if (this.previewBackgroundMesh) {
|
||||
const currentToneMapping = renderer.toneMapping
|
||||
const currentExposure = renderer.toneMappingExposure
|
||||
|
||||
renderer.toneMapping = THREE.NoToneMapping
|
||||
renderer.render(this.previewBackgroundScene, this.previewBackgroundCamera)
|
||||
|
||||
renderer.toneMapping = currentToneMapping
|
||||
renderer.toneMappingExposure = currentExposure
|
||||
}
|
||||
}
|
||||
|
||||
setPreviewBackgroundColor(color: string | number | THREE.Color): void {
|
||||
this.currentBackgroundColor.set(color)
|
||||
|
||||
if (!this.previewBackgroundMesh || !this.previewBackgroundColorMaterial) {
|
||||
this.initPreviewBackgroundScene()
|
||||
}
|
||||
|
||||
this.previewBackgroundColorMaterial!.color.copy(this.currentBackgroundColor)
|
||||
|
||||
if (this.previewBackgroundMesh) {
|
||||
this.previewBackgroundMesh.material = this.previewBackgroundColorMaterial!
|
||||
}
|
||||
|
||||
if (this.previewBackgroundTexture) {
|
||||
this.previewBackgroundTexture.dispose()
|
||||
this.previewBackgroundTexture = null
|
||||
}
|
||||
}
|
||||
|
||||
togglePreview(showPreview: boolean): void {
|
||||
this.showPreview = showPreview
|
||||
if (this.previewContainer) {
|
||||
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
|
||||
}
|
||||
|
||||
this.eventManager.emitEvent('showPreviewChange', showPreview)
|
||||
}
|
||||
|
||||
setTargetSize(width: number, height: number): void {
|
||||
const oldAspect = this.targetWidth / this.targetHeight
|
||||
|
||||
this.targetWidth = width
|
||||
this.targetHeight = height
|
||||
|
||||
this.updatePreviewSize()
|
||||
|
||||
const newAspect = width / height
|
||||
if (Math.abs(oldAspect - newAspect) > 0.001) {
|
||||
this.updateBackgroundSize(
|
||||
this.previewBackgroundTexture,
|
||||
this.previewBackgroundMesh,
|
||||
width,
|
||||
height
|
||||
)
|
||||
}
|
||||
|
||||
if (this.previewCamera) {
|
||||
if (this.previewCamera instanceof THREE.PerspectiveCamera) {
|
||||
this.previewCamera.aspect = width / height
|
||||
this.previewCamera.updateProjectionMatrix()
|
||||
} else if (this.previewCamera instanceof THREE.OrthographicCamera) {
|
||||
const frustumSize = 10
|
||||
const aspect = width / height
|
||||
this.previewCamera.left = (-frustumSize * aspect) / 2
|
||||
this.previewCamera.right = (frustumSize * aspect) / 2
|
||||
this.previewCamera.updateProjectionMatrix()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleResize(): void {
|
||||
this.updatePreviewSize()
|
||||
}
|
||||
|
||||
updateBackgroundTexture(texture: THREE.Texture | null): void {
|
||||
if (texture) {
|
||||
if (this.previewBackgroundTexture) {
|
||||
this.previewBackgroundTexture.dispose()
|
||||
}
|
||||
|
||||
this.previewBackgroundTexture = texture
|
||||
|
||||
if (this.previewBackgroundMesh) {
|
||||
const imageMaterial = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
|
||||
if (
|
||||
this.previewBackgroundMesh.material instanceof THREE.Material &&
|
||||
this.previewBackgroundMesh.material !==
|
||||
this.previewBackgroundColorMaterial
|
||||
) {
|
||||
this.previewBackgroundMesh.material.dispose()
|
||||
}
|
||||
|
||||
this.previewBackgroundMesh.material = imageMaterial
|
||||
this.previewBackgroundMesh.position.set(0, 0, 0)
|
||||
|
||||
this.updateBackgroundSize(
|
||||
this.previewBackgroundTexture,
|
||||
this.previewBackgroundMesh,
|
||||
this.targetWidth,
|
||||
this.targetHeight
|
||||
)
|
||||
}
|
||||
} else {
|
||||
this.setPreviewBackgroundColor(this.currentBackgroundColor)
|
||||
}
|
||||
}
|
||||
|
||||
private updateBackgroundSize(
|
||||
backgroundTexture: THREE.Texture | null,
|
||||
backgroundMesh: THREE.Mesh | null,
|
||||
targetWidth: number,
|
||||
targetHeight: number
|
||||
): void {
|
||||
if (!backgroundTexture || !backgroundMesh) return
|
||||
|
||||
const material = backgroundMesh.material as THREE.MeshBasicMaterial
|
||||
|
||||
if (!material.map) return
|
||||
|
||||
const imageAspect =
|
||||
backgroundTexture.image.width / backgroundTexture.image.height
|
||||
const targetAspect = targetWidth / targetHeight
|
||||
|
||||
if (imageAspect > targetAspect) {
|
||||
backgroundMesh.scale.set(imageAspect / targetAspect, 1, 1)
|
||||
} else {
|
||||
backgroundMesh.scale.set(1, targetAspect / imageAspect, 1)
|
||||
}
|
||||
|
||||
material.needsUpdate = true
|
||||
}
|
||||
|
||||
reset(): void {}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ export class RecordingManager {
|
||||
private recordingStartTime: number = 0
|
||||
private recordingDuration: number = 0
|
||||
private recordingCanvas: HTMLCanvasElement | null = null
|
||||
private recordingContext: CanvasRenderingContext2D | null = null
|
||||
private animationFrameId: number | null = null
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
@@ -50,13 +52,70 @@ export class RecordingManager {
|
||||
this.scene.add(this.recordingIndicator)
|
||||
}
|
||||
|
||||
public async startRecording(): Promise<void> {
|
||||
public async startRecording(
|
||||
targetWidth?: number,
|
||||
targetHeight?: number
|
||||
): Promise<void> {
|
||||
if (this.isRecording) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.recordingCanvas = this.renderer.domElement
|
||||
const sourceCanvas = this.renderer.domElement
|
||||
const sourceWidth = sourceCanvas.width
|
||||
const sourceHeight = sourceCanvas.height
|
||||
|
||||
const recordWidth = targetWidth || sourceWidth
|
||||
const recordHeight = targetHeight || sourceHeight
|
||||
|
||||
this.recordingCanvas = document.createElement('canvas')
|
||||
this.recordingCanvas.width = recordWidth
|
||||
this.recordingCanvas.height = recordHeight
|
||||
this.recordingContext = this.recordingCanvas.getContext('2d', {
|
||||
alpha: false
|
||||
})
|
||||
|
||||
if (!this.recordingContext) {
|
||||
throw new Error('Failed to get 2D context for recording canvas')
|
||||
}
|
||||
|
||||
const sourceAspectRatio = sourceWidth / sourceHeight
|
||||
const targetAspectRatio = recordWidth / recordHeight
|
||||
|
||||
let sx = 0,
|
||||
sy = 0,
|
||||
sw = sourceWidth,
|
||||
sh = sourceHeight
|
||||
|
||||
if (Math.abs(sourceAspectRatio - targetAspectRatio) > 0.01) {
|
||||
if (sourceAspectRatio > targetAspectRatio) {
|
||||
sw = sourceHeight * targetAspectRatio
|
||||
sx = (sourceWidth - sw) / 2
|
||||
} else {
|
||||
sh = sourceWidth / targetAspectRatio
|
||||
sy = (sourceHeight - sh) / 2
|
||||
}
|
||||
}
|
||||
|
||||
const captureFrame = () => {
|
||||
if (!this.isRecording || !this.recordingContext) {
|
||||
return
|
||||
}
|
||||
|
||||
this.recordingContext.drawImage(
|
||||
sourceCanvas,
|
||||
sx,
|
||||
sy,
|
||||
sw,
|
||||
sh,
|
||||
0,
|
||||
0,
|
||||
recordWidth,
|
||||
recordHeight
|
||||
)
|
||||
|
||||
this.animationFrameId = requestAnimationFrame(captureFrame)
|
||||
}
|
||||
|
||||
this.recordingStream = this.recordingCanvas.captureStream(30)
|
||||
|
||||
@@ -82,6 +141,11 @@ export class RecordingManager {
|
||||
this.isRecording = false
|
||||
this.recordingStream = null
|
||||
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId)
|
||||
this.animationFrameId = null
|
||||
}
|
||||
|
||||
this.eventManager.emitEvent('recordingStopped', {
|
||||
duration: this.recordingDuration,
|
||||
hasRecording: this.recordedChunks.length > 0
|
||||
@@ -96,6 +160,8 @@ export class RecordingManager {
|
||||
this.isRecording = true
|
||||
this.recordingStartTime = Date.now()
|
||||
|
||||
captureFrame()
|
||||
|
||||
this.eventManager.emitEvent('recordingStarted', null)
|
||||
} catch (error) {
|
||||
console.error('Error starting recording:', error)
|
||||
@@ -110,10 +176,18 @@ export class RecordingManager {
|
||||
|
||||
this.recordingDuration = (Date.now() - this.recordingStartTime) / 1000
|
||||
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId)
|
||||
this.animationFrameId = null
|
||||
}
|
||||
|
||||
this.mediaRecorder.stop()
|
||||
if (this.recordingStream) {
|
||||
this.recordingStream.getTracks().forEach((track) => track.stop())
|
||||
}
|
||||
|
||||
this.recordingCanvas = null
|
||||
this.recordingContext = null
|
||||
}
|
||||
|
||||
public getIsRecording(): boolean {
|
||||
@@ -167,9 +241,17 @@ export class RecordingManager {
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId)
|
||||
this.animationFrameId = null
|
||||
}
|
||||
|
||||
this.stopRecording()
|
||||
this.clearRecording()
|
||||
|
||||
this.recordingCanvas = null
|
||||
this.recordingContext = null
|
||||
|
||||
if (this.recordingIndicator) {
|
||||
this.scene.remove(this.recordingIndicator)
|
||||
;(this.recordingIndicator.material as THREE.SpriteMaterial).map?.dispose()
|
||||
|
||||
@@ -134,9 +134,20 @@ export class SceneManager implements SceneManagerInterface {
|
||||
|
||||
this.eventManager.emitEvent('backgroundImageLoadingStart', null)
|
||||
|
||||
let imageUrl = Load3dUtils.getResourceURL(
|
||||
...Load3dUtils.splitFilePath(uploadPath)
|
||||
)
|
||||
let type = 'input'
|
||||
let pathParts = Load3dUtils.splitFilePath(uploadPath)
|
||||
let subfolder = pathParts[0]
|
||||
let filename = pathParts[1]
|
||||
|
||||
if (subfolder === 'temp') {
|
||||
type = 'temp'
|
||||
pathParts = ['', filename]
|
||||
} else if (subfolder === 'output') {
|
||||
type = 'output'
|
||||
pathParts = ['', filename]
|
||||
}
|
||||
|
||||
let imageUrl = Load3dUtils.getResourceURL(...pathParts, type)
|
||||
|
||||
if (!imageUrl.startsWith('/api')) {
|
||||
imageUrl = '/api' + imageUrl
|
||||
@@ -184,8 +195,8 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.updateBackgroundSize(
|
||||
this.backgroundTexture,
|
||||
this.backgroundMesh,
|
||||
this.renderer.domElement.width,
|
||||
this.renderer.domElement.height
|
||||
this.renderer.domElement.clientWidth,
|
||||
this.renderer.domElement.clientHeight
|
||||
)
|
||||
|
||||
this.eventManager.emitEvent('backgroundImageChange', uploadPath)
|
||||
@@ -268,7 +279,7 @@ export class SceneManager implements SceneManagerInterface {
|
||||
captureScene(
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<{ scene: string; mask: string; normal: string; lineart: string }> {
|
||||
): Promise<{ scene: string; mask: string; normal: string }> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const originalWidth = this.renderer.domElement.width
|
||||
@@ -359,60 +370,9 @@ export class SceneManager implements SceneManagerInterface {
|
||||
}
|
||||
})
|
||||
|
||||
let lineartModel: THREE.Group | null = null
|
||||
|
||||
const originalSceneVisible: Map<THREE.Object3D, boolean> = new Map()
|
||||
|
||||
this.scene.traverse((child) => {
|
||||
if (child instanceof THREE.Group && child.name === 'lineartModel') {
|
||||
lineartModel = child as THREE.Group
|
||||
}
|
||||
|
||||
if (
|
||||
child instanceof THREE.Mesh &&
|
||||
!(child.parent?.name === 'lineartModel')
|
||||
) {
|
||||
originalSceneVisible.set(child, child.visible)
|
||||
|
||||
child.visible = false
|
||||
}
|
||||
})
|
||||
|
||||
this.renderer.setClearColor(0xffffff, 1)
|
||||
this.renderer.clear()
|
||||
|
||||
if (lineartModel !== null) {
|
||||
lineartModel = lineartModel as THREE.Group
|
||||
|
||||
const originalLineartVisibleMap: Map<THREE.Object3D, boolean> =
|
||||
new Map()
|
||||
|
||||
lineartModel.traverse((child: THREE.Object3D) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
originalLineartVisibleMap.set(child, child.visible)
|
||||
|
||||
child.visible = true
|
||||
}
|
||||
})
|
||||
|
||||
const originalLineartVisible = lineartModel.visible
|
||||
lineartModel.visible = true
|
||||
|
||||
this.renderer.render(this.scene, this.getActiveCamera())
|
||||
|
||||
lineartModel.visible = originalLineartVisible
|
||||
|
||||
originalLineartVisibleMap.forEach((visible, object) => {
|
||||
object.visible = visible
|
||||
})
|
||||
}
|
||||
|
||||
const lineartData = this.renderer.domElement.toDataURL('image/png')
|
||||
|
||||
originalSceneVisible.forEach((visible, object) => {
|
||||
object.visible = visible
|
||||
})
|
||||
|
||||
this.gridHelper.visible = gridVisible
|
||||
|
||||
this.renderer.setClearColor(originalClearColor, originalClearAlpha)
|
||||
@@ -424,8 +384,7 @@ export class SceneManager implements SceneManagerInterface {
|
||||
resolve({
|
||||
scene: sceneData,
|
||||
mask: maskData,
|
||||
normal: normalData,
|
||||
lineart: lineartData
|
||||
normal: normalData
|
||||
})
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import * as THREE from 'three'
|
||||
import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial'
|
||||
import { LineSegments2 } from 'three/examples/jsm/lines/LineSegments2'
|
||||
import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeometry'
|
||||
import { type GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils'
|
||||
|
||||
import { ColoredShadowMaterial } from './conditional-lines/ColoredShadowMaterial'
|
||||
import { ConditionalEdgesGeometry } from './conditional-lines/ConditionalEdgesGeometry'
|
||||
import { ConditionalEdgesShader } from './conditional-lines/ConditionalEdgesShader.js'
|
||||
import { ConditionalLineMaterial } from './conditional-lines/Lines2/ConditionalLineMaterial'
|
||||
import { ConditionalLineSegmentsGeometry } from './conditional-lines/Lines2/ConditionalLineSegmentsGeometry'
|
||||
import {
|
||||
type EventManagerInterface,
|
||||
type Load3DOptions,
|
||||
type MaterialMode,
|
||||
type ModelManagerInterface,
|
||||
type UpDirection
|
||||
@@ -45,25 +35,13 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
private eventManager: EventManagerInterface
|
||||
private activeCamera: THREE.Camera
|
||||
private setupCamera: (size: THREE.Vector3) => void
|
||||
private lineartModel: THREE.Group
|
||||
private createLineartModel: boolean = false
|
||||
|
||||
LIGHT_MODEL = 0xffffff
|
||||
LIGHT_LINES = 0x455a64
|
||||
|
||||
conditionalModel: THREE.Object3D | null = null
|
||||
edgesModel: THREE.Object3D | null = null
|
||||
backgroundModel: THREE.Object3D | null = null
|
||||
shadowModel: THREE.Object3D | null = null
|
||||
depthModel: THREE.Object3D | null = null
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
eventManager: EventManagerInterface,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
setupCamera: (size: THREE.Vector3) => void,
|
||||
options: Load3DOptions
|
||||
setupCamera: (size: THREE.Vector3) => void
|
||||
) {
|
||||
this.scene = scene
|
||||
this.renderer = renderer
|
||||
@@ -72,14 +50,6 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
this.setupCamera = setupCamera
|
||||
this.textureLoader = new THREE.TextureLoader()
|
||||
|
||||
if (
|
||||
options &&
|
||||
!options.inputSpec?.isPreview &&
|
||||
!options.inputSpec?.isAnimation
|
||||
) {
|
||||
this.createLineartModel = true
|
||||
}
|
||||
|
||||
this.normalMaterial = new THREE.MeshNormalMaterial({
|
||||
flatShading: false,
|
||||
side: THREE.DoubleSide,
|
||||
@@ -101,10 +71,6 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
})
|
||||
|
||||
this.standardMaterial = this.createSTLMaterial()
|
||||
|
||||
this.lineartModel = new THREE.Group()
|
||||
|
||||
this.lineartModel.name = 'lineartModel'
|
||||
}
|
||||
|
||||
init(): void {}
|
||||
@@ -120,8 +86,6 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
this.appliedTexture.dispose()
|
||||
this.appliedTexture = null
|
||||
}
|
||||
|
||||
this.disposeLineartModel()
|
||||
}
|
||||
|
||||
createSTLMaterial(): THREE.MeshStandardMaterial {
|
||||
@@ -134,360 +98,6 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
})
|
||||
}
|
||||
|
||||
disposeLineartModel(): void {
|
||||
this.disposeEdgesModel()
|
||||
this.disposeShadowModel()
|
||||
this.disposeBackgroundModel()
|
||||
this.disposeDepthModel()
|
||||
this.disposeConditionalModel()
|
||||
}
|
||||
|
||||
disposeEdgesModel(): void {
|
||||
if (this.edgesModel) {
|
||||
if (this.edgesModel.parent) {
|
||||
this.edgesModel.parent.remove(this.edgesModel)
|
||||
}
|
||||
|
||||
this.edgesModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach((m) => m.dispose())
|
||||
} else {
|
||||
child.material.dispose()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
initEdgesModel() {
|
||||
this.disposeEdgesModel()
|
||||
|
||||
if (!this.currentModel) {
|
||||
return
|
||||
}
|
||||
|
||||
this.edgesModel = this.currentModel.clone()
|
||||
this.lineartModel.add(this.edgesModel)
|
||||
|
||||
const meshes: THREE.Mesh[] = []
|
||||
|
||||
this.edgesModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
meshes.push(child)
|
||||
}
|
||||
})
|
||||
|
||||
for (const key in meshes) {
|
||||
const mesh = meshes[key]
|
||||
const parent = mesh.parent
|
||||
|
||||
let lineGeom = new THREE.EdgesGeometry(mesh.geometry, 85)
|
||||
|
||||
const line = new THREE.LineSegments(
|
||||
lineGeom,
|
||||
new THREE.LineBasicMaterial({ color: this.LIGHT_LINES })
|
||||
)
|
||||
line.position.copy(mesh.position)
|
||||
line.scale.copy(mesh.scale)
|
||||
line.rotation.copy(mesh.rotation)
|
||||
|
||||
const thickLineGeom = new LineSegmentsGeometry().fromEdgesGeometry(
|
||||
lineGeom
|
||||
)
|
||||
const thickLines = new LineSegments2(
|
||||
thickLineGeom,
|
||||
new LineMaterial({ color: this.LIGHT_LINES, linewidth: 13 })
|
||||
)
|
||||
thickLines.position.copy(mesh.position)
|
||||
thickLines.scale.copy(mesh.scale)
|
||||
thickLines.rotation.copy(mesh.rotation)
|
||||
|
||||
parent?.remove(mesh)
|
||||
parent?.add(line)
|
||||
parent?.add(thickLines)
|
||||
}
|
||||
|
||||
this.edgesModel.traverse((child) => {
|
||||
if (
|
||||
child instanceof THREE.Mesh &&
|
||||
child.material &&
|
||||
child.material.resolution
|
||||
) {
|
||||
this.renderer.getSize(child.material.resolution)
|
||||
child.material.resolution.multiplyScalar(window.devicePixelRatio)
|
||||
child.material.linewidth = 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setEdgeThreshold(threshold: number): void {
|
||||
if (!this.edgesModel || !this.currentModel) {
|
||||
return
|
||||
}
|
||||
|
||||
const linesToRemove: THREE.Object3D[] = []
|
||||
this.edgesModel.traverse((child) => {
|
||||
if (
|
||||
child instanceof THREE.LineSegments ||
|
||||
child instanceof LineSegments2
|
||||
) {
|
||||
linesToRemove.push(child)
|
||||
}
|
||||
})
|
||||
|
||||
for (const line of linesToRemove) {
|
||||
if (line.parent) {
|
||||
line.parent.remove(line)
|
||||
}
|
||||
}
|
||||
|
||||
const meshes: THREE.Mesh[] = []
|
||||
this.currentModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
meshes.push(child)
|
||||
}
|
||||
})
|
||||
|
||||
for (const mesh of meshes) {
|
||||
const meshClone = mesh.clone()
|
||||
|
||||
let lineGeom = new THREE.EdgesGeometry(meshClone.geometry, threshold)
|
||||
|
||||
const line = new THREE.LineSegments(
|
||||
lineGeom,
|
||||
new THREE.LineBasicMaterial({ color: this.LIGHT_LINES })
|
||||
)
|
||||
line.position.copy(mesh.position)
|
||||
line.scale.copy(mesh.scale)
|
||||
line.rotation.copy(mesh.rotation)
|
||||
|
||||
const thickLineGeom = new LineSegmentsGeometry().fromEdgesGeometry(
|
||||
lineGeom
|
||||
)
|
||||
const thickLines = new LineSegments2(
|
||||
thickLineGeom,
|
||||
new LineMaterial({ color: this.LIGHT_LINES, linewidth: 13 })
|
||||
)
|
||||
thickLines.position.copy(mesh.position)
|
||||
thickLines.scale.copy(mesh.scale)
|
||||
thickLines.rotation.copy(mesh.rotation)
|
||||
|
||||
this.edgesModel.add(line)
|
||||
this.edgesModel.add(thickLines)
|
||||
}
|
||||
|
||||
this.edgesModel.traverse((child) => {
|
||||
if (
|
||||
child instanceof THREE.Mesh &&
|
||||
child.material &&
|
||||
child.material.resolution
|
||||
) {
|
||||
this.renderer.getSize(child.material.resolution)
|
||||
child.material.resolution.multiplyScalar(window.devicePixelRatio)
|
||||
child.material.linewidth = 1
|
||||
}
|
||||
})
|
||||
this.eventManager.emitEvent('edgeThresholdChange', threshold)
|
||||
}
|
||||
|
||||
disposeBackgroundModel(): void {
|
||||
if (this.backgroundModel) {
|
||||
if (this.backgroundModel.parent) {
|
||||
this.backgroundModel.parent.remove(this.backgroundModel)
|
||||
}
|
||||
|
||||
this.backgroundModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.material.dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
disposeShadowModel(): void {
|
||||
if (this.shadowModel) {
|
||||
if (this.shadowModel.parent) {
|
||||
this.shadowModel.parent.remove(this.shadowModel)
|
||||
}
|
||||
|
||||
this.shadowModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.material.dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
disposeDepthModel(): void {
|
||||
if (this.depthModel) {
|
||||
if (this.depthModel.parent) {
|
||||
this.depthModel.parent.remove(this.depthModel)
|
||||
}
|
||||
|
||||
this.depthModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.material.dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
disposeConditionalModel(): void {
|
||||
if (this.conditionalModel) {
|
||||
if (this.conditionalModel.parent) {
|
||||
this.conditionalModel.parent.remove(this.conditionalModel)
|
||||
}
|
||||
|
||||
this.conditionalModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.material.dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
initBackgroundModel() {
|
||||
this.disposeBackgroundModel()
|
||||
this.disposeShadowModel()
|
||||
this.disposeDepthModel()
|
||||
|
||||
if (!this.currentModel) {
|
||||
return
|
||||
}
|
||||
|
||||
this.backgroundModel = this.currentModel.clone()
|
||||
this.backgroundModel.visible = true
|
||||
this.backgroundModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.material = new THREE.MeshBasicMaterial({
|
||||
color: this.LIGHT_MODEL
|
||||
})
|
||||
child.material.polygonOffset = true
|
||||
child.material.polygonOffsetFactor = 1
|
||||
child.material.polygonOffsetUnits = 1
|
||||
child.renderOrder = 2
|
||||
child.material.transparent = false
|
||||
child.material.opacity = 0.25
|
||||
}
|
||||
})
|
||||
|
||||
this.lineartModel.add(this.backgroundModel)
|
||||
|
||||
this.shadowModel = this.currentModel.clone()
|
||||
|
||||
// TODO this has some error, need to fix later
|
||||
this.shadowModel.visible = false
|
||||
|
||||
this.shadowModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.material = new ColoredShadowMaterial({
|
||||
color: this.LIGHT_MODEL,
|
||||
shininess: 1.0
|
||||
})
|
||||
child.material.polygonOffset = true
|
||||
child.material.polygonOffsetFactor = 1
|
||||
child.material.polygonOffsetUnits = 1
|
||||
child.receiveShadow = true
|
||||
child.renderOrder = 2
|
||||
}
|
||||
})
|
||||
|
||||
this.lineartModel.add(this.shadowModel)
|
||||
|
||||
this.depthModel = this.currentModel.clone()
|
||||
this.depthModel.visible = true
|
||||
this.depthModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.material = new THREE.MeshBasicMaterial({
|
||||
color: this.LIGHT_MODEL
|
||||
})
|
||||
child.material.polygonOffset = true
|
||||
child.material.polygonOffsetFactor = 1
|
||||
child.material.polygonOffsetUnits = 1
|
||||
child.material.colorWrite = false
|
||||
child.renderOrder = 1
|
||||
}
|
||||
})
|
||||
|
||||
this.lineartModel.add(this.depthModel)
|
||||
}
|
||||
|
||||
initConditionalModel() {
|
||||
this.disposeConditionalModel()
|
||||
|
||||
if (!this.currentModel) {
|
||||
return
|
||||
}
|
||||
|
||||
this.conditionalModel = this.currentModel.clone()
|
||||
this.lineartModel.add(this.conditionalModel)
|
||||
this.conditionalModel.visible = true
|
||||
|
||||
const meshes: THREE.Mesh[] = []
|
||||
|
||||
this.conditionalModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
meshes.push(child)
|
||||
}
|
||||
})
|
||||
|
||||
for (const key in meshes) {
|
||||
const mesh = meshes[key]
|
||||
const parent = mesh.parent
|
||||
|
||||
const mergedGeom = mesh.geometry.clone()
|
||||
for (const key in mergedGeom.attributes) {
|
||||
if (key !== 'position') {
|
||||
mergedGeom.deleteAttribute(key)
|
||||
}
|
||||
}
|
||||
|
||||
const lineGeom = new ConditionalEdgesGeometry(mergeVertices(mergedGeom))
|
||||
const material = new THREE.ShaderMaterial(ConditionalEdgesShader)
|
||||
material.uniforms.diffuse.value.set(this.LIGHT_LINES)
|
||||
|
||||
const line = new THREE.LineSegments(lineGeom, material)
|
||||
line.position.copy(mesh.position)
|
||||
line.scale.copy(mesh.scale)
|
||||
line.rotation.copy(mesh.rotation)
|
||||
|
||||
const thickLineGeom =
|
||||
new ConditionalLineSegmentsGeometry().fromConditionalEdgesGeometry(
|
||||
lineGeom
|
||||
)
|
||||
|
||||
const conditionalLineMaterial = new ConditionalLineMaterial({
|
||||
color: this.LIGHT_LINES,
|
||||
linewidth: 2
|
||||
})
|
||||
|
||||
const thickLines = new LineSegments2(
|
||||
thickLineGeom,
|
||||
conditionalLineMaterial
|
||||
)
|
||||
thickLines.position.copy(mesh.position)
|
||||
thickLines.scale.copy(mesh.scale)
|
||||
thickLines.rotation.copy(mesh.rotation)
|
||||
|
||||
parent?.remove(mesh)
|
||||
parent?.add(line)
|
||||
parent?.add(thickLines)
|
||||
}
|
||||
|
||||
this.conditionalModel.traverse((child) => {
|
||||
if (
|
||||
child instanceof THREE.Mesh &&
|
||||
child.material &&
|
||||
child.material.resolution
|
||||
) {
|
||||
this.renderer.getSize(child.material.resolution)
|
||||
child.material.resolution.multiplyScalar(window.devicePixelRatio)
|
||||
child.material.linewidth = 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setMaterialMode(mode: MaterialMode): void {
|
||||
if (!this.currentModel || mode === this.materialMode) {
|
||||
return
|
||||
@@ -502,11 +112,7 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
}
|
||||
|
||||
if (this.currentModel) {
|
||||
this.currentModel.visible = mode !== 'lineart'
|
||||
}
|
||||
|
||||
if (this.lineartModel) {
|
||||
this.lineartModel.visible = mode === 'lineart'
|
||||
this.currentModel.visible = true
|
||||
}
|
||||
|
||||
this.currentModel.traverse((child) => {
|
||||
@@ -649,6 +255,7 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
|
||||
reset(): void {
|
||||
this.currentModel = null
|
||||
this.originalModel = null
|
||||
this.originalRotation = null
|
||||
this.currentUpDirection = 'original'
|
||||
this.setMaterialMode('original')
|
||||
@@ -699,20 +306,6 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
this.setupModelMaterials(model)
|
||||
|
||||
this.setupCamera(size)
|
||||
|
||||
if (this.createLineartModel) {
|
||||
this.setupLineartModel()
|
||||
}
|
||||
}
|
||||
|
||||
setupLineartModel(): void {
|
||||
this.scene.add(this.lineartModel)
|
||||
|
||||
this.initEdgesModel()
|
||||
this.initBackgroundModel()
|
||||
this.initConditionalModel()
|
||||
|
||||
this.lineartModel.visible = false
|
||||
}
|
||||
|
||||
setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void {
|
||||
@@ -730,7 +323,6 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
|
||||
if (this.originalRotation) {
|
||||
this.currentModel.rotation.copy(this.originalRotation)
|
||||
this.lineartModel.rotation.copy(this.originalRotation)
|
||||
}
|
||||
|
||||
switch (direction) {
|
||||
@@ -755,8 +347,6 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
break
|
||||
}
|
||||
|
||||
this.lineartModel.rotation.copy(this.currentModel.rotation)
|
||||
|
||||
this.eventManager.emitEvent('upDirectionChange', direction)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
|
||||
this.viewHelper.update(delta)
|
||||
|
||||
if (!this.viewHelper.animating) {
|
||||
this.nodeStorage.storeNodeProperty('Camera Info', {
|
||||
const cameraState = {
|
||||
position: this.getActiveCamera().position.clone(),
|
||||
target: this.getControls().target.clone(),
|
||||
zoom:
|
||||
@@ -85,7 +85,20 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
|
||||
this.getActiveCamera() instanceof THREE.PerspectiveCamera
|
||||
? 'perspective'
|
||||
: 'orthographic'
|
||||
})
|
||||
}
|
||||
|
||||
const cameraConfig = this.nodeStorage.loadNodeProperty(
|
||||
'Camera Config',
|
||||
{
|
||||
cameraType: cameraState.cameraType,
|
||||
fov:
|
||||
this.getActiveCamera() instanceof THREE.PerspectiveCamera
|
||||
? (this.getActiveCamera() as THREE.PerspectiveCamera).fov
|
||||
: 75
|
||||
}
|
||||
)
|
||||
cameraConfig.state = cameraState
|
||||
this.nodeStorage.storeNodeProperty('Camera Config', cameraConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
/*
|
||||
Taken from: https://github.com/gkjohnson/threejs-sandbox/tree/master/conditional-lines
|
||||
under MIT license
|
||||
*/
|
||||
import { Color, ShaderLib, ShaderMaterial, UniformsUtils } from 'three'
|
||||
|
||||
export class ColoredShadowMaterial extends ShaderMaterial {
|
||||
get color() {
|
||||
return this.uniforms.diffuse.value
|
||||
}
|
||||
|
||||
get shadowColor() {
|
||||
return this.uniforms.shadowColor.value
|
||||
}
|
||||
|
||||
set shininess(v) {
|
||||
this.uniforms.shininess.value = v
|
||||
}
|
||||
get shininess() {
|
||||
return this.uniforms.shininess.value
|
||||
}
|
||||
|
||||
constructor(options) {
|
||||
super({
|
||||
uniforms: UniformsUtils.merge([
|
||||
ShaderLib.phong.uniforms,
|
||||
{
|
||||
shadowColor: {
|
||||
value: new Color(0xff0000)
|
||||
}
|
||||
}
|
||||
]),
|
||||
vertexShader: `
|
||||
#define PHONG
|
||||
varying vec3 vViewPosition;
|
||||
#ifndef FLAT_SHADED
|
||||
varying vec3 vNormal;
|
||||
#endif
|
||||
#include <common>
|
||||
#include <uv_pars_vertex>
|
||||
#include <uv2_pars_vertex>
|
||||
#include <displacementmap_pars_vertex>
|
||||
#include <envmap_pars_vertex>
|
||||
#include <color_pars_vertex>
|
||||
#include <fog_pars_vertex>
|
||||
#include <morphtarget_pars_vertex>
|
||||
#include <skinning_pars_vertex>
|
||||
#include <shadowmap_pars_vertex>
|
||||
#include <logdepthbuf_pars_vertex>
|
||||
#include <clipping_planes_pars_vertex>
|
||||
void main() {
|
||||
#include <uv_vertex>
|
||||
#include <uv2_vertex>
|
||||
#include <color_vertex>
|
||||
#include <beginnormal_vertex>
|
||||
#include <morphnormal_vertex>
|
||||
#include <skinbase_vertex>
|
||||
#include <skinnormal_vertex>
|
||||
#include <defaultnormal_vertex>
|
||||
#ifndef FLAT_SHADED
|
||||
vNormal = normalize( transformedNormal );
|
||||
#endif
|
||||
#include <begin_vertex>
|
||||
#include <morphtarget_vertex>
|
||||
#include <skinning_vertex>
|
||||
#include <displacementmap_vertex>
|
||||
#include <project_vertex>
|
||||
#include <logdepthbuf_vertex>
|
||||
#include <clipping_planes_vertex>
|
||||
vViewPosition = - mvPosition.xyz;
|
||||
#include <worldpos_vertex>
|
||||
#include <envmap_vertex>
|
||||
#include <shadowmap_vertex>
|
||||
#include <fog_vertex>
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
#define PHONG
|
||||
uniform vec3 diffuse;
|
||||
uniform vec3 emissive;
|
||||
uniform vec3 specular;
|
||||
uniform float shininess;
|
||||
uniform float opacity;
|
||||
uniform vec3 shadowColor;
|
||||
#include <common>
|
||||
#include <packing>
|
||||
#include <dithering_pars_fragment>
|
||||
#include <color_pars_fragment>
|
||||
#include <uv_pars_fragment>
|
||||
#include <uv2_pars_fragment>
|
||||
#include <map_pars_fragment>
|
||||
#include <alphamap_pars_fragment>
|
||||
#include <aomap_pars_fragment>
|
||||
#include <lightmap_pars_fragment>
|
||||
#include <emissivemap_pars_fragment>
|
||||
#include <envmap_common_pars_fragment>
|
||||
#include <envmap_pars_fragment>
|
||||
#include <cube_uv_reflection_fragment>
|
||||
#include <fog_pars_fragment>
|
||||
#include <bsdfs>
|
||||
#include <lights_pars_begin>
|
||||
#include <lights_phong_pars_fragment>
|
||||
#include <shadowmap_pars_fragment>
|
||||
#include <bumpmap_pars_fragment>
|
||||
#include <normalmap_pars_fragment>
|
||||
#include <specularmap_pars_fragment>
|
||||
#include <logdepthbuf_pars_fragment>
|
||||
#include <clipping_planes_pars_fragment>
|
||||
void main() {
|
||||
#include <clipping_planes_fragment>
|
||||
vec4 diffuseColor = vec4( 1.0, 1.0, 1.0, opacity );
|
||||
ReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );
|
||||
vec3 totalEmissiveRadiance = emissive;
|
||||
#include <logdepthbuf_fragment>
|
||||
#include <map_fragment>
|
||||
#include <color_fragment>
|
||||
#include <alphamap_fragment>
|
||||
#include <alphatest_fragment>
|
||||
#include <specularmap_fragment>
|
||||
#include <normal_fragment_begin>
|
||||
#include <normal_fragment_maps>
|
||||
#include <emissivemap_fragment>
|
||||
#include <lights_phong_fragment>
|
||||
#include <lights_fragment_begin>
|
||||
#include <lights_fragment_maps>
|
||||
#include <lights_fragment_end>
|
||||
#include <aomap_fragment>
|
||||
vec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular + totalEmissiveRadiance;
|
||||
#include <envmap_fragment>
|
||||
|
||||
gl_FragColor = vec4( outgoingLight, diffuseColor.a );
|
||||
#include <tonemapping_fragment>
|
||||
#include <fog_fragment>
|
||||
#include <premultiplied_alpha_fragment>
|
||||
#include <dithering_fragment>
|
||||
|
||||
gl_FragColor.rgb = mix(
|
||||
shadowColor.rgb,
|
||||
diffuse.rgb,
|
||||
min( gl_FragColor.r, 1.0 )
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
`
|
||||
})
|
||||
|
||||
Object.defineProperties(this, {
|
||||
opacity: {
|
||||
set(v) {
|
||||
this.uniforms.opacity.value = v
|
||||
},
|
||||
|
||||
get() {
|
||||
return this.uniforms.opacity.value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.setValues(options)
|
||||
this.lights = true
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/*
|
||||
Taken from: https://github.com/gkjohnson/threejs-sandbox/tree/master/conditional-lines
|
||||
under MIT license
|
||||
*/
|
||||
import { BufferAttribute, BufferGeometry, Triangle, Vector3 } from 'three'
|
||||
|
||||
const vec0 = new Vector3()
|
||||
const vec1 = new Vector3()
|
||||
const vec2 = new Vector3()
|
||||
const vec3 = new Vector3()
|
||||
const vec4 = new Vector3()
|
||||
|
||||
const triangle0 = new Triangle()
|
||||
const triangle1 = new Triangle()
|
||||
const normal0 = new Vector3()
|
||||
const normal1 = new Vector3()
|
||||
export class ConditionalEdgesGeometry extends BufferGeometry {
|
||||
constructor(geometry) {
|
||||
super()
|
||||
|
||||
const edgeInfo = {}
|
||||
|
||||
const position = geometry.attributes.position
|
||||
let index
|
||||
if (geometry.index) {
|
||||
index = geometry.index
|
||||
} else {
|
||||
const arr = new Array(position.count / 3).fill().map((_, i) => i)
|
||||
index = new BufferAttribute(new Uint32Array(arr), 1, false)
|
||||
}
|
||||
|
||||
for (let i = 0, l = index.count; i < l; i += 3) {
|
||||
const indices = [index.getX(i + 0), index.getX(i + 1), index.getX(i + 2)]
|
||||
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const index0 = indices[j]
|
||||
const index1 = indices[(j + 1) % 3]
|
||||
|
||||
const hash = `${index0}_${index1}`
|
||||
const reverseHash = `${index1}_${index0}`
|
||||
if (reverseHash in edgeInfo) {
|
||||
edgeInfo[reverseHash].controlIndex1 = indices[(j + 2) % 3]
|
||||
edgeInfo[reverseHash].tri1 = i / 3
|
||||
} else {
|
||||
edgeInfo[hash] = {
|
||||
index0,
|
||||
index1,
|
||||
|
||||
controlIndex0: indices[(j + 2) % 3],
|
||||
controlIndex1: null,
|
||||
|
||||
tri0: i / 3,
|
||||
tri1: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const edgePositions = []
|
||||
const edgeDirections = []
|
||||
const edgeControl0 = []
|
||||
const edgeControl1 = []
|
||||
for (const key in edgeInfo) {
|
||||
const { index0, index1, controlIndex0, controlIndex1, tri0, tri1 } =
|
||||
edgeInfo[key]
|
||||
|
||||
if (controlIndex1 === null) {
|
||||
continue
|
||||
}
|
||||
|
||||
triangle0.a.fromBufferAttribute(position, index.getX(tri0 * 3 + 0))
|
||||
triangle0.b.fromBufferAttribute(position, index.getX(tri0 * 3 + 1))
|
||||
triangle0.c.fromBufferAttribute(position, index.getX(tri0 * 3 + 2))
|
||||
|
||||
triangle1.a.fromBufferAttribute(position, index.getX(tri1 * 3 + 0))
|
||||
triangle1.b.fromBufferAttribute(position, index.getX(tri1 * 3 + 1))
|
||||
triangle1.c.fromBufferAttribute(position, index.getX(tri1 * 3 + 2))
|
||||
|
||||
triangle0.getNormal(normal0).normalize()
|
||||
triangle1.getNormal(normal1).normalize()
|
||||
|
||||
if (normal0.dot(normal1) < 0.01) {
|
||||
continue
|
||||
}
|
||||
|
||||
// positions
|
||||
vec0.fromBufferAttribute(position, index0)
|
||||
vec1.fromBufferAttribute(position, index1)
|
||||
|
||||
// direction
|
||||
vec2.subVectors(vec0, vec1)
|
||||
|
||||
// control positions
|
||||
vec3.fromBufferAttribute(position, controlIndex0)
|
||||
vec4.fromBufferAttribute(position, controlIndex1)
|
||||
|
||||
// create arrays
|
||||
edgePositions.push(vec0.x, vec0.y, vec0.z)
|
||||
edgeDirections.push(vec2.x, vec2.y, vec2.z)
|
||||
edgeControl0.push(vec3.x, vec3.y, vec3.z)
|
||||
edgeControl1.push(vec4.x, vec4.y, vec4.z)
|
||||
|
||||
edgePositions.push(vec1.x, vec1.y, vec1.z)
|
||||
edgeDirections.push(vec2.x, vec2.y, vec2.z)
|
||||
edgeControl0.push(vec3.x, vec3.y, vec3.z)
|
||||
edgeControl1.push(vec4.x, vec4.y, vec4.z)
|
||||
}
|
||||
|
||||
this.setAttribute(
|
||||
'position',
|
||||
new BufferAttribute(new Float32Array(edgePositions), 3, false)
|
||||
)
|
||||
this.setAttribute(
|
||||
'direction',
|
||||
new BufferAttribute(new Float32Array(edgeDirections), 3, false)
|
||||
)
|
||||
this.setAttribute(
|
||||
'control0',
|
||||
new BufferAttribute(new Float32Array(edgeControl0), 3, false)
|
||||
)
|
||||
this.setAttribute(
|
||||
'control1',
|
||||
new BufferAttribute(new Float32Array(edgeControl1), 3, false)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/*
|
||||
Taken from: https://github.com/gkjohnson/threejs-sandbox/tree/master/conditional-lines
|
||||
under MIT license
|
||||
*/
|
||||
import { Color } from 'three'
|
||||
|
||||
export const ConditionalEdgesShader = {
|
||||
uniforms: {
|
||||
diffuse: {
|
||||
value: new Color()
|
||||
},
|
||||
|
||||
opacity: {
|
||||
value: 1.0
|
||||
}
|
||||
},
|
||||
|
||||
vertexShader: /* glsl */ `
|
||||
attribute vec3 control0;
|
||||
attribute vec3 control1;
|
||||
attribute vec3 direction;
|
||||
|
||||
#include <common>
|
||||
#include <color_pars_vertex>
|
||||
#include <fog_pars_vertex>
|
||||
#include <logdepthbuf_pars_vertex>
|
||||
#include <clipping_planes_pars_vertex>
|
||||
void main() {
|
||||
|
||||
#include <color_vertex>
|
||||
|
||||
vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
|
||||
// Transform the line segment ends and control points into camera clip space
|
||||
vec4 c0 = projectionMatrix * modelViewMatrix * vec4( control0, 1.0 );
|
||||
vec4 c1 = projectionMatrix * modelViewMatrix * vec4( control1, 1.0 );
|
||||
vec4 p0 = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
|
||||
vec4 p1 = projectionMatrix * modelViewMatrix * vec4( position + direction, 1.0 );
|
||||
|
||||
c0 /= c0.w;
|
||||
c1 /= c1.w;
|
||||
p0 /= p0.w;
|
||||
p1 /= p1.w;
|
||||
|
||||
// Get the direction of the segment and an orthogonal vector
|
||||
vec2 dir = p1.xy - p0.xy;
|
||||
vec2 norm = vec2( -dir.y, dir.x );
|
||||
|
||||
// Get control point directions from the line
|
||||
vec2 c0dir = c0.xy - p1.xy;
|
||||
vec2 c1dir = c1.xy - p1.xy;
|
||||
|
||||
// If the vectors to the controls points are pointed in different directions away
|
||||
// from the line segment then the line should not be drawn.
|
||||
float d0 = dot( normalize( norm ), normalize( c0dir ) );
|
||||
float d1 = dot( normalize( norm ), normalize( c1dir ) );
|
||||
float discardFlag = float( sign( d0 ) != sign( d1 ) );
|
||||
gl_Position = discardFlag > 0.5 ? c0 : gl_Position;
|
||||
|
||||
#include <logdepthbuf_vertex>
|
||||
#include <clipping_planes_vertex>
|
||||
#include <fog_vertex>
|
||||
|
||||
}
|
||||
`,
|
||||
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform vec3 diffuse;
|
||||
uniform float opacity;
|
||||
|
||||
#include <common>
|
||||
#include <color_pars_fragment>
|
||||
#include <fog_pars_fragment>
|
||||
#include <logdepthbuf_pars_fragment>
|
||||
#include <clipping_planes_pars_fragment>
|
||||
void main() {
|
||||
|
||||
#include <clipping_planes_fragment>
|
||||
|
||||
vec3 outgoingLight = vec3( 0.0 );
|
||||
vec4 diffuseColor = vec4( diffuse, opacity );
|
||||
|
||||
#include <logdepthbuf_fragment>
|
||||
#include <color_fragment>
|
||||
|
||||
outgoingLight = diffuseColor.rgb; // simple shader
|
||||
gl_FragColor = vec4( outgoingLight, diffuseColor.a );
|
||||
|
||||
#include <tonemapping_fragment>
|
||||
#include <fog_fragment>
|
||||
#include <premultiplied_alpha_fragment>
|
||||
|
||||
}
|
||||
`
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
/*
|
||||
Taken from: https://github.com/gkjohnson/threejs-sandbox/tree/master/conditional-lines
|
||||
under MIT license
|
||||
*/
|
||||
import { ShaderMaterial, UniformsLib, UniformsUtils, Vector2 } from 'three'
|
||||
import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial'
|
||||
|
||||
/**
|
||||
* parameters = {
|
||||
* color: <hex>,
|
||||
* linewidth: <float>,
|
||||
* dashed: <boolean>,
|
||||
* dashScale: <float>,
|
||||
* dashSize: <float>,
|
||||
* gapSize: <float>,
|
||||
* resolution: <Vector2>, // to be set by renderer
|
||||
* }
|
||||
*/
|
||||
|
||||
const uniforms = {
|
||||
linewidth: { value: 1 },
|
||||
resolution: { value: new Vector2(1, 1) },
|
||||
dashScale: { value: 1 },
|
||||
dashSize: { value: 1 },
|
||||
gapSize: { value: 1 }, // todo FIX - maybe change to totalSize
|
||||
opacity: { value: 1 }
|
||||
}
|
||||
|
||||
const shader = {
|
||||
uniforms: UniformsUtils.merge([
|
||||
UniformsLib.common,
|
||||
UniformsLib.fog,
|
||||
uniforms
|
||||
]),
|
||||
|
||||
vertexShader: /* glsl */ `
|
||||
#include <common>
|
||||
#include <color_pars_vertex>
|
||||
#include <fog_pars_vertex>
|
||||
#include <logdepthbuf_pars_vertex>
|
||||
#include <clipping_planes_pars_vertex>
|
||||
|
||||
uniform float linewidth;
|
||||
uniform vec2 resolution;
|
||||
|
||||
attribute vec3 control0;
|
||||
attribute vec3 control1;
|
||||
attribute vec3 direction;
|
||||
|
||||
attribute vec3 instanceStart;
|
||||
attribute vec3 instanceEnd;
|
||||
|
||||
attribute vec3 instanceColorStart;
|
||||
attribute vec3 instanceColorEnd;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
#ifdef USE_DASH
|
||||
|
||||
uniform float dashScale;
|
||||
attribute float instanceDistanceStart;
|
||||
attribute float instanceDistanceEnd;
|
||||
varying float vLineDistance;
|
||||
|
||||
#endif
|
||||
|
||||
void trimSegment( const in vec4 start, inout vec4 end ) {
|
||||
|
||||
// trim end segment so it terminates between the camera plane and the near plane
|
||||
|
||||
// conservative estimate of the near plane
|
||||
float a = projectionMatrix[ 2 ][ 2 ]; // 3nd entry in 3th column
|
||||
float b = projectionMatrix[ 3 ][ 2 ]; // 3nd entry in 4th column
|
||||
float nearEstimate = - 0.5 * b / a;
|
||||
|
||||
float alpha = ( nearEstimate - start.z ) / ( end.z - start.z );
|
||||
|
||||
end.xyz = mix( start.xyz, end.xyz, alpha );
|
||||
|
||||
}
|
||||
|
||||
void main() {
|
||||
|
||||
#ifdef USE_COLOR
|
||||
|
||||
vColor.xyz = ( position.y < 0.5 ) ? instanceColorStart : instanceColorEnd;
|
||||
|
||||
#endif
|
||||
|
||||
#ifdef USE_DASH
|
||||
|
||||
vLineDistance = ( position.y < 0.5 ) ? dashScale * instanceDistanceStart : dashScale * instanceDistanceEnd;
|
||||
|
||||
#endif
|
||||
|
||||
float aspect = resolution.x / resolution.y;
|
||||
|
||||
vUv = uv;
|
||||
|
||||
// camera space
|
||||
vec4 start = modelViewMatrix * vec4( instanceStart, 1.0 );
|
||||
vec4 end = modelViewMatrix * vec4( instanceEnd, 1.0 );
|
||||
|
||||
// special case for perspective projection, and segments that terminate either in, or behind, the camera plane
|
||||
// clearly the gpu firmware has a way of addressing this issue when projecting into ndc space
|
||||
// but we need to perform ndc-space calculations in the shader, so we must address this issue directly
|
||||
// perhaps there is a more elegant solution -- WestLangley
|
||||
|
||||
bool perspective = ( projectionMatrix[ 2 ][ 3 ] == - 1.0 ); // 4th entry in the 3rd column
|
||||
|
||||
if ( perspective ) {
|
||||
|
||||
if ( start.z < 0.0 && end.z >= 0.0 ) {
|
||||
|
||||
trimSegment( start, end );
|
||||
|
||||
} else if ( end.z < 0.0 && start.z >= 0.0 ) {
|
||||
|
||||
trimSegment( end, start );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// clip space
|
||||
vec4 clipStart = projectionMatrix * start;
|
||||
vec4 clipEnd = projectionMatrix * end;
|
||||
|
||||
// ndc space
|
||||
vec2 ndcStart = clipStart.xy / clipStart.w;
|
||||
vec2 ndcEnd = clipEnd.xy / clipEnd.w;
|
||||
|
||||
// direction
|
||||
vec2 dir = ndcEnd - ndcStart;
|
||||
|
||||
// account for clip-space aspect ratio
|
||||
dir.x *= aspect;
|
||||
dir = normalize( dir );
|
||||
|
||||
// perpendicular to dir
|
||||
vec2 offset = vec2( dir.y, - dir.x );
|
||||
|
||||
// undo aspect ratio adjustment
|
||||
dir.x /= aspect;
|
||||
offset.x /= aspect;
|
||||
|
||||
// sign flip
|
||||
if ( position.x < 0.0 ) offset *= - 1.0;
|
||||
|
||||
// endcaps
|
||||
if ( position.y < 0.0 ) {
|
||||
|
||||
offset += - dir;
|
||||
|
||||
} else if ( position.y > 1.0 ) {
|
||||
|
||||
offset += dir;
|
||||
|
||||
}
|
||||
|
||||
// adjust for linewidth
|
||||
offset *= linewidth;
|
||||
|
||||
// adjust for clip-space to screen-space conversion // maybe resolution should be based on viewport ...
|
||||
offset /= resolution.y;
|
||||
|
||||
// select end
|
||||
vec4 clip = ( position.y < 0.5 ) ? clipStart : clipEnd;
|
||||
|
||||
// back to clip space
|
||||
offset *= clip.w;
|
||||
|
||||
clip.xy += offset;
|
||||
|
||||
gl_Position = clip;
|
||||
|
||||
vec4 mvPosition = ( position.y < 0.5 ) ? start : end; // this is an approximation
|
||||
|
||||
#include <logdepthbuf_vertex>
|
||||
#include <clipping_planes_vertex>
|
||||
#include <fog_vertex>
|
||||
|
||||
// conditional logic
|
||||
// Transform the line segment ends and control points into camera clip space
|
||||
vec4 c0 = projectionMatrix * modelViewMatrix * vec4( control0, 1.0 );
|
||||
vec4 c1 = projectionMatrix * modelViewMatrix * vec4( control1, 1.0 );
|
||||
vec4 p0 = projectionMatrix * modelViewMatrix * vec4( instanceStart, 1.0 );
|
||||
vec4 p1 = projectionMatrix * modelViewMatrix * vec4( instanceStart + direction, 1.0 );
|
||||
|
||||
c0 /= c0.w;
|
||||
c1 /= c1.w;
|
||||
p0 /= p0.w;
|
||||
p1 /= p1.w;
|
||||
|
||||
// Get the direction of the segment and an orthogonal vector
|
||||
vec2 segDir = p1.xy - p0.xy;
|
||||
vec2 norm = vec2( - segDir.y, segDir.x );
|
||||
|
||||
// Get control point directions from the line
|
||||
vec2 c0dir = c0.xy - p1.xy;
|
||||
vec2 c1dir = c1.xy - p1.xy;
|
||||
|
||||
// If the vectors to the controls points are pointed in different directions away
|
||||
// from the line segment then the line should not be drawn.
|
||||
float d0 = dot( normalize( norm ), normalize( c0dir ) );
|
||||
float d1 = dot( normalize( norm ), normalize( c1dir ) );
|
||||
float discardFlag = float( sign( d0 ) != sign( d1 ) );
|
||||
gl_Position = discardFlag > 0.5 ? c0 : gl_Position;
|
||||
// end conditional line logic
|
||||
|
||||
}
|
||||
`,
|
||||
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform vec3 diffuse;
|
||||
uniform float opacity;
|
||||
|
||||
#ifdef USE_DASH
|
||||
|
||||
uniform float dashSize;
|
||||
uniform float gapSize;
|
||||
|
||||
#endif
|
||||
|
||||
varying float vLineDistance;
|
||||
|
||||
#include <common>
|
||||
#include <color_pars_fragment>
|
||||
#include <fog_pars_fragment>
|
||||
#include <logdepthbuf_pars_fragment>
|
||||
#include <clipping_planes_pars_fragment>
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
|
||||
#include <clipping_planes_fragment>
|
||||
|
||||
#ifdef USE_DASH
|
||||
|
||||
if ( vUv.y < - 1.0 || vUv.y > 1.0 ) discard; // discard endcaps
|
||||
|
||||
if ( mod( vLineDistance, dashSize + gapSize ) > dashSize ) discard; // todo - FIX
|
||||
|
||||
#endif
|
||||
|
||||
if ( abs( vUv.y ) > 1.0 ) {
|
||||
|
||||
float a = vUv.x;
|
||||
float b = ( vUv.y > 0.0 ) ? vUv.y - 1.0 : vUv.y + 1.0;
|
||||
float len2 = a * a + b * b;
|
||||
|
||||
if ( len2 > 1.0 ) discard;
|
||||
|
||||
}
|
||||
|
||||
vec4 diffuseColor = vec4( diffuse, opacity );
|
||||
|
||||
#include <logdepthbuf_fragment>
|
||||
#include <color_fragment>
|
||||
|
||||
gl_FragColor = vec4( diffuseColor.rgb, diffuseColor.a );
|
||||
|
||||
#include <tonemapping_fragment>
|
||||
#include <fog_fragment>
|
||||
#include <premultiplied_alpha_fragment>
|
||||
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
class ConditionalLineMaterial extends LineMaterial {
|
||||
constructor(parameters) {
|
||||
super({
|
||||
type: 'ConditionalLineMaterial',
|
||||
|
||||
uniforms: UniformsUtils.clone(shader.uniforms),
|
||||
|
||||
vertexShader: shader.vertexShader,
|
||||
fragmentShader: shader.fragmentShader,
|
||||
|
||||
clipping: true // required for clipping support
|
||||
})
|
||||
|
||||
this.dashed = false
|
||||
|
||||
Object.defineProperties(this, {
|
||||
color: {
|
||||
enumerable: true,
|
||||
|
||||
get: function () {
|
||||
return this.uniforms.diffuse.value
|
||||
},
|
||||
|
||||
set: function (value) {
|
||||
this.uniforms.diffuse.value = value
|
||||
}
|
||||
},
|
||||
|
||||
linewidth: {
|
||||
enumerable: true,
|
||||
|
||||
get: function () {
|
||||
return this.uniforms.linewidth.value
|
||||
},
|
||||
|
||||
set: function (value) {
|
||||
this.uniforms.linewidth.value = value
|
||||
}
|
||||
},
|
||||
|
||||
dashScale: {
|
||||
enumerable: true,
|
||||
|
||||
get: function () {
|
||||
return this.uniforms.dashScale.value
|
||||
},
|
||||
|
||||
set: function (value) {
|
||||
this.uniforms.dashScale.value = value
|
||||
}
|
||||
},
|
||||
|
||||
dashSize: {
|
||||
enumerable: true,
|
||||
|
||||
get: function () {
|
||||
return this.uniforms.dashSize.value
|
||||
},
|
||||
|
||||
set: function (value) {
|
||||
this.uniforms.dashSize.value = value
|
||||
}
|
||||
},
|
||||
|
||||
gapSize: {
|
||||
enumerable: true,
|
||||
|
||||
get: function () {
|
||||
return this.uniforms.gapSize.value
|
||||
},
|
||||
|
||||
set: function (value) {
|
||||
this.uniforms.gapSize.value = value
|
||||
}
|
||||
},
|
||||
|
||||
opacity: {
|
||||
enumerable: true,
|
||||
|
||||
get: function () {
|
||||
return this.uniforms.opacity.value
|
||||
},
|
||||
|
||||
set: function (value) {
|
||||
this.uniforms.opacity.value = value
|
||||
}
|
||||
},
|
||||
|
||||
resolution: {
|
||||
enumerable: true,
|
||||
|
||||
get: function () {
|
||||
return this.uniforms.resolution.value
|
||||
},
|
||||
|
||||
set: function (value) {
|
||||
this.uniforms.resolution.value.copy(value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.setValues(parameters)
|
||||
}
|
||||
}
|
||||
|
||||
ConditionalLineMaterial.prototype.isConditionalLineMaterial = true
|
||||
|
||||
export { ConditionalLineMaterial }
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
Taken from: https://github.com/gkjohnson/threejs-sandbox/tree/master/conditional-lines
|
||||
under MIT license
|
||||
*/
|
||||
import * as THREE from 'three'
|
||||
import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeometry.js'
|
||||
|
||||
export class ConditionalLineSegmentsGeometry extends LineSegmentsGeometry {
|
||||
fromConditionalEdgesGeometry(geometry) {
|
||||
super.fromEdgesGeometry(geometry)
|
||||
|
||||
const { direction, control0, control1 } = geometry.attributes
|
||||
|
||||
this.setAttribute(
|
||||
'direction',
|
||||
new THREE.InterleavedBufferAttribute(
|
||||
new THREE.InstancedInterleavedBuffer(direction.array, 6, 1),
|
||||
3,
|
||||
0
|
||||
)
|
||||
)
|
||||
|
||||
this.setAttribute(
|
||||
'control0',
|
||||
new THREE.InterleavedBufferAttribute(
|
||||
new THREE.InstancedInterleavedBuffer(control0.array, 6, 1),
|
||||
3,
|
||||
0
|
||||
)
|
||||
)
|
||||
|
||||
this.setAttribute(
|
||||
'control1',
|
||||
new THREE.InterleavedBufferAttribute(
|
||||
new THREE.InstancedInterleavedBuffer(control1.array, 6, 1),
|
||||
3,
|
||||
0
|
||||
)
|
||||
)
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
||||
@@ -10,16 +10,7 @@ import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
export type Load3DNodeType = 'Load3D' | 'Preview3D'
|
||||
|
||||
export type Load3DAnimationNodeType = 'Load3DAnimation' | 'Preview3DAnimation'
|
||||
|
||||
export type MaterialMode =
|
||||
| 'original'
|
||||
| 'normal'
|
||||
| 'wireframe'
|
||||
| 'depth'
|
||||
| 'lineart'
|
||||
export type MaterialMode = 'original' | 'normal' | 'wireframe' | 'depth'
|
||||
export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
|
||||
export type CameraType = 'perspective' | 'orthographic'
|
||||
|
||||
@@ -30,6 +21,27 @@ export interface CameraState {
|
||||
cameraType: CameraType
|
||||
}
|
||||
|
||||
export interface SceneConfig {
|
||||
showGrid: boolean
|
||||
backgroundColor: string
|
||||
backgroundImage?: string
|
||||
}
|
||||
|
||||
export interface ModelConfig {
|
||||
upDirection: UpDirection
|
||||
materialMode: MaterialMode
|
||||
}
|
||||
|
||||
export interface CameraConfig {
|
||||
cameraType: CameraType
|
||||
fov: number
|
||||
state?: CameraState
|
||||
}
|
||||
|
||||
export interface LightConfig {
|
||||
intensity: number
|
||||
}
|
||||
|
||||
export interface EventCallback {
|
||||
(data?: any): void
|
||||
}
|
||||
@@ -45,7 +57,6 @@ export interface CaptureResult {
|
||||
scene: string
|
||||
mask: string
|
||||
normal: string
|
||||
lineart: string
|
||||
}
|
||||
|
||||
interface BaseManager {
|
||||
@@ -101,26 +112,6 @@ export interface ViewHelperManagerInterface extends BaseManager {
|
||||
handleResize(): void
|
||||
}
|
||||
|
||||
export interface PreviewManagerInterface extends BaseManager {
|
||||
previewCamera: THREE.Camera
|
||||
previewContainer: HTMLDivElement
|
||||
showPreview: boolean
|
||||
previewWidth: number
|
||||
createCapturePreview(container: Element | HTMLElement): void
|
||||
updatePreviewSize(): void
|
||||
togglePreview(showPreview: boolean): void
|
||||
setTargetSize(width: number, height: number): void
|
||||
handleResize(): void
|
||||
updateBackgroundTexture(texture: THREE.Texture | null): void
|
||||
getPreviewViewport(): {
|
||||
left: number
|
||||
bottom: number
|
||||
width: number
|
||||
height: number
|
||||
} | null
|
||||
renderPreview(): void
|
||||
}
|
||||
|
||||
export interface EventManagerInterface {
|
||||
addEventListener(event: string, callback: EventCallback): void
|
||||
removeEventListener(event: string, callback: EventCallback): void
|
||||
@@ -185,3 +176,11 @@ export interface LoaderManagerInterface {
|
||||
dispose(): void
|
||||
loadModel(url: string, originalFileName?: string): Promise<void>
|
||||
}
|
||||
|
||||
export const SUPPORTED_EXTENSIONS = new Set([
|
||||
'.gltf',
|
||||
'.glb',
|
||||
'.obj',
|
||||
'.fbx',
|
||||
'.stl'
|
||||
])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import Load3D from '@/components/load3d/Load3D.vue'
|
||||
import { useLoad3d } from '@/composables/useLoad3d'
|
||||
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
@@ -10,6 +11,12 @@ import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
|
||||
const inputSpec: CustomInputSpec = {
|
||||
name: 'image',
|
||||
type: 'Preview3D',
|
||||
isPreview: true
|
||||
}
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.SaveGLB',
|
||||
|
||||
@@ -23,13 +30,6 @@ useExtensionService().registerExtension({
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
PREVIEW_3D(node) {
|
||||
const inputSpec: CustomInputSpec = {
|
||||
name: 'image',
|
||||
type: 'Preview3D',
|
||||
isAnimation: false,
|
||||
isPreview: true
|
||||
}
|
||||
|
||||
const widget = new ComponentWidgetImpl({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
@@ -38,6 +38,8 @@ useExtensionService().registerExtension({
|
||||
options: {}
|
||||
})
|
||||
|
||||
widget.type = 'load3D'
|
||||
|
||||
addWidget(node, widget)
|
||||
|
||||
return { widget }
|
||||
@@ -71,19 +73,19 @@ useExtensionService().registerExtension({
|
||||
|
||||
const fileInfo = message['3d'][0]
|
||||
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (load3d && modelWidget) {
|
||||
const filePath = fileInfo['subfolder'] + '/' + fileInfo['filename']
|
||||
|
||||
if (load3d && modelWidget) {
|
||||
const filePath = fileInfo['subfolder'] + '/' + fileInfo['filename']
|
||||
modelWidget.value = filePath
|
||||
|
||||
modelWidget.value = filePath
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
|
||||
config.configureForSaveMesh(fileInfo['type'], filePath)
|
||||
}
|
||||
config.configureForSaveMesh(fileInfo['type'], filePath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1045,7 +1045,7 @@
|
||||
"UV": "UV",
|
||||
"ContextMenu": "Context Menu",
|
||||
"Reroute": "Reroute",
|
||||
"Load 3D": "Load 3D",
|
||||
"Load 3D": "Load 3D & Animation",
|
||||
"Camera": "Camera",
|
||||
"Scene": "Scene",
|
||||
"3D": "3D",
|
||||
@@ -1428,18 +1428,17 @@
|
||||
"camera": "Camera",
|
||||
"light": "Light",
|
||||
"switchingMaterialMode": "Switching Material Mode...",
|
||||
"edgeThreshold": "Edge Threshold",
|
||||
"export": "Export",
|
||||
"exportModel": "Export Model",
|
||||
"exportingModel": "Exporting model...",
|
||||
"reloadingModel": "Reloading model...",
|
||||
"uploadTexture": "Upload Texture",
|
||||
"applyingTexture": "Applying Texture...",
|
||||
"materialModes": {
|
||||
"normal": "Normal",
|
||||
"wireframe": "Wireframe",
|
||||
"original": "Original",
|
||||
"depth": "Depth",
|
||||
"lineart": "Lineart"
|
||||
"depth": "Depth"
|
||||
},
|
||||
"upDirections": {
|
||||
"original": "Original"
|
||||
@@ -1465,7 +1464,10 @@
|
||||
"exportSettings": "Export Settings",
|
||||
"modelSettings": "Model Settings"
|
||||
},
|
||||
"openIn3DViewer": "Open in 3D Viewer"
|
||||
"openIn3DViewer": "Open in 3D Viewer",
|
||||
"dropToLoad": "Drop 3D model to load",
|
||||
"unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl)",
|
||||
"uploadingModel": "Uploading 3D model..."
|
||||
},
|
||||
"toastMessages": {
|
||||
"nothingToQueue": "Nothing to queue",
|
||||
@@ -1507,7 +1509,10 @@
|
||||
"nothingSelected": "Nothing selected",
|
||||
"cannotCreateSubgraph": "Cannot create subgraph",
|
||||
"failedToConvertToSubgraph": "Failed to convert items to subgraph",
|
||||
"failedToInitializeLoad3dViewer": "Failed to initialize 3D Viewer"
|
||||
"failedToInitializeLoad3dViewer": "Failed to initialize 3D Viewer",
|
||||
"failedToLoadBackgroundImage": "Failed to load background image",
|
||||
"failedToLoadModel": "Failed to load 3D model",
|
||||
"modelLoadedSuccessfully": "3D model loaded successfully"
|
||||
},
|
||||
"auth": {
|
||||
"apiKey": {
|
||||
|
||||
@@ -47,8 +47,8 @@ export function useLayoutSync() {
|
||||
liteNode.size[0] !== layout.size.width ||
|
||||
liteNode.size[1] !== layout.size.height
|
||||
) {
|
||||
liteNode.size[0] = layout.size.width
|
||||
liteNode.size[1] = layout.size.height
|
||||
// Use setSize() to trigger onResize callback
|
||||
liteNode.setSize([layout.size.width, layout.size.height])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import Load3D from '@/components/load3d/Load3D.vue'
|
||||
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
|
||||
|
||||
import WidgetAudioUI from '../components/WidgetAudioUI.vue'
|
||||
@@ -131,7 +132,8 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
aliases: ['AUDIOUI', 'AUDIO_UI'],
|
||||
essential: false
|
||||
}
|
||||
]
|
||||
],
|
||||
['load3D', { component: Load3D, aliases: ['LOAD_3D'], essential: false }]
|
||||
]
|
||||
|
||||
const getComboWidgetAdditions = (): Map<string, Component> => {
|
||||
|
||||
@@ -482,7 +482,6 @@ const zSettings = z.object({
|
||||
'Comfy.MaskEditor.BrushAdjustmentSpeed': z.number(),
|
||||
'Comfy.MaskEditor.UseDominantAxis': z.boolean(),
|
||||
'Comfy.Load3D.ShowGrid': z.boolean(),
|
||||
'Comfy.Load3D.ShowPreview': z.boolean(),
|
||||
'Comfy.Load3D.BackgroundColor': z.string(),
|
||||
'Comfy.Load3D.LightIntensity': z.number(),
|
||||
'Comfy.Load3D.LightIntensityMaximum': z.number(),
|
||||
|
||||
@@ -1112,6 +1112,8 @@ export class ComfyApp {
|
||||
if (n.type == 'ConditioningAverage ') n.type = 'ConditioningAverage' //typo fix
|
||||
if (n.type == 'SDV_img2vid_Conditioning')
|
||||
n.type = 'SVD_img2vid_Conditioning' //typo fix
|
||||
if (n.type == 'Load3DAnimation') n.type = 'Load3D' // Animation node merged into Load3D
|
||||
if (n.type == 'Preview3DAnimation') n.type = 'Preview3D' // Animation node merged into Load3D
|
||||
|
||||
// Find missing node types
|
||||
if (!(n.type in LiteGraph.registered_node_types)) {
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { toRaw } from 'vue'
|
||||
|
||||
import { nodeToLoad3dMap } from '@/composables/useLoad3d'
|
||||
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
type Load3dReadyCallback = (load3d: Load3d | Load3dAnimation) => void
|
||||
|
||||
const viewerInstances = new Map<NodeId, any>()
|
||||
|
||||
export class Load3dService {
|
||||
private static instance: Load3dService
|
||||
private nodeToLoad3dMap = new Map<LGraphNode, Load3d | Load3dAnimation>()
|
||||
private pendingCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -25,84 +20,14 @@ export class Load3dService {
|
||||
return Load3dService.instance
|
||||
}
|
||||
|
||||
registerLoad3d(
|
||||
node: LGraphNode,
|
||||
container: HTMLElement,
|
||||
inputSpec: CustomInputSpec
|
||||
) {
|
||||
getLoad3d(node: LGraphNode): Load3d | null {
|
||||
const rawNode = toRaw(node)
|
||||
|
||||
if (this.nodeToLoad3dMap.has(rawNode)) {
|
||||
this.removeLoad3d(rawNode)
|
||||
}
|
||||
|
||||
const type = inputSpec.type
|
||||
|
||||
const isAnimation = type.includes('Animation')
|
||||
|
||||
const Load3dClass = isAnimation ? Load3dAnimation : Load3d
|
||||
|
||||
const instance = new Load3dClass(container, {
|
||||
node: rawNode,
|
||||
inputSpec: inputSpec
|
||||
})
|
||||
|
||||
rawNode.onMouseEnter = function () {
|
||||
instance.refreshViewport()
|
||||
|
||||
instance.updateStatusMouseOnNode(true)
|
||||
}
|
||||
|
||||
rawNode.onMouseLeave = function () {
|
||||
instance.updateStatusMouseOnNode(false)
|
||||
}
|
||||
|
||||
rawNode.onResize = function () {
|
||||
instance.handleResize()
|
||||
}
|
||||
|
||||
rawNode.onDrawBackground = function () {
|
||||
instance.renderer.domElement.hidden = this.flags.collapsed ?? false
|
||||
}
|
||||
|
||||
this.nodeToLoad3dMap.set(rawNode, instance)
|
||||
|
||||
const callbacks = this.pendingCallbacks.get(rawNode)
|
||||
|
||||
if (callbacks) {
|
||||
callbacks.forEach((callback) => callback(instance))
|
||||
this.pendingCallbacks.delete(rawNode)
|
||||
}
|
||||
|
||||
return instance
|
||||
return nodeToLoad3dMap.get(rawNode) || null
|
||||
}
|
||||
|
||||
getLoad3d(node: LGraphNode): Load3d | Load3dAnimation | null {
|
||||
const rawNode = toRaw(node)
|
||||
|
||||
return this.nodeToLoad3dMap.get(rawNode) || null
|
||||
}
|
||||
|
||||
waitForLoad3d(node: LGraphNode, callback: Load3dReadyCallback): void {
|
||||
const rawNode = toRaw(node)
|
||||
|
||||
const existingInstance = this.nodeToLoad3dMap.get(rawNode)
|
||||
|
||||
if (existingInstance) {
|
||||
callback(existingInstance)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.pendingCallbacks.has(rawNode)) {
|
||||
this.pendingCallbacks.set(rawNode, [])
|
||||
}
|
||||
|
||||
this.pendingCallbacks.get(rawNode)!.push(callback)
|
||||
}
|
||||
|
||||
getNodeByLoad3d(load3d: Load3d | Load3dAnimation): LGraphNode | null {
|
||||
for (const [node, instance] of this.nodeToLoad3dMap) {
|
||||
getNodeByLoad3d(load3d: Load3d): LGraphNode | null {
|
||||
for (const [node, instance] of nodeToLoad3dMap) {
|
||||
if (instance === load3d) {
|
||||
return node
|
||||
}
|
||||
@@ -113,22 +38,19 @@ export class Load3dService {
|
||||
removeLoad3d(node: LGraphNode) {
|
||||
const rawNode = toRaw(node)
|
||||
|
||||
const instance = this.nodeToLoad3dMap.get(rawNode)
|
||||
const instance = nodeToLoad3dMap.get(rawNode)
|
||||
|
||||
if (instance) {
|
||||
instance.remove()
|
||||
|
||||
this.nodeToLoad3dMap.delete(rawNode)
|
||||
nodeToLoad3dMap.delete(rawNode)
|
||||
}
|
||||
|
||||
this.pendingCallbacks.delete(rawNode)
|
||||
}
|
||||
|
||||
clear() {
|
||||
for (const [node] of this.nodeToLoad3dMap) {
|
||||
for (const [node] of nodeToLoad3dMap) {
|
||||
this.removeLoad3d(node)
|
||||
}
|
||||
this.pendingCallbacks.clear()
|
||||
}
|
||||
|
||||
getOrCreateViewer(node: LGraphNode) {
|
||||
@@ -149,7 +71,7 @@ export class Load3dService {
|
||||
viewerInstances.delete(node.id)
|
||||
}
|
||||
|
||||
async copyLoad3dState(source: Load3d, target: Load3d | Load3dAnimation) {
|
||||
async copyLoad3dState(source: Load3d, target: Load3d) {
|
||||
const sourceModel = source.modelManager.currentModel
|
||||
|
||||
if (sourceModel) {
|
||||
@@ -188,12 +110,13 @@ export class Load3dService {
|
||||
.getCurrentBackgroundInfo()
|
||||
if (sourceBackgroundInfo.type === 'image') {
|
||||
const sourceNode = this.getNodeByLoad3d(source)
|
||||
const backgroundPath = sourceNode?.properties?.[
|
||||
'Background Image'
|
||||
] as string
|
||||
const sceneConfig = sourceNode?.properties?.['Scene Config'] as any
|
||||
const backgroundPath = sceneConfig?.backgroundImage
|
||||
if (backgroundPath) {
|
||||
await target.setBackgroundImage(backgroundPath)
|
||||
}
|
||||
} else {
|
||||
await target.setBackgroundImage('')
|
||||
}
|
||||
|
||||
target.setLightIntensity(
|
||||
@@ -203,11 +126,6 @@ export class Load3dService {
|
||||
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) {
|
||||
@@ -230,6 +148,11 @@ export class Load3dService {
|
||||
|
||||
if (viewer.needApplyChanges.value) {
|
||||
await viewer.applyChanges()
|
||||
|
||||
// Sync configuration back to the node's UI
|
||||
if ((node as any).syncLoad3dConfig) {
|
||||
;(node as any).syncLoad3dConfig()
|
||||
}
|
||||
}
|
||||
|
||||
useLoad3dService().removeViewer(node)
|
||||
|
||||
880
tests-ui/tests/composables/useLoad3d.test.ts
Normal file
880
tests-ui/tests/composables/useLoad3d.test.ts
Normal file
@@ -0,0 +1,880 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3d', () => ({
|
||||
default: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
|
||||
default: {
|
||||
splitFilePath: vi.fn(),
|
||||
getResourceURL: vi.fn(),
|
||||
uploadFile: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: vi.fn((key) => key)
|
||||
}))
|
||||
|
||||
describe('useLoad3d', () => {
|
||||
let mockLoad3d: any
|
||||
let mockNode: any
|
||||
let mockToastStore: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
nodeToLoad3dMap.clear()
|
||||
|
||||
mockNode = {
|
||||
properties: {
|
||||
'Scene Config': {
|
||||
showGrid: true,
|
||||
backgroundColor: '#000000',
|
||||
backgroundImage: ''
|
||||
},
|
||||
'Model Config': {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
},
|
||||
'Camera Config': {
|
||||
cameraType: 'perspective',
|
||||
fov: 75,
|
||||
state: null
|
||||
},
|
||||
'Light Config': {
|
||||
intensity: 5
|
||||
},
|
||||
'Resource Folder': ''
|
||||
},
|
||||
widgets: [
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 }
|
||||
],
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
},
|
||||
flags: {},
|
||||
onMouseEnter: null,
|
||||
onMouseLeave: null,
|
||||
onResize: null,
|
||||
onDrawBackground: null
|
||||
}
|
||||
|
||||
mockLoad3d = {
|
||||
toggleGrid: vi.fn(),
|
||||
setBackgroundColor: vi.fn(),
|
||||
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
|
||||
setUpDirection: vi.fn(),
|
||||
setMaterialMode: vi.fn(),
|
||||
toggleCamera: vi.fn(),
|
||||
setFOV: vi.fn(),
|
||||
setLightIntensity: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
loadModel: vi.fn().mockResolvedValue(undefined),
|
||||
refreshViewport: vi.fn(),
|
||||
updateStatusMouseOnNode: vi.fn(),
|
||||
updateStatusMouseOnScene: vi.fn(),
|
||||
handleResize: vi.fn(),
|
||||
toggleAnimation: vi.fn(),
|
||||
setAnimationSpeed: vi.fn(),
|
||||
updateSelectedAnimation: vi.fn(),
|
||||
startRecording: vi.fn().mockResolvedValue(undefined),
|
||||
stopRecording: vi.fn(),
|
||||
getRecordingDuration: vi.fn().mockReturnValue(10),
|
||||
exportRecording: vi.fn(),
|
||||
clearRecording: vi.fn(),
|
||||
exportModel: vi.fn().mockResolvedValue(undefined),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
renderer: {
|
||||
domElement: {
|
||||
hidden: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vi.mocked(Load3d).mockImplementation(() => mockLoad3d)
|
||||
|
||||
mockToastStore = {
|
||||
addAlert: vi.fn()
|
||||
}
|
||||
vi.mocked(useToastStore).mockReturnValue(mockToastStore)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
expect(composable.sceneConfig.value).toEqual({
|
||||
showGrid: true,
|
||||
backgroundColor: '#000000',
|
||||
backgroundImage: ''
|
||||
})
|
||||
expect(composable.modelConfig.value).toEqual({
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
})
|
||||
expect(composable.cameraConfig.value).toEqual({
|
||||
cameraType: 'perspective',
|
||||
fov: 75
|
||||
})
|
||||
expect(composable.lightConfig.value).toEqual({
|
||||
intensity: 5
|
||||
})
|
||||
expect(composable.isRecording.value).toBe(false)
|
||||
expect(composable.hasRecording.value).toBe(false)
|
||||
expect(composable.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should initialize Load3d with container and node', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(Load3d).toHaveBeenCalledWith(containerRef, {
|
||||
node: mockNode
|
||||
})
|
||||
expect(nodeToLoad3dMap.has(mockNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('should restore configurations from node', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(true)
|
||||
expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#000000')
|
||||
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('original')
|
||||
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('original')
|
||||
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('perspective')
|
||||
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(75)
|
||||
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(5)
|
||||
})
|
||||
|
||||
it('should set up node event handlers', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(mockNode.onMouseEnter).toBeDefined()
|
||||
expect(mockNode.onMouseLeave).toBeDefined()
|
||||
expect(mockNode.onResize).toBeDefined()
|
||||
expect(mockNode.onDrawBackground).toBeDefined()
|
||||
|
||||
// Test the handlers
|
||||
mockNode.onMouseEnter()
|
||||
expect(mockLoad3d.refreshViewport).toHaveBeenCalled()
|
||||
expect(mockLoad3d.updateStatusMouseOnNode).toHaveBeenCalledWith(true)
|
||||
|
||||
mockNode.onMouseLeave()
|
||||
expect(mockLoad3d.updateStatusMouseOnNode).toHaveBeenCalledWith(false)
|
||||
|
||||
mockNode.onResize()
|
||||
expect(mockLoad3d.handleResize).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle collapsed state', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
mockNode.flags.collapsed = true
|
||||
mockNode.onDrawBackground()
|
||||
|
||||
expect(mockLoad3d.renderer.domElement.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it('should load model if model_file widget exists', async () => {
|
||||
mockNode.widgets.push({ name: 'model_file', value: 'test.glb' })
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'subfolder',
|
||||
'test.glb'
|
||||
])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/api/view/test.glb'
|
||||
)
|
||||
vi.mocked(api.apiURL).mockReturnValue(
|
||||
'http://localhost/api/view/test.glb'
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
|
||||
'http://localhost/api/view/test.glb'
|
||||
)
|
||||
})
|
||||
|
||||
it('should restore camera state after loading model', async () => {
|
||||
mockNode.widgets.push({ name: 'model_file', value: 'test.glb' })
|
||||
mockNode.properties['Camera Config'].state = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
target: { x: 0, y: 0, z: 0 }
|
||||
}
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'subfolder',
|
||||
'test.glb'
|
||||
])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/api/view/test.glb'
|
||||
)
|
||||
vi.mocked(api.apiURL).mockReturnValue(
|
||||
'http://localhost/api/view/test.glb'
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setCameraState).toHaveBeenCalledWith({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
target: { x: 0, y: 0, z: 0 }
|
||||
})
|
||||
})
|
||||
|
||||
it('should set preview mode when no width/height widgets', async () => {
|
||||
mockNode.widgets = []
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(composable.isPreview.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle initialization errors', async () => {
|
||||
vi.mocked(Load3d).mockImplementationOnce(() => {
|
||||
throw new Error('Load3d creation failed')
|
||||
})
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToInitializeLoad3d'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle missing container or node', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
await composable.initializeLoad3d(null as any)
|
||||
|
||||
expect(Load3d).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should accept ref as parameter', () => {
|
||||
const nodeRef = ref(mockNode)
|
||||
const composable = useLoad3d(nodeRef)
|
||||
|
||||
expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForLoad3d', () => {
|
||||
it('should execute callback immediately if Load3d exists', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
const callback = vi.fn()
|
||||
composable.waitForLoad3d(callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(mockLoad3d)
|
||||
})
|
||||
|
||||
it('should queue callback if Load3d does not exist', () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const callback = vi.fn()
|
||||
|
||||
composable.waitForLoad3d(callback)
|
||||
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should execute queued callbacks after initialization', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const callback1 = vi.fn()
|
||||
const callback2 = vi.fn()
|
||||
|
||||
composable.waitForLoad3d(callback1)
|
||||
composable.waitForLoad3d(callback2)
|
||||
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(callback1).toHaveBeenCalledWith(mockLoad3d)
|
||||
expect(callback2).toHaveBeenCalledWith(mockLoad3d)
|
||||
})
|
||||
})
|
||||
|
||||
describe('configuration watchers', () => {
|
||||
it('should update scene config when values change', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
mockLoad3d.toggleGrid.mockClear()
|
||||
mockLoad3d.setBackgroundColor.mockClear()
|
||||
mockLoad3d.setBackgroundImage.mockClear()
|
||||
|
||||
composable.sceneConfig.value = {
|
||||
showGrid: false,
|
||||
backgroundColor: '#ffffff',
|
||||
backgroundImage: 'test.jpg'
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(false)
|
||||
expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#ffffff')
|
||||
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('test.jpg')
|
||||
expect(mockNode.properties['Scene Config']).toEqual({
|
||||
showGrid: false,
|
||||
backgroundColor: '#ffffff',
|
||||
backgroundImage: 'test.jpg'
|
||||
})
|
||||
})
|
||||
|
||||
it('should update model config when values change', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.modelConfig.value.upDirection = '+y'
|
||||
composable.modelConfig.value.materialMode = 'wireframe'
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y')
|
||||
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
|
||||
expect(mockNode.properties['Model Config']).toEqual({
|
||||
upDirection: '+y',
|
||||
materialMode: 'wireframe'
|
||||
})
|
||||
})
|
||||
|
||||
it('should update camera config when values change', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.cameraConfig.value.cameraType = 'orthographic'
|
||||
composable.cameraConfig.value.fov = 90
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
|
||||
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
|
||||
expect(mockNode.properties['Camera Config']).toEqual({
|
||||
cameraType: 'orthographic',
|
||||
fov: 90,
|
||||
state: null
|
||||
})
|
||||
})
|
||||
|
||||
it('should update light config when values change', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.lightConfig.value.intensity = 10
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(10)
|
||||
expect(mockNode.properties['Light Config']).toEqual({
|
||||
intensity: 10
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('animation controls', () => {
|
||||
it('should toggle animation playback', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.playing.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.toggleAnimation).toHaveBeenCalledWith(true)
|
||||
|
||||
composable.playing.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.toggleAnimation).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should update animation speed', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.selectedSpeed.value = 2
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setAnimationSpeed).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('should update selected animation', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.selectedAnimation.value = 1
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.updateSelectedAnimation).toHaveBeenCalledWith(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recording controls', () => {
|
||||
it('should start recording', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
await composable.handleStartRecording()
|
||||
|
||||
expect(mockLoad3d.startRecording).toHaveBeenCalled()
|
||||
expect(composable.isRecording.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should stop recording', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.handleStopRecording()
|
||||
|
||||
expect(mockLoad3d.stopRecording).toHaveBeenCalled()
|
||||
expect(composable.isRecording.value).toBe(false)
|
||||
expect(composable.recordingDuration.value).toBe(10)
|
||||
expect(composable.hasRecording.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should export recording with timestamp', async () => {
|
||||
const dateSpy = vi
|
||||
.spyOn(Date.prototype, 'toISOString')
|
||||
.mockReturnValue('2024-01-01T12:00:00.000Z')
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.handleExportRecording()
|
||||
|
||||
expect(mockLoad3d.exportRecording).toHaveBeenCalledWith(
|
||||
'2024-01-01T12-00-00-000Z-scene-recording.mp4'
|
||||
)
|
||||
|
||||
dateSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should clear recording', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.hasRecording.value = true
|
||||
composable.recordingDuration.value = 10
|
||||
|
||||
composable.handleClearRecording()
|
||||
|
||||
expect(mockLoad3d.clearRecording).toHaveBeenCalled()
|
||||
expect(composable.hasRecording.value).toBe(false)
|
||||
expect(composable.recordingDuration.value).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('background image handling', () => {
|
||||
it('should upload and set background image', async () => {
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
|
||||
await composable.handleBackgroundImageUpdate(file)
|
||||
|
||||
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
|
||||
expect(composable.sceneConfig.value.backgroundImage).toBe(
|
||||
'uploaded-image.jpg'
|
||||
)
|
||||
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith(
|
||||
'uploaded-image.jpg'
|
||||
)
|
||||
})
|
||||
|
||||
it('should use resource folder for upload', async () => {
|
||||
mockNode.properties['Resource Folder'] = 'subfolder'
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
|
||||
await composable.handleBackgroundImageUpdate(file)
|
||||
|
||||
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d/subfolder')
|
||||
})
|
||||
|
||||
it('should clear background image when file is null', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.sceneConfig.value.backgroundImage = 'existing.jpg'
|
||||
|
||||
await composable.handleBackgroundImageUpdate(null)
|
||||
|
||||
expect(composable.sceneConfig.value.backgroundImage).toBe('')
|
||||
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('model export', () => {
|
||||
it('should export model successfully', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
await composable.handleExportModel('glb')
|
||||
|
||||
expect(mockLoad3d.exportModel).toHaveBeenCalledWith('glb')
|
||||
})
|
||||
|
||||
it('should show alert when no Load3d instance', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
await composable.handleExportModel('glb')
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.no3dSceneToExport'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle export errors', async () => {
|
||||
mockLoad3d.exportModel.mockRejectedValueOnce(new Error('Export failed'))
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
await composable.handleExportModel('glb')
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToExportModel'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mouse interactions', () => {
|
||||
it('should handle mouse enter on scene', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.handleMouseEnter()
|
||||
|
||||
expect(mockLoad3d.updateStatusMouseOnScene).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should handle mouse leave on scene', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.handleMouseLeave()
|
||||
|
||||
expect(mockLoad3d.updateStatusMouseOnScene).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('event handling', () => {
|
||||
it('should add event listeners on initialization', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
const expectedEvents = [
|
||||
'materialModeChange',
|
||||
'backgroundColorChange',
|
||||
'lightIntensityChange',
|
||||
'fovChange',
|
||||
'cameraTypeChange',
|
||||
'showGridChange',
|
||||
'upDirectionChange',
|
||||
'backgroundImageChange',
|
||||
'backgroundImageLoadingStart',
|
||||
'backgroundImageLoadingEnd',
|
||||
'modelLoadingStart',
|
||||
'modelLoadingEnd',
|
||||
'exportLoadingStart',
|
||||
'exportLoadingEnd',
|
||||
'recordingStatusChange',
|
||||
'animationListChange'
|
||||
]
|
||||
|
||||
expectedEvents.forEach((event) => {
|
||||
expect(mockLoad3d.addEventListener).toHaveBeenCalledWith(
|
||||
event,
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle materialModeChange event', async () => {
|
||||
let materialModeHandler: any
|
||||
|
||||
mockLoad3d.addEventListener.mockImplementation(
|
||||
(event: string, handler: any) => {
|
||||
if (event === 'materialModeChange') {
|
||||
materialModeHandler = handler
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
materialModeHandler('wireframe')
|
||||
|
||||
expect(composable.modelConfig.value.materialMode).toBe('wireframe')
|
||||
})
|
||||
|
||||
it('should handle loading events', async () => {
|
||||
let modelLoadingStartHandler: any
|
||||
let modelLoadingEndHandler: any
|
||||
|
||||
mockLoad3d.addEventListener.mockImplementation(
|
||||
(event: string, handler: any) => {
|
||||
if (event === 'modelLoadingStart') {
|
||||
modelLoadingStartHandler = handler
|
||||
} else if (event === 'modelLoadingEnd') {
|
||||
modelLoadingEndHandler = handler
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
modelLoadingStartHandler()
|
||||
expect(composable.loading.value).toBe(true)
|
||||
expect(composable.loadingMessage.value).toBe('load3d.loadingModel')
|
||||
|
||||
modelLoadingEndHandler()
|
||||
expect(composable.loading.value).toBe(false)
|
||||
expect(composable.loadingMessage.value).toBe('')
|
||||
})
|
||||
|
||||
it('should handle recordingStatusChange event', async () => {
|
||||
let recordingStatusHandler: any
|
||||
|
||||
mockLoad3d.addEventListener.mockImplementation(
|
||||
(event: string, handler: any) => {
|
||||
if (event === 'recordingStatusChange') {
|
||||
recordingStatusHandler = handler
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
recordingStatusHandler(false)
|
||||
|
||||
expect(composable.isRecording.value).toBe(false)
|
||||
expect(composable.recordingDuration.value).toBe(10)
|
||||
expect(composable.hasRecording.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should remove event listeners and clean up resources', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.cleanup()
|
||||
|
||||
expect(mockLoad3d.removeEventListener).toHaveBeenCalled()
|
||||
expect(mockLoad3d.remove).toHaveBeenCalled()
|
||||
expect(nodeToLoad3dMap.has(mockNode)).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle cleanup when not initialized', () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
expect(() => composable.cleanup()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getModelUrl', () => {
|
||||
it('should handle http URLs directly', async () => {
|
||||
mockNode.widgets.push({
|
||||
name: 'model_file',
|
||||
value: 'http://example.com/model.glb'
|
||||
})
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
|
||||
'http://example.com/model.glb'
|
||||
)
|
||||
})
|
||||
|
||||
it('should construct URL for local files', async () => {
|
||||
mockNode.widgets.push({ name: 'model_file', value: 'models/test.glb' })
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'models',
|
||||
'test.glb'
|
||||
])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/api/view/models/test.glb'
|
||||
)
|
||||
vi.mocked(api.apiURL).mockReturnValue(
|
||||
'http://localhost/api/view/models/test.glb'
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(Load3dUtils.splitFilePath).toHaveBeenCalledWith('models/test.glb')
|
||||
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
|
||||
'models',
|
||||
'test.glb',
|
||||
'input'
|
||||
)
|
||||
expect(api.apiURL).toHaveBeenCalledWith('/api/view/models/test.glb')
|
||||
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
|
||||
'http://localhost/api/view/models/test.glb'
|
||||
)
|
||||
})
|
||||
|
||||
it('should use output type for preview mode', async () => {
|
||||
mockNode.widgets = [{ name: 'model_file', value: 'test.glb' }] // No width/height widgets
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'test.glb'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/api/view/test.glb'
|
||||
)
|
||||
vi.mocked(api.apiURL).mockReturnValue(
|
||||
'http://localhost/api/view/test.glb'
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
|
||||
'',
|
||||
'test.glb',
|
||||
'output'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null node ref', () => {
|
||||
const nodeRef = ref(null)
|
||||
const composable = useLoad3d(nodeRef)
|
||||
|
||||
const callback = vi.fn()
|
||||
composable.waitForLoad3d(callback)
|
||||
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle missing configurations', async () => {
|
||||
delete mockNode.properties['Scene Config']
|
||||
delete mockNode.properties['Model Config']
|
||||
delete mockNode.properties['Camera Config']
|
||||
delete mockNode.properties['Light Config']
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
// Should not throw and should use defaults
|
||||
expect(Load3d).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle background image with existing config', async () => {
|
||||
mockNode.properties['Scene Config'].backgroundImage = 'existing.jpg'
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('existing.jpg')
|
||||
})
|
||||
})
|
||||
})
|
||||
267
tests-ui/tests/composables/useLoad3dDrag.test.ts
Normal file
267
tests-ui/tests/composables/useLoad3dDrag.test.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: vi.fn((key) => key)
|
||||
}))
|
||||
|
||||
function createMockDragEvent(
|
||||
type: string,
|
||||
options: { hasFiles?: boolean; files?: File[] } = {}
|
||||
): DragEvent {
|
||||
const files = options.files || []
|
||||
const types = options.hasFiles ? ['Files'] : []
|
||||
|
||||
const dataTransfer = {
|
||||
types,
|
||||
files,
|
||||
dropEffect: 'none' as DataTransfer['dropEffect']
|
||||
}
|
||||
|
||||
const event = {
|
||||
type,
|
||||
dataTransfer
|
||||
} as unknown as DragEvent
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
describe('useLoad3dDrag', () => {
|
||||
let mockToastStore: any
|
||||
let mockOnModelDrop: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockToastStore = {
|
||||
addAlert: vi.fn()
|
||||
}
|
||||
vi.mocked(useToastStore).mockReturnValue(mockToastStore)
|
||||
|
||||
mockOnModelDrop = vi.fn()
|
||||
})
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { isDragging, dragMessage } = useLoad3dDrag({
|
||||
onModelDrop: mockOnModelDrop
|
||||
})
|
||||
|
||||
expect(isDragging.value).toBe(false)
|
||||
expect(dragMessage.value).toBe('')
|
||||
})
|
||||
|
||||
describe('handleDragOver', () => {
|
||||
it('should set isDragging to true when files are being dragged', () => {
|
||||
const { isDragging, handleDragOver } = useLoad3dDrag({
|
||||
onModelDrop: mockOnModelDrop
|
||||
})
|
||||
|
||||
const event = createMockDragEvent('dragover', { hasFiles: true })
|
||||
|
||||
handleDragOver(event)
|
||||
|
||||
expect(isDragging.value).toBe(true)
|
||||
expect(event.dataTransfer!.dropEffect).toBe('copy')
|
||||
})
|
||||
|
||||
it('should not set isDragging when disabled', () => {
|
||||
const disabled = ref(true)
|
||||
const { isDragging, handleDragOver } = useLoad3dDrag({
|
||||
onModelDrop: mockOnModelDrop,
|
||||
disabled
|
||||
})
|
||||
|
||||
const event = createMockDragEvent('dragover', { hasFiles: true })
|
||||
|
||||
handleDragOver(event)
|
||||
|
||||
expect(isDragging.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not set isDragging when no files are being dragged', () => {
|
||||
const { isDragging, handleDragOver } = useLoad3dDrag({
|
||||
onModelDrop: mockOnModelDrop
|
||||
})
|
||||
|
||||
const event = createMockDragEvent('dragover', { hasFiles: false })
|
||||
|
||||
handleDragOver(event)
|
||||
|
||||
expect(isDragging.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDragLeave', () => {
|
||||
it('should reset isDragging to false', () => {
|
||||
const { isDragging, handleDragLeave, handleDragOver } = useLoad3dDrag({
|
||||
onModelDrop: mockOnModelDrop
|
||||
})
|
||||
|
||||
// First set isDragging to true
|
||||
const dragOverEvent = createMockDragEvent('dragover', { hasFiles: true })
|
||||
handleDragOver(dragOverEvent)
|
||||
expect(isDragging.value).toBe(true)
|
||||
|
||||
// Then test dragleave
|
||||
handleDragLeave()
|
||||
expect(isDragging.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDrop', () => {
|
||||
it('should call onModelDrop with valid model file', async () => {
|
||||
const { handleDrop } = useLoad3dDrag({
|
||||
onModelDrop: mockOnModelDrop
|
||||
})
|
||||
|
||||
const modelFile = new File([], 'model.glb', { type: 'model/gltf-binary' })
|
||||
const event = createMockDragEvent('drop', {
|
||||
hasFiles: true,
|
||||
files: [modelFile]
|
||||
})
|
||||
|
||||
await handleDrop(event)
|
||||
|
||||
expect(mockOnModelDrop).toHaveBeenCalledWith(modelFile)
|
||||
})
|
||||
|
||||
it('should show error toast for unsupported file types', async () => {
|
||||
const { handleDrop } = useLoad3dDrag({
|
||||
onModelDrop: mockOnModelDrop
|
||||
})
|
||||
|
||||
const invalidFile = new File([], 'image.png', { type: 'image/png' })
|
||||
const event = createMockDragEvent('drop', {
|
||||
hasFiles: true,
|
||||
files: [invalidFile]
|
||||
})
|
||||
|
||||
await handleDrop(event)
|
||||
|
||||
expect(mockOnModelDrop).not.toHaveBeenCalled()
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'load3d.unsupportedFileType'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not call onModelDrop when disabled', async () => {
|
||||
const disabled = ref(true)
|
||||
const { handleDrop } = useLoad3dDrag({
|
||||
onModelDrop: mockOnModelDrop,
|
||||
disabled
|
||||
})
|
||||
|
||||
const modelFile = new File([], 'model.glb', { type: 'model/gltf-binary' })
|
||||
const event = createMockDragEvent('drop', {
|
||||
hasFiles: true,
|
||||
files: [modelFile]
|
||||
})
|
||||
|
||||
await handleDrop(event)
|
||||
|
||||
expect(mockOnModelDrop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset isDragging after drop', async () => {
|
||||
const { isDragging, handleDrop, handleDragOver } = useLoad3dDrag({
|
||||
onModelDrop: mockOnModelDrop
|
||||
})
|
||||
|
||||
// Set isDragging to true
|
||||
const dragOverEvent = createMockDragEvent('dragover', { hasFiles: true })
|
||||
handleDragOver(dragOverEvent)
|
||||
expect(isDragging.value).toBe(true)
|
||||
|
||||
// Drop the file
|
||||
const modelFile = new File([], 'model.glb', { type: 'model/gltf-binary' })
|
||||
const dropEvent = createMockDragEvent('drop', {
|
||||
hasFiles: true,
|
||||
files: [modelFile]
|
||||
})
|
||||
|
||||
await handleDrop(dropEvent)
|
||||
|
||||
expect(isDragging.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should support all valid 3D model extensions', async () => {
|
||||
const { handleDrop } = useLoad3dDrag({
|
||||
onModelDrop: mockOnModelDrop
|
||||
})
|
||||
|
||||
const extensions = ['.gltf', '.glb', '.obj', '.fbx', '.stl']
|
||||
|
||||
for (const ext of extensions) {
|
||||
mockOnModelDrop.mockClear()
|
||||
|
||||
const modelFile = new File([], `model${ext}`)
|
||||
const event = createMockDragEvent('drop', {
|
||||
hasFiles: true,
|
||||
files: [modelFile]
|
||||
})
|
||||
|
||||
await handleDrop(event)
|
||||
|
||||
expect(mockOnModelDrop).toHaveBeenCalledWith(modelFile)
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle empty file list', async () => {
|
||||
const { handleDrop } = useLoad3dDrag({
|
||||
onModelDrop: mockOnModelDrop
|
||||
})
|
||||
|
||||
const event = createMockDragEvent('drop', {
|
||||
hasFiles: true,
|
||||
files: []
|
||||
})
|
||||
|
||||
await handleDrop(event)
|
||||
|
||||
expect(mockOnModelDrop).not.toHaveBeenCalled()
|
||||
expect(mockToastStore.addAlert).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('disabled option', () => {
|
||||
it('should work with reactive disabled ref', () => {
|
||||
const disabled = ref(false)
|
||||
const { isDragging, handleDragOver } = useLoad3dDrag({
|
||||
onModelDrop: mockOnModelDrop,
|
||||
disabled
|
||||
})
|
||||
|
||||
const event = createMockDragEvent('dragover', { hasFiles: true })
|
||||
|
||||
// Should work when disabled is false
|
||||
handleDragOver(event)
|
||||
expect(isDragging.value).toBe(true)
|
||||
|
||||
// Reset
|
||||
isDragging.value = false
|
||||
|
||||
// Should not work when disabled is true
|
||||
disabled.value = true
|
||||
handleDragOver(event)
|
||||
expect(isDragging.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should work with plain boolean', () => {
|
||||
const { isDragging, handleDragOver } = useLoad3dDrag({
|
||||
onModelDrop: mockOnModelDrop,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const event = createMockDragEvent('dragover', { hasFiles: true })
|
||||
handleDragOver(event)
|
||||
expect(isDragging.value).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -41,20 +41,28 @@ describe('useLoad3dViewer', () => {
|
||||
|
||||
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
|
||||
'Scene Config': {
|
||||
backgroundColor: '#282828',
|
||||
showGrid: true,
|
||||
backgroundImage: ''
|
||||
},
|
||||
'Camera Config': {
|
||||
cameraType: 'perspective',
|
||||
fov: 75
|
||||
},
|
||||
'Light Config': {
|
||||
intensity: 1
|
||||
},
|
||||
'Model Config': {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
},
|
||||
'Resource Folder': ''
|
||||
},
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
}
|
||||
},
|
||||
widgets: []
|
||||
} as any
|
||||
|
||||
mockLoad3d = {
|
||||
@@ -66,7 +74,6 @@ describe('useLoad3dViewer', () => {
|
||||
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(),
|
||||
@@ -77,7 +84,8 @@ describe('useLoad3dViewer', () => {
|
||||
cameraType: 'perspective'
|
||||
}),
|
||||
forceRender: vi.fn(),
|
||||
remove: vi.fn()
|
||||
remove: vi.fn(),
|
||||
setTargetSize: vi.fn()
|
||||
}
|
||||
|
||||
mockSourceLoad3d = {
|
||||
@@ -142,7 +150,6 @@ describe('useLoad3dViewer', () => {
|
||||
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 () => {
|
||||
@@ -169,7 +176,6 @@ describe('useLoad3dViewer', () => {
|
||||
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 () => {
|
||||
@@ -177,7 +183,7 @@ describe('useLoad3dViewer', () => {
|
||||
type: 'image',
|
||||
value: ''
|
||||
})
|
||||
mockNode.properties['Background Image'] = 'test-image.jpg'
|
||||
mockNode.properties['Scene Config'].backgroundImage = 'test-image.jpg'
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
@@ -302,18 +308,6 @@ describe('useLoad3dViewer', () => {
|
||||
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')
|
||||
@@ -411,16 +405,20 @@ describe('useLoad3dViewer', () => {
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
|
||||
mockNode.properties['Background Color'] = '#ff0000'
|
||||
mockNode.properties['Show Grid'] = false
|
||||
mockNode.properties['Scene Config'].backgroundColor = '#ff0000'
|
||||
mockNode.properties['Scene Config'].showGrid = 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)
|
||||
expect(mockNode.properties['Scene Config'].backgroundColor).toBe(
|
||||
'#282828'
|
||||
)
|
||||
expect(mockNode.properties['Scene Config'].showGrid).toBe(true)
|
||||
expect(mockNode.properties['Camera Config'].cameraType).toBe(
|
||||
'perspective'
|
||||
)
|
||||
expect(mockNode.properties['Camera Config'].fov).toBe(75)
|
||||
expect(mockNode.properties['Light Config'].intensity).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -437,8 +435,10 @@ describe('useLoad3dViewer', () => {
|
||||
const result = await viewer.applyChanges()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockNode.properties['Background Color']).toBe('#ff0000')
|
||||
expect(mockNode.properties['Show Grid']).toBe(false)
|
||||
expect(mockNode.properties['Scene Config'].backgroundColor).toBe(
|
||||
'#ff0000'
|
||||
)
|
||||
expect(mockNode.properties['Scene Config'].showGrid).toBe(false)
|
||||
expect(mockLoad3dService.copyLoad3dState).toHaveBeenCalledWith(
|
||||
mockLoad3d,
|
||||
mockSourceLoad3d
|
||||
@@ -582,7 +582,10 @@ describe('useLoad3dViewer', () => {
|
||||
|
||||
it('should handle orthographic camera', async () => {
|
||||
mockSourceLoad3d.getCurrentCameraType.mockReturnValue('orthographic')
|
||||
mockSourceLoad3d.cameraManager = {} // No perspective camera
|
||||
mockSourceLoad3d.cameraManager = {
|
||||
perspectiveCamera: { fov: 75 }
|
||||
}
|
||||
delete mockNode.properties['Camera Config'].cameraType
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
Reference in New Issue
Block a user