mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
3dgs & ply support (#7602)
## Summary integrated sparkjs https://sparkjs.dev/, built by [world labs ](https://www.worldlabs.ai/) to support 3dgs. - Add 3D Gaussian Splatting (3DGS) support using @sparkjsdev/spark library - Add PLY file format support with multiple rendering engines - Support new file formats: `.ply`, `.spz`, `.splat`, `.ksplat` - Add PLY Engine setting with three options: `threejs` (mesh), `fastply` (optimized ASCII point clouds), `sparkjs` (3DGS) - Add `FastPLYLoader` for 4-5x faster ASCII PLY parsing - Add `original(Advanced)` material mode for point cloud rendering with THREE.Points 3dgs generated by https://marble.worldlabs.ai/ test ply file from: 1. made by https://github.com/PozzettiAndrea/ComfyUI-DepthAnythingV3 2. threejs offically repo ## Screenshots https://github.com/user-attachments/assets/44e64d3e-b58d-4341-9a70-a9aa64801220 https://github.com/user-attachments/assets/76b0dfba-0c12-4f64-91cb-bfc5d672294d https://github.com/user-attachments/assets/2a8bfe81-1fb2-44c4-8787-dff325369c61 https://github.com/user-attachments/assets/e4beecee-d7a2-40c9-97f7-79b09c60312d ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7602-3dgs-ply-support-2cd6d73d3650814098fcea86cfaf747d) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -146,6 +146,7 @@
|
||||
"@primevue/icons": "catalog:",
|
||||
"@primevue/themes": "catalog:",
|
||||
"@sentry/vue": "catalog:",
|
||||
"@sparkjsdev/spark": "catalog:",
|
||||
"@tiptap/core": "^2.10.4",
|
||||
"@tiptap/extension-link": "^2.10.4",
|
||||
"@tiptap/extension-table": "^2.10.4",
|
||||
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -78,6 +78,9 @@ catalogs:
|
||||
'@sentry/vue':
|
||||
specifier: ^8.48.0
|
||||
version: 8.48.0
|
||||
'@sparkjsdev/spark':
|
||||
specifier: ^0.1.10
|
||||
version: 0.1.10
|
||||
'@storybook/addon-docs':
|
||||
specifier: ^10.1.9
|
||||
version: 10.1.9
|
||||
@@ -374,6 +377,9 @@ importers:
|
||||
'@sentry/vue':
|
||||
specifier: 'catalog:'
|
||||
version: 8.48.0(pinia@2.2.2(typescript@5.9.3)(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))
|
||||
'@sparkjsdev/spark':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.10
|
||||
'@tiptap/core':
|
||||
specifier: ^2.10.4
|
||||
version: 2.10.4(@tiptap/pm@2.10.4)
|
||||
@@ -3112,6 +3118,9 @@ packages:
|
||||
'@sinclair/typebox@0.34.40':
|
||||
resolution: {integrity: sha512-gwBNIP8ZAYev/ORDWW0QvxdwPXwxBtLsdsJgSc7eDIRt8ubP+rxUBzPsrwnu16fgEF8Bx4lh/+mvQvJzcTM6Kw==}
|
||||
|
||||
'@sparkjsdev/spark@0.1.10':
|
||||
resolution: {integrity: sha512-CiijdZQuj7KPDUqIZPiEqyUkJCYo1JqR05vq/V+ElxMwqR7L70ZuZDyIKcasjZHSiPB8pGRMH8HZGqUKO9aRPQ==}
|
||||
|
||||
'@storybook/addon-docs@10.1.9':
|
||||
resolution: {integrity: sha512-SvwEZ32lyk5p3PRmE3pmfAhs4HMiVo5zxjTBVmK9kgz9zGgWCTlikb56tJ998hVe52CFyCvt3I9rkHeYMCKPww==}
|
||||
peerDependencies:
|
||||
@@ -10857,6 +10866,10 @@ snapshots:
|
||||
|
||||
'@sinclair/typebox@0.34.40': {}
|
||||
|
||||
'@sparkjsdev/spark@0.1.10':
|
||||
dependencies:
|
||||
fflate: 0.8.2
|
||||
|
||||
'@storybook/addon-docs@10.1.9(@types/react@19.1.9)(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@mdx-js/react': 3.1.1(@types/react@19.1.9)(react@19.2.3)
|
||||
|
||||
@@ -27,6 +27,7 @@ catalog:
|
||||
'@primevue/themes': ^4.2.5
|
||||
'@sentry/vite-plugin': ^4.6.0
|
||||
'@sentry/vue': ^8.48.0
|
||||
'@sparkjsdev/spark': ^0.1.10
|
||||
'@storybook/addon-docs': ^10.1.9
|
||||
'@storybook/vue3': ^10.1.9
|
||||
'@storybook/vue3-vite': ^10.1.9
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
v-model:model-config="modelConfig"
|
||||
v-model:camera-config="cameraConfig"
|
||||
v-model:light-config="lightConfig"
|
||||
:is-splat-model="isSplatModel"
|
||||
:is-ply-model="isPlyModel"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
@@ -109,6 +111,8 @@ const {
|
||||
// other state
|
||||
isRecording,
|
||||
isPreview,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
animations,
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
v-if="showModelControls"
|
||||
v-model:material-mode="modelConfig!.materialMode"
|
||||
v-model:up-direction="modelConfig!.upDirection"
|
||||
:hide-material-mode="isSplatModel"
|
||||
:is-ply-model="isPlyModel"
|
||||
/>
|
||||
|
||||
<CameraControls
|
||||
@@ -85,6 +87,11 @@ import type {
|
||||
SceneConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const { isSplatModel = false, isPlyModel = false } = defineProps<{
|
||||
isSplatModel?: boolean
|
||||
isPlyModel?: boolean
|
||||
}>()
|
||||
|
||||
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
|
||||
const modelConfig = defineModel<ModelConfig>('modelConfig')
|
||||
const cameraConfig = defineModel<CameraConfig>('cameraConfig')
|
||||
@@ -101,6 +108,10 @@ const categoryLabels: Record<string, string> = {
|
||||
}
|
||||
|
||||
const availableCategories = computed(() => {
|
||||
if (isSplatModel) {
|
||||
return ['scene', 'model', 'camera']
|
||||
}
|
||||
|
||||
return ['scene', 'model', 'camera', 'light', 'export']
|
||||
})
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
<ModelControls
|
||||
v-model:up-direction="viewer.upDirection.value"
|
||||
v-model:material-mode="viewer.materialMode.value"
|
||||
:hide-material-mode="viewer.isSplatModel.value"
|
||||
:is-ply-model="viewer.isPlyModel.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -56,13 +58,13 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<div v-if="!viewer.isSplatModel.value" class="space-y-4 p-2">
|
||||
<LightControls
|
||||
v-model:light-intensity="viewer.lightIntensity.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<div v-if="!viewer.isSplatModel.value" class="space-y-4 p-2">
|
||||
<ExportControls @export-model="viewer.exportModel" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="show-material-mode relative">
|
||||
<div v-if="!hideMaterialMode" class="show-material-mode relative">
|
||||
<Button
|
||||
class="p-button-rounded p-button-text"
|
||||
@click="toggleMaterialMode"
|
||||
@@ -71,6 +71,11 @@ import type {
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{
|
||||
hideMaterialMode?: boolean
|
||||
isPlyModel?: boolean
|
||||
}>()
|
||||
|
||||
const materialMode = defineModel<MaterialMode>('materialMode')
|
||||
const upDirection = defineModel<UpDirection>('upDirection')
|
||||
|
||||
@@ -95,6 +100,11 @@ const materialModes = computed(() => {
|
||||
//'depth' disable for now
|
||||
]
|
||||
|
||||
// Only show pointCloud mode for PLY files (point cloud rendering)
|
||||
if (isPlyModel) {
|
||||
modes.splice(1, 0, 'pointCloud')
|
||||
}
|
||||
|
||||
return modes
|
||||
})
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div v-if="!hideMaterialMode">
|
||||
<label>{{ $t('load3d.materialMode') }}</label>
|
||||
<Select
|
||||
v-model="materialMode"
|
||||
@@ -32,6 +32,11 @@ import type {
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{
|
||||
hideMaterialMode?: boolean
|
||||
isPlyModel?: boolean
|
||||
}>()
|
||||
|
||||
const upDirection = defineModel<UpDirection>('upDirection')
|
||||
const materialMode = defineModel<MaterialMode>('materialMode')
|
||||
|
||||
@@ -46,10 +51,22 @@ const upDirectionOptions = [
|
||||
]
|
||||
|
||||
const materialModeOptions = computed(() => {
|
||||
return [
|
||||
{ label: t('load3d.materialModes.original'), value: 'original' },
|
||||
const options = [
|
||||
{ label: t('load3d.materialModes.original'), value: 'original' }
|
||||
]
|
||||
|
||||
if (isPlyModel) {
|
||||
options.push({
|
||||
label: t('load3d.materialModes.pointCloud'),
|
||||
value: 'pointCloud'
|
||||
})
|
||||
}
|
||||
|
||||
options.push(
|
||||
{ label: t('load3d.materialModes.normal'), value: 'normal' },
|
||||
{ label: t('load3d.materialModes.wireframe'), value: 'wireframe' }
|
||||
]
|
||||
)
|
||||
|
||||
return options
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -63,6 +63,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const loading = ref(false)
|
||||
const loadingMessage = ref('')
|
||||
const isPreview = ref(false)
|
||||
const isSplatModel = ref(false)
|
||||
const isPlyModel = ref(false)
|
||||
|
||||
const initializeLoad3d = async (containerRef: HTMLElement) => {
|
||||
const rawNode = toRaw(nodeRef.value)
|
||||
@@ -490,6 +492,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
modelLoadingEnd: () => {
|
||||
loadingMessage.value = ''
|
||||
loading.value = false
|
||||
isSplatModel.value = load3d?.isSplatModel() ?? false
|
||||
isPlyModel.value = load3d?.isPlyModel() ?? false
|
||||
},
|
||||
exportLoadingStart: (message: string) => {
|
||||
loadingMessage.value = message || t('load3d.exportingModel')
|
||||
@@ -561,6 +565,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
lightConfig,
|
||||
isRecording,
|
||||
isPreview,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
animations,
|
||||
|
||||
@@ -46,6 +46,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
const needApplyChanges = ref(true)
|
||||
const isPreview = ref(false)
|
||||
const isStandaloneMode = ref(false)
|
||||
const isSplatModel = ref(false)
|
||||
const isPlyModel = ref(false)
|
||||
|
||||
let load3d: Load3d | null = null
|
||||
let sourceLoad3d: Load3d | null = null
|
||||
@@ -253,6 +255,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
modelConfig.materialMode || source.modelManager.materialMode
|
||||
}
|
||||
|
||||
isSplatModel.value = source.isSplatModel()
|
||||
isPlyModel.value = source.isPlyModel()
|
||||
|
||||
initialState.value = {
|
||||
backgroundColor: backgroundColor.value,
|
||||
showGrid: showGrid.value,
|
||||
@@ -301,6 +306,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
backgroundRenderMode.value = 'tiled'
|
||||
upDirection.value = 'original'
|
||||
materialMode.value = 'original'
|
||||
isSplatModel.value = load3d.isSplatModel()
|
||||
isPlyModel.value = load3d.isPlyModel()
|
||||
|
||||
isPreview.value = true
|
||||
} catch (error) {
|
||||
@@ -517,6 +524,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
needApplyChanges,
|
||||
isPreview,
|
||||
isStandaloneMode,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
|
||||
// Methods
|
||||
initializeViewer,
|
||||
|
||||
@@ -198,6 +198,17 @@ useExtensionService().registerExtension({
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
experimental: true
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Load3D.PLYEngine',
|
||||
category: ['3D', 'PLY', 'PLY Engine'],
|
||||
name: 'PLY Engine',
|
||||
tooltip:
|
||||
'Select the engine for loading PLY files. "threejs" uses the native Three.js PLYLoader (best for mesh PLY files). "fastply" uses an optimized loader for ASCII point cloud PLY files. "sparkjs" uses Spark.js for 3D Gaussian Splatting PLY files.',
|
||||
type: 'combo',
|
||||
options: ['threejs', 'fastply', 'sparkjs'],
|
||||
defaultValue: 'threejs',
|
||||
experimental: true
|
||||
}
|
||||
],
|
||||
commands: [
|
||||
@@ -238,7 +249,10 @@ useExtensionService().registerExtension({
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
LOAD_3D(node) {
|
||||
const fileInput = createFileInput('.gltf,.glb,.obj,.fbx,.stl', false)
|
||||
const fileInput = createFileInput(
|
||||
'.gltf,.glb,.obj,.fbx,.stl,.ply,.spz,.splat,.ksplat',
|
||||
false
|
||||
)
|
||||
|
||||
node.properties['Resource Folder'] = ''
|
||||
|
||||
@@ -301,6 +315,8 @@ useExtensionService().registerExtension({
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
|
||||
if (load3d.isSplatModel()) return []
|
||||
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
|
||||
@@ -409,6 +425,8 @@ useExtensionService().registerExtension({
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
|
||||
if (load3d.isSplatModel()) return []
|
||||
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
|
||||
|
||||
@@ -577,6 +577,14 @@ class Load3d {
|
||||
this.loadingPromise = null
|
||||
}
|
||||
|
||||
isSplatModel(): boolean {
|
||||
return this.modelManager.containsSplatMesh()
|
||||
}
|
||||
|
||||
isPlyModel(): boolean {
|
||||
return this.modelManager.originalModel instanceof THREE.BufferGeometry
|
||||
}
|
||||
|
||||
clearModel(): void {
|
||||
this.animationManager.dispose()
|
||||
this.modelManager.clearModel()
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { SplatMesh } from '@sparkjsdev/spark'
|
||||
import * as THREE from 'three'
|
||||
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
|
||||
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
|
||||
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { isPLYAsciiFormat } from '@/scripts/metadata/ply'
|
||||
|
||||
import {
|
||||
type EventManagerInterface,
|
||||
type LoaderManagerInterface,
|
||||
type ModelManagerInterface
|
||||
} from './interfaces'
|
||||
import { FastPLYLoader } from './loader/FastPLYLoader'
|
||||
|
||||
export class LoaderManager implements LoaderManagerInterface {
|
||||
gltfLoader: GLTFLoader
|
||||
@@ -20,9 +26,12 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
mtlLoader: MTLLoader
|
||||
fbxLoader: FBXLoader
|
||||
stlLoader: STLLoader
|
||||
plyLoader: PLYLoader
|
||||
fastPlyLoader: FastPLYLoader
|
||||
|
||||
private modelManager: ModelManagerInterface
|
||||
private eventManager: EventManagerInterface
|
||||
private currentLoadId: number = 0
|
||||
|
||||
constructor(
|
||||
modelManager: ModelManagerInterface,
|
||||
@@ -36,6 +45,8 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
this.mtlLoader = new MTLLoader()
|
||||
this.fbxLoader = new FBXLoader()
|
||||
this.stlLoader = new STLLoader()
|
||||
this.plyLoader = new PLYLoader()
|
||||
this.fastPlyLoader = new FastPLYLoader()
|
||||
}
|
||||
|
||||
init(): void {}
|
||||
@@ -43,6 +54,8 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
dispose(): void {}
|
||||
|
||||
async loadModel(url: string, originalFileName?: string): Promise<void> {
|
||||
const loadId = ++this.currentLoadId
|
||||
|
||||
try {
|
||||
this.eventManager.emitEvent('modelLoadingStart', null)
|
||||
|
||||
@@ -72,7 +85,11 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
return
|
||||
}
|
||||
|
||||
let model = await this.loadModelInternal(url, fileExtension)
|
||||
const model = await this.loadModelInternal(url, fileExtension)
|
||||
|
||||
if (loadId !== this.currentLoadId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (model) {
|
||||
await this.modelManager.setupModel(model)
|
||||
@@ -80,9 +97,11 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
|
||||
this.eventManager.emitEvent('modelLoadingEnd', null)
|
||||
} catch (error) {
|
||||
this.eventManager.emitEvent('modelLoadingEnd', null)
|
||||
console.error('Error loading model:', error)
|
||||
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
|
||||
if (loadId === this.currentLoadId) {
|
||||
this.eventManager.emitEvent('modelLoadingEnd', null)
|
||||
console.error('Error loading model:', error)
|
||||
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,8 +206,132 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'ply':
|
||||
model = await this.loadPLY(path, filename)
|
||||
break
|
||||
|
||||
case 'spz':
|
||||
case 'splat':
|
||||
case 'ksplat':
|
||||
model = await this.loadSplat(path, filename)
|
||||
break
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
private async fetchModelData(path: string, filename: string) {
|
||||
const route =
|
||||
'/' + path.replace(/^api\//, '') + encodeURIComponent(filename)
|
||||
const response = await api.fetchApi(route)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch model: ${response.status}`)
|
||||
}
|
||||
return response.arrayBuffer()
|
||||
}
|
||||
|
||||
private async loadSplat(
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
const arrayBuffer = await this.fetchModelData(path, filename)
|
||||
|
||||
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
|
||||
this.modelManager.setOriginalModel(splatMesh)
|
||||
const splatGroup = new THREE.Group()
|
||||
splatGroup.add(splatMesh)
|
||||
return splatGroup
|
||||
}
|
||||
|
||||
private async loadPLY(
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null> {
|
||||
const plyEngine = useSettingStore().get('Comfy.Load3D.PLYEngine') as string
|
||||
|
||||
if (plyEngine === 'sparkjs') {
|
||||
return this.loadSplat(path, filename)
|
||||
}
|
||||
|
||||
// Use Three.js PLYLoader or FastPLYLoader for point cloud PLY files
|
||||
const arrayBuffer = await this.fetchModelData(path, filename)
|
||||
|
||||
const isASCII = isPLYAsciiFormat(arrayBuffer)
|
||||
|
||||
let plyGeometry: THREE.BufferGeometry
|
||||
|
||||
if (isASCII && plyEngine === 'fastply') {
|
||||
plyGeometry = this.fastPlyLoader.parse(arrayBuffer)
|
||||
} else {
|
||||
this.plyLoader.setPath(path)
|
||||
plyGeometry = this.plyLoader.parse(arrayBuffer)
|
||||
}
|
||||
|
||||
this.modelManager.setOriginalModel(plyGeometry)
|
||||
plyGeometry.computeVertexNormals()
|
||||
|
||||
const hasVertexColors = plyGeometry.attributes.color !== undefined
|
||||
const materialMode = this.modelManager.materialMode
|
||||
|
||||
// Use Points rendering for pointCloud mode (better for point clouds)
|
||||
if (materialMode === 'pointCloud') {
|
||||
plyGeometry.computeBoundingSphere()
|
||||
if (plyGeometry.boundingSphere) {
|
||||
const center = plyGeometry.boundingSphere.center
|
||||
const radius = plyGeometry.boundingSphere.radius
|
||||
|
||||
plyGeometry.translate(-center.x, -center.y, -center.z)
|
||||
|
||||
if (radius > 0) {
|
||||
const scale = 1.0 / radius
|
||||
plyGeometry.scale(scale, scale, scale)
|
||||
}
|
||||
}
|
||||
|
||||
const pointMaterial = hasVertexColors
|
||||
? new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
vertexColors: true,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
: new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
color: 0xcccccc,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
|
||||
const plyPoints = new THREE.Points(plyGeometry, pointMaterial)
|
||||
this.modelManager.originalMaterials.set(
|
||||
plyPoints as unknown as THREE.Mesh,
|
||||
pointMaterial
|
||||
)
|
||||
|
||||
const plyGroup = new THREE.Group()
|
||||
plyGroup.add(plyPoints)
|
||||
return plyGroup
|
||||
}
|
||||
|
||||
// Use Mesh rendering for other modes
|
||||
let plyMaterial: THREE.Material
|
||||
|
||||
if (hasVertexColors) {
|
||||
plyMaterial = new THREE.MeshStandardMaterial({
|
||||
vertexColors: true,
|
||||
metalness: 0.0,
|
||||
roughness: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
} else {
|
||||
plyMaterial = this.modelManager.standardMaterial.clone()
|
||||
plyMaterial.side = THREE.DoubleSide
|
||||
}
|
||||
|
||||
const plyMesh = new THREE.Mesh(plyGeometry, plyMaterial)
|
||||
this.modelManager.originalMaterials.set(plyMesh, plyMaterial)
|
||||
|
||||
const plyGroup = new THREE.Group()
|
||||
plyGroup.add(plyMesh)
|
||||
return plyGroup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SplatMesh } from '@sparkjsdev/spark'
|
||||
import * as THREE from 'three'
|
||||
import { type GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
|
||||
@@ -98,6 +99,145 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
})
|
||||
}
|
||||
|
||||
private handlePLYModeSwitch(mode: MaterialMode): void {
|
||||
if (!(this.originalModel instanceof THREE.BufferGeometry)) {
|
||||
return
|
||||
}
|
||||
|
||||
const plyGeometry = this.originalModel.clone()
|
||||
const hasVertexColors = plyGeometry.attributes.color !== undefined
|
||||
|
||||
// Find and remove ALL MainModel instances by name to ensure deletion
|
||||
const oldMainModels: THREE.Object3D[] = []
|
||||
this.scene.traverse((obj) => {
|
||||
if (obj.name === 'MainModel') {
|
||||
oldMainModels.push(obj)
|
||||
}
|
||||
})
|
||||
|
||||
// Remove and dispose all found MainModels
|
||||
oldMainModels.forEach((oldModel) => {
|
||||
oldModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh || child instanceof THREE.Points) {
|
||||
child.geometry?.dispose()
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach((m) => m.dispose())
|
||||
} else {
|
||||
child.material?.dispose()
|
||||
}
|
||||
}
|
||||
})
|
||||
this.scene.remove(oldModel)
|
||||
})
|
||||
|
||||
this.currentModel = null
|
||||
|
||||
let newModel: THREE.Object3D
|
||||
|
||||
if (mode === 'pointCloud') {
|
||||
// Use Points rendering for point cloud mode
|
||||
plyGeometry.computeBoundingSphere()
|
||||
if (plyGeometry.boundingSphere) {
|
||||
const center = plyGeometry.boundingSphere.center
|
||||
const radius = plyGeometry.boundingSphere.radius
|
||||
|
||||
plyGeometry.translate(-center.x, -center.y, -center.z)
|
||||
|
||||
if (radius > 0) {
|
||||
const scale = 1.0 / radius
|
||||
plyGeometry.scale(scale, scale, scale)
|
||||
}
|
||||
}
|
||||
|
||||
const pointMaterial = hasVertexColors
|
||||
? new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
vertexColors: true,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
: new THREE.PointsMaterial({
|
||||
size: 0.005,
|
||||
color: 0xcccccc,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
|
||||
const points = new THREE.Points(plyGeometry, pointMaterial)
|
||||
newModel = new THREE.Group()
|
||||
newModel.add(points)
|
||||
} else {
|
||||
// Use Mesh rendering for other modes
|
||||
let meshMaterial: THREE.Material = hasVertexColors
|
||||
? new THREE.MeshStandardMaterial({
|
||||
vertexColors: true,
|
||||
metalness: 0.0,
|
||||
roughness: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
: this.standardMaterial.clone()
|
||||
|
||||
if (
|
||||
!hasVertexColors &&
|
||||
meshMaterial instanceof THREE.MeshStandardMaterial
|
||||
) {
|
||||
meshMaterial.side = THREE.DoubleSide
|
||||
}
|
||||
|
||||
const mesh = new THREE.Mesh(plyGeometry, meshMaterial)
|
||||
this.originalMaterials.set(mesh, meshMaterial)
|
||||
|
||||
newModel = new THREE.Group()
|
||||
newModel.add(mesh)
|
||||
|
||||
// Apply the requested material mode
|
||||
if (mode === 'normal') {
|
||||
mesh.material = new THREE.MeshNormalMaterial({
|
||||
flatShading: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
} else if (mode === 'wireframe') {
|
||||
mesh.material = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
wireframe: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Double check: remove any remaining MainModel before adding new one
|
||||
const remainingMainModels: THREE.Object3D[] = []
|
||||
this.scene.traverse((obj) => {
|
||||
if (obj.name === 'MainModel') {
|
||||
remainingMainModels.push(obj)
|
||||
}
|
||||
})
|
||||
remainingMainModels.forEach((obj) => this.scene.remove(obj))
|
||||
|
||||
this.currentModel = newModel
|
||||
newModel.name = 'MainModel'
|
||||
|
||||
// Setup the new model
|
||||
if (mode === 'pointCloud') {
|
||||
this.scene.add(newModel)
|
||||
} else {
|
||||
const box = new THREE.Box3().setFromObject(newModel)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
|
||||
const maxDim = Math.max(size.x, size.y, size.z)
|
||||
const targetSize = 5
|
||||
const scale = targetSize / maxDim
|
||||
newModel.scale.multiplyScalar(scale)
|
||||
|
||||
box.setFromObject(newModel)
|
||||
box.getCenter(center)
|
||||
box.getSize(size)
|
||||
|
||||
newModel.position.set(-center.x, -box.min.y, -center.z)
|
||||
this.scene.add(newModel)
|
||||
}
|
||||
|
||||
this.eventManager.emitEvent('materialModeChange', mode)
|
||||
}
|
||||
|
||||
setMaterialMode(mode: MaterialMode): void {
|
||||
if (!this.currentModel || mode === this.materialMode) {
|
||||
return
|
||||
@@ -105,6 +245,12 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
|
||||
this.materialMode = mode
|
||||
|
||||
// Handle PLY files specially - they need to be recreated for mode switch
|
||||
if (this.originalModel instanceof THREE.BufferGeometry) {
|
||||
this.handlePLYModeSwitch(mode)
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'depth') {
|
||||
this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace
|
||||
} else {
|
||||
@@ -186,6 +332,7 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
})
|
||||
break
|
||||
case 'original':
|
||||
case 'pointCloud':
|
||||
const originalMaterial = this.originalMaterials.get(child)
|
||||
if (originalMaterial) {
|
||||
child.material = originalMaterial
|
||||
@@ -272,12 +419,25 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
|
||||
addModelToScene(model: THREE.Object3D): void {
|
||||
this.currentModel = model
|
||||
model.name = 'MainModel'
|
||||
|
||||
this.scene.add(this.currentModel)
|
||||
}
|
||||
|
||||
async setupModel(model: THREE.Object3D): Promise<void> {
|
||||
this.currentModel = model
|
||||
model.name = 'MainModel'
|
||||
|
||||
// Check if model is or contains a SplatMesh (3D Gaussian Splatting)
|
||||
const isSplatModel = this.containsSplatMesh(model)
|
||||
|
||||
if (isSplatModel) {
|
||||
// SplatMesh handles its own rendering, just add to scene
|
||||
this.scene.add(model)
|
||||
// Set a default camera distance for splat models
|
||||
this.setupCamera(new THREE.Vector3(5, 5, 5))
|
||||
return
|
||||
}
|
||||
|
||||
const box = new THREE.Box3().setFromObject(model)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
@@ -308,6 +468,17 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
this.setupCamera(size)
|
||||
}
|
||||
|
||||
containsSplatMesh(model?: THREE.Object3D | null): boolean {
|
||||
const target = model ?? this.currentModel
|
||||
if (!target) return false
|
||||
if (target instanceof SplatMesh) return true
|
||||
let found = false
|
||||
target.traverse((child) => {
|
||||
if (child instanceof SplatMesh) found = true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void {
|
||||
this.originalModel = model
|
||||
}
|
||||
|
||||
@@ -7,7 +7,12 @@ import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
|
||||
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
|
||||
export type MaterialMode = 'original' | 'normal' | 'wireframe' | 'depth'
|
||||
export type MaterialMode =
|
||||
| 'original'
|
||||
| 'pointCloud'
|
||||
| 'normal'
|
||||
| 'wireframe'
|
||||
| 'depth'
|
||||
export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
|
||||
export type CameraType = 'perspective' | 'orthographic'
|
||||
export type BackgroundRenderModeType = 'tiled' | 'panorama'
|
||||
@@ -186,5 +191,9 @@ export const SUPPORTED_EXTENSIONS = new Set([
|
||||
'.glb',
|
||||
'.obj',
|
||||
'.fbx',
|
||||
'.stl'
|
||||
'.stl',
|
||||
'.spz',
|
||||
'.splat',
|
||||
'.ply',
|
||||
'.ksplat'
|
||||
])
|
||||
|
||||
33
src/extensions/core/load3d/loader/FastPLYLoader.ts
Normal file
33
src/extensions/core/load3d/loader/FastPLYLoader.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { parseASCIIPLY } from '@/scripts/metadata/ply'
|
||||
|
||||
/**
|
||||
* Fast ASCII PLY Loader
|
||||
* Optimized for simple ASCII PLY files with position and color data
|
||||
* 4-5x faster than Three.js PLYLoader for ASCII files
|
||||
*/
|
||||
export class FastPLYLoader {
|
||||
parse(arrayBuffer: ArrayBuffer): THREE.BufferGeometry {
|
||||
const plyData = parseASCIIPLY(arrayBuffer)
|
||||
|
||||
if (!plyData) {
|
||||
throw new Error('Failed to parse PLY data')
|
||||
}
|
||||
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
new THREE.BufferAttribute(plyData.positions, 3)
|
||||
)
|
||||
|
||||
if (plyData.colors) {
|
||||
geometry.setAttribute(
|
||||
'color',
|
||||
new THREE.BufferAttribute(plyData.colors, 3)
|
||||
)
|
||||
}
|
||||
|
||||
return geometry
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,8 @@ useExtensionService().registerExtension({
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
|
||||
if (load3d.isSplatModel()) return []
|
||||
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
|
||||
|
||||
@@ -1637,6 +1637,7 @@
|
||||
"normal": "Normal",
|
||||
"wireframe": "Wireframe",
|
||||
"original": "Original",
|
||||
"pointCloud": "Point Cloud",
|
||||
"depth": "Depth",
|
||||
"lineart": "Lineart"
|
||||
},
|
||||
|
||||
@@ -508,6 +508,7 @@ const zSettings = z.object({
|
||||
'Comfy.Load3D.LightAdjustmentIncrement': z.number(),
|
||||
'Comfy.Load3D.CameraType': z.enum(['perspective', 'orthographic']),
|
||||
'Comfy.Load3D.3DViewerEnable': z.boolean(),
|
||||
'Comfy.Load3D.PLYEngine': z.enum(['threejs', 'fastply', 'sparkjs']),
|
||||
'Comfy.Memory.AllowManualUnload': z.boolean(),
|
||||
'pysssss.SnapToGrid': z.boolean(),
|
||||
/** VHS setting is used for queue video preview support. */
|
||||
|
||||
153
src/scripts/metadata/ply.ts
Normal file
153
src/scripts/metadata/ply.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* PLY (Polygon File Format) decoder
|
||||
* Parses ASCII PLY files and extracts vertex positions and colors
|
||||
*/
|
||||
|
||||
interface PLYHeader {
|
||||
vertexCount: number
|
||||
hasColor: boolean
|
||||
propertyIndices: {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
red: number
|
||||
green: number
|
||||
blue: number
|
||||
}
|
||||
headerEndLine: number
|
||||
}
|
||||
|
||||
interface PLYData {
|
||||
positions: Float32Array
|
||||
colors: Float32Array | null
|
||||
vertexCount: number
|
||||
}
|
||||
|
||||
function parsePLYHeader(lines: string[]): PLYHeader | null {
|
||||
let vertexCount = 0
|
||||
let headerEndLine = 0
|
||||
let hasColor = false
|
||||
let xIndex = -1
|
||||
let yIndex = -1
|
||||
let zIndex = -1
|
||||
let redIndex = -1
|
||||
let greenIndex = -1
|
||||
let blueIndex = -1
|
||||
let propertyIndex = 0
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
|
||||
if (line.startsWith('element vertex')) {
|
||||
vertexCount = parseInt(line.split(/\s+/)[2])
|
||||
} else if (line.startsWith('property')) {
|
||||
const parts = line.split(/\s+/)
|
||||
const propName = parts[parts.length - 1]
|
||||
|
||||
if (propName === 'x') xIndex = propertyIndex
|
||||
else if (propName === 'y') yIndex = propertyIndex
|
||||
else if (propName === 'z') zIndex = propertyIndex
|
||||
else if (propName === 'red') {
|
||||
hasColor = true
|
||||
redIndex = propertyIndex
|
||||
} else if (propName === 'green') greenIndex = propertyIndex
|
||||
else if (propName === 'blue') blueIndex = propertyIndex
|
||||
|
||||
propertyIndex++
|
||||
} else if (line === 'end_header') {
|
||||
headerEndLine = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (vertexCount === 0 || xIndex < 0 || yIndex < 0 || zIndex < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
vertexCount,
|
||||
hasColor,
|
||||
propertyIndices: {
|
||||
x: xIndex,
|
||||
y: yIndex,
|
||||
z: zIndex,
|
||||
red: redIndex,
|
||||
green: greenIndex,
|
||||
blue: blueIndex
|
||||
},
|
||||
headerEndLine
|
||||
}
|
||||
}
|
||||
|
||||
function parsePLYVertices(lines: string[], header: PLYHeader): PLYData {
|
||||
const { vertexCount, hasColor, propertyIndices, headerEndLine } = header
|
||||
const { x: xIndex, y: yIndex, z: zIndex } = propertyIndices
|
||||
const { red: redIndex, green: greenIndex, blue: blueIndex } = propertyIndices
|
||||
|
||||
const positions = new Float32Array(vertexCount * 3)
|
||||
const colors = hasColor ? new Float32Array(vertexCount * 3) : null
|
||||
|
||||
let vertexIndex = 0
|
||||
|
||||
for (
|
||||
let i = headerEndLine + 1;
|
||||
i < lines.length && vertexIndex < vertexCount;
|
||||
i++
|
||||
) {
|
||||
const line = lines[i].trim()
|
||||
if (!line) continue
|
||||
|
||||
const parts = line.split(/\s+/)
|
||||
if (parts.length < 3) continue
|
||||
|
||||
const posIndex = vertexIndex * 3
|
||||
|
||||
positions[posIndex] = parseFloat(parts[xIndex])
|
||||
positions[posIndex + 1] = parseFloat(parts[yIndex])
|
||||
positions[posIndex + 2] = parseFloat(parts[zIndex])
|
||||
|
||||
if (
|
||||
hasColor &&
|
||||
colors &&
|
||||
redIndex >= 0 &&
|
||||
greenIndex >= 0 &&
|
||||
blueIndex >= 0
|
||||
) {
|
||||
if (parts.length > Math.max(redIndex, greenIndex, blueIndex)) {
|
||||
colors[posIndex] = parseInt(parts[redIndex]) / 255
|
||||
colors[posIndex + 1] = parseInt(parts[greenIndex]) / 255
|
||||
colors[posIndex + 2] = parseInt(parts[blueIndex]) / 255
|
||||
}
|
||||
}
|
||||
|
||||
vertexIndex++
|
||||
}
|
||||
|
||||
return {
|
||||
positions,
|
||||
colors,
|
||||
vertexCount: vertexIndex
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ASCII PLY data from an ArrayBuffer
|
||||
* Returns positions and colors as typed arrays
|
||||
*/
|
||||
export function parseASCIIPLY(arrayBuffer: ArrayBuffer): PLYData | null {
|
||||
const text = new TextDecoder().decode(arrayBuffer)
|
||||
const lines = text.split('\n')
|
||||
|
||||
const header = parsePLYHeader(lines)
|
||||
if (!header) return null
|
||||
|
||||
return parsePLYVertices(lines, header)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if PLY data is in ASCII format
|
||||
*/
|
||||
export function isPLYAsciiFormat(arrayBuffer: ArrayBuffer): boolean {
|
||||
const header = new TextDecoder().decode(arrayBuffer.slice(0, 500))
|
||||
return header.includes('format ascii')
|
||||
}
|
||||
@@ -75,23 +75,36 @@ export class Load3dService {
|
||||
const sourceModel = source.modelManager.currentModel
|
||||
|
||||
if (sourceModel) {
|
||||
const modelClone = sourceModel.clone()
|
||||
if (source.isSplatModel()) {
|
||||
const originalURL = source.modelManager.originalURL
|
||||
if (originalURL) {
|
||||
await target.loadModel(originalURL)
|
||||
}
|
||||
} else {
|
||||
const modelClone = sourceModel.clone()
|
||||
|
||||
target.getModelManager().currentModel = modelClone
|
||||
target.getSceneManager().scene.add(modelClone)
|
||||
target.getModelManager().currentModel = modelClone
|
||||
target.getSceneManager().scene.add(modelClone)
|
||||
|
||||
target.getModelManager().materialMode =
|
||||
source.getModelManager().materialMode
|
||||
const sourceOriginalModel = source.getModelManager().originalModel
|
||||
|
||||
target.getModelManager().currentUpDirection =
|
||||
source.getModelManager().currentUpDirection
|
||||
if (sourceOriginalModel) {
|
||||
target.getModelManager().originalModel = sourceOriginalModel
|
||||
}
|
||||
|
||||
target.setMaterialMode(source.getModelManager().materialMode)
|
||||
target.setUpDirection(source.getModelManager().currentUpDirection)
|
||||
target.getModelManager().materialMode =
|
||||
source.getModelManager().materialMode
|
||||
|
||||
if (source.getModelManager().appliedTexture) {
|
||||
target.getModelManager().appliedTexture =
|
||||
source.getModelManager().appliedTexture
|
||||
target.getModelManager().currentUpDirection =
|
||||
source.getModelManager().currentUpDirection
|
||||
|
||||
target.setMaterialMode(source.getModelManager().materialMode)
|
||||
target.setUpDirection(source.getModelManager().currentUpDirection)
|
||||
|
||||
if (source.getModelManager().appliedTexture) {
|
||||
target.getModelManager().appliedTexture =
|
||||
source.getModelManager().appliedTexture
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ describe('useLoad3d', () => {
|
||||
exportRecording: vi.fn(),
|
||||
clearRecording: vi.fn(),
|
||||
exportModel: vi.fn().mockResolvedValue(undefined),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
|
||||
448
tests-ui/tests/scripts/metadata/ply.test.ts
Normal file
448
tests-ui/tests/scripts/metadata/ply.test.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isPLYAsciiFormat, parseASCIIPLY } from '@/scripts/metadata/ply'
|
||||
|
||||
function createPLYBuffer(content: string): ArrayBuffer {
|
||||
return new TextEncoder().encode(content).buffer
|
||||
}
|
||||
|
||||
describe('PLY metadata parser', () => {
|
||||
describe('isPLYAsciiFormat', () => {
|
||||
it('should return true for ASCII format PLY', () => {
|
||||
const ply = `ply
|
||||
format ascii 1.0
|
||||
element vertex 3
|
||||
property float x
|
||||
property float y
|
||||
property float z
|
||||
end_header
|
||||
0 0 0
|
||||
1 0 0
|
||||
0 1 0`
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
expect(isPLYAsciiFormat(buffer)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for binary format PLY', () => {
|
||||
const ply = `ply
|
||||
format binary_little_endian 1.0
|
||||
element vertex 3
|
||||
property float x
|
||||
property float y
|
||||
property float z
|
||||
end_header`
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
expect(isPLYAsciiFormat(buffer)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for binary big endian format', () => {
|
||||
const ply = `ply
|
||||
format binary_big_endian 1.0
|
||||
element vertex 3
|
||||
end_header`
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
expect(isPLYAsciiFormat(buffer)).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty buffer', () => {
|
||||
const buffer = new ArrayBuffer(0)
|
||||
expect(isPLYAsciiFormat(buffer)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseASCIIPLY', () => {
|
||||
it('should parse simple PLY with positions only', () => {
|
||||
const ply = `ply
|
||||
format ascii 1.0
|
||||
element vertex 3
|
||||
property float x
|
||||
property float y
|
||||
property float z
|
||||
end_header
|
||||
0.0 0.0 0.0
|
||||
1.0 0.0 0.0
|
||||
0.0 1.0 0.0`
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
const result = parseASCIIPLY(buffer)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.vertexCount).toBe(3)
|
||||
expect(result!.colors).toBeNull()
|
||||
expect(result!.positions).toBeInstanceOf(Float32Array)
|
||||
expect(result!.positions.length).toBe(9)
|
||||
|
||||
expect(result!.positions[0]).toBeCloseTo(0.0)
|
||||
expect(result!.positions[1]).toBeCloseTo(0.0)
|
||||
expect(result!.positions[2]).toBeCloseTo(0.0)
|
||||
|
||||
expect(result!.positions[3]).toBeCloseTo(1.0)
|
||||
expect(result!.positions[4]).toBeCloseTo(0.0)
|
||||
expect(result!.positions[5]).toBeCloseTo(0.0)
|
||||
|
||||
expect(result!.positions[6]).toBeCloseTo(0.0)
|
||||
expect(result!.positions[7]).toBeCloseTo(1.0)
|
||||
expect(result!.positions[8]).toBeCloseTo(0.0)
|
||||
})
|
||||
|
||||
it('should parse PLY with positions and colors', () => {
|
||||
const ply = `ply
|
||||
format ascii 1.0
|
||||
element vertex 2
|
||||
property float x
|
||||
property float y
|
||||
property float z
|
||||
property uchar red
|
||||
property uchar green
|
||||
property uchar blue
|
||||
end_header
|
||||
1.0 2.0 3.0 255 128 0
|
||||
-1.0 -2.0 -3.0 0 255 128`
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
const result = parseASCIIPLY(buffer)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.vertexCount).toBe(2)
|
||||
expect(result!.colors).not.toBeNull()
|
||||
expect(result!.colors).toBeInstanceOf(Float32Array)
|
||||
expect(result!.colors!.length).toBe(6)
|
||||
|
||||
// First vertex position
|
||||
expect(result!.positions[0]).toBeCloseTo(1.0)
|
||||
expect(result!.positions[1]).toBeCloseTo(2.0)
|
||||
expect(result!.positions[2]).toBeCloseTo(3.0)
|
||||
|
||||
// First vertex color (normalized to 0-1)
|
||||
expect(result!.colors![0]).toBeCloseTo(1.0) // 255/255
|
||||
expect(result!.colors![1]).toBeCloseTo(128 / 255)
|
||||
expect(result!.colors![2]).toBeCloseTo(0.0)
|
||||
|
||||
// Second vertex color
|
||||
expect(result!.colors![3]).toBeCloseTo(0.0)
|
||||
expect(result!.colors![4]).toBeCloseTo(1.0)
|
||||
expect(result!.colors![5]).toBeCloseTo(128 / 255)
|
||||
})
|
||||
|
||||
it('should handle properties in non-standard order', () => {
|
||||
const ply = `ply
|
||||
format ascii 1.0
|
||||
element vertex 1
|
||||
property uchar red
|
||||
property float z
|
||||
property uchar green
|
||||
property float x
|
||||
property uchar blue
|
||||
property float y
|
||||
end_header
|
||||
255 3.0 128 1.0 64 2.0`
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
const result = parseASCIIPLY(buffer)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.vertexCount).toBe(1)
|
||||
|
||||
expect(result!.positions[0]).toBeCloseTo(1.0)
|
||||
expect(result!.positions[1]).toBeCloseTo(2.0)
|
||||
expect(result!.positions[2]).toBeCloseTo(3.0)
|
||||
|
||||
expect(result!.colors![0]).toBeCloseTo(1.0)
|
||||
expect(result!.colors![1]).toBeCloseTo(128 / 255)
|
||||
expect(result!.colors![2]).toBeCloseTo(64 / 255)
|
||||
})
|
||||
|
||||
it('should handle extra properties', () => {
|
||||
const ply = `ply
|
||||
format ascii 1.0
|
||||
element vertex 1
|
||||
property float x
|
||||
property float y
|
||||
property float z
|
||||
property float nx
|
||||
property float ny
|
||||
property float nz
|
||||
property uchar red
|
||||
property uchar green
|
||||
property uchar blue
|
||||
property uchar alpha
|
||||
end_header
|
||||
1.0 2.0 3.0 0.0 1.0 0.0 255 128 64 255`
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
const result = parseASCIIPLY(buffer)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.positions[0]).toBeCloseTo(1.0)
|
||||
expect(result!.positions[1]).toBeCloseTo(2.0)
|
||||
expect(result!.positions[2]).toBeCloseTo(3.0)
|
||||
|
||||
expect(result!.colors![0]).toBeCloseTo(1.0)
|
||||
expect(result!.colors![1]).toBeCloseTo(128 / 255)
|
||||
expect(result!.colors![2]).toBeCloseTo(64 / 255)
|
||||
})
|
||||
|
||||
it('should handle negative coordinates', () => {
|
||||
const ply = `ply
|
||||
format ascii 1.0
|
||||
element vertex 1
|
||||
property float x
|
||||
property float y
|
||||
property float z
|
||||
end_header
|
||||
-1.5 -2.5 -3.5`
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
const result = parseASCIIPLY(buffer)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.positions[0]).toBeCloseTo(-1.5)
|
||||
expect(result!.positions[1]).toBeCloseTo(-2.5)
|
||||
expect(result!.positions[2]).toBeCloseTo(-3.5)
|
||||
})
|
||||
|
||||
it('should handle scientific notation', () => {
|
||||
const ply = `ply
|
||||
format ascii 1.0
|
||||
element vertex 1
|
||||
property float x
|
||||
property float y
|
||||
property float z
|
||||
end_header
|
||||
1.5e-3 2.5e+2 -3.5e1`
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
const result = parseASCIIPLY(buffer)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.positions[0]).toBeCloseTo(0.0015)
|
||||
expect(result!.positions[1]).toBeCloseTo(250)
|
||||
expect(result!.positions[2]).toBeCloseTo(-35)
|
||||
})
|
||||
|
||||
it('should skip empty lines in vertex data', () => {
|
||||
const ply = `ply
|
||||
format ascii 1.0
|
||||
element vertex 2
|
||||
property float x
|
||||
property float y
|
||||
property float z
|
||||
end_header
|
||||
|
||||
1.0 0.0 0.0
|
||||
|
||||
0.0 1.0 0.0
|
||||
`
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
const result = parseASCIIPLY(buffer)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.vertexCount).toBe(2)
|
||||
expect(result!.positions[0]).toBeCloseTo(1.0)
|
||||
expect(result!.positions[3]).toBeCloseTo(0.0)
|
||||
expect(result!.positions[4]).toBeCloseTo(1.0)
|
||||
})
|
||||
|
||||
it('should handle whitespace variations', () => {
|
||||
const ply = `ply
|
||||
format ascii 1.0
|
||||
element vertex 1
|
||||
property float x
|
||||
property float y
|
||||
property float z
|
||||
end_header
|
||||
1.0 2.0 3.0 `
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
const result = parseASCIIPLY(buffer)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.positions[0]).toBeCloseTo(1.0)
|
||||
expect(result!.positions[1]).toBeCloseTo(2.0)
|
||||
expect(result!.positions[2]).toBeCloseTo(3.0)
|
||||
})
|
||||
|
||||
it('should return null for invalid header - missing vertex count', () => {
|
||||
const ply = `ply
|
||||
format ascii 1.0
|
||||
property float x
|
||||
property float y
|
||||
property float z
|
||||
end_header
|
||||
1.0 2.0 3.0`
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
const result = parseASCIIPLY(buffer)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for invalid header - missing x property', () => {
|
||||
const ply = `ply
|
||||
format ascii 1.0
|
||||
element vertex 1
|
||||
property float y
|
||||
property float z
|
||||
end_header
|
||||
2.0 3.0`
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
const result = parseASCIIPLY(buffer)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for invalid header - missing y property', () => {
|
||||
const ply = `ply
|
||||
format ascii 1.0
|
||||
element vertex 1
|
||||
property float x
|
||||
property float z
|
||||
end_header
|
||||
1.0 3.0`
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
const result = parseASCIIPLY(buffer)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for invalid header - missing z property', () => {
|
||||
const ply = `ply
|
||||
format ascii 1.0
|
||||
element vertex 1
|
||||
property float x
|
||||
property float y
|
||||
end_header
|
||||
1.0 2.0`
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
const result = parseASCIIPLY(buffer)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for empty buffer', () => {
|
||||
const buffer = new ArrayBuffer(0)
|
||||
const result = parseASCIIPLY(buffer)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle large vertex count', () => {
|
||||
const vertexCount = 1000
|
||||
let plyContent = `ply
|
||||
format ascii 1.0
|
||||
element vertex ${vertexCount}
|
||||
property float x
|
||||
property float y
|
||||
property float z
|
||||
end_header
|
||||
`
|
||||
for (let i = 0; i < vertexCount; i++) {
|
||||
plyContent += `${i} ${i * 2} ${i * 3}\n`
|
||||
}
|
||||
|
||||
const buffer = createPLYBuffer(plyContent)
|
||||
const result = parseASCIIPLY(buffer)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.vertexCount).toBe(vertexCount)
|
||||
expect(result!.positions.length).toBe(vertexCount * 3)
|
||||
|
||||
expect(result!.positions[0]).toBeCloseTo(0)
|
||||
expect(result!.positions[1]).toBeCloseTo(0)
|
||||
expect(result!.positions[2]).toBeCloseTo(0)
|
||||
|
||||
const lastIdx = (vertexCount - 1) * 3
|
||||
expect(result!.positions[lastIdx]).toBeCloseTo(vertexCount - 1)
|
||||
expect(result!.positions[lastIdx + 1]).toBeCloseTo((vertexCount - 1) * 2)
|
||||
expect(result!.positions[lastIdx + 2]).toBeCloseTo((vertexCount - 1) * 3)
|
||||
})
|
||||
|
||||
it('should handle partial color properties', () => {
|
||||
const ply = `ply
|
||||
format ascii 1.0
|
||||
element vertex 1
|
||||
property float x
|
||||
property float y
|
||||
property float z
|
||||
property uchar red
|
||||
end_header
|
||||
1.0 2.0 3.0 255`
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
const result = parseASCIIPLY(buffer)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
// hasColor is true but green/blue indices are -1, so colors won't be parsed
|
||||
expect(result!.positions[0]).toBeCloseTo(1.0)
|
||||
})
|
||||
|
||||
it('should handle double property type', () => {
|
||||
const ply = `ply
|
||||
format ascii 1.0
|
||||
element vertex 1
|
||||
property double x
|
||||
property double y
|
||||
property double z
|
||||
end_header
|
||||
1.123456789 2.987654321 3.111111111`
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
const result = parseASCIIPLY(buffer)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.positions[0]).toBeCloseTo(1.123456789)
|
||||
expect(result!.positions[1]).toBeCloseTo(2.987654321)
|
||||
expect(result!.positions[2]).toBeCloseTo(3.111111111)
|
||||
})
|
||||
|
||||
it('should stop parsing at vertex count limit', () => {
|
||||
const ply = `ply
|
||||
format ascii 1.0
|
||||
element vertex 2
|
||||
property float x
|
||||
property float y
|
||||
property float z
|
||||
end_header
|
||||
1.0 0.0 0.0
|
||||
0.0 1.0 0.0
|
||||
0.0 0.0 1.0
|
||||
999 999 999`
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
const result = parseASCIIPLY(buffer)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.vertexCount).toBe(2)
|
||||
expect(result!.positions.length).toBe(6)
|
||||
})
|
||||
|
||||
it('should handle face elements after vertices', () => {
|
||||
const ply = `ply
|
||||
format ascii 1.0
|
||||
element vertex 3
|
||||
property float x
|
||||
property float y
|
||||
property float z
|
||||
element face 1
|
||||
property list uchar int vertex_indices
|
||||
end_header
|
||||
0.0 0.0 0.0
|
||||
1.0 0.0 0.0
|
||||
0.0 1.0 0.0
|
||||
3 0 1 2`
|
||||
|
||||
const buffer = createPLYBuffer(ply)
|
||||
const result = parseASCIIPLY(buffer)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.vertexCount).toBe(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -435,7 +435,7 @@ export default defineConfig({
|
||||
return 'vendor-chart'
|
||||
}
|
||||
|
||||
if (id.includes('three')) {
|
||||
if (id.includes('three') || id.includes('@sparkjsdev')) {
|
||||
return 'vendor-three'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { vi } from 'vitest'
|
||||
import 'vue'
|
||||
|
||||
// Mock @sparkjsdev/spark which uses WASM that doesn't work in Node.js
|
||||
vi.mock('@sparkjsdev/spark', () => ({
|
||||
SplatMesh: class SplatMesh {
|
||||
constructor() {}
|
||||
}
|
||||
}))
|
||||
|
||||
// Augment Window interface for tests
|
||||
declare global {
|
||||
interface Window {
|
||||
|
||||
Reference in New Issue
Block a user