mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 19:21:54 +00:00
[3d] add support to export different formats (#3176)
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
@@ -54,6 +54,7 @@
|
|||||||
@updateUpDirection="handleUpdateUpDirection"
|
@updateUpDirection="handleUpdateUpDirection"
|
||||||
@updateMaterialMode="handleUpdateMaterialMode"
|
@updateMaterialMode="handleUpdateMaterialMode"
|
||||||
@updateEdgeThreshold="handleUpdateEdgeThreshold"
|
@updateEdgeThreshold="handleUpdateEdgeThreshold"
|
||||||
|
@exportModel="handleExportModel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -72,6 +73,7 @@ import {
|
|||||||
} from '@/extensions/core/load3d/interfaces'
|
} from '@/extensions/core/load3d/interfaces'
|
||||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||||
|
import { useToastStore } from '@/stores/toastStore'
|
||||||
|
|
||||||
const { widget } = defineProps<{
|
const { widget } = defineProps<{
|
||||||
widget: ComponentWidget<string[]>
|
widget: ComponentWidget<string[]>
|
||||||
@@ -183,6 +185,22 @@ const handleUpdateMaterialMode = (value: MaterialMode) => {
|
|||||||
node.properties['Material Mode'] = value
|
node.properties['Material Mode'] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleExportModel = async (format: string) => {
|
||||||
|
if (!load3DSceneRef.value?.load3d) {
|
||||||
|
useToastStore().addAlert('No 3D scene to export')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await load3DSceneRef.value.load3d.exportModel(format)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting model:', error)
|
||||||
|
useToastStore().addAlert(
|
||||||
|
`Failed to export model as ${format.toUpperCase()}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const listenMaterialModeChange = (mode: MaterialMode) => {
|
const listenMaterialModeChange = (mode: MaterialMode) => {
|
||||||
materialMode.value = mode
|
materialMode.value = mode
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
>
|
>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<Button
|
<Button
|
||||||
v-for="(label, category) in categories"
|
v-for="category in availableCategories"
|
||||||
:key="category"
|
:key="category"
|
||||||
class="p-button-text w-full flex items-center justify-start"
|
class="p-button-text w-full flex items-center justify-start"
|
||||||
:class="{ 'bg-gray-600': activeCategory === category }"
|
:class="{ 'bg-gray-600': activeCategory === category }"
|
||||||
@click="selectCategory(category)"
|
@click="selectCategory(category)"
|
||||||
>
|
>
|
||||||
<i :class="getCategoryIcon(category)"></i>
|
<i :class="getCategoryIcon(category)"></i>
|
||||||
<span class="text-white">{{ t(label) }}</span>
|
<span class="text-white">{{ t(categoryLabels[category]) }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,6 +70,12 @@
|
|||||||
@updateLightIntensity="handleUpdateLightIntensity"
|
@updateLightIntensity="handleUpdateLightIntensity"
|
||||||
ref="lightControlsRef"
|
ref="lightControlsRef"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ExportControls
|
||||||
|
v-if="activeCategory === 'export'"
|
||||||
|
@exportModel="handleExportModel"
|
||||||
|
ref="exportControlsRef"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showPreviewButton">
|
<div v-if="showPreviewButton">
|
||||||
<Button class="p-button-rounded p-button-text" @click="togglePreview">
|
<Button class="p-button-rounded p-button-text" @click="togglePreview">
|
||||||
@@ -89,9 +95,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Tooltip } from 'primevue'
|
import { Tooltip } from 'primevue'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
|
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
|
||||||
|
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
|
||||||
import LightControls from '@/components/load3d/controls/LightControls.vue'
|
import LightControls from '@/components/load3d/controls/LightControls.vue'
|
||||||
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
|
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
|
||||||
import SceneControls from '@/components/load3d/controls/SceneControls.vue'
|
import SceneControls from '@/components/load3d/controls/SceneControls.vue'
|
||||||
@@ -123,19 +130,30 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isMenuOpen = ref(false)
|
const isMenuOpen = ref(false)
|
||||||
const activeCategory = ref<'scene' | 'model' | 'camera' | 'light'>('scene')
|
const activeCategory = ref<string>('scene')
|
||||||
const categories = {
|
const categoryLabels: Record<string, string> = {
|
||||||
scene: 'load3d.scene',
|
scene: 'load3d.scene',
|
||||||
model: 'load3d.model',
|
model: 'load3d.model',
|
||||||
camera: 'load3d.camera',
|
camera: 'load3d.camera',
|
||||||
light: 'load3d.light'
|
light: 'load3d.light',
|
||||||
|
export: 'load3d.export'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const availableCategories = computed(() => {
|
||||||
|
const baseCategories = ['scene', 'model', 'camera', 'light']
|
||||||
|
|
||||||
|
if (!props.inputSpec.isAnimation) {
|
||||||
|
return [...baseCategories, 'export']
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseCategories
|
||||||
|
})
|
||||||
|
|
||||||
const toggleMenu = () => {
|
const toggleMenu = () => {
|
||||||
isMenuOpen.value = !isMenuOpen.value
|
isMenuOpen.value = !isMenuOpen.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectCategory = (category: 'scene' | 'model' | 'camera' | 'light') => {
|
const selectCategory = (category: string) => {
|
||||||
activeCategory.value = category
|
activeCategory.value = category
|
||||||
isMenuOpen.value = false
|
isMenuOpen.value = false
|
||||||
}
|
}
|
||||||
@@ -145,7 +163,8 @@ const getCategoryIcon = (category: string) => {
|
|||||||
scene: 'pi pi-image',
|
scene: 'pi pi-image',
|
||||||
model: 'pi pi-box',
|
model: 'pi pi-box',
|
||||||
camera: 'pi pi-camera',
|
camera: 'pi pi-camera',
|
||||||
light: 'pi pi-sun'
|
light: 'pi pi-sun',
|
||||||
|
export: 'pi pi-download'
|
||||||
}
|
}
|
||||||
// @ts-expect-error fixme ts strict error
|
// @ts-expect-error fixme ts strict error
|
||||||
return `${icons[category]} text-white text-lg`
|
return `${icons[category]} text-white text-lg`
|
||||||
@@ -162,6 +181,7 @@ const emit = defineEmits<{
|
|||||||
(e: 'updateUpDirection', direction: UpDirection): void
|
(e: 'updateUpDirection', direction: UpDirection): void
|
||||||
(e: 'updateMaterialMode', mode: MaterialMode): void
|
(e: 'updateMaterialMode', mode: MaterialMode): void
|
||||||
(e: 'updateEdgeThreshold', value: number): void
|
(e: 'updateEdgeThreshold', value: number): void
|
||||||
|
(e: 'exportModel', format: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const backgroundColor = ref(props.backgroundColor)
|
const backgroundColor = ref(props.backgroundColor)
|
||||||
@@ -218,6 +238,10 @@ const handleUpdateFOV = (value: number) => {
|
|||||||
emit('updateFOV', value)
|
emit('updateFOV', value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleExportModel = (format: string) => {
|
||||||
|
emit('exportModel', format)
|
||||||
|
}
|
||||||
|
|
||||||
const closeSlider = (e: MouseEvent) => {
|
const closeSlider = (e: MouseEvent) => {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,13 @@ const eventConfig = {
|
|||||||
modelLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
|
modelLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
|
||||||
materialLoadingStart: () =>
|
materialLoadingStart: () =>
|
||||||
loadingOverlayRef.value?.startLoading(t('load3d.switchingMaterialMode')),
|
loadingOverlayRef.value?.startLoading(t('load3d.switchingMaterialMode')),
|
||||||
materialLoadingEnd: () => loadingOverlayRef.value?.endLoading()
|
materialLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
|
||||||
|
exportLoadingStart: (message: string) => {
|
||||||
|
loadingOverlayRef.value?.startLoading(message || t('load3d.exportingModel'))
|
||||||
|
},
|
||||||
|
exportLoadingEnd: () => {
|
||||||
|
loadingOverlayRef.value?.endLoading()
|
||||||
|
}
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
|
|||||||
@@ -15,19 +15,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { nextTick, ref } from 'vue'
|
||||||
|
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
const modelLoading = ref(false)
|
const modelLoading = ref(false)
|
||||||
const loadingMessage = ref('')
|
const loadingMessage = ref('')
|
||||||
|
|
||||||
const startLoading = (message?: string) => {
|
const startLoading = async (message?: string) => {
|
||||||
modelLoading.value = true
|
|
||||||
loadingMessage.value = message || t('load3d.loadingModel')
|
loadingMessage.value = message || t('load3d.loadingModel')
|
||||||
|
modelLoading.value = true
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
}
|
}
|
||||||
|
|
||||||
const endLoading = () => {
|
const endLoading = async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
modelLoading.value = false
|
modelLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
81
src/components/load3d/controls/ExportControls.vue
Normal file
81
src/components/load3d/controls/ExportControls.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="relative show-export-formats">
|
||||||
|
<Button
|
||||||
|
class="p-button-rounded p-button-text"
|
||||||
|
@click="toggleExportFormats"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="pi pi-download text-white text-lg"
|
||||||
|
v-tooltip.right="{
|
||||||
|
value: t('load3d.exportModel'),
|
||||||
|
showDelay: 300
|
||||||
|
}"
|
||||||
|
></i>
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
v-show="showExportFormats"
|
||||||
|
class="absolute left-12 top-0 bg-black bg-opacity-50 rounded-lg shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<Button
|
||||||
|
v-for="format in exportFormats"
|
||||||
|
:key="format.value"
|
||||||
|
class="p-button-text text-white"
|
||||||
|
@click="exportModel(format.value)"
|
||||||
|
>
|
||||||
|
{{ format.label }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const showExportFormats = ref(false)
|
||||||
|
|
||||||
|
const exportFormats = [
|
||||||
|
{ label: 'GLB', value: 'glb' },
|
||||||
|
{ label: 'OBJ', value: 'obj' },
|
||||||
|
{ label: 'STL', value: 'stl' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const toggleExportFormats = () => {
|
||||||
|
showExportFormats.value = !showExportFormats.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportModel = (format: string) => {
|
||||||
|
emit('exportModel', format)
|
||||||
|
|
||||||
|
showExportFormats.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeExportFormatsList = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
|
||||||
|
if (!target.closest('.show-export-formats')) {
|
||||||
|
showExportFormats.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', closeExportFormatsList)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', closeExportFormatsList)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -8,6 +8,7 @@ import { ControlsManager } from './ControlsManager'
|
|||||||
import { EventManager } from './EventManager'
|
import { EventManager } from './EventManager'
|
||||||
import { LightingManager } from './LightingManager'
|
import { LightingManager } from './LightingManager'
|
||||||
import { LoaderManager } from './LoaderManager'
|
import { LoaderManager } from './LoaderManager'
|
||||||
|
import { ModelExporter } from './ModelExporter'
|
||||||
import { ModelManager } from './ModelManager'
|
import { ModelManager } from './ModelManager'
|
||||||
import { NodeStorage } from './NodeStorage'
|
import { NodeStorage } from './NodeStorage'
|
||||||
import { PreviewManager } from './PreviewManager'
|
import { PreviewManager } from './PreviewManager'
|
||||||
@@ -225,6 +226,47 @@ class Load3d {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exportModel(format: string): Promise<void> {
|
||||||
|
if (!this.modelManager.currentModel) {
|
||||||
|
throw new Error('No model to export')
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportMessage = `Exporting as ${format.toUpperCase()}...`
|
||||||
|
this.eventManager.emitEvent('exportLoadingStart', exportMessage)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const model = this.modelManager.currentModel.clone()
|
||||||
|
|
||||||
|
const originalFileName = this.modelManager.originalFileName || 'model'
|
||||||
|
const filename = `${originalFileName}.${format}`
|
||||||
|
|
||||||
|
const originalURL = this.modelManager.originalURL
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'glb':
|
||||||
|
await ModelExporter.exportGLB(model, filename, originalURL)
|
||||||
|
break
|
||||||
|
case 'obj':
|
||||||
|
await ModelExporter.exportOBJ(model, filename, originalURL)
|
||||||
|
break
|
||||||
|
case 'stl':
|
||||||
|
await ModelExporter.exportSTL(model, filename), originalURL
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported export format: ${format}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error exporting model as ${format}:`, error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
this.eventManager.emitEvent('exportLoadingEnd', null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setBackgroundColor(color: string): void {
|
setBackgroundColor(color: string): void {
|
||||||
this.sceneManager.setBackgroundColor(color)
|
this.sceneManager.setBackgroundColor(color)
|
||||||
this.forceRender()
|
this.forceRender()
|
||||||
|
|||||||
@@ -47,12 +47,23 @@ export class LoaderManager implements LoaderManagerInterface {
|
|||||||
|
|
||||||
this.modelManager.clearModel()
|
this.modelManager.clearModel()
|
||||||
|
|
||||||
|
this.modelManager.originalURL = url
|
||||||
|
|
||||||
let fileExtension: string | undefined
|
let fileExtension: string | undefined
|
||||||
if (originalFileName) {
|
if (originalFileName) {
|
||||||
fileExtension = originalFileName.split('.').pop()?.toLowerCase()
|
fileExtension = originalFileName.split('.').pop()?.toLowerCase()
|
||||||
|
|
||||||
|
this.modelManager.originalFileName =
|
||||||
|
originalFileName.split('/').pop()?.split('.')[0] || 'model'
|
||||||
} else {
|
} else {
|
||||||
const filename = new URLSearchParams(url.split('?')[1]).get('filename')
|
const filename = new URLSearchParams(url.split('?')[1]).get('filename')
|
||||||
fileExtension = filename?.split('.').pop()?.toLowerCase()
|
fileExtension = filename?.split('.').pop()?.toLowerCase()
|
||||||
|
|
||||||
|
if (filename) {
|
||||||
|
this.modelManager.originalFileName = filename.split('.')[0] || 'model'
|
||||||
|
} else {
|
||||||
|
this.modelManager.originalFileName = 'model'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fileExtension) {
|
if (!fileExtension) {
|
||||||
|
|||||||
163
src/extensions/core/load3d/ModelExporter.ts
Normal file
163
src/extensions/core/load3d/ModelExporter.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import * as THREE from 'three'
|
||||||
|
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter'
|
||||||
|
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter'
|
||||||
|
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter'
|
||||||
|
|
||||||
|
import { useToastStore } from '@/stores/toastStore'
|
||||||
|
|
||||||
|
export class ModelExporter {
|
||||||
|
static detectFormatFromURL(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const filenameParam = new URLSearchParams(url.split('?')[1]).get(
|
||||||
|
'filename'
|
||||||
|
)
|
||||||
|
if (filenameParam) {
|
||||||
|
const extension = filenameParam.split('.').pop()?.toLowerCase()
|
||||||
|
return extension || null
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing URL:', e)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
static canUseDirectURL(url: string | null, format: string): boolean {
|
||||||
|
if (!url) return false
|
||||||
|
|
||||||
|
const urlFormat = ModelExporter.detectFormatFromURL(url)
|
||||||
|
if (!urlFormat) return false
|
||||||
|
|
||||||
|
return urlFormat.toLowerCase() === format.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
static async downloadFromURL(
|
||||||
|
url: string,
|
||||||
|
desiredFilename: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url)
|
||||||
|
const blob = await response.blob()
|
||||||
|
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = URL.createObjectURL(blob)
|
||||||
|
link.download = desiredFilename
|
||||||
|
link.click()
|
||||||
|
|
||||||
|
URL.revokeObjectURL(link.href)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading from URL:', error)
|
||||||
|
useToastStore().addAlert('Failed to download file from URL')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async exportGLB(
|
||||||
|
model: THREE.Object3D,
|
||||||
|
filename: string = 'model.glb',
|
||||||
|
originalURL?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
if (originalURL && ModelExporter.canUseDirectURL(originalURL, 'glb')) {
|
||||||
|
console.log('Using direct URL download for GLB')
|
||||||
|
return ModelExporter.downloadFromURL(originalURL, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const exporter = new GLTFExporter()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
const result = await new Promise<ArrayBuffer>((resolve, reject) => {
|
||||||
|
exporter.parse(
|
||||||
|
model,
|
||||||
|
(gltf) => {
|
||||||
|
resolve(gltf as ArrayBuffer)
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
reject(error)
|
||||||
|
},
|
||||||
|
{ binary: true }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
ModelExporter.saveArrayBuffer(result, filename)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting GLB:', error)
|
||||||
|
useToastStore().addAlert('Failed to export model as GLB')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async exportOBJ(
|
||||||
|
model: THREE.Object3D,
|
||||||
|
filename: string = 'model.obj',
|
||||||
|
originalURL?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
if (originalURL && ModelExporter.canUseDirectURL(originalURL, 'obj')) {
|
||||||
|
console.log('Using direct URL download for OBJ')
|
||||||
|
return ModelExporter.downloadFromURL(originalURL, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const exporter = new OBJExporter()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
const result = exporter.parse(model)
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
ModelExporter.saveString(result, filename)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting OBJ:', error)
|
||||||
|
useToastStore().addAlert('Failed to export model as OBJ')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async exportSTL(
|
||||||
|
model: THREE.Object3D,
|
||||||
|
filename: string = 'model.stl',
|
||||||
|
originalURL?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
if (originalURL && ModelExporter.canUseDirectURL(originalURL, 'stl')) {
|
||||||
|
console.log('Using direct URL download for STL')
|
||||||
|
return ModelExporter.downloadFromURL(originalURL, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const exporter = new STLExporter()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
const result = exporter.parse(model)
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
ModelExporter.saveString(result, filename)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting STL:', error)
|
||||||
|
useToastStore().addAlert('Failed to export model as STL')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static saveArrayBuffer(buffer: ArrayBuffer, filename: string): void {
|
||||||
|
const blob = new Blob([buffer], { type: 'application/octet-stream' })
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = URL.createObjectURL(blob)
|
||||||
|
link.download = filename
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(link.href)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static saveString(text: string, filename: string): void {
|
||||||
|
const blob = new Blob([text], { type: 'text/plain' })
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = URL.createObjectURL(blob)
|
||||||
|
link.download = filename
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(link.href)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ export class ModelManager implements ModelManagerInterface {
|
|||||||
standardMaterial: THREE.MeshStandardMaterial
|
standardMaterial: THREE.MeshStandardMaterial
|
||||||
wireframeMaterial: THREE.MeshBasicMaterial
|
wireframeMaterial: THREE.MeshBasicMaterial
|
||||||
depthMaterial: THREE.MeshDepthMaterial
|
depthMaterial: THREE.MeshDepthMaterial
|
||||||
|
originalFileName: string | null = null
|
||||||
|
originalURL: string | null = null
|
||||||
|
|
||||||
private scene: THREE.Scene
|
private scene: THREE.Scene
|
||||||
private renderer: THREE.WebGLRenderer
|
private renderer: THREE.WebGLRenderer
|
||||||
@@ -633,6 +635,8 @@ export class ModelManager implements ModelManagerInterface {
|
|||||||
this.originalRotation = null
|
this.originalRotation = null
|
||||||
this.currentUpDirection = 'original'
|
this.currentUpDirection = 'original'
|
||||||
this.setMaterialMode('original')
|
this.setMaterialMode('original')
|
||||||
|
this.originalFileName = null
|
||||||
|
this.originalURL = null
|
||||||
|
|
||||||
this.originalMaterials = new WeakMap()
|
this.originalMaterials = new WeakMap()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ export interface AnimationManagerInterface extends BaseManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelManagerInterface {
|
export interface ModelManagerInterface {
|
||||||
|
originalFileName: string | null
|
||||||
|
originalURL: string | null
|
||||||
currentModel: THREE.Object3D | null
|
currentModel: THREE.Object3D | null
|
||||||
originalModel: THREE.Object3D | THREE.BufferGeometry | GLTF | null
|
originalModel: THREE.Object3D | THREE.BufferGeometry | GLTF | null
|
||||||
originalRotation: THREE.Euler | null
|
originalRotation: THREE.Euler | null
|
||||||
|
|||||||
@@ -978,6 +978,9 @@
|
|||||||
"camera": "Camera",
|
"camera": "Camera",
|
||||||
"light": "Light",
|
"light": "Light",
|
||||||
"switchingMaterialMode": "Switching Material Mode...",
|
"switchingMaterialMode": "Switching Material Mode...",
|
||||||
"edgeThreshold": "Edge Threshold"
|
"edgeThreshold": "Edge Threshold",
|
||||||
|
"export": "Export",
|
||||||
|
"exportModel": "Export Model",
|
||||||
|
"exportingModel": "Exporting model..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,6 +349,9 @@
|
|||||||
"backgroundColor": "Couleur de fond",
|
"backgroundColor": "Couleur de fond",
|
||||||
"camera": "Caméra",
|
"camera": "Caméra",
|
||||||
"edgeThreshold": "Seuil de Bordure",
|
"edgeThreshold": "Seuil de Bordure",
|
||||||
|
"export": "Exportation",
|
||||||
|
"exportModel": "Exportation du modèle",
|
||||||
|
"exportingModel": "Exportation du modèle en cours...",
|
||||||
"fov": "FOV",
|
"fov": "FOV",
|
||||||
"light": "Lumière",
|
"light": "Lumière",
|
||||||
"lightIntensity": "Intensité de la lumière",
|
"lightIntensity": "Intensité de la lumière",
|
||||||
|
|||||||
@@ -349,6 +349,9 @@
|
|||||||
"backgroundColor": "背景色",
|
"backgroundColor": "背景色",
|
||||||
"camera": "カメラ",
|
"camera": "カメラ",
|
||||||
"edgeThreshold": "エッジ閾値",
|
"edgeThreshold": "エッジ閾値",
|
||||||
|
"export": "エクスポート",
|
||||||
|
"exportModel": "モデルをエクスポート",
|
||||||
|
"exportingModel": "モデルをエクスポート中...",
|
||||||
"fov": "FOV",
|
"fov": "FOV",
|
||||||
"light": "ライト",
|
"light": "ライト",
|
||||||
"lightIntensity": "光の強度",
|
"lightIntensity": "光の強度",
|
||||||
|
|||||||
@@ -349,6 +349,9 @@
|
|||||||
"backgroundColor": "배경색",
|
"backgroundColor": "배경색",
|
||||||
"camera": "카메라",
|
"camera": "카메라",
|
||||||
"edgeThreshold": "엣지 임계값",
|
"edgeThreshold": "엣지 임계값",
|
||||||
|
"export": "수출",
|
||||||
|
"exportModel": "모델 수출",
|
||||||
|
"exportingModel": "모델 수출 중...",
|
||||||
"fov": "FOV",
|
"fov": "FOV",
|
||||||
"light": "빛",
|
"light": "빛",
|
||||||
"lightIntensity": "조명 강도",
|
"lightIntensity": "조명 강도",
|
||||||
|
|||||||
@@ -349,6 +349,9 @@
|
|||||||
"backgroundColor": "Цвет фона",
|
"backgroundColor": "Цвет фона",
|
||||||
"camera": "Камера",
|
"camera": "Камера",
|
||||||
"edgeThreshold": "Пороговое значение края",
|
"edgeThreshold": "Пороговое значение края",
|
||||||
|
"export": "Экспорт",
|
||||||
|
"exportModel": "Экспорт модели",
|
||||||
|
"exportingModel": "Экспорт модели...",
|
||||||
"fov": "Угол обзора",
|
"fov": "Угол обзора",
|
||||||
"light": "Свет",
|
"light": "Свет",
|
||||||
"lightIntensity": "Интенсивность света",
|
"lightIntensity": "Интенсивность света",
|
||||||
|
|||||||
@@ -349,6 +349,9 @@
|
|||||||
"backgroundColor": "背景颜色",
|
"backgroundColor": "背景颜色",
|
||||||
"camera": "相机",
|
"camera": "相机",
|
||||||
"edgeThreshold": "边缘阈值",
|
"edgeThreshold": "边缘阈值",
|
||||||
|
"export": "导出",
|
||||||
|
"exportModel": "导出模型",
|
||||||
|
"exportingModel": "正在导出模型...",
|
||||||
"fov": "视场",
|
"fov": "视场",
|
||||||
"light": "灯光",
|
"light": "灯光",
|
||||||
"lightIntensity": "光照强度",
|
"lightIntensity": "光照强度",
|
||||||
|
|||||||
Reference in New Issue
Block a user