diff --git a/src/core/nodeOps.test.ts b/src/core/nodeOps.test.ts index 3ec90aebf..f298a95e9 100644 --- a/src/core/nodeOps.test.ts +++ b/src/core/nodeOps.test.ts @@ -185,6 +185,50 @@ describe('nodeOps', () => { expect(parent.children.includes(child)).toBeTruthy() }) + describe('primitive :object', () => { + describe('into mesh', () => { + it.skip('inserts a mesh :object', () => { + const parent = nodeOps.createElement('Mesh', undefined, undefined, {}) + const object = new THREE.Mesh() + const primitive = nodeOps.createElement('primitive', undefined, undefined, { object }) + + expect(parent.material.uuid).not.toBe(object.uuid) + nodeOps.insert(primitive, parent) + expect(parent.material.uuid).toBe(object.uuid) + }) + + it.skip('inserts a material :object', () => { + const parent = nodeOps.createElement('Mesh', undefined, undefined, {}) + const object = new THREE.MeshNormalMaterial() + const primitive = nodeOps.createElement('primitive', undefined, undefined, { object }) + + expect(parent.material.uuid).not.toBe(object.uuid) + nodeOps.insert(primitive, parent) + expect(parent.material.uuid).toBe(object.uuid) + }) + + it.skip('inserts a geometry :object', () => { + const parent = nodeOps.createElement('Mesh', undefined, undefined, {}) + const object = new THREE.BoxGeometry() + const primitive = nodeOps.createElement('primitive', undefined, undefined, { object }) + + expect(parent.material.uuid).not.toBe(object.uuid) + nodeOps.insert(primitive, parent) + expect(parent.material.uuid).toBe(object.uuid) + }) + + it.skip('inserts a group :object', () => { + const parent = nodeOps.createElement('Mesh', undefined, undefined, {}) + const object = new THREE.Group() + const primitive = nodeOps.createElement('primitive', undefined, undefined, { object }) + + expect(parent.material.uuid).not.toBe(object.uuid) + nodeOps.insert(primitive, parent) + expect(parent.material.uuid).toBe(object.uuid) + }) + }) + }) + it('does not insert a falsy child', () => { const parent = nodeOps.createElement('Object3D', undefined, undefined, {}) for (const falsyChild of [undefined, null]) { @@ -228,7 +272,7 @@ describe('nodeOps', () => { } }) - it('calls dispose on materials', () => { + it('calls dispose on a material', () => { const parent = mockTresObjectRootInObject(nodeOps.createElement('Mesh', undefined, undefined, {})) const material = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, {}) const spy = vi.spyOn(material, 'dispose') @@ -237,6 +281,18 @@ describe('nodeOps', () => { expect(spy).toHaveBeenCalledOnce() }) + it('calls dispose on a material array', () => { + const parent = mockTresObjectRootInObject(nodeOps.createElement('Mesh', undefined, undefined, {})) + const material0 = new THREE.MeshNormalMaterial() + const material1 = new THREE.MeshNormalMaterial() + const spy0 = vi.spyOn(material0, 'dispose') + const spy1 = vi.spyOn(material1, 'dispose') + parent.material = [material0, material1] + nodeOps.remove(parent) + expect(spy0).toHaveBeenCalledOnce() + expect(spy1).toHaveBeenCalledOnce() + }) + it('calls dispose on geometries', () => { const parent = mockTresObjectRootInObject(nodeOps.createElement('Mesh', undefined, undefined, {})) const geometry = nodeOps.createElement('SphereGeometry', undefined, undefined, {}) @@ -245,6 +301,220 @@ describe('nodeOps', () => { nodeOps.remove(parent) expect(spy).toHaveBeenCalledOnce() }) + + it('calls dispose on every material/geometry in a TresMesh tree', () => { + const NUM_LEVEL = 5 + const NUM_CHILD_PER_NODE = 3 + const rootNode = mockTresObjectRootInObject(nodeOps.createElement('Mesh')) + const disposalSpies = [] + + createTreeIn(rootNode, (parent, childI, levelI) => { + if (levelI > NUM_LEVEL || childI >= NUM_CHILD_PER_NODE) { + return false + } + const { mesh, material, geometry } = createElementMesh(nodeOps) + nodeOps.insert(mesh, parent) + disposalSpies.push(vi.spyOn(geometry, 'dispose')) + disposalSpies.push(vi.spyOn(material, 'dispose')) + return mesh + }) + + nodeOps.remove(rootNode) + for (const spy of disposalSpies) { + expect(spy).toHaveBeenCalledOnce() + } + }) + + it('calls dispose on every material/geometry in a TresMesh/TresGroup tree', () => { + const NUM_LEVEL = 5 + const NUM_CHILD_PER_NODE = 3 + const rootNode = mockTresObjectRootInObject(nodeOps.createElement('Group')) + const disposalSpies = [] + + createTreeIn(rootNode, (parent, childI, levelI) => { + if (childI > NUM_CHILD_PER_NODE || levelI > NUM_LEVEL) { + return false + } + if (Math.random() > 0.3) { + const { mesh, material, geometry } = createElementMesh(nodeOps) + nodeOps.insert(mesh, parent) + disposalSpies.push(vi.spyOn(geometry, 'dispose')) + disposalSpies.push(vi.spyOn(material, 'dispose')) + return mesh + } + else { + const group = nodeOps.createElement('Group') + nodeOps.insert(group, parent) + return group + } + }) + + nodeOps.remove(rootNode) + for (const spy of disposalSpies) { + expect(spy).toHaveBeenCalledOnce() + } + }) + + it('does not dispose primitive material/geometries on remove(primitive)', () => { + const { primitive, material, geometry } = createElementPrimitiveMesh(nodeOps) + const spy0 = vi.spyOn(material, 'dispose') + const spy1 = vi.spyOn(geometry, 'dispose') + + const group = nodeOps.createElement('Group') + nodeOps.insert(primitive, group) + nodeOps.remove(primitive) + + expect(spy0).not.toBeCalled() + expect(spy1).not.toBeCalled() + }) + + it.skip('does not dispose primitive material/geometries on remove(ascestorOfPrimitive)', () => { + const { primitive, material, geometry } = createElementPrimitiveMesh(nodeOps) + const spy0 = vi.spyOn(material, 'dispose') + const spy1 = vi.spyOn(geometry, 'dispose') + + const group = nodeOps.createElement('Group') + nodeOps.insert(primitive, group) + nodeOps.remove(group) + + expect(spy0).not.toBeCalled() + expect(spy1).not.toBeCalled() + }) + + it.skip('does not call dispose on primitive materials/geometries in a tree of Mesh/Groups/Primitives created by nodeOps', () => { + const NUM_LEVEL = 5 + const NUM_CHILD_PER_NODE = 3 + const rootNode = mockTresObjectRootInObject(nodeOps.createElement('Group')) + const disposalSpies = [] + + createTreeIn(rootNode, (parent, childI, levelI) => { + if (childI > NUM_CHILD_PER_NODE || levelI > NUM_LEVEL) { + return false + } + if (Math.random() > 0.5) { + const { mesh } = createElementMesh(nodeOps) + nodeOps.insert(mesh, parent) + return mesh + } + else if (Math.random() > 0.5) { + const group = nodeOps.createElement('Group') + nodeOps.insert(group, parent) + return group + } + else { + const { primitive, material, geometry } = createElementPrimitiveMesh(nodeOps) + disposalSpies.push(vi.spyOn(geometry, 'dispose')) + disposalSpies.push(vi.spyOn(material, 'dispose')) + nodeOps.insert(primitive, parent) + return primitive + } + }) + + nodeOps.remove(rootNode) + for (const spy of disposalSpies) { + expect(spy).not.toHaveBeenCalled() + } + }) + + describe(':dispose="null"', () => { + it.skip('does not call dispose on any element in a subtree where the root :dispose==="null"', () => { + const NUM_LEVEL = 5 + const NUM_CHILD_PER_NODE = 3 + const rootNode = mockTresObjectRootInObject(nodeOps.createElement('Group')) + const disposalSpies = [] + const nullDisposeObjects = new Set() + + createTreeIn(rootNode, (parent, childI, levelI) => { + if (childI > NUM_CHILD_PER_NODE || levelI > NUM_LEVEL) { + return false + } + const { mesh, material, geometry } = createElementMesh(nodeOps) + if (nullDisposeObjects.has(parent)) { + nullDisposeObjects.add(mesh) + disposalSpies.push(vi.spyOn(geometry, 'dispose')) + disposalSpies.push(vi.spyOn(material, 'dispose')) + } + else if (levelI > 2 && Math.random() > 0.8) { + nodeOps.patchProp(mesh, 'dispose', undefined, null) + nullDisposeObjects.add(mesh) + disposalSpies.push(vi.spyOn(geometry, 'dispose')) + disposalSpies.push(vi.spyOn(material, 'dispose')) + } + nodeOps.insert(mesh, parent) + return mesh + }) + + nodeOps.remove(rootNode) + for (const spy of disposalSpies) { + expect(spy).not.toHaveBeenCalled() + } + }) + }) + + describe('in the THREE parent-child graph', () => { + it('detaches mesh from mesh', () => { + const { mesh: parent } = createElementMesh(nodeOps) + const { mesh: child } = createElementMesh(nodeOps) + nodeOps.insert(child, parent) + expect(child.parent.uuid).toBe(parent.uuid) + + nodeOps.remove(child) + expect(child.parent?.uuid).toBeFalsy() + }) + it('detaches group from mesh', () => { + const { mesh: parent } = createElementMesh(nodeOps) + const child = nodeOps.createElement('Group') + nodeOps.insert(child, parent) + expect(child.parent.uuid).toBe(parent.uuid) + + nodeOps.remove(child) + expect(child.parent?.uuid).toBeFalsy() + }) + it('detaches mesh from group', () => { + const parent = nodeOps.createElement('Group') + const { mesh: child } = createElementMesh(nodeOps) + nodeOps.insert(child, parent) + expect(child.parent.uuid).toBe(parent.uuid) + + nodeOps.remove(child) + expect(child.parent?.uuid).toBeFalsy() + }) + it.skip('detaches mesh (in primitive :object) from mesh', () => { + const { mesh: parent } = createElementMesh(nodeOps) + const { primitive, mesh } = createElementPrimitiveMesh(nodeOps) + nodeOps.insert(primitive, parent) + expect(primitive.parent?.uuid).toBe(mesh.uuid) + + nodeOps.remove(primitive) + expect(mesh.parent?.uuid).toBeFalsy() + }) + it.skip('detaches mesh (in primitive :object) when mesh ancestor is removed', () => { + const { mesh: grandparent } = createElementMesh(nodeOps) + const { mesh: parent } = createElementMesh(nodeOps) + const { primitive, mesh: primitiveMesh } = createElementPrimitiveMesh(nodeOps) + nodeOps.insert(parent, grandparent) + nodeOps.insert(primitive, parent) + expect(primitiveMesh.parent?.uuid).toBe(parent.uuid) + + nodeOps.remove(parent) + expect(primitiveMesh.parent?.uuid).toBeFalsy() + }) + it('does not detach primitive :object descendants', () => { + const { mesh: parent } = createElementMesh(nodeOps) + const { primitive, mesh: primitiveMesh } = createElementPrimitiveMesh(nodeOps) + const grandChild0 = new THREE.Mesh() + const grandChild1 = new THREE.Group() + primitiveMesh.add(grandChild0, grandChild1) + + nodeOps.insert(primitive, parent) + expect(grandChild0.parent.uuid).toBe(primitiveMesh.uuid) + expect(grandChild1.parent.uuid).toBe(primitiveMesh.uuid) + + nodeOps.remove(primitive) + expect(grandChild0.parent.uuid).toBe(primitiveMesh.uuid) + expect(grandChild1.parent.uuid).toBe(primitiveMesh.uuid) + }) + }) }) describe('patchProp', () => { @@ -468,3 +738,44 @@ function mockTresContext() { deregisterCamera: () => {}, } as unknown as TresContext } + +function createElementMesh(nodeOps: ReturnType) { + const geometry = nodeOps.createElement('BoxGeometry') + const material = nodeOps.createElement('MeshNormalMaterial') + const mesh = nodeOps.createElement('Mesh') + nodeOps.insert(geometry, mesh) + nodeOps.insert(material, mesh) + return { mesh, geometry, material } +} + +function createElementPrimitiveMesh(nodeOps: ReturnType) { + const geometry = new THREE.BoxGeometry() + const material = new THREE.MeshNormalMaterial() + const mesh = new THREE.Mesh(geometry, material) + const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: mesh }) + return { primitive, mesh, geometry, material } +} + +function createTreeIn(root: T, insertCallback: (parent: T, childI: number, levelI: number) => T) { + let levelII = 0 + const nextLevel = [root] as T[] + while (nextLevel.length) { + const currLevel = Array.from(nextLevel) + nextLevel.length = 0 + + while (currLevel.length) { + const parent = currLevel.shift() + let childI = 0 + while (true) { + const child = insertCallback(parent, childI++, levelII) + if (child) { + nextLevel.push(child) + } + else { + break + } + } + } + levelII++ + } +}