mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-14 18:46:11 +00:00
Compare commits
1 Commits
version-bu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9112f9bd7 |
@@ -27,6 +27,7 @@
|
||||
:can-use-background-image="canUseBackgroundImage"
|
||||
:material-modes="materialModes"
|
||||
:has-skeleton="hasSkeleton"
|
||||
:source-format="sourceFormat"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@export-model="handleExportModel"
|
||||
@update-hdri-file="handleHDRIFileUpdate"
|
||||
@@ -166,6 +167,7 @@ const {
|
||||
canExport,
|
||||
materialModes,
|
||||
hasSkeleton,
|
||||
sourceFormat,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
animations,
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
|
||||
<ExportControls
|
||||
v-if="showExportControls"
|
||||
:source-format="sourceFormat"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
|
||||
@@ -134,7 +135,8 @@ const {
|
||||
canUseHdri = true,
|
||||
canUseBackgroundImage = true,
|
||||
materialModes = ['original', 'normal', 'wireframe'],
|
||||
hasSkeleton = false
|
||||
hasSkeleton = false,
|
||||
sourceFormat = null
|
||||
} = defineProps<{
|
||||
canUseGizmo?: boolean
|
||||
canUseLighting?: boolean
|
||||
@@ -143,6 +145,7 @@ const {
|
||||
canUseBackgroundImage?: boolean
|
||||
materialModes?: readonly MaterialMode[]
|
||||
hasSkeleton?: boolean
|
||||
sourceFormat?: string | null
|
||||
}>()
|
||||
|
||||
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
|
||||
|
||||
@@ -59,6 +59,7 @@ function buildViewerStub() {
|
||||
canUseGizmo: ref(true),
|
||||
canUseLighting: ref(true),
|
||||
canExport: ref(true),
|
||||
sourceFormat: ref<string | null>(null),
|
||||
materialModes: ref(['original', 'normal', 'wireframe']),
|
||||
animations: ref<Array<{ name: string; index: number }>>([]),
|
||||
playing: ref(false),
|
||||
|
||||
@@ -82,7 +82,10 @@
|
||||
</div>
|
||||
|
||||
<div v-if="viewer.canExport.value" class="space-y-4 p-2">
|
||||
<ExportControls @export-model="viewer.exportModel" />
|
||||
<ExportControls
|
||||
:source-format="viewer.sourceFormat.value"
|
||||
@export-model="viewer.exportModel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,12 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
function renderComponent(onExportModel?: (format: string) => void) {
|
||||
function renderComponent(
|
||||
onExportModel?: (format: string) => void,
|
||||
sourceFormat: string | null = null
|
||||
) {
|
||||
const utils = render(ExportControls, {
|
||||
props: { onExportModel },
|
||||
props: { onExportModel, sourceFormat },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
@@ -63,6 +66,23 @@ describe('ExportControls', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers only the source format for direct-export files (e.g. ply)', async () => {
|
||||
const onExportModel = vi.fn()
|
||||
const { user } = renderComponent(onExportModel, 'ply')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export model' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'PLY' })).toBeVisible()
|
||||
for (const label of ['GLB', 'OBJ', 'STL', 'FBX']) {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: label })
|
||||
).not.toBeInTheDocument()
|
||||
}
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'PLY' }))
|
||||
expect(onExportModel).toHaveBeenCalledWith('ply')
|
||||
})
|
||||
|
||||
it('hides the popup when a click happens outside the trigger', async () => {
|
||||
const { user } = renderComponent()
|
||||
|
||||
|
||||
@@ -35,9 +35,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
|
||||
|
||||
const { sourceFormat = null } = defineProps<{
|
||||
sourceFormat?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'exportModel', format: string): void
|
||||
@@ -45,12 +50,7 @@ const emit = defineEmits<{
|
||||
|
||||
const showExportFormats = ref(false)
|
||||
|
||||
const exportFormats = [
|
||||
{ label: 'GLB', value: 'glb' },
|
||||
{ label: 'OBJ', value: 'obj' },
|
||||
{ label: 'STL', value: 'stl' },
|
||||
{ label: 'FBX', value: 'fbx' }
|
||||
]
|
||||
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
|
||||
|
||||
function toggleExportFormats() {
|
||||
showExportFormats.value = !showExportFormats.value
|
||||
|
||||
@@ -72,9 +72,12 @@ const i18n = createI18n({
|
||||
messages: { en: { load3d: { export: 'Export' } } }
|
||||
})
|
||||
|
||||
function renderComponent(onExportModel?: (format: string) => void) {
|
||||
function renderComponent(
|
||||
onExportModel?: (format: string) => void,
|
||||
sourceFormat: string | null = null
|
||||
) {
|
||||
const utils = render(ViewerExportControls, {
|
||||
props: { onExportModel },
|
||||
props: { onExportModel, sourceFormat },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
return { ...utils, user: userEvent.setup() }
|
||||
@@ -114,4 +117,32 @@ describe('ViewerExportControls', () => {
|
||||
|
||||
expect(onExportModel).toHaveBeenCalledWith('glb')
|
||||
})
|
||||
|
||||
it('offers only the source format for direct-export files (e.g. spz)', async () => {
|
||||
const onExportModel = vi.fn()
|
||||
const { user } = renderComponent(onExportModel, 'spz')
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement
|
||||
|
||||
expect(Array.from(select.options).map((o) => o.value)).toEqual(['spz'])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
|
||||
expect(onExportModel).toHaveBeenCalledWith('spz')
|
||||
})
|
||||
|
||||
it('repairs the selected format when sourceFormat switches to a direct-export type', async () => {
|
||||
const onExportModel = vi.fn()
|
||||
const { user, rerender } = renderComponent(onExportModel, null)
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement
|
||||
|
||||
expect(select.value).toBe('obj')
|
||||
|
||||
await rerender({ onExportModel, sourceFormat: 'ply' })
|
||||
|
||||
expect(Array.from(select.options).map((o) => o.value)).toEqual(['ply'])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
|
||||
expect(onExportModel).toHaveBeenCalledWith('ply')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
@@ -34,20 +34,30 @@ import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
|
||||
|
||||
const { sourceFormat = null } = defineProps<{
|
||||
sourceFormat?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'exportModel', format: string): void
|
||||
}>()
|
||||
|
||||
const exportFormats = [
|
||||
{ label: 'GLB', value: 'glb' },
|
||||
{ label: 'OBJ', value: 'obj' },
|
||||
{ label: 'STL', value: 'stl' },
|
||||
{ label: 'FBX', value: 'fbx' }
|
||||
]
|
||||
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
|
||||
|
||||
const exportFormat = ref('obj')
|
||||
|
||||
watch(
|
||||
exportFormats,
|
||||
(formats) => {
|
||||
if (!formats.some((fmt) => fmt.value === exportFormat.value)) {
|
||||
exportFormat.value = formats[0]?.value ?? ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const exportModel = (format: string) => {
|
||||
emit('exportModel', format)
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ describe('useLoad3d', () => {
|
||||
exportModel: vi.fn().mockResolvedValue(undefined),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
getSourceFormat: vi.fn().mockReturnValue(null),
|
||||
getCurrentModelCapabilities: vi.fn().mockReturnValue({
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
|
||||
@@ -169,6 +169,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const isPreview = ref(false)
|
||||
const isSplatModel = ref(false)
|
||||
const isPlyModel = ref(false)
|
||||
const sourceFormat = ref<string | null>(null)
|
||||
const canFitToViewer = ref(true)
|
||||
const canCenterCameraOnModel = ref(false)
|
||||
const canUseGizmo = ref(true)
|
||||
@@ -905,6 +906,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
loading.value = false
|
||||
isSplatModel.value = load3d?.isSplatModel() ?? false
|
||||
isPlyModel.value = load3d?.isPlyModel() ?? false
|
||||
sourceFormat.value = load3d?.getSourceFormat() ?? null
|
||||
canCenterCameraOnModel.value = isSplatModel.value || isPlyModel.value
|
||||
const caps = load3d?.getCurrentModelCapabilities()
|
||||
canFitToViewer.value = caps?.fitToViewer ?? true
|
||||
@@ -1070,6 +1072,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isPreview,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
sourceFormat,
|
||||
canFitToViewer,
|
||||
canCenterCameraOnModel,
|
||||
canUseGizmo,
|
||||
|
||||
@@ -130,6 +130,7 @@ describe('useLoad3dViewer', () => {
|
||||
hasAnimations: vi.fn().mockReturnValue(false),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
getSourceFormat: vi.fn().mockReturnValue(null),
|
||||
getCurrentModelCapabilities: vi.fn().mockReturnValue({
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
@@ -618,7 +619,7 @@ describe('useLoad3dViewer', () => {
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: false,
|
||||
exportable: false,
|
||||
exportable: true,
|
||||
materialModes: [],
|
||||
fitTargetSize: 20
|
||||
})
|
||||
@@ -630,7 +631,7 @@ describe('useLoad3dViewer', () => {
|
||||
expect.stringContaining('dropped.splat')
|
||||
)
|
||||
expect(viewer.canUseLighting.value).toBe(false)
|
||||
expect(viewer.canExport.value).toBe(false)
|
||||
expect(viewer.canExport.value).toBe(true)
|
||||
expect(viewer.isSplatModel.value).toBe(true)
|
||||
expect([...viewer.materialModes.value]).toEqual([])
|
||||
})
|
||||
|
||||
@@ -83,6 +83,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
const isStandaloneMode = ref(false)
|
||||
const isSplatModel = ref(false)
|
||||
const isPlyModel = ref(false)
|
||||
const sourceFormat = ref<string | null>(null)
|
||||
const canFitToViewer = ref(true)
|
||||
const canUseGizmo = ref(true)
|
||||
const canUseLighting = ref(true)
|
||||
@@ -96,6 +97,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
const captureAdapterFlags = (source: Load3d) => {
|
||||
isSplatModel.value = source.isSplatModel()
|
||||
isPlyModel.value = source.isPlyModel()
|
||||
sourceFormat.value = source.getSourceFormat()
|
||||
const caps = source.getCurrentModelCapabilities()
|
||||
canFitToViewer.value = caps.fitToViewer
|
||||
canUseGizmo.value = caps.gizmoTransform
|
||||
@@ -839,6 +841,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
isStandaloneMode,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
sourceFormat,
|
||||
canFitToViewer,
|
||||
canUseGizmo,
|
||||
canUseLighting,
|
||||
|
||||
@@ -12,13 +12,17 @@ const {
|
||||
exportGLBMock,
|
||||
exportOBJMock,
|
||||
exportSTLMock,
|
||||
exportFBXMock
|
||||
exportFBXMock,
|
||||
exportDirectMock,
|
||||
detectFormatFromURLMock
|
||||
} = vi.hoisted(() => ({
|
||||
cloneSkinnedMock: vi.fn(),
|
||||
exportGLBMock: vi.fn(),
|
||||
exportOBJMock: vi.fn(),
|
||||
exportSTLMock: vi.fn(),
|
||||
exportFBXMock: vi.fn()
|
||||
exportFBXMock: vi.fn(),
|
||||
exportDirectMock: vi.fn(),
|
||||
detectFormatFromURLMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/utils/SkeletonUtils.js', () => ({
|
||||
@@ -30,7 +34,9 @@ vi.mock('@/extensions/core/load3d/ModelExporter', () => ({
|
||||
exportGLB: exportGLBMock,
|
||||
exportOBJ: exportOBJMock,
|
||||
exportSTL: exportSTLMock,
|
||||
exportFBX: exportFBXMock
|
||||
exportFBX: exportFBXMock,
|
||||
exportDirect: exportDirectMock,
|
||||
detectFormatFromURL: detectFormatFromURLMock
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -1172,5 +1178,56 @@ describe('Load3d', () => {
|
||||
'Unsupported export format: xyz'
|
||||
)
|
||||
})
|
||||
|
||||
it('downloads the source file directly for direct-export formats', async () => {
|
||||
exportDirectMock.mockReset()
|
||||
detectFormatFromURLMock.mockReturnValue('ply')
|
||||
const model = new THREE.Object3D()
|
||||
setupForExport({
|
||||
currentModel: model,
|
||||
originalFileName: 'cloud',
|
||||
originalURL: 'http://example.com/api/view?filename=cloud.ply'
|
||||
})
|
||||
|
||||
await ctx.load3d.exportModel('ply')
|
||||
|
||||
expect(exportDirectMock).toHaveBeenCalledWith(
|
||||
'http://example.com/api/view?filename=cloud.ply',
|
||||
'cloud.ply',
|
||||
'ply'
|
||||
)
|
||||
expect(exportGLBMock).not.toHaveBeenCalled()
|
||||
expect(exportOBJMock).not.toHaveBeenCalled()
|
||||
expect(cloneSkinnedMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a direct export when the requested format differs from the source', async () => {
|
||||
exportDirectMock.mockReset()
|
||||
detectFormatFromURLMock.mockReturnValue('spz')
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
setupForExport({
|
||||
currentModel: new THREE.Object3D(),
|
||||
originalFileName: 'scene',
|
||||
originalURL: 'http://example.com/api/view?filename=scene.spz'
|
||||
})
|
||||
|
||||
await expect(ctx.load3d.exportModel('ply')).rejects.toThrow(
|
||||
'Cannot export ply without converting from the loaded spz source'
|
||||
)
|
||||
expect(exportDirectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('getSourceFormat derives the extension from the original URL', () => {
|
||||
detectFormatFromURLMock.mockReturnValue('spz')
|
||||
setupForExport({
|
||||
currentModel: new THREE.Object3D(),
|
||||
originalURL: 'http://example.com/api/view?filename=scene.spz'
|
||||
})
|
||||
|
||||
expect(ctx.load3d.getSourceFormat()).toBe('spz')
|
||||
expect(detectFormatFromURLMock).toHaveBeenCalledWith(
|
||||
'http://example.com/api/view?filename=scene.spz'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { GizmoManager } from './GizmoManager'
|
||||
import type { HDRIManager } from './HDRIManager'
|
||||
import type { LightingManager } from './LightingManager'
|
||||
import type { LoaderManager } from './LoaderManager'
|
||||
import { DIRECT_EXPORT_FORMATS } from './constants'
|
||||
import { ModelExporter } from './ModelExporter'
|
||||
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
|
||||
import type { AdapterRef, ModelAdapterCapabilities } from './ModelAdapter'
|
||||
@@ -359,6 +360,27 @@ class Load3d {
|
||||
const exportMessage = `Exporting as ${format.toUpperCase()}...`
|
||||
this.eventManager.emitEvent('exportLoadingStart', exportMessage)
|
||||
|
||||
const originalFileName = this.modelManager.originalFileName || 'model'
|
||||
const filename = `${originalFileName}.${format}`
|
||||
const originalURL = this.modelManager.originalURL
|
||||
|
||||
if (DIRECT_EXPORT_FORMATS.has(format)) {
|
||||
try {
|
||||
if (this.getSourceFormat() !== format) {
|
||||
throw new Error(
|
||||
`Cannot export ${format} without converting from the loaded ${this.getSourceFormat() ?? 'unknown'} source`
|
||||
)
|
||||
}
|
||||
await ModelExporter.exportDirect(originalURL, filename, format)
|
||||
} catch (error) {
|
||||
console.error(`Error exporting model as ${format}:`, error)
|
||||
throw error
|
||||
} finally {
|
||||
this.eventManager.emitEvent('exportLoadingEnd', null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const source = this.modelManager.currentModel
|
||||
const savedPos = source.position.clone()
|
||||
const savedRot = source.rotation.clone()
|
||||
@@ -384,11 +406,6 @@ class Load3d {
|
||||
? Object.assign(cloneSkinned(source), { animations: clips })
|
||||
: source.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) {
|
||||
@@ -399,7 +416,7 @@ class Load3d {
|
||||
await ModelExporter.exportOBJ(model, filename, originalURL)
|
||||
break
|
||||
case 'stl':
|
||||
;(await ModelExporter.exportSTL(model, filename), originalURL)
|
||||
await ModelExporter.exportSTL(model, filename, originalURL)
|
||||
break
|
||||
case 'fbx':
|
||||
await ModelExporter.exportFBX(model, filename, originalURL)
|
||||
@@ -421,6 +438,12 @@ class Load3d {
|
||||
}
|
||||
}
|
||||
|
||||
getSourceFormat(): string | null {
|
||||
const url = this.modelManager.originalURL
|
||||
if (!url) return null
|
||||
return ModelExporter.detectFormatFromURL(url)
|
||||
}
|
||||
|
||||
setBackgroundColor(color: string): void {
|
||||
this.sceneManager.setBackgroundColor(color)
|
||||
|
||||
|
||||
@@ -338,6 +338,35 @@ describe('ModelExporter', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportDirect', () => {
|
||||
it('downloads the original source file unchanged', async () => {
|
||||
const blob = new Blob(['x'])
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) })
|
||||
)
|
||||
|
||||
await ModelExporter.exportDirect(
|
||||
'http://example.com/api/view?filename=src.ply',
|
||||
'out.ply',
|
||||
'ply'
|
||||
)
|
||||
|
||||
expect(downloadBlobMock).toHaveBeenCalledWith('out.ply', blob)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('throws without toasting when there is no source URL, leaving the alert to the caller', async () => {
|
||||
await expect(
|
||||
ModelExporter.exportDirect(null, 'out.spz', 'spz')
|
||||
).rejects.toThrow('No source file available to export as spz')
|
||||
expect(downloadBlobMock).not.toHaveBeenCalled()
|
||||
expect(addAlertMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportFBX', () => {
|
||||
it('uses the direct-URL fast path for matching .fbx URLs', async () => {
|
||||
const blob = new Blob(['x'])
|
||||
|
||||
@@ -184,6 +184,18 @@ export class ModelExporter {
|
||||
}
|
||||
}
|
||||
|
||||
static async exportDirect(
|
||||
originalURL: string | null | undefined,
|
||||
filename: string,
|
||||
format: string
|
||||
): Promise<void> {
|
||||
if (!originalURL) {
|
||||
throw new Error(`No source file available to export as ${format}`)
|
||||
}
|
||||
|
||||
return ModelExporter.downloadFromURL(originalURL, filename)
|
||||
}
|
||||
|
||||
private static saveArrayBuffer(buffer: ArrayBuffer, filename: string): void {
|
||||
const blob = new Blob([buffer], { type: 'application/octet-stream' })
|
||||
downloadBlob(filename, blob)
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('SplatModelAdapter', () => {
|
||||
const adapter = new SplatModelAdapter()
|
||||
expect(adapter.kind).toBe('splat')
|
||||
expect(adapter.capabilities.lighting).toBe(false)
|
||||
expect(adapter.capabilities.exportable).toBe(false)
|
||||
expect(adapter.capabilities.exportable).toBe(true)
|
||||
expect([...adapter.capabilities.materialModes]).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export class SplatModelAdapter implements ModelAdapter {
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: false,
|
||||
exportable: false,
|
||||
exportable: true,
|
||||
materialModes: [],
|
||||
fitTargetSize: 20
|
||||
}
|
||||
|
||||
38
src/extensions/core/load3d/constants.test.ts
Normal file
38
src/extensions/core/load3d/constants.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getExportFormatOptions } from './constants'
|
||||
|
||||
describe('getExportFormatOptions', () => {
|
||||
it('returns the convertible mesh formats for mesh sources', () => {
|
||||
expect(getExportFormatOptions('glb').map((o) => o.value)).toEqual([
|
||||
'glb',
|
||||
'obj',
|
||||
'stl',
|
||||
'fbx'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns the convertible mesh formats when the source is unknown', () => {
|
||||
expect(getExportFormatOptions(null).map((o) => o.value)).toEqual([
|
||||
'glb',
|
||||
'obj',
|
||||
'stl',
|
||||
'fbx'
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['ply', 'spz', 'splat', 'ksplat'])(
|
||||
'offers only the source format for direct-export type %s',
|
||||
(format) => {
|
||||
expect(getExportFormatOptions(format)).toEqual([
|
||||
{ label: format.toUpperCase(), value: format }
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('matches direct-export formats case-insensitively', () => {
|
||||
expect(getExportFormatOptions('PLY')).toEqual([
|
||||
{ label: 'PLY', value: 'ply' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -24,3 +24,27 @@ export const SUPPORTED_HDRI_EXTENSIONS_ACCEPT = [
|
||||
].join(',')
|
||||
|
||||
export const LOAD3D_NONE_MODEL = 'none'
|
||||
|
||||
export const DIRECT_EXPORT_FORMATS = new Set(['ply', 'spz', 'splat', 'ksplat'])
|
||||
|
||||
export interface ExportFormatOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const CONVERTIBLE_EXPORT_FORMAT_OPTIONS: ExportFormatOption[] = [
|
||||
{ label: 'GLB', value: 'glb' },
|
||||
{ label: 'OBJ', value: 'obj' },
|
||||
{ label: 'STL', value: 'stl' },
|
||||
{ label: 'FBX', value: 'fbx' }
|
||||
]
|
||||
|
||||
export function getExportFormatOptions(
|
||||
sourceFormat: string | null | undefined
|
||||
): ExportFormatOption[] {
|
||||
const format = sourceFormat?.toLowerCase()
|
||||
if (format && DIRECT_EXPORT_FORMATS.has(format)) {
|
||||
return [{ label: format.toUpperCase(), value: format }]
|
||||
}
|
||||
return CONVERTIBLE_EXPORT_FORMAT_OPTIONS
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ onUnmounted(() => {
|
||||
:can-export="viewer.canExport"
|
||||
:material-modes="viewer.materialModes"
|
||||
:has-skeleton="viewer.hasSkeleton"
|
||||
:source-format="viewer.sourceFormat"
|
||||
@update-background-image="viewer.handleBackgroundImageUpdate"
|
||||
@export-model="viewer.exportModel"
|
||||
/>
|
||||
|
||||
@@ -37,6 +37,7 @@ type UseLoad3dViewerFn = (node?: LGraphNode) => {
|
||||
isStandaloneMode: { value: boolean }
|
||||
isSplatModel: { value: boolean }
|
||||
isPlyModel: { value: boolean }
|
||||
sourceFormat: { value: string | null }
|
||||
animations: { value: AnimationItem[] }
|
||||
playing: { value: boolean }
|
||||
selectedSpeed: { value: number }
|
||||
|
||||
Reference in New Issue
Block a user