Files
ComfyUI_frontend/src/extensions/core/load3d/SceneModelManager.test.ts
Terry Jia 52d77e6ee0 chore: upgrade sparkjs to 2.x and three to 0.184 (#12396)
## Summary
- Spark 2.x requires SparkRenderer in scene tree; add it in SceneManager
and protect it in clearModel so model reloads don't dispose the splat
renderer.
- three 0.184 OrbitControls listens on ownerDocument; drop redundant
pointermove/up .stop in Load3D containers so the document listener can
receive events.
- Narrow Texture.image type for 0.184 strict typing.
2026-05-21 10:06:20 -04:00

897 lines
28 KiB
TypeScript

import { SparkRenderer } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { describe, expect, it, vi } from 'vitest'
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import type { ModelAdapterCapabilities } from './ModelAdapter'
import { SceneModelManager } from './SceneModelManager'
import type { EventManagerInterface } from './interfaces'
function createMockRenderer(): THREE.WebGLRenderer {
return {
outputColorSpace: THREE.SRGBColorSpace,
dispose: vi.fn()
} as unknown as THREE.WebGLRenderer
}
function createMockEventManager(): EventManagerInterface {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
}
}
function createManager(
overrides: {
scene?: THREE.Scene
eventManager?: EventManagerInterface
capabilities?: Partial<ModelAdapterCapabilities>
} = {}
) {
const scene = overrides.scene ?? new THREE.Scene()
const renderer = createMockRenderer()
const eventManager = overrides.eventManager ?? createMockEventManager()
const camera = new THREE.PerspectiveCamera()
const getActiveCamera = () => camera
const setupCamera = vi.fn()
const setupGizmo = vi.fn()
const capabilities: ModelAdapterCapabilities = {
...DEFAULT_MODEL_CAPABILITIES,
...overrides.capabilities
}
const manager = new SceneModelManager(
scene,
renderer,
eventManager,
getActiveCamera,
setupCamera,
setupGizmo,
() => capabilities
)
return {
manager,
scene,
renderer,
eventManager,
camera,
setupCamera,
setupGizmo
}
}
function createManagerWithPose(opts: {
capabilities?: Partial<ModelAdapterCapabilities>
pose: { size: THREE.Vector3; center: THREE.Vector3 } | null
}) {
const scene = new THREE.Scene()
const renderer = createMockRenderer()
const eventManager = createMockEventManager()
const camera = new THREE.PerspectiveCamera()
const setupCamera = vi.fn()
const setupGizmo = vi.fn()
const capabilities: ModelAdapterCapabilities = {
...DEFAULT_MODEL_CAPABILITIES,
...opts.capabilities
}
const manager = new SceneModelManager(
scene,
renderer,
eventManager,
() => camera,
setupCamera,
setupGizmo,
() => capabilities,
() => null,
() => {},
() => opts.pose
)
return { manager, scene, setupCamera, setupGizmo }
}
function createMeshModel(name = 'TestModel'): THREE.Group {
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshStandardMaterial({ color: 0xff0000 })
const mesh = new THREE.Mesh(geometry, material)
const group = new THREE.Group()
group.name = name
group.add(mesh)
return group
}
function createPointsModel(name = 'TestModel'): THREE.Group {
const geometry = new THREE.BufferGeometry()
const material = new THREE.PointsMaterial({ color: 0xff0000 })
const points = new THREE.Points(geometry, material)
const group = new THREE.Group()
group.name = name
group.add(points)
return group
}
describe('SceneModelManager', () => {
describe('constructor', () => {
it('initializes default state', () => {
const { manager } = createManager()
expect(manager.currentModel).toBeNull()
expect(manager.originalModel).toBeNull()
expect(manager.originalRotation).toBeNull()
expect(manager.currentUpDirection).toBe('original')
expect(manager.materialMode).toBe('original')
expect(manager.originalFileName).toBeNull()
expect(manager.originalURL).toBeNull()
expect(manager.appliedTexture).toBeNull()
expect(manager.skeletonHelper).toBeNull()
expect(manager.showSkeleton).toBe(false)
})
it('creates material instances', () => {
const { manager } = createManager()
expect(manager.normalMaterial).toBeInstanceOf(THREE.MeshNormalMaterial)
expect(manager.wireframeMaterial).toBeInstanceOf(THREE.MeshBasicMaterial)
expect(manager.wireframeMaterial.wireframe).toBe(true)
expect(manager.depthMaterial).toBeInstanceOf(THREE.MeshDepthMaterial)
expect(manager.standardMaterial).toBeInstanceOf(
THREE.MeshStandardMaterial
)
})
})
describe('dispose', () => {
it('disposes all materials', () => {
const { manager } = createManager()
const normalDispose = vi.spyOn(manager.normalMaterial, 'dispose')
const standardDispose = vi.spyOn(manager.standardMaterial, 'dispose')
const wireframeDispose = vi.spyOn(manager.wireframeMaterial, 'dispose')
const depthDispose = vi.spyOn(manager.depthMaterial, 'dispose')
manager.dispose()
expect(normalDispose).toHaveBeenCalled()
expect(standardDispose).toHaveBeenCalled()
expect(wireframeDispose).toHaveBeenCalled()
expect(depthDispose).toHaveBeenCalled()
})
it('disposes applied texture', () => {
const { manager } = createManager()
const texture = new THREE.Texture()
const textureDispose = vi.spyOn(texture, 'dispose')
manager.appliedTexture = texture
manager.dispose()
expect(textureDispose).toHaveBeenCalled()
expect(manager.appliedTexture).toBeNull()
})
})
describe('createSTLMaterial', () => {
it('returns a MeshStandardMaterial with expected properties', () => {
const { manager } = createManager()
const mat = manager.createSTLMaterial()
expect(mat).toBeInstanceOf(THREE.MeshStandardMaterial)
expect(mat.color.getHex()).toBe(0x808080)
expect(mat.metalness).toBe(0.1)
expect(mat.roughness).toBe(0.8)
expect(mat.side).toBe(THREE.DoubleSide)
})
})
describe('addModelToScene', () => {
it('adds the model to the scene and sets currentModel', () => {
const { manager, scene } = createManager()
const model = createMeshModel()
manager.addModelToScene(model)
expect(manager.currentModel).toBe(model)
expect(model.name).toBe('MainModel')
expect(scene.children).toContain(model)
})
})
describe('setupModel', () => {
it('scales and positions the model, then adds to scene', async () => {
const { manager, scene, setupCamera } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
expect(manager.currentModel).toBe(model)
expect(model.name).toBe('MainModel')
expect(scene.children).toContain(model)
expect(setupCamera).toHaveBeenCalled()
})
it('reapplies non-original materialMode after snapshotting', async () => {
const { manager } = createManager()
const model = createMeshModel()
// setupModel calls setupModelMaterials first (which internally calls
// setMaterialMode('original') to reset), then reapplies the stored mode.
manager.materialMode = 'wireframe'
const spy = vi.spyOn(manager, 'setMaterialMode')
await manager.setupModel(model)
expect(spy).toHaveBeenCalledWith('original')
expect(spy).toHaveBeenCalledWith('wireframe')
// The final material mode visible on the mesh should be wireframe.
const mesh = model.children[0] as THREE.Mesh
expect((mesh.material as THREE.MeshBasicMaterial).wireframe).toBe(true)
})
it('snapshots original materials before applying materialMode so restore is correct', async () => {
const { manager } = createManager()
const model = createMeshModel()
const mesh = model.children[0] as THREE.Mesh
const originalMat = mesh.material
// Set a non-original mode before loading — this was the bug:
// originalMaterials would capture the wireframe material instead of the real one.
manager.materialMode = 'wireframe'
await manager.setupModel(model)
// The snapshot must hold the *pre-mutation* material.
expect(manager.originalMaterials.get(mesh)).toBe(originalMat)
// Restoring to 'original' must give back the true original, not wireframe.
manager.setMaterialMode('original')
expect(mesh.material).toBe(originalMat)
})
it('applies current up direction if not original', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
manager.currentUpDirection = '+z'
await manager.setupModel(model)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'upDirectionChange',
'+z'
)
})
it('uses the adapter default pose when fitToViewer is disabled and a pose is provided', async () => {
const pose = {
size: new THREE.Vector3(5, 5, 5),
center: new THREE.Vector3(0, 2.5, 0)
}
const { manager, scene, setupCamera, setupGizmo } = createManagerWithPose(
{
capabilities: { fitToViewer: false },
pose
}
)
const model = createMeshModel()
await manager.setupModel(model)
expect(scene.children).toContain(model)
expect(setupCamera).toHaveBeenCalledWith(pose.size, pose.center)
expect(setupGizmo).not.toHaveBeenCalled()
})
it('falls back to the full setup path when fitToViewer is disabled but no default pose is provided', async () => {
const { manager, scene, setupCamera, setupGizmo } = createManagerWithPose(
{
capabilities: { fitToViewer: false },
pose: null
}
)
const model = createMeshModel()
await manager.setupModel(model)
expect(scene.children).toContain(model)
expect(setupCamera).toHaveBeenCalled()
const callArgs = setupCamera.mock.calls[0]
expect(callArgs[0]).toBeInstanceOf(THREE.Vector3)
expect(callArgs[1]).toBeInstanceOf(THREE.Vector3)
expect(setupGizmo).toHaveBeenCalledWith(model)
})
})
describe('setOriginalModel', () => {
it('stores the original model reference', () => {
const { manager } = createManager()
const model = new THREE.Group()
manager.setOriginalModel(model)
expect(manager.originalModel).toBe(model)
})
})
describe('clearModel', () => {
it('removes non-environment objects from scene', async () => {
const { manager, scene } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const light = new THREE.DirectionalLight()
scene.add(light)
manager.clearModel()
expect(manager.currentModel).toBeNull()
expect(scene.children).toContain(light)
})
it('disposes mesh geometry and materials', async () => {
const { manager } = createManager()
const model = createMeshModel()
const mesh = model.children[0] as THREE.Mesh
const geoDispose = vi.spyOn(mesh.geometry, 'dispose')
const matDispose = vi.spyOn(mesh.material as THREE.Material, 'dispose')
await manager.setupModel(model)
manager.clearModel()
expect(geoDispose).toHaveBeenCalled()
expect(matDispose).toHaveBeenCalled()
})
it('disposes points geometry and materials', async () => {
const { manager } = createManager()
const model = createPointsModel()
const points = model.children[0] as THREE.Points
const geoDispose = vi.spyOn(points.geometry, 'dispose')
const matDispose = vi.spyOn(points.material as THREE.Material, 'dispose')
await manager.setupModel(model)
manager.clearModel()
expect(geoDispose).toHaveBeenCalled()
expect(matDispose).toHaveBeenCalled()
})
it('preserves SparkRenderer across model reloads', async () => {
const { manager, scene } = createManager()
const sparkRenderer = new SparkRenderer({
renderer: {} as THREE.WebGLRenderer
})
scene.add(sparkRenderer)
const model = createMeshModel()
await manager.setupModel(model)
manager.clearModel()
expect(scene.children).toContain(sparkRenderer)
})
})
describe('reset', () => {
it('resets all state to defaults', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.originalFileName = 'test.glb'
manager.originalURL = 'http://example.com/test.glb'
manager.originalModel = model
manager.reset()
expect(manager.currentModel).toBeNull()
expect(manager.originalModel).toBeNull()
expect(manager.originalRotation).toBeNull()
expect(manager.currentUpDirection).toBe('original')
expect(manager.originalFileName).toBeNull()
expect(manager.originalURL).toBeNull()
})
it('disposes applied texture', () => {
const { manager } = createManager()
const texture = new THREE.Texture()
const textureDispose = vi.spyOn(texture, 'dispose')
manager.appliedTexture = texture
manager.reset()
expect(textureDispose).toHaveBeenCalled()
expect(manager.appliedTexture).toBeNull()
})
it('removes and disposes skeleton helper', async () => {
const { manager, scene } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const mockHelper = new THREE.SkeletonHelper(model)
const helperDispose = vi.spyOn(mockHelper, 'dispose')
manager.skeletonHelper = mockHelper
scene.add(mockHelper)
manager.reset()
expect(helperDispose).toHaveBeenCalled()
expect(manager.skeletonHelper).toBeNull()
expect(manager.showSkeleton).toBe(false)
})
})
describe('setMaterialMode', () => {
it('does nothing when no current model', () => {
const { manager, eventManager } = createManager()
manager.setMaterialMode('normal')
expect(eventManager.emitEvent).not.toHaveBeenCalled()
})
it('does nothing when mode is unchanged', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
vi.mocked(eventManager.emitEvent).mockClear()
manager.setMaterialMode('original')
expect(eventManager.emitEvent).not.toHaveBeenCalled()
})
it('switches to normal material', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setMaterialMode('normal')
const mesh = model.children[0] as THREE.Mesh
expect(mesh.material).toBeInstanceOf(THREE.MeshNormalMaterial)
expect(manager.materialMode).toBe('normal')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'materialModeChange',
'normal'
)
})
it('switches to wireframe material', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setMaterialMode('wireframe')
const mesh = model.children[0] as THREE.Mesh
expect(mesh.material).toBeInstanceOf(THREE.MeshBasicMaterial)
expect((mesh.material as THREE.MeshBasicMaterial).wireframe).toBe(true)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'materialModeChange',
'wireframe'
)
})
it('switches to depth material', async () => {
const { manager, renderer } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setMaterialMode('depth')
const mesh = model.children[0] as THREE.Mesh
expect(mesh.material).toBeInstanceOf(THREE.MeshDepthMaterial)
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
})
it('restores original material when switching back', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const mesh = model.children[0] as THREE.Mesh
const originalMat = mesh.material
manager.setMaterialMode('normal')
manager.setMaterialMode('original')
expect(mesh.material).toBe(originalMat)
})
it('uses appliedTexture when no original material stored', async () => {
const { manager } = createManager()
const model = createMeshModel()
const texture = new THREE.Texture()
manager.appliedTexture = texture
manager.addModelToScene(model)
manager.materialMode = 'normal'
manager.setMaterialMode('original')
const mesh = model.children[0] as THREE.Mesh
expect(mesh.material).toBeInstanceOf(THREE.MeshStandardMaterial)
expect((mesh.material as THREE.MeshStandardMaterial).map).toBe(texture)
})
it('sets renderer color space to SRGB for non-depth modes', async () => {
const { manager, renderer } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setMaterialMode('depth')
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
manager.setMaterialMode('normal')
expect(renderer.outputColorSpace).toBe(THREE.SRGBColorSpace)
})
it('delegates to handlePLYModeSwitch for BufferGeometry original model', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.originalModel = new THREE.BufferGeometry()
;(manager.originalModel as THREE.BufferGeometry).setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 1, 1, 2, 2, 2], 3)
)
manager.setMaterialMode('wireframe')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'materialModeChange',
'wireframe'
)
})
})
describe('setupModelMaterials', () => {
it('stores original materials in the WeakMap', () => {
const { manager } = createManager()
const model = createMeshModel()
const mesh = model.children[0] as THREE.Mesh
const originalMat = mesh.material
manager.currentModel = model
manager.setupModelMaterials(model)
expect(manager.originalMaterials.get(mesh)).toBe(originalMat)
})
})
describe('setUpDirection', () => {
it('does nothing when no current model', () => {
const { manager, eventManager } = createManager()
manager.setUpDirection('+x')
expect(eventManager.emitEvent).not.toHaveBeenCalled()
})
it('stores the original rotation on first call', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setUpDirection('+x')
expect(manager.originalRotation).not.toBeNull()
})
it('applies correct rotation for each direction', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const directions: Array<{
dir: '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
axis: 'x' | 'z'
value: number
}> = [
{ dir: '-x', axis: 'z', value: Math.PI / 2 },
{ dir: '+x', axis: 'z', value: -Math.PI / 2 },
{ dir: '-y', axis: 'x', value: Math.PI },
{ dir: '-z', axis: 'x', value: Math.PI / 2 },
{ dir: '+z', axis: 'x', value: -Math.PI / 2 }
]
for (const { dir, axis, value } of directions) {
manager.setUpDirection(dir)
expect(model.rotation[axis]).toBeCloseTo(value)
expect(manager.currentUpDirection).toBe(dir)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'upDirectionChange',
dir
)
}
})
it('restores original rotation before applying new direction', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setUpDirection('+x')
const zAfterX = model.rotation.z
manager.setUpDirection('-z')
expect(model.rotation.x).toBeCloseTo(Math.PI / 2)
expect(model.rotation.z).not.toBeCloseTo(zAfterX)
})
it('emits upDirectionChange event', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setUpDirection('original')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'upDirectionChange',
'original'
)
})
})
describe('hasSkeleton', () => {
it('returns false when no current model', () => {
const { manager } = createManager()
expect(manager.hasSkeleton()).toBe(false)
})
it('returns false for model without skeleton', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
expect(manager.hasSkeleton()).toBe(false)
})
it('returns true for model with SkinnedMesh', () => {
const { manager } = createManager()
const group = new THREE.Group()
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshStandardMaterial()
const bones = [new THREE.Bone(), new THREE.Bone()]
bones[0].add(bones[1])
const skeleton = new THREE.Skeleton(bones)
const skinnedMesh = new THREE.SkinnedMesh(geometry, material)
skinnedMesh.add(bones[0])
skinnedMesh.bind(skeleton)
group.add(skinnedMesh)
manager.currentModel = group
expect(manager.hasSkeleton()).toBe(true)
})
})
describe('setShowSkeleton', () => {
it('sets showSkeleton flag', () => {
const { manager } = createManager()
manager.setShowSkeleton(true)
expect(manager.showSkeleton).toBe(true)
})
it('emits skeletonVisibilityChange event', () => {
const { manager, eventManager } = createManager()
manager.setShowSkeleton(true)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'skeletonVisibilityChange',
true
)
})
it('hides existing skeleton helper when set to false', async () => {
const { manager, scene } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const helper = new THREE.SkeletonHelper(model)
manager.skeletonHelper = helper
scene.add(helper)
manager.setShowSkeleton(false)
expect(helper.visible).toBe(false)
})
it('shows existing skeleton helper when set to true', async () => {
const { manager, scene } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
const helper = new THREE.SkeletonHelper(model)
helper.visible = false
manager.skeletonHelper = helper
scene.add(helper)
manager.setShowSkeleton(true)
expect(helper.visible).toBe(true)
})
})
describe('fitToViewer', () => {
it('does nothing when no current model', () => {
const { manager, setupCamera, setupGizmo } = createManager()
manager.fitToViewer()
expect(setupCamera).not.toHaveBeenCalled()
expect(setupGizmo).not.toHaveBeenCalled()
})
it('reapplies currentUpDirection after fitting', async () => {
const { manager, eventManager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setUpDirection('+z')
vi.mocked(eventManager.emitEvent).mockClear()
manager.fitToViewer()
// rotation.x should reflect +z direction (-PI/2) applied to the post-fit base (0,0,0)
expect(model.rotation.x).toBeCloseTo(-Math.PI / 2)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'upDirectionChange',
'+z'
)
})
it('does not compound rotations when fitToViewer is called multiple times', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setUpDirection('-x')
manager.fitToViewer()
const rotationAfterFirst = model.rotation.z
manager.fitToViewer()
expect(model.rotation.z).toBeCloseTo(rotationAfterFirst)
})
it('leaves rotation at zero when currentUpDirection is original', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.fitToViewer()
expect(model.rotation.x).toBeCloseTo(0)
expect(model.rotation.y).toBeCloseTo(0)
expect(model.rotation.z).toBeCloseTo(0)
})
it('does not compound rotation when fitToViewer is called after manual rotation override', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
// Set an up direction, then manually override originalRotation to simulate
// a prior state where the base rotation was non-zero before fit
manager.setUpDirection('+x')
// Simulate that originalRotation was captured at a non-zero rotation
manager.originalRotation = new THREE.Euler(0.5, 0.3, 0.1)
manager.fitToViewer()
// After fit, the rotation should be correct for +x direction applied to (0,0,0) base
// Not compounded with the stale originalRotation
expect(model.rotation.x).toBeCloseTo(0)
expect(model.rotation.z).toBeCloseTo(-Math.PI / 2)
})
})
describe('PLY mode switching', () => {
function createPLYManager() {
const ctx = createManager({
capabilities: { requiresMaterialRebuild: true }
})
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 1, 1, 2, 0, 0], 3)
)
const mesh = new THREE.Mesh(
geometry.clone(),
ctx.manager.standardMaterial.clone()
)
const group = new THREE.Group()
group.name = 'MainModel'
group.add(mesh)
ctx.scene.add(group)
ctx.manager.currentModel = group
ctx.manager.originalModel = geometry
return ctx
}
it('recreates model as point cloud', () => {
const { manager, scene, eventManager } = createPLYManager()
manager.setMaterialMode('pointCloud')
const mainModel = scene.children.find((c) => c.name === 'MainModel')
expect(mainModel).toBeDefined()
const points = mainModel!.children.find((c) => c instanceof THREE.Points)
expect(points).toBeInstanceOf(THREE.Points)
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'materialModeChange',
'pointCloud'
)
})
it('recreates model as wireframe mesh', () => {
const { manager, scene } = createPLYManager()
manager.setMaterialMode('wireframe')
const mainModel = scene.children.find((c) => c.name === 'MainModel')
expect(mainModel).toBeDefined()
let foundWireframe = false
mainModel!.traverse((child) => {
if (
child instanceof THREE.Mesh &&
child.material instanceof THREE.MeshBasicMaterial
) {
foundWireframe = child.material.wireframe
}
})
expect(foundWireframe).toBe(true)
})
it('uses vertex colors when available', () => {
const { manager, scene } = createManager({
capabilities: { requiresMaterialRebuild: true }
})
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 1, 1, 2, 0, 0], 3)
)
geometry.setAttribute(
'color',
new THREE.Float32BufferAttribute([1, 0, 0, 0, 1, 0, 0, 0, 1], 3)
)
const mesh = new THREE.Mesh(
geometry.clone(),
new THREE.MeshBasicMaterial()
)
const group = new THREE.Group()
group.name = 'MainModel'
group.add(mesh)
scene.add(group)
manager.currentModel = group
manager.originalModel = geometry
manager.setMaterialMode('pointCloud')
const mainModel = scene.children.find((c) => c.name === 'MainModel')
const points = mainModel!.children.find(
(c) => c instanceof THREE.Points
) as THREE.Points
expect((points.material as THREE.PointsMaterial).vertexColors).toBe(true)
})
it('removes old MainModel objects before adding new one', () => {
const { manager, scene } = createPLYManager()
manager.setMaterialMode('wireframe')
const mainModels = scene.children.filter((c) => c.name === 'MainModel')
expect(mainModels).toHaveLength(1)
})
})
})