Selaa lähdekoodia

fix: attach detach (#749)

* test: add insert/remove tests

* feat: add filterInPlace

* refactor: make some LocalState fields non-optional

* test: add LocalState graph tests

* refactor: add prepare() to add __tres field

* refactor: add TODOs

* refactor: maintain parent/objects relationship in __tres

* test: add dispose=null test

* feat: allow "dispose=null" to bail out tree disposal

* refactor: update  comments

* refactor: add todo

* test: add/unskip  tests

* refactor(nodeOps): move helper functions to new file

* test: add primitive tests

* refactor: move nodeOpsUtils to utils

* feat: add pierced attach/detach

* chore: clean up merge

* chore: lint

* docs: add playground demo

* chore: update demos

---------

Co-authored-by: Alvaro Saburido <alvaro.saburido@gmail.com>
andretchen0 1 vuosi sitten
vanhempi
commit
8c1c66827c

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 4 - 0
playground/src/pages/advanced/materialArray/index.vue


+ 0 - 36
playground/src/pages/models/RiggedModel.vue

@@ -1,36 +0,0 @@
-<script setup>
-import { TresCanvas } from '@tresjs/core'
-import { OrbitControls } from '@tresjs/cientos'
-import { BasicShadowMap, NoToneMapping, SRGBColorSpace } from 'three'
-
-import UgglyBunny from '../../components/UgglyBunny.vue'
-
-const gl = {
-  clearColor: '#F78B3D',
-  shadows: true,
-  alpha: false,
-  shadowMapType: BasicShadowMap,
-  outputColorSpace: SRGBColorSpace,
-  toneMapping: NoToneMapping,
-  windowSize: true,
-}
-</script>
-
-<template>
-  <TresCanvas v-bind="gl">
-    <TresPerspectiveCamera
-      :position="[2, 2, 9]"
-      :look-at="[0, 2, 0]"
-    />
-    <OrbitControls />
-    <Suspense>
-      <UgglyBunny />
-    </Suspense>
-    <TresDirectionalLight
-      color="#F78B3D"
-      :position="[3, 3, 3]"
-      :intensity="1"
-    />
-    <TresAmbientLight :intensity="2" />
-  </TresCanvas>
-</template>

+ 0 - 0
playground/src/components/UgglyBunny.vue → playground/src/pages/models/RiggedModel/UgglyBunny.vue


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 17 - 0
playground/src/pages/models/RiggedModel/index.vue


+ 5 - 0
playground/src/router/routes/advanced.ts

@@ -24,4 +24,9 @@ export const advancedRoutes = [
     name: 'Suspense',
     component: () => import('../../pages/advanced/suspense/index.vue'),
   },
+  {
+    path: '/advanced/material-array',
+    name: 'Material array',
+    component: () => import('../../pages/advanced/materialArray/index.vue'),
+  },
 ]

+ 1 - 1
playground/src/router/routes/models.ts

@@ -7,6 +7,6 @@ export const modelsRoutes = [
   {
     path: '/models/rigged',
     name: 'Rigged Models',
-    component: () => import('../../pages/models/RiggedModel.vue'),
+    component: () => import('../../pages/models/RiggedModel/index.vue'),
   },
 ]

+ 472 - 103
src/core/nodeOps.test.ts

