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:
Terry Jia
2025-12-20 16:04:16 -05:00
committed by GitHub
parent 212d19e2fa
commit 3c4b99ed84
25 changed files with 1110 additions and 27 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -54,6 +54,8 @@ useExtensionService().registerExtension({
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
if (load3d.isSplatModel()) return []
return createExportMenuItems(load3d)
},

View File

@@ -1637,6 +1637,7 @@
"normal": "Normal",
"wireframe": "Wireframe",
"original": "Original",
"pointCloud": "Point Cloud",
"depth": "Depth",
"lineart": "Lineart"
},

View File

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

View File

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

View File

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

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

View File

@@ -435,7 +435,7 @@ export default defineConfig({
return 'vendor-chart'
}
if (id.includes('three')) {
if (id.includes('three') || id.includes('@sparkjsdev')) {
return 'vendor-three'
}

View File

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