mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 14:16:00 +00:00
## 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.
897 lines
28 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|