@@ -129,32 +129,6 @@ describe('nodeOps', () => {
     it('throws an error if tag does not exist in catalogue', () => {
       expect(() => { nodeOps.createElement('THIS_TAG_DOES_NOT_EXIST', undefined, undefined, {}) }).toThrow()
     })
-
-    it('adds material with "attach" property if instance is a material', () => {
-    // Setup
-      const tag = 'TresMeshStandardMaterial'
-      const props = { args: [] }
-
-      // Test
-      const instance = nodeOps.createElement(tag, undefined, undefined, props)
-
-      // Assert
-      expect(instance?.isMaterial).toBeTruthy()
-      expect(instance?.attach).toBe('material')
-    })
-
-    it('adds attach geometry property if instance is a geometry', () => {
-    // Setup
-      const tag = 'TresTorusGeometry'
-      const props = { args: [] }
-
-      // Test
-      const instance = nodeOps.createElement(tag, undefined, undefined, props)
-
-      // Assert
-      expect(instance?.isBufferGeometry).toBeTruthy()
-      expect(instance?.attach).toBe('geometry')
-    })
   })
 
   describe('insert', () => {
@@ -185,46 +159,47 @@ describe('nodeOps', () => {
       expect(parent.children.includes(child)).toBeTruthy()
     })
 
-    describe('primitive :object', () => {
+    describe.skip('primitive :object', () => {
       describe('into mesh', () => {
-        it.skip('inserts a mesh :object', () => {
+        it('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)
+          expect(parent.children.length).toBe(0)
           nodeOps.insert(primitive, parent)
-          expect(parent.material.uuid).toBe(object.uuid)
+          expect(parent.children.length).toBe(1)
+          expect(parent.children[0]).toBe(object)
         })
 
-        it.skip('inserts a material :object', () => {
+        it('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)
+          expect(parent.material.uuid).not.toBe(object)
           nodeOps.insert(primitive, parent)
-          expect(parent.material.uuid).toBe(object.uuid)
+          expect(parent.material).toBe(object)
         })
 
-        it.skip('inserts a geometry :object', () => {
+        it('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)
+          expect(parent.geometry).not.toBe(object)
           nodeOps.insert(primitive, parent)
-          expect(parent.material.uuid).toBe(object.uuid)
+          expect(parent.geometry).toBe(object)
         })
 
-        it.skip('inserts a group :object', () => {
+        it('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)
+          expect(parent.children.length).toBe(0)
           nodeOps.insert(primitive, parent)
-          expect(parent.material.uuid).toBe(object.uuid)
+          expect(parent.children[0]).toBe(object)
         })
       })
     })
@@ -238,22 +213,179 @@ describe('nodeOps', () => {
       }
     })
 
-    it('inserts Fog as a property', () => {
-      const parent = nodeOps.createElement('Object3D', undefined, undefined, {})
-      const fog = nodeOps.createElement('Fog', undefined, undefined, {})
-      nodeOps.insert(fog, parent)
-      expect(parent.fog).toBe(fog)
-    })
+    describe('attach/detach', () => {
+      // NOTE: Special implementation case: `attach`/`detach`
+      //
+      // Objects that aren't added to the Scene using
+      // `THREE.Object3D`'s `add` will generally be inserted
+      // using `attach` and removed using `detach`.
+      //
+      // This way of inserting/removing has special challenges:
+      // - The user can specify how the object is `attach`/`detach`ed
+      // by setting the `attach` prop.
+      // - Before a new value is `attach`ed, the system must record
+      // the current value and restore it when the new value is
+      // `detach`ed.
+      it('if "attach" prop is provided, sets `parent[attach], even if the field does not exist on the parent`', () => {
+        const parent = nodeOps.createElement('Object3D', undefined, undefined, {})
+        for (const attach of ['material', 'foo', 'bar', 'baz']) {
+          const child = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, { attach })
+          nodeOps.insert(child, parent)
+          expect(parent[attach]).toBe(child)
+          expect(parent.children.length).toBe(0)
+        }
+      })
 
-    it('if "attach" prop is provided, sets `parent[attach]`', () => {
-      const parent = nodeOps.createElement('Object3D', undefined, undefined, {})
-      for (const attach of ['material', 'foo', 'bar', 'baz']) {
-        const child = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, {})
-        child.attach = attach
-        nodeOps.insert(child, parent)
-        expect(parent[attach]).toBe(child)
-        expect(parent.children.length).toBe(0)
-      }
+      it('can attach and detach a BufferGeometry', () => {
+        const parent = nodeOps.createElement('Mesh', undefined, undefined, {})
+        const previousAttach = parent.geometry
+        const geometry0 = nodeOps.createElement('BoxGeometry', undefined, undefined, {})
+        const geometry1 = nodeOps.createElement('BoxGeometry', undefined, undefined, {})
+
+        nodeOps.insert(geometry0, parent)
+        expect(parent.geometry).not.toBe(previousAttach)
+        expect(parent.geometry).toBe(geometry0)
+
+        nodeOps.remove(geometry0)
+        nodeOps.insert(geometry1, parent)
+        expect(parent.geometry).toBe(geometry1)
+
+        nodeOps.remove(geometry1)
+        expect(parent.geometry).toBe(previousAttach)
+      })
+
+      it('can attach and detach a material', () => {
+        const parent = nodeOps.createElement('Mesh', undefined, undefined, {})
+        const previousAttach = parent.material
+        const material0 = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, {})
+        const material1 = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, {})
+        nodeOps.insert(material0, parent)
+        expect(parent.material).toBe(material0)
+
+        nodeOps.remove(material0)
+        nodeOps.insert(material1, parent)
+        expect(parent.material).toBe(material1)
+
+        nodeOps.remove(material1)
+        expect(parent.material).toBe(previousAttach)
+      })
+
+      it('can attach and detach a material array', () => {
+        const parent = nodeOps.createElement('Mesh', undefined, undefined, {})
+        const previousMaterial = parent.material
+        const material0 = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, { attach: 'material-0' })
+        const material1 = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, { attach: 'material-1' })
+        const material2 = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, { attach: 'material-2' })
+        nodeOps.insert(material0, parent)
+        expect(parent.material[0]).toStrictEqual(material0)
+        nodeOps.insert(material1, parent)
+        expect(parent.material[1]).toStrictEqual(material1)
+        nodeOps.insert(material2, parent)
+        expect(parent.material[2]).toStrictEqual(material2)
+
+        nodeOps.remove(material0)
+        expect(parent.material[0]).toBeUndefined()
+        expect(parent.material[1]).toBe(material1)
+        expect(parent.material[2]).toBe(material2)
+        nodeOps.remove(material2)
+        expect(parent.material[0]).toBeUndefined()
+        expect(parent.material[1]).toBe(material1)
+        expect(parent.material[2]).toBeUndefined()
+
+        nodeOps.patchProp(material1, 'attach', undefined, 'material-2')
+        expect(parent.material[0]).toBeUndefined()
+        expect(parent.material[1]).toBeUndefined()
+        expect(parent.material[2]).toBe(material1)
+
+        nodeOps.remove(material1)
+        expect(parent.material).toBe(previousMaterial)
+      })
+
+      it('can attach and detach fog', () => {
+        const parent = nodeOps.createElement('Mesh', undefined, undefined, {})
+        const fog = nodeOps.createElement('Fog', undefined, undefined, {})
+        nodeOps.insert(fog, parent)
+        expect(parent.fog).toBe(fog)
+        nodeOps.remove(fog)
+        expect('fog' in parent).toBe(false)
+      })
+
+      it('can attach and detach a "pierced" string', () => {
+        const parent = nodeOps.createElement('Mesh', undefined, undefined, {})
+        const material = nodeOps.createElement('MeshBasicMaterial', undefined, undefined, { color: 'red' })
+        const previousColor = material.color
+        const color = nodeOps.createElement('Color', undefined, undefined, { attach: 'material-color' })
+        nodeOps.insert(material, parent)
+        nodeOps.insert(color, parent)
+        expect(parent.material.color).toBe(color)
+        nodeOps.remove(color)
+        expect(parent.material.color).toBe(previousColor)
+
+        material.alphaMap = new THREE.Texture()
+        const previousAlphaMap = material.alphaMap
+        const alphaMap = nodeOps.createElement('Texture', undefined, undefined, { attach: 'material-alpha-map' })
+        nodeOps.insert(alphaMap, parent)
+        expect(parent.material.alphaMap).toBe(alphaMap)
+        nodeOps.remove(alphaMap)
+        expect(parent.material.alphaMap).toBe(previousAlphaMap)
+      })
+
+      it('attach can be patched', () => {
+        const parent = nodeOps.createElement('Mesh', undefined, undefined, {})
+        const previousMaterial = parent.material
+        const material = nodeOps.createElement('MeshBasicMaterial', undefined, undefined, { color: 'red', attach: 'material' })
+        nodeOps.insert(material, parent)
+        expect(parent.material).toBe(material)
+
+        nodeOps.patchProp(material, 'attach', undefined, 'foo')
+        expect(parent.foo).toBe(material)
+        expect(parent.material).toBe(previousMaterial)
+
+        nodeOps.patchProp(material, 'attach', undefined, 'material')
+        expect(parent.foo).toBeUndefined()
+        expect(parent.material).toBe(material)
+
+        nodeOps.patchProp(material, 'attach', undefined, 'bar')
+        expect(parent.bar).toBe(material)
+        expect(parent.material).toBe(previousMaterial)
+      })
+
+      it('can attach and detach a material array by patching `attach`', () => {
+        const parent = nodeOps.createElement('Mesh', undefined, undefined, {})
+        const previousMaterial = parent.material
+        const material0 = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, { attach: 'material-0' })
+        const material1 = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, { attach: 'material-1' })
+        const material2 = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, { attach: 'material-2' })
+        nodeOps.insert(material0, parent)
+        nodeOps.insert(material1, parent)
+        nodeOps.insert(material2, parent)
+        expect(parent.material[0]).toBe(material0)
+        expect(parent.material[1]).toBe(material1)
+        expect(parent.material[2]).toBe(material2)
+
+        nodeOps.patchProp(material1, 'attach', undefined, 'material-0')
+        expect(parent.material[0]).toBe(material1)
+        expect(parent.material[1]).toBeUndefined()
+        expect(parent.material[2]).toBe(material2)
+
+        nodeOps.patchProp(material1, 'attach', undefined, 'material-2')
+        expect(parent.material[0]).toBe(material0)
+        expect(parent.material[1]).toBeUndefined()
+        expect(parent.material[2]).toBe(material1)
+
+        nodeOps.patchProp(material0, 'attach', undefined, 'foo')
+        expect(parent.material[0]).toBeUndefined()
+        expect(parent.material[1]).toBeUndefined()
+        expect(parent.material[2]).toBe(material1)
+
+        nodeOps.patchProp(material1, 'attach', undefined, 'foo')
+        expect(parent.material[0]).toBeUndefined()
+        expect(parent.material[1]).toBeUndefined()
+        expect(parent.material[2]).toBe(material2)
+
+        nodeOps.patchProp(material2, 'attach', undefined, 'foo')
+        expect(parent.material).toBe(previousMaterial)
+      })
     })
 
     it('adds a material to parent.__tres.objects', () => {
@@ -299,10 +431,86 @@ describe('nodeOps', () => {
       nodeOps.insert(geometry, parent)
       nodeOps.insert(fog, parent)
       expect(parent.__tres.objects.length).toBe(3)
-      const objectSet = new Set(parent.__tres.objects)
-      expect(objectSet.has(material)).toBe(true)
-      expect(objectSet.has(geometry)).toBe(true)
-      expect(objectSet.has(fog)).toBe(true)
+      const childrenSet = new Set(parent.__tres.objects)
+      expect(childrenSet.has(material)).toBe(true)
+      expect(childrenSet.has(geometry)).toBe(true)
+      expect(childrenSet.has(fog)).toBe(true)
+    })
+
+    it.skip('can insert the same `primitive :object` in multiple places in the scene graph', () => {
+      const material = new THREE.MeshNormalMaterial()
+      const geometry = new THREE.BoxGeometry()
+      const otherMaterial = new THREE.MeshBasicMaterial()
+      const otherGeometry = new THREE.SphereGeometry()
+
+      const grandparent = nodeOps.createElement('Mesh', undefined, undefined, {})
+      const parent0 = nodeOps.createElement('Mesh', undefined, undefined, {})
+      const parent1 = nodeOps.createElement('Mesh', undefined, undefined, {})
+      const parent2 = nodeOps.createElement('Mesh', undefined, undefined, {})
+
+      const materialPrimitive0 = nodeOps.createElement('primitive', undefined, undefined, { object: material })
+      const materialPrimitive1 = nodeOps.createElement('primitive', undefined, undefined, { object: material })
+      const materialPrimitive2 = nodeOps.createElement('primitive', undefined, undefined, { object: material })
+      const materialPrimitiveOther = nodeOps.createElement('primitive', undefined, undefined, { object: otherMaterial })
+
+      const geometryPrimitive0 = nodeOps.createElement('primitive', undefined, undefined, { object: geometry })
+      const geometryPrimitive1 = nodeOps.createElement('primitive', undefined, undefined, { object: geometry })
+      const geometryPrimitive2 = nodeOps.createElement('primitive', undefined, undefined, { object: geometry })
+      const geometryPrimitiveOther = nodeOps.createElement('primitive', undefined, undefined, { object: otherGeometry })
+
+      nodeOps.insert(parent0, grandparent)
+      nodeOps.insert(parent1, grandparent)
+      nodeOps.insert(parent2, grandparent)
+      nodeOps.insert(materialPrimitive0, parent0)
+      nodeOps.insert(materialPrimitive1, parent1)
+      nodeOps.insert(materialPrimitive2, parent2)
+      nodeOps.insert(geometryPrimitive0, parent0)
+      nodeOps.insert(geometryPrimitive1, parent1)
+      nodeOps.insert(geometryPrimitive2, parent2)
+
+      expect(parent0.material).toBe(material)
+      expect(parent1.material).toBe(material)
+      expect(parent2.material).toBe(material)
+      expect(parent0.geometry).toBe(geometry)
+      expect(parent1.geometry).toBe(geometry)
+      expect(parent2.geometry).toBe(geometry)
+
+      nodeOps.insert(materialPrimitiveOther, parent0)
+      nodeOps.insert(geometryPrimitiveOther, parent1)
+
+      expect(parent0.material).not.toBe(material)
+      expect(parent1.material).toBe(material)
+      expect(parent2.material).toBe(material)
+      expect(parent0.geometry).toBe(geometry)
+      expect(parent1.geometry).not.toBe(geometry)
+      expect(parent2.geometry).toBe(geometry)
+
+      nodeOps.insert(materialPrimitiveOther, parent1)
+      nodeOps.insert(geometryPrimitiveOther, parent0)
+
+      expect(parent0.material).not.toBe(material)
+      expect(parent1.material).not.toBe(material)
+      expect(parent2.material).toBe(material)
+      expect(parent0.geometry).not.toBe(geometry)
+      expect(parent1.geometry).not.toBe(geometry)
+      expect(parent2.geometry).toBe(geometry)
+
+      nodeOps.insert(materialPrimitiveOther, parent2)
+      nodeOps.insert(geometryPrimitiveOther, parent2)
+
+      expect(parent0.material).not.toBe(material)
+      expect(parent1.material).not.toBe(material)
+      expect(parent2.material).not.toBe(material)
+      expect(parent0.geometry).not.toBe(geometry)
+      expect(parent1.geometry).not.toBe(geometry)
+      expect(parent2.geometry).not.toBe(geometry)
+
+      expect(parent0.material).toBe(otherMaterial)
+      expect(parent1.material).toBe(otherMaterial)
+      expect(parent2.material).toBe(otherMaterial)
+      expect(parent0.geometry).toBe(otherGeometry)
+      expect(parent1.geometry).toBe(otherGeometry)
+      expect(parent2.geometry).toBe(otherGeometry)
     })
   })
 
