[3d] add support to export different formats (#3176)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Terry Jia
2025-03-21 11:04:39 -04:00
committed by GitHub
parent 8530406c3e
commit 0863fda6a4
16 changed files with 386 additions and 14 deletions

View File

@@ -54,6 +54,7 @@
@updateUpDirection="handleUpdateUpDirection"
@updateMaterialMode="handleUpdateMaterialMode"
@updateEdgeThreshold="handleUpdateEdgeThreshold"
@exportModel="handleExportModel"
/>
</div>
</template>
@@ -72,6 +73,7 @@ import {
} from '@/extensions/core/load3d/interfaces'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComponentWidget } from '@/scripts/domWidget'
import { useToastStore } from '@/stores/toastStore'
const { widget } = defineProps<{
widget: ComponentWidget<string[]>
@@ -183,6 +185,22 @@ const handleUpdateMaterialMode = (value: MaterialMode) => {
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) => {
materialMode.value = mode

View File

@@ -16,14 +16,14 @@
>
<div class="flex flex-col">
<Button
v-for="(label, category) in categories"
v-for="category in availableCategories"
:key="category"
class="p-button-text w-full flex items-center justify-start"
:class="{ 'bg-gray-600': activeCategory === category }"
@click="selectCategory(category)"
>
<i :class="getCategoryIcon(category)"></i>
<span class="text-white">{{ t(label) }}</span>
<span class="text-white">{{ t(categoryLabels[category]) }}</span>
</Button>
</div>
</div>
@@ -70,6 +70,12 @@
@updateLightIntensity="handleUpdateLightIntensity"
ref="lightControlsRef"
/>
<ExportControls
v-if="activeCategory === 'export'"
@exportModel="handleExportModel"
ref="exportControlsRef"
/>
</div>
<div v-if="showPreviewButton">
<Button class="p-button-rounded p-button-text" @click="togglePreview">
@@ -89,9 +95,10 @@
<script setup lang="ts">
import { Tooltip } from 'primevue'
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 ExportControls from '@/components/load3d/controls/ExportControls.vue'
import LightControls from '@/components/load3d/controls/LightControls.vue'
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
import SceneControls from '@/components/load3d/controls/SceneControls.vue'
@@ -123,19 +130,30 @@ const props = defineProps<{
}>()
const isMenuOpen = ref(false)
const activeCategory = ref<'scene' | 'model' | 'camera' | 'light'>('scene')
const categories = {
const activeCategory = ref<string>('scene')
const categoryLabels: Record<string, string> = {
scene: 'load3d.scene',
model: 'load3d.model',
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 = () => {
isMenuOpen.value = !isMenuOpen.value
}
const selectCategory = (category: 'scene' | 'model' | 'camera' | 'light') => {
const selectCategory = (category: string) => {
activeCategory.value = category
isMenuOpen.value = false
}
@@ -145,7 +163,8 @@ const getCategoryIcon = (category: string) => {
scene: 'pi pi-image',
model: 'pi pi-box',
camera: 'pi pi-camera',
light: 'pi pi-sun'
light: 'pi pi-sun',
export: 'pi pi-download'
}
// @ts-expect-error fixme ts strict error
return `${icons[category]} text-white text-lg`
@@ -162,6 +181,7 @@ const emit = defineEmits<{
(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)
@@ -218,6 +238,10 @@ const handleUpdateFOV = (value: number) => {
emit('updateFOV', value)
}
const handleExportModel = (format: string) => {
emit('exportModel', format)
}
const closeSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement

View File

@@ -59,7 +59,13 @@ const eventConfig = {
modelLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
materialLoadingStart: () =>
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
watchEffect(() => {

View File

@@ -15,19 +15,22 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import { t } from '@/i18n'
const modelLoading = ref(false)
const loadingMessage = ref('')
const startLoading = (message?: string) => {
modelLoading.value = true
const startLoading = async (message?: string) => {
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
}

View 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>

View File

@@ -8,6 +8,7 @@ import { ControlsManager } from './ControlsManager'
import { EventManager } from './EventManager'
import { LightingManager } from './LightingManager'
import { LoaderManager } from './LoaderManager'
import { ModelExporter } from './ModelExporter'
import { ModelManager } from './ModelManager'
import { NodeStorage } from './NodeStorage'
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 {
this.sceneManager.setBackgroundColor(color)
this.forceRender()

View File

@@ -47,12 +47,23 @@ export class LoaderManager implements LoaderManagerInterface {
this.modelManager.clearModel()
this.modelManager.originalURL = url
let fileExtension: string | undefined
if (originalFileName) {
fileExtension = originalFileName.split('.').pop()?.toLowerCase()
this.modelManager.originalFileName =
originalFileName.split('/').pop()?.split('.')[0] || 'model'
} else {
const filename = new URLSearchParams(url.split('?')[1]).get('filename')
fileExtension = filename?.split('.').pop()?.toLowerCase()
if (filename) {
this.modelManager.originalFileName = filename.split('.')[0] || 'model'
} else {
this.modelManager.originalFileName = 'model'
}
}
if (!fileExtension) {

View 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)
}
}

View File

@@ -35,6 +35,8 @@ export class ModelManager implements ModelManagerInterface {
standardMaterial: THREE.MeshStandardMaterial
wireframeMaterial: THREE.MeshBasicMaterial
depthMaterial: THREE.MeshDepthMaterial
originalFileName: string | null = null
originalURL: string | null = null
private scene: THREE.Scene
private renderer: THREE.WebGLRenderer
@@ -633,6 +635,8 @@ export class ModelManager implements ModelManagerInterface {
this.originalRotation = null
this.currentUpDirection = 'original'
this.setMaterialMode('original')
this.originalFileName = null
this.originalURL = null
this.originalMaterials = new WeakMap()
}

View File

@@ -142,6 +142,8 @@ export interface AnimationManagerInterface extends BaseManager {
}
export interface ModelManagerInterface {
originalFileName: string | null
originalURL: string | null
currentModel: THREE.Object3D | null
originalModel: THREE.Object3D | THREE.BufferGeometry | GLTF | null
originalRotation: THREE.Euler | null

View File

@@ -978,6 +978,9 @@
"camera": "Camera",
"light": "Light",
"switchingMaterialMode": "Switching Material Mode...",
"edgeThreshold": "Edge Threshold"
"edgeThreshold": "Edge Threshold",
"export": "Export",
"exportModel": "Export Model",
"exportingModel": "Exporting model..."
}
}

View File

@@ -349,6 +349,9 @@
"backgroundColor": "Couleur de fond",
"camera": "Caméra",
"edgeThreshold": "Seuil de Bordure",
"export": "Exportation",
"exportModel": "Exportation du modèle",
"exportingModel": "Exportation du modèle en cours...",
"fov": "FOV",
"light": "Lumière",
"lightIntensity": "Intensité de la lumière",

View File

@@ -349,6 +349,9 @@
"backgroundColor": "背景色",
"camera": "カメラ",
"edgeThreshold": "エッジ閾値",
"export": "エクスポート",
"exportModel": "モデルをエクスポート",
"exportingModel": "モデルをエクスポート中...",
"fov": "FOV",
"light": "ライト",
"lightIntensity": "光の強度",

View File

@@ -349,6 +349,9 @@
"backgroundColor": "배경색",
"camera": "카메라",
"edgeThreshold": "엣지 임계값",
"export": "수출",
"exportModel": "모델 수출",
"exportingModel": "모델 수출 중...",
"fov": "FOV",
"light": "빛",
"lightIntensity": "조명 강도",

View File

@@ -349,6 +349,9 @@
"backgroundColor": "Цвет фона",
"camera": "Камера",
"edgeThreshold": "Пороговое значение края",
"export": "Экспорт",
"exportModel": "Экспорт модели",
"exportingModel": "Экспорт модели...",
"fov": "Угол обзора",
"light": "Свет",
"lightIntensity": "Интенсивность света",

View File

@@ -349,6 +349,9 @@
"backgroundColor": "背景颜色",
"camera": "相机",
"edgeThreshold": "边缘阈值",
"export": "导出",
"exportModel": "导出模型",
"exportingModel": "正在导出模型...",
"fov": "视场",
"light": "灯光",
"lightIntensity": "光照强度",