From cb838882342146df51cfae3a5afeeaba04d92bdf Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 19 Jun 2024 16:48:27 +0200 Subject: [PATCH 01/20] test: add insert/remove tests --- src/core/nodeOps.test.ts | 313 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 312 insertions(+), 1 deletion(-) 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++ + } +} From 700d1bac7e4b6493dac9455fad5fbd467fa4b0df Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 20 Jun 2024 17:25:28 +0200 Subject: [PATCH 02/20] feat: add filterInPlace --- src/utils/index.test.ts | 62 +++++++++++++++++++++++++++++++++++++++++ src/utils/index.ts | 17 +++++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/utils/index.test.ts diff --git a/src/utils/index.test.ts b/src/utils/index.test.ts new file mode 100644 index 000000000..e39f822a7 --- /dev/null +++ b/src/utils/index.test.ts @@ -0,0 +1,62 @@ +import * as utils from './index' + +describe('filterInPlace', () => { + it('returns the passed array', () => { + const arr = [1, 2, 3] + const result = utils.filterInPlace(arr, v => v !== 0) + expect(result).toBe(arr) + }) + it('removes a single occurence', () => { + const arr = [1, 2, 3] + utils.filterInPlace(arr, v => v !== 1) + expect(arr).toStrictEqual([2, 3]) + }) + it('removes every occurence 0', () => { + const arr = [1, 1, 2, 1, 3, 1] + utils.filterInPlace(arr, v => v !== 1) + expect(arr).toStrictEqual([2, 3]) + }) + + it('removes every occurence 1', () => { + const [a, b, c] = [{}, {}, {}] + const COUNT = 400 + const arr = [] + for (const val of [a, b, c]) { + for (let i = 0; i < COUNT; i++) { + arr.push(val) + } + } + shuffle(arr) + + let filtered = [...arr] + utils.filterInPlace(arr, v => v !== b) + filtered = filtered.filter(v => v !== b) + expect(arr).toStrictEqual(filtered) + + utils.filterInPlace(arr, v => v !== c) + filtered = filtered.filter(v => v !== c) + expect(arr).toStrictEqual(filtered) + + utils.filterInPlace(arr, v => v !== a) + expect(arr).toStrictEqual([]) + }) + + it('sends an index to the callbackFn', () => { + const arr = 'abcdefghi'.split('') + utils.filterInPlace(arr, (_, i) => i % 2 === 0) + expect(arr).toStrictEqual('acegi'.split('')) + }) +}) + +function shuffle(array: any[]) { + let currentIndex = array.length + while (currentIndex !== 0) { + const randomIndex = Math.floor(Math.random() * currentIndex) + currentIndex--; + [array[currentIndex], array[randomIndex]] = [ + array[randomIndex], + array[currentIndex], + ] + } + return array +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 77faf0019..f85c5f04b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -313,3 +313,20 @@ export function disposeObject3D(object: TresObject): void { } } } + +/** + * Like Array.filter, but modifies the array in place. + * @param array - Array to modify + * @param callbackFn - A function called for each element of the array. It should return a truthy value to keep the element in the array. + */ +export function filterInPlace(array: T[], callbackFn: (element: T, index: number) => unknown) { + let i = 0 + for (let ii = 0; ii < array.length; ii++) { + if (callbackFn(array[ii], ii)) { + array[i] = array[ii] + i++ + } + } + array.length = i + return array +} From 3d1541f0e278186144354d9a9adce773b667390b Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 20 Jun 2024 17:59:26 +0200 Subject: [PATCH 03/20] refactor: make some LocalState fields non-optional --- src/types/index.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/types/index.ts b/src/types/index.ts index 0f7550d93..34beb158e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -42,15 +42,22 @@ interface TresBaseObject { export interface LocalState { type: string - // objects and parent are used when children are added with `attach` instead of being added to the Object3D scene graph - objects?: TresObject3D[] - parent?: TresObject3D | null + eventCount: number + root: TresContext + handlers: Partial + memoizedProps: { [key: string]: any } + // NOTE: + // LocalState holds information about the parent/child relationship + // in the Vue graph. If a child is `insert`ed into a parent using + // anything but THREE's `add`, it's put into the parent's `objects`. + // objects and parent are used when children are added with `attach` + // instead of being added to the Object3D scene graph + objects: TresObject[] + parent: TresObject | null + // NOTE: End graph info + primitive?: boolean - eventCount?: number - handlers?: Partial - memoizedProps?: { [key: string]: any } disposable?: boolean - root?: TresContext } // Custom type for geometry and material properties in Object3D @@ -62,6 +69,8 @@ export interface TresObject3D extends THREE.Object3D { export type TresObject = TresBaseObject & (TresObject3D | THREE.BufferGeometry | THREE.Material | THREE.Fog) & { __tres?: LocalState } +export type TresInstance = TresObject & { __tres: LocalState } + export interface TresScene extends THREE.Scene { __tres: { root: TresContext From 808e08205bcda837437f22ffb676363607881140 Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 20 Jun 2024 18:02:45 +0200 Subject: [PATCH 04/20] test: add LocalState graph tests --- src/core/nodeOps.test.ts | 78 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/core/nodeOps.test.ts b/src/core/nodeOps.test.ts index f298a95e9..1e71920ee 100644 --- a/src/core/nodeOps.test.ts +++ b/src/core/nodeOps.test.ts @@ -255,6 +255,55 @@ describe('nodeOps', () => { expect(parent.children.length).toBe(0) } }) + + it('adds a material to parent.__tres.objects', () => { + const parent = nodeOps.createElement('Mesh', undefined, undefined, {}) + const material = nodeOps.createElement('MeshNormalMaterial') + nodeOps.insert(material, parent) + expect(parent.__tres.objects.map(child => child.uuid)).toStrictEqual([material.uuid]) + }) + + it('adds a geometry to parent.__tres.objects', () => { + const parent = nodeOps.createElement('Mesh', undefined, undefined, {}) + const geometry = nodeOps.createElement('BoxGeometry') + nodeOps.insert(geometry, parent) + expect(parent.__tres.objects.map(child => child.uuid)).toStrictEqual([geometry.uuid]) + }) + + it('adds a fog to parent.__tres.objects', () => { + const parent = nodeOps.createElement('Mesh', undefined, undefined, {}) + const fog = nodeOps.createElement('Fog') + nodeOps.insert(fog, parent) + expect(parent.__tres.objects.map(child => child.uuid)).toStrictEqual([fog.uuid]) + }) + + it('adds parent to child.__tres.parent', () => { + const parent = nodeOps.createElement('Mesh', undefined, undefined, {}) + const material = nodeOps.createElement('MeshNormalMaterial') + const geometry = nodeOps.createElement('BoxGeometry') + const fog = nodeOps.createElement('Fog') + nodeOps.insert(material, parent) + nodeOps.insert(geometry, parent) + nodeOps.insert(fog, parent) + expect(material.__tres.parent).toBe(parent) + expect(geometry.__tres.parent).toBe(parent) + expect(fog.__tres.parent).toBe(parent) + }) + + it('adds non-Object3D children to parent.__tres.objects, but no more than once', () => { + const parent = nodeOps.createElement('Mesh', undefined, undefined, {}) + const material = nodeOps.createElement('MeshNormalMaterial') + const geometry = nodeOps.createElement('BoxGeometry') + const fog = nodeOps.createElement('Fog') + nodeOps.insert(material, parent) + 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) + }) }) describe('remove', () => { @@ -515,6 +564,35 @@ describe('nodeOps', () => { expect(grandChild1.parent.uuid).toBe(primitiveMesh.uuid) }) }) + describe('in the __tres parent-object graph', () => { + it('removes parent-object relationship when object is removed', () => { + const parent = nodeOps.createElement('Mesh', undefined, undefined, {}) + const material = nodeOps.createElement('MeshNormalMaterial') + const geometry = nodeOps.createElement('BoxGeometry') + const fog = nodeOps.createElement('Fog') + nodeOps.insert(material, parent) + nodeOps.insert(geometry, parent) + nodeOps.insert(fog, parent) + expect(material.__tres.parent).toBe(parent) + expect(geometry.__tres.parent).toBe(parent) + expect(fog.__tres.parent).toBe(parent) + + nodeOps.remove(fog) + expect(fog.__tres.parent).toBe(null) + 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(parent.__tres.objects.length).toBe(1) + expect(parent.__tres.objects.includes(material)).toBe(false) + + nodeOps.remove(geometry) + expect(geometry.__tres.parent).toBe(null) + expect(parent.__tres.objects.length).toBe(0) + expect(parent.__tres.objects.includes(geometry)).toBe(false) + }) + }) }) describe('patchProp', () => { From 9af075a907de11c5bd9b42b1c68bc025f59d226e Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 20 Jun 2024 22:37:32 +0200 Subject: [PATCH 05/20] refactor: add prepare() to add __tres field --- src/core/nodeOps.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/core/nodeOps.ts b/src/core/nodeOps.ts index 4325dffd7..3cc7c5357 100644 --- a/src/core/nodeOps.ts +++ b/src/core/nodeOps.ts @@ -88,14 +88,14 @@ export const nodeOps: (context: TresContext) => RendererOptions RendererOptions RendererOptions noop('cloneNode'), insertStaticContent: () => noop('insertStaticContent'), } + + function prepare(obj: T, state: Partial) { + const instance = obj as unknown as TresInstance + instance.__tres = { + type: 'unknown', + eventCount: 0, + root: context, + handlers: {}, + memoizedProps: {}, + objects: [], + parent: null, + ...state, + } + return instance + } } From b2eb17acf9bb912f20086d354273176b267085a6 Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 20 Jun 2024 22:38:37 +0200 Subject: [PATCH 06/20] refactor: add TODOs --- src/core/nodeOps.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/core/nodeOps.ts b/src/core/nodeOps.ts index 3cc7c5357..1f17ebd91 100644 --- a/src/core/nodeOps.ts +++ b/src/core/nodeOps.ts @@ -121,6 +121,10 @@ export const nodeOps: (context: TresContext) => RendererOptions RendererOptions Date: Thu, 20 Jun 2024 23:06:47 +0200 Subject: [PATCH 07/20] refactor: maintain parent/objects relationship in __tres --- src/core/nodeOps.ts | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/core/nodeOps.ts b/src/core/nodeOps.ts index 1f17ebd91..8f514a6a1 100644 --- a/src/core/nodeOps.ts +++ b/src/core/nodeOps.ts @@ -2,8 +2,8 @@ import type { RendererOptions } from 'vue' import { BufferAttribute, Object3D } from 'three' import type { TresContext } from '../composables' import { useLogger } from '../composables' -import { deepArrayEqual, disposeObject3D, isHTMLTag, kebabToCamel } from '../utils' -import type { InstanceProps, TresObject, TresObject3D } from '../types' +import { deepArrayEqual, disposeObject3D, filterInPlace, isHTMLTag, kebabToCamel } from '../utils' +import type { InstanceProps, LocalState, TresInstance, TresObject, TresObject3D } from '../types' import * as is from '../utils/is' import { catalogue } from './catalogue' @@ -108,7 +108,7 @@ export const nodeOps: (context: TresContext) => RendererOptions RendererOptions RendererOptions RendererOptions obj !== node) + } + if (is.object3D(node)) { node.removeFromParent?.() @@ -331,7 +348,7 @@ export const nodeOps: (context: TresContext) => RendererOptions noop('insertStaticContent'), } - function prepare(obj: T, state: Partial) { + function prepare(obj: T, state: Partial): TresInstance { const instance = obj as unknown as TresInstance instance.__tres = { type: 'unknown', From ec47e0dd0d2738a957bdcc48f8913367127d07bd Mon Sep 17 00:00:00 2001 From: Peter Date: Fri, 21 Jun 2024 03:19:58 +0200 Subject: [PATCH 08/20] test: add dispose=null test --- src/core/nodeOps.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/core/nodeOps.test.ts b/src/core/nodeOps.test.ts index 1e71920ee..13548cb63 100644 --- a/src/core/nodeOps.test.ts +++ b/src/core/nodeOps.test.ts @@ -466,7 +466,18 @@ 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 dipose 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 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')) From fac2a08062b7a5453f9efb88abad5fa9a9921785 Mon Sep 17 00:00:00 2001 From: Peter Date: Fri, 21 Jun 2024 14:17:36 +0200 Subject: [PATCH 09/20] feat: allow "dispose=null" to bail out tree disposal --- src/core/nodeOps.test.ts | 6 ++-- src/core/nodeOps.ts | 65 ++++++++++++++++++++++++++-------------- src/utils/is.ts | 6 +++- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/src/core/nodeOps.test.ts b/src/core/nodeOps.test.ts index 13548cb63..5ee25ce2a 100644 --- a/src/core/nodeOps.test.ts +++ b/src/core/nodeOps.test.ts @@ -312,7 +312,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 +330,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() diff --git a/src/core/nodeOps.ts b/src/core/nodeOps.ts index 8f514a6a1..30cd14b8a 100644 --- a/src/core/nodeOps.ts +++ b/src/core/nodeOps.ts @@ -2,7 +2,7 @@ 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 { deepArrayEqual, filterInPlace, isHTMLTag, kebabToCamel } from '../utils' import type { InstanceProps, LocalState, TresInstance, TresObject, TresObject3D } from '../types' import * as is from '../utils/is' import { catalogue } from './catalogue' @@ -145,9 +145,18 @@ export const nodeOps: (context: TresContext) => RendererOptions + // 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 @@ -161,29 +170,41 @@ export const nodeOps: (context: TresContext) => RendererOptions obj !== node) } - if (is.object3D(node)) { - 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) - }) + node.removeFromParent?.() - context.deregisterCamera(node) - /* deregisterAtPointerEventHandlerIfRequired?.(node as TresObject) */ - invalidateInstance(node as TresObject) + // 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) + }) - // 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 + 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?.() + } + + if (shouldDispose && node.dispose && !is.scene(node)) { + node.dispose() } } diff --git a/src/utils/is.ts b/src/utils/is.ts index 0f1a4ef15..e75ea3a15 100644 --- a/src/utils/is.ts +++ b/src/utils/is.ts @@ -1,5 +1,5 @@ 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 arr(u: unknown) { return Array.isArray(u) @@ -33,6 +33,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 From f57de05c8dcfd1ba66d30d42ca2577750de32b1f Mon Sep 17 00:00:00 2001 From: Peter Date: Mon, 24 Jun 2024 16:31:09 +0200 Subject: [PATCH 10/20] refactor: update comments --- src/core/nodeOps.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/core/nodeOps.ts b/src/core/nodeOps.ts index 30cd14b8a..ba97c5b70 100644 --- a/src/core/nodeOps.ts +++ b/src/core/nodeOps.ts @@ -146,6 +146,14 @@ export const nodeOps: (context: TresContext) => RendererOptions RendererOptions obj !== node) } + // NOTE: THREE.removeFromParent removes `node` from + // `parent.children`. node.removeFromParent?.() - // Remove nested child objects. Primitives should not have objects and children that are - // attached to them declaratively ... + // NOTE: Deregister `node` THREE.Object3D children node.traverse?.((child) => { context.deregisterCamera(child) // deregisterAtPointerEventHandlerIfRequired?.(child as TresObject) context.eventManager?.deregisterPointerMissedObject(child) }) + // NOTE: Deregister `node` context.deregisterCamera(node) /* deregisterAtPointerEventHandlerIfRequired?.(node as TresObject) */ invalidateInstance(node as TresObject) @@ -203,6 +213,7 @@ export const nodeOps: (context: TresContext) => RendererOptions Date: Mon, 24 Jun 2024 16:32:14 +0200 Subject: [PATCH 11/20] refactor: add todo --- src/core/nodeOps.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/nodeOps.ts b/src/core/nodeOps.ts index ba97c5b70..e157fbeb5 100644 --- a/src/core/nodeOps.ts +++ b/src/core/nodeOps.ts @@ -109,7 +109,6 @@ export const nodeOps: (context: TresContext) => RendererOptions RendererOptions RendererOptions Date: Mon, 24 Jun 2024 16:32:46 +0200 Subject: [PATCH 12/20] test: add/unskip tests --- src/core/nodeOps.test.ts | 42 ++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/core/nodeOps.test.ts b/src/core/nodeOps.test.ts index 5ee25ce2a..546a2d113 100644 --- a/src/core/nodeOps.test.ts +++ b/src/core/nodeOps.test.ts @@ -353,6 +353,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 @@ -419,7 +432,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') @@ -432,7 +445,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')) @@ -468,7 +481,7 @@ describe('nodeOps', () => { }) describe(':dispose="null"', () => { - it('does not call dipose on geometry/material in a Mesh where :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') @@ -479,6 +492,19 @@ describe('nodeOps', () => { expect(spy0).not.toBeCalled() expect(spy1).not.toBeCalled() }) + it('does not call dispose on child\'s geometry/material, for remove()', () => { + 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 @@ -577,8 +603,8 @@ describe('nodeOps', () => { 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') @@ -591,17 +617,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) }) From a585a351519581dda81909358b18d9ec0bf0694b Mon Sep 17 00:00:00 2001 From: Peter Date: Mon, 24 Jun 2024 16:47:57 +0200 Subject: [PATCH 13/20] refactor(nodeOps): move helper functions to new file --- src/core/nodeOps.ts | 39 +++++---------------------------------- src/utils/nodeOpsUtils.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 34 deletions(-) create mode 100644 src/utils/nodeOpsUtils.ts diff --git a/src/core/nodeOps.ts b/src/core/nodeOps.ts index 8f514a6a1..6623e7755 100644 --- a/src/core/nodeOps.ts +++ b/src/core/nodeOps.ts @@ -3,15 +3,11 @@ import { BufferAttribute, Object3D } from 'three' import type { TresContext } from '../composables' import { useLogger } from '../composables' import { deepArrayEqual, disposeObject3D, filterInPlace, isHTMLTag, kebabToCamel } from '../utils' -import type { InstanceProps, LocalState, TresInstance, TresObject, TresObject3D } from '../types' +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' -function noop(fn: string): any { - // eslint-disable-next-line no-unused-expressions - fn -} - const { logError } = useLogger() const supportedPointerEvents = [ @@ -31,16 +27,6 @@ const supportedPointerEvents = [ 'onWheel', ] -export function invalidateInstance(instance: TresObject) { - const ctx = instance?.__tres?.root - - if (!ctx) { return } - - if (ctx.render && ctx.render.canBeInvalidated.value) { - ctx.invalidate() - } -} - export const nodeOps: (context: TresContext) => RendererOptions = (context) => { const scene = context.scene.value @@ -88,14 +74,14 @@ export const nodeOps: (context: TresContext) => RendererOptions RendererOptions RendererOptions noop('cloneNode'), insertStaticContent: () => noop('insertStaticContent'), } - - function prepare(obj: T, state: Partial): TresInstance { - const instance = obj as unknown as TresInstance - instance.__tres = { - type: 'unknown', - eventCount: 0, - root: context, - handlers: {}, - memoizedProps: {}, - objects: [], - parent: null, - ...state, - } - return instance - } } diff --git a/src/utils/nodeOpsUtils.ts b/src/utils/nodeOpsUtils.ts new file mode 100644 index 000000000..2c3ada316 --- /dev/null +++ b/src/utils/nodeOpsUtils.ts @@ -0,0 +1,32 @@ +import type { TresContext } from '../composables/useTresContextProvider' +import type { LocalState, TresInstance, TresObject } from '../types' + +export function prepareTresInstance(obj: T, state: Partial, 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 +} From 46e62383b67e1bc29e11c90b32895158b73b1dcc Mon Sep 17 00:00:00 2001 From: Peter Date: Thu, 27 Jun 2024 16:24:34 +0200 Subject: [PATCH 14/20] test: add primitive tests --- src/core/nodeOps.test.ts | 251 +++++++++++++++++++++++++++++++++++---- 1 file changed, 225 insertions(+), 26 deletions(-) diff --git a/src/core/nodeOps.test.ts b/src/core/nodeOps.test.ts index 546a2d113..903c3242b 100644 --- a/src/core/nodeOps.test.ts +++ b/src/core/nodeOps.test.ts @@ -185,46 +185,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) }) }) }) @@ -299,10 +300,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) }) }) @@ -567,16 +644,17 @@ describe('nodeOps', () => { nodeOps.remove(child) expect(child.parent?.uuid).toBeFalsy() }) - it.skip('detaches mesh (in primitive :object) from mesh', () => { + 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(mesh.uuid) + 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', () => { + 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) @@ -585,7 +663,7 @@ describe('nodeOps', () => { expect(primitiveMesh.parent?.uuid).toBe(parent.uuid) nodeOps.remove(parent) - expect(primitiveMesh.parent?.uuid).toBeFalsy() + expect(primitiveMesh.parent?.type).toBeFalsy() }) it('does not detach primitive :object descendants', () => { const { mesh: parent } = createElementMesh(nodeOps) @@ -603,6 +681,7 @@ describe('nodeOps', () => { expect(grandChild1.parent.uuid).toBe(primitiveMesh.uuid) }) }) + }) describe('in the __tres parent-objects graph', () => { it('removes parent-objects relationship when object is removed', () => { const parent = nodeOps.createElement('Mesh', undefined, undefined, {}) @@ -738,20 +817,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) }) }) @@ -856,6 +1050,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) { const geometry = nodeOps.createElement('BoxGeometry') const material = nodeOps.createElement('MeshNormalMaterial') @@ -866,9 +1067,7 @@ function createElementMesh(nodeOps: ReturnType) { } function createElementPrimitiveMesh(nodeOps: ReturnType) { - 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 } } From 21dded6d3b00c8062196efeaf86c584c957c97df Mon Sep 17 00:00:00 2001 From: Peter Date: Fri, 28 Jun 2024 23:35:15 +0200 Subject: [PATCH 15/20] refactor: move nodeOpsUtils to utils --- src/core/nodeOps.ts | 3 +-- src/utils/index.ts | 35 ++++++++++++++++++++++++++++++++++- src/utils/nodeOpsUtils.ts | 32 -------------------------------- 3 files changed, 35 insertions(+), 35 deletions(-) delete mode 100644 src/utils/nodeOpsUtils.ts diff --git a/src/core/nodeOps.ts b/src/core/nodeOps.ts index bbaad95f0..3138411cd 100644 --- a/src/core/nodeOps.ts +++ b/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, filterInPlace, isHTMLTag, kebabToCamel } from '../utils' +import { attach, deepArrayEqual, detach, filterInPlace, isHTMLTag, kebabToCamel, prepareTresInstance, noop, invalidateInstance } 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() diff --git a/src/utils/index.ts b/src/utils/index.ts index f85c5f04b..0b2da4531 100644 --- a/src/utils/index.ts +++ b/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,34 @@ export function filterInPlace(array: T[], callbackFn: (element: T, index: num array.length = i return array } + +export function prepareTresInstance(obj: T, state: Partial, 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 +} diff --git a/src/utils/nodeOpsUtils.ts b/src/utils/nodeOpsUtils.ts deleted file mode 100644 index 2c3ada316..000000000 --- a/src/utils/nodeOpsUtils.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { TresContext } from '../composables/useTresContextProvider' -import type { LocalState, TresInstance, TresObject } from '../types' - -export function prepareTresInstance(obj: T, state: Partial, 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 -} From 81e90086e05c1f1d0c63ca38c51980bff5dcdeda Mon Sep 17 00:00:00 2001 From: Peter Date: Fri, 28 Jun 2024 23:36:06 +0200 Subject: [PATCH 16/20] feat: add pierced attach/detach --- .../advanced/multipleMaterials/index.vue | 41 +++ playground/src/router/routes/advanced.ts | 5 + src/core/nodeOps.test.ts | 291 +++++++++++++----- src/core/nodeOps.ts | 90 +++--- src/types/index.ts | 8 +- src/utils/index.test.ts | 75 +++++ src/utils/index.ts | 82 +++++ src/utils/is.test.ts | 30 ++ src/utils/is.ts | 8 + 9 files changed, 507 insertions(+), 123 deletions(-) create mode 100644 playground/src/pages/advanced/multipleMaterials/index.vue diff --git a/playground/src/pages/advanced/multipleMaterials/index.vue b/playground/src/pages/advanced/multipleMaterials/index.vue new file mode 100644 index 000000000..e45bb29b2 --- /dev/null +++ b/playground/src/pages/advanced/multipleMaterials/index.vue @@ -0,0 +1,41 @@ + + + diff --git a/playground/src/router/routes/advanced.ts b/playground/src/router/routes/advanced.ts index a2c5cdd96..867cd8cda 100644 --- a/playground/src/router/routes/advanced.ts +++ b/playground/src/router/routes/advanced.ts @@ -24,4 +24,9 @@ export const advancedRoutes = [ name: 'Suspense', component: () => import('../../pages/advanced/suspense/index.vue'), }, + { + path: '/advanced/multiple-materials', + name: 'Multiple materials', + component: () => import('../../pages/advanced/multipleMaterials/index.vue'), + }, ] diff --git a/src/core/nodeOps.test.ts b/src/core/nodeOps.test.ts index 903c3242b..62d9f3a16 100644 --- a/src/core/nodeOps.test.ts +++ b/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', () => { @@ -239,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', () => { @@ -317,15 +448,15 @@ describe('nodeOps', () => { 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 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}) + 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) @@ -645,43 +776,43 @@ describe('nodeOps', () => { expect(child.parent?.uuid).toBeFalsy() }) 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) + 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('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?.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.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-objects graph', () => { it('removes parent-objects relationship when object is removed', () => { const parent = nodeOps.createElement('Mesh', undefined, undefined, {}) diff --git a/src/core/nodeOps.ts b/src/core/nodeOps.ts index 3138411cd..d4c2e329f 100644 --- a/src/core/nodeOps.ts +++ b/src/core/nodeOps.ts @@ -38,13 +38,13 @@ export const nodeOps: (context: TresContext) => RendererOptions RendererOptions RendererOptions { @@ -212,6 +208,19 @@ export const nodeOps: (context: TresContext) => RendererOptions RendererOptions 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 diff --git a/src/types/index.ts b/src/types/index.ts index 34beb158e..afbfbd91b 100644 --- a/src/types/index.ts +++ b/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 = (parent: any, self: O) => () => void -export type AttachType = string | AttachFnType +export type AttachFnType = (parent: any, self: TresInstance) => () => void +export type AttachType = string | AttachFnType export type ConstructorRepresentation = new (...args: any[]) => any export type NonFunctionKeys

= { [K in keyof P]-?: P[K] extends Function ? never : K }[keyof P] @@ -29,12 +29,10 @@ export interface InstanceProps { object?: T visible?: boolean dispose?: null - attach?: AttachType [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 diff --git a/src/utils/index.test.ts b/src/utils/index.test.ts index e39f822a7..861e961c7 100644 --- a/src/utils/index.test.ts +++ b/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') + }) +}) diff --git a/src/utils/index.ts b/src/utils/index.ts index 0b2da4531..bde0fbd4e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -333,6 +333,88 @@ export function filterInPlace(array: T[], callbackFn: (element: T, index: num return array } +export function resolve(obj: Record, 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(obj: T, state: Partial, context: TresContext): TresInstance { const instance = obj as unknown as TresInstance instance.__tres = { diff --git a/src/utils/is.test.ts b/src/utils/is.test.ts index f51c055e2..c60a14a0d 100644 --- a/src/utils/is.test.ts +++ b/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', () => { diff --git a/src/utils/is.ts b/src/utils/is.ts index e75ea3a15..67fa0f70c 100644 --- a/src/utils/is.ts +++ b/src/utils/is.ts @@ -1,10 +1,18 @@ import type { TresObject } from 'src/types' 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' } From 5a037c42ee5ed39955e2dbe720970cfcbfdc7527 Mon Sep 17 00:00:00 2001 From: Peter Date: Sat, 29 Jun 2024 03:12:48 +0200 Subject: [PATCH 17/20] chore: clean up merge --- src/core/nodeOps.test.ts | 1 - src/core/nodeOps.ts | 11 +---------- src/types/index.ts | 16 ---------------- src/utils/nodeOpsUtils.ts | 32 -------------------------------- 4 files changed, 1 insertion(+), 59 deletions(-) delete mode 100644 src/utils/nodeOpsUtils.ts diff --git a/src/core/nodeOps.test.ts b/src/core/nodeOps.test.ts index 03382feca..62d9f3a16 100644 --- a/src/core/nodeOps.test.ts +++ b/src/core/nodeOps.test.ts @@ -529,7 +529,6 @@ describe('nodeOps', () => { } }) - it('calls dispose on a material', () => { it('calls dispose on a material', () => { const parent = mockTresObjectRootInObject(nodeOps.createElement('Mesh', undefined, undefined, {})) const material = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, {}) diff --git a/src/core/nodeOps.ts b/src/core/nodeOps.ts index 61ce8551f..d4c2e329f 100644 --- a/src/core/nodeOps.ts +++ b/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 { attach, deepArrayEqual, detach, filterInPlace, invalidateInstance, isHTMLTag, kebabToCamel, noop, prepareTresInstance } from '../utils' +import { attach, deepArrayEqual, detach, filterInPlace, isHTMLTag, kebabToCamel, prepareTresInstance, noop, invalidateInstance } 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() @@ -121,14 +120,6 @@ export const nodeOps: (context: TresContext) => RendererOptions - memoizedProps: { [key: string]: any } - // NOTE: - // LocalState holds information about the parent/child relationship - // in the Vue graph. If a child is `insert`ed into a parent using - // anything but THREE's `add`, it's put into the parent's `objects`. - // objects and parent are used when children are added with `attach` - // instead of being added to the Object3D scene graph - objects: TresObject[] - parent: TresObject | null - // NOTE: End graph info - primitive?: boolean disposable?: boolean attach?: AttachType @@ -85,8 +71,6 @@ export type TresObject = export type TresInstance = TresObject & { __tres: LocalState } -export type TresInstance = TresObject & { __tres: LocalState } - export interface TresScene extends THREE.Scene { __tres: { root: TresContext diff --git a/src/utils/nodeOpsUtils.ts b/src/utils/nodeOpsUtils.ts deleted file mode 100644 index 2c3ada316..000000000 --- a/src/utils/nodeOpsUtils.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { TresContext } from '../composables/useTresContextProvider' -import type { LocalState, TresInstance, TresObject } from '../types' - -export function prepareTresInstance(obj: T, state: Partial, 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 -} From 6c2a4367425bab79de02e3b9215facc6c38e39f3 Mon Sep 17 00:00:00 2001 From: Peter Date: Sat, 29 Jun 2024 03:53:04 +0200 Subject: [PATCH 18/20] chore: lint --- .../advanced/multipleMaterials/index.vue | 41 ------------------- src/core/nodeOps.ts | 2 +- 2 files changed, 1 insertion(+), 42 deletions(-) delete mode 100644 playground/src/pages/advanced/multipleMaterials/index.vue diff --git a/playground/src/pages/advanced/multipleMaterials/index.vue b/playground/src/pages/advanced/multipleMaterials/index.vue deleted file mode 100644 index e45bb29b2..000000000 --- a/playground/src/pages/advanced/multipleMaterials/index.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - diff --git a/src/core/nodeOps.ts b/src/core/nodeOps.ts index d4c2e329f..622473015 100644 --- a/src/core/nodeOps.ts +++ b/src/core/nodeOps.ts @@ -2,7 +2,7 @@ import type { RendererOptions } from 'vue' import { BufferAttribute, Object3D } from 'three' import type { TresContext } from '../composables' import { useLogger } from '../composables' -import { attach, deepArrayEqual, detach, filterInPlace, isHTMLTag, kebabToCamel, prepareTresInstance, noop, invalidateInstance } 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 { catalogue } from './catalogue' From 7f4e72f240f04638bda3b46c4dfc32a9c8517b98 Mon Sep 17 00:00:00 2001 From: Peter Date: Sat, 29 Jun 2024 03:53:47 +0200 Subject: [PATCH 19/20] docs: add playground demo --- .../pages/advanced/materialArray/index.vue | 41 +++++++++++++++++++ playground/src/router/routes/advanced.ts | 6 +-- 2 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 playground/src/pages/advanced/materialArray/index.vue diff --git a/playground/src/pages/advanced/materialArray/index.vue b/playground/src/pages/advanced/materialArray/index.vue new file mode 100644 index 000000000..d8fc00908 --- /dev/null +++ b/playground/src/pages/advanced/materialArray/index.vue @@ -0,0 +1,41 @@ + + + diff --git a/playground/src/router/routes/advanced.ts b/playground/src/router/routes/advanced.ts index 867cd8cda..86a96f5ae 100644 --- a/playground/src/router/routes/advanced.ts +++ b/playground/src/router/routes/advanced.ts @@ -25,8 +25,8 @@ export const advancedRoutes = [ component: () => import('../../pages/advanced/suspense/index.vue'), }, { - path: '/advanced/multiple-materials', - name: 'Multiple materials', - component: () => import('../../pages/advanced/multipleMaterials/index.vue'), + path: '/advanced/material-array', + name: 'Material array', + component: () => import('../../pages/advanced/materialArray/index.vue'), }, ] From 00e1cb4e7bdd01f3a0e50a893192209c3b9db60b Mon Sep 17 00:00:00 2001 From: Peter Date: Sat, 29 Jun 2024 13:36:27 +0200 Subject: [PATCH 20/20] chore: update demos --- .../pages/advanced/materialArray/index.vue | 24 ++++++---- playground/src/pages/models/RiggedModel.vue | 36 -------------- .../models/RiggedModel}/UgglyBunny.vue | 0 .../src/pages/models/RiggedModel/index.vue | 48 +++++++++++++++++++ playground/src/router/routes/models.ts | 2 +- 5 files changed, 64 insertions(+), 46 deletions(-) delete mode 100644 playground/src/pages/models/RiggedModel.vue rename playground/src/{components => pages/models/RiggedModel}/UgglyBunny.vue (100%) create mode 100644 playground/src/pages/models/RiggedModel/index.vue diff --git a/playground/src/pages/advanced/materialArray/index.vue b/playground/src/pages/advanced/materialArray/index.vue index d8fc00908..843276d16 100644 --- a/playground/src/pages/advanced/materialArray/index.vue +++ b/playground/src/pages/advanced/materialArray/index.vue @@ -2,7 +2,13 @@ import { TresCanvas } from '@tresjs/core' import { OrbitControls } from '@tresjs/cientos' -const previewDataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJYAAACoCAYAAAACcRc/AAAgAElEQVR4Xu1dZ3Bc53U921AWHewN7A0sIkXKKiPJorocy9I4tmxLjqXYHmfsJM5MfiQTJz/0I5PM2M7YiVM8zthxXMaVVGR19iZWsAEgQBAdIIleSJTF9pz7vX3gggII7GLf1rcaiBxi9+2+7zt7zy3n3s/y+uuvB2E+zBWI8QpYTGDFeEXNy6kVMIFlAsGQFTCBZciymhc1gWViwJAVMIFlyLKaFzWBZWLAkBUwgWXIspoXNYFlYsCQFTCBZciymhc1gWViwJAVMIFlyLKaFzWBZWLAkBUwgWXIspoXNYEVAQYs8txgEEGL+pv5uMsKmMCaITysIUAJqNTfTYDddeVMYE0DLB1QXpsV8PlhCQQQtNthJcDs8ncTYJOuoAmsKYAlgALB47bZCCgf5t0aws6GRszr60HNylW4snQJhpxOOPgcm98PUUuaFHl7MU1g3QEsHVA+qxUeWqSlg4Mob23D5spzKA62qmf7kIXrxVtRXb4RdYsX4VZ+Hhz8d1vApEh9OU1ghVZCpzwBlJ8AWUBAbW9pwdqaKszxNfBZJYDVQatkJR3SPgVvEmBjuDb3PtSsXY/apYsx7MyDXSyYSZGmgtRCyrOEKM8SorztjU1YX1+DEvdVAqoIQVsuQLqzIKBgqJoEBGBWRof+fniDAXTM3Y6qdbRgIYpUAMtgisxYi6UDSiyUlxZmCS3UphZSXvUFFAWaiZwcBB2lgNc7DqjJ3LGAhY68lVAjwHywkiI3kyI3aRSZR4ok9jKRIjMOWAIocbLlx0ukLBwYxDZS3rqaSyj1NSkLBVqoAOnOGvTNKBmhLBhpklcFAiGKnHMfatetR80SUiQBlmkUmTHAEkBJlBeghQqQoopGR7G6/TrKmxqwsveM8qGCtuwJlDcjVIU9aZwiJYEa0ClyBylyvYoihxlFahTJNIX8l8aJ1rQHVjjlWQgoJwG1sf0aVje3oKy7CtkWGykvb1rKixhkpEiLosheUqQdN0iRVeXlpMjFGUGRaQuscQtFqyCENufmTazo7sammhos6O+A09pH+ipWJRrQ+TbioSyYjRQp76FTZCkpcr1GkUMqTZGeUWTaAWsC5dEpLx4exqrrN7CxqRGLuuuQa/Fzt7NIQ3YCysdtNf4xkSIHCXQPbtAHq167DrXLlt6myDRKU6QNsCajvPK2a1hFx3w5KS9L/BkLAcXITSxUPAA1GWQF0B+hyI3luEoLdjONosiUB1Y45YktKhXK6yLl1QrlXSflDYxTnvLfQ7ko4+3U1O8wGUVeL92JmvUbximS9pS1yNTN5KcssMbTBhLlhShv5bUb2MQob0FPA5wWN1HEXBQd53hRXqRg1SiSFswiPlh6UWTKAWtyymsn5bWS8i6S8rLUZiWa8iIG2QSKtDKK3IqqCRSZWk5+ygBLpzy/5KIk60TKWxmivIUD1+mUa5QnMhbJUyYD5UUMrvEokncYuKWc/Osl96JmIymSaQqJIoUiJZOf7ILDpAdWOOX5SXmlQ0NYcb0DmxrrsaC3mZTnIoqcIQsVnygvUsBE+nxlbaUWKRFGKE0hUeRlRpE1KRJFJi2wJqW8VlJeGymv6zwpL2ec8iwG5aEiBYQRz1dqCqUJ61a1yEkpMgkz+UkHrHHKo3JAWK341i2s6uzExto6LB5oRY5liJRXmNKUFykAb9cidYr0kSK3KYqslUx+ElJk0gDrNuUxU05EzaFic8WNTmyqr8OCvhZS3jAtVEFaUV7kAPsoRV4nRdYkIUUmHFhTRXmrGeWVdVeQ8pyS0dEEdmlMeRGDTOnBSJF+oUiggxasckN4ojWxxe7EAYtWid8/TW3AvxfSKV/T0YkNdaS8/sykvIjBxRcEKdfhCobyYAElma5hsVunyERJphMCLJEBC6C8EvZQcSCAevjkKcwb6mSm/GYY5UnpxZgCcaSbmMzPnyyKTDRFxg1YivJUptkCF6Mch8eDsv4B3Ft3Basaa+lD3QrV8piJnqHALpk3O1GfTbkMiiJFrkPJNPNglRs2hNUi4yOZNhxYOqCCjPI8tFJBrw9re3qxhWqDtVfPMrHZzz2grlzSB3FSGyRq0+P1vqoVzUrJtFK0SqkISg9WTYq8EooijaZIQ4EVTnkWNnuu7O7BtuYmrK47Q8ojoCzzuAD8dgXYqGA65jHHnZamIAPIn6FEq1BkLaPIy6JoNVAyHXNgST1VtN8iux3TKa9vADuuXsHKhssEVIcGKJpsBNIjUx5zRBhwwckosoqCQ+kq0uQ6saXImAFrnPL4AT2SKfZ4saaXlNdIyqvXKa8EAXsuLD53wvRQBuxZSl0yIBQp3/5QV9GNos24LFEk9WCxbLyNCbAmRHmkvNWUAN/T0ozVV07RQg1y4eewUYGsblJeUoBwIkUO0wcbwWQUaRVFa5SfePbAIoG75VvAKG9FXx92Ksqr0SjPSsqTbJVJeVFuj/Ev0yhSa7z18b+Okp0QirxSRsl0bi5s5BZVZovwMStgiVPo9waxor8L9zXUoayhhVHedf5rbqjzxaS8CPcjYU9Xjbfy7sEB+CzZ6MxbhfNbt+HKqjL4JMCK8BE9sAhim92PruvzUPZTN56f+0Oszmpk9WUhDZT0noyaflSEm5HopwdzCSB28YoPDLjQ89LDeHvxfbjWQ+feFogoVR01sIJBzoeye3GjYxEO//Yhzshw428K38cn819HsQL4Cn4QmXcwxh/JpKjvg/lIxhWQOQB+yXkN0HUpgPvxdXB/djVuzS/B4YulaKrPQnY2qyURMOLsgGXzo7unCKf3PAHP9SXwZ41irb8afzHnJ9iZt5sSFx1gklZwmRYs2UAlgMriz4jk6LPgfaoMY8+tgKe8FLYCO3r67Di0v4B/OmhEpLFj5jcwa2B1dZfgwO92Ia9rHhwM/Eb9MleqAy/lVOCZ4jdxT+47zJGw0xil/PBjtFum9Zr59hj0TMkhZhElbulrGuC+FGH0bx+F66mlQLYkrKUFhUaj14GDR4vQyz/jDqzunmIcfGMX7NcWAA4/HMSNlR/LzVyJJ9iBP89/D58q+VcstPfwJhbzJnT/ywSYQbC5+2XFQnkEUC4FKPfL6+F+Yhl8ywsINqkxyvhLcXOC6OohsA4Xom/ADjt/FTeLZeNsgqFhO47vfwKjF1bAkk2T6hH+CyJP0hABByMMD2PEdvxV8e/xZOH3Qv7Xct6UKBdMBz9u4BLQOGilQrTn+cRKuD6zBv4yiifptEvJDQFp6tUA5CCwWtqyse9gEXPdHOgb+veZft5ZUaEAa3jEhg8JrOHzK2lGfQgqYGkPG8GTzf+7GMr6GMY+Yr+IF4r2YWf+j1GkfHkBmNCjx3TuZ7pjkT5PaI8gkTlfAEcsLVmLkW9uhW8zXZPCbE4nJKDEgKlOce3iOrCaWzVg+XzMZcUTWDJwbGTUhhMHHsdQxaowixV+90E1n9OhAEYPK9iP5xwn8OV5/4Y12ZX8DbPyjESCNM2a/2U+YrICOqAklPOPwV8wF2Nf2QD3xxbCvyRfa/P3iTd+G1D6+4YDa++BIkrm4ggs7UMIxMdw5sSTuL53E6z5XvhHppqKEOTIoCBsQQdcVDJ4LB34u6I9eKzwB/S/2CAB5r/G/S8R95npiagAJoDKDoHF5VW5p7FXt2Psj5bDP58jLx3MtHtC4skptkoByxFAbV0ePthXiJwIUw0KGa+//noEQeSdtyq9b6OoOPkk2t7fPA2w9NcGKOqzwhWwU0XqQpm1Fl8sOIwHC36AxXb2CKr8lyeUnjDBFRG4xmlP1nEEY099DO6nmTnfWIxASQ4sXpEwc7vFYbrLQwdWTW0+3t9XAKeTOawIhbwxAdbZE0+h/YPpLNZEepSOXlFrj9H/CnAR1lqr8NdzfsT81zv8VwFUWcj/Et/AfNx1BQRQkjSU9EFgCN7yNRj73Fp4t8yFfwEB5SMq7vCjZgKs2joCa28BcnMTAiwXzp3ehdZ3xWL570KFk9+KwIoDFDHExXFzjvrX8/bj6aI3sSL7DL2yefxtAa3XkJm9n2z5bJLgVC0pzB4MEjvzMfr3O+C5n25FCcdeCiV5iagwx3wmX1GxWJJuqK7Jw/6DBFZOZFn3GFChhKEeXLx0L+r37ITdaYNfrHDEj6D6wnmEHi2MLNGCr+Udx4ul38YCO2cyKHo0s/cTltUe8qO8g0ra4v70/XA/sgTerQyGssP8qGlob6qtkmDx3MUCnKlwIovJ1EhyWLED1sUdaHhjJ2xODt+PClja7XEaKAsLFAoGmfe1eLHBdgVfLNqNRwt/SL9MnrGMAPOSKDOYHtk3wMQgXSgPV8sNz1NbVBnGu5HDeYu4euKYhwb5Rvz9DnuB+FTnCazTZwgsOu/xB5bVg8rKbaj7/X2wZzvgVxHH3Z3D6W+YfYY038NSvwr24lPZR/HpkjexLuc9ynK4gDLhmH5ZRuW/dEAJtXk4kXn1Roz+5Rb41tMxLySgpIgsvlSUFip8T2T3eJYCKs4XoOJCAiyWNHTZrD7U16/EhZ8/DLs1n1QWyuBOj55pnhEkiPgOtF4ufl081m68lHUcr8z9J6zIooiQyVUyP38yoP4oZRh5eHp5x4vheXk1xh5fBu+GEk2ER01cpH7UVIsvl5PE96jLimMnClHfkMPUQ5wt1jiwGgisn8UaWNqtS2MG4xpGyVJ/9GKhtRFfL3qL9PgvKFDZiIXa1L50VE+Mqw9GlZMw9sWtcD+6GN41PNeHYFPpA7VIs2WI2zALB9bxk4W4Wp8wYHlx9epqXPwFgWXPIwBiZbHu/E5p9UcXYeZjK9ODWRX4bNE72J7381B5aJVKT/AguFkT8ayN7WwvILRHfxXDo7zSEMaelHwULdS2uQgWsIjvjo0fNdnHVMCy0dEYseLI8SI0tWSrumFcfSypgttsPrS0LMXpX34cdjfHC1GjpYqZBj1yLAEeRSL0SPmgZRAP2C7gq3P/FzucHyjrxbRzatcfRcU5xuCEtVX/0jVwP7+CtFcG/0In/aiZJThns/R6qmFg0M6sexH6+u3c48iUDcqIzibzrgGLup3uYhzd/QwsHaXUutPrY9HSyIfQo2Rp+O5MsPIHjfhW4R48UfhdzBdWZP0xANbD6P4b+0lieJeig1IVsm74cym4+7ONcO9cAP/yQnEEpqzrxfATqEvpwOqnVOaD/cXo77clDlidnaU48runYO8hsGjBjLRYExdS/C/mcCQ9YR3BGmsDXir4gPXH/8BcmygmUiD/JY65+FIjfaoY771/FVwvroT74cWqUKzV9T5aKI41oPTrhVusvfsp8qOKNEEWK4D+gTwceeM5BFroA8TBYt25qJL/kkalEVrQgHUM99qq8LXSX2Gr81esx1LAphQUmv+VNMVtKcOIHzXCgXJMnXh3bIH72eVwP7hISx9ITW8Gdb1YA0wHVh/lyO9+UIRbQ4myWPxWuekSHHzzBYxWLaKL452gyYr1jU99PY0epYlplPVHyX+9mvseXij9L6xk95CFuS9RTMomqnlSiXyobhhxGZiP2rwJ7j9eDfe2+QjMpz5KanpqKrJmqOL90ArQQXR2ZmHPH2QKNasrcphHhFKFGPhYBBYNwYE3X4Srms5zwoA1bswZmAvA7ISQBwWWdnzOeQTPl3wfSx0ij15KWEkbpqhX4wwwAZQMPxnrJW6K4H14NUa+XE71AfNRouCcQh8VT3Dpyoau7mzseZPJVwZiCQCWJNPo49BiHXrnExi5sOwjKtJ4Lsqd/lc+bdNIkETJeuYG2yV8peQ9bHP+J0pUe5qkJ0SeM8Ifg+U5UtcTjdRIj4i24Xl6IyUtLMOwroccRhu6JiWG+aho112nwus3svHWu0WJA5ag2ctv2rGDH8fAh+tgoVw0eeamSVpRfGPap5D+62H7cbzG9rTNOYeUz6w5+KJeNUAeLYsj1fVRrQHU/cw2uJ8sg2cLfb58jhcSgymlmARQ3t2AJ0ew1DfmspFC/NPoPtwsqZCusACLFuvogccweHJNkgHr9vLJISji4A9LecjSic9kV+CFkl9ga+4xPknUq5Lfl97HGMijdRXnmADqJq+9AO7PrcPoF9YjsJDTdtwSOctni1+0F4kFE2A1EFgHEg0sH4ufHx59BL1HNhBYzDLFYG8iWYiZP1fAJeO+2dyhhDjN+IeSt/F44T+H6FGrP9LERPk95UtVXx6v4R2mznw+vM8yJ/UMVZwrGDiI+D8Cwd3M7yu2zxRgNTblYv+hBFmscXeZDum5im1oe2cnz9xmb45yQpP3Id1DObo8mvXHB+xn8JniN7Aj7xeh+qPIczyR0aPwqp3m23WLry2m6mAuXF/dAM/H2G9JxYHqhgm1VyXvymifTPysy7V5+PBkXtRlyFlR4URgbSewdqQEsPTPLTM68wiwYVKkdA993FGFF4vewEP5v2aNV3JfnI1K63VX/dc47Y3wsm74cxbBRTmL+4FFCJbQw5NAIYlp706Qi0cllaPzF/Mpm3EqVyeax6yBpU3UDqCi4l5ce/felAKWvmByqqF0b7t4M1704GvOt/Dp0m+xe0iesZi/dYQAdkf3dk4ofcB8i7+MfhQFd2O7mM5YwEOjaMEsYrkTlI+KBgzyUWXan58lubPn83H+klNF/dE8Zg0sLWrwobp6E8V+D/JsbcqLRXAWvZcSzX3E4DWaesIvBW7LGMtDNfhC4SE8kP/dEMAk/yXyaC/7J0OA8rgUJD0PrMHINzbDv1ISsOzwVr18apdi8LnidwmhQOkV9bLz+TBnNlxtpGQmCmWDfOKYAMtCnXpTIwd1/ewxWG08d1nlZVJrUfXt0/RfbOxg3sSHW1hprcc3S35PevyxNtzEUkwr1CWogW/NKoy+thG+TXPgnyNdxWKhJm8CjR88on+nCcA6RmCJyC/RwLp6dQ0qf/loygNL3xbpHsolPQ5TPeGn/uu5nON4dc4PsCr7AnDPVjaAlsF9/wLVrycersqcJ0GCM3pYad8J3WKJFqtORH6JApauybp2bQlO/uhpfjB6LCL2S1GLNXFjNHl0kN3bY/P9cHU14nvf2Iv7v94Od2kR75CD5cTtUjuSmhY6/H51kZ/bbcXeA8VobcuKSpYcEyrUJ/v1D8zBvu8/z2Nl6bgaLPabzbcymtcKPZYsBwZbcvH5753En37+N2waEUqU5oVorpicr9GB5aLe/Q/vlKKn1554YPX1zcP+73+S0TUH26YZsAQGecuYeGiz48vfPonPv7KHXSwEljjqqW+oxlGuA2uMFustAqurO+HA8lGTVYK9P3kW9kFKLdIQWPkE1kibA69+5yReeWU3fN70BdbNm3a8u7cYfRT5RdOhEzsqJJAGb+Vh7+4nYG1m3ieNgfUagfVymgOrsysL+w8U4uawLeJJfuPR9Ww073IR5WMJsG4W4P3fPKlGRqYzsDLBYnV1Z2GfAOsWVW2JjQoDGBrKwYG3d8FfS02WNDgmbSE6Osc5k6iwm8Dam3hgaWI/l8uGIwcfxvAZ0WSZwIoOvol9le68t7bmcFpyIVxj0ukeuSw5Rj6WNDhSaMKRkccOPIKhs2tNYCUWH1G/u9QMpL2+jhn3Y8cLVWkn2qh31iUdHeWjo3Yc2f8IRipMYEW9s0nwQsm8N7BGeITA8nBQsZqWHMXnmjWwlNnjm/PwL5w9dT86D2yFVWZWitI3jR6Z4GPJdgmwKqudOHEqX3Y26h2MCbDkA/h5dFzl+Z1ofptiPxNYUW9Iol8ospmKC/k4fTY/6ogwJj6WZrEoJmER9sK5+9AumiwTWInGR9TvL8A6fykfp87kRdUBHbM8lnYhcfI8qKu9B9W/foiT/ShHcqWQwm0G25ApVCh695OnC3GOA9eyOSIykhO/wpcxJlQYDFqZ+nehuWkTzv7kMc4ipfTPBNYM4Jo8T9GnSwqwDh0tRmVVLsdw82CaKHt6YwYsO2e0NzVuxrmfcpyRCazkQcwMP0k4sI4cK8bFypyo5rvHlAqVxSKwGpsIrP8xgTXDvUyqp4UD6zCBdSk5gCXHkHnQ1rYCJ/+bVJjt1HTvBg5gi/eupLuPpatHXWNWHKYsubEhO+JTVQ3wsbRjfNvbl+LDHz1Gf6uA3GwCK97gn8376cAaGeGpqizntLZmqfnuCXbeNWC1tS+jPJkWy8HpySawZrPPcX+tDiwpzR1ih05zC8+BTgpgUTrT1V2Kg7/dBUf3PPbVyVie6DO3cV/Zad4wE6hQhtr2cuDaoSOFPOs78uN6jaFCmUXaW4hDb+6CrZVivwRM9jMSjJkALDuB1dEtx/UWYXAwukl+MY4KNbFfd28x9u95DFnXOHvdtFhG4jzm11ZzsQis2ZwDbZjF6ukrxMH/E4vFkZEEVtCkwpgDwKgL6gPXWnkO9IFDhZCGimgm+cXcYo2fD31wF4YrVk9xjK9Ry2L8dTOBCqU5tbEpB/sILBkRGek50AZYLG0Am0QUJwisW2dNYBkP9di+g5o9SmA1NedwLlYhfFGcA20YsKTRUYB18wwn+zk52sxlRoWx3X7jrqYDSwau7ePAtaSwWNrtSkuwnLb6ONre2zLD86GNW6hYXzkjqJAHjF+uyWcjRXSnqsbcYunAslpHcf7M4xT7UUV61xPtY73txl8vU4BVfVkDVjQHjBsGLEsIWC1vi8XyRXw+tPHwiP4dMgVYVdX52MdzoJMIWDJo3o3KSztQt3s250NHv/lGvjLdgSVrJ2NSK+Qc6LNO5IgKOJouitAmxESPpV1LgMVjfC9tJ7Dug6OQcz2H0kdFms7A0o8zkeE5clzv2XNUjyYLsLQ5WTwUs34tLv6cIyNRCKvTC7+MOk8D+Uy6AkvXYUkr/TAPvzzBU1WvXI3uVFWDfCztsn6fFS2ty3HpwA5YbpSC5yWpWQ5B1Q6WuumHdASWrmjwM2c1xFO+rtQ5UVWTo1INs33EkApDhCiHLXB2el//HFSe24aeY0uYiMiHjabVL1OEY/ChZ3vT0bw+nYClU5/UBv10pOqu5uHU2TyV4BaFQyweMQfWbX/LzybWIG50LMXFE9vgq10ivTw8uYEnz6eg9UoXYOmd6zKl8EZnNkHlVNqrUc5psEdxRO9UIDQIWNp4IynzWK1eDmUjb18uR9vBNbB7i+h7+el7pZb1SnVg6VZKOp1lxmgHzyM8TSslf+bk8JztKId/xB1Y8obazcjEkgBNrl9p4s8f34Zg/XzeCI9NEgUE5wOkwiOVgaUcdAJKqE8ODz91ppCn02epSTLyMz5FPIYbYZjFCv+MuvWSqfo3b+Xi6pVytLxTzsiRZ7WQGtXZO0nue6UisO6M+BobnRz4kY2OLp5DS0DNRr0wHQbjAiz9Q2gTlv0cjxOkWH8lqivK4b28OMx6JW/kmErA0mlPHHGJ+ORc55paJ8/HcSo/15FFPzc2PvqU+IorsDR61HQ+VquPIW4uqi5tw7W9a5T1sjq5EEnqe6UKsML7A0dHrWhoyMVFTo8ZYY5KnHOJn6Ltbp7OSoX/Pu7ACrdeNhsPb1OR4xJcOrWN1ksiR9roJFSfJjuwwq2U/F2O3r1QmYf2a5JIlOR1dJP5IgFTUgBLd+6tnOylR461jBzbVeQoWXs6/ElkvZIZWHqiU/ymWxxIW8/BaZeZ6JSx2g62cN0OpKKFSeSvS5jFGrdc6q5vR47t7ctx/th2BCRy5KGaQUaUyRA5JjOwJIUwxg5mad26VCV5qWyV6BQrFQ/amwx2CQdWODVqhy4ycryZh6t1G0KRYz4jR3/CI8dkA5buSwmoxH+6wJlWUuOT8Y4CqERYqaShwsmQrs2ND3CQW0BFjlVnypm1T3zkmCzA0n0pOTFCQHS1Phe1dbnq3BuNEuPrS01Fkkljse7Me6nIkUdr3Rqmv8DIsX3vOrr1PPFTIkemK+LdZZ0MwNJ9KSkSi//U1JqNizwFVaI/kbkk2koltcWa4HuFsvYSOV6/sRRVp7fCc3mpFjmSHuNZc0wksMLLMWKW2tpzcfxkvsqiC6DkS5goXyqlLNZE6yXmXRMRDrDmWHN5E9o/XAX7cLGadeqXFY1DY2yigKVbKXHGu3uyUMWcVFt7NkbYARXtcP/IY7zIX5GUVDiV7yU1Rx8PNu+nJKfq3L0YOLmC1svOVjM69y55lXF1x3gDSxEb/yeAkplV3ZypUFmVh8ZmbbxQvPNSkUIrZYCl+Q9a1t5m8zG8tqOONcfaQ+Ww95fSekmh2zjrFU9g6RGfgGp42KrkwlWXc9UMdrvkO/WjpyPd7Tg+P6WA9dHUhBu9vfNx/tROdl8vo+eVZVhqIh7ACi8ae9yM+FiOkXOZu2itlD2WUyIMrvHFCnspCSw9+rFYrEpnL8etNDduQPXxTbDeoO/lsJJFYjuUxGhghUd8UjSWGQpSNB4bs9FBN75oHCtA6ddJWWBNtF4SGYkcuhhV57ej++gKFrVZyRdJDiPKWPheRgFrYo0viBaeO/0hjxsZHNTKMckY8c0EhCkPrJCPq+QgVjr3blLItWtlOHdoO6xtUhYifcSgLGQEsMIjvi6ealpzxalOjpdumWR3zqcDV1oA607fS6xXb28pUxPlPDRqNa1X/qzl0LEEVnheSuZQ9XAs44WLeWiWgbJMoSRzGmE6QKUNFd55o2rTyB+SmvB6A/RV1uLSsS2wtc1VvhfJMaq8V6yANW6lRIkwZGVzaAFq6rJZZbgtbUkVB/1uIEsrizUxsao1c8gZwgM8r7qmags69q9n5JgTlRx6tsAKLxqLEkHmUIm8paMz9SK+mVittAXW7ZsXS+CHm9FVU8tK1JzZSEnOItjEellnXhaKFljhiU5pWuhky1XNlVzUN2TB52WChNSXDhbqTrBlALBCrWjSSBtqRauUyPHISvpeubRecmjn9JFjNMAKt1KjHErX0qKlEAZFgEd1QqpGfKbFClsBDTq3I8d2Ro4Xjm6DpXkBrCLblcjxLjXHSIA1oWjM95VyzIXKfI4ecHdap3YAAAKISURBVKj29VSP+ExgTbICWiuaWAsfFZeMHCsZOR6aPnKcKbDCUwjDwzY0NDlRTZlwP5UIMngjlbLnMwHQVM/JCCqcNHIUSY4IChk5NjWvwcXDW2G7pkWOJMePWK+ZAkumR49RgNfJDuM6ivCkj08e0iEzm3lTs9nkRLw2I4F1Z95LIsf+QRkDsAk39m2YNHKcFFiWgNJThLdcSft6dY0TpyucCAbkgNDkEuDFC2QZDaw7I0dJVjY3rwpFjgsZOUozByU5HGKSvwwYaXPg1e+cxCuv7GZEV8yXa8ePCsUFaI6E9kQv1dtnU74UZWQZZaXCQWsCK7QayvcKjWAauFmIS+f0yFGrOToXBTDCeQevEVgvh4BlEYvFo26lrtfWloNL1bmUDNuUXkoeKSJEMMSImcCaKnJkErODjbR1VRsxVLEcBWUWDLdZabFO4ZWXd7N1XbNYImn58GQBpdMOJRPOZCtlWqxpvqNaZKfJoUdHs1F7eSvaLmxAoL0Ur333BL70J79lV8w89vDlMIVAmTCbGfSpLYZ8/VPwoqbFmmLTtFyUqAw4y8vvp9+0AAf//SF86R9b8eyz7xFUC5g9dzAHJuOBUkeAFy+MmsCa1nppcmg583pgIJ9RXg7y8/sx5rIrX0rwl44lmdkC0ATWDFdQmwotSk6p7VkzJtE5w+X5yNNMYEWwclq+SmWuInhVZj7VBFZm7rvhd20Cy/Alzsw3MIGVmftu+F2bwDJ8iTPzDUxgZea+G37XJrAMX+LMfAMTWJm574bftQksw5c4M9/ABFZm7rvhd20Cy/Alzsw3MIGVmftu+F2bwDJ8iTPzDUxgZea+G37XJrAMX+LMfAMTWJm574bf9f8DD51eHr26dzsAAAAASUVORK5CYII=' +const previewDataUri = 'data:image/png;base64,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' + +const i = shallowRef(0) +setInterval(() => { + i.value += 1 + i.value %= 6 +}, 500)