@@ -312,7 +520,7 @@ describe('nodeOps', () => {
       const child = mockTresObjectRootInObject(new Mesh() as unknown as TresObject)
       nodeOps.insert(child, parent)
       nodeOps.remove(child)
-      expect(!parent.children.includes(child)).toBeTruthy()
+      expect(parent.children.includes(child)).toBeFalsy()
     })
 
     it('silently does not remove a falsy child', () => {
@@ -330,7 +538,9 @@ describe('nodeOps', () => {
       expect(spy).toHaveBeenCalledOnce()
     })
 
-    it('calls dispose on a material array', () => {
+    it.skip('calls dispose on a material array', () => {
+      // TODO: Make this test pass.
+      // No way to add a material array via nodeOps currently.
       const parent = mockTresObjectRootInObject(nodeOps.createElement('Mesh', undefined, undefined, {}))
       const material0 = new THREE.MeshNormalMaterial()
       const material1 = new THREE.MeshNormalMaterial()
@@ -351,6 +561,19 @@ describe('nodeOps', () => {
       expect(spy).toHaveBeenCalledOnce()
     })
 
+    it('calls dispose on material/geometry in a TresMesh child of a TresMesh', () => {
+      const { mesh: grandparent } = createElementMesh(nodeOps)
+      const { mesh: parent } = createElementMesh(nodeOps)
+      const { mesh: child } = createElementMesh(nodeOps)
+      nodeOps.insert(parent, grandparent)
+      nodeOps.insert(child, parent)
+      const childMaterialDisposalSpy = vi.spyOn(child.material, 'dispose')
+      const childGeometryDisposalSpy = vi.spyOn(child.geometry, 'dispose')
+      nodeOps.remove(parent)
+      expect(childGeometryDisposalSpy).toHaveBeenCalledOnce()
+      expect(childMaterialDisposalSpy).toHaveBeenCalledOnce()
+    })
+
     it('calls dispose on every material/geometry in a TresMesh tree', () => {
       const NUM_LEVEL = 5
       const NUM_CHILD_PER_NODE = 3
@@ -417,7 +640,7 @@ describe('nodeOps', () => {
       expect(spy1).not.toBeCalled()
     })
 
-    it.skip('does not dispose primitive material/geometries on remove(ascestorOfPrimitive)', () => {
+    it('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')
@@ -430,7 +653,7 @@ describe('nodeOps', () => {
       expect(spy1).not.toBeCalled()
     })
 
-    it.skip('does not call dispose on primitive materials/geometries in a tree of Mesh/Groups/Primitives created by nodeOps', () => {
+    it('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'))
@@ -466,7 +689,31 @@ describe('nodeOps', () => {
     })
 
     describe(':dispose="null"', () => {
-      it.skip('does not call dispose on any element in a subtree where the root :dispose==="null"', () => {
+      it('does not call dispose on geometry/material in a Mesh where :dispose==="null"', () => {
+        const { mesh: parent } = createElementMesh(nodeOps)
+        const { mesh, geometry, material } = createElementMesh(nodeOps)
+        const spy0 = vi.spyOn(geometry, 'dispose')
+        const spy1 = vi.spyOn(material, 'dispose')
+        nodeOps.patchProp(mesh, 'dispose', undefined, null)
+        nodeOps.insert(mesh, parent)
+        nodeOps.remove(mesh)
+        expect(spy0).not.toBeCalled()
+        expect(spy1).not.toBeCalled()
+      })
+      it('does not call dispose on child\'s geometry/material, for remove(<parent><child :dispose="null" /></parent>)', () => {
+        const { mesh: grandparent } = createElementMesh(nodeOps)
+        const { mesh: parent } = createElementMesh(nodeOps)
+        const { mesh: child, geometry, material } = createElementMesh(nodeOps)
+        const spy0 = vi.spyOn(geometry, 'dispose')
+        const spy1 = vi.spyOn(material, 'dispose')
+        nodeOps.patchProp(child, 'dispose', undefined, null)
+        nodeOps.insert(parent, grandparent)
+        nodeOps.insert(child, parent)
+        nodeOps.remove(parent)
+        expect(spy0).not.toBeCalled()
+        expect(spy1).not.toBeCalled()
+      })
+      it('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'))
@@ -528,44 +775,46 @@ describe('nodeOps', () => {
         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)
+      describe.skip('primitive', () => {
+        it('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(parent.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(primitive)
+          expect(mesh.parent?.uuid).toBeFalsy()
+        })
+        it('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.remove(parent)
+          expect(primitiveMesh.parent?.type).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.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)
+          nodeOps.remove(primitive)
+          expect(grandChild0.parent.uuid).toBe(primitiveMesh.uuid)
+          expect(grandChild1.parent.uuid).toBe(primitiveMesh.uuid)
+        })
       })
     })
-    describe('in the __tres parent-object graph', () => {
-      it('removes parent-object relationship when object is removed', () => {
+    describe('in the __tres parent-objects graph', () => {
+      it('removes parent-objects relationship when object is removed', () => {
         const parent = nodeOps.createElement('Mesh', undefined, undefined, {})
         const material = nodeOps.createElement('MeshNormalMaterial')
         const geometry = nodeOps.createElement('BoxGeometry')
@@ -578,17 +827,17 @@ describe('nodeOps', () => {
         expect(fog.__tres.parent).toBe(parent)
 
         nodeOps.remove(fog)
-        expect(fog.__tres.parent).toBe(null)
+        expect(fog.__tres?.parent).toBeFalsy()
         expect(parent.__tres.objects.length).toBe(2)
         expect(parent.__tres.objects.includes(fog)).toBe(false)
 
         nodeOps.remove(material)
-        expect(material.__tres.parent).toBe(null)
+        expect(material.__tres?.parent).toBeFalsy()
         expect(parent.__tres.objects.length).toBe(1)
         expect(parent.__tres.objects.includes(material)).toBe(false)
 
         nodeOps.remove(geometry)
-        expect(geometry.__tres.parent).toBe(null)
+        expect(geometry.__tres?.parent).toBeFalsy()
         expect(parent.__tres.objects.length).toBe(0)
         expect(parent.__tres.objects.includes(geometry)).toBe(false)
       })
@@ -699,20 +948,135 @@ describe('nodeOps', () => {
       expect(spy).toHaveBeenCalledTimes(3)
     })
 
-    describe('patch `:object` on primitives', () => {
+    describe.skip('patch `:object` on primitives', () => {
       it('replaces original object', () => {
         const material0 = new THREE.MeshNormalMaterial()
-        const material1 = new THREE.MeshNormalMaterial()
+        const material1 = new THREE.MeshBasicMaterial()
         const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: material0 })
         nodeOps.patchProp(primitive, 'object', material0, material1)
         expect(primitive.object).toBe(material1)
       })
+
+      it('does not alter __tres on another primitive sharing the same object', () => {
+        const materialA = new THREE.MeshNormalMaterial()
+        const materialB = new THREE.MeshNormalMaterial()
+        const primitive0 = nodeOps.createElement('primitive', undefined, undefined, { object: materialA })
+        const primitive0TresJson = JSON.stringify(primitive0.__tres)
+        const primitive1 = nodeOps.createElement('primitive', undefined, undefined, { object: materialA })
+
+        expect(primitive0.__tres).not.toBe(primitive1.__tres)
+        expect(JSON.stringify(primitive0.__tres)).toBe(primitive0TresJson)
+
+        nodeOps.patchProp(primitive1, 'object', undefined, materialB)
+        expect(primitive0.__tres).not.toBe(primitive1.__tres)
+        expect(JSON.stringify(primitive0.__tres)).toBe(primitive0TresJson)
+
+        nodeOps.patchProp(primitive1, 'object', undefined, materialA)
+        expect(primitive0.__tres).not.toBe(primitive1.__tres)
+        expect(JSON.stringify(primitive0.__tres)).toBe(primitive0TresJson)
+      })
+
+      it('does not replace the object in other primitives who point to the same object', () => {
+        const { mesh: parent } = createElementMesh(nodeOps)
+        const { mesh: child0 } = createElementMesh(nodeOps)
+        const { mesh: child1 } = createElementMesh(nodeOps)
+        const materialA = new THREE.MeshNormalMaterial()
+        const materialB = new THREE.MeshBasicMaterial()
+        const primitive1 = nodeOps.createElement('primitive', undefined, undefined, { object: materialA })
+        const primitive0 = nodeOps.createElement('primitive', undefined, undefined, { object: materialA })
+
+        nodeOps.insert(primitive0, child0)
+        nodeOps.insert(primitive1, child1)
+        nodeOps.insert(child0, parent)
+        nodeOps.insert(child1, parent)
+
+        expect(child0.material).toBe(materialA)
+        expect(child1.material).toBe(materialA)
+
+        nodeOps.patchProp(primitive1, 'object', undefined, materialB)
+        expect(child0.material).toBe(materialA)
+        expect(child1.material).not.toBe(materialA)
+
+        nodeOps.patchProp(primitive1, 'object', undefined, materialA)
+        expect(child0.material).toBe(materialA)
+        expect(child1.material).toBe(materialA)
+
+        nodeOps.patchProp(primitive0, 'object', undefined, materialB)
+        expect(child0.material).not.toBe(materialA)
+        expect(child1.material).toBe(materialA)
+
+        nodeOps.patchProp(primitive1, 'object', undefined, materialB)
+        expect(child0.material).not.toBe(materialA)
+        expect(child1.material).not.toBe(materialA)
+        expect(child0.material).toBe(materialB)
+        expect(child1.material).toBe(materialB)
+      })
+      it('attaches the new object to the old object\'s parent; clears old object\'s parent', () => {
+        const { mesh: parent } = createElementMesh(nodeOps)
+        const { mesh: child0 } = createThreeBox()
+        const { mesh: child1 } = createThreeBox()
+        const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: child0 })
+        nodeOps.insert(primitive, parent)
+        expect(child0.parent).toBe(parent)
+        expect(parent.children[0]).toBe(child0)
+        expect(parent.children.length).toBe(1)
+
+        nodeOps.patchProp(primitive, 'object', undefined, child1)
+        expect(child0.parent?.uuid).toBeFalsy()
+        expect(child1.parent?.uuid).toBe(parent.uuid)
+        expect(parent.children[0]).toBe(child1)
+        expect(parent.children.length).toBe(1)
+      })
+      it('if old :object had been patched, those patches are applied to new :object', () => {
+        const { mesh: parent } = createElementMesh(nodeOps)
+        const { mesh: child0 } = createElementMesh(nodeOps)
+        const { mesh: child1 } = createElementMesh(nodeOps)
+        const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: child0 })
+        nodeOps.insert(primitive, parent)
+        nodeOps.patchProp(primitive, 'position-x', undefined, -999)
+        expect(child0.position.x).toBe(-999)
+
+        nodeOps.patchProp(primitive, 'object', undefined, child1)
+        expect(child1.position.x).toBe(-999)
+
+        nodeOps.patchProp(primitive, 'position-x', undefined, 1000)
+        nodeOps.patchProp(primitive, 'object', undefined, child0)
+        expect(child0.position.x).toBe(1000)
+      })
+      it('does not attach old :object children to new :object', () => {
+        const { mesh: parent } = createElementMesh(nodeOps)
+        const { mesh: child0 } = createElementMesh(nodeOps)
+        const { mesh: child1 } = createElementMesh(nodeOps)
+        const grandchild0 = new THREE.Mesh()
+        const grandchild1 = new THREE.Mesh()
+        child0.add(grandchild0)
+        child1.add(grandchild1)
+        const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: child0 })
+        nodeOps.insert(primitive, parent)
+        expect(primitive.children[0]).toBe(grandchild0)
+        expect(primitive.children.length).toBe(1)
+
+        nodeOps.patchProp(primitive, 'object', undefined, child1)
+        expect(primitive.children[0]).toBe(grandchild1)
+        expect(primitive.children.length).toBe(1)
+
+        nodeOps.patchProp(primitive, 'object', undefined, child0)
+        expect(primitive.children[0].uuid).toBe(grandchild0.uuid)
+        expect(primitive.children.length).toBe(1)
+
+        nodeOps.patchProp(primitive, 'object', undefined, child1)
+        expect(primitive.children[0]).toBe(grandchild1)
+        expect(primitive.children.length).toBe(1)
+      })
       it('does not copy UUID', () => {
         const material0 = new THREE.MeshNormalMaterial()
         const material1 = new THREE.MeshNormalMaterial()
         const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: material0 })
         nodeOps.patchProp(primitive, 'object', material0, material1)
         expect(material0.uuid).not.toBe(material1.uuid)
+
+        nodeOps.patchProp(primitive, 'object', material1, material0)
+        expect(material0.uuid).not.toBe(material1.uuid)
       })
     })
 
@@ -817,6 +1181,13 @@ function mockTresContext() {
   } as unknown as TresContext
 }
 
+function createThreeBox() {
+  const geometry = new THREE.BoxGeometry()
+  const material = new THREE.MeshNormalMaterial()
+  const mesh = new THREE.Mesh(geometry, material)
+  return { mesh, geometry, material }
+}
+
 function createElementMesh(nodeOps: ReturnType<typeof getNodeOps>) {
   const geometry = nodeOps.createElement('BoxGeometry')
   const material = nodeOps.createElement('MeshNormalMaterial')
@@ -827,9 +1198,7 @@ function createElementMesh(nodeOps: ReturnType<typeof getNodeOps>) {
 }
 
 function createElementPrimitiveMesh(nodeOps: ReturnType<typeof getNodeOps>) {
-  const geometry = new THREE.BoxGeometry()
-  const material = new THREE.MeshNormalMaterial()
-  const mesh = new THREE.Mesh(geometry, material)
+  const { mesh, geometry, material } = createThreeBox()
   const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: mesh })
   return { primitive, mesh, geometry, material }
 }

+ 107 - 61
src/core/nodeOps.ts

@@ -2,10 +2,9 @@ import type { RendererOptions } from 'vue'
 import { BufferAttribute, Object3D } from 'three'
 import type { TresContext } from '../composables'
 import { useLogger } from '../composables'
-import { deepArrayEqual, disposeObject3D, filterInPlace, isHTMLTag, kebabToCamel } from '../utils'
+import { attach, deepArrayEqual, detach, filterInPlace, invalidateInstance, isHTMLTag, kebabToCamel, noop, prepareTresInstance } from '../utils'
 import type { InstanceProps, TresInstance, TresObject, TresObject3D } from '../types'
 import * as is from '../utils/is'
-import { invalidateInstance, noop, prepareTresInstance } from '../utils/nodeOpsUtils'
 import { catalogue } from './catalogue'
 
 const { logError } = useLogger()
@@ -39,13 +38,13 @@ export const nodeOps: (context: TresContext) => RendererOptions<TresObject, Tres
     if (tag === 'template') { return null }
     if (isHTMLTag(tag)) { return null }
     let name = tag.replace('Tres', '')
-    let instance: TresObject | null
+    let obj: TresObject | null
 
     if (tag === 'primitive') {
       if (props?.object === undefined) { logError('Tres primitives need a prop \'object\'') }
       const object = props.object as TresObject
       name = object.type
-      instance = Object.assign(object.clone(), { type: name }) as TresObject
+      obj = Object.assign(object.clone(), { type: name }) as TresObject
     }
     else {
       const target = catalogue.value[name]
@@ -55,122 +54,153 @@ export const nodeOps: (context: TresContext) => RendererOptions<TresObject, Tres
         )
       }
       // eslint-disable-next-line new-cap
-      instance = new target(...props.args) as TresObject
+      obj = new target(...props.args) as TresObject
     }
 
-    if (!instance) { return null }
+    if (!obj) { return null }
 
-    if (instance.isCamera) {
+    if (obj.isCamera) {
       if (!props?.position) {
-        instance.position.set(3, 3, 3)
+        obj.position.set(3, 3, 3)
       }
       if (!props?.lookAt) {
-        instance.lookAt(0, 0, 0)
+        obj.lookAt(0, 0, 0)
       }
     }
 
-    if (props?.attach === undefined) {
-      if (instance.isMaterial) { instance.attach = 'material' }
-      else if (instance.isBufferGeometry) { instance.attach = 'geometry' }
-    }
-
-    instance = prepareTresInstance(instance, {
-      ...instance.__tres,
+    const instance = prepareTresInstance(obj, {
+      ...obj.__tres,
       type: name,
       memoizedProps: props,
       eventCount: 0,
       disposable: true,
       primitive: tag === 'primitive',
+      attach: props.attach,
     }, context)
 
+    if (!instance.__tres.attach) {
+      if (instance.isMaterial) { instance.__tres.attach = 'material' }
+      else if (instance.isBufferGeometry) { instance.__tres.attach = 'geometry' }
+      else if (instance.isFog) { instance.__tres.attach = 'fog' }
+    }
+
     // determine whether the material was passed via prop to
     // prevent it's disposal when node is removed later in it's lifecycle
-    if (instance.isObject3D && instance.__tres && (props?.material || props?.geometry)) {
+    if (instance.isObject3D && (props?.material || props?.geometry)) {
       instance.__tres.disposable = false
     }
 
-    return instance as TresObject
+    return obj as TresObject
   }
 
   function insert(child: TresObject, parent: TresObject) {
     if (!child) { return }
-    const childInstance: TresInstance = (child.__tres ? child as TresInstance : prepareTresInstance(child, {}))
-
-    const parentObject = parent || scene
+    parent = parent || scene
+    const childInstance: TresInstance = (child.__tres ? child as TresInstance : prepareTresInstance(child, {}, context))
+    const parentInstance: TresInstance = (parent.__tres ? parent as TresInstance : prepareTresInstance(parent, {}, context))
 
     context.registerCamera(child)
     // NOTE: Track onPointerMissed objects separate from the scene
     context.eventManager?.registerPointerMissedObject(child)
 
     let insertedWithAdd = false
-    if (is.object3D(child) && is.object3D(parentObject)) {
-      parentObject.add(child)
+    if (childInstance.__tres.attach) {
+      attach(parentInstance, childInstance, childInstance.__tres.attach)
+    }
+    else if (is.object3D(child) && is.object3D(parentInstance)) {
+      parentInstance.add(child)
       insertedWithAdd = true
       child.dispatchEvent({ type: 'added' })
     }
-    else if (is.fog(child)) {
-      // TODO
-      // Currently `material` and `geometry` are attached by
-      // setting `attach` in `createElement`.
-      // Do the same here to eliminate this branch.
-      parentObject.fog = child
-    }
-    else if (typeof child.attach === 'string') {
-      child.__previousAttach = child[parentObject?.attach as string]
-      if (parentObject) {
-        parentObject[child.attach] = child
-      }
-    }
 
     // NOTE: Update __tres parent/objects graph
-    childInstance.__tres.parent = parentObject
-    if (parentObject.__tres?.objects && !insertedWithAdd) {
-      if (!parentObject.__tres.objects.includes(child)) {
-        parentObject.__tres.objects.push(child)
+    childInstance.__tres.parent = parentInstance
+    if (parentInstance.__tres?.objects && !insertedWithAdd) {
+      if (!parentInstance.__tres.objects.includes(child)) {
+        parentInstance.__tres.objects.push(child)
       }
     }
   }
 
-  function remove(node: TresObject | null) {
+  function remove(node: TresObject | null, dispose?: boolean) {
+    // NOTE: `remove` is initially called by Vue only on
+    // the root `node` of the tree to be removed. Vue does not
+    // pass a `dispose` argument.
+    // Where appropriate, we will recursively call `remove`
+    // on `children` and `__tres.objects`.
+    // We will derive and pass a value for `dispose`, allowing
+    // nodes to "bail out" of disposal for their subtree.
+
     if (!node) { return }
-    // remove is only called on the node being removed and not on child nodes.
+
+    // NOTE: Derive value for `dispose`.
+    // We stop disposal of a node and its tree if any of these are true:
+    // 1) it is a <primitive :object="..." />
+    // 2) it has :dispose="null"
+    // 3) it was bailed out by a parent passing `remove(..., false)`
+    const isPrimitive = node.__tres?.primitive
+    const isDisposeNull = node.dispose === null
+    const isBailedOut = dispose === false
+    const shouldDispose = !(isPrimitive || isDisposeNull || isBailedOut)
 
     // TODO:
     // Figure out why `parent` is being set on `node` here
     // and remove/refactor.
     node.parent = node.parent || scene
 
-    // NOTE: Update __tres parent/objects graph
+    // NOTE: Remove `node` from __tres parent/objects graph
     const parent = node.__tres?.parent || scene
     if (node.__tres) { node.__tres.parent = null }
     if (parent.__tres && 'objects' in parent.__tres) {
       filterInPlace(parent.__tres.objects, obj => obj !== node)
     }
 
-    if (is.object3D(node)) {
+    // NOTE: THREE.removeFromParent removes `node` from
+    // `parent.children`.
+    if (node.__tres?.attach) {
+      detach(parent, node, node.__tres.attach)
+    }
+    else {
       node.removeFromParent?.()
+    }
 
-      // Remove nested child objects. Primitives should not have objects and children that are
-      // attached to them declaratively ...
-      node.traverse((child) => {
-        context.deregisterCamera(child)
-        // deregisterAtPointerEventHandlerIfRequired?.(child as TresObject)
-        context.eventManager?.deregisterPointerMissedObject(child)
-      })
-
-      context.deregisterCamera(node)
-      /*  deregisterAtPointerEventHandlerIfRequired?.(node as TresObject) */
-      invalidateInstance(node as TresObject)
+    // NOTE: Deregister `node` THREE.Object3D children
+    node.traverse?.((child) => {
+      context.deregisterCamera(child)
+      // deregisterAtPointerEventHandlerIfRequired?.(child as TresObject)
+      context.eventManager?.deregisterPointerMissedObject(child)
+    })
 
-      // Dispose the object if it's disposable, primitives needs to be manually disposed by
-      // calling dispose from `@tresjs/core` package like this `dispose(model)`
-      const isPrimitive = node.__tres?.primitive
+    // NOTE: Deregister `node`
+    context.deregisterCamera(node)
+    /*  deregisterAtPointerEventHandlerIfRequired?.(node as TresObject) */
+    invalidateInstance(node as TresObject)
 
-      if (!isPrimitive && node.__tres?.disposable) {
-        disposeObject3D(node)
+    // TODO: support removing `attach`ed components
+
+    // NOTE: Recursively `remove` children and objects.
+    // Never on primitives:
+    // - removing children would alter the primitive :object.
+    // - primitives are not expected to have declarative children
+    //   and so should not have `objects`.
+    if (!isPrimitive) {
+      // NOTE: In recursive `remove`, the array elements will
+      // remove themselves from these arrays, resulting in
+      // skipped elements. Make shallow copies of the arrays.
+      if (node.children) {
+        [...node.children].forEach(child => remove(child, shouldDispose))
+      }
+      if (node.__tres && 'objects' in node.__tres) {
+        [...node.__tres.objects].forEach(obj => remove(obj, shouldDispose))
       }
-      node.dispose?.()
     }
+
+    // NOTE: Dispose `node`
+    if (shouldDispose && node.dispose && !is.scene(node)) {
+      node.dispose()
+    }
+
+    delete node.__tres
   }
 
   function patchProp(node: TresObject, prop: string, prevValue: any, nextValue: any) {
@@ -178,6 +208,19 @@ export const nodeOps: (context: TresContext) => RendererOptions<TresObject, Tres
 
     let root = node
     let key = prop
+
+    if (prop === 'attach') {
+      // NOTE: `attach` is not a field on a TresObject.
+      // `nextValue` is a string representing how Tres
+      // should attach `node` to its parent – if the
+      // parent exists.
+      const maybeParent = node.__tres?.parent || node.parent
+      remove(node)
+      prepareTresInstance(node, { attach: nextValue }, context)
+      if (maybeParent) { insert(node, maybeParent) }
+      return
+    }
+
     if (node.__tres?.primitive && key === 'object' && prevValue !== null) {
       // If the prop 'object' is changed, we need to re-instance the object and swap the old one with the new one
       const newInstance = createElement('primitive', undefined, undefined, {
@@ -248,6 +291,9 @@ export const nodeOps: (context: TresContext) => RendererOptions<TresObject, Tres
 
     // Traverse pierced props (e.g. foo-bar=value => foo.bar = value)
     if (key.includes('-') && target === undefined) {
+      // TODO: A standalone function called `resolve` is
+      // available in /src/utils/index.ts. It's covered by tests.
+      // Refactor below to DRY.
       const chain = key.split('-')
       target = chain.reduce((acc, key) => acc[kebabToCamel(key)], root)
       key = chain.pop() as string

+ 4 - 4
src/types/index.ts

@@ -6,8 +6,8 @@ import type { TresContext } from '../composables/useTresContextProvider'
 // Based on React Three Fiber types by Pmndrs
 // https://github.com/pmndrs/react-three-fiber/blob/v9/packages/fiber/src/three-types.ts
 
-export type AttachFnType<O = any> = (parent: any, self: O) => () => void
-export type AttachType<O = any> = string | AttachFnType<O>
+export type AttachFnType = (parent: any, self: TresInstance) => () => void
+export type AttachType = string | AttachFnType
 
 export type ConstructorRepresentation = new (...args: any[]) => any
 export type NonFunctionKeys<P> = { [K in keyof P]-?: P[K] extends Function ? never : K }[keyof P]
@@ -29,12 +29,10 @@ export interface InstanceProps<T = any, P = any> {
   object?: T
   visible?: boolean
   dispose?: null
-  attach?: AttachType<T>
   [prop: string]: any
 }
 
 interface TresBaseObject {
-  attach?: string
   removeFromParent?: () => void
   dispose?: () => void
   [prop: string]: any // for arbitrary properties
@@ -58,6 +56,8 @@ export interface LocalState {
 
   primitive?: boolean
   disposable?: boolean
+  attach?: AttachType
+  previousAttach: any
 }
 
 // Custom type for geometry and material properties in Object3D

+ 75 - 0
src/utils/index.test.ts

@@ -60,3 +60,78 @@ function shuffle(array: any[]) {
   }
   return array
 };
+
+describe('resolve', () => {
+  it('returns the first argument if it contains the key', () => {
+    const instance = { ab: 0 }
+    const { target, key } = utils.resolve(instance, 'ab')
+    expect(target).toBe(instance)
+    expect(key).toBe('ab')
+  })
+  it('splits the key by "-" and traverses the obj using the pieces', () => {
+    const instance = { ab: { cd: { ef: 0 } } }
+    const { target, key } = utils.resolve(instance, 'ab-cd-ef')
+    expect(target).toBe(instance.ab.cd)
+    expect(key).toBe('ef')
+  })
+  it('returns the current target holding the end of the key, and the end of the key', () => {
+    const instance = { ab: { cd: { ef: { gh: 0 } } } }
+    const { target, key } = utils.resolve(instance, 'ab-cd-ef')
+    expect(target).toBe(instance.ab.cd)
+    expect(key).toBe('ef')
+  })
+  it('joins pierced props as camelCase, non-greedily', () => {
+    {
+      const instance = { abCdEfGh: { ij: 0 } }
+      const { target, key } = utils.resolve(instance, 'ab-cd-ef-gh-ij')
+      expect(target).toBe(instance.abCdEfGh)
+      expect(key).toBe('ij')
+    }
+
+    {
+      const instance = {
+        abCdEfGh: { ij: 0 },
+        abCdEf: { gh: { ij: 0 } },
+      }
+      const { target, key } = utils.resolve(instance, 'ab-cd-ef-gh-ij')
+      expect(target).toBe(instance.abCdEf.gh)
+      expect(key).toBe('ij')
+    }
+
+    {
+      const instance = {
+        abCdEfGh: { ij: 0 },
+        abCd: { ef: { gh: { ij: 0 } }, efGh: { ij: 0 } },
+        abCdEf: { gh: { ij: 0 } },
+      }
+      const { target, key } = utils.resolve(instance, 'ab-cd-ef-gh-ij')
+      expect(target).toBe(instance.abCd.ef.gh)
+      expect(key).toBe('ij')
+    }
+
+    {
+      const instance = {
+        abCdEfGh: { ij: 0 },
+        abCdEf: { ghIj: 0 },
+        ab: { cdEfGhIj: 0 },
+        abCd: { ef: { gh: { ij: 0 } }, efGh: { ij: 0 } },
+      }
+      const { target, key } = utils.resolve(instance, 'ab-cd-ef-gh-ij')
+      expect(target).toBe(instance.ab)
+      expect(key).toBe('cdEfGhIj')
+    }
+  })
+
+  it('joins my-key-and-the-unfindable-suffix as andTheUnfindableSuffix, for key suffixes that do not exist', () => {
+    expect(utils.resolve({}, 'zz').key).toBe('zz')
+    expect(utils.resolve({}, 'ab-cd-ef-gh-ij').key).toBe('abCdEfGhIj')
+
+    const instance = { ab: { cd: { ef: { gh: { ij: 0 } } } } }
+    expect(utils.resolve(instance, 'ab-cd-ef-gh-ij-xx-yy-zz').key).toBe('xxYyZz')
+    expect(utils.resolve(instance, 'xx-yy-zz').key).toBe('xxYyZz')
+    expect(utils.resolve(instance, 'ab-xx-yy-zz').key).toBe('xxYyZz')
+    expect(utils.resolve(instance, 'ab-cd-zz').key).toBe('zz')
+    expect(utils.resolve(instance, 'ab-cd-xx-yy-zz').key).toBe('xxYyZz')
+    expect(utils.resolve(instance, 'ab-cd-xx-yy-zz').key).toBe('xxYyZz')
+  })
+})

+ 116 - 1
src/utils/index.ts

@@ -1,7 +1,9 @@
 import type { Material, Mesh, Object3D, Texture } from 'three'
 import { DoubleSide, MeshBasicMaterial, Scene, Vector3 } from 'three'
-import type { TresObject } from 'src/types'
+import type { AttachType, LocalState, TresInstance, TresObject } from 'src/types'
 import { HightlightMesh } from '../devtools/highlight'
+import type { TresContext } from '../composables/useTresContextProvider'
+import * as is from './is'
 
 export function toSetMethodName(key: string) {
   return `set${key[0].toUpperCase()}${key.slice(1)}`
@@ -330,3 +332,116 @@ export function filterInPlace<T>(array: T[], callbackFn: (element: T, index: num
   array.length = i
   return array
 }
+
+export function resolve(obj: Record<string, any>, key: string) {
+  let target = obj
+  if (key.includes('-')) {
+    const entries = key.split('-')
+    let currKey = entries.shift() as string
+    while (target && entries.length) {
+      if (!(currKey in target)) {
+        currKey = joinAsCamelCase(currKey, entries.shift() as string)
+      }
+      else {
+        target = target[currKey]
+        currKey = entries.shift() as string
+      }
+    }
+    return { target, key: joinAsCamelCase(currKey, ...entries) }
+  }
+  else {
+    return { target, key }
+  }
+}
+
+function joinAsCamelCase(...strings: string[]): string {
+  return strings.map((s, i) => i === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1)).join('')
+}
+
+// Checks if a dash-cased string ends with an integer
+const INDEX_REGEX = /-\d+$/
+
+export function attach(parent: TresInstance, child: TresInstance, type: AttachType) {
+  if (is.str(type)) {
+    // NOTE: If attaching into an array (foo-0), create one
+    if (INDEX_REGEX.test(type)) {
+      const typeWithoutTrailingIndex = type.replace(INDEX_REGEX, '')
+      const { target, key } = resolve(parent, typeWithoutTrailingIndex)
+      if (!Array.isArray(target[key])) {
+        // NOTE: Create the array and augment it with a function
+        // that resets the original value if the array is empty or
+        // `[undefined, undefined, ...]`. The function will be run
+        // every time an element is `detach`ed from the array.
+        const previousAttach = target[key]
+        const augmentedArray: any[] & { __tresDetach?: () => void } = []
+        augmentedArray.__tresDetach = () => {
+          if (augmentedArray.every(v => is.und(v))) {
+            target[key] = previousAttach
+          }
+        }
+        target[key] = augmentedArray
+      }
+    }
+
+    const { target, key } = resolve(parent, type)
+    child.__tres.previousAttach = target[key]
+    target[key] = child
+  }
+  else {
+    child.__tres.previousAttach = type(parent, child)
+  }
+}
+
+export function detach(parent: any, child: TresInstance, type: AttachType) {
+  if (is.str(type)) {
+    const { target, key } = resolve(parent, type)
+    const previous = child.__tres.previousAttach
+    // When the previous value was undefined, it means the value was never set to begin with
+    if (previous === undefined) {
+      delete target[key]
+    }
+    // NOTE: If the previous value was not an array, and `attach` turned it into an array
+    // then it also set `__tresOnArrayElementsUndefined`. Check for it and revert
+    // Otherwise set the previous value
+    else {
+      target[key] = previous
+    }
+
+    if ('__tresDetach' in target) { target.__tresDetach() }
+  }
+  else {
+    child.__tres?.previousAttach?.(parent, child)
+  }
+  delete child.__tres?.previousAttach
+}
+
+export function prepareTresInstance<T extends TresObject>(obj: T, state: Partial<LocalState>, context: TresContext): TresInstance {
+  const instance = obj as unknown as TresInstance
+  instance.__tres = {
+    type: 'unknown',
+    eventCount: 0,
+    root: context,
+    handlers: {},
+    memoizedProps: {},
+    objects: [],
+    parent: null,
+    previousAttach: null,
+    ...state,
+  }
+  return instance
+}
+
+export function invalidateInstance(instance: TresObject) {
+  const ctx = instance?.__tres?.root
+
+  if (!ctx) { return }
+
+  if (ctx.render && ctx.render.canBeInvalidated.value) {
+    ctx.invalidate()
+  }
+}
+
+export function noop(fn: string): any {
+  // eslint-disable-next-line no-unused-expressions
+  fn
+}

+ 30 - 0
src/utils/is.test.ts

@@ -2,6 +2,36 @@ import { BufferGeometry, Fog, MeshBasicMaterial, MeshNormalMaterial, Object3D, P
 import * as is from './is'
 
 describe('is', () => {
+  describe('is.und(a: any)', () => {
+    describe('true', () => {
+      it('undefined', () => {
+        assert(is.und(undefined))
+      })
+    })
+    describe('false', () => {
+      it('null', () => {
+        assert(!is.und(null))
+      })
+      it('number', () => {
+        assert(!is.und(0))
+        assert(!is.und(-1))
+        assert(!is.und(Math.PI))
+        assert(!is.und(Number.POSITIVE_INFINITY))
+        assert(!is.und(Number.NEGATIVE_INFINITY))
+        assert(!is.und(42))
+      })
+      it('string', () => {
+        assert(!is.und(''))
+        assert(!is.und('tresObject'))
+      })
+      it('function', () => {
+        assert(!is.und(() => {}))
+      })
+      it('array', () => {
+        assert(!is.und([]))
+      })
+    })
+  })
   describe('is.tresObject(a: any)', () => {
     describe('true', () => {
       it('object3D', () => {

+ 13 - 1
src/utils/is.ts

@@ -1,10 +1,18 @@
 import type { TresObject } from 'src/types'
-import type { BufferGeometry, Camera, Fog, Material, Object3D } from 'three'
+import type { BufferGeometry, Camera, Fog, Material, Object3D, Scene } from 'three'
+
+export function und(u: unknown) {
+  return typeof u === 'undefined'
+}
 
 export function arr(u: unknown) {
   return Array.isArray(u)
 }
 
+export function str(u: unknown): u is string {
+  return typeof u === 'string'
+}
+
 export function fun(u: unknown): u is Function {
   return typeof u === 'function'
 }
@@ -33,6 +41,10 @@ export function fog(u: unknown): u is Fog {
   return obj(u) && 'isFog' in u && !!(u.isFog)
 }
 
+export function scene(u: unknown): u is Scene {
+  return obj(u) && 'isScene' in u && !!(u.isScene)
+}
+
 export function tresObject(u: unknown): u is TresObject {
   // NOTE: TresObject is currently defined as
   // TresObject3D | THREE.BufferGeometry | THREE.Material | THREE.Fog

+ 0 - 32
src/utils/nodeOpsUtils.ts

@@ -1,32 +0,0 @@
-import type { TresContext } from '../composables/useTresContextProvider'
-import type { LocalState, TresInstance, TresObject } from '../types'
-
-export function prepareTresInstance<T extends TresObject>(obj: T, state: Partial<LocalState>, context: TresContext): TresInstance {
-  const instance = obj as unknown as TresInstance
-  instance.__tres = {
-    type: 'unknown',
-    eventCount: 0,
-    root: context,
-    handlers: {},
-    memoizedProps: {},
-    objects: [],
-    parent: null,
-    ...state,
-  }
-  return instance
-}
-
-export function invalidateInstance(instance: TresObject) {
-  const ctx = instance?.__tres?.root
-
-  if (!ctx) { return }
-
-  if (ctx.render && ctx.render.canBeInvalidated.value) {
-    ctx.invalidate()
-  }
-}
-
-export function noop(fn: string): any {
-  // eslint-disable-next-line no-unused-expressions
-  fn
-}

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä