mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-14 17:37:46 +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:
@@ -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)
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user