Compare commits

...

1 Commits

Author SHA1 Message Date
Terry Jia
b9112f9bd7 feat(load3d) FE-999: export point cloud / splat files as-is (#12810)
## Summary
PLY, SPZ, SPLAT and KSPLAT have no THREE.js exporter, so the 3D node now
offers them as export options only when the loaded file already uses
that format, and exports the original file unchanged instead of
attempting a conversion. Mesh files keep the convertible GLB/OBJ/STL/FBX
set.

Splat models (spz/splat/ksplat and gaussian-splat ply) previously had
exportable: false, which hid the export UI entirely; enable it so these
formats can be downloaded as-is. Also fixes STL export dropping its
originalURL fast-path arg.

## Screenshots (if applicable)



https://github.com/user-attachments/assets/8417c697-eb25-4cfb-af44-3855b814fa5d
2026-06-14 04:48:06 -04:00
22 changed files with 296 additions and 33 deletions

View File

@@ -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,

View File

@@ -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')

View File

@@ -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),

View File

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

View File

@@ -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()

View File

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

View File

@@ -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')
})
})

View File

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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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([])
})

View File

@@ -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,

View File

@@ -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'
)
})
})
})

View File

@@ -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)

View File

@@ -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'])

View File

@@ -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)

View File

@@ -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([])
})

View File

@@ -19,7 +19,7 @@ export class SplatModelAdapter implements ModelAdapter {
requiresMaterialRebuild: false,
gizmoTransform: true,
lighting: false,
exportable: false,
exportable: true,
materialModes: [],
fitTargetSize: 20
}

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

View File

@@ -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
}

View File

@@ -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"
/>

View File

@@ -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 }